@flowscripter/dynamic-cli-framework 12.0.1 → 12.2.0

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.
Files changed (80) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/src/cli/BaseCLI.d.ts.map +1 -1
  5. package/dist/src/cli/BaseCLI.js +3 -1
  6. package/dist/src/cli/BaseCLIFeatureOptions.d.ts +6 -0
  7. package/dist/src/cli/BaseCLIFeatureOptions.d.ts.map +1 -1
  8. package/dist/src/runtime/runner.d.ts.map +1 -1
  9. package/dist/src/runtime/runner.js +4 -1
  10. package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts +33 -15
  11. package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts.map +1 -1
  12. package/dist/src/service/configuration/ConfigurationServiceProvider.js +80 -173
  13. package/dist/src/service/configuration/DefaultConfigurationService.d.ts +78 -0
  14. package/dist/src/service/configuration/DefaultConfigurationService.d.ts.map +1 -0
  15. package/dist/src/service/configuration/DefaultConfigurationService.js +296 -0
  16. package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
  17. package/dist/src/service/configuration/DefaultKeyValueService.js +7 -3
  18. package/dist/src/service/configuration/DefaultSecretService.d.ts +17 -2
  19. package/dist/src/service/configuration/DefaultSecretService.d.ts.map +1 -1
  20. package/dist/src/service/configuration/DefaultSecretService.js +44 -5
  21. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts +3 -1
  22. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -1
  23. package/dist/src/service/configuration/KeyValueServiceProvider.js +6 -3
  24. package/dist/src/service/configuration/command/ConfigDumpSubCommand.d.ts +3 -7
  25. package/dist/src/service/configuration/command/ConfigDumpSubCommand.d.ts.map +1 -1
  26. package/dist/src/service/configuration/command/ConfigDumpSubCommand.js +5 -10
  27. package/dist/src/service/configuration/command/ConfigGetSubCommand.d.ts +16 -0
  28. package/dist/src/service/configuration/command/ConfigGetSubCommand.d.ts.map +1 -0
  29. package/dist/src/service/configuration/command/ConfigGetSubCommand.js +137 -0
  30. package/dist/src/service/configuration/command/ConfigSetSubCommand.d.ts +18 -0
  31. package/dist/src/service/configuration/command/ConfigSetSubCommand.d.ts.map +1 -0
  32. package/dist/src/service/configuration/command/ConfigSetSubCommand.js +289 -0
  33. package/dist/src/service/configuration/command/ConfigUnsetSubCommand.d.ts +15 -0
  34. package/dist/src/service/configuration/command/ConfigUnsetSubCommand.d.ts.map +1 -0
  35. package/dist/src/service/configuration/command/ConfigUnsetSubCommand.js +101 -0
  36. package/dist/src/service/configuration/configTargets.d.ts +91 -0
  37. package/dist/src/service/configuration/configTargets.d.ts.map +1 -0
  38. package/dist/src/service/configuration/configTargets.js +348 -0
  39. package/dist/src/service/configuration/format/ConfigFormat.d.ts +42 -0
  40. package/dist/src/service/configuration/format/ConfigFormat.d.ts.map +1 -0
  41. package/dist/src/service/configuration/format/ConfigFormat.js +0 -0
  42. package/dist/src/service/configuration/format/JsonConfigFormat.d.ts +7 -0
  43. package/dist/src/service/configuration/format/JsonConfigFormat.d.ts.map +1 -0
  44. package/dist/src/service/configuration/format/JsonConfigFormat.js +17 -0
  45. package/dist/src/service/configuration/format/TomlConfigFormat.d.ts +10 -0
  46. package/dist/src/service/configuration/format/TomlConfigFormat.d.ts.map +1 -0
  47. package/dist/src/service/configuration/format/TomlConfigFormat.js +78 -0
  48. package/dist/src/service/configuration/format/YamlConfigFormat.d.ts +9 -0
  49. package/dist/src/service/configuration/format/YamlConfigFormat.d.ts.map +1 -0
  50. package/dist/src/service/configuration/format/YamlConfigFormat.js +63 -0
  51. package/dist/src/service/configuration/resolveSecrets.d.ts.map +1 -1
  52. package/dist/src/service/configuration/resolveSecrets.js +7 -1
  53. package/dist/toml.d.ts +2 -0
  54. package/dist/toml.d.ts.map +1 -0
  55. package/dist/toml.js +1 -0
  56. package/dist/yaml.d.ts +2 -0
  57. package/dist/yaml.d.ts.map +1 -0
  58. package/dist/yaml.js +1 -0
  59. package/index.ts +3 -0
  60. package/package.json +27 -3
  61. package/src/cli/BaseCLI.ts +4 -0
  62. package/src/cli/BaseCLIFeatureOptions.ts +6 -0
  63. package/src/runtime/runner.ts +4 -1
  64. package/src/service/configuration/ConfigurationServiceProvider.ts +108 -214
  65. package/src/service/configuration/DefaultConfigurationService.ts +378 -0
  66. package/src/service/configuration/DefaultKeyValueService.ts +9 -3
  67. package/src/service/configuration/DefaultSecretService.ts +51 -8
  68. package/src/service/configuration/KeyValueServiceProvider.ts +7 -2
  69. package/src/service/configuration/command/ConfigDumpSubCommand.ts +6 -10
  70. package/src/service/configuration/command/ConfigGetSubCommand.ts +198 -0
  71. package/src/service/configuration/command/ConfigSetSubCommand.ts +401 -0
  72. package/src/service/configuration/command/ConfigUnsetSubCommand.ts +126 -0
  73. package/src/service/configuration/configTargets.ts +481 -0
  74. package/src/service/configuration/format/ConfigFormat.ts +42 -0
  75. package/src/service/configuration/format/JsonConfigFormat.ts +22 -0
  76. package/src/service/configuration/format/TomlConfigFormat.ts +92 -0
  77. package/src/service/configuration/format/YamlConfigFormat.ts +72 -0
  78. package/src/service/configuration/resolveSecrets.ts +7 -1
  79. package/toml.ts +1 -0
  80. package/yaml.ts +1 -0
@@ -0,0 +1,126 @@
1
+ import {
2
+ ARGUMENT_PROMPTER_SERVICE_ID,
3
+ PRINTER_SERVICE_ID,
4
+ ValueTypeName,
5
+ type ArgumentPrompterService,
6
+ type Context,
7
+ type Option,
8
+ type Positional,
9
+ type PrinterService,
10
+ type SubCommand,
11
+ type Values,
12
+ } from "@flowscripter/dynamic-cli-framework-api";
13
+ import type DefaultConfigurationService from "../DefaultConfigurationService.ts";
14
+ import { removeAtPath, resolveOptionPath } from "../configTargets.ts";
15
+
16
+ type Raw = Record<string, unknown>;
17
+
18
+ /**
19
+ * Member of the `config` group which removes a stored default value (and any OS secret it refers to).
20
+ */
21
+ export default class ConfigUnsetSubCommand implements SubCommand {
22
+ readonly name = "unset";
23
+ readonly description = "Remove a default value for an option of a command";
24
+ readonly options: ReadonlyArray<Option> = [
25
+ {
26
+ name: "command",
27
+ type: ValueTypeName.STRING,
28
+ description: "The command (or <group>:<member>) to remove defaults from",
29
+ isOptional: true,
30
+ validate: (value) => {
31
+ const targets = this.#configurationService.getConfigTargets();
32
+ return targets.has(value as string) ||
33
+ this.#configurationService.defaultsData.has(value as string)
34
+ ? undefined
35
+ : `unknown command, valid commands: ${[...targets.keys()].join(", ")}`;
36
+ },
37
+ },
38
+ {
39
+ name: "option",
40
+ type: ValueTypeName.STRING,
41
+ description:
42
+ "The option (or option path) to remove. Omit to remove all defaults of the command.",
43
+ isOptional: true,
44
+ validate: (value, ctx) => {
45
+ const target = this.#configurationService
46
+ .getConfigTargets()
47
+ .get((ctx.values as Raw)["command"] as string);
48
+ if (target === undefined) {
49
+ return undefined;
50
+ }
51
+ if (target.argument !== undefined) {
52
+ return `command '${target.key}' has no options: omit --option`;
53
+ }
54
+ const resolved = resolveOptionPath(target, value as string);
55
+ if (typeof resolved === "string") {
56
+ return `${resolved}, valid options: ${target.options.map((o) => o.name).join(", ")}`;
57
+ }
58
+ return resolved.steps[resolved.steps.length - 1]!.index !== undefined
59
+ ? "unsetting an array element is not supported: unset the whole option instead"
60
+ : undefined;
61
+ },
62
+ },
63
+ ];
64
+ readonly positionals: ReadonlyArray<Positional> = [];
65
+
66
+ readonly #configurationService: DefaultConfigurationService;
67
+
68
+ public constructor(configurationService: DefaultConfigurationService) {
69
+ this.#configurationService = configurationService;
70
+ }
71
+
72
+ async execute(context: Context, argumentValues: Values): Promise<void> {
73
+ const printer = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
74
+ const targets = this.#configurationService.getConfigTargets();
75
+
76
+ let commandKey = argumentValues["command"] as string | undefined;
77
+ if (commandKey === undefined) {
78
+ const names = [...this.#configurationService.defaultsData.keys()];
79
+ const prompter = context.doesServiceExist(ARGUMENT_PROMPTER_SERVICE_ID)
80
+ ? (context.getServiceById(ARGUMENT_PROMPTER_SERVICE_ID) as ArgumentPrompterService)
81
+ : undefined;
82
+ const prompted =
83
+ names.length > 0
84
+ ? await prompter?.promptForArguments([
85
+ {
86
+ name: "command",
87
+ description: "Command to remove defaults from",
88
+ type: ValueTypeName.STRING,
89
+ allowableValues: names,
90
+ },
91
+ ])
92
+ : undefined;
93
+ if (prompted === undefined) {
94
+ throw new Error(`--command is required, commands with defaults: ${names.join(", ")}`);
95
+ }
96
+ commandKey = prompted["command"] as string;
97
+ }
98
+
99
+ const existing = this.#configurationService.defaultsData.get(commandKey);
100
+ const optionPath = argumentValues["option"] as string | undefined;
101
+ const target = targets.get(commandKey);
102
+ if (optionPath !== undefined && target === undefined) {
103
+ throw new Error(`Unknown or non-configurable command '${commandKey}'`);
104
+ }
105
+
106
+ let next: Values | undefined;
107
+ let removed = existing !== undefined;
108
+ if (optionPath !== undefined && existing !== undefined) {
109
+ const resolved = resolveOptionPath(target!, optionPath);
110
+ if (typeof resolved === "string") {
111
+ throw new Error(resolved);
112
+ }
113
+ const values = structuredClone(existing) as Record<string, unknown>;
114
+ removed = removeAtPath(values, resolved.steps);
115
+ next = Object.keys(values).length > 0 ? (values as Values) : undefined;
116
+ }
117
+ if (!removed) {
118
+ await printer.info(
119
+ `Nothing to unset for ${commandKey}${optionPath ? `.${optionPath}` : ""}\n`,
120
+ );
121
+ return;
122
+ }
123
+ await this.#configurationService.commitDefault(commandKey, [], () => next);
124
+ await printer.info(`Removed default ${commandKey}${optionPath ? `.${optionPath}` : ""}\n`);
125
+ }
126
+ }
@@ -0,0 +1,481 @@
1
+ import {
2
+ MAXIMUM_COMPLEX_OPTION_NESTING_DEPTH,
3
+ SECRET_SENTINEL_PREFIX,
4
+ ValueTypeName,
5
+ type Command,
6
+ type ComplexOption,
7
+ type GlobalCommandArgument,
8
+ type GroupCommand,
9
+ type InvalidArgument,
10
+ type Option,
11
+ type CommandRegistry,
12
+ type CLIConfig,
13
+ type GlobalCommand,
14
+ type SubCommand,
15
+ type SingleValueType,
16
+ type Values,
17
+ type ValueNode,
18
+ } from "@flowscripter/dynamic-cli-framework-api";
19
+ import { isComplexOption } from "../../runtime/argument/ArgumentTypeGuards.ts";
20
+ import {
21
+ getInvalidArgumentString,
22
+ runCustomValidation,
23
+ validateGlobalCommandArgumentValue,
24
+ validateOptionValue,
25
+ type DeferredCustomValidation,
26
+ } from "../../runtime/values/argumentValueValidation.ts";
27
+ import {
28
+ getGlobalCommandValueFromEnvVars,
29
+ getSubCommandValuesFromEnvVars,
30
+ } from "../../util/envVarHelper.ts";
31
+ import { isGlobalModifierCommand } from "../../runtime/command/CommandTypeGuards.ts";
32
+
33
+ export const MASK = "********";
34
+
35
+ type Node = unknown;
36
+
37
+ /**
38
+ * A command whose default argument values can be managed via the `config` group commands.
39
+ */
40
+ export interface ConfigTarget {
41
+ /** The key under `defaults`: the command name or `<group name>:<member name>`. */
42
+ readonly key: string;
43
+ readonly command: Command;
44
+ readonly group?: GroupCommand;
45
+ /** Set for global commands and global modifier commands. */
46
+ readonly argument?: GlobalCommandArgument;
47
+ /** Empty for global commands. */
48
+ readonly options: ReadonlyArray<Option | ComplexOption>;
49
+ }
50
+
51
+ /**
52
+ * Collect all commands in the registry with `enableConfiguration` set, except the `config` modifier.
53
+ */
54
+ export function getConfigTargets(registry: CommandRegistry): Map<string, ConfigTarget> {
55
+ const targets = new Map<string, ConfigTarget>();
56
+ const globals = [...registry.getGlobalCommands(), ...registry.getGlobalModifierCommands()];
57
+ for (const command of globals) {
58
+ if (command.enableConfiguration !== true || command.argument === undefined) {
59
+ continue;
60
+ }
61
+ if (isGlobalModifierCommand(command) && command.name === "config") {
62
+ continue;
63
+ }
64
+ targets.set(command.name, {
65
+ key: command.name,
66
+ command,
67
+ argument: command.argument,
68
+ options: [],
69
+ });
70
+ }
71
+ for (const command of registry.getSubCommands()) {
72
+ if (command.enableConfiguration === true) {
73
+ targets.set(command.name, { key: command.name, command, options: command.options });
74
+ }
75
+ }
76
+ for (const [key, { groupCommand, command }] of registry.getGroupAndMemberCommandsByJoinedName()) {
77
+ if (command.enableConfiguration === true) {
78
+ targets.set(key, { key, command, group: groupCommand, options: command.options });
79
+ }
80
+ }
81
+ return targets;
82
+ }
83
+
84
+ export interface PathStep {
85
+ readonly name: string;
86
+ readonly index?: number;
87
+ }
88
+
89
+ /**
90
+ * Parse an option path such as `address[1].port` into steps.
91
+ */
92
+ export function parseOptionPath(path: string): Array<PathStep> | string {
93
+ const elements = path.split(".");
94
+ if (elements.length > MAXIMUM_COMPLEX_OPTION_NESTING_DEPTH) {
95
+ return `option path '${path}' exceeds the maximum nesting depth`;
96
+ }
97
+ const steps: Array<PathStep> = [];
98
+ for (const element of elements) {
99
+ const match = /^([^[\]]+)(?:\[(\d+)\])?$/.exec(element);
100
+ if (match === null) {
101
+ return `invalid option path '${path}'`;
102
+ }
103
+ steps.push(
104
+ match[2] === undefined ? { name: match[1]! } : { name: match[1]!, index: Number(match[2]) },
105
+ );
106
+ }
107
+ return steps;
108
+ }
109
+
110
+ export interface ResolvedPath {
111
+ readonly steps: Array<PathStep>;
112
+ readonly leaf: Option | ComplexOption;
113
+ }
114
+
115
+ /**
116
+ * Walk the steps through the target's options returning the option the path refers to.
117
+ */
118
+ export function resolveOptionPath(target: ConfigTarget, path: string): ResolvedPath | string {
119
+ const steps = parseOptionPath(path);
120
+ if (typeof steps === "string") {
121
+ return steps;
122
+ }
123
+ let candidates: ReadonlyArray<Option | ComplexOption> = target.options;
124
+ let leaf: Option | ComplexOption | undefined;
125
+ for (let i = 0; i < steps.length; i++) {
126
+ const step = steps[i]!;
127
+ leaf = candidates.find((o) => o.name === step.name);
128
+ if (leaf === undefined) {
129
+ return `unknown option '${step.name}' in '${path}'`;
130
+ }
131
+ if (step.index !== undefined && leaf.isArray !== true) {
132
+ return `option '${step.name}' is not an array`;
133
+ }
134
+ if (i < steps.length - 1) {
135
+ if (!isComplexOption(leaf)) {
136
+ return `option '${step.name}' has no properties`;
137
+ }
138
+ if (leaf.isArray === true && step.index === undefined) {
139
+ return `option '${step.name}' is an array and requires an index`;
140
+ }
141
+ candidates = leaf.properties;
142
+ }
143
+ }
144
+ return { steps, leaf: leaf! };
145
+ }
146
+
147
+ export function containsSecret(option: Option | ComplexOption): boolean {
148
+ if (isComplexOption(option)) {
149
+ return option.properties.some(containsSecret);
150
+ }
151
+ return option.type === ValueTypeName.SECRET;
152
+ }
153
+
154
+ function asOptional<T extends Option | ComplexOption>(option: T): T {
155
+ if (isComplexOption(option)) {
156
+ return {
157
+ ...option,
158
+ isOptional: true,
159
+ properties: option.properties.map(asOptional),
160
+ } as T;
161
+ }
162
+ return { ...option, isOptional: true } as T;
163
+ }
164
+
165
+ function clone<T>(value: T): T {
166
+ return value === undefined ? value : structuredClone(value);
167
+ }
168
+
169
+ export function getAtPath(root: Node, steps: ReadonlyArray<PathStep>): Node {
170
+ let current = root;
171
+ for (const step of steps) {
172
+ if (typeof current !== "object" || current === null) {
173
+ return undefined;
174
+ }
175
+ current = (current as Record<string, Node>)[step.name];
176
+ if (step.index !== undefined) {
177
+ if (!Array.isArray(current)) {
178
+ return undefined;
179
+ }
180
+ current = current[step.index];
181
+ }
182
+ }
183
+ return current;
184
+ }
185
+
186
+ /**
187
+ * Set a value at the path in the provided (mutable) root creating parents as needed.
188
+ */
189
+ export function setAtPath(
190
+ root: Record<string, Node>,
191
+ steps: ReadonlyArray<PathStep>,
192
+ value: Node,
193
+ ): void {
194
+ let container = root;
195
+ steps.forEach((step, i) => {
196
+ const isLast = i === steps.length - 1;
197
+ if (step.index === undefined) {
198
+ if (isLast) {
199
+ container[step.name] = value;
200
+ return;
201
+ }
202
+ if (typeof container[step.name] !== "object" || container[step.name] === null) {
203
+ container[step.name] = {};
204
+ }
205
+ container = container[step.name] as Record<string, Node>;
206
+ return;
207
+ }
208
+ if (!Array.isArray(container[step.name])) {
209
+ container[step.name] = [];
210
+ }
211
+ const array = container[step.name] as Array<Node>;
212
+ if (step.index > array.length) {
213
+ throw new Error(
214
+ `cannot set '${step.name}[${step.index}]': array has ${array.length} element(s) and sparse arrays are not supported`,
215
+ );
216
+ }
217
+ if (isLast) {
218
+ array[step.index] = value;
219
+ return;
220
+ }
221
+ if (typeof array[step.index] !== "object" || array[step.index] === null) {
222
+ array[step.index] = {};
223
+ }
224
+ container = array[step.index] as Record<string, Node>;
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Remove the value at the path and any parent objects left empty. Returns false if there was no value.
230
+ */
231
+ export function removeAtPath(root: Record<string, Node>, steps: ReadonlyArray<PathStep>): boolean {
232
+ if (getAtPath(root, steps) === undefined) {
233
+ return false;
234
+ }
235
+ const last = steps[steps.length - 1]!;
236
+ if (last.index !== undefined) {
237
+ throw new Error("unsetting an array element is not supported: unset the whole option instead");
238
+ }
239
+ const parents: Array<Record<string, Node>> = [root];
240
+ let container = root;
241
+ for (const step of steps.slice(0, -1)) {
242
+ container =
243
+ step.index === undefined
244
+ ? (container[step.name] as Record<string, Node>)
245
+ : ((container[step.name] as Array<Node>)[step.index] as Record<string, Node>);
246
+ parents.push(container);
247
+ }
248
+ delete container[last.name];
249
+ for (let i = steps.length - 2; i >= 0; i--) {
250
+ const step = steps[i]!;
251
+ if (step.index !== undefined || Object.keys(parents[i + 1]!).length > 0) {
252
+ break;
253
+ }
254
+ delete parents[i]![step.name];
255
+ }
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * All `__SECRET__:` sentinel strings within the value.
261
+ */
262
+ export function collectSentinels(value: Node): Array<string> {
263
+ if (typeof value === "string") {
264
+ return value.startsWith(SECRET_SENTINEL_PREFIX) ? [value] : [];
265
+ }
266
+ if (Array.isArray(value)) {
267
+ return value.flatMap(collectSentinels);
268
+ }
269
+ if (typeof value === "object" && value !== null) {
270
+ return Object.values(value).flatMap(collectSentinels);
271
+ }
272
+ return [];
273
+ }
274
+
275
+ /**
276
+ * @param secrets plain text SECRET values which must not appear in the message.
277
+ */
278
+ function invalidMessage(
279
+ invalid: Array<InvalidArgument>,
280
+ secrets: ReadonlyArray<unknown> = [],
281
+ ): string {
282
+ return invalid
283
+ .map((i) => {
284
+ const hidden = secrets.includes(i.value) ? { ...i, value: undefined } : i;
285
+ return getInvalidArgumentString(hidden, false) + (i.message ?? "");
286
+ })
287
+ .join("; ");
288
+ }
289
+
290
+ /**
291
+ * A validated, converted change to a single option of a target.
292
+ */
293
+ export interface ConfigChange {
294
+ readonly steps: ReadonlyArray<PathStep>;
295
+ readonly path: string;
296
+ readonly leaf: Option | ComplexOption;
297
+ /** Native value, or the plain text of a SECRET which must be stored via the secret service. */
298
+ readonly value: Node;
299
+ readonly isSecret: boolean;
300
+ }
301
+
302
+ /**
303
+ * Validate and convert the raw (string) value for the option path of the target. Required checks are
304
+ * not applied. Custom validators of the target are run later by {@link validateChanges}.
305
+ */
306
+ export function prepareChange(
307
+ target: ConfigTarget,
308
+ path: string,
309
+ raw: string,
310
+ deferred: Array<DeferredCustomValidation>,
311
+ ): ConfigChange | string {
312
+ const resolved = resolveOptionPath(target, path);
313
+ if (typeof resolved === "string") {
314
+ return resolved;
315
+ }
316
+ const { steps, leaf } = resolved;
317
+ const hasIndex = steps[steps.length - 1]!.index !== undefined;
318
+ const whole = isComplexOption(leaf) || (leaf.isArray === true && !hasIndex);
319
+ const invalid: Array<InvalidArgument> = [];
320
+ let value: Node;
321
+ if (whole) {
322
+ if (!/^\s*[[{]/.test(raw)) {
323
+ return `option '${path}' requires a JSON ${leaf.isArray === true && !hasIndex ? "array" : "object"} value`;
324
+ }
325
+ if (containsSecret(leaf)) {
326
+ return `option '${path}' contains SECRET values: set each secret property individually`;
327
+ }
328
+ try {
329
+ value = JSON.parse(raw);
330
+ } catch (err) {
331
+ return `invalid JSON value for '${path}': ${(err as Error).message}`;
332
+ }
333
+ value = validateOptionValue(asOptional(leaf), value as never, invalid, deferred);
334
+ } else {
335
+ value = validateOptionValue(
336
+ { ...asOptional(leaf), isArray: false } as Option,
337
+ raw,
338
+ invalid,
339
+ deferred,
340
+ );
341
+ }
342
+ if (invalid.length > 0) {
343
+ return `${path}: ${invalidMessage(invalid, [raw])}`;
344
+ }
345
+ const isSecret = !isComplexOption(leaf) && leaf.type === ValueTypeName.SECRET;
346
+ return { steps, path, leaf, value, isSecret };
347
+ }
348
+
349
+ /**
350
+ * Validate a raw value for a global command argument.
351
+ */
352
+ export function prepareGlobalValue(
353
+ target: ConfigTarget,
354
+ raw: string,
355
+ deferred: Array<DeferredCustomValidation>,
356
+ ): { value: SingleValueType; isSecret: boolean } | string {
357
+ const invalid: Array<InvalidArgument> = [];
358
+ const argument = { ...target.argument!, isOptional: true };
359
+ const value = validateGlobalCommandArgumentValue(
360
+ { ...target.command, argument } as never,
361
+ raw,
362
+ invalid,
363
+ deferred,
364
+ );
365
+ if (invalid.length > 0 || value === undefined) {
366
+ return invalidMessage(invalid, [raw]);
367
+ }
368
+ return { value, isSecret: target.argument!.type === ValueTypeName.SECRET };
369
+ }
370
+
371
+ /**
372
+ * Apply the changes to a copy of the existing values and run the custom validators of the target.
373
+ * Returns the new values or an error message.
374
+ */
375
+ export function applyChanges(
376
+ target: ConfigTarget,
377
+ existing: Values | undefined,
378
+ changes: ReadonlyArray<ConfigChange>,
379
+ deferred: Array<DeferredCustomValidation>,
380
+ ): Values | string {
381
+ const values = clone(existing ?? {}) as Record<string, Node>;
382
+ try {
383
+ for (const change of changes) {
384
+ setAtPath(values, change.steps, change.value);
385
+ }
386
+ } catch (err) {
387
+ return (err as Error).message;
388
+ }
389
+ const invalid: Array<InvalidArgument> = [];
390
+ runCustomValidation(deferred, target.command, values as never, invalid);
391
+ if (invalid.length > 0) {
392
+ return invalidMessage(
393
+ invalid,
394
+ changes.filter((c) => c.isSecret).map((c) => c.value),
395
+ );
396
+ }
397
+ return values as Values;
398
+ }
399
+
400
+ function maskSentinels(value: Node): Node {
401
+ if (typeof value === "string") {
402
+ return value.startsWith(SECRET_SENTINEL_PREFIX) ? MASK : value;
403
+ }
404
+ if (Array.isArray(value)) {
405
+ return value.map(maskSentinels);
406
+ }
407
+ if (typeof value === "object" && value !== null) {
408
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskSentinels(v)]));
409
+ }
410
+ return value;
411
+ }
412
+
413
+ function maskOption(option: Option | ComplexOption, value: Node): Node {
414
+ if (value === undefined || value === null) {
415
+ return value;
416
+ }
417
+ if (isComplexOption(option)) {
418
+ if (Array.isArray(value)) {
419
+ return value.map((v) => maskOption({ ...option, isArray: false }, v));
420
+ }
421
+ return maskOptions(option.properties, value);
422
+ }
423
+ if (option.type === ValueTypeName.SECRET) {
424
+ return Array.isArray(value) ? value.map(() => MASK) : MASK;
425
+ }
426
+ return value;
427
+ }
428
+
429
+ function maskOptions(options: ReadonlyArray<Option | ComplexOption>, value: Node): Node {
430
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
431
+ return value;
432
+ }
433
+ return Object.fromEntries(
434
+ Object.entries(value).map(([name, v]) => {
435
+ const option = options.find((o) => o.name === name);
436
+ return [name, option === undefined ? v : maskOption(option, v)];
437
+ }),
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Copy of a stored default value with SECRET typed values and any secret sentinels masked.
443
+ */
444
+ export function maskDefault(target: ConfigTarget | undefined, value: Node): Node {
445
+ let masked = value;
446
+ if (target?.argument !== undefined) {
447
+ if (target.argument.type === ValueTypeName.SECRET && value !== undefined) {
448
+ masked = MASK;
449
+ }
450
+ } else if (target !== undefined) {
451
+ masked = maskOptions(target.options, value);
452
+ }
453
+ return maskSentinels(masked);
454
+ }
455
+
456
+ export type { ValueNode };
457
+
458
+ /**
459
+ * Whether an environment variable would override the stored value at the path.
460
+ */
461
+ export function isOverriddenByEnvVar(
462
+ provider: { readonly envVarsEnabled: boolean },
463
+ cliConfig: CLIConfig,
464
+ target: ConfigTarget,
465
+ steps: ReadonlyArray<PathStep>,
466
+ ): boolean {
467
+ if (!provider.envVarsEnabled) {
468
+ return false;
469
+ }
470
+ if (target.argument !== undefined) {
471
+ return (
472
+ getGlobalCommandValueFromEnvVars(cliConfig, target.command as GlobalCommand) !== undefined
473
+ );
474
+ }
475
+ const values = getSubCommandValuesFromEnvVars(
476
+ cliConfig,
477
+ target.command as SubCommand,
478
+ target.group,
479
+ );
480
+ return getAtPath(values, steps) !== undefined;
481
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The top level structure of a configuration file: `defaults` and `key-values` objects.
3
+ */
4
+ export type ConfigData = Record<string, any>;
5
+
6
+ /**
7
+ * Defines the on-disk syntax of the CLI configuration file.
8
+ *
9
+ * The built-in implementation is JSON (the default). YAML and TOML implementations are available
10
+ * from the `@flowscripter/dynamic-cli-framework/yaml` and `@flowscripter/dynamic-cli-framework/toml`
11
+ * entry points. Third party formats can be supplied by implementing this interface.
12
+ */
13
+ export default interface ConfigFormat {
14
+ /**
15
+ * The extension (without the leading dot) used for the default configuration file name,
16
+ * e.g. `json`.
17
+ */
18
+ readonly fileExtension: string;
19
+
20
+ /**
21
+ * Parse configuration file text.
22
+ *
23
+ * @returns the parsed `data` and an opaque `handle` retaining whatever the format needs to write
24
+ * changes back while preserving comments and layout (`undefined` if the format has nothing to retain).
25
+ * @throws if the text is not valid for this format.
26
+ */
27
+ parse(text: string): { data: ConfigData; handle: unknown };
28
+
29
+ /**
30
+ * Produce updated file text from a `handle` returned by {@link parse}.
31
+ *
32
+ * @param handle the handle returned by {@link parse} for the text being updated.
33
+ * @param changes the complete updated configuration data. The format reconciles the existing text
34
+ * with it, leaving unchanged content (including comments) untouched.
35
+ */
36
+ apply(handle: unknown, changes: ConfigData): string;
37
+
38
+ /**
39
+ * Produce new file text from scratch.
40
+ */
41
+ serialize(data: ConfigData): string;
42
+ }
@@ -0,0 +1,22 @@
1
+ import type ConfigFormat from "./ConfigFormat.ts";
2
+ import type { ConfigData } from "./ConfigFormat.ts";
3
+
4
+ function serialize(data: ConfigData): string {
5
+ return JSON.stringify(data, null, 2);
6
+ }
7
+
8
+ /**
9
+ * The built-in JSON {@link ConfigFormat}. The file is regenerated on write: JSON has no comments to preserve.
10
+ */
11
+ const jsonFormat: ConfigFormat = {
12
+ fileExtension: "json",
13
+ parse(text: string) {
14
+ return { data: JSON.parse(text) as ConfigData, handle: undefined };
15
+ },
16
+ apply(_handle: unknown, changes: ConfigData): string {
17
+ return serialize(changes);
18
+ },
19
+ serialize,
20
+ };
21
+
22
+ export default jsonFormat;