@flowscripter/dynamic-cli-framework 1.1.6 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.github/workflows/release-bun-library.yml +1 -0
  2. package/README/core-commands.md +40 -0
  3. package/README/core-service-providers.md +198 -0
  4. package/README/development.md +60 -0
  5. package/README/implementation-details.md +474 -0
  6. package/README/key-concepts.md +751 -0
  7. package/README.md +12 -1413
  8. package/bun.lock +22 -18
  9. package/index.ts +97 -1
  10. package/package.json +8 -7
  11. package/src/api/BaseCLIFeatureOptions.ts +10 -0
  12. package/src/api/CLIConfig.ts +5 -0
  13. package/src/api/RunResult.ts +16 -0
  14. package/src/api/argument/Argument.ts +24 -0
  15. package/src/api/service/ServiceProvider.ts +3 -3
  16. package/src/api/service/core/ArgumentPrompterService.ts +10 -0
  17. package/src/api/service/core/AsciiBannerGeneratorService.ts +44 -7
  18. package/src/api/service/core/CompletionService.ts +36 -0
  19. package/src/api/service/core/DataDumpGeneratorService.ts +27 -0
  20. package/src/api/service/core/ImagePrinterService.ts +26 -0
  21. package/src/api/service/core/KeyValueService.ts +22 -5
  22. package/src/api/service/core/PrinterService.ts +127 -1
  23. package/src/api/service/core/PrompterService.ts +49 -0
  24. package/src/api/service/core/SecretService.ts +27 -0
  25. package/src/api/service/core/ShutdownService.ts +17 -1
  26. package/src/api/service/core/Table.ts +85 -0
  27. package/src/api/service/core/TableGeneratorService.ts +38 -0
  28. package/src/api/service/core/TreePrinterService.ts +11 -0
  29. package/src/cli/BaseCLI.ts +116 -44
  30. package/src/cli/DefaultRuntimeCLI.ts +10 -18
  31. package/src/command/CompletionCommand.ts +179 -0
  32. package/src/command/MultiCommandCliHelpCommand.ts +18 -2
  33. package/src/command/SingleCommandCliHelpCommand.ts +10 -1
  34. package/src/launcher.ts +16 -58
  35. package/src/runtime/command/CommandValidator.ts +3 -3
  36. package/src/runtime/runner.ts +102 -30
  37. package/src/runtime/scanner.ts +18 -15
  38. package/src/runtime/values/argumentValueValidation.ts +49 -5
  39. package/src/runtime/values/globalCommandValuePopulation.ts +1 -1
  40. package/src/runtime/values/subCommandValuePopulation.ts +9 -8
  41. package/src/service/argumentPrompter/ArgumentPrompterServiceProvider.ts +34 -0
  42. package/src/service/argumentPrompter/DefaultArgumentPrompterService.ts +330 -0
  43. package/src/service/asciiBannerGenerator/AsciiBannerGeneratorServiceProvider.ts +1 -1
  44. package/src/service/asciiBannerGenerator/DefaultAsciiBannerGeneratorService.ts +254 -17
  45. package/src/service/banner/BannerServiceProvider.ts +2 -2
  46. package/src/service/chiselAsciiBannerGenerator/ChiselFontAsciiBannerGeneratorService.ts +85 -0
  47. package/src/service/chiselAsciiBannerGenerator/chisel.flf.json +3 -0
  48. package/src/service/completion/CompletionServiceProvider.ts +212 -0
  49. package/src/service/completion/DefaultCompletionService.ts +364 -0
  50. package/src/service/completion/shell/ShellHandler.ts +16 -0
  51. package/src/service/completion/shell/bash.ts +47 -0
  52. package/src/service/completion/shell/fish.ts +43 -0
  53. package/src/service/completion/shell/powershell.ts +66 -0
  54. package/src/service/completion/shell/zsh.ts +50 -0
  55. package/src/service/configuration/ConfigurationServiceProvider.ts +138 -42
  56. package/src/service/configuration/DefaultKeyValueService.ts +53 -7
  57. package/src/service/configuration/DefaultSecretService.ts +85 -0
  58. package/src/service/dataDumpGenerator/DataDumpGeneratorServiceProvider.ts +41 -0
  59. package/src/service/dataDumpGenerator/DefaultDataDumpGeneratorService.ts +80 -0
  60. package/src/service/imagePrinter/DefaultImagePrinterService.ts +23 -0
  61. package/src/service/imagePrinter/ImagePrinterServiceProvider.ts +32 -0
  62. package/src/service/prettyPrinter/PrettyPrinterServiceProvider.ts +1 -1
  63. package/src/service/printer/DefaultPrinterService.ts +147 -27
  64. package/src/service/printer/PrinterServiceProvider.ts +7 -7
  65. package/src/service/printer/command/NoHyperlinksCommand.ts +40 -0
  66. package/src/service/printer/terminal/Progress.ts +21 -4
  67. package/src/service/printer/terminal/Spinner.ts +32 -8
  68. package/src/service/printer/terminal/Theme.ts +1 -1
  69. package/src/service/prompter/DefaultPrompterService.ts +87 -0
  70. package/src/service/prompter/PrompterServiceProvider.ts +36 -0
  71. package/src/service/prompter/command/NoPromptCommand.ts +40 -0
  72. package/src/service/prompter/prompt/PromptContext.ts +47 -0
  73. package/src/service/prompter/prompt/promptAcknowledge.ts +28 -0
  74. package/src/service/prompter/prompt/promptMultiSelect.ts +99 -0
  75. package/src/service/prompter/prompt/promptOpenUrl.ts +97 -0
  76. package/src/service/prompter/prompt/promptSingleSelect.ts +102 -0
  77. package/src/service/prompter/prompt/promptText.ts +107 -0
  78. package/src/service/prompter/prompt/promptToggle.ts +28 -0
  79. package/src/service/shutdown/DefaultShutdownService.ts +13 -0
  80. package/src/service/shutdown/ShutdownServiceProvider.ts +28 -9
  81. package/src/service/shutdown/ShutdownState.ts +5 -0
  82. package/src/service/syntaxHighlighter/SyntaxHighlighterServiceProvider.ts +1 -1
  83. package/src/service/tableGenerator/DefaultTableGeneratorService.ts +415 -0
  84. package/src/service/tableGenerator/TableGeneratorServiceProvider.ts +43 -0
  85. package/src/service/treePrinter/DefaultTreePrinterService.ts +49 -0
  86. package/src/service/treePrinter/TreePrinterServiceProvider.ts +40 -0
  87. package/src/terminal/Ansi.ts +27 -0
  88. package/src/terminal/ImageRenderer.ts +199 -0
  89. package/src/terminal/KeyReader.ts +23 -0
  90. package/src/terminal/PngDecoder.ts +143 -0
  91. package/src/{service/printer/terminal → terminal}/Styler.ts +3 -0
  92. package/src/{service/printer/terminal → terminal}/Terminal.ts +1 -0
  93. package/src/terminal/TtyKeyReader.ts +83 -0
  94. package/src/terminal/TtyStyler.ts +78 -0
  95. package/src/{service/printer/terminal → terminal}/TtyTerminal.ts +9 -1
  96. package/src/terminal/supportsHyperlinks.ts +110 -0
  97. package/src/util/configHelper.ts +2 -2
  98. package/src/util/envVarHelper.ts +2 -2
  99. package/src/util/helpHelper.ts +66 -122
  100. package/src/util/logger.ts +16 -14
  101. package/src/util/runnerHelper.ts +1 -1
  102. package/tests/cli/BaseCLI.test.ts +50 -54
  103. package/tests/command/CompletionCommand.test.ts +68 -0
  104. package/tests/command/UsageCommand.test.ts +3 -2
  105. package/tests/command/VersionCommand.test.ts +3 -2
  106. package/tests/fixtures/Context.ts +9 -2
  107. package/tests/fixtures/util.ts +1 -1
  108. package/tests/runtime/parser.test.ts +9 -7
  109. package/tests/runtime/registry/DefaultServiceProviderRegistry.test.ts +3 -3
  110. package/tests/runtime/values/argumentValueValidation.test.ts +193 -0
  111. package/tests/service/argumentPrompter/ArgumentPrompterServiceProvider.test.ts +47 -0
  112. package/tests/service/argumentPrompter/DefaultArgumentPrompterService.test.ts +754 -0
  113. package/tests/service/asciiBannerGenerator/AsciiBannerGeneratorServiceProvider.test.ts +5 -4
  114. package/tests/service/asciiBannerGenerator/DefaultAsciiBannerGeneratorService.test.ts +163 -3
  115. package/tests/service/asciiBannerGenerator/__snapshots__/DefaultAsciiBannerGeneratorService.test.ts.snap +1 -1
  116. package/tests/service/banner/BannerServiceProvider.test.ts +8 -4
  117. package/tests/service/chiselAsciiBannerGenerator/ChiselFontAsciiBannerGeneratorService.test.ts +52 -0
  118. package/tests/service/completion/CompletionServiceProvider.test.ts +404 -0
  119. package/tests/service/completion/DefaultCompletionService.test.ts +278 -0
  120. package/tests/service/completion/shell/bash.test.ts +73 -0
  121. package/tests/service/completion/shell/fish.test.ts +62 -0
  122. package/tests/service/completion/shell/powershell.test.ts +59 -0
  123. package/tests/service/completion/shell/zsh.test.ts +60 -0
  124. package/tests/service/configuration/ConfigurationServiceProvider.test.ts +24 -12
  125. package/tests/service/configuration/DefaultKeyValueService.test.ts +118 -16
  126. package/tests/service/configuration/DefaultSecretService.test.ts +148 -0
  127. package/tests/service/configuration/command/ConfigCommand.test.ts +29 -0
  128. package/tests/service/configuration/command/DumpConfigCommand.test.ts +60 -0
  129. package/tests/service/dataDumpGenerator/DataDumpGeneratorServiceProvider.test.ts +57 -0
  130. package/tests/service/dataDumpGenerator/DefaultDataDumpGeneratorService.test.ts +217 -0
  131. package/tests/service/imagePrinter/DefaultImagePrinterService.test.ts +96 -0
  132. package/tests/service/imagePrinter/ImagePrinterServiceProvider.test.ts +17 -0
  133. package/tests/service/prettyPrinter/DefaultPrettyPrinterService.test.ts +2 -2
  134. package/tests/service/prettyPrinter/PrettyPrinterServiceProvider.test.ts +5 -4
  135. package/tests/service/printer/DefaultPrinterService.test.ts +233 -2
  136. package/tests/service/printer/PrinterServiceProvider.test.ts +6 -5
  137. package/tests/service/printer/command/DarkModeCommand.test.ts +83 -0
  138. package/tests/service/printer/command/LogLevelCommand.test.ts +7 -5
  139. package/tests/service/printer/command/NoColorCommand.test.ts +83 -0
  140. package/tests/service/printer/command/NoHyperlinksCommand.test.ts +89 -0
  141. package/tests/service/printer/terminal/Progress.test.ts +64 -4
  142. package/tests/service/printer/terminal/Spinner.test.ts +44 -3
  143. package/tests/service/prompter/DefaultPrompterService.test.ts +615 -0
  144. package/tests/service/prompter/PrompterServiceProvider.test.ts +47 -0
  145. package/tests/service/prompter/command/NoPromptCommand.test.ts +58 -0
  146. package/tests/service/shutdown/ShutdownServiceProvider.test.ts +4 -2
  147. package/tests/service/syntaxHighlighter/DefaultSyntaxHighlighterService.test.ts +3 -2
  148. package/tests/service/syntaxHighlighter/SyntaxHighlighterServiceProvider.test.ts +5 -4
  149. package/tests/service/tableGenerator/DefaultTableGeneratorService.test.ts +363 -0
  150. package/tests/service/tableGenerator/TableGeneratorServiceProvider.test.ts +47 -0
  151. package/tests/service/treePrinter/DefaultTreePrinterService.test.ts +144 -0
  152. package/tests/service/treePrinter/TreePrinterServiceProvider.test.ts +55 -0
  153. package/tests/terminal/ImageRenderer.test.ts +161 -0
  154. package/tests/terminal/PngDecoder.test.ts +223 -0
  155. package/tests/terminal/supportsHyperlinks.test.ts +149 -0
  156. package/tests/util/configHelper.test.ts +4 -4
  157. package/tests/util/logger.test.ts +23 -1
  158. package/tsconfig.json +31 -0
  159. package/src/service/printer/terminal/Ansi.ts +0 -10
  160. package/src/service/printer/terminal/TtyStyler.ts +0 -39
@@ -0,0 +1,66 @@
1
+ import process from "node:process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CompletionItem } from "../../../api/service/core/CompletionService.ts";
5
+ import type ShellHandler from "./ShellHandler.ts";
6
+ import type { CompletionContext } from "./ShellHandler.ts";
7
+
8
+ // Reference: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/register-argumentcompleter
9
+
10
+ export default class PowerShellShellHandler implements ShellHandler {
11
+ getDefaultConfigPath(): string {
12
+ if (process.platform === "win32") {
13
+ return join(
14
+ homedir(),
15
+ "Documents",
16
+ "PowerShell",
17
+ "Microsoft.PowerShell_profile.ps1",
18
+ );
19
+ }
20
+ return join(
21
+ homedir(),
22
+ ".config",
23
+ "powershell",
24
+ "Microsoft.PowerShell_profile.ps1",
25
+ );
26
+ }
27
+
28
+ getBootstrapScript(cliName: string): string {
29
+ return [
30
+ `Register-ArgumentCompleter -Native -CommandName ${cliName} -ScriptBlock {`,
31
+ ` param($wordToComplete, $commandAst, $cursorPosition)`,
32
+ ` $completions = & ${cliName} completions:complete powershell "$commandAst" "$cursorPosition"`,
33
+ ` $completions -split '\\n' | ForEach-Object {`,
34
+ ` [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)`,
35
+ ` }`,
36
+ `}`,
37
+ ].join("\n");
38
+ }
39
+
40
+ validateEnvironment(): Promise<boolean> {
41
+ try {
42
+ const result = Bun.spawnSync(["pwsh", "--version"]);
43
+ if (result.exitCode === 0) {
44
+ return Promise.resolve(true);
45
+ }
46
+ } catch {
47
+ // try powershell fallback
48
+ }
49
+ try {
50
+ const result = Bun.spawnSync(["powershell", "-Command", "echo ok"]);
51
+ return Promise.resolve(result.exitCode === 0);
52
+ } catch {
53
+ return Promise.resolve(false);
54
+ }
55
+ }
56
+
57
+ formatCompletions(completions: ReadonlyArray<CompletionItem>): string {
58
+ return completions.map((c) => c.value).join("\n");
59
+ }
60
+
61
+ parseCompletionContext(args: ReadonlyArray<string>): CompletionContext {
62
+ const line = args[0] || "";
63
+ const cursorPosition = parseInt(args[1] || "0", 10) || line.length;
64
+ return { line, cursorPosition };
65
+ }
66
+ }
@@ -0,0 +1,50 @@
1
+ import process from "node:process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CompletionItem } from "../../../api/service/core/CompletionService.ts";
5
+ import type ShellHandler from "./ShellHandler.ts";
6
+ import type { CompletionContext } from "./ShellHandler.ts";
7
+
8
+ // Reference: https://zsh.sourceforge.io/Doc/Release/Completion-System.html
9
+
10
+ export default class ZshShellHandler implements ShellHandler {
11
+ getDefaultConfigPath(): string {
12
+ return join(homedir(), ".zshrc");
13
+ }
14
+
15
+ getBootstrapScript(cliName: string): string {
16
+ return [
17
+ `_${cliName}_completions() {`,
18
+ ` local -a completions`,
19
+ ` completions=("\${(@f)$(${cliName} completions:complete zsh "\${words}" "\${CURSOR}")}")`,
20
+ ` _describe '${cliName}' completions`,
21
+ `}`,
22
+ `compdef _${cliName}_completions ${cliName}`,
23
+ ].join("\n");
24
+ }
25
+
26
+ validateEnvironment(): Promise<boolean> {
27
+ const shell = process.env.SHELL || "";
28
+ if (shell.endsWith("/zsh")) {
29
+ return Promise.resolve(true);
30
+ }
31
+ try {
32
+ const result = Bun.spawnSync(["zsh", "--version"]);
33
+ return Promise.resolve(result.exitCode === 0);
34
+ } catch {
35
+ return Promise.resolve(false);
36
+ }
37
+ }
38
+
39
+ formatCompletions(completions: ReadonlyArray<CompletionItem>): string {
40
+ return completions
41
+ .map((c) => c.description ? `${c.value}:${c.description}` : c.value)
42
+ .join("\n");
43
+ }
44
+
45
+ parseCompletionContext(args: ReadonlyArray<string>): CompletionContext {
46
+ const line = args[0] || "";
47
+ const cursorPosition = parseInt(args[1] || "0", 10) || line.length;
48
+ return { line, cursorPosition };
49
+ }
50
+ }
@@ -16,8 +16,12 @@ import type {
16
16
  import getLogger from "../../util/logger.ts";
17
17
  import type Context from "../../api/Context.ts";
18
18
  import DumpConfigCommand from "./command/DumpConfigCommand.ts";
19
- import { KEY_VALUE_SERVICE_ID } from "../../api/service/core/KeyValueService.ts";
19
+ import {
20
+ KEY_VALUE_SERVICE_ID,
21
+ SECRET_SENTINEL_PREFIX,
22
+ } from "../../api/service/core/KeyValueService.ts";
20
23
  import DefaultKeyValueService from "./DefaultKeyValueService.ts";
24
+ import DefaultSecretService from "./DefaultSecretService.ts";
21
25
  import type Command from "../../api/command/Command.ts";
22
26
  import argumentValueMerge from "../../runtime/values/argumentValueMerge.ts";
23
27
  import {
@@ -79,6 +83,11 @@ const logger = getLogger("ConfigurationServiceProvider");
79
83
  * default configuration will be used. The location of the configuration file can be modified via the
80
84
  * {@link ConfigCommand}.
81
85
  *
86
+ * NOTE: You may store default arguments for secrets by manually configuring secrets in your OS
87
+ * credential store and referencing them in the config file using the sentinel format
88
+ * `__SECRET__:<bun_secret_name>`. These values will be resolved from the OS secret store
89
+ * before being returned as default argument values. See "Generic Key-Value Service" below.
90
+ *
82
91
  * *Environment Variables*
83
92
  *
84
93
  * Values are parsed using a key path defined by custom {@link Argument.configurationKey}
@@ -119,12 +128,21 @@ const logger = getLogger("ConfigurationServiceProvider");
119
128
  * **Generic Key-Value Service**
120
129
  *
121
130
  * The same configuration file above is used to provide storage for the provided {@link KeyValueService}. The values
122
- * are stored under a top level `key-values` property. These are not expected to be modified by the user of the CLI.
131
+ * are stored under a top level `key-values` property. These are expected to not be modified by the user of the CLI.
123
132
  *
124
133
  * The second and third level of properties is used to scope the key-values to specific command names
125
134
  * (via {@link Command.name} values) and specific service IDs (via {@link ServiceInfo.serviceId} values).
126
135
  * Both keys and values are string based.
127
136
  *
137
+ * Values may be stored as OS-native secrets via {@link KeyValueService.setKey} with `isSecret=true`.
138
+ * When a value is stored as a secret, a sentinel value of the format `__SECRET__:<bun_secret_name>`
139
+ * is stored in the config file instead of the actual value. The actual value is stored in the OS
140
+ * credential store via Bun.secrets. The sentinel prefix `__SECRET__:` is reserved and must not be
141
+ * used for regular key-value data.
142
+ *
143
+ * Secret support requires `secretServiceEnabled=true` in the constructor (which also requires
144
+ * `configEnabled=true`).
145
+ *
128
146
  * As an example:
129
147
  * ```
130
148
  * {
@@ -135,7 +153,7 @@ const logger = getLogger("ConfigurationServiceProvider");
135
153
  * "commands": {
136
154
  * "command1": {
137
155
  * "foo1": "bar1",
138
- * "foo2": "bar2"
156
+ * "foo2": "__SECRET__:command_command1_foo2"
139
157
  * },
140
158
  * "command2": {
141
159
  * "foo1": "bar3"
@@ -157,6 +175,7 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
157
175
  public readonly envVarsEnabled: boolean;
158
176
  public readonly configEnabled: boolean;
159
177
  public readonly keyValueServiceEnabled: boolean;
178
+ public readonly secretServiceEnabled: boolean;
160
179
 
161
180
  // the location of the currently managed configuration data
162
181
  public configLocation: string | undefined;
@@ -178,7 +197,10 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
178
197
  #serviceKeyValueData = new Map<string, Map<string, string>>();
179
198
 
180
199
  // the optional service providing scope limited key-value data to commands and other services
181
- readonly #defaultKeyValueService: DefaultKeyValueService | undefined;
200
+ #defaultKeyValueService: DefaultKeyValueService | undefined;
201
+
202
+ // the optional service providing OS-native secret storage
203
+ #defaultSecretService: DefaultSecretService | undefined;
182
204
 
183
205
  #currentCommandNameKeyValueScope: string | undefined;
184
206
  #currentServiceIdKeyValueScope: string | undefined;
@@ -190,36 +212,49 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
190
212
  * @param envVarsEnabled optionally support checking env variables for default argument values.
191
213
  * @param configEnabled optionally enable configuration file support for default argument values.
192
214
  * @param keyValueServiceEnabled optionally provide a {@link KeyValueService} implementation: `configEnabled` must be true in this case
215
+ * @param secretServiceEnabled optionally enable OS-native secret storage via Bun.secrets: `configEnabled` must be true in this case
193
216
  */
194
217
  public constructor(
195
218
  servicePriority: number,
196
219
  envVarsEnabled = false,
197
220
  configEnabled = false,
198
221
  keyValueServiceEnabled = false,
222
+ secretServiceEnabled = false,
199
223
  ) {
200
224
  if (!configEnabled && keyValueServiceEnabled) {
201
225
  throw new Error(
202
226
  "configEnabled must be true if keyValueServiceEnabled is true",
203
227
  );
204
228
  }
229
+ if (!configEnabled && secretServiceEnabled) {
230
+ throw new Error(
231
+ "configEnabled must be true if secretServiceEnabled is true",
232
+ );
233
+ }
205
234
  this.servicePriority = servicePriority;
206
235
  this.envVarsEnabled = envVarsEnabled;
207
236
  this.configEnabled = configEnabled;
208
237
  this.keyValueServiceEnabled = keyValueServiceEnabled;
209
- if (keyValueServiceEnabled) {
210
- this.#defaultKeyValueService = new DefaultKeyValueService();
211
- }
238
+ this.secretServiceEnabled = secretServiceEnabled;
212
239
  }
213
240
 
214
- public provide(_cliConfig: CLIConfig): Promise<ServiceInfo> {
241
+ public getServiceInfo(cliConfig: CLIConfig): Promise<ServiceInfo> {
215
242
  const commands: Array<Command> = [];
216
243
 
217
244
  if (this.configEnabled) {
218
245
  commands.push(new ConfigCommand(this, this.servicePriority));
219
246
  commands.push(new DumpConfigCommand(this));
220
247
  }
248
+ if (this.secretServiceEnabled) {
249
+ this.#defaultSecretService = new DefaultSecretService(cliConfig.name);
250
+ }
251
+ if (this.keyValueServiceEnabled || this.secretServiceEnabled) {
252
+ this.#defaultKeyValueService = new DefaultKeyValueService(
253
+ this.#defaultSecretService,
254
+ );
255
+ }
221
256
  return Promise.resolve({
222
- // this may be undefined if keyValueServiceEnabled is false
257
+ // this may be undefined if keyValueServiceEnabled and secretServiceEnabled are false
223
258
  service: this.#defaultKeyValueService,
224
259
  // this may be empty if configEnabled is false
225
260
  commands,
@@ -231,17 +266,19 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
231
266
  }
232
267
 
233
268
  /**
234
- * Retrieve the defaults command argument values (if any) for the provided {@link Command}.
269
+ * Retrieve the default command argument values (if any) for the provided {@link Command}.
235
270
  *
236
271
  * This will retrieve values both from the configuration location and environment variables.
237
272
  *
238
273
  * @param cliConfig the {@link CLIConfig} to use for retrieving configuration keys.
239
274
  * @param command the {@link Command} instance to retrieve default argument values for.
240
275
  */
241
- public getDefaultArgumentValues(
276
+ public async getDefaultArgumentValues(
242
277
  cliConfig: CLIConfig,
243
278
  command: Command,
244
- ): PopulatedArgumentValues | PopulatedArgumentValueType | undefined {
279
+ ): Promise<
280
+ PopulatedArgumentValues | PopulatedArgumentValueType | undefined
281
+ > {
245
282
  if (!this.configEnabled) {
246
283
  logger.debug(
247
284
  "configuration of default values is not enabled",
@@ -265,6 +302,11 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
265
302
  return undefined;
266
303
  }
267
304
 
305
+ let result:
306
+ | PopulatedArgumentValues
307
+ | PopulatedArgumentValueType
308
+ | undefined;
309
+
268
310
  if (isGlobalModifierCommand(command) || isGlobalCommand(command)) {
269
311
  const configuredValue = this.defaultsData.get(
270
312
  command.name,
@@ -276,11 +318,7 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
276
318
  )
277
319
  : undefined;
278
320
  // default to environment variable value
279
- if (envVarValue !== undefined) {
280
- return envVarValue;
281
- }
282
-
283
- return configuredValue;
321
+ result = envVarValue !== undefined ? envVarValue : configuredValue;
284
322
  } else if (isSubCommand(command)) {
285
323
  const configuredValues = this.defaultsData.get(
286
324
  command.name,
@@ -292,16 +330,56 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
292
330
  )
293
331
  : undefined;
294
332
  if (envVarValues === undefined) {
295
- return configuredValues;
333
+ result = configuredValues;
334
+ } else if (configuredValues === undefined) {
335
+ result = envVarValues;
336
+ } else {
337
+ result = argumentValueMerge(
338
+ envVarValues,
339
+ configuredValues,
340
+ ) as PopulatedArgumentValues;
341
+ }
342
+ }
343
+
344
+ if (result !== undefined && this.#defaultSecretService) {
345
+ result = await this.#resolveSecrets(result);
346
+ }
347
+
348
+ return result;
349
+ }
350
+
351
+ async #resolveSecrets(
352
+ value: PopulatedArgumentValues | PopulatedArgumentValueType,
353
+ ): Promise<PopulatedArgumentValues | PopulatedArgumentValueType> {
354
+ if (typeof value === "string" && value.startsWith(SECRET_SENTINEL_PREFIX)) {
355
+ const bunSecretName = value.slice(SECRET_SENTINEL_PREFIX.length);
356
+ const secretValue = await this.#defaultSecretService!.getSecret(
357
+ bunSecretName,
358
+ );
359
+ if (secretValue === null) {
360
+ throw new Error(
361
+ `Secret not found in OS secret store for sentinel: '${value}'`,
362
+ );
296
363
  }
297
- if (configuredValues === undefined) {
298
- return envVarValues;
364
+ return secretValue;
365
+ }
366
+ if ((typeof value === "object") && (value !== null)) {
367
+ if (Array.isArray(value)) {
368
+ const resolved = [];
369
+ for (const item of value) {
370
+ resolved.push(await this.#resolveSecrets(item));
371
+ }
372
+ return resolved as unknown as PopulatedArgumentValues;
373
+ }
374
+ const resolved: Record<string, unknown> = {};
375
+ for (const [key, val] of Object.entries(value)) {
376
+ resolved[key] = await this.#resolveSecrets(
377
+ val as PopulatedArgumentValueType,
378
+ );
299
379
  }
300
- return argumentValueMerge(
301
- envVarValues,
302
- configuredValues,
303
- ) as PopulatedArgumentValues;
380
+ return resolved as unknown as PopulatedArgumentValues;
304
381
  }
382
+ return value;
305
383
  }
306
384
 
307
385
  /**
@@ -313,9 +391,9 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
313
391
  * @throws {Error} if an existing scope is set.
314
392
  */
315
393
  public setCommandKeyValueScope(commandName: string): void {
316
- if (!this.keyValueServiceEnabled) {
394
+ if (!this.keyValueServiceEnabled && !this.secretServiceEnabled) {
317
395
  throw new Error(
318
- `Attempt to use KeyValueService which is not enabled`,
396
+ `Attempt to use KeyValueService/SecretService which is not enabled`,
319
397
  );
320
398
  }
321
399
  if (this.#currentCommandNameKeyValueScope) {
@@ -332,12 +410,17 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
332
410
 
333
411
  logger.debug("currentCommandNameKeyValueScope: %s", commandName);
334
412
 
335
- if (!this.#commandKeyValueData.has(commandName)) {
336
- this.#commandKeyValueData.set(commandName, new Map());
413
+ if (this.#defaultKeyValueService) {
414
+ if (!this.#commandKeyValueData.has(commandName)) {
415
+ this.#commandKeyValueData.set(commandName, new Map());
416
+ }
417
+ this.#defaultKeyValueService.setKeyValueData(
418
+ this.#commandKeyValueData.get(commandName)!,
419
+ );
420
+ }
421
+ if (this.#defaultSecretService) {
422
+ this.#defaultSecretService.setScope("command_" + commandName);
337
423
  }
338
- this.#defaultKeyValueService!.setKeyValueData(
339
- this.#commandKeyValueData.get(commandName)!,
340
- );
341
424
  }
342
425
 
343
426
  /**
@@ -349,9 +432,9 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
349
432
  * @throws {Error} if an existing scope is set.
350
433
  */
351
434
  public setServiceKeyValueScope(serviceId: string): void {
352
- if (!this.keyValueServiceEnabled) {
435
+ if (!this.keyValueServiceEnabled && !this.secretServiceEnabled) {
353
436
  throw new Error(
354
- `Attempt to use KeyValueService which is not enabled`,
437
+ `Attempt to use KeyValueService/SecretService which is not enabled`,
355
438
  );
356
439
  }
357
440
  if (this.#currentServiceIdKeyValueScope) {
@@ -368,12 +451,17 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
368
451
 
369
452
  logger.debug("currentServiceIdKeyValueScope: %s", serviceId);
370
453
 
371
- if (!this.#serviceKeyValueData.has(serviceId)) {
372
- this.#serviceKeyValueData.set(serviceId, new Map());
454
+ if (this.#defaultKeyValueService) {
455
+ if (!this.#serviceKeyValueData.has(serviceId)) {
456
+ this.#serviceKeyValueData.set(serviceId, new Map());
457
+ }
458
+ this.#defaultKeyValueService.setKeyValueData(
459
+ this.#serviceKeyValueData.get(serviceId)!,
460
+ );
461
+ }
462
+ if (this.#defaultSecretService) {
463
+ this.#defaultSecretService.setScope("service_" + serviceId);
373
464
  }
374
- this.#defaultKeyValueService!.setKeyValueData(
375
- this.#serviceKeyValueData.get(serviceId)!,
376
- );
377
465
  }
378
466
 
379
467
  /**
@@ -383,12 +471,15 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
383
471
  * to the current configuration location.
384
472
  */
385
473
  public async clearKeyValueScope(): Promise<void> {
386
- if (!this.keyValueServiceEnabled) {
474
+ if (!this.keyValueServiceEnabled && !this.secretServiceEnabled) {
387
475
  throw new Error(
388
- `Attempt to use KeyValueService which is not enabled`,
476
+ `Attempt to use KeyValueService/SecretService which is not enabled`,
389
477
  );
390
478
  }
391
- if (this.#defaultKeyValueService!.isDirty()) {
479
+ if (
480
+ this.#defaultKeyValueService &&
481
+ this.#defaultKeyValueService.isDirty()
482
+ ) {
392
483
  if (this.configLocation === undefined) {
393
484
  throw new Error(
394
485
  "Attempt to write updated config with no configLocation set",
@@ -399,7 +490,12 @@ export default class ConfigurationServiceProvider implements ServiceProvider {
399
490
  this.#currentCommandNameKeyValueScope = undefined;
400
491
  this.#currentServiceIdKeyValueScope = undefined;
401
492
 
402
- this.#defaultKeyValueService!.clearKeyValueData();
493
+ if (this.#defaultKeyValueService) {
494
+ this.#defaultKeyValueService.clearKeyValueData();
495
+ }
496
+ if (this.#defaultSecretService) {
497
+ this.#defaultSecretService.clearScope();
498
+ }
403
499
  }
404
500
 
405
501
  public async initService(context: Context): Promise<void> {
@@ -1,8 +1,15 @@
1
1
  import type KeyValueService from "../../api/service/core/KeyValueService.ts";
2
+ import { SECRET_SENTINEL_PREFIX } from "../../api/service/core/KeyValueService.ts";
3
+ import type DefaultSecretService from "./DefaultSecretService.ts";
2
4
 
3
5
  export default class DefaultKeyValueService implements KeyValueService {
4
6
  #keyValueData: Map<string, string> | undefined;
5
7
  #dirty = false;
8
+ readonly #secretService: DefaultSecretService | undefined;
9
+
10
+ constructor(secretService?: DefaultSecretService) {
11
+ this.#secretService = secretService;
12
+ }
6
13
 
7
14
  public setKeyValueData(keyValueData: Map<string, string>) {
8
15
  if (this.#keyValueData) {
@@ -23,7 +30,7 @@ export default class DefaultKeyValueService implements KeyValueService {
23
30
  return this.#dirty;
24
31
  }
25
32
 
26
- public getKey(key: string): string {
33
+ public async getKey(key: string): Promise<string> {
27
34
  if (this.#keyValueData === undefined) {
28
35
  throw new Error("Attempt to access undefined key-value data");
29
36
  }
@@ -32,28 +39,67 @@ export default class DefaultKeyValueService implements KeyValueService {
32
39
  throw new Error("Attempt to access unknown key");
33
40
  }
34
41
 
42
+ if (value.startsWith(SECRET_SENTINEL_PREFIX)) {
43
+ if (!this.#secretService) {
44
+ throw new Error(
45
+ "Secret sentinel found but no secret service is available",
46
+ );
47
+ }
48
+ const bunSecretName = value.slice(SECRET_SENTINEL_PREFIX.length);
49
+ const secretValue = await this.#secretService.getSecret(bunSecretName);
50
+ if (secretValue === null) {
51
+ throw new Error(
52
+ `Secret not found in OS secret store for key: '${key}'`,
53
+ );
54
+ }
55
+ return secretValue;
56
+ }
57
+
35
58
  return value;
36
59
  }
37
60
 
38
- public hasKey(key: string): boolean {
61
+ public hasKey(key: string): Promise<boolean> {
39
62
  if (this.#keyValueData === undefined) {
40
- throw new Error("Attempt to access undefined key-value data");
63
+ return Promise.reject(
64
+ new Error("Attempt to access undefined key-value data"),
65
+ );
41
66
  }
42
- return this.#keyValueData.has(key);
67
+ return Promise.resolve(this.#keyValueData.has(key));
43
68
  }
44
69
 
45
- public setKey(key: string, value: string): void {
70
+ public async setKey(
71
+ key: string,
72
+ value: string,
73
+ isSecret = false,
74
+ ): Promise<void> {
46
75
  if (this.#keyValueData === undefined) {
47
76
  throw new Error("Attempt to access undefined key-value data");
48
77
  }
49
- this.#keyValueData.set(key, value);
78
+ if (isSecret) {
79
+ if (!this.#secretService) {
80
+ throw new Error(
81
+ "Attempt to set a secret but no secret service is available",
82
+ );
83
+ }
84
+ const bunSecretName = await this.#secretService.setSecret(key, value);
85
+ this.#keyValueData.set(key, SECRET_SENTINEL_PREFIX + bunSecretName);
86
+ } else {
87
+ this.#keyValueData.set(key, value);
88
+ }
50
89
  this.#dirty = true;
51
90
  }
52
91
 
53
- public deleteKey(key: string): void {
92
+ public async deleteKey(key: string): Promise<void> {
54
93
  if (this.#keyValueData === undefined) {
55
94
  throw new Error("Attempt to access undefined key-value data");
56
95
  }
96
+ const value = this.#keyValueData.get(key);
97
+ if (value !== undefined && value.startsWith(SECRET_SENTINEL_PREFIX)) {
98
+ if (this.#secretService) {
99
+ const bunSecretName = value.slice(SECRET_SENTINEL_PREFIX.length);
100
+ await this.#secretService.deleteSecret(bunSecretName);
101
+ }
102
+ }
57
103
  this.#keyValueData.delete(key);
58
104
  this.#dirty = true;
59
105
  }
@@ -0,0 +1,85 @@
1
+ import type SecretService from "../../api/service/core/SecretService.ts";
2
+ import { secrets as bunSecrets } from "bun";
3
+
4
+ const MAX_NAME_LENGTH = 255;
5
+ const MAX_VALUE_BYTES = 2047;
6
+
7
+ interface SecretsApi {
8
+ get(options: { service: string; name: string }): Promise<string | null>;
9
+ set(
10
+ options: { service: string; name: string; value: string },
11
+ ): Promise<void>;
12
+ delete(options: { service: string; name: string }): Promise<boolean>;
13
+ }
14
+
15
+ export default class DefaultSecretService implements SecretService {
16
+ readonly #serviceName: string;
17
+ readonly #secrets: SecretsApi;
18
+ #scope: string | undefined;
19
+
20
+ constructor(serviceName: string, secretsApi?: SecretsApi) {
21
+ this.#serviceName = DefaultSecretService.#sanitize(serviceName);
22
+ if (this.#serviceName.length > MAX_NAME_LENGTH) {
23
+ throw new Error(
24
+ `Service name exceeds ${MAX_NAME_LENGTH} characters after sanitization: '${this.#serviceName}'`,
25
+ );
26
+ }
27
+ this.#secrets = secretsApi ?? bunSecrets;
28
+ }
29
+
30
+ static #sanitize(value: string): string {
31
+ return value.replace(/[^a-zA-Z0-9_]/g, "_");
32
+ }
33
+
34
+ public setScope(scope: string): void {
35
+ this.#scope = DefaultSecretService.#sanitize(scope);
36
+ }
37
+
38
+ public clearScope(): void {
39
+ this.#scope = undefined;
40
+ }
41
+
42
+ public async setSecret(key: string, value: string): Promise<string> {
43
+ if (this.#scope === undefined) {
44
+ throw new Error("Attempt to set secret without a scope being set");
45
+ }
46
+ const name = this.#scope + "_" + DefaultSecretService.#sanitize(key);
47
+ if (name.length > MAX_NAME_LENGTH) {
48
+ throw new Error(
49
+ `Secret name exceeds ${MAX_NAME_LENGTH} characters: '${name}'`,
50
+ );
51
+ }
52
+ if (new TextEncoder().encode(value).byteLength > MAX_VALUE_BYTES) {
53
+ throw new Error(
54
+ `Secret value exceeds ${MAX_VALUE_BYTES} bytes`,
55
+ );
56
+ }
57
+ await this.#secrets.set({
58
+ service: this.#serviceName,
59
+ name,
60
+ value,
61
+ });
62
+ return name;
63
+ }
64
+
65
+ public async getSecret(bunSecretName: string): Promise<string | null> {
66
+ return await this.#secrets.get({
67
+ service: this.#serviceName,
68
+ name: bunSecretName,
69
+ });
70
+ }
71
+
72
+ public async deleteSecret(bunSecretName: string): Promise<boolean> {
73
+ return await this.#secrets.delete({
74
+ service: this.#serviceName,
75
+ name: bunSecretName,
76
+ });
77
+ }
78
+
79
+ public async hasSecret(bunSecretName: string): Promise<boolean> {
80
+ return (await this.#secrets.get({
81
+ service: this.#serviceName,
82
+ name: bunSecretName,
83
+ })) !== null;
84
+ }
85
+ }
@@ -0,0 +1,41 @@
1
+ import type {
2
+ ServiceInfo,
3
+ ServiceProvider,
4
+ } from "../../api/service/ServiceProvider.ts";
5
+ import DefaultDataDumpGeneratorService from "./DefaultDataDumpGeneratorService.ts";
6
+ import { DATA_DUMP_GENERATOR_SERVICE_ID } from "../../api/service/core/DataDumpGeneratorService.ts";
7
+ import type Context from "../../api/Context.ts";
8
+ import type PrinterService from "../../api/service/core/PrinterService.ts";
9
+ import { PRINTER_SERVICE_ID } from "../../api/service/core/PrinterService.ts";
10
+ import type CLIConfig from "../../api/CLIConfig.ts";
11
+
12
+ export default class DataDumpGeneratorServiceProvider
13
+ implements ServiceProvider {
14
+ readonly serviceId: string = DATA_DUMP_GENERATOR_SERVICE_ID;
15
+ readonly #defaultService: DefaultDataDumpGeneratorService;
16
+
17
+ public constructor(
18
+ readonly servicePriority: number,
19
+ ) {
20
+ this.#defaultService = new DefaultDataDumpGeneratorService();
21
+ }
22
+
23
+ public getServiceInfo(_cliConfig: CLIConfig): Promise<ServiceInfo> {
24
+ return Promise.resolve({
25
+ service: this.#defaultService,
26
+ commands: [],
27
+ });
28
+ }
29
+
30
+ initService(context: Context): Promise<void> {
31
+ const printerService = context.getServiceById(
32
+ PRINTER_SERVICE_ID,
33
+ ) as PrinterService;
34
+
35
+ this.#defaultService.colorEnabled = printerService.colorEnabled;
36
+ this.#defaultService.colorFunction = printerService.color
37
+ .bind(printerService);
38
+
39
+ return Promise.resolve(undefined);
40
+ }
41
+ }