argsbarg 4.0.4 → 4.1.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.
- package/CHANGELOG.md +77 -1
- package/README.md +91 -85
- package/docs/README.md +6 -6
- package/docs/ai-skills.md +8 -5
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +9 -7
- package/docs/config-schema.md +37 -13
- package/docs/developing.md +8 -8
- package/docs/distribution-homebrew.md +103 -0
- package/docs/install.md +143 -106
- package/docs/mcp.md +23 -12
- package/docs/output-schema.md +1 -1
- package/examples/full-example/Formula/.gitkeep +0 -0
- package/examples/full-example/README.md +98 -0
- package/examples/full-example/biome.json +22 -0
- package/examples/{consumer-app → full-example}/bun.lock +2 -0
- package/examples/full-example/justfile +134 -0
- package/examples/{consumer-app → full-example}/package.json +10 -3
- package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
- package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
- package/examples/full-example/scripts/create-identity.ts +11 -0
- package/examples/full-example/scripts/formula-shared.ts +73 -0
- package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
- package/examples/full-example/scripts/print-identity.ts +27 -0
- package/examples/full-example/src/commands/echo/command.ts +21 -0
- package/examples/full-example/src/commands/status/command.test.ts +10 -0
- package/examples/full-example/src/commands/status/command.ts +36 -0
- package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
- package/examples/full-example/src/index.ts +10 -0
- package/examples/full-example/src/program.ts +57 -0
- package/examples/{consumer-app → full-example}/src/types.ts +1 -1
- package/examples/mcp-test.ts +8 -24
- package/examples/nested.ts +1 -3
- package/index.d.ts +81 -65
- package/package.json +2 -2
- package/src/builtins/builtins.test.ts +37 -22
- package/src/builtins/completion-group.ts +17 -15
- package/src/builtins/config.test.ts +31 -25
- package/src/builtins/config.ts +4 -3
- package/src/builtins/dispatch.ts +25 -1
- package/src/builtins/install.ts +45 -82
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/registry.ts +2 -0
- package/src/builtins/uninstall.ts +80 -0
- package/src/capabilities.ts +5 -7
- package/src/cli-tool/cli-smoke.test.ts +19 -0
- package/src/cli-tool/create.test.ts +119 -0
- package/src/cli-tool/create.ts +380 -0
- package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
- package/src/cli-tool/main.ts +8 -0
- package/src/cli-tool/post-create.ts +111 -0
- package/src/cli-tool/program.ts +82 -0
- package/src/cli-tool/prompt.ts +28 -0
- package/src/cli-tool/run-create.ts +149 -0
- package/src/config/bootstrap.ts +174 -66
- package/src/config/context.test.ts +22 -36
- package/src/config/context.ts +5 -4
- package/src/config/file.test.ts +66 -56
- package/src/config/file.ts +33 -25
- package/src/config/resolve.test.ts +192 -1
- package/src/config/resolve.ts +92 -13
- package/src/config.integration.test.ts +17 -10
- package/src/docs/api-guide.test.ts +4 -5
- package/src/docs/docs.test.ts +2 -1
- package/src/docs/mcp-guide.ts +7 -8
- package/src/hidden-mcpb.test.ts +41 -1
- package/src/index.ts +7 -10
- package/src/install/binary-placement.test.ts +101 -0
- package/src/install/binary-placement.ts +47 -0
- package/src/install/detect-installed.ts +2 -97
- package/src/install/index.ts +239 -168
- package/src/install/install-validate.test.ts +61 -0
- package/src/install/install.test.ts +170 -90
- package/src/install/mcp-openclaw.test.ts +40 -0
- package/src/install/mcp-openclaw.ts +106 -0
- package/src/install/normalize-uninstall.ts +11 -0
- package/src/install/normalize.ts +20 -0
- package/src/install/paths.ts +18 -26
- package/src/install/plan.ts +40 -261
- package/src/install/shell.ts +0 -14
- package/src/install/status.test.ts +85 -0
- package/src/install/status.ts +22 -15
- package/src/install/target-base.ts +93 -0
- package/src/install/target-detect.ts +20 -0
- package/src/install/target-effective.ts +129 -0
- package/src/install/target-mcp-cli.ts +149 -0
- package/src/install/target-mcp-json.ts +130 -0
- package/src/install/target-plan-build.ts +67 -0
- package/src/install/target-registry.ts +57 -0
- package/src/install/target-scope.ts +253 -0
- package/src/install/target-skill.ts +104 -0
- package/src/install/target-types.ts +129 -0
- package/src/install/targets/app.ts +60 -0
- package/src/install/targets/chatgpt-mcp.ts +12 -0
- package/src/install/targets/claude-code-mcp.ts +15 -0
- package/src/install/targets/claude-desktop-mcp.ts +12 -0
- package/src/install/targets/claude-skill.ts +16 -0
- package/src/install/targets/codex-mcp.ts +25 -0
- package/src/install/targets/codex-skill.ts +14 -0
- package/src/install/targets/configure.ts +63 -0
- package/src/install/targets/cursor-mcp.ts +15 -0
- package/src/install/targets/cursor-skill.ts +16 -0
- package/src/install/targets/index.ts +50 -0
- package/src/install/targets/openclaw-mcp.ts +25 -0
- package/src/install/targets/openclaw-skill.ts +17 -0
- package/src/install/targets/opencode-mcp.ts +101 -0
- package/src/install/targets/opencode-skill.ts +15 -0
- package/src/install/targets.test.ts +118 -0
- package/src/install/uninstall.ts +16 -152
- package/src/invoke.test.ts +7 -1
- package/src/mcp/bundle.ts +16 -4
- package/src/mcp/claude.test.ts +14 -1
- package/src/mcp/claude.ts +11 -4
- package/src/mcp/env.test.ts +92 -0
- package/src/mcp/env.ts +15 -14
- package/src/mcp/zip.test.ts +17 -0
- package/src/mcp/zip.ts +62 -9
- package/src/mcp.integration.test.ts +1 -1
- package/src/parse.test.ts +14 -2
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- package/src/prompt.ts +10 -0
- package/src/schema.ts +9 -1
- package/src/skill/generate.ts +18 -4
- package/src/skill/install.ts +33 -6
- package/src/skill/naming.ts +28 -0
- package/src/types.ts +86 -7
- package/src/validate.ts +73 -9
- package/docs/templates/cursor/rules/cli-program.mdc +0 -31
- package/examples/config-app/main.ts +0 -20
- package/examples/config-app/program.ts +0 -81
- package/examples/config-app/schema.ts +0 -37
- package/examples/config-app/types.ts +0 -19
- package/examples/consumer-app/README.md +0 -57
- package/examples/consumer-app/src/main.ts +0 -15
- package/examples/consumer-app/src/program.ts +0 -108
- package/src/install/binary.ts +0 -94
- package/src/install/completions.ts +0 -56
- package/src/install/update.test.ts +0 -108
- package/src/install/update.ts +0 -57
- /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
- /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
package/src/config/file.test.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import type { CliProgram } from "../types.ts";
|
|
6
6
|
import { bootstrapAppConfig } from "./bootstrap.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
readAppConfigFile,
|
|
9
|
+
resolveAppConfigDir,
|
|
10
|
+
resolveAppConfigPath,
|
|
11
|
+
uninstallAppConfig,
|
|
12
|
+
writeAppConfigFile,
|
|
13
|
+
} from "./file.ts";
|
|
8
14
|
import { buildProgramUserConfig } from "./manifest.ts";
|
|
9
15
|
import { formatMissingConfigMessage, missingRequiredConfig, resolveAppConfig } from "./resolve.ts";
|
|
10
16
|
|
|
@@ -13,7 +19,6 @@ const program: CliProgram = {
|
|
|
13
19
|
version: "1.0.0",
|
|
14
20
|
description: "Demo.",
|
|
15
21
|
appConfig: {
|
|
16
|
-
path: "~/.config/myapp-test/config",
|
|
17
22
|
entries: {
|
|
18
23
|
apiToken: { description: "Token.", env: "API_TOKEN" },
|
|
19
24
|
port: { description: "HTTP listen port (default 8080).", required: false },
|
|
@@ -22,6 +27,19 @@ const program: CliProgram = {
|
|
|
22
27
|
handler: () => {},
|
|
23
28
|
};
|
|
24
29
|
|
|
30
|
+
function withHome<T>(fn: (home: string) => T): T {
|
|
31
|
+
const home = mkdtempSync(join(tmpdir(), "cfg-test-"));
|
|
32
|
+
const prevHome = process.env.HOME;
|
|
33
|
+
process.env.HOME = home;
|
|
34
|
+
try {
|
|
35
|
+
return fn(home);
|
|
36
|
+
} finally {
|
|
37
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
38
|
+
else process.env.HOME = prevHome;
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
describe("config/file", () => {
|
|
26
44
|
test("buildProgramUserConfig from program.appConfig env entries", () => {
|
|
27
45
|
const cfg = buildProgramUserConfig(program);
|
|
@@ -33,31 +51,29 @@ describe("config/file", () => {
|
|
|
33
51
|
expect(cfg?.port).toBeUndefined();
|
|
34
52
|
});
|
|
35
53
|
|
|
54
|
+
test("resolveAppConfigPath uses config.json", () => {
|
|
55
|
+
withHome((home) => {
|
|
56
|
+
expect(resolveAppConfigPath(program)).toBe(
|
|
57
|
+
join(home, ".local", "lib", "myapp", "config.json"),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
36
62
|
test("resolveAppConfig prefers host env over file", () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
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
|
-
}
|
|
63
|
+
withHome((_home) => {
|
|
64
|
+
const prevToken = process.env.API_TOKEN;
|
|
65
|
+
process.env.API_TOKEN = "from-host";
|
|
66
|
+
try {
|
|
67
|
+
const configPath = resolveAppConfigPath(program);
|
|
68
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
69
|
+
writeFileSync(configPath, `${JSON.stringify({ apiToken: "from-file" })}\n`);
|
|
70
|
+
const resolved = resolveAppConfig(program, { apiToken: "from-file" });
|
|
71
|
+
expect(resolved.apiToken).toBe("from-host");
|
|
72
|
+
} finally {
|
|
73
|
+
if (prevToken === undefined) delete process.env.API_TOKEN;
|
|
74
|
+
else process.env.API_TOKEN = prevToken;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
61
77
|
});
|
|
62
78
|
|
|
63
79
|
test("missingRequiredConfig and formatMissingConfigMessage", () => {
|
|
@@ -76,37 +92,31 @@ describe("config/file", () => {
|
|
|
76
92
|
});
|
|
77
93
|
|
|
78
94
|
test("rejects unknown keys on read", () => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
}
|
|
95
|
+
withHome(() => {
|
|
96
|
+
const configPath = resolveAppConfigPath(program);
|
|
97
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
98
|
+
writeFileSync(configPath, `${JSON.stringify({ extra: true })}\n`);
|
|
99
|
+
expect(() => readAppConfigFile(program)).toThrow(/Unknown config key/);
|
|
100
|
+
});
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
test("writeAppConfigFile round-trip", () => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
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 });
|
|
104
|
+
withHome(() => {
|
|
105
|
+
writeAppConfigFile(program, { apiToken: "saved" });
|
|
106
|
+
const { resolved } = bootstrapAppConfig(program, { validateFile: true });
|
|
107
107
|
expect(resolved.apiToken).toBe("saved");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("uninstallAppConfig removes config directory recursively", () => {
|
|
112
|
+
withHome(() => {
|
|
113
|
+
writeAppConfigFile(program, { apiToken: "saved" });
|
|
114
|
+
const configPath = resolveAppConfigPath(program);
|
|
115
|
+
const configDir = resolveAppConfigDir(program);
|
|
116
|
+
writeFileSync(join(configDir, "extra.txt"), "leftover", "utf8");
|
|
117
|
+
expect(uninstallAppConfig(program, false)).toEqual([configPath, `${configDir}/`]);
|
|
118
|
+
expect(existsSync(configPath)).toBe(false);
|
|
119
|
+
expect(existsSync(configDir)).toBe(false);
|
|
120
|
+
});
|
|
111
121
|
});
|
|
112
122
|
});
|
package/src/config/file.ts
CHANGED
|
@@ -2,24 +2,20 @@
|
|
|
2
2
|
JSON app config file path helpers and strict read/write.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { sanitizeToolSegment } from "../mcp/tools.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { appConfigLibHome, displayHomePath } from "../paths/host.ts";
|
|
9
9
|
import type { CliProgram } from "../types.ts";
|
|
10
10
|
import { effectiveJsonSchema } from "./schema.ts";
|
|
11
11
|
import { validateConfigDocument } from "./validate.ts";
|
|
12
12
|
|
|
13
13
|
export type AppConfigFileData = Record<string, unknown>;
|
|
14
14
|
|
|
15
|
-
/** Resolved absolute path to the app JSON config file. */
|
|
15
|
+
/** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
|
|
16
16
|
export function resolveAppConfigPath(program: CliProgram): string {
|
|
17
|
-
const custom = program.appConfig?.path;
|
|
18
|
-
if (custom) {
|
|
19
|
-
return expandTilde(custom);
|
|
20
|
-
}
|
|
21
17
|
const dirName = sanitizeToolSegment(program.key);
|
|
22
|
-
return join(
|
|
18
|
+
return join(appConfigLibHome(), dirName, "config.json");
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
/** Resolved absolute directory containing the app JSON config file. */
|
|
@@ -27,14 +23,9 @@ export function resolveAppConfigDir(program: CliProgram): string {
|
|
|
27
23
|
return dirname(resolveAppConfigPath(program));
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
/** Human-readable config path for error messages (
|
|
26
|
+
/** Human-readable config path for error messages (`~/…` when under home). */
|
|
31
27
|
export function displayAppConfigPath(program: CliProgram): string {
|
|
32
|
-
|
|
33
|
-
const home = process.env.HOME ?? "";
|
|
34
|
-
if (home.length > 0 && resolved.startsWith(home)) {
|
|
35
|
-
return `~${resolved.slice(home.length)}`;
|
|
36
|
-
}
|
|
37
|
-
return resolved;
|
|
28
|
+
return displayHomePath(resolveAppConfigPath(program));
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
function parseConfigJson(text: string, path: string): AppConfigFileData {
|
|
@@ -46,7 +37,7 @@ function parseConfigJson(text: string, path: string): AppConfigFileData {
|
|
|
46
37
|
return parsed as AppConfigFileData;
|
|
47
38
|
} catch (err) {
|
|
48
39
|
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
-
throw new Error(`Invalid JSON in config file ${path}: ${msg}`);
|
|
40
|
+
throw new Error(`Invalid JSON in config file ${displayHomePath(path)}: ${msg}`);
|
|
50
41
|
}
|
|
51
42
|
}
|
|
52
43
|
|
|
@@ -60,7 +51,7 @@ export function readAppConfigFileRaw(path: string): AppConfigFileData {
|
|
|
60
51
|
text = readFileSync(path, "utf8");
|
|
61
52
|
} catch (err) {
|
|
62
53
|
const msg = err instanceof Error ? err.message : String(err);
|
|
63
|
-
throw new Error(`Could not read config file ${path}: ${msg}`);
|
|
54
|
+
throw new Error(`Could not read config file ${displayHomePath(path)}: ${msg}`);
|
|
64
55
|
}
|
|
65
56
|
return parseConfigJson(text, path);
|
|
66
57
|
}
|
|
@@ -89,7 +80,7 @@ export function validateAppConfigData(
|
|
|
89
80
|
const allowed = new Set(Object.keys(appConfig.entries));
|
|
90
81
|
for (const key of Object.keys(data)) {
|
|
91
82
|
if (!allowed.has(key)) {
|
|
92
|
-
const where = pathLabel
|
|
83
|
+
const where = pathLabel ? displayHomePath(pathLabel) : "config";
|
|
93
84
|
throw new Error(`Unknown config key '${key}' in ${where}`);
|
|
94
85
|
}
|
|
95
86
|
}
|
|
@@ -99,7 +90,7 @@ export function validateAppConfigData(
|
|
|
99
90
|
}
|
|
100
91
|
const result = validateConfigDocument(data, jsonSchema);
|
|
101
92
|
if (!result.valid) {
|
|
102
|
-
const where = pathLabel
|
|
93
|
+
const where = pathLabel ? displayHomePath(pathLabel) : "config";
|
|
103
94
|
throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
|
|
104
95
|
}
|
|
105
96
|
}
|
|
@@ -112,14 +103,31 @@ export function writeAppConfigFile(program: CliProgram, data: AppConfigFileData)
|
|
|
112
103
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
113
104
|
}
|
|
114
105
|
|
|
115
|
-
/**
|
|
116
|
-
export function
|
|
106
|
+
/** True when the app config file or config directory is present. */
|
|
107
|
+
export function appConfigInstalled(program: CliProgram): boolean {
|
|
117
108
|
const path = resolveAppConfigPath(program);
|
|
118
|
-
if (
|
|
119
|
-
|
|
109
|
+
if (existsSync(path)) return true;
|
|
110
|
+
return existsSync(resolveAppConfigDir(program));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Removes the app config file and config directory when present. */
|
|
114
|
+
export function uninstallAppConfig(program: CliProgram, dry: boolean): string[] {
|
|
115
|
+
const path = resolveAppConfigPath(program);
|
|
116
|
+
const dir = resolveAppConfigDir(program);
|
|
117
|
+
const hasFile = existsSync(path);
|
|
118
|
+
const hasDir = existsSync(dir);
|
|
119
|
+
|
|
120
|
+
if (!hasFile && !hasDir) {
|
|
121
|
+
return [];
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
const changed: string[] = [];
|
|
125
|
+
if (hasFile) changed.push(path);
|
|
126
|
+
if (hasDir) changed.push(`${dir}/`);
|
|
127
|
+
|
|
121
128
|
if (!dry) {
|
|
122
|
-
unlinkSync(path);
|
|
129
|
+
if (hasFile) unlinkSync(path);
|
|
130
|
+
if (hasDir) rmSync(dir, { recursive: true, force: true });
|
|
123
131
|
}
|
|
124
|
-
return
|
|
132
|
+
return changed;
|
|
125
133
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { CliProgram } from "../types.ts";
|
|
3
|
-
import { resolveAppConfig } from "./resolve.ts";
|
|
3
|
+
import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
|
|
4
4
|
|
|
5
5
|
const program: CliProgram = {
|
|
6
6
|
key: "app",
|
|
@@ -85,4 +85,195 @@ describe("config/resolve", () => {
|
|
|
85
85
|
const resolved = resolveAppConfig(stringProgram, {});
|
|
86
86
|
expect(resolved.greeting).toBe("world");
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test("prefers captured host env over file when process.env was exported from file", () => {
|
|
90
|
+
const hostEnv = { API_TOKEN: "from-host" };
|
|
91
|
+
process.env.API_TOKEN = "from-file-export";
|
|
92
|
+
try {
|
|
93
|
+
const resolved = resolveAppConfig(program, { apiToken: "from-file" }, hostEnv);
|
|
94
|
+
expect(resolved.apiToken).toBe("from-host");
|
|
95
|
+
} finally {
|
|
96
|
+
delete process.env.API_TOKEN;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("exportConfigToEnv does not overwrite host env", () => {
|
|
101
|
+
const prev = process.env.API_TOKEN;
|
|
102
|
+
process.env.API_TOKEN = "from-host";
|
|
103
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
104
|
+
try {
|
|
105
|
+
exportConfigToEnv(program, { apiToken: "from-file" }, hostEnv);
|
|
106
|
+
expect(process.env.API_TOKEN).toBe("from-host");
|
|
107
|
+
} finally {
|
|
108
|
+
if (prev === undefined) delete process.env.API_TOKEN;
|
|
109
|
+
else process.env.API_TOKEN = prev;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("resolve callback supplies value when env and file are absent", () => {
|
|
114
|
+
const resolveProgram: CliProgram = {
|
|
115
|
+
...program,
|
|
116
|
+
appConfig: {
|
|
117
|
+
...program.appConfig!,
|
|
118
|
+
entries: {
|
|
119
|
+
...program.appConfig!.entries,
|
|
120
|
+
apiToken: {
|
|
121
|
+
description: "Token.",
|
|
122
|
+
env: "API_TOKEN",
|
|
123
|
+
resolve: () => "from-resolve",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const prev = process.env.API_TOKEN;
|
|
129
|
+
delete process.env.API_TOKEN;
|
|
130
|
+
try {
|
|
131
|
+
const resolved = resolveAppConfig(resolveProgram, {});
|
|
132
|
+
expect(resolved.apiToken).toBe("from-resolve");
|
|
133
|
+
} finally {
|
|
134
|
+
if (prev !== undefined) process.env.API_TOKEN = prev;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("env overrides resolve callback", () => {
|
|
139
|
+
const resolveProgram: CliProgram = {
|
|
140
|
+
...program,
|
|
141
|
+
appConfig: {
|
|
142
|
+
...program.appConfig!,
|
|
143
|
+
entries: {
|
|
144
|
+
...program.appConfig!.entries,
|
|
145
|
+
apiToken: {
|
|
146
|
+
description: "Token.",
|
|
147
|
+
env: "API_TOKEN",
|
|
148
|
+
resolve: () => "from-resolve",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const prev = process.env.API_TOKEN;
|
|
154
|
+
process.env.API_TOKEN = "from-env";
|
|
155
|
+
try {
|
|
156
|
+
const resolved = resolveAppConfig(resolveProgram, {});
|
|
157
|
+
expect(resolved.apiToken).toBe("from-env");
|
|
158
|
+
} finally {
|
|
159
|
+
if (prev === undefined) delete process.env.API_TOKEN;
|
|
160
|
+
else process.env.API_TOKEN = prev;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("file overrides resolve callback", () => {
|
|
165
|
+
const resolveProgram: CliProgram = {
|
|
166
|
+
...program,
|
|
167
|
+
appConfig: {
|
|
168
|
+
...program.appConfig!,
|
|
169
|
+
entries: {
|
|
170
|
+
...program.appConfig!.entries,
|
|
171
|
+
apiToken: {
|
|
172
|
+
description: "Token.",
|
|
173
|
+
env: "API_TOKEN",
|
|
174
|
+
resolve: () => "from-resolve",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const prev = process.env.API_TOKEN;
|
|
180
|
+
delete process.env.API_TOKEN;
|
|
181
|
+
try {
|
|
182
|
+
const resolved = resolveAppConfig(resolveProgram, { apiToken: "from-file" });
|
|
183
|
+
expect(resolved.apiToken).toBe("from-file");
|
|
184
|
+
} finally {
|
|
185
|
+
if (prev !== undefined) process.env.API_TOKEN = prev;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("falls back to env when resolve returns undefined", () => {
|
|
190
|
+
const resolveProgram: CliProgram = {
|
|
191
|
+
...program,
|
|
192
|
+
appConfig: {
|
|
193
|
+
...program.appConfig!,
|
|
194
|
+
entries: {
|
|
195
|
+
...program.appConfig!.entries,
|
|
196
|
+
apiToken: {
|
|
197
|
+
description: "Token.",
|
|
198
|
+
env: "API_TOKEN",
|
|
199
|
+
resolve: () => undefined,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const hostEnv = { API_TOKEN: "from-env-fallback" };
|
|
205
|
+
const prev = process.env.API_TOKEN;
|
|
206
|
+
delete process.env.API_TOKEN;
|
|
207
|
+
try {
|
|
208
|
+
const resolved = resolveAppConfig(resolveProgram, {}, hostEnv);
|
|
209
|
+
expect(resolved.apiToken).toBe("from-env-fallback");
|
|
210
|
+
} finally {
|
|
211
|
+
if (prev !== undefined) process.env.API_TOKEN = prev;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("resolve callback is skipped when env is set", () => {
|
|
216
|
+
let resolveCalled = false;
|
|
217
|
+
const resolveProgram: CliProgram = {
|
|
218
|
+
...program,
|
|
219
|
+
appConfig: {
|
|
220
|
+
...program.appConfig!,
|
|
221
|
+
entries: {
|
|
222
|
+
...program.appConfig!.entries,
|
|
223
|
+
apiToken: {
|
|
224
|
+
description: "Token.",
|
|
225
|
+
env: "API_TOKEN",
|
|
226
|
+
resolve: () => {
|
|
227
|
+
resolveCalled = true;
|
|
228
|
+
return "from-resolve";
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const prev = process.env.API_TOKEN;
|
|
235
|
+
process.env.API_TOKEN = "from-env";
|
|
236
|
+
try {
|
|
237
|
+
const resolved = resolveAppConfig(resolveProgram, {});
|
|
238
|
+
expect(resolved.apiToken).toBe("from-env");
|
|
239
|
+
expect(resolveCalled).toBe(false);
|
|
240
|
+
} finally {
|
|
241
|
+
if (prev === undefined) delete process.env.API_TOKEN;
|
|
242
|
+
else process.env.API_TOKEN = prev;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("async resolve is ignored with stderr warning", () => {
|
|
247
|
+
const resolveProgram: CliProgram = {
|
|
248
|
+
...program,
|
|
249
|
+
appConfig: {
|
|
250
|
+
...program.appConfig!,
|
|
251
|
+
entries: {
|
|
252
|
+
...program.appConfig!.entries,
|
|
253
|
+
apiToken: {
|
|
254
|
+
description: "Token.",
|
|
255
|
+
env: "API_TOKEN",
|
|
256
|
+
resolve: async () => "from-async-resolve",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
const prev = process.env.API_TOKEN;
|
|
262
|
+
delete process.env.API_TOKEN;
|
|
263
|
+
const prevStderr = process.stderr.write;
|
|
264
|
+
const stderrLines: string[] = [];
|
|
265
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
266
|
+
stderrLines.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
|
|
267
|
+
return true;
|
|
268
|
+
}) as typeof process.stderr.write;
|
|
269
|
+
try {
|
|
270
|
+
const resolved = resolveAppConfig(resolveProgram, {}, { API_TOKEN: undefined });
|
|
271
|
+
expect(resolved.apiToken).toBeUndefined();
|
|
272
|
+
expect(stderrLines.join("")).toContain("returned a Promise");
|
|
273
|
+
} finally {
|
|
274
|
+
process.stderr.write = prevStderr;
|
|
275
|
+
if (prev === undefined) delete process.env.API_TOKEN;
|
|
276
|
+
else process.env.API_TOKEN = prev;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
88
279
|
});
|
package/src/config/resolve.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Resolve program.appConfig values: defaults, file, env override, export to process.env.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
5
|
+
import type { CliAppConfigEntry, CliAppConfigResolveContext, CliProgram } from "../types.ts";
|
|
6
6
|
import { configEntryRequired, jsonSchemaRequiredKeys } from "./entry.ts";
|
|
7
7
|
import type { AppConfigFileData } from "./file.ts";
|
|
8
8
|
import { displayAppConfigPath } from "./file.ts";
|
|
@@ -20,8 +20,26 @@ function isPresent(value: unknown): boolean {
|
|
|
20
20
|
return true;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Snapshot mapped host env at bootstrap entry (before file exports mutate process.env). */
|
|
24
|
+
export function captureMappedHostEnv(program: CliProgram): Record<string, string | undefined> {
|
|
25
|
+
const out: Record<string, string | undefined> = {};
|
|
26
|
+
const entries = program.appConfig?.entries;
|
|
27
|
+
if (!entries) {
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of Object.values(entries)) {
|
|
31
|
+
if (entry.env) {
|
|
32
|
+
out[entry.env] = process.env[entry.env];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envOverrideValue(
|
|
39
|
+
envName: string,
|
|
40
|
+
hostEnv?: Record<string, string | undefined>,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const val = hostEnv && envName in hostEnv ? hostEnv[envName] : process.env[envName];
|
|
25
43
|
if (val === undefined || val.length === 0) {
|
|
26
44
|
return undefined;
|
|
27
45
|
}
|
|
@@ -55,15 +73,19 @@ function coerceEnvValue(program: CliProgram, key: string, raw: string): unknown
|
|
|
55
73
|
return raw;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
/** Resolve all schema keys from file data and
|
|
59
|
-
export function resolveAppConfig(
|
|
76
|
+
/** Resolve all schema keys from file data and mapped host env (env wins over file). */
|
|
77
|
+
export function resolveAppConfig(
|
|
78
|
+
program: CliProgram,
|
|
79
|
+
fileData: AppConfigFileData,
|
|
80
|
+
hostEnv?: Record<string, string | undefined>,
|
|
81
|
+
): ResolvedConfig {
|
|
60
82
|
const appConfig = program.appConfig;
|
|
61
83
|
if (!appConfig) {
|
|
62
84
|
return {};
|
|
63
85
|
}
|
|
64
86
|
const out: ResolvedConfig = {};
|
|
65
87
|
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
66
|
-
const value = resolveConfigKey(program, key, entry, fileData);
|
|
88
|
+
const value = resolveConfigKey(program, key, entry, fileData, hostEnv);
|
|
67
89
|
if (value !== undefined) {
|
|
68
90
|
out[key] = value;
|
|
69
91
|
}
|
|
@@ -71,21 +93,66 @@ export function resolveAppConfig(program: CliProgram, fileData: AppConfigFileDat
|
|
|
71
93
|
return out;
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
function tryEnvOverride(
|
|
97
|
+
program: CliProgram,
|
|
98
|
+
key: string,
|
|
99
|
+
entry: CliAppConfigEntry,
|
|
100
|
+
hostEnv?: Record<string, string | undefined>,
|
|
101
|
+
): unknown {
|
|
102
|
+
if (!entry.env) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const fromEnv = envOverrideValue(entry.env, hostEnv);
|
|
106
|
+
if (fromEnv === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return coerceEnvValue(program, key, fromEnv);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildResolveContext(
|
|
113
|
+
program: CliProgram,
|
|
114
|
+
key: string,
|
|
115
|
+
entry: CliAppConfigEntry,
|
|
116
|
+
fileData: AppConfigFileData,
|
|
117
|
+
hostEnv?: Record<string, string | undefined>,
|
|
118
|
+
): CliAppConfigResolveContext {
|
|
119
|
+
return {
|
|
120
|
+
key,
|
|
121
|
+
entry,
|
|
122
|
+
program,
|
|
123
|
+
fileValue: fileData[key],
|
|
124
|
+
envValue: entry.env ? envOverrideValue(entry.env, hostEnv) : undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
74
128
|
function resolveConfigKey(
|
|
75
129
|
program: CliProgram,
|
|
76
130
|
key: string,
|
|
77
131
|
entry: CliAppConfigEntry,
|
|
78
132
|
fileData: AppConfigFileData,
|
|
133
|
+
hostEnv?: Record<string, string | undefined>,
|
|
79
134
|
): unknown {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return coerceEnvValue(program, key, fromEnv);
|
|
84
|
-
}
|
|
135
|
+
const fromEnv = tryEnvOverride(program, key, entry, hostEnv);
|
|
136
|
+
if (fromEnv !== undefined) {
|
|
137
|
+
return fromEnv;
|
|
85
138
|
}
|
|
86
139
|
if (key in fileData && isPresent(fileData[key])) {
|
|
87
140
|
return fileData[key];
|
|
88
141
|
}
|
|
142
|
+
if (entry.resolve) {
|
|
143
|
+
const fromResolve = entry.resolve(buildResolveContext(program, key, entry, fileData, hostEnv));
|
|
144
|
+
if (fromResolve != null && typeof (fromResolve as Promise<unknown>).then === "function") {
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`[argsbarg] config "${key}": resolve() returned a Promise; use a synchronous resolver (e.g. Bun.spawnSync with piped stdout).\n`,
|
|
147
|
+
);
|
|
148
|
+
} else if (isPresent(fromResolve)) {
|
|
149
|
+
return fromResolve;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const fromEnvFallback = tryEnvOverride(program, key, entry, hostEnv);
|
|
153
|
+
if (fromEnvFallback !== undefined) {
|
|
154
|
+
return fromEnvFallback;
|
|
155
|
+
}
|
|
89
156
|
const def = schemaDefaultForKey(program, key);
|
|
90
157
|
if (def !== undefined) {
|
|
91
158
|
return def;
|
|
@@ -93,8 +160,12 @@ function resolveConfigKey(
|
|
|
93
160
|
return undefined;
|
|
94
161
|
}
|
|
95
162
|
|
|
96
|
-
/** Write mapped config values to process.env for subprocess inheritance. */
|
|
97
|
-
export function exportConfigToEnv(
|
|
163
|
+
/** Write mapped config values to process.env for subprocess inheritance (never overwrites host env). */
|
|
164
|
+
export function exportConfigToEnv(
|
|
165
|
+
program: CliProgram,
|
|
166
|
+
resolved: ResolvedConfig,
|
|
167
|
+
hostEnv?: Record<string, string | undefined>,
|
|
168
|
+
): void {
|
|
98
169
|
const appConfig = program.appConfig;
|
|
99
170
|
if (!appConfig) {
|
|
100
171
|
return;
|
|
@@ -103,6 +174,14 @@ export function exportConfigToEnv(program: CliProgram, resolved: ResolvedConfig)
|
|
|
103
174
|
if (!entry.env) {
|
|
104
175
|
continue;
|
|
105
176
|
}
|
|
177
|
+
const captured = hostEnv?.[entry.env];
|
|
178
|
+
if (captured !== undefined && captured.length > 0) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const existing = process.env[entry.env];
|
|
182
|
+
if (existing !== undefined && existing.length > 0) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
106
185
|
const value = resolved[key];
|
|
107
186
|
if (!isPresent(value)) {
|
|
108
187
|
continue;
|