@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,378 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import type { Stats } from "node:fs";
5
+ import {
6
+ SECRET_SENTINEL_PREFIX,
7
+ type CommandRegistry,
8
+ type ConfigurationService,
9
+ type SingleValueType,
10
+ type ValueNode,
11
+ type Values,
12
+ } from "@flowscripter/dynamic-cli-framework-api";
13
+ import getLogger from "../../util/logger.ts";
14
+ import {
15
+ collectSentinels,
16
+ getConfigTargets,
17
+ maskDefault,
18
+ type ConfigTarget,
19
+ } from "./configTargets.ts";
20
+ import type DefaultSecretService from "./DefaultSecretService.ts";
21
+ import type ConfigFormat from "./format/ConfigFormat.ts";
22
+ import type { ConfigData } from "./format/ConfigFormat.ts";
23
+ import resolveSecrets from "./resolveSecrets.ts";
24
+
25
+ const logger = getLogger("DefaultConfigurationService");
26
+
27
+ export type KeyValueServiceScopeType = "command" | "service";
28
+
29
+ /**
30
+ * Owns the configuration data read from (and written back to) the configuration file: the default
31
+ * command argument values and the raw per-scope key-value data.
32
+ *
33
+ * The `config` group commands are constructed with this instance. It must never be registered in the
34
+ * {@link Context}: it exposes every command's defaults, the raw key-value data of every scope and secret
35
+ * resolution. Only a location-only view is registered under {@link CONFIGURATION_SERVICE_ID}.
36
+ */
37
+ export default class DefaultConfigurationService implements ConfigurationService {
38
+ public readonly envVarsEnabled: boolean;
39
+ public readonly configFormat: ConfigFormat;
40
+
41
+ // the location of the currently managed configuration data
42
+ public configLocation: string | undefined;
43
+
44
+ // the configuration data used when setting command defaults. SingleValueType is for
45
+ // GlobalCommandArgument values, Values is for SubCommandArgument values.
46
+ public defaultsData: Map<string, Values | SingleValueType> = new Map();
47
+
48
+ // the format specific handle for the text last read or written, used to preserve comments and layout on write
49
+ #configHandle: unknown;
50
+
51
+ #commandKeyValueData = new Map<string, Map<string, ValueNode>>();
52
+ #serviceKeyValueData = new Map<string, Map<string, ValueNode>>();
53
+
54
+ // used only to resolve secret sentinels embedded in default command argument values and to store
55
+ // SECRET values set via the `config` group commands
56
+ #defaultsSecretService: DefaultSecretService | undefined;
57
+
58
+ readonly #commandRegistry: CommandRegistry | undefined;
59
+
60
+ /**
61
+ * @param envVarsEnabled whether environment variables may override stored default values.
62
+ * @param configFormat the syntax of the configuration file.
63
+ * @param commandRegistry optional registry of all commands, required for {@link getConfigTargets}.
64
+ */
65
+ public constructor(
66
+ envVarsEnabled: boolean,
67
+ configFormat: ConfigFormat,
68
+ commandRegistry?: CommandRegistry,
69
+ ) {
70
+ this.envVarsEnabled = envVarsEnabled;
71
+ this.configFormat = configFormat;
72
+ this.#commandRegistry = commandRegistry;
73
+ }
74
+
75
+ public setConfigLocation(location: string): void {
76
+ this.configLocation = location;
77
+ }
78
+
79
+ public setDefaultsSecretService(secretService: DefaultSecretService): void {
80
+ this.#defaultsSecretService = secretService;
81
+ }
82
+
83
+ /**
84
+ * Resolve any secret sentinels within the value using the OS secret store (if enabled).
85
+ *
86
+ * @param commandKey the key of the command under `defaults` the value belongs to: a sentinel
87
+ * may only refer to a secret stored for this command.
88
+ */
89
+ public async resolveDefaultSecrets<T>(value: T, commandKey: string): Promise<T> {
90
+ if (this.#defaultsSecretService === undefined) {
91
+ return value;
92
+ }
93
+ const secretService = this.#defaultsSecretService;
94
+ return await resolveSecrets<T>(value, async (bunSecretName) => {
95
+ if (!secretService.ownsSecretName(bunSecretName, commandKey)) {
96
+ throw new Error(
97
+ `Secret sentinel '${SECRET_SENTINEL_PREFIX}${bunSecretName}' does not refer to a secret stored for command: '${commandKey}'`,
98
+ );
99
+ }
100
+ const secretValue = await secretService.getSecret(bunSecretName);
101
+ if (secretValue === null) {
102
+ throw new Error(
103
+ `Secret not found in OS secret store for sentinel: '${SECRET_SENTINEL_PREFIX}${bunSecretName}'`,
104
+ );
105
+ }
106
+ return secretValue;
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Get-or-create the underlying, raw {@link ValueNode} `Map` for the given scope.
112
+ *
113
+ * @param scopeType whether `scopeKey` is a command name or a service/task ID.
114
+ * @param scopeKey the command name or service/task ID to scope the key-value data to.
115
+ */
116
+ public getKeyValueData(
117
+ scopeType: KeyValueServiceScopeType,
118
+ scopeKey: string,
119
+ ): Map<string, ValueNode> {
120
+ const keyValueData =
121
+ scopeType === "command" ? this.#commandKeyValueData : this.#serviceKeyValueData;
122
+ if (!keyValueData.has(scopeKey)) {
123
+ keyValueData.set(scopeKey, new Map());
124
+ }
125
+ return keyValueData.get(scopeKey)!;
126
+ }
127
+
128
+ /**
129
+ * Write the configuration file, if `isDirty` is true.
130
+ *
131
+ * @param isDirty whether any scoped KeyValueService has unwritten changes.
132
+ */
133
+ public async flushIfDirty(isDirty: boolean): Promise<void> {
134
+ if (!isDirty) {
135
+ return;
136
+ }
137
+ if (this.configLocation === undefined) {
138
+ throw new Error("Attempt to write updated config with no configLocation set");
139
+ }
140
+ await this.#writeConfig(this.configLocation);
141
+ }
142
+
143
+ /**
144
+ * Determine the configuration location (defaulting to `$HOME/.<application_name>.<format extension>`)
145
+ * and read the configuration from it if it exists.
146
+ *
147
+ * @param applicationName the name of the CLI.
148
+ */
149
+ public async init(applicationName: string): Promise<void> {
150
+ let isDefault = false;
151
+ if (this.configLocation === undefined) {
152
+ isDefault = true;
153
+ const home = process.env["HOME"];
154
+ if (home) {
155
+ this.configLocation = path.join(
156
+ home,
157
+ `.${applicationName.replace(/\W/g, "")}.${this.configFormat.fileExtension}`,
158
+ );
159
+ }
160
+ }
161
+ if (this.configLocation === undefined) {
162
+ return;
163
+ }
164
+ let fileInfo: Stats;
165
+ try {
166
+ fileInfo = await fs.lstat(this.configLocation);
167
+ } catch (err) {
168
+ const error = err as Error;
169
+ if (isDefault) {
170
+ logger.debug(
171
+ "Default config file location: %s does not exist or is not visible - ignoring: %s",
172
+ this.configLocation,
173
+ error.message,
174
+ );
175
+ return;
176
+ } else {
177
+ throw new Error(
178
+ `Config file location: '${this.configLocation}' doesn't exist or not visible: ${error.message}`,
179
+ );
180
+ }
181
+ }
182
+ if (fileInfo.isDirectory()) {
183
+ throw new Error(
184
+ `Config file location: '${this.configLocation}' is a directory and not a file!`,
185
+ );
186
+ }
187
+
188
+ await this.#readConfig(this.configLocation);
189
+ }
190
+
191
+ async #readConfig(location: string): Promise<void> {
192
+ try {
193
+ logger.debug("Reading config from: %s", location);
194
+
195
+ const data = await fs.readFile(location);
196
+ const { data: config, handle } = this.configFormat.parse(data.toString());
197
+ this.#configHandle = handle;
198
+
199
+ this.defaultsData.clear();
200
+ this.#commandKeyValueData.clear();
201
+ this.#serviceKeyValueData.clear();
202
+ if (config.defaults !== undefined) {
203
+ const defaults = config.defaults;
204
+ Object.keys(defaults).forEach((commandName) => {
205
+ this.defaultsData.set(commandName, defaults[commandName] as Values);
206
+ logger.debug("Set default argument values for command name: %s", commandName);
207
+ });
208
+ }
209
+ if (config["key-values"] !== undefined) {
210
+ const keyValues = config["key-values"];
211
+ if (keyValues.commands !== undefined) {
212
+ const commandKeyValues = keyValues.commands;
213
+ Object.keys(commandKeyValues).forEach((commandName) => {
214
+ this.#commandKeyValueData.set(
215
+ commandName,
216
+ new Map(Object.entries(commandKeyValues[commandName])),
217
+ );
218
+ logger.debug("Set default argument values for command name: %s", commandName);
219
+ });
220
+ }
221
+ if (keyValues.services !== undefined) {
222
+ const serviceKeyValues = keyValues.services;
223
+ Object.keys(serviceKeyValues).forEach((serviceId) => {
224
+ this.#serviceKeyValueData.set(
225
+ serviceId,
226
+ new Map(Object.entries(serviceKeyValues[serviceId])),
227
+ );
228
+ logger.debug("Set default argument values for service ID: %s", serviceId);
229
+ });
230
+ }
231
+ }
232
+ } catch (err) {
233
+ throw new Error(`Failed to read config at location: '${this.configLocation}': ${err}`);
234
+ }
235
+ }
236
+
237
+ async #writeConfig(location: string): Promise<void> {
238
+ logger.debug("Writing config to: %s", location);
239
+
240
+ try {
241
+ const config = this.#getConfigData();
242
+ const text =
243
+ this.#configHandle === undefined
244
+ ? this.configFormat.serialize(config)
245
+ : this.configFormat.apply(this.#configHandle, config);
246
+ // owner-only when the file is created; an existing file keeps its mode
247
+ await fs.writeFile(location, text, { mode: 0o600 });
248
+ this.#configHandle = this.configFormat.parse(text).handle;
249
+ } catch (err) {
250
+ throw new Error(`Failed to write config at location: '${this.configLocation}': ${err}`);
251
+ }
252
+ }
253
+
254
+ public getConfigString(): string {
255
+ return this.configFormat.serialize(this.#getConfigData());
256
+ }
257
+
258
+ /**
259
+ * The configuration text with secret sentinels and the values of SECRET typed arguments of
260
+ * known commands masked.
261
+ */
262
+ public getMaskedConfigString(): string {
263
+ const data = this.#getConfigData();
264
+ const targets = this.getConfigTargets();
265
+ const masked: ConfigData = maskDefault(undefined, data) as ConfigData;
266
+ if (data.defaults !== undefined) {
267
+ masked.defaults = Object.fromEntries(
268
+ Object.entries(data.defaults).map(([key, value]) => [
269
+ key,
270
+ maskDefault(targets.get(key), value),
271
+ ]),
272
+ );
273
+ }
274
+ return this.configFormat.serialize(masked);
275
+ }
276
+
277
+ /**
278
+ * The commands whose defaults can be managed via the `config` group commands.
279
+ */
280
+ public getConfigTargets(): Map<string, ConfigTarget> {
281
+ return this.#commandRegistry === undefined
282
+ ? new Map()
283
+ : getConfigTargets(this.#commandRegistry);
284
+ }
285
+
286
+ /**
287
+ * Replace (or remove, if `build` returns `undefined`) the stored defaults of a command and write the
288
+ * configuration file immediately. Memory, file and secret store are left unchanged if anything fails.
289
+ *
290
+ * @param commandKey the key of the command under `defaults`.
291
+ * @param secrets plain text secrets to store in the OS secret store, keyed to be used for naming.
292
+ * @param build creates the new value given the sentinels (one per secret) to reference the stored secrets.
293
+ */
294
+ public async commitDefault(
295
+ commandKey: string,
296
+ secrets: ReadonlyArray<{ key: ReadonlyArray<string>; value: string }>,
297
+ build: (sentinels: ReadonlyArray<string>) => Values | SingleValueType | undefined,
298
+ ): Promise<void> {
299
+ if (this.configLocation === undefined) {
300
+ throw new Error("Unable to write config: no config location is set and $HOME is not defined");
301
+ }
302
+ if (secrets.length > 0 && this.#defaultsSecretService === undefined) {
303
+ throw new Error("Unable to store a SECRET value: the secret service is not enabled");
304
+ }
305
+ const secretService = this.#defaultsSecretService;
306
+ const previous = this.defaultsData.get(commandKey);
307
+ const restores: Array<() => Promise<unknown>> = [];
308
+ const sentinels: Array<string> = [];
309
+ try {
310
+ for (const { key, value } of secrets) {
311
+ const name = secretService!.secretName(key);
312
+ const old = await secretService!.getSecret(name);
313
+ await secretService!.setSecret(key, value);
314
+ restores.push(() =>
315
+ old === null ? secretService!.deleteSecret(name) : secretService!.setSecret(key, old),
316
+ );
317
+ sentinels.push(`${SECRET_SENTINEL_PREFIX}${name}`);
318
+ }
319
+ const next = build(sentinels);
320
+ if (next === undefined) {
321
+ this.defaultsData.delete(commandKey);
322
+ } else {
323
+ this.defaultsData.set(commandKey, next);
324
+ }
325
+ await this.#writeConfig(this.configLocation);
326
+ const kept = new Set(collectSentinels(next));
327
+ for (const sentinel of collectSentinels(previous)) {
328
+ if (!kept.has(sentinel) && secretService !== undefined) {
329
+ await secretService.deleteSecret(sentinel.slice(SECRET_SENTINEL_PREFIX.length));
330
+ }
331
+ }
332
+ } catch (err) {
333
+ if (previous === undefined) {
334
+ this.defaultsData.delete(commandKey);
335
+ } else {
336
+ this.defaultsData.set(commandKey, previous);
337
+ }
338
+ for (const restore of restores.reverse()) {
339
+ await restore().catch(() => undefined);
340
+ }
341
+ throw err;
342
+ }
343
+ }
344
+
345
+ #getConfigData(): ConfigData {
346
+ const config: {
347
+ defaults?: Record<string, Values | SingleValueType>;
348
+ "key-values"?: {
349
+ commands?: Record<string, Record<string, ValueNode>>;
350
+ services?: Record<string, Record<string, ValueNode>>;
351
+ };
352
+ } = {};
353
+
354
+ if (this.defaultsData.size > 0) {
355
+ config.defaults = Object.fromEntries(this.defaultsData);
356
+ }
357
+ if (this.#commandKeyValueData.size > 0 || this.#serviceKeyValueData.size > 0) {
358
+ config["key-values"] = {};
359
+ if (this.#commandKeyValueData.size > 0) {
360
+ config["key-values"].commands = {};
361
+ for (const [commandName, keyValueData] of this.#commandKeyValueData.entries()) {
362
+ if (keyValueData.size > 0) {
363
+ config["key-values"].commands[commandName] = Object.fromEntries(keyValueData);
364
+ }
365
+ }
366
+ }
367
+ if (this.#serviceKeyValueData.size > 0) {
368
+ config["key-values"].services = {};
369
+ for (const [serviceId, keyValueData] of this.#serviceKeyValueData.entries()) {
370
+ if (keyValueData.size > 0) {
371
+ config["key-values"].services[serviceId] = Object.fromEntries(keyValueData);
372
+ }
373
+ }
374
+ }
375
+ }
376
+ return config;
377
+ }
378
+ }
@@ -57,6 +57,11 @@ export default class DefaultKeyValueService implements KeyValueService {
57
57
  if (!this.#secretService) {
58
58
  throw new Error("Secret sentinel found but no secret service is available");
59
59
  }
60
+ if (!this.#secretService.ownsSecretName(bunSecretName)) {
61
+ throw new Error(
62
+ `Secret sentinel for key: '${key}' refers to a secret outside of this scope`,
63
+ );
64
+ }
60
65
  const secretValue = await this.#secretService.getSecret(bunSecretName);
61
66
  if (secretValue === null) {
62
67
  throw new Error(`Secret not found in OS secret store for key: '${key}'`);
@@ -89,9 +94,8 @@ export default class DefaultKeyValueService implements KeyValueService {
89
94
  if (!this.#secretService) {
90
95
  throw new Error("Attempt to set a secret but no secret service is available");
91
96
  }
92
- const secretName = [key, ...path].join("_");
93
97
  const bunSecretName = await this.#secretService.setSecret(
94
- secretName,
98
+ [key, ...path],
95
99
  JSON.stringify(value.value),
96
100
  );
97
101
  return SECRET_SENTINEL_PREFIX + bunSecretName;
@@ -129,7 +133,9 @@ export default class DefaultKeyValueService implements KeyValueService {
129
133
  if (typeof value === "string" && value.startsWith(SECRET_SENTINEL_PREFIX)) {
130
134
  if (this.#secretService) {
131
135
  const bunSecretName = value.slice(SECRET_SENTINEL_PREFIX.length);
132
- await this.#secretService.deleteSecret(bunSecretName);
136
+ if (this.#secretService.ownsSecretName(bunSecretName)) {
137
+ await this.#secretService.deleteSecret(bunSecretName);
138
+ }
133
139
  }
134
140
  return;
135
141
  }
@@ -10,28 +10,71 @@ export interface SecretsApi {
10
10
  delete(options: { service: string; name: string }): Promise<boolean>;
11
11
  }
12
12
 
13
+ /**
14
+ * Injective escape of a name component to [A-Za-z0-9_]: alphanumerics are kept and every other
15
+ * character (including `_`) becomes `_<UPPERCASE HEX CODE POINT>_`.
16
+ */
17
+ function escapeComponent(value: string): string {
18
+ let result = "";
19
+ for (const char of value) {
20
+ result += /[a-zA-Z0-9]/.test(char)
21
+ ? char
22
+ : `_${char.codePointAt(0)!.toString(16).toUpperCase()}_`;
23
+ }
24
+ return result;
25
+ }
26
+
27
+ // `Z` is not a hex digit so `_Z` can never be mistaken for the start of an escape.
28
+ const SEPARATOR = "_Z";
29
+
13
30
  export default class DefaultSecretService implements SecretService {
14
31
  readonly #serviceName: string;
15
32
  readonly #secrets: SecretsApi;
16
- readonly #scope: string;
33
+ readonly #scope: ReadonlyArray<string>;
17
34
 
18
- constructor(serviceName: string, scope: string, secretsApi?: SecretsApi) {
19
- this.#serviceName = DefaultSecretService.#sanitize(serviceName);
35
+ /**
36
+ * @param scope the components identifying the scope, e.g. `["command", "a"]`.
37
+ */
38
+ constructor(serviceName: string, scope: ReadonlyArray<string>, secretsApi?: SecretsApi) {
39
+ this.#serviceName = serviceName.replace(/[^a-zA-Z0-9_]/g, "_");
20
40
  if (this.#serviceName.length > MAX_NAME_LENGTH) {
21
41
  throw new Error(
22
42
  `Service name exceeds ${MAX_NAME_LENGTH} characters after sanitization: '${this.#serviceName}'`,
23
43
  );
24
44
  }
25
- this.#scope = DefaultSecretService.#sanitize(scope);
45
+ this.#scope = scope;
26
46
  this.#secrets = secretsApi ?? bunSecrets;
27
47
  }
28
48
 
29
- static #sanitize(value: string): string {
30
- return value.replace(/[^a-zA-Z0-9_]/g, "_");
49
+ #scopePrefix(): string {
50
+ return this.#scope.map((c) => escapeComponent(c) + SEPARATOR).join("");
51
+ }
52
+
53
+ /**
54
+ * The name under which a secret with the provided key is stored. The scope and key components
55
+ * are escaped and delimited so that distinct (scope, key) pairs never produce the same name.
56
+ *
57
+ * @param key a single key or the components of a key, e.g. a key and a path within its value.
58
+ */
59
+ public secretName(key: string | ReadonlyArray<string>): string {
60
+ const parts = typeof key === "string" ? [key] : key;
61
+ return this.#scopePrefix() + parts.map(escapeComponent).join(SEPARATOR);
62
+ }
63
+
64
+ /**
65
+ * Whether the stored secret name was created by this scope, and, if `firstKeyComponent` is
66
+ * provided, with that value as the first component of its key.
67
+ */
68
+ public ownsSecretName(bunSecretName: string, firstKeyComponent?: string): boolean {
69
+ if (firstKeyComponent === undefined) {
70
+ return bunSecretName.startsWith(this.#scopePrefix());
71
+ }
72
+ const base = this.#scopePrefix() + escapeComponent(firstKeyComponent);
73
+ return bunSecretName === base || bunSecretName.startsWith(base + SEPARATOR);
31
74
  }
32
75
 
33
- public async setSecret(key: string, value: string): Promise<string> {
34
- const name = this.#scope + "_" + DefaultSecretService.#sanitize(key);
76
+ public async setSecret(key: string | ReadonlyArray<string>, value: string): Promise<string> {
77
+ const name = this.secretName(key);
35
78
  if (name.length > MAX_NAME_LENGTH) {
36
79
  throw new Error(`Secret name exceeds ${MAX_NAME_LENGTH} characters: '${name}'`);
37
80
  }
@@ -8,7 +8,7 @@ import type {
8
8
  } from "@flowscripter/dynamic-cli-framework-api";
9
9
  import { KEY_VALUE_SERVICE_ID, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
10
10
  import DefaultKeyValueService from "./DefaultKeyValueService.ts";
11
- import DefaultSecretService from "./DefaultSecretService.ts";
11
+ import DefaultSecretService, { type SecretsApi } from "./DefaultSecretService.ts";
12
12
  import type ConfigurationServiceProvider from "./ConfigurationServiceProvider.ts";
13
13
  import type { KeyValueServiceScopeType } from "./ConfigurationServiceProvider.ts";
14
14
  import type DefaultContext from "../../runtime/DefaultContext.ts";
@@ -118,6 +118,8 @@ export default class KeyValueServiceProvider implements ServiceProvider {
118
118
  readonly #commandScopedKeyValueServices = new Map<string, DefaultKeyValueService>();
119
119
  readonly #serviceScopedKeyValueServices = new Map<string, DefaultKeyValueService>();
120
120
 
121
+ readonly #secretsApi: SecretsApi | undefined;
122
+
121
123
  // used to construct scoped DefaultSecretService instances on demand.
122
124
  #cliConfigName: string | undefined;
123
125
 
@@ -129,12 +131,14 @@ export default class KeyValueServiceProvider implements ServiceProvider {
129
131
  * backs this provider's data - an internal wiring reference, never exposed further.
130
132
  * @param keyValueServiceEnabled optionally provide a {@link KeyValueService} implementation: `configurationServiceProvider.configEnabled` must be true in this case
131
133
  * @param secretServiceEnabled optionally enable OS-native secret storage via Bun.secrets: `configurationServiceProvider.configEnabled` must be true in this case
134
+ * @param secretsApi optionally override the OS secret store.
132
135
  */
133
136
  public constructor(
134
137
  servicePriority: number,
135
138
  configurationServiceProvider: ConfigurationServiceProvider,
136
139
  keyValueServiceEnabled = false,
137
140
  secretServiceEnabled = false,
141
+ secretsApi?: SecretsApi,
138
142
  ) {
139
143
  if (!configurationServiceProvider.configEnabled && keyValueServiceEnabled) {
140
144
  throw new Error("configEnabled must be true if keyValueServiceEnabled is true");
@@ -145,6 +149,7 @@ export default class KeyValueServiceProvider implements ServiceProvider {
145
149
  this.servicePriority = servicePriority;
146
150
  this.#configurationServiceProvider = configurationServiceProvider;
147
151
  this.keyValueServiceEnabled = keyValueServiceEnabled;
152
+ this.#secretsApi = secretsApi;
148
153
  this.secretServiceEnabled = secretServiceEnabled;
149
154
  }
150
155
 
@@ -191,7 +196,7 @@ export default class KeyValueServiceProvider implements ServiceProvider {
191
196
  const keyValueData = this.#configurationServiceProvider.getKeyValueData(scopeType, scopeKey);
192
197
 
193
198
  const secretService = this.secretServiceEnabled
194
- ? new DefaultSecretService(this.#cliConfigName!, `${scopeType}_${scopeKey}`)
199
+ ? new DefaultSecretService(this.#cliConfigName!, [scopeType, scopeKey], this.#secretsApi)
195
200
  : undefined;
196
201
  const scopedService = new DefaultKeyValueService(keyValueData, secretService, () =>
197
202
  this.#flushDirtyScopes(),
@@ -1,3 +1,4 @@
1
+ import type DefaultConfigurationService from "../DefaultConfigurationService.ts";
1
2
  import type { Context } from "@flowscripter/dynamic-cli-framework-api";
2
3
  import type { Option } from "@flowscripter/dynamic-cli-framework-api";
3
4
  import type { Positional } from "@flowscripter/dynamic-cli-framework-api";
@@ -9,12 +10,7 @@ import { SYNTAX_HIGHLIGHTER_SERVICE_ID } from "@flowscripter/dynamic-cli-framewo
9
10
 
10
11
  /**
11
12
  * Member of the `config` group which dumps the configuration loaded by
12
- * {@link ConfigurationServiceProvider}.
13
- *
14
- * Takes a direct reference to {@link ConfigurationServiceProvider.getConfigString}, wired by the
15
- * provider that constructs this command, rather than looking it up via {@link Context} -
16
- * deliberately, since a full config dump is not something every command/task should be able to
17
- * reach through a general lookup (see {@link ConfigurationService}'s doc comment).
13
+ * {@link DefaultConfigurationService}, with secrets masked.
18
14
  */
19
15
  export default class ConfigDumpSubCommand implements SubCommand {
20
16
  readonly name = "dump";
@@ -22,10 +18,10 @@ export default class ConfigDumpSubCommand implements SubCommand {
22
18
  readonly options: ReadonlyArray<Option> = [];
23
19
  readonly positionals: ReadonlyArray<Positional> = [];
24
20
 
25
- readonly #getConfigString: () => string;
21
+ readonly #configurationService: DefaultConfigurationService;
26
22
 
27
- public constructor(getConfigString: () => string) {
28
- this.#getConfigString = getConfigString;
23
+ public constructor(configurationService: DefaultConfigurationService) {
24
+ this.#configurationService = configurationService;
29
25
  }
30
26
 
31
27
  public async execute(context: Context): Promise<void> {
@@ -35,7 +31,7 @@ export default class ConfigDumpSubCommand implements SubCommand {
35
31
  ) as SyntaxHighlighterService;
36
32
 
37
33
  await printerService.print(
38
- `${syntaxHighlighterService.highlight(this.#getConfigString(), "json")}\n`,
34
+ `${syntaxHighlighterService.highlight(this.#configurationService.getMaskedConfigString(), "json")}\n`,
39
35
  );
40
36
  }
41
37
  }