argsbarg 3.6.4 → 4.0.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 (98) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/README.md +21 -9
  3. package/docs/README.md +12 -8
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +62 -2
  6. package/docs/config-schema.md +192 -0
  7. package/docs/developing.md +13 -0
  8. package/docs/install.md +38 -1
  9. package/docs/mcp.md +43 -19
  10. package/docs/output-schema.md +74 -52
  11. package/docs/templates/cursor/rules/cli-program.mdc +10 -5
  12. package/examples/config-app/main.ts +20 -0
  13. package/examples/config-app/program.ts +81 -0
  14. package/examples/config-app/schema.ts +37 -0
  15. package/examples/config-app/types.ts +19 -0
  16. package/examples/consumer-app/README.md +56 -0
  17. package/examples/consumer-app/bun.lock +75 -0
  18. package/examples/consumer-app/capabilities.test.ts +69 -0
  19. package/examples/consumer-app/package.json +17 -0
  20. package/examples/consumer-app/schemas/configSchemas.ts +6 -0
  21. package/examples/consumer-app/schemas/generated/app-config.json +40 -0
  22. package/examples/consumer-app/schemas/generated/status.json +28 -0
  23. package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
  24. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
  25. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
  26. package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
  27. package/examples/consumer-app/scripts/schemagen.ts +76 -0
  28. package/examples/consumer-app/src/commands/status/types.ts +11 -0
  29. package/examples/consumer-app/src/main.ts +15 -0
  30. package/examples/consumer-app/src/program.ts +116 -0
  31. package/examples/consumer-app/src/types.ts +23 -0
  32. package/examples/consumer-app/tsconfig.json +14 -0
  33. package/examples/formats.ts +10 -3
  34. package/examples/mcp-test.ts +27 -8
  35. package/examples/minimal.ts +4 -3
  36. package/examples/nested.ts +5 -4
  37. package/examples/option-required.ts +8 -4
  38. package/index.d.ts +152 -75
  39. package/package.json +1 -1
  40. package/src/builtins/builtins.test.ts +13 -0
  41. package/src/builtins/config.test.ts +82 -0
  42. package/src/builtins/config.ts +220 -0
  43. package/src/builtins/dispatch.ts +18 -9
  44. package/src/builtins/export.ts +8 -33
  45. package/src/builtins/index.ts +1 -0
  46. package/src/builtins/install.ts +13 -0
  47. package/src/builtins/mcp.ts +1 -1
  48. package/src/builtins/presentation.ts +2 -17
  49. package/src/builtins/registry.ts +40 -0
  50. package/src/capabilities.ts +46 -0
  51. package/src/cli-errors.ts +15 -0
  52. package/src/cli.ts +389 -0
  53. package/src/config/bootstrap.ts +265 -0
  54. package/src/config/context.test.ts +61 -0
  55. package/src/config/context.ts +98 -0
  56. package/src/config/entry.ts +81 -0
  57. package/src/config/file.test.ts +112 -0
  58. package/src/config/file.ts +120 -0
  59. package/src/config/manifest.ts +62 -0
  60. package/src/config/resolve.test.ts +88 -0
  61. package/src/config/resolve.ts +167 -0
  62. package/src/config/schema.ts +101 -0
  63. package/src/config/validate.test.ts +63 -0
  64. package/src/config/validate.ts +292 -0
  65. package/src/config.integration.test.ts +100 -0
  66. package/src/context.ts +5 -0
  67. package/src/docs/docs.test.ts +16 -16
  68. package/src/docs/mcp-guide.ts +35 -11
  69. package/src/hidden-mcpb.test.ts +40 -2
  70. package/src/index.ts +4 -3
  71. package/src/install/index.ts +46 -3
  72. package/src/install/paths.ts +5 -20
  73. package/src/install/plan.ts +6 -0
  74. package/src/install/status.ts +12 -0
  75. package/src/install/uninstall.ts +11 -0
  76. package/src/install/update.test.ts +5 -5
  77. package/src/invoke.test.ts +207 -0
  78. package/src/mcp/bundle.ts +9 -116
  79. package/src/mcp/claude.test.ts +73 -0
  80. package/src/mcp/claude.ts +168 -0
  81. package/src/mcp/env.ts +3 -37
  82. package/src/mcp/server.ts +18 -10
  83. package/src/mcp/tools.ts +3 -7
  84. package/src/mcp/zip.ts +82 -0
  85. package/src/mcp.integration.test.ts +502 -0
  86. package/src/{index.test.ts → parse.test.ts} +24 -935
  87. package/src/paths/host.ts +40 -0
  88. package/src/schema.ts +1 -1
  89. package/src/skill/generate.ts +18 -5
  90. package/src/skill/hint.ts +18 -0
  91. package/src/skill/install.ts +1 -5
  92. package/src/test-fixtures.ts +192 -0
  93. package/src/types.ts +39 -13
  94. package/src/validate.ts +70 -0
  95. package/src/completion.ts +0 -13
  96. package/src/invoke.ts +0 -217
  97. package/src/mcp.ts +0 -28
  98. package/src/runtime.ts +0 -134
@@ -0,0 +1,98 @@
1
+ /*
2
+ Handler-facing resolved app config snapshot (ctx.appConfig).
3
+ */
4
+
5
+ import type { CliProgram } from "../types.ts";
6
+ import { writeAppConfigFile } from "./file.ts";
7
+ import type { ResolvedConfig } from "./resolve.ts";
8
+ import { exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
9
+
10
+ /** Empty snapshot when program.appConfig is not set. */
11
+ export class EmptyAppConfigSnapshot {
12
+ get(_key: string): undefined {
13
+ return undefined;
14
+ }
15
+
16
+ require(key: string): never {
17
+ throw new Error(`Configuration key '${key}' is not available (program.appConfig is not set)`);
18
+ }
19
+
20
+ set(_key: string, _value: unknown): void {
21
+ throw new Error("program.appConfig is not set");
22
+ }
23
+
24
+ read(): ResolvedConfig {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ /** Resolved config for handlers with program.appConfig set. */
30
+ export class AppConfigSnapshot {
31
+ private snapshot: ResolvedConfig;
32
+ private fileData: Record<string, unknown>;
33
+
34
+ constructor(
35
+ private readonly program: CliProgram,
36
+ fileData: Record<string, unknown>,
37
+ resolved: ResolvedConfig,
38
+ ) {
39
+ this.fileData = { ...fileData };
40
+ this.snapshot = { ...resolved };
41
+ }
42
+
43
+ get(key: string): unknown {
44
+ this.assertEntryKey(key);
45
+ return this.snapshot[key];
46
+ }
47
+
48
+ require(key: string): unknown {
49
+ const value = this.get(key);
50
+ if (
51
+ value === undefined ||
52
+ value === null ||
53
+ (typeof value === "string" && value.length === 0)
54
+ ) {
55
+ throw new Error(`Missing required configuration: ${key}`);
56
+ }
57
+ return value;
58
+ }
59
+
60
+ set(key: string, value: unknown): void {
61
+ this.assertEntryKey(key);
62
+ const next = { ...this.fileData, [key]: value };
63
+ writeAppConfigFile(this.program, next);
64
+ this.fileData = next;
65
+ this.snapshot = resolveAppConfig(this.program, next);
66
+ exportConfigToEnv(this.program, this.snapshot);
67
+ }
68
+
69
+ read(): ResolvedConfig {
70
+ return { ...this.snapshot };
71
+ }
72
+
73
+ /** Replace snapshot after external bootstrap (internal). */
74
+ refresh(fileData: Record<string, unknown>, resolved: ResolvedConfig): void {
75
+ this.fileData = { ...fileData };
76
+ this.snapshot = { ...resolved };
77
+ }
78
+
79
+ private assertEntryKey(key: string): void {
80
+ const entries = this.program.appConfig?.entries;
81
+ if (!entries || !(key in entries)) {
82
+ throw new Error(`Unknown configuration key: ${key}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ export type AnyAppConfigSnapshot = AppConfigSnapshot | EmptyAppConfigSnapshot;
88
+
89
+ export function createAppConfigSnapshot(
90
+ program: CliProgram,
91
+ fileData: Record<string, unknown>,
92
+ resolved: ResolvedConfig,
93
+ ): AnyAppConfigSnapshot {
94
+ if (!program.appConfig) {
95
+ return new EmptyAppConfigSnapshot();
96
+ }
97
+ return new AppConfigSnapshot(program, fileData, resolved);
98
+ }
@@ -0,0 +1,81 @@
1
+ /*
2
+ Shared helpers for program.appConfig schema entries.
3
+ */
4
+
5
+ import type { CliAppConfigEntry, CliProgram } from "../types.ts";
6
+
7
+ /** Default title when `CliAppConfigEntry.title` is omitted. */
8
+ export function defaultConfigEntryTitle(key: string): string {
9
+ return key;
10
+ }
11
+
12
+ /** Default sensitive flag from config key name. */
13
+ export function defaultConfigEntrySensitive(key: string): boolean {
14
+ return /key|token|secret|password/i.test(key);
15
+ }
16
+
17
+ /** Whether a schema entry is required for bootstrap / MCP enforcement. */
18
+ export function configEntryRequired(
19
+ key: string,
20
+ entry: CliAppConfigEntry,
21
+ jsonSchemaRequired: Set<string> | undefined,
22
+ ): boolean {
23
+ if (entry.required === false) {
24
+ return false;
25
+ }
26
+ if (jsonSchemaRequired !== undefined) {
27
+ return jsonSchemaRequired.has(key);
28
+ }
29
+ return true;
30
+ }
31
+
32
+ /** Whether prompts and `config get` should redact this entry. */
33
+ export function configEntrySensitive(key: string, entry: CliAppConfigEntry): boolean {
34
+ return entry.sensitive ?? defaultConfigEntrySensitive(key);
35
+ }
36
+
37
+ /** Manifest / plugin user_config key from a schema key. */
38
+ export function configUserConfigKey(key: string): string {
39
+ const snake = key
40
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
41
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, "_")
44
+ .replace(/^_|_$/g, "");
45
+ return snake || "config_key";
46
+ }
47
+
48
+ /** Required keys from a JSON Schema object root. */
49
+ export function jsonSchemaRequiredKeys(
50
+ jsonSchema: Record<string, unknown>,
51
+ ): Set<string> | undefined {
52
+ const required = jsonSchema.required;
53
+ if (!Array.isArray(required)) {
54
+ return undefined;
55
+ }
56
+ return new Set(required.filter((k): k is string => typeof k === "string"));
57
+ }
58
+
59
+ /** Whether built-in config get/set commands are enabled. */
60
+ export function configCommandsEnabled(program: CliProgram): boolean {
61
+ if (!program.appConfig) {
62
+ return false;
63
+ }
64
+ const commands = program.appConfig.commands;
65
+ if (commands === false) {
66
+ return false;
67
+ }
68
+ if (typeof commands === "object" && commands.enabled === false) {
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ /** Whether MCP exposes config set (config_get always on when commands enabled). */
75
+ export function configMcpSetEnabled(program: CliProgram): boolean {
76
+ const commands = program.appConfig?.commands;
77
+ if (typeof commands === "object" && commands.mcpSet === false) {
78
+ return false;
79
+ }
80
+ return true;
81
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
+ import { bootstrapAppConfig } from "./bootstrap.ts";
7
+ import { readAppConfigFile, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
8
+ import { buildProgramUserConfig } from "./manifest.ts";
9
+ import { formatMissingConfigMessage, missingRequiredConfig, resolveAppConfig } from "./resolve.ts";
10
+
11
+ const program: CliProgram = {
12
+ key: "myapp",
13
+ version: "1.0.0",
14
+ description: "Demo.",
15
+ appConfig: {
16
+ path: "~/.config/myapp-test/config",
17
+ entries: {
18
+ apiToken: { description: "Token.", env: "API_TOKEN" },
19
+ port: { description: "HTTP listen port (default 8080).", required: false },
20
+ },
21
+ },
22
+ handler: () => {},
23
+ };
24
+
25
+ describe("config/file", () => {
26
+ test("buildProgramUserConfig from program.appConfig env entries", () => {
27
+ const cfg = buildProgramUserConfig(program);
28
+ expect(cfg?.api_token).toMatchObject({
29
+ title: "apiToken",
30
+ description: "Token.",
31
+ required: true,
32
+ });
33
+ expect(cfg?.port).toBeUndefined();
34
+ });
35
+
36
+ test("resolveAppConfig prefers host env over file", () => {
37
+ const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
38
+ const prevHome = process.env.HOME;
39
+ process.env.HOME = dir;
40
+ const prevToken = process.env.API_TOKEN;
41
+ process.env.API_TOKEN = "from-host";
42
+ try {
43
+ const p: CliProgram = {
44
+ ...program,
45
+ appConfig: {
46
+ ...program.appConfig!,
47
+ path: join(dir, ".config", "myapp-test", "config"),
48
+ },
49
+ };
50
+ mkdirSync(join(dir, ".config", "myapp-test"), { recursive: true });
51
+ writeFileSync(resolveAppConfigPath(p), `${JSON.stringify({ apiToken: "from-file" })}\n`);
52
+ const resolved = resolveAppConfig(p, { apiToken: "from-file" });
53
+ expect(resolved.apiToken).toBe("from-host");
54
+ } finally {
55
+ if (prevHome === undefined) delete process.env.HOME;
56
+ else process.env.HOME = prevHome;
57
+ if (prevToken === undefined) delete process.env.API_TOKEN;
58
+ else process.env.API_TOKEN = prevToken;
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test("missingRequiredConfig and formatMissingConfigMessage", () => {
64
+ const prev = process.env.API_TOKEN;
65
+ delete process.env.API_TOKEN;
66
+ try {
67
+ const resolved = resolveAppConfig(program, {});
68
+ const missing = missingRequiredConfig(program, resolved);
69
+ expect(missing).toContain("apiToken");
70
+ expect(missing).not.toContain("port");
71
+ const msg = formatMissingConfigMessage(program, missing);
72
+ expect(msg).toContain("install --configure");
73
+ } finally {
74
+ if (prev !== undefined) process.env.API_TOKEN = prev;
75
+ }
76
+ });
77
+
78
+ test("rejects unknown keys on read", () => {
79
+ const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
80
+ try {
81
+ const p: CliProgram = {
82
+ ...program,
83
+ appConfig: {
84
+ ...program.appConfig!,
85
+ path: join(dir, "config"),
86
+ },
87
+ };
88
+ writeFileSync(join(dir, "config"), `${JSON.stringify({ extra: true })}\n`);
89
+ expect(() => readAppConfigFile(p)).toThrow(/Unknown config key/);
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("writeAppConfigFile round-trip", () => {
96
+ const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
97
+ try {
98
+ const p: CliProgram = {
99
+ ...program,
100
+ appConfig: {
101
+ ...program.appConfig!,
102
+ path: join(dir, "config"),
103
+ },
104
+ };
105
+ writeAppConfigFile(p, { apiToken: "saved" });
106
+ const { resolved } = bootstrapAppConfig(p, { validateFile: true });
107
+ expect(resolved.apiToken).toBe("saved");
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ });
@@ -0,0 +1,120 @@
1
+ /*
2
+ JSON app config file path helpers and strict read/write.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { sanitizeToolSegment } from "../mcp/tools.ts";
8
+ import { appConfigHome, expandTilde } from "../paths/host.ts";
9
+ import type { CliProgram } from "../types.ts";
10
+ import { effectiveJsonSchema } from "./schema.ts";
11
+ import { validateConfigDocument } from "./validate.ts";
12
+
13
+ export type AppConfigFileData = Record<string, unknown>;
14
+
15
+ /** Resolved absolute path to the app JSON config file. */
16
+ export function resolveAppConfigPath(program: CliProgram): string {
17
+ const custom = program.appConfig?.path;
18
+ if (custom) {
19
+ return expandTilde(custom);
20
+ }
21
+ const dirName = sanitizeToolSegment(program.key);
22
+ return join(appConfigHome(), dirName, "config");
23
+ }
24
+
25
+ /** Human-readable config path for error messages (`~` when under home). */
26
+ export function displayAppConfigPath(program: CliProgram): string {
27
+ const resolved = resolveAppConfigPath(program);
28
+ const home = process.env.HOME ?? "";
29
+ if (home.length > 0 && resolved.startsWith(home)) {
30
+ return `~${resolved.slice(home.length)}`;
31
+ }
32
+ return resolved;
33
+ }
34
+
35
+ function parseConfigJson(text: string, path: string): AppConfigFileData {
36
+ try {
37
+ const parsed = JSON.parse(text) as unknown;
38
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
39
+ throw new Error("root must be a JSON object");
40
+ }
41
+ return parsed as AppConfigFileData;
42
+ } catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ throw new Error(`Invalid JSON in config file ${path}: ${msg}`);
45
+ }
46
+ }
47
+
48
+ /** Read config file; returns `{}` when missing. Does not validate. */
49
+ export function readAppConfigFileRaw(path: string): AppConfigFileData {
50
+ if (!existsSync(path)) {
51
+ return {};
52
+ }
53
+ let text: string;
54
+ try {
55
+ text = readFileSync(path, "utf8");
56
+ } catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ throw new Error(`Could not read config file ${path}: ${msg}`);
59
+ }
60
+ return parseConfigJson(text, path);
61
+ }
62
+
63
+ /** Read and validate config file against program schema. */
64
+ export function readAppConfigFile(program: CliProgram): AppConfigFileData {
65
+ const path = resolveAppConfigPath(program);
66
+ const data = readAppConfigFileRaw(path);
67
+ if (Object.keys(data).length === 0) {
68
+ return data;
69
+ }
70
+ validateAppConfigData(program, data, path);
71
+ return data;
72
+ }
73
+
74
+ /** Validate in-memory config data; throws on failure. */
75
+ export function validateAppConfigData(
76
+ program: CliProgram,
77
+ data: AppConfigFileData,
78
+ pathLabel?: string,
79
+ ): void {
80
+ const appConfig = program.appConfig;
81
+ if (!appConfig) {
82
+ throw new Error("program.appConfig is not set");
83
+ }
84
+ const allowed = new Set(Object.keys(appConfig.entries));
85
+ for (const key of Object.keys(data)) {
86
+ if (!allowed.has(key)) {
87
+ const where = pathLabel ?? "config";
88
+ throw new Error(`Unknown config key '${key}' in ${where}`);
89
+ }
90
+ }
91
+ const jsonSchema = effectiveJsonSchema(program);
92
+ if (!jsonSchema) {
93
+ return;
94
+ }
95
+ const result = validateConfigDocument(data, jsonSchema);
96
+ if (!result.valid) {
97
+ const where = pathLabel ?? "config";
98
+ throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
99
+ }
100
+ }
101
+
102
+ /** Merge-write schema keys to the config file (`0o600`). */
103
+ export function writeAppConfigFile(program: CliProgram, data: AppConfigFileData): void {
104
+ validateAppConfigData(program, data, displayAppConfigPath(program));
105
+ const path = resolveAppConfigPath(program);
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
108
+ }
109
+
110
+ /** Removes the app config file when present. Returns true if removed. */
111
+ export function uninstallAppConfig(program: CliProgram, dry: boolean): boolean {
112
+ const path = resolveAppConfigPath(program);
113
+ if (!existsSync(path)) {
114
+ return false;
115
+ }
116
+ if (!dry) {
117
+ unlinkSync(path);
118
+ }
119
+ return true;
120
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ MCP bundle / plugin manifest builders for program.appConfig env-mapped entries.
3
+ */
4
+
5
+ import type { CliAppConfigEntry, CliProgram } from "../types.ts";
6
+ import {
7
+ configEntryRequired,
8
+ configEntrySensitive,
9
+ configUserConfigKey,
10
+ defaultConfigEntryTitle,
11
+ jsonSchemaRequiredKeys,
12
+ } from "./entry.ts";
13
+ import { effectiveJsonSchema } from "./schema.ts";
14
+
15
+ function buildConfigUserConfigEntry(
16
+ key: string,
17
+ entry: CliAppConfigEntry,
18
+ jsonSchemaRequired: Set<string> | undefined,
19
+ ): Record<string, unknown> {
20
+ return {
21
+ type: "string",
22
+ title: entry.title ?? defaultConfigEntryTitle(key),
23
+ description: entry.description,
24
+ sensitive: configEntrySensitive(key, entry),
25
+ required: configEntryRequired(key, entry, jsonSchemaRequired),
26
+ };
27
+ }
28
+
29
+ /** Builds MCPB/plugin user_config from schema entries with `env` set. */
30
+ export function buildProgramUserConfig(program: CliProgram): Record<string, unknown> | undefined {
31
+ const appConfig = program.appConfig;
32
+ if (!appConfig) {
33
+ return undefined;
34
+ }
35
+ const jsonSchema = effectiveJsonSchema(program);
36
+ const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
37
+ const out: Record<string, unknown> = {};
38
+ for (const [key, entry] of Object.entries(appConfig.entries)) {
39
+ if (!entry.env) {
40
+ continue;
41
+ }
42
+ out[configUserConfigKey(key)] = buildConfigUserConfigEntry(key, entry, fromSchema);
43
+ }
44
+ return Object.keys(out).length > 0 ? out : undefined;
45
+ }
46
+
47
+ /** Plugin .mcp.json env mapping from program.appConfig env entries. */
48
+ export function buildPluginMcpEnvMapping(program: CliProgram): Record<string, string> | undefined {
49
+ const appConfig = program.appConfig;
50
+ if (!appConfig) {
51
+ return undefined;
52
+ }
53
+ const out: Record<string, string> = {};
54
+ for (const [key, entry] of Object.entries(appConfig.entries)) {
55
+ if (!entry.env) {
56
+ continue;
57
+ }
58
+ const manifestKey = configUserConfigKey(key);
59
+ out[entry.env] = `\${user_config.${manifestKey}}`;
60
+ }
61
+ return Object.keys(out).length > 0 ? out : undefined;
62
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CliProgram } from "../types.ts";
3
+ import { resolveAppConfig } from "./resolve.ts";
4
+
5
+ const program: CliProgram = {
6
+ key: "app",
7
+ version: "1.0.0",
8
+ description: "Test.",
9
+ appConfig: {
10
+ jsonSchema: {
11
+ type: "object",
12
+ properties: {
13
+ apiToken: { type: "string" },
14
+ maxRetries: { type: "integer", default: 3 },
15
+ region: { type: "string", default: "us-east-1" },
16
+ },
17
+ required: ["apiToken"],
18
+ },
19
+ entries: {
20
+ apiToken: { description: "Token.", env: "API_TOKEN" },
21
+ maxRetries: { description: "Retries." },
22
+ region: { description: "Region.", required: false },
23
+ },
24
+ },
25
+ handler: () => {},
26
+ };
27
+
28
+ describe("config/resolve", () => {
29
+ test("prefers env over file for mapped keys", () => {
30
+ const prev = process.env.API_TOKEN;
31
+ process.env.API_TOKEN = "from-env";
32
+ try {
33
+ const resolved = resolveAppConfig(program, { apiToken: "from-file" });
34
+ expect(resolved.apiToken).toBe("from-env");
35
+ } finally {
36
+ if (prev === undefined) delete process.env.API_TOKEN;
37
+ else process.env.API_TOKEN = prev;
38
+ }
39
+ });
40
+
41
+ test("uses file when env empty", () => {
42
+ const prev = process.env.API_TOKEN;
43
+ delete process.env.API_TOKEN;
44
+ try {
45
+ const resolved = resolveAppConfig(program, { apiToken: "from-file" });
46
+ expect(resolved.apiToken).toBe("from-file");
47
+ } finally {
48
+ if (prev !== undefined) process.env.API_TOKEN = prev;
49
+ }
50
+ });
51
+
52
+ test("empty string in env or file counts as missing", () => {
53
+ const prev = process.env.API_TOKEN;
54
+ process.env.API_TOKEN = "";
55
+ try {
56
+ const resolved = resolveAppConfig(program, { apiToken: "" });
57
+ expect(resolved.apiToken).toBeUndefined();
58
+ } finally {
59
+ if (prev === undefined) delete process.env.API_TOKEN;
60
+ else process.env.API_TOKEN = prev;
61
+ }
62
+ });
63
+
64
+ test("applies jsonSchema default when file and env absent", () => {
65
+ const prev = process.env.API_TOKEN;
66
+ delete process.env.API_TOKEN;
67
+ try {
68
+ const resolved = resolveAppConfig(program, { apiToken: "ok" });
69
+ expect(resolved.maxRetries).toBe(3);
70
+ expect(resolved.region).toBe("us-east-1");
71
+ } finally {
72
+ if (prev !== undefined) process.env.API_TOKEN = prev;
73
+ }
74
+ });
75
+
76
+ test("all-string mode uses entry.default", () => {
77
+ const stringProgram: CliProgram = {
78
+ ...program,
79
+ appConfig: {
80
+ entries: {
81
+ greeting: { description: "Hello.", default: "world" },
82
+ },
83
+ },
84
+ };
85
+ const resolved = resolveAppConfig(stringProgram, {});
86
+ expect(resolved.greeting).toBe("world");
87
+ });
88
+ });