@flowscripter/dynamic-cli-framework 1.1.6 → 1.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 (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 +20 -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,73 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+ import BashShellHandler from "../../../../src/service/completion/shell/bash.ts";
5
+
6
+ describe("BashShellHandler", () => {
7
+ const handler = new BashShellHandler();
8
+
9
+ test("getDefaultConfigPath returns ~/.bashrc", () => {
10
+ const configPath = handler.getDefaultConfigPath();
11
+ expect(configPath).toEndWith(path.sep + ".bashrc");
12
+ });
13
+
14
+ test("getBootstrapScript generates correct function", () => {
15
+ const script = handler.getBootstrapScript("mycli");
16
+ expect(script).toContain("_mycli_completions()");
17
+ expect(script).toContain("COMPREPLY=");
18
+ expect(script).toContain("mycli completions:complete bash");
19
+ expect(script).toContain("COMP_LINE");
20
+ expect(script).toContain("COMP_POINT");
21
+ expect(script).toContain("complete -F _mycli_completions mycli");
22
+ });
23
+
24
+ test("formatCompletions outputs one value per line", () => {
25
+ const result = handler.formatCompletions([
26
+ { value: "help", description: "Display help" },
27
+ { value: "version", description: "Show version" },
28
+ ]);
29
+ expect(result).toEqual("help\nversion");
30
+ });
31
+
32
+ test("formatCompletions handles empty list", () => {
33
+ expect(handler.formatCompletions([])).toEqual("");
34
+ });
35
+
36
+ test("parseCompletionContext extracts line and cursor position", () => {
37
+ const ctx = handler.parseCompletionContext(["mycli he", "8"]);
38
+ expect(ctx.line).toEqual("mycli he");
39
+ expect(ctx.cursorPosition).toEqual(8);
40
+ });
41
+
42
+ test("parseCompletionContext handles missing args", () => {
43
+ const ctx = handler.parseCompletionContext([]);
44
+ expect(ctx.line).toEqual("");
45
+ expect(ctx.cursorPosition).toEqual(0);
46
+ });
47
+
48
+ describe("validateEnvironment", () => {
49
+ const originalShell = process.env.SHELL;
50
+
51
+ test("returns true when SHELL ends with /bash", async () => {
52
+ process.env.SHELL = "/bin/bash";
53
+ const result = await handler.validateEnvironment();
54
+ expect(result).toBe(true);
55
+ process.env.SHELL = originalShell;
56
+ });
57
+
58
+ test("falls back to spawning bash --version when SHELL is not bash", async () => {
59
+ process.env.SHELL = "/bin/zsh";
60
+ const result = await handler.validateEnvironment();
61
+ // Result depends on whether bash is installed; just verify it returns a boolean
62
+ expect(typeof result).toBe("boolean");
63
+ process.env.SHELL = originalShell;
64
+ });
65
+
66
+ test("falls back to spawning bash --version when SHELL is empty", async () => {
67
+ process.env.SHELL = "";
68
+ const result = await handler.validateEnvironment();
69
+ expect(typeof result).toBe("boolean");
70
+ process.env.SHELL = originalShell;
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,62 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+ import FishShellHandler from "../../../../src/service/completion/shell/fish.ts";
5
+
6
+ describe("FishShellHandler", () => {
7
+ const handler = new FishShellHandler();
8
+
9
+ test("getDefaultConfigPath returns fish config.fish", () => {
10
+ const configPath = handler.getDefaultConfigPath();
11
+ expect(configPath).toEndWith(
12
+ path.sep + path.join(".config", "fish", "config.fish"),
13
+ );
14
+ });
15
+
16
+ test("getBootstrapScript generates correct complete command", () => {
17
+ const script = handler.getBootstrapScript("mycli");
18
+ expect(script).toContain("complete -c mycli");
19
+ expect(script).toContain("mycli completions:complete fish");
20
+ expect(script).toContain("commandline -cp");
21
+ expect(script).toContain("commandline -C");
22
+ });
23
+
24
+ test("formatCompletions uses tab separator for descriptions", () => {
25
+ const result = handler.formatCompletions([
26
+ { value: "help", description: "Display help" },
27
+ { value: "version" },
28
+ ]);
29
+ expect(result).toEqual("help\tDisplay help\nversion");
30
+ });
31
+
32
+ test("parseCompletionContext extracts line and cursor position", () => {
33
+ const ctx = handler.parseCompletionContext(["mycli he", "8"]);
34
+ expect(ctx.line).toEqual("mycli he");
35
+ expect(ctx.cursorPosition).toEqual(8);
36
+ });
37
+
38
+ describe("validateEnvironment", () => {
39
+ const originalShell = process.env.SHELL;
40
+
41
+ test("returns true when SHELL ends with /fish", async () => {
42
+ process.env.SHELL = "/usr/bin/fish";
43
+ const result = await handler.validateEnvironment();
44
+ expect(result).toBe(true);
45
+ process.env.SHELL = originalShell;
46
+ });
47
+
48
+ test("falls back to spawning fish --version when SHELL is not fish", async () => {
49
+ process.env.SHELL = "/bin/bash";
50
+ const result = await handler.validateEnvironment();
51
+ expect(typeof result).toBe("boolean");
52
+ process.env.SHELL = originalShell;
53
+ });
54
+
55
+ test("falls back to spawning fish --version when SHELL is empty", async () => {
56
+ process.env.SHELL = "";
57
+ const result = await handler.validateEnvironment();
58
+ expect(typeof result).toBe("boolean");
59
+ process.env.SHELL = originalShell;
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,59 @@
1
+ import process from "node:process";
2
+ import { describe, expect, test } from "bun:test";
3
+ import PowerShellShellHandler from "../../../../src/service/completion/shell/powershell.ts";
4
+
5
+ describe("PowerShellShellHandler", () => {
6
+ const handler = new PowerShellShellHandler();
7
+
8
+ test("getDefaultConfigPath returns PowerShell profile path", () => {
9
+ const path = handler.getDefaultConfigPath();
10
+ expect(path).toContain("Microsoft.PowerShell_profile.ps1");
11
+ });
12
+
13
+ test("getBootstrapScript generates Register-ArgumentCompleter", () => {
14
+ const script = handler.getBootstrapScript("mycli");
15
+ expect(script).toContain("Register-ArgumentCompleter");
16
+ expect(script).toContain("-Native");
17
+ expect(script).toContain("-CommandName mycli");
18
+ expect(script).toContain("mycli completions:complete powershell");
19
+ expect(script).toContain("CompletionResult");
20
+ });
21
+
22
+ test("formatCompletions outputs one value per line", () => {
23
+ const result = handler.formatCompletions([
24
+ { value: "help", description: "Display help" },
25
+ { value: "version" },
26
+ ]);
27
+ expect(result).toEqual("help\nversion");
28
+ });
29
+
30
+ test("parseCompletionContext extracts line and cursor position", () => {
31
+ const ctx = handler.parseCompletionContext(["mycli he", "8"]);
32
+ expect(ctx.line).toEqual("mycli he");
33
+ expect(ctx.cursorPosition).toEqual(8);
34
+ });
35
+
36
+ describe("validateEnvironment", () => {
37
+ test("returns a boolean", async () => {
38
+ // validateEnvironment tries pwsh first, then powershell fallback
39
+ // Result depends on whether either is installed
40
+ const result = await handler.validateEnvironment();
41
+ expect(typeof result).toBe("boolean");
42
+ });
43
+ });
44
+
45
+ describe("getDefaultConfigPath platform handling", () => {
46
+ test("path contains Microsoft.PowerShell_profile.ps1", () => {
47
+ const configPath = handler.getDefaultConfigPath();
48
+ expect(configPath).toContain("Microsoft.PowerShell_profile.ps1");
49
+ // On non-win32 platforms, path should contain .config/powershell
50
+ if (process.platform !== "win32") {
51
+ expect(configPath).toContain(".config");
52
+ expect(configPath).toContain("powershell");
53
+ } else {
54
+ expect(configPath).toContain("Documents");
55
+ expect(configPath).toContain("PowerShell");
56
+ }
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,60 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+ import ZshShellHandler from "../../../../src/service/completion/shell/zsh.ts";
5
+
6
+ describe("ZshShellHandler", () => {
7
+ const handler = new ZshShellHandler();
8
+
9
+ test("getDefaultConfigPath returns ~/.zshrc", () => {
10
+ const configPath = handler.getDefaultConfigPath();
11
+ expect(configPath).toEndWith(path.sep + ".zshrc");
12
+ });
13
+
14
+ test("getBootstrapScript generates correct function", () => {
15
+ const script = handler.getBootstrapScript("mycli");
16
+ expect(script).toContain("_mycli_completions()");
17
+ expect(script).toContain("mycli completions:complete zsh");
18
+ expect(script).toContain("compdef _mycli_completions mycli");
19
+ expect(script).toContain("_describe");
20
+ });
21
+
22
+ test("formatCompletions includes descriptions with colon separator", () => {
23
+ const result = handler.formatCompletions([
24
+ { value: "help", description: "Display help" },
25
+ { value: "version" },
26
+ ]);
27
+ expect(result).toEqual("help:Display help\nversion");
28
+ });
29
+
30
+ test("parseCompletionContext extracts line and cursor position", () => {
31
+ const ctx = handler.parseCompletionContext(["mycli he", "8"]);
32
+ expect(ctx.line).toEqual("mycli he");
33
+ expect(ctx.cursorPosition).toEqual(8);
34
+ });
35
+
36
+ describe("validateEnvironment", () => {
37
+ const originalShell = process.env.SHELL;
38
+
39
+ test("returns true when SHELL ends with /zsh", async () => {
40
+ process.env.SHELL = "/bin/zsh";
41
+ const result = await handler.validateEnvironment();
42
+ expect(result).toBe(true);
43
+ process.env.SHELL = originalShell;
44
+ });
45
+
46
+ test("falls back to spawning zsh --version when SHELL is not zsh", async () => {
47
+ process.env.SHELL = "/bin/bash";
48
+ const result = await handler.validateEnvironment();
49
+ expect(typeof result).toBe("boolean");
50
+ process.env.SHELL = originalShell;
51
+ });
52
+
53
+ test("falls back to spawning zsh --version when SHELL is empty", async () => {
54
+ process.env.SHELL = "";
55
+ const result = await handler.validateEnvironment();
56
+ expect(typeof result).toBe("boolean");
57
+ process.env.SHELL = originalShell;
58
+ });
59
+ });
60
+ });
@@ -61,13 +61,13 @@ function getSubCommand(): SubCommand {
61
61
  };
62
62
  }
63
63
  describe("ConfigurationServiceProvider tests", () => {
64
- test("ConfigurationServiceProvider provide works", async () => {
64
+ test("ConfigurationServiceProvider getServiceInfo works", async () => {
65
65
  const configurationServiceProvider = new ConfigurationServiceProvider(
66
66
  100,
67
67
  false,
68
68
  true,
69
69
  );
70
- const serviceInfo = await configurationServiceProvider.provide(
70
+ const serviceInfo = await configurationServiceProvider.getServiceInfo(
71
71
  getCLIConfig(),
72
72
  );
73
73
  expect(serviceInfo.commands.length).toEqual(2);
@@ -131,9 +131,10 @@ describe("ConfigurationServiceProvider tests", () => {
131
131
  const subCommand = getSubCommand();
132
132
 
133
133
  await configurationServiceProvider.initService(context);
134
+ await configurationServiceProvider.getServiceInfo(getCLIConfig());
134
135
 
135
136
  expect(
136
- configurationServiceProvider.getDefaultArgumentValues(
137
+ await configurationServiceProvider.getDefaultArgumentValues(
137
138
  getCLIConfig(),
138
139
  subCommand,
139
140
  ),
@@ -157,9 +158,10 @@ describe("ConfigurationServiceProvider tests", () => {
157
158
  const subCommand = getSubCommand();
158
159
 
159
160
  await configurationServiceProvider.initService(context);
161
+ await configurationServiceProvider.getServiceInfo(getCLIConfig());
160
162
 
161
163
  expect(
162
- configurationServiceProvider.getDefaultArgumentValues(
164
+ await configurationServiceProvider.getDefaultArgumentValues(
163
165
  getCLIConfig(),
164
166
  subCommand,
165
167
  ),
@@ -189,7 +191,9 @@ describe("ConfigurationServiceProvider tests", () => {
189
191
 
190
192
  await fs.writeFile(configLocation, JSON.stringify(config));
191
193
 
192
- const serviceInfo = await configurationServiceProvider.provide(cliConfig);
194
+ const serviceInfo = await configurationServiceProvider.getServiceInfo(
195
+ cliConfig,
196
+ );
193
197
 
194
198
  await configurationServiceProvider.initService(context);
195
199
 
@@ -197,16 +201,16 @@ describe("ConfigurationServiceProvider tests", () => {
197
201
 
198
202
  configurationServiceProvider.setCommandKeyValueScope("command2");
199
203
 
200
- expect(keyValueService.hasKey("foo2")).toBeFalse();
204
+ expect(await keyValueService.hasKey("foo2")).toBeFalse();
201
205
 
202
206
  await configurationServiceProvider.clearKeyValueScope();
203
207
  configurationServiceProvider.setCommandKeyValueScope("command1");
204
208
 
205
- expect(keyValueService.getKey("foo2")).toEqual("bar2");
209
+ expect(await keyValueService.getKey("foo2")).toEqual("bar2");
206
210
 
207
211
  await configurationServiceProvider.clearKeyValueScope();
208
212
 
209
- expect(() => keyValueService.hasKey("foo2")).toThrow();
213
+ expect(keyValueService.hasKey("foo2")).rejects.toThrow();
210
214
  });
211
215
 
212
216
  test("setServiceKeyValueScope works", async () => {
@@ -227,7 +231,9 @@ describe("ConfigurationServiceProvider tests", () => {
227
231
 
228
232
  await fs.writeFile(configLocation, JSON.stringify(config));
229
233
 
230
- const serviceInfo = await configurationServiceProvider.provide(cliConfig);
234
+ const serviceInfo = await configurationServiceProvider.getServiceInfo(
235
+ cliConfig,
236
+ );
231
237
 
232
238
  await configurationServiceProvider.initService(context);
233
239
 
@@ -235,15 +241,21 @@ describe("ConfigurationServiceProvider tests", () => {
235
241
 
236
242
  configurationServiceProvider.setServiceKeyValueScope("service-id-2");
237
243
 
238
- expect(keyValueService.hasKey("foo1")).toBeFalse();
244
+ expect(await keyValueService.hasKey("foo1")).toBeFalse();
239
245
 
240
246
  await configurationServiceProvider.clearKeyValueScope();
241
247
  configurationServiceProvider.setServiceKeyValueScope("service-id-1");
242
248
 
243
- expect(keyValueService.getKey("foo1")).toEqual("bar");
249
+ expect(await keyValueService.getKey("foo1")).toEqual("bar");
244
250
 
245
251
  await configurationServiceProvider.clearKeyValueScope();
246
252
 
247
- expect(() => keyValueService.hasKey("foo1")).toThrow();
253
+ expect(keyValueService.hasKey("foo1")).rejects.toThrow();
254
+ });
255
+
256
+ test("secretServiceEnabled requires configEnabled", () => {
257
+ expect(
258
+ () => new ConfigurationServiceProvider(100, false, false, false, true),
259
+ ).toThrow("configEnabled must be true");
248
260
  });
249
261
  });
@@ -1,14 +1,23 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import DefaultKeyValueService from "../../../src/service/configuration/DefaultKeyValueService.ts";
3
+ import DefaultSecretService from "../../../src/service/configuration/DefaultSecretService.ts";
4
+
5
+ function createMockSecretsApi() {
6
+ return {
7
+ get: mock(() => Promise.resolve(null as string | null)),
8
+ set: mock(() => Promise.resolve()),
9
+ delete: mock(() => Promise.resolve(true)),
10
+ };
11
+ }
3
12
 
4
13
  describe("DefaultKeyValueService tests", () => {
5
- test("Setting data works", () => {
14
+ test("Setting data works", async () => {
6
15
  const keyValueService = new DefaultKeyValueService();
7
16
 
8
17
  keyValueService.setKeyValueData(new Map([["foo", "bar"]]));
9
18
 
10
- expect(keyValueService.hasKey("foo")).toBeTrue();
11
- expect(keyValueService.getKey("foo")).toEqual("bar");
19
+ expect(await keyValueService.hasKey("foo")).toBeTrue();
20
+ expect(await keyValueService.getKey("foo")).toEqual("bar");
12
21
  });
13
22
 
14
23
  test("Cannot set data without clearing", () => {
@@ -21,23 +30,23 @@ describe("DefaultKeyValueService tests", () => {
21
30
  keyValueService.setKeyValueData(new Map());
22
31
  });
23
32
 
24
- test("Cannot modify data before setting it", () => {
33
+ test("Cannot modify data before setting it", async () => {
25
34
  const keyValueService = new DefaultKeyValueService();
26
35
 
27
- expect(() => keyValueService.setKey("foo", "bar")).toThrow();
28
- expect(() => keyValueService.hasKey("foo")).toThrow();
29
- expect(() => keyValueService.getKey("foo")).toThrow();
30
- expect(() => keyValueService.deleteKey("foo")).toThrow();
36
+ expect(keyValueService.setKey("foo", "bar")).rejects.toThrow();
37
+ expect(keyValueService.hasKey("foo")).rejects.toThrow();
38
+ expect(keyValueService.getKey("foo")).rejects.toThrow();
39
+ expect(keyValueService.deleteKey("foo")).rejects.toThrow();
31
40
 
32
41
  keyValueService.setKeyValueData(new Map());
33
42
 
34
- keyValueService.setKey("foo", "bar");
35
- expect(keyValueService.hasKey("foo")).toBeTrue();
36
- expect(keyValueService.getKey("foo")).toEqual("bar");
37
- keyValueService.deleteKey("foo");
43
+ await keyValueService.setKey("foo", "bar");
44
+ expect(await keyValueService.hasKey("foo")).toBeTrue();
45
+ expect(await keyValueService.getKey("foo")).toEqual("bar");
46
+ await keyValueService.deleteKey("foo");
38
47
  });
39
48
 
40
- test("Dirty state is managed correctly", () => {
49
+ test("Dirty state is managed correctly", async () => {
41
50
  const keyValueService = new DefaultKeyValueService();
42
51
 
43
52
  expect(keyValueService.isDirty()).toBeFalse();
@@ -46,7 +55,7 @@ describe("DefaultKeyValueService tests", () => {
46
55
 
47
56
  expect(keyValueService.isDirty()).toBeFalse();
48
57
 
49
- keyValueService.setKey("foo", "bar");
58
+ await keyValueService.setKey("foo", "bar");
50
59
 
51
60
  expect(keyValueService.isDirty()).toBeTrue();
52
61
 
@@ -58,8 +67,101 @@ describe("DefaultKeyValueService tests", () => {
58
67
 
59
68
  expect(keyValueService.isDirty()).toBeFalse();
60
69
 
61
- keyValueService.deleteKey("foo");
70
+ await keyValueService.deleteKey("foo");
71
+
72
+ expect(keyValueService.isDirty()).toBeTrue();
73
+ });
74
+
75
+ test("setKey with isSecret=true throws without secret service", async () => {
76
+ const keyValueService = new DefaultKeyValueService();
77
+ keyValueService.setKeyValueData(new Map());
78
+
79
+ await expect(
80
+ keyValueService.setKey("token", "secret", true),
81
+ ).rejects.toThrow("no secret service");
82
+ });
83
+
84
+ test("setKey with isSecret=true stores sentinel", async () => {
85
+ const mockApi = createMockSecretsApi();
86
+ const secretService = new DefaultSecretService("test-cli", mockApi);
87
+ secretService.setScope("command_test");
88
+ const keyValueService = new DefaultKeyValueService(secretService);
89
+ keyValueService.setKeyValueData(new Map());
90
+
91
+ await keyValueService.setKey("token", "my-secret", true);
92
+
93
+ expect(await keyValueService.hasKey("token")).toBeTrue();
94
+ expect(keyValueService.isDirty()).toBeTrue();
95
+ expect(mockApi.set).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ test("getKey resolves secret sentinel", async () => {
99
+ const mockApi = createMockSecretsApi();
100
+ const secretService = new DefaultSecretService("test-cli", mockApi);
101
+ secretService.setScope("command_test");
102
+ const keyValueService = new DefaultKeyValueService(secretService);
103
+ keyValueService.setKeyValueData(
104
+ new Map([["token", "__SECRET__:command_test_token"]]),
105
+ );
106
+
107
+ mockApi.get.mockResolvedValueOnce("resolved-secret");
108
+ const value = await keyValueService.getKey("token");
109
+ expect(value).toEqual("resolved-secret");
110
+ expect(mockApi.get).toHaveBeenCalledWith({
111
+ service: "test_cli",
112
+ name: "command_test_token",
113
+ });
114
+ });
115
+
116
+ test("getKey throws when secret not found in OS store", async () => {
117
+ const mockApi = createMockSecretsApi();
118
+ const secretService = new DefaultSecretService("test-cli", mockApi);
119
+ secretService.setScope("command_test");
120
+ const keyValueService = new DefaultKeyValueService(secretService);
121
+ keyValueService.setKeyValueData(
122
+ new Map([["token", "__SECRET__:command_test_token"]]),
123
+ );
124
+
125
+ mockApi.get.mockResolvedValueOnce(null);
126
+ await expect(keyValueService.getKey("token")).rejects.toThrow(
127
+ "Secret not found",
128
+ );
129
+ });
130
+
131
+ test("getKey with sentinel throws without secret service", async () => {
132
+ const keyValueService = new DefaultKeyValueService();
133
+ keyValueService.setKeyValueData(
134
+ new Map([["token", "__SECRET__:command_test_token"]]),
135
+ );
136
+
137
+ await expect(keyValueService.getKey("token")).rejects.toThrow(
138
+ "no secret service",
139
+ );
140
+ });
62
141
 
142
+ test("deleteKey removes secret from OS store", async () => {
143
+ const mockApi = createMockSecretsApi();
144
+ const secretService = new DefaultSecretService("test-cli", mockApi);
145
+ secretService.setScope("command_test");
146
+ const keyValueService = new DefaultKeyValueService(secretService);
147
+ keyValueService.setKeyValueData(
148
+ new Map([["token", "__SECRET__:command_test_token"]]),
149
+ );
150
+
151
+ await keyValueService.deleteKey("token");
152
+ expect(mockApi.delete).toHaveBeenCalledWith({
153
+ service: "test_cli",
154
+ name: "command_test_token",
155
+ });
156
+ expect(await keyValueService.hasKey("token")).toBeFalse();
63
157
  expect(keyValueService.isDirty()).toBeTrue();
64
158
  });
159
+
160
+ test("deleteKey works for non-secret values", async () => {
161
+ const keyValueService = new DefaultKeyValueService();
162
+ keyValueService.setKeyValueData(new Map([["foo", "bar"]]));
163
+
164
+ await keyValueService.deleteKey("foo");
165
+ expect(await keyValueService.hasKey("foo")).toBeFalse();
166
+ });
65
167
  });
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import DefaultSecretService from "../../../src/service/configuration/DefaultSecretService.ts";
3
+
4
+ function createMockSecretsApi() {
5
+ return {
6
+ get: mock(() => Promise.resolve(null as string | null)),
7
+ set: mock(() => Promise.resolve()),
8
+ delete: mock(() => Promise.resolve(true)),
9
+ };
10
+ }
11
+
12
+ describe("DefaultSecretService tests", () => {
13
+ let service: DefaultSecretService;
14
+ let mockApi: ReturnType<typeof createMockSecretsApi>;
15
+
16
+ beforeEach(() => {
17
+ mockApi = createMockSecretsApi();
18
+ service = new DefaultSecretService("my-test-cli", mockApi);
19
+ });
20
+
21
+ afterEach(() => {
22
+ service.clearScope();
23
+ });
24
+
25
+ test("Constructor sanitizes service name", async () => {
26
+ const api = createMockSecretsApi();
27
+ const s = new DefaultSecretService("my-cool-cli!", api);
28
+ s.setScope("command_test");
29
+ await s.setSecret("key", "value");
30
+ expect(api.set).toHaveBeenCalledWith({
31
+ service: "my_cool_cli_",
32
+ name: "command_test_key",
33
+ value: "value",
34
+ });
35
+ });
36
+
37
+ test("Constructor throws if sanitized service name exceeds 255 chars", () => {
38
+ const api = createMockSecretsApi();
39
+ expect(() => new DefaultSecretService("a".repeat(256), api)).toThrow(
40
+ "exceeds 255 characters",
41
+ );
42
+ });
43
+
44
+ test("setSecret throws without scope", async () => {
45
+ await expect(service.setSecret("key", "value")).rejects.toThrow(
46
+ "without a scope",
47
+ );
48
+ });
49
+
50
+ test("setSecret constructs correct name and returns it", async () => {
51
+ service.setScope("command_mycommand");
52
+ const name = await service.setSecret("token", "secret123");
53
+ expect(name).toEqual("command_mycommand_token");
54
+ expect(mockApi.set).toHaveBeenCalledWith({
55
+ service: "my_test_cli",
56
+ name: "command_mycommand_token",
57
+ value: "secret123",
58
+ });
59
+ });
60
+
61
+ test("setSecret sanitizes scope and key", async () => {
62
+ service.setScope("command_my-cmd");
63
+ const name = await service.setSecret("my-key!", "value");
64
+ expect(name).toEqual("command_my_cmd_my_key_");
65
+ expect(mockApi.set).toHaveBeenCalledWith({
66
+ service: "my_test_cli",
67
+ name: "command_my_cmd_my_key_",
68
+ value: "value",
69
+ });
70
+ });
71
+
72
+ test("setSecret throws if name exceeds 255 chars", async () => {
73
+ service.setScope("command_test");
74
+ await expect(
75
+ service.setSecret("a".repeat(250), "value"),
76
+ ).rejects.toThrow("exceeds 255 characters");
77
+ });
78
+
79
+ test("setSecret throws if value exceeds 2047 bytes", async () => {
80
+ service.setScope("command_test");
81
+ await expect(
82
+ service.setSecret("key", "a".repeat(2048)),
83
+ ).rejects.toThrow("exceeds 2047 bytes");
84
+ });
85
+
86
+ test("getSecret delegates to secrets API", async () => {
87
+ mockApi.get.mockResolvedValueOnce("secret_value");
88
+ const result = await service.getSecret("command_test_key");
89
+ expect(result).toEqual("secret_value");
90
+ expect(mockApi.get).toHaveBeenCalledWith({
91
+ service: "my_test_cli",
92
+ name: "command_test_key",
93
+ });
94
+ });
95
+
96
+ test("getSecret returns null when not found", async () => {
97
+ mockApi.get.mockResolvedValueOnce(null);
98
+ const result = await service.getSecret("nonexistent");
99
+ expect(result).toBeNull();
100
+ });
101
+
102
+ test("deleteSecret delegates to secrets API", async () => {
103
+ mockApi.delete.mockResolvedValueOnce(true);
104
+ const result = await service.deleteSecret("command_test_key");
105
+ expect(result).toBeTrue();
106
+ expect(mockApi.delete).toHaveBeenCalledWith({
107
+ service: "my_test_cli",
108
+ name: "command_test_key",
109
+ });
110
+ });
111
+
112
+ test("deleteSecret returns false when not found", async () => {
113
+ mockApi.delete.mockResolvedValueOnce(false);
114
+ const result = await service.deleteSecret("nonexistent");
115
+ expect(result).toBeFalse();
116
+ });
117
+
118
+ test("hasSecret returns true when secret exists", async () => {
119
+ mockApi.get.mockResolvedValueOnce("some_value");
120
+ const result = await service.hasSecret("command_test_key");
121
+ expect(result).toBeTrue();
122
+ });
123
+
124
+ test("hasSecret returns false when secret does not exist", async () => {
125
+ mockApi.get.mockResolvedValueOnce(null);
126
+ const result = await service.hasSecret("nonexistent");
127
+ expect(result).toBeFalse();
128
+ });
129
+
130
+ test("clearScope resets scope", async () => {
131
+ service.setScope("command_test");
132
+ service.clearScope();
133
+ await expect(service.setSecret("key", "value")).rejects.toThrow(
134
+ "without a scope",
135
+ );
136
+ });
137
+
138
+ test("setScope updates scope for subsequent calls", async () => {
139
+ service.setScope("command_cmd1");
140
+ let name = await service.setSecret("key", "value1");
141
+ expect(name).toEqual("command_cmd1_key");
142
+
143
+ service.clearScope();
144
+ service.setScope("service_svc1");
145
+ name = await service.setSecret("key", "value2");
146
+ expect(name).toEqual("service_svc1_key");
147
+ });
148
+ });