@percy/cli-command 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import logger from '@percy/logger';
2
+ import PercyConfig from '@percy/config';
3
+ import { set, del } from '@percy/config/utils';
4
+ import * as CoreConfig from '@percy/core/config';
5
+ import * as builtInFlags from './flags.js';
6
+ import formatHelp from './help.js';
7
+ import parse from './parse.js'; // Copies a command definition and adds built-in flags and config options.
8
+
9
+ function withBuiltIns(definition) {
10
+ let def = { ...definition
11
+ };
12
+ def.flags = [...(def.flags ?? [])]; // ensure built-ins aren't already overridden
13
+
14
+ let addDedupedFlag = flag => {
15
+ if (!def.flags.find(f => f.name === flag.name)) {
16
+ let short = def.flags.find(f => f.short === flag.short);
17
+ def.flags.push(short ? del({ ...flag
18
+ }, 'short') : flag);
19
+ }
20
+ }; // add percy specific built-in flags
21
+
22
+
23
+ if (def.percy && def.percy !== true) {
24
+ builtInFlags.PERCY.forEach(addDedupedFlag); // maybe include percy discovery flags
25
+
26
+ if (def.percy.discoveryFlags !== false) {
27
+ builtInFlags.DISCOVERY.forEach(addDedupedFlag);
28
+ }
29
+ } // always add global built-in flags
30
+
31
+
32
+ builtInFlags.GLOBAL.forEach(addDedupedFlag); // copy any existing config before adding to it
33
+
34
+ def.config = { ...def.config
35
+ };
36
+ def.config.schemas = [...(def.config.schemas ?? [])];
37
+ def.config.migrations = [...(def.config.migrations ?? [])]; // add percy specific built-in config options
38
+
39
+ if (def.percy) {
40
+ def.config.schemas.unshift(CoreConfig.schemas);
41
+ def.config.migrations.unshift(CoreConfig.migrations);
42
+ }
43
+
44
+ return def;
45
+ } // Calls the provided callback and handles callback cancelation when process signals are
46
+ // triggered. If the provided callback returns a generator, it will automatically run and throw
47
+ // an error when canceled to be able to gracefully handle it's own cancelation.
48
+
49
+
50
+ async function handleProcessSignals(callback) {
51
+ let signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP'];
52
+ let signalError; // keep track of signal handlers for cleanup
53
+
54
+ let signalHandlers = signals.map(signal => {
55
+ let handler = () => {
56
+ signalError = Object.assign(new Error(signal), {
57
+ canceled: true,
58
+ exitCode: 0,
59
+ signal
60
+ });
61
+ };
62
+
63
+ process.on(signal, handler);
64
+ return [signal, handler];
65
+ });
66
+
67
+ try {
68
+ // maybe async function
69
+ let gen = await callback(); // run any returned generator
70
+
71
+ if (typeof (gen === null || gen === void 0 ? void 0 : gen.next) === 'function' && (typeof gen[Symbol.iterator] === 'function' || typeof gen[Symbol.asyncIterator] === 'function')) {
72
+ let result = await gen.next();
73
+
74
+ while (!result.done) {
75
+ result = signalError ? await gen.throw(signalError) : await gen.next(result.value);
76
+ }
77
+ }
78
+ } finally {
79
+ // always cleanup
80
+ for (let [signal, handler] of signalHandlers) {
81
+ process.off(signal, handler);
82
+ }
83
+ }
84
+ } // Helper to throw an error with an exit code and optional reason message
85
+
86
+
87
+ function exit(exitCode, reason = '') {
88
+ let err = reason instanceof Error ? reason : new Error(reason);
89
+ throw Object.assign(err, {
90
+ exitCode
91
+ });
92
+ } // Runs the parsed command callback with a contextual argument consisting of specific parsed input
93
+ // and other common command helpers and properties.
94
+
95
+
96
+ async function runCommandWithContext(parsed) {
97
+ let {
98
+ command,
99
+ flags,
100
+ args,
101
+ argv,
102
+ log
103
+ } = parsed; // include flags, args, argv, logger, exit helper, and env info
104
+
105
+ let context = {
106
+ flags,
107
+ args,
108
+ argv,
109
+ log,
110
+ exit
111
+ };
112
+ let env = context.env = process.env;
113
+ let pkg = command.packageInformation;
114
+ let def = command.definition; // automatically include a preconfigured percy instance
115
+
116
+ if (def.percy) {
117
+ let {
118
+ Percy
119
+ } = await import('@percy/core'); // set defaults and prune preconfiguraton options
120
+
121
+ let conf = del({
122
+ server: false,
123
+ ...def.percy
124
+ }, 'discoveryFlags');
125
+ if (pkg) conf.clientInfo || (conf.clientInfo = `${pkg.name}/${pkg.version}`);
126
+ conf.environmentInfo || (conf.environmentInfo = `node/${process.version}`);
127
+ Object.defineProperty(context, 'percy', {
128
+ configurable: true,
129
+
130
+ get() {
131
+ // percy is disabled, do not return an instance
132
+ if (env.PERCY_ENABLE === '0') return; // redefine the context property once configured
133
+
134
+ Object.defineProperty(context, 'percy', {
135
+ // map and merge percy arguments with config options
136
+ value: new Percy([...parsed.operators.entries()].reduce((conf, [opt, value]) => opt.percyrc ? set(conf, opt.percyrc, value) : conf, conf))
137
+ });
138
+ return context.percy;
139
+ }
140
+
141
+ });
142
+ } // wrap and bind the parsed command callback
143
+
144
+
145
+ await handleProcessSignals(command.callback.bind(null, context));
146
+ } // Returns a command runner function that when run will parse provided command-line options and run
147
+ // the parsed command callback. The returned runner will automatically output version and help
148
+ // information when requested, and handle any thrown errors exiting when appropriate.
149
+
150
+
151
+ export function command(name, definition, callback) {
152
+ definition = withBuiltIns(definition); // auto register config schemas and migrations
153
+
154
+ PercyConfig.addSchema(definition.config.schemas);
155
+ PercyConfig.addMigration(definition.config.migrations);
156
+
157
+ async function runner(argv = []) {
158
+ // reset loglevel for testing
159
+ logger.loglevel('info');
160
+ let log = logger('cli');
161
+
162
+ try {
163
+ // parse input
164
+ let parsed = await parse(runner, argv);
165
+
166
+ if (parsed.version) {
167
+ // version requested
168
+ log.stdout.write(parsed.command.definition.version + '\n');
169
+ } else if (parsed.help || !parsed.command.callback) {
170
+ // command help requested
171
+ log.stdout.write((await formatHelp(parsed.command)) + '\n');
172
+ } else {
173
+ // run command callback
174
+ await runCommandWithContext(parsed);
175
+ }
176
+ } catch (err) {
177
+ // auto log unhandled error messages
178
+ if (err.message && !err.signal) {
179
+ if (err.exitCode === 0) log.warn(err.message);else log.error(err);
180
+ } // exit when appropriate
181
+
182
+
183
+ if (err.exitCode !== 0) {
184
+ err.exitCode ?? (err.exitCode = 1);
185
+ err.message || (err.message = `EEXIT: ${err.exitCode}`);
186
+
187
+ if (definition.exitOnError) {
188
+ process.exit(err.exitCode);
189
+ } // re-throw when not exiting
190
+
191
+
192
+ throw err;
193
+ }
194
+ }
195
+ } // define command meta information
196
+
197
+
198
+ Object.defineProperties(runner, {
199
+ name: {
200
+ enumerable: true,
201
+ value: name
202
+ },
203
+ definition: {
204
+ enumerable: true,
205
+ value: definition
206
+ },
207
+ callback: {
208
+ enumerable: true,
209
+ value: callback
210
+ }
211
+ });
212
+ return runner;
213
+ }
214
+ export default command;
package/dist/flags.js ADDED
@@ -0,0 +1,85 @@
1
+ import logger from '@percy/logger'; // Global CLI flags
2
+
3
+ export const verbose = {
4
+ name: 'verbose',
5
+ description: 'Log everything',
6
+ exclusive: ['quiet', 'silent'],
7
+ parse: () => !!logger.loglevel('debug'),
8
+ group: 'Global',
9
+ short: 'v'
10
+ };
11
+ export const quiet = {
12
+ name: 'quiet',
13
+ description: 'Log errors only',
14
+ exclusive: ['verbose', 'silent'],
15
+ parse: () => !!logger.loglevel('warn'),
16
+ group: 'Global',
17
+ short: 'q'
18
+ };
19
+ export const silent = {
20
+ name: 'silent',
21
+ description: 'Log nothing',
22
+ exclusive: ['verbose', 'quiet'],
23
+ parse: () => !!logger.loglevel('silent'),
24
+ group: 'Global',
25
+ short: 's'
26
+ }; // Common percy and asset discovery flags
27
+
28
+ export const config = {
29
+ name: 'config',
30
+ description: 'Config file path',
31
+ percyrc: 'config',
32
+ type: 'file',
33
+ group: 'Percy',
34
+ short: 'c'
35
+ };
36
+ export const dryRun = {
37
+ name: 'dry-run',
38
+ description: 'Print snapshot names only',
39
+ percyrc: 'dryRun',
40
+ group: 'Percy',
41
+ short: 'd'
42
+ };
43
+ export const allowedHostnames = {
44
+ name: 'allowed-hostname',
45
+ description: 'Allowed hostnames to capture in asset discovery',
46
+ percyrc: 'discovery.allowedHostnames',
47
+ type: 'hostname',
48
+ multiple: true,
49
+ group: 'Percy',
50
+ short: 'h'
51
+ };
52
+ export const disallowedHostnames = {
53
+ name: 'disallowed-hostname',
54
+ description: 'Disallowed hostnames to abort in asset discovery',
55
+ percyrc: 'discovery.disallowedHostnames',
56
+ type: 'hostname',
57
+ multiple: true,
58
+ group: 'Percy'
59
+ };
60
+ export const networkIdleTimeout = {
61
+ name: 'network-idle-timeout',
62
+ description: 'Asset discovery network idle timeout',
63
+ percyrc: 'discovery.networkIdleTimeout',
64
+ type: 'ms',
65
+ parse: Number,
66
+ group: 'Percy',
67
+ short: 't'
68
+ };
69
+ export const disableCache = {
70
+ name: 'disable-cache',
71
+ description: 'Disable asset discovery caches',
72
+ percyrc: 'discovery.disableCache',
73
+ group: 'Percy'
74
+ };
75
+ export const debug = {
76
+ name: 'debug',
77
+ description: 'Debug asset discovery and do not upload snapshots',
78
+ parse: () => !!logger.loglevel('debug'),
79
+ percyrc: 'skipUploads',
80
+ group: 'Percy'
81
+ }; // Group constants
82
+
83
+ export const GLOBAL = [verbose, quiet, silent];
84
+ export const PERCY = [config, dryRun];
85
+ export const DISCOVERY = [allowedHostnames, disallowedHostnames, networkIdleTimeout, disableCache, debug];
package/dist/help.js ADDED
@@ -0,0 +1,243 @@
1
+ import logger from '@percy/logger'; // Helper to return an argument's description including it's default value.
2
+
3
+ function argumentDescription(arg) {
4
+ let desc = arg.description || '';
5
+
6
+ if (arg.default != null) {
7
+ desc += ` (default: ${JSON.stringify(arg.default)})`;
8
+ }
9
+
10
+ return desc.trim();
11
+ } // Helper to return an array of visible argument usage-description pairs.
12
+
13
+
14
+ async function visibleArguments(command) {
15
+ let args = command.definition.args ?? []; // not hidden and not deprecated
16
+
17
+ let visible = args.reduce((visible, arg) => {
18
+ if (arg.hidden || arg.deprecated) return visible;
19
+ visible.push([arg.name, argumentDescription(arg)]);
20
+ return visible;
21
+ }, []); // do not return any arguments if none have descriptions
22
+
23
+ if (!visible.some(([, desc]) => !!desc)) return [];
24
+ return visible;
25
+ } // Helper to return a command's usage string.
26
+
27
+
28
+ function commandUsage(command) {
29
+ let {
30
+ name,
31
+ definition
32
+ } = command;
33
+ let {
34
+ usage,
35
+ args,
36
+ commands
37
+ } = definition;
38
+
39
+ if (!command.callback && commands !== null && commands !== void 0 && commands.length) {
40
+ // if there are subcommands but no callback show subcommand usage
41
+ usage || (usage = '<command>');
42
+ } else {
43
+ var _args, _args2;
44
+
45
+ // show options followed by arguments
46
+ args = (_args = args) === null || _args === void 0 ? void 0 : _args.filter(a => !a.hidden && !a.deprecated).map(a => a.required ? `<${a.name}>` : `[${a.name}]`);
47
+ usage || (usage = `[options] ${((_args2 = args) === null || _args2 === void 0 ? void 0 : _args2.join(' ')) || ''}`);
48
+ }
49
+
50
+ return `${name} ${usage}`.trim();
51
+ } // Helper to return an array of visible command usage-description pairs.
52
+
53
+
54
+ async function visibleCommands(command, help) {
55
+ let commands = command.definition.commands ?? [];
56
+ if (typeof commands === 'function') commands = await commands();
57
+ let visible = [];
58
+
59
+ for (let cmd of commands) {
60
+ // create parent references and format subcommand names
61
+ let name = command.parent ? `${command.name}:${cmd.name}` : cmd.name;
62
+ cmd = { ...cmd,
63
+ name,
64
+ parent: command
65
+ }; // commands without callbacks are not visible themselves
66
+
67
+ if (cmd.callback) {
68
+ let {
69
+ description
70
+ } = cmd.definition;
71
+ visible.push([commandUsage(cmd), description]);
72
+ } // include visible subcommands without their implicit help
73
+
74
+
75
+ if (cmd.definition.commands) {
76
+ visible.push(...(await visibleCommands(cmd, false)));
77
+ }
78
+ } // include implicit help if there are visible commands
79
+
80
+
81
+ if (help !== false && visible.length) {
82
+ visible.push(['help [command]', 'Display command help']);
83
+ }
84
+
85
+ return visible;
86
+ } // Helper to return an flags's description including it's default value.
87
+
88
+
89
+ function flagDescription(flag) {
90
+ let desc = flag.description || ''; // a boolean that defaults to false will be displayed with a --no prefix
91
+
92
+ if (flag.type && flag.type !== 'boolean' && flag.default != null) {
93
+ desc += ` (default: ${JSON.stringify(flag.default)})`;
94
+ }
95
+
96
+ return desc.trim();
97
+ } // Helper to return a flag's usage string.
98
+
99
+
100
+ export function flagUsage(flag) {
101
+ let {
102
+ short,
103
+ usage,
104
+ type
105
+ } = flag;
106
+ let term = flag.name ? `--${flag.name}` : '';
107
+
108
+ if (type && type !== 'boolean') {
109
+ // if there is a default it is not required
110
+ usage || (usage = flag.default != null ? `[${type}]` : `<${type}>`);
111
+ } else if (flag.default === true) {
112
+ // negated booleans have a prefix
113
+ term && (term = `--no-${flag.name}`);
114
+ } // add short flag usage
115
+
116
+
117
+ if (short) term = `-${short}${term ? `, ${term}` : ''}`;
118
+ return `${term} ${usage || ''}`.trim();
119
+ } // Helper to return an array of visible flag usage-description pairs. A third entry is also included
120
+ // with the returned pair representing any group the flag should belong to.
121
+
122
+ async function visibleFlags(command) {
123
+ let flags = command.definition.flags; // not hidden and not deprecated
124
+
125
+ let visible = flags.reduce((visible, flag) => {
126
+ if (flag.hidden || flag.deprecated) return visible;
127
+ visible.push([flagUsage(flag), flagDescription(flag), flag.group]);
128
+ return visible;
129
+ }, []); // maybe hide the implicit help long or short flag (or both)
130
+
131
+ let showLongHelpFlag = !flags.some(f => f.name === 'help');
132
+ let showShortHelpFlag = !flags.some(f => f.short === 'h');
133
+
134
+ if (showLongHelpFlag || showShortHelpFlag) {
135
+ let help = {
136
+ name: 'help',
137
+ short: 'h'
138
+ };
139
+ if (!showLongHelpFlag) delete help.name;
140
+ if (!showShortHelpFlag) delete help.short;
141
+ visible.push([flagUsage(help), 'Display command help', 'Global']);
142
+ }
143
+
144
+ if (command.definition.version) {
145
+ // maybe hide the implicit version long or short flag (or both)
146
+ let showLongVerFlag = !flags.some(f => f.name === 'version');
147
+ let showShortVerFlag = !flags.some(f => f.short === 'V');
148
+
149
+ if (showLongVerFlag || showShortVerFlag) {
150
+ let ver = {
151
+ name: 'version',
152
+ short: 'V'
153
+ };
154
+ if (!showLongVerFlag) delete ver.name;
155
+ if (!showShortVerFlag) delete ver.short;
156
+ visible.push([flagUsage(ver), 'Display version', 'Global']);
157
+ }
158
+ }
159
+
160
+ return visible;
161
+ } // Helper to wrap a string at a length by inserting a newline between every segment.
162
+
163
+
164
+ function wrapString(str, len) {
165
+ let regex = new RegExp(`.{1,${len - 1}}(\\s|$)|\\S+?(\\s|$)`, 'g');
166
+ return str.replace(regex, s => s.replace(/\s*\n?$/, str.endsWith(s) ? '' : '\n'));
167
+ } // Helper to format an option usage-description pair into a single string where the description
168
+ // wraps within the available space beside the usage string.
169
+
170
+
171
+ function formatOptionHelp(option, usageWidth, maxWidth) {
172
+ if (!option[1]) return option[0]; // calculate the available description width, at minimum 40
173
+
174
+ let descWidth = Math.max(maxWidth - usageWidth, 40); // pad usage up to the description and indent the description
175
+
176
+ return option[0].padEnd(usageWidth) + wrapString(option[1], descWidth).replace(/\n+/gm, `$&${' '.repeat(usageWidth)}`);
177
+ } // Returns a formatted string displaying help output for the specified command. The help output
178
+ // total width is determined by the logger's stdout TTY columns. Help output includes the command
179
+ // description, usage, arguments, subcommand usage and descriptions, flag and flag group usage and
180
+ // descriptions, and any command examples.
181
+
182
+
183
+ export async function formatHelp(command) {
184
+ var _examples, _examples2;
185
+
186
+ let {
187
+ name,
188
+ parent: top,
189
+ definition
190
+ } = command;
191
+ let {
192
+ description,
193
+ examples
194
+ } = definition;
195
+ let output = []; // collect visible items first to determine max usage width
196
+
197
+ let args = await visibleArguments(command);
198
+ let commands = await visibleCommands(command);
199
+ let flags = await visibleFlags(command); // maximum help output total width
200
+
201
+ let maxWidth = logger.stdout.columns || 100; // calculate the longest usage width
202
+
203
+ let usageWidth = Math.max(args.reduce((max, [usage]) => Math.max(max, usage.length), 0), commands.reduce((max, [usage]) => Math.max(max, usage.length), 0), flags.reduce((max, [usage]) => Math.max(max, usage.length), 0)); // format lists of options and add appropriate indentation
204
+
205
+ let formatList = items => items.map(item => Array.isArray(item) ? // indent items and separate columns by 2 spaces each
206
+ formatOptionHelp(item, usageWidth + 2, maxWidth - 2) : item).join('\n').replace(/^(?!$)/gm, ' '.repeat(2)); // format the command name to include the topmost command name
207
+
208
+
209
+ while ((_top = top) !== null && _top !== void 0 && _top.parent) {
210
+ var _top;
211
+
212
+ top = top.parent;
213
+ }
214
+
215
+ if (top) name = `${top.name} ${name}`;
216
+ command = { ...command,
217
+ name
218
+ }; // include command description and own usage
219
+
220
+ if (description) output.push(wrapString(description, maxWidth), '');
221
+ output.push('Usage:', formatList([`$ ${commandUsage(command)}`]), ''); // include command arguments
222
+
223
+ if (args.length) output.push('Arguments:', formatList(args), ''); // include command subcommands
224
+
225
+ let commandGroupName = command.parent ? 'Subcommands:' : 'Commands:';
226
+ if (commands.length) output.push(commandGroupName, formatList(commands), ''); // separate flags into groups
227
+
228
+ let groups = flags.reduce((all, option) => {
229
+ let group = option[2] ? `${option[2]} options` : 'Options';
230
+ return all.set(group, [...(all.get(group) || []), option]);
231
+ }, new Map([['Options', []]])); // include each group of command flags
232
+
233
+ for (let [group, flags] of groups.entries()) {
234
+ if (flags.length) output.push(`${group}:`, formatList(flags), '');
235
+ } // include command examples and replace '$0' with the command name
236
+
237
+
238
+ examples = (_examples = examples) === null || _examples === void 0 ? void 0 : _examples.map(ex => ex.replace('$0', `$ ${name}`));
239
+ if ((_examples2 = examples) !== null && _examples2 !== void 0 && _examples2.length) output.push('Examples:', formatList(examples), ''); // join each line of output
240
+
241
+ return output.join('\n');
242
+ }
243
+ export default formatHelp;
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { default, command } from './command.js';
2
+ export { legacyCommand, legacyFlags as flags } from './legacy.js'; // export common packages to avoid dependency resolution issues
3
+
4
+ export { default as PercyConfig } from '@percy/config';
5
+ export { default as logger } from '@percy/logger';
package/dist/legacy.js ADDED
@@ -0,0 +1,161 @@
1
+ import { merge } from '@percy/config/utils';
2
+ import { command } from './command.js'; // Legacy flags for older commands that inadvertently import a newer @percy/cli-command
3
+
4
+ export const legacyFlags = {
5
+ config: {},
6
+ logging: {},
7
+ discovery: {
8
+ __percy__: {}
9
+ },
10
+ string: def => ({
11
+ type: 'string',
12
+ ...def
13
+ }),
14
+ boolean: def => ({
15
+ type: 'boolean',
16
+ ...def
17
+ }),
18
+ integer: def => ({
19
+ type: 'integer',
20
+ ...def
21
+ })
22
+ }; // Maps a legacy arg that might have alternate properties
23
+
24
+ function legacyArg(legacy) {
25
+ let {
26
+ name,
27
+ ...def
28
+ } = legacy; // do not camelcase legacy attribute names
29
+
30
+ def.attribute = name; // add name back to mapped options
31
+
32
+ return {
33
+ name,
34
+ ...def
35
+ };
36
+ } // Maps a legacy flag that might have alternate properties
37
+
38
+
39
+ function legacyFlag(name, legacy) {
40
+ let {
41
+ char: short,
42
+ deprecated,
43
+ ...def
44
+ } = legacy; // generic legacy type looks odd in help output
45
+
46
+ if (def.type === 'option') def.type = 'value'; // do not camelcase legacy attribute names
47
+
48
+ def.attribute = name; // transform legacy deprecated syntax
49
+
50
+ if (deprecated) {
51
+ let {
52
+ until,
53
+ map,
54
+ alt
55
+ } = deprecated !== true ? deprecated : {};
56
+ def.deprecated = [until, map ? `--${map}` : alt];
57
+ } // add name and mapped short option
58
+
59
+
60
+ return {
61
+ name,
62
+ short,
63
+ ...def
64
+ };
65
+ } // Maps a legacy class-based command to the new functional syntax
66
+
67
+
68
+ export function legacyCommand(name, constructor) {
69
+ var _constructor$args, _constructor$flags;
70
+
71
+ let runner = command(name, {
72
+ description: constructor.description,
73
+ examples: constructor.examples,
74
+ // map legacy args and flags
75
+ args: (_constructor$args = constructor.args) === null || _constructor$args === void 0 ? void 0 : _constructor$args.map(legacyArg),
76
+ flags: Object.entries(constructor.flags ?? []).map(([name, def]) => legacyFlag(name, def)).filter(({
77
+ name
78
+ }) => name !== '__percy__'),
79
+ percy: ((_constructor$flags = constructor.flags) === null || _constructor$flags === void 0 ? void 0 : _constructor$flags.__percy__) ?? true,
80
+ loose: constructor.strict === false,
81
+ legacy: true
82
+ }, async function* (ctx) {
83
+ let instance = new constructor();
84
+ let error; // not an instance of the constructor, create one ourself
85
+
86
+ if (!(instance instanceof constructor)) {
87
+ instance = Object.create(constructor.prototype);
88
+ } // alias legacy global flags attribute names when necessary
89
+
90
+
91
+ if (ctx.flags.dryRun) ctx.flags['dry-run'] = ctx.flags.dryRun; // legacy percyrc value
92
+
93
+ let percyrc = overrides => merge([ctx.percy.config, overrides, {
94
+ skipUploads: ctx.flags.debug,
95
+ dryRun: ctx.flags['dry-run'],
96
+ config: false
97
+ }], (path, prev, next) => // do not merge arrays
98
+ Array.isArray(next) && [path, next]); // augment legacy instance properties
99
+
100
+
101
+ Object.defineProperties(instance, {
102
+ isPercyEnabled: {
103
+ value: () => !!ctx.percy
104
+ },
105
+ error: {
106
+ value: msg => ctx.exit(1, msg)
107
+ },
108
+ parse: {
109
+ value: () => ctx
110
+ },
111
+ percyrc: {
112
+ value: percyrc
113
+ },
114
+ flags: {
115
+ value: ctx.flags
116
+ },
117
+ args: {
118
+ value: ctx.args
119
+ },
120
+ exit: {
121
+ value: ctx.exit
122
+ },
123
+ log: {
124
+ value: ctx.log
125
+ }
126
+ }); // legacy signal handling
127
+
128
+ let signals = ['SIGHUP', 'SIGINT', 'SIGTERM'];
129
+
130
+ let cleanup = () => {
131
+ var _instance$finally, _instance;
132
+
133
+ return (_instance$finally = (_instance = instance).finally) === null || _instance$finally === void 0 ? void 0 : _instance$finally.call(_instance, new Error('SIGTERM'));
134
+ };
135
+
136
+ for (let e of signals) process.on(e, cleanup); // run and handle legacy command
137
+
138
+
139
+ try {
140
+ yield instance.run();
141
+ } catch (err) {
142
+ throw error = err;
143
+ } finally {
144
+ var _instance$finally2, _instance2;
145
+
146
+ await ((_instance$finally2 = (_instance2 = instance).finally) === null || _instance$finally2 === void 0 ? void 0 : _instance$finally2.call(_instance2, error));
147
+
148
+ for (let e of signals) process.off(e, cleanup);
149
+ }
150
+ }); // throw legacy errors for test compatibility
151
+
152
+ return Object.defineProperties(args => runner(args).catch(e => {
153
+ throw new Error(`EEXIT: ${e.exitCode}`);
154
+ }), Object.getOwnPropertyDescriptors(runner));
155
+ } // Support the legacy static run method
156
+
157
+ command.run = function run(args) {
158
+ return legacyCommand('legacy', this)(args);
159
+ };
160
+
161
+ export default legacyCommand;
package/dist/parse.js ADDED
@@ -0,0 +1,400 @@
1
+ import logger from '@percy/logger';
2
+ import { camelcase } from '@percy/config/utils';
3
+ import { flagUsage } from './help.js'; // Make it possible to identify parse errors.
4
+
5
+ export class ParseError extends Error {
6
+ name = this.constructor.name;
7
+ } // Helper that makes it possible to throw parse errors without throw or new keywords.
8
+
9
+ function parseError(msg) {
10
+ throw new ParseError(msg);
11
+ } // Helper to identify a potential command-line option as a flag.
12
+
13
+
14
+ function isFlag(option) {
15
+ // not a flag if it doesn't start with a dash
16
+ if (!option.startsWith('-')) return false; // safe to say it's a flag without a space
17
+
18
+ if (!option.includes(' ')) return true; // containing a space with no operator is not a proper flag
19
+
20
+ if (!option.includes('=')) return false; // as long as the operator precedes the space, it is a flag
21
+
22
+ return option.indexOf('=') < option.indexOf(' ');
23
+ } // Parses input to identify a command. Consumes input and adds to parsed output as needed, returning
24
+ // true if any defined command was found. A command is only identified via leading unparsed input
25
+ // options. If a command is found, parsed output is updated accordingly and the current input option
26
+ // is removed from further processing. An implicit help command is also supported.
27
+
28
+
29
+ async function maybeParseCommand(input, parsed) {
30
+ var _input$;
31
+
32
+ // a flag, no defined commands, or already parsed other options
33
+ if (isFlag(input[0]) || !parsed.command.definition.commands || parsed.operators.size || parsed.argv.length) {
34
+ return;
35
+ } // implicit help
36
+
37
+
38
+ if (input[0] === 'help') {
39
+ parsed.help = true;
40
+ input.shift();
41
+ } // nested commands can be a single argument with command names separated by colons (:)
42
+
43
+
44
+ let names = ((_input$ = input[0]) === null || _input$ === void 0 ? void 0 : _input$.split(':')) ?? [];
45
+ let command;
46
+
47
+ for (let name of names) {
48
+ var _parent$definition$co;
49
+
50
+ let parent = command || parsed.command; // find any nested command by name
51
+
52
+ command = (_parent$definition$co = parent.definition.commands) === null || _parent$definition$co === void 0 ? void 0 : _parent$definition$co.find(cmd => cmd.name === name);
53
+ if (!command) break; // maintain nested names and parent relationships
54
+
55
+ name = parent.parent ? `${parent.name}:${command.name}` : command.name;
56
+ command = await normalizeCommand(command, {
57
+ name,
58
+ parent
59
+ });
60
+ }
61
+
62
+ if (command) {
63
+ // update parsed properties and discard current input
64
+ parsed.log = logger(`cli:${command.name}`);
65
+ parsed.command = command;
66
+ input.shift();
67
+ } // help was requested, drain remaining input
68
+
69
+
70
+ if (parsed.help) {
71
+ input.splice(0, input.length);
72
+ }
73
+
74
+ return parsed.help || !!command;
75
+ } // Parses input to identify a flag option. Consumes input and adds to parsed output as needed,
76
+ // returning true if any defined flag was found. If a found flag is missing a required argument, the
77
+ // next input option will be consumed as that argument. Boolean flags support negation if their
78
+ // default value is true, integer flags are automatically parsed, and flags that can be provided
79
+ // multiple times will be concatenated together. Implicit help and version flags are also supported.
80
+
81
+
82
+ async function maybeParseFlag(input, parsed) {
83
+ var _value;
84
+
85
+ if (!isFlag(input[0])) return; // end of options and arguments, consume remaining input
86
+
87
+ if (input[0] === '--') {
88
+ let [, ...argv] = input.splice(0, input.length);
89
+ parsed.argv.push(...argv);
90
+ return true;
91
+ } // a short flag was provided
92
+
93
+
94
+ let short = input[0][1] !== '-' && input[0][1]; // get any value set with `=` and remove any `=` from short values
95
+
96
+ let [name, value] = short ? ['', input[0].substr(2) || null] : input[0].split('=');
97
+ value = short && (_value = value) !== null && _value !== void 0 && _value.startsWith('=') ? value.substr(1) : value; // the flag was negated
98
+
99
+ let negated = name.startsWith('--no-');
100
+ name = name.replace(/^--(no-)?/, ''); // search for the provided short or long flag
101
+
102
+ let flag = parsed.command.definition.flags.find(f => {
103
+ return short ? f.short === short : f.name === name;
104
+ });
105
+
106
+ if (flag) {
107
+ // discard current input
108
+ input.shift();
109
+
110
+ if (!flag.type || flag.type === 'boolean') {
111
+ // short boolean flags can be provided with other short flags
112
+ if (short && value) input.unshift(`-${value}`); // a value was provided to a boolean flag
113
+
114
+ if (!short && value) {
115
+ parseError(`Unexpected option argument for '${flagUsage(flag)}'`);
116
+ } // a boolean value is based on if it was negated
117
+
118
+
119
+ value = !negated;
120
+ } else if (value == null) {
121
+ // expected a value for a non-boolean flag
122
+ if (!input.length || isFlag(input[0])) {
123
+ parseError(`Missing option argument for '${flagUsage(flag)}'`);
124
+ } // the value is actually the next input option
125
+
126
+
127
+ value = input.shift();
128
+ } // parse the value according to flag properties
129
+
130
+
131
+ if (flag.type === 'integer') value = parseInt(value, 10);
132
+ if (flag.parse) value = await flag.parse(value);
133
+
134
+ if (flag.multiple) {
135
+ value = (parsed.operators.get(flag) ?? []).concat(value);
136
+ } // add this flag to the set of parsed operators
137
+
138
+
139
+ parsed.operators.set(flag, value);
140
+ return true;
141
+ } // implicit version and help support
142
+
143
+
144
+ if (parsed.command.definition.version && (name === 'version' || short === 'V')) {
145
+ parsed.version = true;
146
+ } else if (name === 'help' || short === 'h') {
147
+ parsed.help = true;
148
+ } // help or version was requested, drain remaining input
149
+
150
+
151
+ if (parsed.help || parsed.version) {
152
+ input.splice(0, input.length);
153
+ return true;
154
+ }
155
+ } // Parses input to identify an argument option. Consumes input and adds to parsed output as needed,
156
+ // returning true if any defined argument was found. Arguments are matched according to their
157
+ // respective index and command-line position.
158
+
159
+
160
+ async function maybeParseArg(input, parsed) {
161
+ var _parsed$command$defin;
162
+
163
+ if (isFlag(input[0])) return; // the argument index needs to be tracked since flag options can precede argument options
164
+
165
+ let index = parsed.__args_index__ ?? (parsed.__args_index__ = 0); // there are arguments left to parse
166
+
167
+ if (index < ((_parsed$command$defin = parsed.command.definition.args) === null || _parsed$command$defin === void 0 ? void 0 : _parsed$command$defin.length)) {
168
+ parsed.__args_index__ += 1;
169
+ let value = input.shift();
170
+ let arg = parsed.command.definition.args[index];
171
+ if (arg.parse) value = await arg.parse(value); // add this arg to the set of parsed operators
172
+
173
+ parsed.operators.set(arg, value);
174
+ return true;
175
+ }
176
+ } // Parses input to identify unknown options. Consumes input and adds to parsed output as needed,
177
+ // returning true when unknown options are found. By default, unknown options result in an error
178
+ // unless the command is defined to accept loose options. If `loose` is a string, it will be logged
179
+ // as a warning when unknown options are found. When the first unknown option is found, all
180
+ // remaining options are also considered unknown.
181
+
182
+
183
+ async function maybeParseUnknown(input, parsed) {
184
+ let {
185
+ loose
186
+ } = parsed.command.definition;
187
+
188
+ if (!loose) {
189
+ // throw a more descriptive error message
190
+ let reason = isFlag(input[0]) ? 'Unknown option' : 'Unexpected argument';
191
+ parseError(`${reason} '${input[0]}'`);
192
+ } else if (typeof loose === 'string') {
193
+ // warn when loose is a string
194
+ parsed.log.warn(loose);
195
+ } // consume all remaining unknown input options
196
+
197
+
198
+ let unknown = input.splice(0, input.length);
199
+ parsed.argv.push(...unknown);
200
+ return true;
201
+ } // Parses an option's value defined by an environment variable. Adds to parsed output as needed,
202
+ // returning true when the option's value was parsed from the environment. Boolean environment
203
+ // variables will be interpreted as true unless "false" or "0", and integer environment variables
204
+ // will be parsed automatically.
205
+
206
+
207
+ async function maybeParseEnv(option, parsed) {
208
+ // no env option, already provided, or no defined env var
209
+ if (!option.env || parsed.operators.has(option) || !(option.env in process.env)) {
210
+ return;
211
+ } // get the value from the process env
212
+
213
+
214
+ let value = process.env[option.env];
215
+ let flag = parsed.command.definition.flags.includes(option);
216
+
217
+ if (flag && (!option.type || option.type === 'boolean')) {
218
+ // parse booleans as not "false" or not "0"
219
+ value = value !== 'false' && value !== '0';
220
+ } else if (option.type === 'integer') {
221
+ // parse integer option values
222
+ value = parseInt(value, 10);
223
+ } // custom parse and add this option to the set of parsed operators
224
+
225
+
226
+ if (option.parse) value = await option.parse(value);
227
+ parsed.operators.set(option, value);
228
+ return true;
229
+ } // Helper to find and return a matching flag by name.
230
+
231
+
232
+ function findFlag(name, parsed, map) {
233
+ if (name.startsWith('--')) name = name.substr(2);
234
+ let {
235
+ flags
236
+ } = parsed.command.definition;
237
+ let match = flags.find(f => f.name === name);
238
+ return map ? map(match) : match;
239
+ } // Throws an error if the provided flag is inclusive of any missing flags.
240
+
241
+
242
+ function validateInclusiveFlag(flag, parsed) {
243
+ if (!flag.inclusive) return;
244
+ let invalid = flag.inclusive.reduce((invalid, name) => findFlag(name, parsed, flag => // invalid options are options that have _not_ been provided
245
+ !parsed.operators.has(flag) ? invalid.concat(flag) : invalid), []);
246
+
247
+ if (invalid.length) {
248
+ // show full flag usage for invalid options
249
+ invalid = [flag, ...invalid].map(f => flagUsage(f)).join("', '");
250
+ parseError(`Options must be used together: '${invalid}'`);
251
+ }
252
+ } // Throws an error if the provided flag is exclusive of other provided flags.
253
+
254
+
255
+ function validateExclusiveFlag(flag, parsed) {
256
+ if (!flag.exclusive) return;
257
+ let invalid = flag.exclusive.reduce((invalid, name) => findFlag(name, parsed, flag => // invalid options are options that _have_ been provided
258
+ parsed.operators.has(flag) ? invalid.concat(flag) : invalid), []);
259
+
260
+ if (invalid.length) {
261
+ // show full flag usage for invalid options
262
+ invalid = [flag, ...invalid].map(f => flagUsage(f)).join("', '");
263
+ parseError(`Options cannot be used together: '${invalid}'`);
264
+ }
265
+ } // Returns the attribute name of an option, which may be contingent on it's parsed value. If an
266
+ // option is deprecated and is recommended to be replaced with flag, the attribute name will be
267
+ // mapped to the replacement flag's attribute name. If a deprecated option is found in the provided
268
+ // operators, a deprecation warning will be logged with any recommendation message.
269
+
270
+
271
+ function attributeName(option, parsed) {
272
+ let {
273
+ attribute,
274
+ deprecated
275
+ } = option;
276
+ let value = parsed.operators.get(option) ?? option.default; // the option's attribute name might be contingent on it's value
277
+
278
+ let name = typeof attribute === 'function' ? attribute(value) : attribute; // default the attribute name to the camelcase name or short character
279
+
280
+ name || (name = option.name ? camelcase(option.name) : option.short); // deprecated options can be mapped to other options
281
+
282
+ if (deprecated) {
283
+ var _recommend;
284
+
285
+ let [until, recommend] = Array.isArray(deprecated) ? deprecated : [typeof deprecated === 'string' ? deprecated : '']; // recommended replacement flag
286
+
287
+ if ((_recommend = recommend) !== null && _recommend !== void 0 && _recommend.startsWith('--')) {
288
+ let opt = findFlag(recommend, parsed);
289
+ recommend = opt ? `Use '${flagUsage(opt)}' instead.` : '';
290
+ name = opt ? attributeName(opt, parsed) : name;
291
+ } // operator was provided, log deprecation warning
292
+
293
+
294
+ if (parsed.operators.has(option)) {
295
+ // show better usage message for flags
296
+ let flag = parsed.command.definition.flags.includes(option);
297
+ let usage = flag ? flagUsage(option) : option.name;
298
+ parsed.log.deprecated((`The '${usage}' ${flag ? 'option' : 'argument'} ` + `will be removed in ${until || 'a future release'}. ` + (recommend || '')).trim());
299
+ }
300
+ }
301
+
302
+ return name;
303
+ } // Normalizes a command runner into an object of command properties. Optionally derives nested
304
+ // commands from any provided definition commands method.
305
+
306
+
307
+ async function normalizeCommand(command, properties) {
308
+ var _ref;
309
+
310
+ // shallow copy command definition
311
+ let { ...definition
312
+ } = command.definition; // evaluate commands if needed
313
+
314
+ if (typeof definition.commands === 'function') {
315
+ definition.commands = await definition.commands();
316
+ } // create a shallow copy with additional properties
317
+
318
+
319
+ let normalized = { ...command,
320
+ ...properties,
321
+ definition
322
+ }; // inherit parent package information by default
323
+
324
+ normalized.packageInformation || (normalized.packageInformation = (_ref = (properties === null || properties === void 0 ? void 0 : properties.parent) || command.parent) === null || _ref === void 0 ? void 0 : _ref.packageInformation);
325
+ return normalized;
326
+ } // Parses and validates command-line arguments according to a command definition.
327
+
328
+
329
+ export async function parse(command, argv) {
330
+ // initial input and output
331
+ let input = argv.slice().filter(a => a != null);
332
+ let parsed = {
333
+ command: await normalizeCommand(command),
334
+ operators: new Map(),
335
+ log: logger('cli'),
336
+ flags: {},
337
+ args: {},
338
+ argv: []
339
+ }; // while there is input left, parse it
340
+
341
+ while (input.length) {
342
+ // parse and mutate input and output, looping if an option is found
343
+ if (await maybeParseCommand(input, parsed)) continue;
344
+ if (await maybeParseFlag(input, parsed)) continue;
345
+ if (await maybeParseArg(input, parsed)) continue;
346
+ await maybeParseUnknown(input, parsed);
347
+ } // help or version requested, skip remaining parse logic
348
+
349
+
350
+ if (parsed.help || parsed.version) {
351
+ return parsed;
352
+ } // validate and map defined flags
353
+
354
+
355
+ for (let flag of parsed.command.definition.flags) {
356
+ // collect last-minute env info
357
+ await maybeParseEnv(flag, parsed);
358
+
359
+ if (parsed.operators.has(flag)) {
360
+ // validate before setting the parsed attribute
361
+ validateInclusiveFlag(flag, parsed);
362
+ validateExclusiveFlag(flag, parsed);
363
+ let value = parsed.operators.get(flag);
364
+ if (flag.validate) await flag.validate(value, parsed);
365
+ parsed.flags[attributeName(flag, parsed)] = value;
366
+ } else if (flag.default != null) {
367
+ // a default value needs to be set, but not parsed
368
+ parsed.flags[attributeName(flag, parsed)] = flag.default;
369
+ }
370
+ } // validate and map defined args
371
+
372
+
373
+ for (let arg of parsed.command.definition.args ?? []) {
374
+ // collect last-minute env info
375
+ await maybeParseEnv(arg, parsed);
376
+
377
+ if (parsed.operators.has(arg)) {
378
+ var _arg$deprecated, _arg$deprecated$;
379
+
380
+ // validate before setting the parsed attribute
381
+ let value = parsed.operators.get(arg);
382
+ if (arg.validate) await arg.validate(value, parsed); // a deprecated arg might be mapped to a flag
383
+
384
+ if ((_arg$deprecated = arg.deprecated) !== null && _arg$deprecated !== void 0 && (_arg$deprecated$ = _arg$deprecated[1]) !== null && _arg$deprecated$ !== void 0 && _arg$deprecated$.startsWith('--') && findFlag(arg.deprecated[1], parsed)) {
385
+ parsed.flags[attributeName(arg, parsed)] = value;
386
+ } else {
387
+ parsed.args[attributeName(arg, parsed)] = value;
388
+ }
389
+ } else if (arg.required) {
390
+ // a required argument was not provided
391
+ parseError(`Missing required argument '${arg.name}'`);
392
+ } else if (arg.default != null) {
393
+ // a default value needs to be set, but not parsed
394
+ parsed.args[attributeName(arg, parsed)] = arg.default;
395
+ }
396
+ }
397
+
398
+ return parsed;
399
+ }
400
+ export default parse;
package/dist/utils.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from '@percy/config/utils';
2
+ export * from '@percy/core/utils';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-command",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,7 +11,8 @@
11
11
  "access": "public"
12
12
  },
13
13
  "files": [
14
- "./dist"
14
+ "dist",
15
+ "test/helpers.js"
15
16
  ],
16
17
  "engines": {
17
18
  "node": ">=14"
@@ -33,9 +34,9 @@
33
34
  "test:coverage": "yarn test --coverage"
34
35
  },
35
36
  "dependencies": {
36
- "@percy/config": "1.0.0",
37
- "@percy/core": "1.0.0",
38
- "@percy/logger": "1.0.0"
37
+ "@percy/config": "1.0.3",
38
+ "@percy/core": "1.0.3",
39
+ "@percy/logger": "1.0.3"
39
40
  },
40
- "gitHead": "6df509421a60144e4f9f5d59dc57a5675372a0b2"
41
+ "gitHead": "a259d5cff0933711bced21a979c577e70765d318"
41
42
  }
@@ -0,0 +1 @@
1
+ export * from '@percy/core/test/helpers';