@gunshi/plugin-global 1.0.0-rc.5 → 1.0.0-rc.6

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
@@ -5,7 +5,7 @@
5
5
 
6
6
  > global options plugin for gunshi.
7
7
 
8
- This plugin provides standard global options (`--help` and `--version`) for all commands in your CLI application. It's installed by default in gunshi, ensuring consistent behavior across all CLI applications.
8
+ This plugin provides standard global options (`--help` and `--version`) for all commands in your CLI application, and the JSON output of `--json` for the commands that spread `jsonArgs`. It's installed by default in gunshi, ensuring consistent behavior across all CLI applications.
9
9
 
10
10
  ## 💿 Installation
11
11
 
@@ -74,6 +74,16 @@ When these options are used:
74
74
  - **With `--help`**: The command execution is bypassed, and the usage information is displayed instead
75
75
  - **With `--version`**: The command execution is bypassed, and only the version number is printed
76
76
 
77
+ ### JSON Output
78
+
79
+ A command that spreads `jsonArgs` from `gunshi` into its arguments accepts `--json`. With it, this plugin:
80
+
81
+ - prints the result that the command returns from `run`, an object keyed by a noun, as one JSON document to stdout
82
+ - prints an error as one line of JSON to stderr, with `status`, `reason`, `message`, `next` and `data`, and leaves stdout empty
83
+ - stops a command that does not spread `jsonArgs` before it runs, with `json_not_supported`, when `--json` is given to it
84
+
85
+ `--json` is not a global option: the commands that support it declare it. For the details, see [JSON Output](https://gunshi.dev/guide/essentials/json-output).
86
+
77
87
  ### Commands That Take the Name
78
88
 
79
89
  `help` and `version` are not reserved. A command may declare an argument of its own under either name, and gunshi merges the arguments of a command over the global options, so the command's argument wins:
@@ -119,6 +129,8 @@ Available extensions:
119
129
 
120
130
  <!-- eslint-enable markdown/no-missing-label-refs -->
121
131
 
132
+ `showVersion()`, `showHeader()` and `showUsage()` print with `ctx.log`. When a command that spreads `jsonArgs` is asked for JSON with `--json`, they print nothing and return the text only.
133
+
122
134
  - **`showVersion(): string`**: Display the application version. Returns `'unknown'` if no version is specified in the CLI configuration.
123
135
 
124
136
  - **`showHeader(): Awaitable<string | undefined>`**: Display the application header. Returns `undefined` if no `renderHeader` function is provided in the CLI configuration.
package/lib/index.d.ts CHANGED
@@ -3,6 +3,10 @@ import { Awaitable, PluginWithExtension } from "@gunshi/plugin";
3
3
  /**
4
4
  * Extended command context which provides utilities via global options plugin.
5
5
  * These utilities are available via `CommandContext.extensions['g:global']`.
6
+ *
7
+ * The functions that show the version, the header and the usage print with `ctx.log`. So they print
8
+ * nothing when `ctx.log` is silenced, with `usageSilent` or when a command that spreads `jsonArgs` is
9
+ * asked for JSON with `--json`, and return the text only.
6
10
  */
7
11
  interface GlobalExtension {
8
12
  /**
package/lib/index.js CHANGED
@@ -1,8 +1,94 @@
1
- import { hasPriorityValidationError, plugin } from "@gunshi/plugin";
1
+ import { ArgsValidationErrorKeys, CommandNotFoundErrorKeys, CommandResolutionErrorKeys, JsonOutputError, JsonOutputErrorKeys, hasPriorityValidationError, isArgsValidationError, isCommandError, isCommandNotFoundError, isCommandResolutionError, plugin } from "@gunshi/plugin";
2
2
  /**
3
3
  * @author kazuya kawaguchi (a.k.a. kazupon)
4
4
  * @license MIT
5
5
  */
6
+ //#endregion
7
+ //#region ../../node_modules/.pnpm/args-tokens@1.0.2/node_modules/args-tokens/lib/utils.js
8
+ /**
9
+ * Entry point of utils.
10
+ *
11
+ * Note that this entry point is used by gunshi to import utility functions.
12
+ *
13
+ * @module
14
+ */
15
+ /**
16
+ * @author kazuya kawaguchi (a.k.a. kazupon)
17
+ * @license MIT
18
+ */
19
+ /**
20
+ * Convert a string to kebab-case.
21
+ *
22
+ * @param str - A string to convert
23
+ * @returns Converted string into kebab-case.
24
+ */
25
+ function kebabnize(str) {
26
+ return str.replace(/[A-Z]/g, (match, offset) => (offset > 0 ? "-" : "") + match.toLowerCase());
27
+ }
28
+ //#endregion
29
+ //#region ../gunshi/src/utils.ts
30
+ /**
31
+ * @author kazuya kawaguchi (a.k.a. kazupon)
32
+ * @license MIT
33
+ */
34
+ /**
35
+ * Check whether an argument schema is the `json` argument of `jsonArgs`, by its mark.
36
+ *
37
+ * @param schema - An argument schema
38
+ * @returns `true` if the schema has the mark of `jsonArgs`
39
+ */
40
+ function isJsonArg(schema) {
41
+ return typeof schema === "object" && schema !== null && schema["~gunshi.json-arg"] === true;
42
+ }
43
+ /**
44
+ * Resolve whether `--json` asks a command for JSON.
45
+ *
46
+ * A command that spreads `jsonArgs` is asked for JSON when the resolved value of its `json` is `true`:
47
+ * with `--json`, or with a short name that the command gives it, but not with `--json=false`, nor with
48
+ * `--json` after `--`. The raw tokens are read only when they are given, and only when there is no
49
+ * value to read: when the command has no `json` argument, and when the core could not resolve the
50
+ * command and left the values empty. A `json` argument without the mark belongs to the command, and
51
+ * `--json` is never taken for a request then.
52
+ *
53
+ * @param args - The arguments of the command, the global options included
54
+ * @param values - The resolved values of the arguments
55
+ * @param tokens - The raw tokens of the arguments, to find a `--json` that no argument declares
56
+ * @returns How the command is asked for JSON, or `undefined` when it is not
57
+ */
58
+ function resolveJsonRequest(args, values, tokens) {
59
+ const schema = Object.hasOwn(args, "json") ? args.json : void 0;
60
+ if (schema === void 0) return tokens !== void 0 && hasJsonOption(tokens) ? "undeclared" : void 0;
61
+ if (!isJsonArg(schema)) return;
62
+ if (values.json !== void 0) return values.json === true ? "declared" : void 0;
63
+ return tokens !== void 0 && hasJsonOption(tokens) ? "declared" : void 0;
64
+ }
65
+ /**
66
+ * Check whether the raw tokens have a `--json` that asks for JSON: `--json` or `--json=true`, before
67
+ * `--`. Without a declaration, a short name of `--json` is not known.
68
+ *
69
+ * @param tokens - The raw tokens of the arguments
70
+ * @returns `true` if a `--json` asks for JSON
71
+ */
72
+ function hasJsonOption(tokens) {
73
+ for (const token of tokens) {
74
+ if (token.kind === "option-terminator") return false;
75
+ if (token.kind === "option" && token.rawName === "--json" && (token.value === void 0 || token.value === "true")) return true;
76
+ }
77
+ return false;
78
+ }
79
+ /**
80
+ * Log a message to the console.
81
+ *
82
+ * @param args - Arguments to log
83
+ */
84
+ function log(...args) {
85
+ console.log(...args);
86
+ }
87
+ /**
88
+ * @author kazuya kawaguchi (a.k.a. kazupon)
89
+ * @license MIT
90
+ */
91
+ const BUILD_IN_PREFIX_AND_KEY_SEPARATOR = `_:`;
6
92
  const COMMON_ARGS = {
7
93
  help: {
8
94
  type: "boolean",
@@ -16,12 +102,80 @@ const COMMON_ARGS = {
16
102
  }
17
103
  };
18
104
  //#endregion
105
+ //#region ../resources/locales/en-US.json
106
+ var en_US_default = {
107
+ COMMAND: "COMMAND",
108
+ COMMANDS: "COMMANDS",
109
+ SUBCOMMAND: "SUBCOMMAND",
110
+ USAGE: "USAGE",
111
+ ARGUMENTS: "ARGUMENTS",
112
+ OPTIONS: "OPTIONS",
113
+ EXAMPLES: "EXAMPLES",
114
+ FORMORE: "For more info, run any command with the `--help` flag",
115
+ NEGATABLE: "Negatable of",
116
+ DEFAULT: "default",
117
+ CHOICES: "choices",
118
+ CAUSEDBY: "Caused by",
119
+ help: "Display this help message",
120
+ version: "Display this version",
121
+ json: "Output the result as JSON",
122
+ "json:invalid-arguments": "{$count} arguments are invalid",
123
+ "json:next:help": "See the arguments of the command",
124
+ "json:next:help-placeholder": "Replace <COMMAND> with how you ran this CLI, to see the arguments of the command",
125
+ "err:arg:required-option": "Option {$displayName} is required",
126
+ "err:arg:required-positional": "Positional argument {$displayName} is required",
127
+ "err:arg:invalid-type": "Invalid value for {$displayName}: expected {$expected}",
128
+ "err:arg:invalid-choice": "Invalid value for {$displayName}: expected one of {$choices}",
129
+ "err:arg:custom-parse": "Invalid value for {$displayName}: {$reason}",
130
+ "err:arg:unknown-option": "Unknown option: {$rawName}",
131
+ "err:arg:unexpected-value": "Option {$displayName} does not take a value",
132
+ "err:arg:missing-value": "Option {$displayName} requires a value",
133
+ "err:arg:missing-value:hint": "(to pass '{$next}' as its value, write '{$suggestion}')",
134
+ "err:arg:conflict": "{$displayName} conflicts with {$conflictDisplayName}",
135
+ "err:arg:invalid-default": "Invalid default for {$displayName}: {$actual} is not one of {$choices}",
136
+ "err:cmd:not-found": "Command not found: {$commandName}",
137
+ "err:cmd:ambiguous": "The command is ambiguous. Move options after the command name: {$candidatePaths}",
138
+ "err:cmd:inconsistent-options": "The options do not identify a consistent command. Move options after the command name.",
139
+ "err:cmd:lazy-schema-mismatch": "The lazy command definition does not match its loaded options. Declare leading options in the lazy definition or move options after the command name: {$commandPath}",
140
+ "err:json:not-supported": "This command does not support --json",
141
+ "err:json:invalid-result": "The command finished, but did not return an object to output as JSON. A plugin that decorates the command may have dropped its result",
142
+ "err:json:unserializable-result": "The command finished, but its result cannot be converted to JSON: {$message}",
143
+ "err:suggestion:did-you-mean": "Did you mean {$name}?"
144
+ };
145
+ //#endregion
19
146
  //#region ../shared/src/utils.ts
20
147
  /**
21
148
  * @author kazuya kawaguchi (a.k.a. kazupon)
22
149
  * @license MIT
23
150
  */
24
151
  /**
152
+ * Resolve a namespaced key for built-in resources.
153
+ *
154
+ * Built-in keys are prefixed with "_:".
155
+ *
156
+ * @typeParam K - The type of the built-in key to resolve. Defaults to command built-in argument and resource keys.
157
+ *
158
+ * @param key - The built-in key to resolve.
159
+ * @returns Prefixed built-in key.
160
+ */
161
+ function resolveBuiltInKey(key) {
162
+ return `_:${key}`;
163
+ }
164
+ /**
165
+ * Resolve a namespaced key for non-built-in resources.
166
+ *
167
+ * Non-built-in keys are not prefixed with any special characters. If the command name is provided, it will be prefixed with the command name (e.g. "cmd1:foo").
168
+ *
169
+ * @typeParam T - The type of the non-built-in key to resolve. Defaults to string.
170
+ *
171
+ * @param key - The non-built-in key to resolve.
172
+ * @param name - The command name.
173
+ * @returns Prefixed non-built-in key.
174
+ */
175
+ function resolveKey(key, name) {
176
+ return `${name ? `${name}:` : ""}${key}`;
177
+ }
178
+ /**
25
179
  * Generate a namespaced key for a plugin.
26
180
  *
27
181
  * @typeParam K - The type of the plugin id to generate a namespaced key for.
@@ -32,12 +186,128 @@ const COMMON_ARGS = {
32
186
  function namespacedId(id) {
33
187
  return `g:${id}`;
34
188
  }
189
+ /**
190
+ * Resolve the name that an argument is parsed and rendered under.
191
+ *
192
+ * @param name - The key of the argument.
193
+ * @param schema - The {@linkcode ArgSchema | argument schema}.
194
+ * @param toKebab - Whether to convert the name to kebab-case.
195
+ * @returns The name of the argument, kebab-cased when `toKebab` asks for it.
196
+ */
197
+ function resolveDisplayName(name, schema, toKebab) {
198
+ return toKebab || schema.toKebab ? kebabnize(name) : name;
199
+ }
35
200
  //#endregion
36
201
  //#region ../shared/src/localization.ts
37
202
  /**
38
203
  * @author kazuya kawaguchi (a.k.a. kazupon)
39
204
  * @license MIT
40
205
  */
206
+ /**
207
+ * Format a resource message by replacing placeholder values.
208
+ *
209
+ * @param resource - Resource message template
210
+ * @param values - Placeholder values
211
+ * @returns Formatted resource message
212
+ */
213
+ function formatResource(resource, values = Object.create(null)) {
214
+ return resource?.replaceAll(/\{\$(\w+)\}/g, (_, name) => {
215
+ return values[name] == null ? "" : String(values[name]);
216
+ });
217
+ }
218
+ //#endregion
219
+ //#region ../shared/src/validation.ts
220
+ /**
221
+ * @author kazuya kawaguchi (a.k.a. kazupon)
222
+ * @license MIT
223
+ */
224
+ const i18nPluginId = namespacedId("i18n");
225
+ const missingValueHintKey = "err:arg:missing-value:hint";
226
+ /**
227
+ * Localize the message of a validation error, as gunshi shows it to the user: with the i18n plugin when
228
+ * it is installed, and otherwise the message of the error.
229
+ *
230
+ * NOTE(kazupon): the renderer plugin renders the validation errors for people, and the global options
231
+ * plugin prints them as JSON with `--json`. Both take the messages from here, so that a message is the
232
+ * same in both.
233
+ *
234
+ * @param ctx - A {@link CommandContext | command context}
235
+ * @param error - A validation error: an {@link ArgsValidationError}, a command-not-found error, or a
236
+ * command-resolution error
237
+ * @returns The localized message
238
+ */
239
+ async function localizeValidationError(ctx, error) {
240
+ if (isCommandNotFoundError(error) && error.code) {
241
+ const message = await localize(ctx, error.code, error.values);
242
+ if (message && message !== error.code) return message;
243
+ }
244
+ if (isCommandResolutionError(error) && error.code) {
245
+ const message = await localize(ctx, error.code, error.values);
246
+ if (message && message !== error.code) return message;
247
+ }
248
+ if (isArgsValidationError(error) && error.code) {
249
+ const values = await resolveValidationValues(ctx, error);
250
+ const message = await localize(ctx, error.code, values);
251
+ if (message && message !== error.code) {
252
+ const hint = await localizeHint(ctx, error, values);
253
+ return hint ? `${message} ${hint}` : message;
254
+ }
255
+ }
256
+ return error.message;
257
+ }
258
+ /**
259
+ * Localize a text of the built-in resources: with the i18n plugin when it is installed and knows the key,
260
+ * and otherwise the English one.
261
+ *
262
+ * @param ctx - A {@link CommandContext | command context}
263
+ * @param key - The key of the text: `_:<key>` for a text that is not an error, and the key itself for an
264
+ * error, such as `err:json:not-supported`
265
+ * @param values - The values of the placeholders of the text
266
+ * @returns The localized text, or the key when no resource has it
267
+ */
268
+ async function localizeBuiltinResource(ctx, key, values) {
269
+ const message = await localize(ctx, key, values);
270
+ if (message && message !== key) return message;
271
+ return formatResource(en_US_default[key.startsWith(BUILD_IN_PREFIX_AND_KEY_SEPARATOR) ? key.slice(BUILD_IN_PREFIX_AND_KEY_SEPARATOR.length) : key], values) || key;
272
+ }
273
+ /**
274
+ * Localize the hint that args-tokens adds to the message of an error.
275
+ *
276
+ * A translation replaces the whole message, hint included, so the hint is translated by a key of
277
+ * its own and follows the translated error. args-tokens gives one today: for an option given without
278
+ * a value, when the argument after it may be the value, the long form with `=` (`--port=-5` for
279
+ * `--port -5`), in `values.next` and `values.suggestion`.
280
+ *
281
+ * @param ctx - A {@link CommandContext | command context}
282
+ * @param error - An args validation error
283
+ * @param values - The values of the error, as given to its translation
284
+ * @returns The localized hint, or `undefined` when the error has none.
285
+ */
286
+ async function localizeHint(ctx, error, values) {
287
+ if (error.code !== ArgsValidationErrorKeys.missingValue || typeof values.next !== "string" || typeof values.suggestion !== "string") return;
288
+ const hint = await localize(ctx, missingValueHintKey, values);
289
+ return hint && hint !== missingValueHintKey ? hint : void 0;
290
+ }
291
+ async function resolveValidationValues(ctx, error) {
292
+ if (error.code !== ArgsValidationErrorKeys.customParse) return error.values;
293
+ const { reasonKey, reasonValues } = error.values;
294
+ if (typeof reasonKey !== "string") return error.values;
295
+ const key = resolveKey(reasonKey, ctx.name);
296
+ const localizedReason = await localize(ctx, key, toRecord(reasonValues));
297
+ if (!localizedReason || localizedReason === key) return error.values;
298
+ return {
299
+ ...error.values,
300
+ reason: localizedReason
301
+ };
302
+ }
303
+ async function localize(ctx, key, values) {
304
+ const i18n = ctx.extensions?.[i18nPluginId];
305
+ if (i18n?.translate) return i18n.translate(key, values);
306
+ return key;
307
+ }
308
+ function toRecord(value) {
309
+ return value != null && typeof value === "object" ? value : Object.create(null);
310
+ }
41
311
  //#endregion
42
312
  //#region ../shared/src/index.ts
43
313
  /**
@@ -55,18 +325,321 @@ function namespacedId(id) {
55
325
  */
56
326
  const pluginId = namespacedId("global");
57
327
  //#endregion
328
+ //#region src/extension.ts
329
+ /**
330
+ * @author kazuya kawaguchi (a.k.a. kazupon)
331
+ * @license MIT
332
+ */
333
+ /**
334
+ * Print the version of the application.
335
+ *
336
+ * @param ctx - The command context
337
+ * @param write - The function to print with
338
+ * @returns The version of the application, or `unknown` if the version is not specified
339
+ */
340
+ function writeVersion(ctx, write) {
341
+ const version = ctx.env.version || "unknown";
342
+ write(version);
343
+ return version;
344
+ }
345
+ /**
346
+ * Print the usage of the application, with the header as its title.
347
+ *
348
+ * NOTE(kazupon): the header is the title of the usage, so it comes with the usage, wherever the usage is
349
+ * shown: for `--help`, and when a command that takes `--help` itself shows it. It is not shown before a
350
+ * command runs, where it would come before what the command prints, such as JSON.
351
+ *
352
+ * @param ctx - The command context
353
+ * @param write - The function to print with
354
+ * @returns The header and the usage of the application, or `undefined` if the `renderUsage` is not specified
355
+ */
356
+ async function writeUsage(ctx, write) {
357
+ if (ctx.env.renderUsage == null) return;
358
+ const usage = await ctx.env.renderUsage(ctx);
359
+ if (!usage) return;
360
+ const header = ctx.env.renderHeader == null ? void 0 : await ctx.env.renderHeader(ctx);
361
+ if (header) {
362
+ write(header);
363
+ write();
364
+ }
365
+ write(usage);
366
+ return header ? [header, usage].join("\n") : usage;
367
+ }
368
+ /**
369
+ * Extends the command context for global options plugin.
370
+ *
371
+ * @param ctx - The {@linkcode CommandContextCore | command context} core
372
+ * @returns An {@linkcode GlobalExtension} of global options plugin
373
+ */
374
+ function extension(ctx) {
375
+ return {
376
+ showVersion: () => writeVersion(ctx, ctx.log),
377
+ showHeader: async () => {
378
+ let header;
379
+ if (ctx.env.renderHeader != null) {
380
+ header = await ctx.env.renderHeader(ctx);
381
+ if (header) {
382
+ ctx.log(header);
383
+ ctx.log();
384
+ }
385
+ }
386
+ return header;
387
+ },
388
+ showUsage: async () => await writeUsage(ctx, ctx.log),
389
+ showValidationErrors: async (error) => {
390
+ if (ctx.env.renderValidationErrors === null) return;
391
+ if (ctx.env.renderValidationErrors !== void 0) {
392
+ const message = await ctx.env.renderValidationErrors(ctx, error);
393
+ /**
394
+ * NOTE(kazupon): the validation errors are diagnostics, not the output of the command, so they
395
+ * go to stderr, as the other CLI libraries do. stdout stays for what a command prints, such as JSON.
396
+ */
397
+ if (message && !ctx.env.usageSilent) console.error(message);
398
+ return message;
399
+ }
400
+ }
401
+ };
402
+ }
403
+ //#endregion
404
+ //#region src/json.ts
405
+ /**
406
+ * @author kazuya kawaguchi (a.k.a. kazupon)
407
+ * @license MIT
408
+ */
409
+ /**
410
+ * The reasons that are not derived from the codes by the rule: they would read as something else.
411
+ */
412
+ const RENAMED_REASONS = /* @__PURE__ */ new Map([
413
+ [ArgsValidationErrorKeys.conflict, "conflicting_options"],
414
+ [CommandNotFoundErrorKeys.notFound, "unknown_command"],
415
+ [CommandResolutionErrorKeys.ambiguous, "ambiguous_command"],
416
+ [JsonOutputErrorKeys.notSupported, "json_not_supported"]
417
+ ]);
418
+ /**
419
+ * Convert the code of an error of gunshi to the `reason` of a JSON error: without the prefix, in
420
+ * snake_case, such as `required_option` for `err:arg:required-option`. So the reasons are a contract
421
+ * apart from the keys of the resources.
422
+ *
423
+ * @param code - The code of the error
424
+ * @returns The reason
425
+ */
426
+ function toReason(code) {
427
+ return RENAMED_REASONS.get(code) ?? code.replace(/^err:[^:]+:/, "").replaceAll("-", "_");
428
+ }
429
+ /**
430
+ * Create the JSON error of the validation errors: the argument errors, an unknown command, and a command
431
+ * that cannot be resolved. They stop the command before it runs.
432
+ *
433
+ * @param ctx - The command context
434
+ * @param error - The aggregate error of the validation errors
435
+ * @returns The JSON error
436
+ */
437
+ async function createValidationFailure(ctx, error) {
438
+ const errors = error.errors;
439
+ const issues = [];
440
+ for (const err of errors) issues.push(await createIssue(ctx, err));
441
+ const [single] = issues.length === 1 ? issues : [];
442
+ return {
443
+ status: "action_required",
444
+ reason: single ? single.reason : "invalid_arguments",
445
+ message: single ? single.message : await localizeBuiltinResource(ctx, resolveBuiltInKey("json:invalid-arguments"), { count: issues.length }),
446
+ next: await createNext(ctx, resolveCommandPath(ctx, errors)),
447
+ data: { issues }
448
+ };
449
+ }
450
+ /**
451
+ * Create the JSON error of `--json` given to a command that does not spread `jsonArgs`.
452
+ *
453
+ * @param ctx - The command context
454
+ * @returns The JSON error
455
+ */
456
+ async function createNotSupportedFailure(ctx) {
457
+ return {
458
+ status: "action_required",
459
+ reason: toReason(JsonOutputErrorKeys.notSupported),
460
+ message: await localizeBuiltinResource(ctx, JsonOutputErrorKeys.notSupported),
461
+ next: await createNext(ctx, ctx.commandPath)
462
+ };
463
+ }
464
+ /**
465
+ * Create the JSON error of a result that cannot be printed as JSON.
466
+ *
467
+ * @param ctx - The command context
468
+ * @param code - The code of the error
469
+ * @param values - The values of the message
470
+ * @returns The JSON error
471
+ */
472
+ async function createResultFailure(ctx, code, values) {
473
+ return {
474
+ status: "error",
475
+ reason: toReason(code),
476
+ message: await localizeBuiltinResource(ctx, code, values)
477
+ };
478
+ }
479
+ /**
480
+ * Create the JSON error of an error that the command threw. The `code` of a `CommandError` is its
481
+ * reason, as it is: it is a contract of the app. Any other error is `command_failed`, even with a
482
+ * `code`, such as `ENOENT`, that the app does not promise.
483
+ *
484
+ * @param error - The thrown error
485
+ * @returns The JSON error
486
+ */
487
+ function createRunFailure(error) {
488
+ const causes = collectCauses(error);
489
+ let message;
490
+ try {
491
+ message = messageOf(error);
492
+ } catch {
493
+ message = Object.prototype.toString.call(error);
494
+ }
495
+ return {
496
+ status: "error",
497
+ reason: (isCommandError(error) ? error.code : void 0) ?? "command_failed",
498
+ message,
499
+ ...causes.length > 0 ? { data: { causes } } : {}
500
+ };
501
+ }
502
+ /**
503
+ * Collect the messages of the causes of an error, from the outermost.
504
+ *
505
+ * A cause may be any value: a value without a message, such as a string, is converted to a string. A
506
+ * cause may point back to an error in the chain, and may have a getter that throws: the chain ends
507
+ * there.
508
+ *
509
+ * @param error - The error
510
+ * @returns The messages of the causes
511
+ */
512
+ function collectCauses(error) {
513
+ const causes = [];
514
+ const seen = /* @__PURE__ */ new Set([error]);
515
+ let cause;
516
+ try {
517
+ cause = typeof error === "object" && error !== null ? error.cause : void 0;
518
+ } catch {
519
+ return causes;
520
+ }
521
+ while (cause != null && !seen.has(cause)) {
522
+ seen.add(cause);
523
+ try {
524
+ causes.push(messageOf(cause));
525
+ cause = typeof cause === "object" ? cause.cause : void 0;
526
+ } catch {
527
+ break;
528
+ }
529
+ }
530
+ return causes;
531
+ }
532
+ /**
533
+ * Get the message of an error, or of a cause, which may be any value. A value without a message, such as
534
+ * a string, is converted to a string.
535
+ *
536
+ * @param value - The error or the cause
537
+ * @returns The message
538
+ * @throws {unknown} The error of a getter of `message` that throws
539
+ */
540
+ function messageOf(value) {
541
+ const message = value?.message;
542
+ if (typeof message === "string" && message !== "") return message;
543
+ try {
544
+ return String(value);
545
+ } catch {
546
+ return Object.prototype.toString.call(value);
547
+ }
548
+ }
549
+ async function createIssue(ctx, error) {
550
+ const field = resolveField(ctx, error);
551
+ const reason = resolveReason(error);
552
+ const message = await localizeValidationError(ctx, error);
553
+ return field === void 0 ? {
554
+ reason,
555
+ message
556
+ } : {
557
+ field,
558
+ reason,
559
+ message
560
+ };
561
+ }
562
+ function resolveReason(error) {
563
+ if (isCommandNotFoundError(error)) return toReason(error.code ?? CommandNotFoundErrorKeys.notFound);
564
+ const code = error.code;
565
+ return typeof code === "string" ? toReason(code) : "invalid_arguments";
566
+ }
567
+ /**
568
+ * Resolve how the user wrote the argument of an error on the command line: the long form of an option,
569
+ * the name of a positional argument, an unknown option as it is typed, and an unknown command as it is
570
+ * typed. So an agent can use it as it is.
571
+ *
572
+ * @param ctx - The command context
573
+ * @param error - A validation error
574
+ * @returns The field, or `undefined` for an error that no argument caused
575
+ */
576
+ function resolveField(ctx, error) {
577
+ if (isCommandNotFoundError(error)) return error.commandName;
578
+ if (!isArgsValidationError(error)) return;
579
+ const { name, rawName } = error.values;
580
+ if (error.code === ArgsValidationErrorKeys.unknownOption) return typeof rawName === "string" ? rawName : void 0;
581
+ if (typeof name !== "string" || !Object.hasOwn(ctx.args, name)) return;
582
+ const schema = ctx.args[name];
583
+ return schema.type === "positional" ? name : `--${resolveDisplayName(name, schema, ctx.toKebab)}`;
584
+ }
585
+ /**
586
+ * Resolve the path of the command whose help `next` points to: the command that is nearest to what the
587
+ * user wrote. For an unknown command, and for a command that cannot be resolved, it is the command
588
+ * under which the resolution failed.
589
+ *
590
+ * @param ctx - The command context
591
+ * @param errors - The validation errors
592
+ * @returns The command path
593
+ */
594
+ function resolveCommandPath(ctx, errors) {
595
+ for (const error of errors) if (isCommandNotFoundError(error) || isCommandResolutionError(error)) return error.commandPath;
596
+ return ctx.commandPath;
597
+ }
598
+ /**
599
+ * Create the `next` of a JSON error: the help of a command. Without the name of the CLI, the command
600
+ * line has `<COMMAND>` for its name, and `when` tells to replace it, since the agent knows how it ran
601
+ * the CLI.
602
+ *
603
+ * @param ctx - The command context
604
+ * @param commandPath - The path of the command
605
+ * @returns The `next`
606
+ */
607
+ async function createNext(ctx, commandPath) {
608
+ const name = ctx.env.name;
609
+ const argv = [
610
+ name || "<COMMAND>",
611
+ ...commandPath,
612
+ "--help"
613
+ ];
614
+ const when = await localizeBuiltinResource(ctx, resolveBuiltInKey(name ? "json:next:help" : "json:next:help-placeholder"));
615
+ return [{
616
+ command: argv.join(" "),
617
+ argv,
618
+ when
619
+ }];
620
+ }
621
+ //#endregion
58
622
  //#region src/decorator.ts
59
623
  /**
60
624
  * @author kazuya kawaguchi (a.k.a. kazupon)
61
625
  * @license MIT
62
626
  */
63
627
  /**
628
+ * The id of the i18n plugin, which translates the heading of the causes of a {@linkcode CommandError}.
629
+ */
630
+ const I18n = namespacedId("i18n");
631
+ /**
632
+ * The key of the built-in resource for the heading of the causes of a {@linkcode CommandError}.
633
+ */
634
+ const CAUSED_BY = resolveBuiltInKey("CAUSEDBY");
635
+ /**
64
636
  * The key of the registry in which the core keeps, for the command context of each run, the set of the
65
637
  * errors that have been reported to the user. An error in the set tells `cli()` to end without
66
638
  * rejecting. The errors are frozen with the command context, so they are not marked themselves. The key
67
639
  * is a registered symbol, since `gunshi` and the plugins bundle their own copies.
68
640
  */
69
641
  const REPORTED = Symbol.for("gunshi.reported");
642
+ const NOOP = () => {};
70
643
  /**
71
644
  * Mark an error as reported to the user, in the set that the core keeps for the run of the context.
72
645
  *
@@ -90,6 +663,98 @@ function setExitCode(code) {
90
663
  const proc = globalThis.process;
91
664
  if (proc != null && typeof proc === "object") proc.exitCode = code;
92
665
  }
666
+ function getStdout() {
667
+ const stdout = globalThis.process?.stdout;
668
+ return stdout != null && typeof stdout === "object" ? stdout : void 0;
669
+ }
670
+ /**
671
+ * Wait until stdout has written what it holds, when it holds something: it drains, fails, or closes.
672
+ *
673
+ * NOTE(kazupon): Node.js writes to a pipe later, and `process.exit()` does not wait for it, so a large
674
+ * JSON document is cut at 64KB or so when the app calls `process.exit()` after `cli()`. The wait has no
675
+ * limit: a limit would cut the document only when the reader is slow. It does not keep the process
676
+ * alive any longer than the write does.
677
+ */
678
+ async function flushStdout() {
679
+ const stdout = getStdout();
680
+ if (stdout === void 0 || typeof stdout.writableLength !== "number" || stdout.writableLength === 0 || typeof stdout.once !== "function") return;
681
+ const once = stdout.once.bind(stdout);
682
+ await new Promise((resolve) => {
683
+ const done = () => {
684
+ for (const event of [
685
+ "drain",
686
+ "error",
687
+ "close"
688
+ ]) stdout.removeListener?.(event, done);
689
+ resolve();
690
+ };
691
+ for (const event of [
692
+ "drain",
693
+ "error",
694
+ "close"
695
+ ]) once(event, done);
696
+ });
697
+ }
698
+ /**
699
+ * Translate the heading of the causes of a {@linkcode CommandError}, with the i18n plugin when it is
700
+ * installed.
701
+ *
702
+ * NOTE(kazupon): the i18n plugin depends on this plugin, so this plugin cannot depend on it, and reads
703
+ * its extension as the renderer plugin does. The i18n plugin translates a built-in key that the
704
+ * resources of the app lack in English, and an older one returns the key itself: then the heading is
705
+ * the English one of this plugin.
706
+ *
707
+ * @param extensions - The extensions of the command context
708
+ * @returns The heading
709
+ */
710
+ function translateCausedBy(extensions) {
711
+ const heading = extensions[I18n]?.translate?.(CAUSED_BY);
712
+ return heading && heading !== CAUSED_BY ? heading : en_US_default.CAUSEDBY;
713
+ }
714
+ /**
715
+ * Render a {@linkcode CommandError} for the user: its message, followed by a `Caused by:` line for each
716
+ * cause in the chain. The stack traces and the code are not rendered.
717
+ *
718
+ * @param error - The error
719
+ * @param extensions - The extensions of the command context, to translate the heading of the causes
720
+ * @returns The rendered error
721
+ */
722
+ function renderCommandError(error, extensions) {
723
+ const causes = collectCauses(error);
724
+ if (causes.length === 0) return error.message;
725
+ const heading = translateCausedBy(extensions);
726
+ return [error.message, ...causes.map((cause) => `${heading}: ${cause}`)].join("\n");
727
+ }
728
+ /**
729
+ * Create a {@linkcode JsonOutputError}, with the English message.
730
+ *
731
+ * @param code - The code of the error
732
+ * @param values - The values of the message
733
+ * @param cause - The cause of the error
734
+ * @returns The error
735
+ */
736
+ function createJsonOutputError(code, values, cause) {
737
+ const message = formatResource(en_US_default[code], values) || code;
738
+ return new JsonOutputError(message, {
739
+ code,
740
+ values,
741
+ cause
742
+ });
743
+ }
744
+ /**
745
+ * Leave out the unknown-option errors of a `--json` that asks for JSON, which `strict` reports for a
746
+ * command without a `json` argument. `--json` is the option of gunshi, so it is not unknown to it: the
747
+ * plugin reports `json_not_supported` for it instead, with or without `strict`.
748
+ *
749
+ * @param error - The aggregate error of the validation errors
750
+ * @returns The aggregate error without them, or `undefined` when no error is left
751
+ */
752
+ function withoutJsonOption(error) {
753
+ if (error === void 0) return;
754
+ const errors = error.errors.filter((err) => !(isArgsValidationError(err) && err.code === ArgsValidationErrorKeys.unknownOption && err.values.rawName === "--json"));
755
+ if (errors.length === error.errors.length) return error;
756
+ return errors.length === 0 ? void 0 : new AggregateError(errors, error.message);
757
+ }
93
758
  /**
94
759
  * Decorator function to extend the command with global options.
95
760
  *
@@ -102,7 +767,26 @@ const decorator = (baseRunner) => async (ctx) => {
102
767
  * declares the same name — even with an identical schema — is not in that map, so `--version` /
103
768
  * `--help` belong to the command.
104
769
  */
105
- const { values, validationError, env: { globalOptions }, extensions: { [pluginId]: { showVersion, showUsage, showValidationErrors } } } = ctx;
770
+ const { values, env: { globalOptions }, extensions: { [pluginId]: { showValidationErrors } } } = ctx;
771
+ /**
772
+ * NOTE(kazupon): whether `--json` asks for JSON. `'declared'` for a command that spreads `jsonArgs`,
773
+ * by the resolved value of its `json`, and `'undeclared'` for a command without a `json` argument, by
774
+ * the raw tokens. Either way, the errors are printed as JSON. A command with a `json` of its own
775
+ * handles `--json` itself.
776
+ */
777
+ const request = resolveJsonRequest(ctx.args, values, ctx.tokens);
778
+ const validationError = request === void 0 ? ctx.validationError : withoutJsonOption(ctx.validationError);
779
+ /**
780
+ * Print a JSON error to stderr, and mark the error as reported, with the exit code.
781
+ *
782
+ * @param error - The error
783
+ * @param failure - The JSON error
784
+ * @param exitCode - The exit code
785
+ */
786
+ const printFailure = (error, failure, exitCode) => {
787
+ console.error(JSON.stringify(failure));
788
+ if (typeof error === "object" && error !== null && markReported(ctx, error)) setExitCode(exitCode);
789
+ };
106
790
  /**
107
791
  * NOTE(kazupon): when the plugin has shown the validation errors to the user, they have been
108
792
  * reported: the exit code is 1, and `cli()` ends without rejecting, as the other CLI libraries end
@@ -111,79 +795,92 @@ const decorator = (baseRunner) => async (ctx) => {
111
795
  * rejects with them, for the app to report them. So it does when the core keeps no set of reported
112
796
  * errors for the run, and then the exit code is left alone.
113
797
  *
798
+ * With `--json`, they are printed as JSON, and the renderer of the validation errors is not called.
799
+ * `renderValidationErrors: null` and `usageSilent` still leave them to the app.
800
+ *
114
801
  * @param error - The aggregate error of the validation errors
802
+ * @param createFailure - The function that creates the JSON error, for an error that is not one of
803
+ * the validation errors
115
804
  */
116
- const report = async (error) => {
805
+ const report = async (error, createFailure = () => createValidationFailure(ctx, error)) => {
806
+ if (request !== void 0) {
807
+ if (ctx.env.renderValidationErrors !== null && !ctx.env.usageSilent) printFailure(error, await createFailure(), 1);
808
+ throw error;
809
+ }
117
810
  if (await showValidationErrors(error) && !ctx.env.usageSilent && markReported(ctx, error)) setExitCode(1);
118
811
  throw error;
119
812
  };
120
813
  if (hasPriorityValidationError(validationError)) return await report(validationError);
121
- if (values.version && globalOptions?.has("version")) return showVersion();
122
- if (values.help && globalOptions?.has("help")) return await showUsage();
814
+ /**
815
+ * NOTE(kazupon): `--help` and `--version` print with `log`, not with `ctx.log`, which is silenced when
816
+ * the command is asked for JSON: the text of the help is what was asked for. `usageSilent` still
817
+ * silences them.
818
+ */
819
+ const write = ctx.env.usageSilent ? NOOP : log;
820
+ if (values.version && globalOptions?.has("version")) return writeVersion(ctx, write);
821
+ if (values.help && globalOptions?.has("help")) return await writeUsage(ctx, write);
822
+ /**
823
+ * NOTE(kazupon): `--json` given to a command that does not spread `jsonArgs` stops it before it runs,
824
+ * as an argument error does, so that the caller does not take its text for JSON.
825
+ */
826
+ if (request === "undeclared") return await report(new AggregateError([createJsonOutputError(JsonOutputErrorKeys.notSupported)]), () => createNotSupportedFailure(ctx));
123
827
  if (validationError) return await report(validationError);
124
- return baseRunner(ctx);
125
- };
126
- //#endregion
127
- //#region src/extension.ts
128
- /**
129
- * @author kazuya kawaguchi (a.k.a. kazupon)
130
- * @license MIT
131
- */
132
- /**
133
- * Extends the command context for global options plugin.
134
- *
135
- * @param ctx - The {@linkcode CommandContextCore | command context} core
136
- * @returns An {@linkcode GlobalExtension} of global options plugin
137
- */
138
- function extension(ctx) {
139
- return {
140
- showVersion: () => {
141
- const version = ctx.env.version || "unknown";
142
- if (!ctx.env.usageSilent) ctx.log(version);
143
- return version;
144
- },
145
- showHeader: async () => {
146
- let header;
147
- if (ctx.env.renderHeader != null) {
148
- header = await ctx.env.renderHeader(ctx);
149
- if (header) {
150
- ctx.log(header);
151
- ctx.log();
152
- }
153
- }
154
- return header;
155
- },
156
- showUsage: async () => {
157
- if (ctx.env.renderUsage == null) return;
158
- const usage = await ctx.env.renderUsage(ctx);
159
- if (!usage) return;
828
+ let result;
829
+ try {
830
+ result = await baseRunner(ctx);
831
+ } catch (error) {
832
+ if (request === "declared")
833
+ /**
834
+ * NOTE(kazupon): with `--json`, any error of the command is printed as JSON, so that the caller
835
+ * always gets JSON: the `code` of a `CommandError` is the reason, with the exit code of the error,
836
+ * and any other error is `command_failed`, with exit code 1. It is printed with `usageSilent` too,
837
+ * since it is not a text for people.
838
+ */
839
+ printFailure(error, createRunFailure(error), isCommandError(error) ? error.exitCode : 1);
840
+ else if (isCommandError(error) && !ctx.env.usageSilent) {
160
841
  /**
161
- * NOTE(kazupon): the header is the title of the usage, so it comes with the usage, wherever the
162
- * usage is shown: for `--help`, and when a command that takes `--help` itself shows it. It is not
163
- * shown before a command runs, where it would come before what the command prints, such as JSON.
842
+ * NOTE(kazupon): a `CommandError` thrown from the command, or from a decorator of another plugin,
843
+ * is a failure that the command expects. It is reported as the validation errors are: the message
844
+ * is printed to stderr without a stack trace, the exit code is the one of the error, and `cli()`
845
+ * ends without rejecting. It is still thrown, so that the `onErrorCommand` hook sees it after the
846
+ * exit code is set. With `usageSilent`, nothing is printed, and `cli()` rejects with it, for the
847
+ * app to report it. Any other error is thrown as before, and `cli()` rejects with it.
164
848
  */
165
- const header = ctx.env.renderHeader == null ? void 0 : await ctx.env.renderHeader(ctx);
166
- if (header) {
167
- ctx.log(header);
168
- ctx.log();
169
- }
170
- ctx.log(usage);
171
- return header ? [header, usage].join("\n") : usage;
172
- },
173
- showValidationErrors: async (error) => {
174
- if (ctx.env.renderValidationErrors === null) return;
175
- if (ctx.env.renderValidationErrors !== void 0) {
176
- const message = await ctx.env.renderValidationErrors(ctx, error);
177
- /**
178
- * NOTE(kazupon): the validation errors are diagnostics, not the output of the command, so they
179
- * go to stderr, as the other CLI libraries do. stdout stays for what a command prints, such as JSON.
180
- */
181
- if (message && !ctx.env.usageSilent) console.error(message);
182
- return message;
183
- }
849
+ console.error(renderCommandError(error, ctx.extensions));
850
+ if (markReported(ctx, error)) setExitCode(error.exitCode);
184
851
  }
852
+ throw error;
853
+ }
854
+ if (request !== "declared") return result;
855
+ /**
856
+ * Report a result that cannot be printed as JSON, and throw the error. The command has finished, so the
857
+ * message says so.
858
+ *
859
+ * @param code - The code of the error
860
+ * @param messageValues - The values of the message
861
+ * @param cause - The cause of the error
862
+ */
863
+ const fail = async (code, messageValues, cause) => {
864
+ const error = createJsonOutputError(code, messageValues, cause);
865
+ printFailure(error, await createResultFailure(ctx, code, messageValues), 1);
866
+ throw error;
185
867
  };
186
- }
868
+ /**
869
+ * NOTE(kazupon): the result is printed as one JSON document to stdout: indented on a terminal, on one
870
+ * line otherwise. It is checked first, for the code written in JavaScript and the types cast away: it
871
+ * must be an object, which `JSON.stringify` turns into an object.
872
+ */
873
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return await fail(JsonOutputErrorKeys.invalidResult);
874
+ let text;
875
+ try {
876
+ text = JSON.stringify(result, null, getStdout()?.isTTY === true ? 2 : void 0);
877
+ } catch (error) {
878
+ return await fail(JsonOutputErrorKeys.unserializableResult, { message: messageOf(error) }, error);
879
+ }
880
+ if (typeof text !== "string" || !text.startsWith("{")) return await fail(JsonOutputErrorKeys.invalidResult);
881
+ log(text);
882
+ await flushStdout();
883
+ };
187
884
  //#endregion
188
885
  //#region src/index.ts
189
886
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gunshi/plugin-global",
3
3
  "description": "global options plugin for gunshi",
4
- "version": "1.0.0-rc.5",
4
+ "version": "1.0.0-rc.6",
5
5
  "author": {
6
6
  "name": "kazuya kawaguchi",
7
7
  "email": "kawakazu80@gmail.com"
@@ -51,10 +51,10 @@
51
51
  }
52
52
  },
53
53
  "dependencies": {
54
- "@gunshi/plugin": "1.0.0-rc.5"
54
+ "@gunshi/plugin": "1.0.0-rc.6"
55
55
  },
56
56
  "devDependencies": {
57
- "@gunshi/shared": "1.0.0-rc.5",
57
+ "@gunshi/shared": "1.0.0-rc.6",
58
58
  "publint": "^0.3.20",
59
59
  "tsdown": "0.22.14"
60
60
  },