@percy/cli-command 1.0.0-beta.8 → 1.0.1

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.
package/README.md CHANGED
@@ -1,60 +1,193 @@
1
1
  # @percy/cli-command
2
2
 
3
- The base command class that Percy CLI commands should extend from. Adds commonly used configuration
4
- options to Percy config files, a `#percyrc()` method for parsing config files, and other shared
5
- methods. Also provides common CLI flags along with oclif flag functions.
3
+ Percy CLI command parser and runner.
4
+
5
+ - [Usage](#usage)
6
+ - [Options](#options)
7
+ - [Args](#args)
8
+ - [Flags](#flags)
9
+ - [Percy](#percy)
6
10
 
7
11
  ## Usage
8
12
 
9
- ```js
10
- import PercyCommand, { flags } from '@percy/cli-command'
11
-
12
- export class PercyPlugin extends PercyCommand {
13
- static flags = {
14
- ...flags.logging,
15
- ...flags.discovery,
16
- ...flags.config
17
- }
18
-
19
- run() {
20
- let { args, flags } = this
21
- let { snapshot, discovery } = this.percyrc()
22
- // ...
23
- }
24
-
25
- finally() {
26
- // called after #run() and also on process termination events
27
- }
28
- }
13
+ The `command` function accepts a name, definition, and callback and returns a runner function that
14
+ accepts an array of command-line arguments.
15
+
16
+ ``` js
17
+ import command from '@percy/cli-command';
18
+
19
+ // example command runner
20
+ const example = command('example', {
21
+ description: 'Example command description',
22
+ // positional arguments
23
+ args: [
24
+ { name: 'one' }
25
+ ],
26
+ // flags and options
27
+ flags: [
28
+ { name: 'foo' }, // boolean by default
29
+ { name: 'bar', type: 'string' }
30
+ ],
31
+ // shows in help output
32
+ examples: [
33
+ // $0 will be replaced by the command name
34
+ '$0 --foo --bar baz qux'
35
+ ]
36
+ }, ({ flags, args }) => {
37
+ // see below for other available properties
38
+ console.log({ flags, args });
39
+ });
40
+
41
+ // remove the script name from argv
42
+ example(process.argv.slice(2));
29
43
  ```
30
44
 
31
- ## Percy Configuration
32
-
33
- This CLI plugin adds the following Percy configuration options that may be commonly used throughout
34
- other Percy CLI plugins (defaults shown).
35
-
36
- ``` yaml
37
- version: 2
38
- snapshot:
39
- widths: [375, 1280]
40
- minHeight: 1024
41
- percyCSS: ''
42
- requestHeaders: {}
43
- discovery:
44
- allowedHostnames: []
45
- networkIdleTimeout: 100
46
- disableAssetCache: false
47
- concurrency: 5
45
+ ``` shell
46
+ $ node ./example --foo --bar baz qux
47
+ { flags: { foo: true, bar: 'baz' }, args: { one: 'qux' } }
48
48
  ```
49
49
 
50
- ### Mapping CLI Flags
50
+ ## Options
51
+
52
+ Only a command `name` is required. However, without any other options, a command will only show the
53
+ default help output with global flags. A command's `callback` will be called when the resulting
54
+ command runner is invoked with an array of command-line arguments. How those arguments are
55
+ interpreted and parsed is defined by the command's `definition`.
56
+
57
+ - `name` — The command name shown in help output, also used to match nested commands.
58
+ - `definition` — A command definition object defines various options to control help output, the
59
+ arguments parser, and other percy options.
60
+ - `description` — The command description shown in help output.
61
+ - `usage` — Custom command usage shown in help output.
62
+ - `examples` — An array of example strings to show in help output.
63
+ - `version` — A version string to display via a `-V, --version` flag.
64
+ - `commands` — An array or function returning an array of nested subcommands.
65
+ - `flags` — Optional flag arguments accepted by the command ([see below](#flags)).
66
+ - `args` — Positional arguments accepted by the command ([see below](#args)).
67
+ - `percy` — Enable creation of a core percy instance ([see below](#percy)).
68
+ - `config` — Automatic percy config registration ([see below](#percy)).
69
+ - `loose` — Push unknown arguments to `argv`. A string value will log as a warning when unknown
70
+ arguments are first encountered by the parser.
71
+ - `exitOnError` — Call `process.exit()` if the command encounters an error while running.
72
+ - `callback` — The command callback invoked after the runner is called with command-line arguments
73
+ and after those arguments are interpreted by the internal parser.
74
+
75
+ The `callback` argument can be a function, an async function, or an async generator function. Given
76
+ an async generator function, the command becomes cancelable via process interruption and is able to
77
+ clean up after itself using traditional try-catch error handling.
78
+
79
+ The `callback` function is invoked with a single argument containing various contextual properties
80
+ according to the command definition:
81
+
82
+ - `flags` — An object containing key-value pairs of flag arguments interpreted by the parser as
83
+ determined by the flag's respective definition ([see below](#flags)).
84
+ - `args` — An object containing key-value pairs of positional arguments interpreted by the parser as
85
+ determined by the arg's respective definition ([see below](#args)).
86
+ - `argv` — An array of unknown arguments that were not parsed due to either the `loose` option or
87
+ the presence of an end-of-arguments delimiter (`--`).
88
+ - `percy` — An instance of `@percy/core` only present when a corresponding `percy` definition option
89
+ is provided ([see below](#percy)).
90
+ - `log` — A `@percy/logger` group namespaced under the command's `name`. The initial loglevel is
91
+ optionally determined by various global logging flags (`--verbose`, `--silent`, `--quiet`).
92
+ - `exit` — Utility function to stop the execution of a command from within the runner. Accepts
93
+ an exit code (default `1`) and an exit reason (default `'EEXIT: ${exitCode}'`). The function will
94
+ throw an error which can be handled, or let bubble where the presence of `exitOnError` will cause
95
+ the process to exit with the provided exit code.
96
+
97
+ ### Flags
98
+
99
+ Each flag definition must contain either a `name` or a `short` option. By default, all flags will be
100
+ interpreted as boolean flags and will produce an error if a value is provided. Setting the `type`
101
+ option to anything other than `'boolean'` will allow providing command-line values and error if one
102
+ _is not_ provided.
103
+
104
+ - `name` — The flag name (i.e. `--flag-name`).
105
+ - `short` — The flag short character (i.e. `-s`).
106
+ - `type` — The flag type as displayed in help output.
107
+ - `default` — The flag's default value when not provided.
108
+ - `parse` — A function used to parse any provided flag value.
109
+ - `validate` — A function that should throw an error when the provided flag value is invalid.
110
+ - `attribute` — A string or function returning a string to set the value at on the `callback`
111
+ argument's `flags` property. Defaults to the camelcase `name` or `short` character.
112
+ - `percyrc` — A string to map this flag to percy config options ([see below](#percy)).
113
+ - `deprecated` — A boolean, string, or tuple used to warn about a deprecated flag and optionally map
114
+ it to an alternate flag ([see below](#deprecations)).
115
+
116
+ ### Args
117
+
118
+ Positional args correspond to their definition index, so the arg definition at index `0` will match
119
+ the first provided command-line argument that is not a flag or subcommand. By default, positional
120
+ args are optional, but can be configured to be required or have default values.
51
121
 
52
- The `#percyrc()` method will parse command flags for Percy configuration mappings as determined by a
53
- `percyrc` flag configuration option.
122
+ - `name` — The arg name shown in help output.
123
+ - `required` — Indicate that this flag is required, which causes an error when not provided.
124
+ - `default` — The arg's default value when not provided.
125
+ - `parse` — A function used to parse the provided argument.
126
+ - `validate` — A function that should throw an error when the provided argument is invalid.
127
+ - `attribute` — A string or function returning a string to set the value at on the `callback`
128
+ argument's `args` property. Defaults to camelcase `name`.
129
+ - `percyrc` — A string to map this arg to percy config options ([see below](#percy)).
130
+ - `deprecated` — A boolean, string, or tuple used to warn about a deprecated arg and optionally map
131
+ it to a flag ([see below](#deprecations)).
132
+
133
+ #### Deprecations
134
+
135
+ When the `deprecated` option is `true` and the flag or positional arg has been provided by
136
+ command-line arguments, a deprecation warning will be logged. A string value indicates the version
137
+ in which this argument will be removed and is reflected in the deprecation message.
138
+
139
+ <details>
140
+ <summary>Remapping deprecated arguments</summary><br>
141
+
142
+ When providing a tuple, the version is the first option of the tuple, while the second option can be
143
+ an alternate flag to use, or a recommendation message to display when the deprecation warning is
144
+ logged. Given an alternate flag, the value will be automatically mapped to the corresponding flag's
145
+ attribute name on the `callback` argument's `flags` property.
54
146
 
55
147
  ``` js
56
- 'allowed-hostnames': flags.string({
57
- // maps the --allowed-hostnames flag to the corresponding config option
58
- percyrc: 'discovery.allowedHostnames'
59
- })
148
+ { name: 'foo', deprecated: true }
149
+ // [percy] Warning: The '--foo' option will be removed in a future release.
150
+
151
+ { name: 'foo', deprecated: 'v2.0.0' }
152
+ // [percy] Warning: The '--foo' option will be removed in v2.0.0.
153
+
154
+ { name: 'foo', deprecated: ['v2.0.0', '--bar'] }
155
+ // [percy] Warning: The '--foo' option will be removed in v2.0.0. Use '--bar' instead.
156
+
157
+ { name: 'foo', deprecated: ['v2.0.0', 'Use the config file option instead.'] }
158
+ // [percy] Warning: The '--foo' option will be removed in v2.0.0. Use the config file option instead.
60
159
  ```
160
+ </details>
161
+
162
+ ### Percy
163
+
164
+ The `percy` definition option accepts an object consisting of `@percy/core` options. The presence of
165
+ this option will add shared percy flags to accepted command-line arguments and provide the command
166
+ callback with a percy instance initialized with the provided options.
167
+
168
+ When the `percy` definition option is `true`, shared percy flags will not be accepted, but a default
169
+ percy instance will still be provided to the command callback when run. Regardless of the `percy`
170
+ option, if the environment variable `PERCY_ENABLE` is `0`, the callback _will not_ receive a percy
171
+ instance (and can act accordingly).
172
+
173
+ - `percy` — Enables creation of a `@percy/core` instance initialized with provided options.
174
+ - `discoveryFlags` — When `false` prevents percy discovery flags from being accepted, but will
175
+ still allow other percy flags such as `--config` and `--dry-run`.
176
+ - `...` — All other options are passed along to the new percy instance.
177
+
178
+ #### Config
179
+
180
+ Acceptable percy config file options can be extended before the file is loaded via
181
+ `PercyConfig.addSchema()` and `PercyConfig.addMigration()`. These functions can be called before the
182
+ command is run to achieve the same effect as including them in the command definition. However, the
183
+ presence of a `percy` definition option will also signal `@percy/core` config schemas and migrations
184
+ to be automatically registered before the following options are registered.
185
+
186
+ - `config` — Percy config schemas and migrations to register while creating the command runner.
187
+ - `schemas` — An array of percy config schemas to automatically register.
188
+ - `migrations` — An array of percy config migrations to automatically register.
189
+
190
+ Additionally, flags and positional arguments that define a `percyrc` option will have their
191
+ associated values mapped to a corresponding `percy` property that is used when initializing a
192
+ `@percy/core` instance. Provided options are validated against config schemas and are made available
193
+ on the command callback argument's `percy.config` property.
package/bin/readme ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = await import('fs');
4
+ const path = await import('path');
5
+ const { formatHelp } = await import('../dist/help');
6
+
7
+ async function updateReadmeCommands(cwd = process.cwd()) {
8
+ let readmePath = path.join(cwd, 'README.md');
9
+ let pkgPath = path.join(cwd, 'package.json');
10
+ let sections = [];
11
+ let toc = [];
12
+
13
+ let pkg = JSON.parse(fs.readFileSync(pkgPath));
14
+ if (!pkg['@percy/cli']?.commands) return '';
15
+
16
+ for (let cmdPath of pkg['@percy/cli'].commands) {
17
+ let cmdURL = url.pathToFileURL(path.join(CWD, cmdPath));
18
+ let { default: command } = await import(cmdURL.href);
19
+ command = { ...command, parent: { name: 'percy' } };
20
+
21
+ for (let cmd of [command, ...(command.definition.commands || [])]) {
22
+ if (!cmd.callback) continue;
23
+ if (cmd !== command) cmd = { ...cmd, parent: command };
24
+ if (cmd.parent.parent) cmd.name = `${cmd.parent.name}:${cmd.name}`;
25
+
26
+ let title = `\`percy ${cmd.name}\``;
27
+ let slug = title.slice(1, -1).replace(/\s/g, '-').replace(/:/g, '');
28
+ toc.push(`* [${title}](#${slug})`);
29
+
30
+ let help = await formatHelp(cmd);
31
+ help = help.replace(/^(.*)?(usage:.*)$/gis, '$1```\n$2```');
32
+ sections.push(`### ${title}\n\n${help}\n`);
33
+ }
34
+ }
35
+
36
+ fs.writeFileSync(readmePath, fs.readFileSync(readmePath, 'utf-8').replace(
37
+ /(<!-- commands -->)(.*)(<!-- commandsstop -->)/is,
38
+ `$1\n${[toc.join('\n'), sections.join('\n')].join('\n\n')}$3`
39
+ ));
40
+ }
41
+
42
+ updateReadmeCommands();
package/dist/command.js CHANGED
@@ -1,83 +1,214 @@
1
- "use strict";
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
2
21
 
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.default = void 0;
7
22
 
8
- var _command = _interopRequireDefault(require("@oclif/command"));
23
+ if (def.percy && def.percy !== true) {
24
+ builtInFlags.PERCY.forEach(addDedupedFlag); // maybe include percy discovery flags
9
25
 
10
- var _config = _interopRequireDefault(require("@percy/config"));
26
+ if (def.percy.discoveryFlags !== false) {
27
+ builtInFlags.DISCOVERY.forEach(addDedupedFlag);
28
+ }
29
+ } // always add global built-in flags
11
30
 
12
- var _logger = _interopRequireDefault(require("@percy/logger"));
13
31
 
14
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
32
+ builtInFlags.GLOBAL.forEach(addDedupedFlag); // copy any existing config before adding to it
15
33
 
16
- // The PercyCommand class that all Percy CLI commands should extend
17
- // from. Provides common #init() and #catch() methods and provides other methods
18
- // for loading configuration and checking if Percy is enabled.
19
- class PercyCommand extends _command.default {
20
- // Initialize flags, args, the loglevel, and attach process handlers to allow
21
- // commands to seemlessly cleanup on interupt or termination
22
- init() {
23
- let {
24
- args,
25
- flags
26
- } = this.parse();
27
- this.flags = flags;
28
- this.args = args; // sets the log level from verbose, quiet, and silent flags
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
29
38
 
30
- _logger.default.loglevel('info', flags); // ensure cleanup is always performed
39
+ if (def.percy) {
40
+ def.config.schemas.unshift(CoreConfig.schemas);
41
+ def.config.migrations.unshift(CoreConfig.migrations);
42
+ }
31
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.
32
48
 
33
- let cleanup = () => this.finally();
34
49
 
35
- process.on('SIGHUP', cleanup);
36
- process.on('SIGINT', cleanup);
37
- process.on('SIGTERM', cleanup);
38
- } // Log errors using the Percy logger
50
+ async function handleProcessSignals(callback) {
51
+ let signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP'];
52
+ let signalError; // keep track of signal handlers for cleanup
39
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
+ };
40
62
 
41
- async catch(err) {
42
- try {
43
- // real errors will bubble
44
- await super.catch(err);
45
- } catch (err) {
46
- // oclif exit method actually throws an error, don't log it
47
- if (!err.oclif || err.code !== 'EEXIT') _logger.default.error(err);
48
- throw err;
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);
49
82
  }
50
- } // Returns true or false if Percy has not been disabled
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
+ }
51
140
 
141
+ });
142
+ } // wrap and bind the parsed command callback
52
143
 
53
- isPercyEnabled() {
54
- return process.env.PERCY_ENABLE !== '0';
55
- } // Parses command flags and maps them to config options according to their
56
- // respective `percyrc` parameter. The flag input is then merged with options
57
- // loaded from a config file and default config options. The PERCY_TOKEN
58
- // environment variable is also included as a convenience.
59
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.
60
149
 
61
- percyrc() {
62
- let flags = Object.entries(this.constructor.flags);
63
- let overrides = flags.reduce((conf, [name, flag]) => {
64
- var _flag$percyrc$split$r, _flag$percyrc;
65
150
 
66
- return (_flag$percyrc$split$r = (_flag$percyrc = flag.percyrc) === null || _flag$percyrc === void 0 ? void 0 : _flag$percyrc.split('.').reduce((target, key, i, paths) => {
67
- var _target$key;
151
+ export function command(name, definition, callback) {
152
+ definition = withBuiltIns(definition); // auto register config schemas and migrations
68
153
 
69
- let last = i === paths.length - 1;
70
- target[key] = last ? this.flags[name] : (_target$key = target[key]) !== null && _target$key !== void 0 ? _target$key : {};
71
- return last ? conf : target[key];
72
- }, conf)) !== null && _flag$percyrc$split$r !== void 0 ? _flag$percyrc$split$r : conf;
73
- }, {}); // will also validate config and log warnings
154
+ PercyConfig.addSchema(definition.config.schemas);
155
+ PercyConfig.addMigration(definition.config.migrations);
74
156
 
75
- return _config.default.load({
76
- path: this.flags.config,
77
- overrides
78
- });
79
- }
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
80
181
 
81
- }
82
182
 
83
- exports.default = PercyCommand;
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;