@mrbuilder/tool
Version:
Tool for changing package files
455 lines (416 loc) • 13.6 kB
text/typescript
import set from 'lodash/set';
import unset from 'lodash/unset';
import has from 'lodash/has';
import get from 'lodash/get';
import fs from 'fs';
import path from 'path';
import inquirer from 'inquirer';
import {lernaFilteredPackages, splitRest} from '@mrbuilder/utils';
export const settings = {
exit: process.exit,
error: console.error,
warn: console.warn,
log: console.log,
trace: console.trace,
};
type CommandFn = (json: any, args: KeyValue, filename: string, options: Option) => Promise<boolean>;
const write = (filename: string, json: any) => new Promise(
(resolve, reject) => fs.writeFile(filename, JSON.stringify(json, null, 2),
'utf8', (e: any, o?: any) => e ? reject(e) : resolve(o)));
const read = (filename: string): {} => new Promise(
(resolve, reject) => fs.readFile(filename, 'utf8',
(e, o) => e ? reject(e) : resolve(JSON.parse(o))));
function parse(value: string): any {
value = value.trim();
if (value === 'true' || value === 'false') {
return JSON.parse(value);
}
if (value === 'null') {
return null;
}
if (value === '') {
return value;
}
if ((value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']')) ||
(value.startsWith('"') && value.endsWith('"')) ||
/^((?:NaN|-?(?:(?:\d+|\d*\.\d+)(?:[E|e][+|-]?\d+)?|Infinity)))$/.test(
value)) {
return JSON.parse(value);
}
return JSON.parse(`"${value}"`)
}
type ConfirmOpts = {
confirm?: boolean
}
async function confirm(message: string, {confirm = false}: ConfirmOpts): Promise<boolean> {
if (confirm) {
const answer = await inquirer.prompt([{message, type: 'confirm', name: 'confirm'}]);
return answer.confirm;
}
return true;
}
type Option = ConfirmOpts & {
ignore?: boolean,
noLerna?: boolean,
preview?: boolean,
skipIfExists?: boolean,
onlyIfExists?: boolean,
createIfNotExists?: boolean,
}
type KeyValue = [string, string];
const _set: CommandFn = async function (json: {}, [key, value]: KeyValue, filename: string, options?: Option) {
const current = get(json, key);
if (current === value) {
return false;
}
const hasKey = has(json, key);
if (hasKey) {
if (options.skipIfExists) {
return false;
}
} else {
if (options.onlyIfExists) {
return false
}
}
if (await confirm(
`Are you sure you want to change ${key} in '${this.name}/${filename}?' ${hasKey
? `[${JSON.stringify(current, null, 2)}]` : ''}`, options)) {
set(json, key, value);
return true;
}
return false;
}
function __get(key: string) {
return JSON.stringify(get(this, key));
}
async function _move(json: {}, keys: KeyValue, filename: string, opts: ConfirmOpts) {
const [from, to] = keys;
if (!from || !to) {
settings.warn(`move requires an argument`, from, to);
}
if (has(json, from)) {
if (!await confirm(
`Are you sure you want to move '${from}' to '${to}' in '${filename}?'`,
opts)) {
return false;
}
const f = get(json, from);
unset(json, from);
//Merge objects if there is a destination
if (typeof f === 'object' && get(json, to)) {
for (const key of Object.keys(f)) {
set(json, `${to}.${key}`, f[key]);
}
} else {
set(json, to, f);
}
return true;
}
return false;
}
const _get: CommandFn = async function (json: any, keys: KeyValue, filename: string, opts: Option = {}) {
const str = keys.map(__get, json).join(',');
if (str) {
if (opts.skipIfExists) {
return false;
}
} else {
if (opts.onlyIfExists) {
return false;
}
}
settings.log(this.name, '=', str);
return false;
}
// noinspection JSUnusedLocalSymbols
const _delete: CommandFn = async function (json: any, keys: KeyValue, filename: string, opts?: ConfirmOpts): Promise<boolean> {
let ret = false;
for (const key of keys) {
if (has(json, key)) {
if (!(await confirm(`Are you sure you want to delete '${key}'`,
opts))) {
continue;
}
}
unset(json, key);
//@ts-ignore
ret |= true;
}
return ret;
}
const _prompt: CommandFn = async function (json: any, args: KeyValue, filename: string, options: Option): Promise<boolean> {
const [key, vmessage = 'Do you want to change the property'] = args,
self = this,
_default = get(json, key),
message = `${vmessage} '${key}' in '${this.name}/${filename}'?`;
if (options.skipIfExists && _default != null) {
return false;
}
const hasKey = has(json, key);
if (hasKey) {
if (options.skipIfExists) {
return false;
}
} else {
if (options.onlyIfExists) {
return false
}
}
if (await confirm(message, {confirm: true})) {
const answer = await inquirer.prompt([{
type: 'input',
name: 'value',
message: has(json, key) ? `OK what would like to change it to?`
: vmessage
}]);
if (answer.value === _default) {
return false;
}
try {
set(json, key, parse(answer.value));
return true;
} catch (e) {
settings.warn(`Could not parse the value try again`, e);
}
return _prompt.call(self, json, args, filename, options);
}
}
type Package = {
name: string,
location: string,
}
type InternalOption = Option & {
options?: Option,
cwd?: string,
ignore?: boolean,
scope?: string[],
filteredPackages?: Package[],
files?: string[], extension?: string, preview?: boolean, noExtension?: boolean, commands: [CommandFn, any][]
};
export async function muckFile(pkg: Package, file: string, opts: InternalOption) {
let saveMuck = false;
const fullname = path.resolve(pkg.location, file);
let json: any;
try {
json = await read(fullname);
} catch (e) {
if (opts.createIfNotExists && !fs.existsSync(fullname)) {
json = {};
} else {
settings.warn(`Error reading ${fullname}`, e);
return false
}
}
for (const cmd of opts.commands) {
if (await cmd[0].call(pkg, json, cmd[1], file, opts)) {
saveMuck = true;
}
}
if (saveMuck && json) {
const backup = fullname + opts.extension;
let newfile = fullname;
if (opts.preview) {
settings.log(JSON.stringify(json, null, 2));
if (!await confirm(`Does above look correct for ${fullname}`,
{confirm: true})) {
return false;
}
}
if (!opts.noExtension) {
if (fs.existsSync(backup) && !await confirm(
`a file named ${newfile} already, do want to overwrite?`,
opts)) {
return false;
}
//rename the current file.
fs.renameSync(newfile, backup);
}
try {
await write(newfile, json);
} catch (e) {
if (backup != newfile && fs.existsSync(backup)) {
try {
fs.renameSync(backup, newfile);
} catch (ee) {
settings.warn(`Error renaming ${backup} back to ${newfile}`,
e)
}
}
settings.warn(`Error writing ${newfile}`, e)
}
}
return true;
}
export function makeOptions(name: string, args: string[],): InternalOption | void {
function help(msg?: string): void {
if (msg) {
settings.error(msg);
}
settings.warn(`${name} [-sdgihfe] <files>
-b\t--backup\t<extension>\tuse a different extension
-p\t--prompt\tkey=question\tprompt for value before changing
-c\t--confirm\t\tconfirm before dangerous operations
-m\t--move\t\tfrom=to\tMove property from=to
-s\t--set\t\tkey=value sets key to value
-d\t--delete\tkey\tdeletes values (comma)
-g\t--get\t\tvalue\tgets the value
-i\t--ignore\tpackages to ignore
-h\t--help\t\tthis helpful message
-f\t--file\t\tpackage.json default
-k\t--skip\t\tSkip the question if it has value
-n\t--no-lerna\tJust use the file don't iterate over lerna projects
-C\t--create-file\tCreate the file if it does not exist
-P\t--preview\tPreview files if there are changes, before writing.
-u\t--unless\tDo the action only if it has a value
-S\t--scope packages,\t Only apply to these packages (glob).
--no-extension\tuse in place
`);
settings.exit(1);
}
const opts: InternalOption = {
extension: '.bck',
files: [],
commands: [],
options: {},
};
const commands = opts.commands;
const options = opts.options;
//need this to suck up files at the end.
let i = 0;
ARGS: for (let l = args.length; i < l; i++) {
let [arg, val] = splitRest(args[i], '=');
switch (arg) {
//actions
case '--prompt':
case '-p':
const message = (val || args[++i]);
if (!message) {
throw new Error(`message must be defined for "${arg}"`);
}
commands.push([_prompt, splitRest(message,'=')]);
break;
case '-s':
case '--set':
const [key, value] = splitRest(val || args[++i],'=');
commands.push([_set, [key, parse(value)]]);
break;
case '-d':
case '--delete':
const keys = (val || args[++i]).split(/,\s*/);
commands.push([_delete, keys]);
break;
case '-g':
case '--get':
commands.push([_get, (val || args[++i]).split(/,\s*/)]);
break;
case '-m':
case '--move':
commands.push([_move, splitRest(val || args[++i], '=')]);
break;
//options
case '-k':
case '--skip':
case '--skip-if-exists':
opts.skipIfExists = true;
break;
case '-u':
case '--unless':
case '--only-if-exists':
opts.onlyIfExists = true;
break;
case '-c':
case '--confirm':
opts.confirm = true;
break;
case '-i':
case '--ignore':
options.ignore = args[++i] ? true : false;
break;
case '-f':
case '--file':
opts.files = opts.files.concat((val || args[++i]).split(/,\s*/));
break;
case '-C':
case '--create-file':
opts.createIfNotExists = true;
break;
case '-e':
case '--extension':
case '-b':
case '--backup':
opts['extension'] = (val || args[++i]).trim();
if (!opts['extension']) {
return help(
`--backup requires an extension use --no-backup to rename in place`)
}
break;
case '-X':
case '--no-extension':
case '--no-backup':
opts.noExtension = true;
break;
case '-n':
case '--no-lerna':
opts.noLerna = true;
break;
case '--preview':
case '-P':
opts.preview = true;
break;
case '--scope':
case '-S':
opts.scope = opts.files.concat((val || args[++i]).split(/,\s*/));
break;
case '-h':
case '--help':
return help();
default: {
break ARGS;
}
}
}
if (commands.length === 0) {
help(`need a command ${args}`);
}
opts.files = opts.files.concat(args.slice(i));
if (opts.files.length === 0) {
opts.files.push('package.json');
}
return opts;
}
export async function muck(opts: InternalOption | void) {
if (!opts) {
return;
}
if (!opts.noLerna) {
const options: InternalOption = {commands: []};
if (opts.cwd) {
options.cwd = opts.cwd;
}
if (opts.scope) {
options.scope = opts.scope;
}
const filteredPackages = await lernaFilteredPackages(opts);
opts.filteredPackages = filteredPackages;
for (const pkg of filteredPackages) {
for (const file of opts.files) {
await muckFile(pkg, file, opts);
}
}
} else {
for (const file of opts.files) {
await muckFile({name: '.', location: process.cwd()}, file, opts);
}
}
}
if (require.main === module) {
muck(makeOptions(process.argv[1], process.argv.slice(2))).then(() => {
settings.exit(0);
}, (e) => {
settings.trace(e);
process.exit(1);
});
}