Browse Source

allow for partial manager

v4
AJ ONeal 5 years ago
parent
commit
7313167ca0
  1. 1
      .gitignore
  2. 75
      bin/init.js
  3. 9
      bin/lib/greenlockrc.js
  4. 29
      bin/tmpl/greenlock.tmpl.js
  5. 3
      bin/tmpl/server.tmpl.js
  6. 139
      greenlock.js
  7. 381
      manager-underlay.js
  8. 25
      package-lock.json
  9. 3
      package.json

1
.gitignore

@ -1,3 +1,4 @@
greenlock.json*
TODO.txt
link.sh
.env

75
bin/init.js

@ -21,20 +21,15 @@ cli.main(async function(argList, flags) {
var pkgpath = path.join(process.cwd(), 'package.json');
var pkgdir = path.dirname(pkgpath);
//var rcpath = path.join(pkgpath, '.greenlockrc');
var configFile = path.join(pkgdir, 'greenlock.d/manager.json');
var manager = flags.manager;
// TODO move to bin/lib/greenlockrc.js
if (!manager) {
manager = 'greenlock-cloud-fs';
if (!flags.managerOpts.configFile) {
flags.managerOpts.configFile = configFile;
}
}
if (['fs', 'cloud'].includes(manager)) {
// TODO publish the 1st party modules under a secure namespace
flags.manager = '@greenlock/manager-' + flags.manager;
manager = '@greenlock/manager';
}
if (['cloud'].includes(manager)) {
flags.managerOpts.cloud = true;
}
flags.manager = flags.managerOpts;
delete flags.managerOpts;
flags.manager.manager = manager;
@ -57,6 +52,7 @@ cli.main(async function(argList, flags) {
var GreenlockRc = require('./lib/greenlockrc.js');
//var rc = await GreenlockRc(pkgpath, manager, flags.manager);
await GreenlockRc(pkgpath, manager, flags.manager);
writeGreenlockJs(pkgdir, flags);
writeServerJs(pkgdir, flags);
writeAppJs(pkgdir);
@ -70,9 +66,41 @@ cli.main(async function(argList, flags) {
*/
}, args);
function writeGreenlockJs(pkgdir, flags) {
var greenlockJs = 'greenlock.js';
var fs = require('fs');
var path = require('path');
var tmpl = fs.readFileSync(
path.join(__dirname, 'tmpl/greenlock.tmpl.js'),
'utf8'
);
try {
fs.accessSync(path.join(pkgdir, greenlockJs));
console.warn("[skip] '%s' exists", greenlockJs);
return;
} catch (e) {
// continue
}
if (flags.cluster) {
tmpl = tmpl.replace(
/options.cluster = false/g,
'options.cluster = true'
);
}
if (flags.maintainerEmail) {
tmpl = tmpl.replace(
/pkg.author/g,
JSON.stringify(flags.maintainerEmail)
);
}
fs.writeFileSync(path.join(pkgdir, greenlockJs), tmpl);
console.info("created '%s'", greenlockJs);
}
function writeServerJs(pkgdir, flags) {
var serverJs = 'server.js';
var bakTmpl = 'server-greenlock-tmpl.js';
var fs = require('fs');
var path = require('path');
var tmpl = fs.readFileSync(
@ -82,13 +110,8 @@ function writeServerJs(pkgdir, flags) {
try {
fs.accessSync(path.join(pkgdir, serverJs));
console.warn(
JSON.stringify(serverJs),
' exists, writing to ',
JSON.stringify(bakTmpl),
'instead'
);
serverJs = bakTmpl;
console.warn("[skip] '%s' exists", serverJs);
return;
} catch (e) {
// continue
}
@ -106,10 +129,10 @@ function writeServerJs(pkgdir, flags) {
);
}
fs.writeFileSync(path.join(pkgdir, serverJs), tmpl);
console.info("created '%s'", serverJs);
}
function writeAppJs(pkgdir) {
var bakTmpl = 'app-greenlock-tmpl.js';
var appJs = 'app.js';
var fs = require('fs');
var path = require('path');
@ -120,16 +143,10 @@ function writeAppJs(pkgdir) {
try {
fs.accessSync(path.join(pkgdir, appJs));
console.warn(
JSON.stringify(appJs),
' exists, writing to ',
JSON.stringify(bakTmpl),
'instead'
);
appJs = bakTmpl;
console.warn("[skip] '%s' exists", appJs);
return;
} catch (e) {
// continue
fs.writeFileSync(path.join(pkgdir, appJs), tmpl);
console.info("created '%s'", appJs);
}
fs.writeFileSync(path.join(pkgdir, appJs), tmpl);
}

9
bin/lib/greenlockrc.js

@ -93,10 +93,11 @@ module.exports = async function(pkgpath, manager, rc) {
});
}
if (!_rc.manager) {
changed = true;
_rc.manager = 'greenlock-manager-fs';
console.info('Using default manager ' + _rc.manager);
if (['@greenlock/manager', 'greenlock-manager-fs'].includes(_rc.manager)) {
if (!_rc.configFile) {
changed = true;
_rc.configFile = path.join(pkgdir, 'greenlock.json');
}
}
if (!changed) {

29
bin/tmpl/greenlock.tmpl.js

@ -0,0 +1,29 @@
'use strict';
module.exports = require('greenlock').create(init());
function init() {
// .greenlockrc defines which manager to use
// (i.e. greenlock-manager-fs or greenlock-manager-cloud)
var options = getGreenlockRc() || {};
// name & version for ACME client user agent
var pkg = require('./package.json');
options.packageAgent = pkg.name + '/' + pkg.version;
// contact for security and critical bug notices
options.maintainerEmail = pkg.author;
return options;
}
function getGreenlockRc() {
// The RC file is also used by the (optional) CLI and (optional) Web GUI.
// You are free to forego CLI and GUI support.
var fs = require('fs');
var path = require('path');
var rcPath = path.join(__dirname, '.greenlockrc');
var rc = fs.readFileSync(rcPath, 'utf8');
rc = JSON.parse(rc);
rc.packageRoot = __dirname;
}

3
bin/tmpl/server.tmpl.js

@ -33,5 +33,6 @@ function getGreenlockRc() {
var path = require('path');
var rcPath = path.join(__dirname, '.greenlockrc');
var rc = fs.readFileSync(rcPath, 'utf8');
return JSON.parse(rc);
rc = JSON.parse(rc);
rc.packageRoot = __dirname;
}

139
greenlock.js

@ -23,7 +23,6 @@ G.create = function(gconf) {
if (!gconf) {
gconf = {};
}
var manager;
greenlock._create = function() {
if (!gconf._bin_mode) {
@ -65,15 +64,14 @@ G.create = function(gconf) {
}
console.info('ACME Directory URL:', gdefaults.directoryUrl);
manager = normalizeManager(gconf);
// Wraps each of the following with appropriate error checking
// greenlock.manager.defaults
// greenlock.manager.add
// greenlock.manager.update
// greenlock.manager.remove
// greenlock.manager.find
require('./manager-underlay.js').wrap(greenlock, manager, gconf);
// greenlock.sites.add
// greenlock.sites.update
// greenlock.sites.remove
// greenlock.sites.find
// greenlock.sites.get
require('./manager-underlay.js').wrap(greenlock, gconf);
// The goal here is to reduce boilerplate, such as error checking
// and duration parsing, that a manager must implement
greenlock.sites.add = greenlock.add = greenlock.manager.add;
@ -83,7 +81,7 @@ G.create = function(gconf) {
// Exports challenges.get for Greenlock Express HTTP-01,
// and whatever odd use case pops up, I suppose
// greenlock.challenges.get
require('./challenges-underlay.js').wrap(greenlock, manager, gconf);
require('./challenges-underlay.js').wrap(greenlock);
greenlock._defaults = gdefaults;
greenlock._defaults.debug = gconf.debug;
@ -108,25 +106,20 @@ G.create = function(gconf) {
return p;
};
if (manager.init) {
// TODO punycode?
p = manager.init({
p = greenlock.manager
.init({
request: request
//punycode: require('punycode')
});
} else {
p = Promise.resolve();
}
p = p
})
.then(function() {
return manager.defaults().then(function(MCONF) {
return greenlock.manager._defaults().then(function(MCONF) {
mergeDefaults(MCONF, gconf);
if (true === MCONF.agreeToTerms) {
gdefaults.agreeToTerms = function(tos) {
return Promise.resolve(tos);
};
}
return manager.defaults(MCONF);
return greenlock.manager._defaults(MCONF);
});
})
.catch(function(err) {
@ -278,7 +271,7 @@ G.create = function(gconf) {
greenlock._config = function(args) {
return greenlock._single(args).then(function() {
return greenlock._configAll(args).then(function (sites) {
return greenlock._configAll(args).then(function(sites) {
return sites[0];
});
});
@ -289,7 +282,7 @@ G.create = function(gconf) {
return null;
}
sites = JSON.parse(JSON.stringify(sites));
return manager.defaults().then(function(mconf) {
return greenlock.manager._defaults().then(function(mconf) {
return sites.map(function(site) {
if (site.store && site.challenges) {
return site;
@ -314,7 +307,7 @@ G.create = function(gconf) {
// needs to get info about the renewal, such as which store and challenge(s) to use
greenlock.renew = function(args) {
return greenlock._init().then(function() {
return manager.defaults().then(function(mconf) {
return greenlock.manager._defaults().then(function(mconf) {
return greenlock._renew(mconf, args);
});
});
@ -418,7 +411,7 @@ G.create = function(gconf) {
greenlock.order = function(args) {
return greenlock._init().then(function() {
return manager.defaults().then(function(mconf) {
return greenlock.manager._defaults().then(function(mconf) {
return greenlock._order(mconf, args);
});
});
@ -485,106 +478,6 @@ function errorToJSON(e) {
return error;
}
function normalizeManager(gconf) {
var m;
// 1. Get the manager
// 2. Figure out if we need to wrap it
if (!gconf.manager) {
gconf.manager = 'greenlock-manager-fs';
if (gconf.find) {
// { manager: 'greenlock-manager-fs', find: function () { } }
warpFind(gconf);
}
}
if ('string' === typeof gconf.manager) {
try {
// wrap this to be safe for greenlock-manager-fs
m = require(gconf.manager).create(gconf);
} catch (e) {
console.error('Error loading manager:');
console.error(e.code);
console.error(e.message);
}
} else {
m = gconf.manager;
}
if (!m) {
console.error();
console.error(
'Failed to load manager plugin ',
JSON.stringify(gconf.manager)
);
console.error();
process.exit(1);
}
if (
['set', 'remove', 'find', 'defaults'].every(function(k) {
return 'function' === typeof m[k];
})
) {
return m;
}
// { manager: { find: function () { } } }
if (m.find) {
warpFind(m);
}
// m.configFile could also be set
m = require('greenlock-manager-fs').create(m);
if ('function' !== typeof m.find) {
console.error();
console.error(
JSON.stringify(gconf.manager),
'must implement `find()` and should implement `set()`, `remove()`, `defaults()`, and `init()`'
);
console.error();
process.exit(1);
}
return m;
}
function warpFind(gconf) {
gconf.__gl_find = gconf.find;
gconf.find = function(args) {
// the incoming args will be normalized by greenlock
return gconf.__gl_find(args).then(function(sites) {
// we also need to error check the incoming sites,
// as if they were being passed through `add()` or `set()`
// (effectively they are) because the manager assumes that
// they're not bad
sites.forEach(function(s) {
if (!s || 'string' !== typeof s.subject) {
throw new Error('missing subject');
}
if (
!Array.isArray(s.altnames) ||
!s.altnames.length ||
!s.altnames[0] ||
s.altnames[0] !== s.subject
) {
throw new Error('missing or malformed altnames');
}
['renewAt', 'issuedAt', 'expiresAt'].forEach(function(k) {
if (s[k]) {
throw new Error(
'`' +
k +
'` should be updated by `set()`, not by `find()`'
);
}
});
});
return sites;
});
};
}
function mergeDefaults(MCONF, gconf) {
if (
gconf.agreeToTerms === true ||

381
manager-underlay.js

@ -5,7 +5,15 @@ var E = require('./errors.js');
var warned = {};
module.exports.wrap = function(greenlock, manager, gconf) {
// The purpose of this file is to try to auto-build
// partial managers so that the external API can be smaller.
module.exports.wrap = function(greenlock, gconf) {
var myFind = gconf.find;
delete gconf.find;
var mega = mergeManager(gconf);
greenlock.manager = {};
greenlock.sites = {};
//greenlock.accounts = {};
@ -31,7 +39,7 @@ module.exports.wrap = function(greenlock, manager, gconf) {
greenlock.manager.defaults = function(conf) {
return greenlock._init().then(function() {
if (!conf) {
return manager.defaults();
return mega.defaults();
}
if (conf.sites) {
@ -83,9 +91,10 @@ module.exports.wrap = function(greenlock, manager, gconf) {
}
});
return manager.defaults(conf);
return mega.defaults(conf);
});
};
greenlock.manager._defaults = mega.defaults;
greenlock.manager.add = function(args) {
if (!args || !Array.isArray(args.altnames) || !args.altnames.length) {
@ -142,7 +151,7 @@ module.exports.wrap = function(greenlock, manager, gconf) {
args.renewStagger = U._parseDuration(args.renewStagger);
}
return manager.set(args).then(function(result) {
return mega.set(args).then(function(result) {
if (!gconf._bin_mode) {
greenlock.renew({}).catch(function(err) {
if (!err.context) {
@ -157,6 +166,22 @@ module.exports.wrap = function(greenlock, manager, gconf) {
});
};
greenlock.manager.get = greenlock.sites.get = function(args) {
return Promise.resolve().then(function() {
if (args.subject) {
throw new Error(
'get({ servername }) searches certificates by altnames, not by subject specifically'
);
}
if (args.servernames || args.altnames || args.renewBefore) {
throw new Error(
'get({ servername }) does not take arguments that could lead to multiple results'
);
}
return mega.get(args);
});
};
greenlock.manager.remove = function(args) {
return Promise.resolve().then(function() {
args.subject = checkSubject(args);
@ -171,57 +196,137 @@ module.exports.wrap = function(greenlock, manager, gconf) {
);
}
// TODO check no altnames
return manager.remove(args);
return mega.remove(args);
});
};
/*
{
subject: site.subject,
altnames: site.altnames,
//issuedAt: site.issuedAt,
//expiresAt: site.expiresAt,
renewOffset: site.renewOffset,
renewStagger: site.renewStagger,
renewAt: site.renewAt,
subscriberEmail: site.subscriberEmail,
customerEmail: site.customerEmail,
challenges: site.challenges,
store: site.store
};
*/
greenlock._find = function(args) {
var servernames = (args.servernames || [])
.concat(args.altnames || [])
.filter(Boolean)
.slice(0);
var modified = servernames.slice(0);
// servername, wildname, and altnames are all the same
['wildname', 'servername'].forEach(function(k) {
var altname = args[k] || '';
if (altname && !modified.includes(altname)) {
modified.push(altname);
}
});
{
subject: site.subject,
altnames: site.altnames,
//issuedAt: site.issuedAt,
//expiresAt: site.expiresAt,
renewOffset: site.renewOffset,
renewStagger: site.renewStagger,
renewAt: site.renewAt,
subscriberEmail: site.subscriberEmail,
customerEmail: site.customerEmail,
challenges: site.challenges,
store: site.store
};
*/
if (modified.length) {
servernames = modified;
servernames = servernames.altnames.map(U._encodeName);
args.altnames = servernames;
args.servernames = args.altnames = checkAltnames(false, args);
// no transaction promise here because it calls set
greenlock._find = async function(args) {
args = _mangleFindArgs(args);
var ours = await mega.find(args);
if (!myFind) {
return ours;
}
// documented as args.servernames
// preserved as args.altnames for v3 beta backwards compat
// my only hesitancy in this choice is that a "servername"
// may NOT contain '*.', in which case `altnames` is a better choice.
// However, `altnames` is ambiguous - as if it means to find a
// certificate by that specific collection of altnames.
// ... perhaps `domains` could work?
return manager.find(args);
// if the user has an overlay find function we'll do a diff
// between the managed state and the overlay, and choose
// what was found.
var theirs = await myFind(args);
theirs = theirs.filter(function(site) {
if (!site || 'string' !== typeof site.subject) {
throw new Error('found site is missing subject');
}
if (
!Array.isArray(site.altnames) ||
!site.altnames.length ||
!site.altnames[0] ||
site.altnames[0] !== site.subject
) {
throw new Error('missing or malformed altnames');
}
['renewAt', 'issuedAt', 'expiresAt'].forEach(function(k) {
if (site[k]) {
throw new Error(
'`' +
k +
'` should be updated by `set()`, not by `find()`'
);
}
});
if (!site) {
return;
}
if (args.subject && site.subject !== args.subject) {
return false;
}
var servernames = args.servernames || args.altnames;
if (
servernames &&
!site.altnames.some(function(altname) {
return servernames.includes(altname);
})
) {
return false;
}
return site.renewAt < (args.renewBefore || Infinity);
});
return _mergeFind(ours, theirs);
};
function _mergeFind(ours, theirs) {
var toUpdate = [];
theirs.forEach(function(_newer) {
var hasCurrent = ours.some(function(_older) {
var changed = false;
if (_newer.subject !== _older.subject) {
return false;
}
// BE SURE TO SET THIS UNDEFINED AFTERWARDS
_older._exists = true;
_newer.deletedAt = _newer.deletedAt || 0;
Object.keys(_newer).forEach(function(k) {
if (_older[k] !== _newer[k]) {
changed = true;
_older[k] = _newer[k];
}
});
if (changed) {
toUpdate.push(_older);
}
// handled the (only) match
return true;
});
if (!hasCurrent) {
toUpdate.push(_newer);
}
});
// delete the things that are gone
ours.forEach(function(_older) {
if (!_older._exists) {
_older.deletedAt = Date.now();
toUpdate.push(_older);
}
_older._exists = undefined;
});
Promise.all(
toUpdate.map(function(site) {
return greenlock.sites.update(site).catch(function(err) {
console.error(
'Developer Error: cannot update sites from user-supplied `find()`:'
);
console.error(err);
});
})
);
// ours is updated from theirs
return ours;
}
greenlock.manager.init = mega.init;
};
function checkSubject(args) {
@ -284,3 +389,185 @@ function checkAltnames(subject, args) {
return altnames;
}
function loadManager(gconf) {
var m;
// 1. Get the manager
// 2. Figure out if we need to wrap it
if (!gconf.manager) {
gconf.manager = '@greenlock/manager';
}
if ('string' !== typeof gconf.manager) {
throw new Error(
'`manager` should be a string representing the npm name or file path of the module'
);
}
try {
// wrap this to be safe for @greenlock/manager
m = require(gconf.manager).create(gconf);
} catch (e) {
console.error('Error loading manager:');
console.error(e.code);
console.error(e.message);
}
if (!m) {
console.error();
console.error(
'Failed to load manager plugin ',
JSON.stringify(gconf.manager)
);
console.error();
process.exit(1);
}
return m;
}
function mergeManager(gconf) {
var mng;
function m() {
if (mng) {
return mng;
}
mng = require('@greenlock/manager').create(gconf);
return mng;
}
var mini = loadManager(gconf);
var mega = {};
// optional
if (mini.defaults) {
mega.defaults = function(opts) {
return mini.defaults(opts);
};
} else {
mega.defaults = m().defaults;
}
// optional
if (mini.remove) {
mega.remove = function(opts) {
return mini.remove(opts);
};
} else {
mega.remove = function(opts) {
mega.get(opts).then(function(site) {
if (!site) {
return null;
}
site.deletedAt = Date.now();
return mega.set(site).then(function() {
return site;
});
});
};
}
if (mini.find) {
// without this there cannot be fully automatic renewal
mega.find = function(opts) {
return mini.find(opts);
};
}
// set and (find and/or get) should be from the same set
if (mini.set) {
mega.set = function(opts) {
if (!mini.find) {
// TODO create the list so that find can be implemented
}
return mini.set(opts);
};
} else {
mega.set = m().set;
mega.get = m().get;
}
if (mini.get) {
mega.get = function(opts) {
return mini.get(opts);
};
} else if (mini.find) {
mega.get = function(opts) {
var servername = opts.servername;
delete opts.servername;
opts.servernames = (servername && [servername]) || undefined;
return mini.find(opts).then(function(sites) {
return sites.filter(function(site) {
return site.altnames.include(servername);
})[0];
});
};
} else if (mini.set) {
throw new Error(
gconf.manager + ' implements `set()`, but not `get()` or `find()`'
);
} else {
mega.find = m().find;
mega.get = m().get;
}
if (!mega.get) {
mega.get = function(opts) {
var servername = opts.servername;
delete opts.servername;
opts.servernames = (servername && [servername]) || undefined;
return mega.find(opts).then(function(sites) {
return sites.filter(function(site) {
return site.altnames.include(servername);
})[0];
});
};
}
mega.init = function(deps) {
if (mini.init) {
return mini.init(deps).then(function() {
if (mng) {
return mng.init(deps);
}
});
} else if (mng) {
return mng.init(deps);
} else {
return Promise.resolve(null);
}
};
return mega;
}
function _mangleFindArgs(args) {
var servernames = (args.servernames || [])
.concat(args.altnames || [])
.filter(Boolean)
.slice(0);
var modified = servernames.slice(0);
// servername, wildname, and altnames are all the same
['wildname', 'servername'].forEach(function(k) {
var altname = args[k] || '';
if (altname && !modified.includes(altname)) {
modified.push(altname);
}
});
if (modified.length) {
servernames = modified;
servernames = servernames.map(U._encodeName);
args.altnames = servernames;
args.servernames = args.altnames = checkAltnames(false, args);
}
// documented as args.servernames
// preserved as args.altnames for v3 beta backwards compat
// my only hesitancy in this choice is that a "servername"
// may NOT contain '*.', in which case `altnames` is a better choice.
// However, `altnames` is ambiguous - as if it means to find a
// certificate by that specific collection of altnames.
// ... perhaps `domains` could work?
return args;
}

25
package-lock.json

@ -4,6 +4,25 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@greenlock/manager": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@greenlock/manager/-/manager-3.0.0.tgz",
"integrity": "sha512-ijgJrFdzJPmzrDk8aKXYoYR8LNfG3hXd9/s54ZY7IgxTulyPQ/qOPgl7sWgCxxLhZBzSY1xI6eC/6Y5TQ01agg==",
"requires": {
"greenlock-manager-fs": "^3.0.5"
},
"dependencies": {
"greenlock-manager-fs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/greenlock-manager-fs/-/greenlock-manager-fs-3.0.5.tgz",
"integrity": "sha512-r/q+tEFuDwklfzPfiGhcIrHuJxMrppC+EseESpu5f0DMokh+1iZVm9nGC/VE7/7GETdOYfEYhhQkmspsi8Gr/A==",
"requires": {
"@root/mkdirp": "^1.0.0",
"safe-replace": "^1.1.0"
}
}
}
},
"@root/acme": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.0.8.tgz",
@ -90,9 +109,9 @@
"dev": true
},
"greenlock-manager-fs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/greenlock-manager-fs/-/greenlock-manager-fs-3.0.3.tgz",
"integrity": "sha512-Jwo60nHd10PNUA9M6cylD9YB4x4hzlfO2LRIGI0X+V+zA0x3KVbNW14yj8frdfHrtsWC1JQe7oFnHVdoRbAU2A==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/greenlock-manager-fs/-/greenlock-manager-fs-3.0.5.tgz",
"integrity": "sha512-r/q+tEFuDwklfzPfiGhcIrHuJxMrppC+EseESpu5f0DMokh+1iZVm9nGC/VE7/7GETdOYfEYhhQkmspsi8Gr/A==",
"requires": {
"@root/mkdirp": "^1.0.0",
"safe-replace": "^1.1.0"

3
package.json

@ -38,6 +38,7 @@
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"dependencies": {
"@greenlock/manager": "^3.0.0",
"@root/acme": "^3.0.8",
"@root/csr": "^0.8.1",
"@root/keypairs": "^0.9.0",
@ -45,7 +46,7 @@
"@root/request": "^1.3.10",
"acme-http-01-standalone": "^3.0.5",
"cert-info": "^1.5.1",
"greenlock-manager-fs": "^3.0.3",
"greenlock-manager-fs": "^3.0.5",
"greenlock-store-fs": "^3.2.0",
"safe-replace": "^1.1.0"
},

Loading…
Cancel
Save