argsbarg 5.1.8 → 5.1.9
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 +18 -1
- package/docs/cli-program.md +3 -1
- package/docs/config-schema.md +5 -3
- package/docs/configure.md +13 -7
- package/docs/distribution-homebrew.md +3 -2
- package/examples/full-example/justfile +2 -5
- package/index.d.ts +10 -1
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +1 -3
- package/src/builtins/completion-bash.ts +6 -27
- package/src/builtins/completion-group.ts +1 -2
- package/src/builtins/completion-simulate-shared.ts +2 -10
- package/src/builtins/completion-zsh.ts +7 -33
- package/src/builtins/config.test.ts +28 -0
- package/src/builtins/config.ts +40 -10
- package/src/builtins/configure-copy.ts +3 -6
- package/src/builtins/dispatch.ts +2 -10
- package/src/builtins/export.ts +1 -3
- package/src/builtins/presentation.ts +2 -7
- package/src/builtins/registry.ts +1 -5
- package/src/cli-tool/create.test.ts +3 -6
- package/src/cli-tool/create.ts +5 -22
- package/src/cli-tool/post-create.ts +1 -3
- package/src/cli-tool/program.ts +1 -2
- package/src/cli-tool/run-create.ts +2 -8
- package/src/cli.ts +6 -27
- package/src/config/bindings.test.ts +72 -0
- package/src/config/bindings.ts +115 -0
- package/src/config/bootstrap.ts +80 -65
- package/src/config/context.test.ts +45 -0
- package/src/config/context.ts +74 -14
- package/src/config/entry.ts +1 -3
- package/src/config/file.test.ts +46 -3
- package/src/config/file.ts +59 -19
- package/src/config/resolve.ts +1 -4
- package/src/config/schema.ts +1 -3
- package/src/config/validate.test.ts +3 -11
- package/src/config/validate.ts +30 -30
- package/src/config.integration.test.ts +1 -5
- package/src/configure/configure.test.ts +19 -5
- package/src/configure/index.ts +17 -15
- package/src/docs/api-guide.ts +1 -6
- package/src/docs/builtin.ts +1 -5
- package/src/docs/docs.test.ts +2 -6
- package/src/docs/mcp-guide.ts +3 -13
- package/src/docs/mcp-resources.test.ts +1 -3
- package/src/docs/mcp-resources.ts +1 -6
- package/src/docs/save.ts +2 -10
- package/src/formats.test.ts +1 -7
- package/src/formats.ts +1 -5
- package/src/headless.test.ts +5 -17
- package/src/help.ts +9 -51
- package/src/hidden-mcpb.test.ts +3 -12
- package/src/hidden.ts +1 -3
- package/src/install/binary-placement.test.ts +2 -6
- package/src/install/binary-placement.ts +1 -4
- package/src/install/gh-release-update.ts +5 -22
- package/src/install/mcp-codex.ts +1 -2
- package/src/install/mcp-config.ts +2 -12
- package/src/install/mcp-opencode.test.ts +1 -3
- package/src/install/mcp-opencode.ts +3 -15
- package/src/install/plan.ts +2 -10
- package/src/install/status.ts +3 -5
- package/src/install/target-base.ts +2 -11
- package/src/install/target-detect.ts +1 -5
- package/src/install/target-effective.ts +3 -13
- package/src/install/target-mcp-cli.ts +4 -26
- package/src/install/target-mcp-json.ts +3 -14
- package/src/install/target-plan-build.ts +1 -5
- package/src/install/target-registry.ts +13 -21
- package/src/install/target-scope.ts +8 -25
- package/src/install/target-types.ts +1 -6
- package/src/install/targets/app.ts +2 -2
- package/src/install/targets/opencode-mcp.ts +1 -6
- package/src/install/targets.test.ts +3 -10
- package/src/install/uninstall.ts +2 -9
- package/src/invoke.test.ts +1 -3
- package/src/mcp/bundle.ts +4 -19
- package/src/mcp/claude.test.ts +3 -3
- package/src/mcp/claude.ts +4 -17
- package/src/mcp/env.ts +1 -3
- package/src/mcp/server.ts +3 -12
- package/src/mcp/tools.ts +2 -11
- package/src/mcp.integration.test.ts +4 -10
- package/src/parse.test.ts +9 -34
- package/src/parse.ts +10 -44
- package/src/schema.ts +2 -10
- package/src/skill/hint.ts +1 -5
- package/src/skill/install.ts +1 -5
- package/src/test-fixtures.ts +1 -3
- package/src/validate.ts +29 -90
package/src/builtins/registry.ts
CHANGED
|
@@ -8,11 +8,7 @@ import { cliBuiltinVersionCommand } from "./version.ts";
|
|
|
8
8
|
|
|
9
9
|
type BuiltinFactory = (program: CliProgram) => CliNode | null;
|
|
10
10
|
|
|
11
|
-
function pushBuiltin(
|
|
12
|
-
builtins: CliNode[],
|
|
13
|
-
program: CliProgram,
|
|
14
|
-
factory: BuiltinFactory | null,
|
|
15
|
-
): void {
|
|
11
|
+
function pushBuiltin(builtins: CliNode[], program: CliProgram, factory: BuiltinFactory | null): void {
|
|
16
12
|
if (!factory) {
|
|
17
13
|
return;
|
|
18
14
|
}
|
|
@@ -52,13 +52,10 @@ describe("argsbarg create", () => {
|
|
|
52
52
|
|
|
53
53
|
/** ResolveCreateOptions derives identity defaults from key. */
|
|
54
54
|
test("resolveCreateOptions derives identity defaults from key", () => {
|
|
55
|
-
expect(resolveCreateOptions({ key: "1password", releaseRepo: "org/1password" }).className).toBe(
|
|
56
|
-
|
|
55
|
+
expect(resolveCreateOptions({ key: "1password", releaseRepo: "org/1password" }).className).toBe("App1password");
|
|
56
|
+
expect(resolveCreateOptions({ key: "my-cli", className: "Custom", releaseRepo: "org/my-cli" }).className).toBe(
|
|
57
|
+
"Custom",
|
|
57
58
|
);
|
|
58
|
-
expect(
|
|
59
|
-
resolveCreateOptions({ key: "my-cli", className: "Custom", releaseRepo: "org/my-cli" })
|
|
60
|
-
.className,
|
|
61
|
-
).toBe("Custom");
|
|
62
59
|
const opts = resolveCreateOptions({ key: "at1", releaseRepo: "bdombro/at1" });
|
|
63
60
|
expect(opts.tap).toBe("bdombro/at1");
|
|
64
61
|
expect(opts.releaseRepo).toBe("bdombro/at1");
|
package/src/cli-tool/create.ts
CHANGED
|
@@ -70,9 +70,7 @@ function tapLibraryParts(tap: string): { org: string; repo: string } {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/** Parse `scripts/create-identity.ts` for inference and template defaults. */
|
|
73
|
-
export function parseCreateIdentityFile(
|
|
74
|
-
path: string,
|
|
75
|
-
): Partial<CreateOptions> & { envPrefix?: string } {
|
|
73
|
+
export function parseCreateIdentityFile(path: string): Partial<CreateOptions> & { envPrefix?: string } {
|
|
76
74
|
if (!existsSync(path)) return {};
|
|
77
75
|
const text = readFileSync(path, "utf8");
|
|
78
76
|
const pick = (field: string) => text.match(new RegExp(`${field}:\\s*"([^"]*)"`))?.[1];
|
|
@@ -109,18 +107,8 @@ export function templateIdentity(): {
|
|
|
109
107
|
};
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
export function inferCreateOptions(
|
|
113
|
-
|
|
114
|
-
partial: Partial<CreateOptions>,
|
|
115
|
-
): Partial<CreateOptions> {
|
|
116
|
-
if (
|
|
117
|
-
partial.key &&
|
|
118
|
-
partial.className &&
|
|
119
|
-
partial.homepage &&
|
|
120
|
-
partial.releaseRepo &&
|
|
121
|
-
partial.desc &&
|
|
122
|
-
partial.tap
|
|
123
|
-
) {
|
|
110
|
+
export function inferCreateOptions(baseDir: string, partial: Partial<CreateOptions>): Partial<CreateOptions> {
|
|
111
|
+
if (partial.key && partial.className && partial.homepage && partial.releaseRepo && partial.desc && partial.tap) {
|
|
124
112
|
return partial;
|
|
125
113
|
}
|
|
126
114
|
const fromIdentity = parseCreateIdentityFile(join(baseDir, CREATE_IDENTITY_REL));
|
|
@@ -146,19 +134,14 @@ function assertReleaseRepoFormat(releaseRepo: string): void {
|
|
|
146
134
|
}
|
|
147
135
|
}
|
|
148
136
|
|
|
149
|
-
export function resolveCreateOptions(
|
|
150
|
-
partial: Partial<CreateOptions>,
|
|
151
|
-
baseDir?: string,
|
|
152
|
-
): CreateOptions {
|
|
137
|
+
export function resolveCreateOptions(partial: Partial<CreateOptions>, baseDir?: string): CreateOptions {
|
|
153
138
|
const merged = baseDir ? inferCreateOptions(baseDir, partial) : partial;
|
|
154
139
|
const tmpl = templateIdentity();
|
|
155
140
|
const key = merged.key ?? tmpl.key ?? "full-example";
|
|
156
141
|
const className = merged.className ?? classNameFromKey(key);
|
|
157
142
|
const releaseRepo = merged.releaseRepo;
|
|
158
143
|
if (!releaseRepo) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
"GitHub release repo (org/repo) is required. Pass --release-repo or use the interactive wizard.",
|
|
161
|
-
);
|
|
144
|
+
throw new Error("GitHub release repo (org/repo) is required. Pass --release-repo or use the interactive wizard.");
|
|
162
145
|
}
|
|
163
146
|
assertReleaseRepoFormat(releaseRepo);
|
|
164
147
|
const devTemplate = merged.devTemplate ?? (baseDir ? isDevTemplateDir(baseDir) : false);
|
|
@@ -69,9 +69,7 @@ export async function runPostCreate(targetDir: string, dryRun: boolean): Promise
|
|
|
69
69
|
run: () => {
|
|
70
70
|
if (dryRun) return;
|
|
71
71
|
if (shouldSkipGitBootstrap(abs)) {
|
|
72
|
-
process.stderr.write(
|
|
73
|
-
"Skipping git bootstrap (existing repo or nested in git work tree).\n",
|
|
74
|
-
);
|
|
72
|
+
process.stderr.write("Skipping git bootstrap (existing repo or nested in git work tree).\n");
|
|
75
73
|
return;
|
|
76
74
|
}
|
|
77
75
|
let proc = Bun.spawnSync(["git", "init"], {
|
package/src/cli-tool/program.ts
CHANGED
|
@@ -40,8 +40,7 @@ export const program = {
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
name: "desc",
|
|
43
|
-
description:
|
|
44
|
-
"App + formula description (default: <ClassName> CLI; stored in create-identity.ts).",
|
|
43
|
+
description: "App + formula description (default: <ClassName> CLI; stored in create-identity.ts).",
|
|
45
44
|
kind: CliOptionKind.String,
|
|
46
45
|
},
|
|
47
46
|
{ name: "force", description: "Overwrite existing files.", kind: CliOptionKind.Presence },
|
|
@@ -39,9 +39,7 @@ function collectInteractiveOptions(
|
|
|
39
39
|
);
|
|
40
40
|
|
|
41
41
|
process.stderr.write(`\nTarget: ${baseDir}\n`);
|
|
42
|
-
process.stderr.write(
|
|
43
|
-
`Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap} Release: ${opts.releaseRepo}\n`,
|
|
44
|
-
);
|
|
42
|
+
process.stderr.write(`Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap} Release: ${opts.releaseRepo}\n`);
|
|
45
43
|
process.stderr.write(`Homepage: ${opts.homepage}\n\n`);
|
|
46
44
|
const tree = renderCreateTree(opts);
|
|
47
45
|
process.stderr.write(`Files (${tree.size}):\n`);
|
|
@@ -101,11 +99,7 @@ export async function runCreate(input: Partial<CreateOptions> & { dir?: string }
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
async function runCreateApply(
|
|
105
|
-
baseDir: string,
|
|
106
|
-
opts: CreateOptions,
|
|
107
|
-
dryRun: boolean,
|
|
108
|
-
): Promise<number> {
|
|
102
|
+
async function runCreateApply(baseDir: string, opts: CreateOptions, dryRun: boolean): Promise<number> {
|
|
109
103
|
if (dryRun) {
|
|
110
104
|
const written = applyCreate(baseDir, { ...opts, dryRun: true, check: false });
|
|
111
105
|
process.stdout.write(`Would write ${written.length} file(s) under ${baseDir}\n`);
|
package/src/cli.ts
CHANGED
|
@@ -11,12 +11,9 @@ import {
|
|
|
11
11
|
resolveCapabilities,
|
|
12
12
|
skipsRequiredAppConfigExit,
|
|
13
13
|
} from "./capabilities.ts";
|
|
14
|
-
import {
|
|
15
|
-
bootstrapAppConfig,
|
|
16
|
-
type EnsureAppConfigOpts,
|
|
17
|
-
ensureAppConfig,
|
|
18
|
-
} from "./config/bootstrap.ts";
|
|
14
|
+
import { bootstrapAppConfig, type EnsureAppConfigOpts, ensureAppConfig } from "./config/bootstrap.ts";
|
|
19
15
|
import { type AnyAppConfigSnapshot, createAppConfigSnapshot } from "./config/context.ts";
|
|
16
|
+
import { readAppConfigFileRaw, resolveAppConfigPath } from "./config/file.ts";
|
|
20
17
|
import { effectiveJsonSchema } from "./config/schema.ts";
|
|
21
18
|
import { CliContext } from "./context.ts";
|
|
22
19
|
import { cliHelpRender } from "./help.ts";
|
|
@@ -124,15 +121,7 @@ export class Cli {
|
|
|
124
121
|
exitOnMissing: !skipRequiredConfig,
|
|
125
122
|
});
|
|
126
123
|
|
|
127
|
-
const ctx = new CliContext(
|
|
128
|
-
this.program.key,
|
|
129
|
-
pr.path,
|
|
130
|
-
pr.args,
|
|
131
|
-
pr.opts,
|
|
132
|
-
this.program,
|
|
133
|
-
"cli",
|
|
134
|
-
snapshot,
|
|
135
|
-
);
|
|
124
|
+
const ctx = new CliContext(this.program.key, pr.path, pr.args, pr.opts, this.program, "cli", snapshot);
|
|
136
125
|
try {
|
|
137
126
|
await Promise.resolve(leaf.handler(ctx));
|
|
138
127
|
process.exit(0);
|
|
@@ -171,15 +160,7 @@ export class Cli {
|
|
|
171
160
|
exitOnMissing: false,
|
|
172
161
|
});
|
|
173
162
|
|
|
174
|
-
const ctx = new CliContext(
|
|
175
|
-
this.program.key,
|
|
176
|
-
pr.path,
|
|
177
|
-
pr.args,
|
|
178
|
-
pr.opts,
|
|
179
|
-
this.program,
|
|
180
|
-
"mcp",
|
|
181
|
-
snapshot,
|
|
182
|
-
);
|
|
163
|
+
const ctx = new CliContext(this.program.key, pr.path, pr.args, pr.opts, this.program, "mcp", snapshot);
|
|
183
164
|
|
|
184
165
|
let stdout = "";
|
|
185
166
|
let stderr = "";
|
|
@@ -293,9 +274,7 @@ export class Cli {
|
|
|
293
274
|
): PreparedDispatch | { error: ParseResult } {
|
|
294
275
|
const program = this.program;
|
|
295
276
|
let parseRoot: CliNode;
|
|
296
|
-
let completionParseRoot: CliRouter = opts?.presentationFallback
|
|
297
|
-
? this.presentationRoot
|
|
298
|
-
: this.parseRootMerged;
|
|
277
|
+
let completionParseRoot: CliRouter = opts?.presentationFallback ? this.presentationRoot : this.parseRootMerged;
|
|
299
278
|
let isLeafCompletionIntercept = false;
|
|
300
279
|
|
|
301
280
|
if (isCliLeaf(program)) {
|
|
@@ -387,7 +366,7 @@ export class Cli {
|
|
|
387
366
|
const bootstrap = ensureAppConfig(this.program, opts);
|
|
388
367
|
const snapshot = bootstrap
|
|
389
368
|
? createAppConfigSnapshot(this.program, bootstrap.fileData, bootstrap.resolved)
|
|
390
|
-
: createAppConfigSnapshot(this.program,
|
|
369
|
+
: createAppConfigSnapshot(this.program, readAppConfigFileRaw(resolveAppConfigPath(this.program)), {});
|
|
391
370
|
this._appConfig = snapshot;
|
|
392
371
|
return snapshot;
|
|
393
372
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tests for config/bindings module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { CliAppConfigEntry } from "../types.ts";
|
|
7
|
+
import {
|
|
8
|
+
bindingForKey,
|
|
9
|
+
CONFIG_BINDINGS_KEY,
|
|
10
|
+
clearBinding,
|
|
11
|
+
clearFileValue,
|
|
12
|
+
isKeyAddressed,
|
|
13
|
+
readBindings,
|
|
14
|
+
setBinding,
|
|
15
|
+
} from "./bindings.ts";
|
|
16
|
+
|
|
17
|
+
const optionalEntry: CliAppConfigEntry = { description: "Optional.", required: false };
|
|
18
|
+
const requiredEntry: CliAppConfigEntry = { description: "Required.", env: "API_TOKEN" };
|
|
19
|
+
|
|
20
|
+
describe("config/bindings", () => {
|
|
21
|
+
test("readBindings returns empty when absent", () => {
|
|
22
|
+
expect(readBindings({})).toEqual({});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("setBinding and readBindings round-trip", () => {
|
|
26
|
+
const next = setBinding({}, "apiToken", "env");
|
|
27
|
+
expect(readBindings(next)).toEqual({ apiToken: "env" });
|
|
28
|
+
expect(next[CONFIG_BINDINGS_KEY]).toEqual({ apiToken: "env" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("clearBinding removes entry", () => {
|
|
32
|
+
const withBinding = setBinding({}, "apiToken", "env");
|
|
33
|
+
const cleared = clearBinding(withBinding, "apiToken");
|
|
34
|
+
expect(readBindings(cleared)).toEqual({});
|
|
35
|
+
expect(CONFIG_BINDINGS_KEY in cleared).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("clearFileValue removes literal", () => {
|
|
39
|
+
expect(clearFileValue({ apiToken: "x" }, "apiToken")).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("isKeyAddressed with binding env", () => {
|
|
43
|
+
const data = setBinding({}, "apiToken", "env");
|
|
44
|
+
expect(isKeyAddressed("apiToken", data, requiredEntry)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("isKeyAddressed with literal file value", () => {
|
|
48
|
+
expect(isKeyAddressed("apiToken", { apiToken: "tok" }, requiredEntry)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("isKeyAddressed false when unaddressed", () => {
|
|
52
|
+
expect(isKeyAddressed("apiToken", {}, requiredEntry)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("isKeyAddressed skip binding", () => {
|
|
56
|
+
const data = setBinding({}, "note", "skip");
|
|
57
|
+
expect(isKeyAddressed("note", data, optionalEntry)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("bindingForKey prefers explicit binding", () => {
|
|
61
|
+
const data = setBinding({}, "apiToken", "env");
|
|
62
|
+
expect(bindingForKey("apiToken", data, true)).toBe("env");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("bindingForKey infers file from literal", () => {
|
|
66
|
+
expect(bindingForKey("apiToken", { apiToken: "x" }, true)).toBe("file");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("bindingForKey missing when unset", () => {
|
|
70
|
+
expect(bindingForKey("apiToken", {}, false)).toBe("missing");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Per-key config binding metadata (`_bindings`) — records how each schema key is satisfied.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliAppConfigEntry } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/** Reserved top-level config file key for per-entry binding metadata. */
|
|
8
|
+
export const CONFIG_BINDINGS_KEY = "_bindings";
|
|
9
|
+
|
|
10
|
+
export type ConfigBinding = "env" | "file" | "skip";
|
|
11
|
+
|
|
12
|
+
const BINDING_VALUES = new Set<string>(["env", "file", "skip"]);
|
|
13
|
+
|
|
14
|
+
/** True for argsbarg-reserved top-level keys (e.g. `_bindings`). */
|
|
15
|
+
export function isFrameworkConfigKey(key: string): boolean {
|
|
16
|
+
return key.startsWith("_");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPresent(value: unknown): boolean {
|
|
20
|
+
if (value === undefined || value === null) return false;
|
|
21
|
+
if (typeof value === "string" && value.length === 0) return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read `_bindings` from raw file data; returns `{}` when absent or invalid. */
|
|
26
|
+
export function readBindings(fileData: Record<string, unknown>): Record<string, ConfigBinding> {
|
|
27
|
+
const raw = fileData[CONFIG_BINDINGS_KEY];
|
|
28
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const out: Record<string, ConfigBinding> = {};
|
|
32
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
33
|
+
if (typeof val === "string" && BINDING_VALUES.has(val)) {
|
|
34
|
+
out[key] = val as ConfigBinding;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Validate `_bindings` shape when present. */
|
|
41
|
+
export function validateBindingsShape(fileData: Record<string, unknown>, pathPrefix = "$"): string[] {
|
|
42
|
+
const raw = fileData[CONFIG_BINDINGS_KEY];
|
|
43
|
+
if (raw === undefined) return [];
|
|
44
|
+
const errors: string[] = [];
|
|
45
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
46
|
+
errors.push(`${pathPrefix}.${CONFIG_BINDINGS_KEY}: must be object`);
|
|
47
|
+
return errors;
|
|
48
|
+
}
|
|
49
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
50
|
+
if (typeof val !== "string" || !BINDING_VALUES.has(val)) {
|
|
51
|
+
errors.push(`${pathPrefix}.${CONFIG_BINDINGS_KEY}.${key}: must be "env", "file", or "skip"`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Return a new file object with an updated binding for `key`. */
|
|
58
|
+
export function setBinding(
|
|
59
|
+
fileData: Record<string, unknown>,
|
|
60
|
+
key: string,
|
|
61
|
+
binding: ConfigBinding,
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
const bindings = { ...readBindings(fileData), [key]: binding };
|
|
64
|
+
return { ...fileData, [CONFIG_BINDINGS_KEY]: bindings };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Return a new file object without a binding for `key`. */
|
|
68
|
+
export function clearBinding(fileData: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
69
|
+
const bindings = readBindings(fileData);
|
|
70
|
+
if (!(key in bindings)) {
|
|
71
|
+
return fileData;
|
|
72
|
+
}
|
|
73
|
+
const nextBindings = { ...bindings };
|
|
74
|
+
delete nextBindings[key];
|
|
75
|
+
const next = { ...fileData };
|
|
76
|
+
if (Object.keys(nextBindings).length === 0) {
|
|
77
|
+
delete next[CONFIG_BINDINGS_KEY];
|
|
78
|
+
} else {
|
|
79
|
+
next[CONFIG_BINDINGS_KEY] = nextBindings;
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove a literal value from file data (used when binding to env). */
|
|
85
|
+
export function clearFileValue(fileData: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
86
|
+
if (!(key in fileData)) {
|
|
87
|
+
return fileData;
|
|
88
|
+
}
|
|
89
|
+
const next = { ...fileData };
|
|
90
|
+
delete next[key];
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether the user already addressed this key (skip wizard re-prompt). */
|
|
95
|
+
export function isKeyAddressed(key: string, fileData: Record<string, unknown>, _entry: CliAppConfigEntry): boolean {
|
|
96
|
+
const bindings = readBindings(fileData);
|
|
97
|
+
if (bindings[key] === "skip" || bindings[key] === "env" || bindings[key] === "file") {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return isPresent(fileData[key]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Binding for status display; `missing` when unset. */
|
|
104
|
+
export function bindingForKey(
|
|
105
|
+
key: string,
|
|
106
|
+
fileData: Record<string, unknown>,
|
|
107
|
+
resolvedPresent: boolean,
|
|
108
|
+
): ConfigBinding | "missing" {
|
|
109
|
+
const bindings = readBindings(fileData);
|
|
110
|
+
const b = bindings[key];
|
|
111
|
+
if (b) return b;
|
|
112
|
+
if (isPresent(fileData[key])) return "file";
|
|
113
|
+
if (resolvedPresent) return "env";
|
|
114
|
+
return "missing";
|
|
115
|
+
}
|
package/src/config/bootstrap.ts
CHANGED
|
@@ -6,14 +6,10 @@ settings are present before the CLI or MCP server handles a request.
|
|
|
6
6
|
import { readSync } from "node:fs";
|
|
7
7
|
import { readPromptLine as readStdinLine } from "../prompt.ts";
|
|
8
8
|
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
9
|
+
import { bindingForKey, clearFileValue, isKeyAddressed, readBindings, setBinding } from "./bindings.ts";
|
|
10
|
+
import { configEntryRequired, configEntrySensitive, defaultConfigEntryTitle, jsonSchemaRequiredKeys } from "./entry.ts";
|
|
9
11
|
import {
|
|
10
|
-
|
|
11
|
-
configEntrySensitive,
|
|
12
|
-
defaultConfigEntryTitle,
|
|
13
|
-
jsonSchemaRequiredKeys,
|
|
14
|
-
} from "./entry.ts";
|
|
15
|
-
import {
|
|
16
|
-
appConfigInstalled,
|
|
12
|
+
appConfigFileExists,
|
|
17
13
|
displayAppConfigPath,
|
|
18
14
|
readAppConfigFile,
|
|
19
15
|
readAppConfigFileRaw,
|
|
@@ -53,13 +49,8 @@ export interface ConfigBootstrapResult {
|
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
/** Read the config file, merge env overrides, and export mapped values into `process.env`. */
|
|
56
|
-
export function bootstrapAppConfig(
|
|
57
|
-
program:
|
|
58
|
-
opts: { validateFile: boolean },
|
|
59
|
-
): ConfigBootstrapResult {
|
|
60
|
-
const fileData = opts.validateFile
|
|
61
|
-
? readAppConfigFile(program)
|
|
62
|
-
: readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
52
|
+
export function bootstrapAppConfig(program: CliProgram, opts: { validateFile: boolean }): ConfigBootstrapResult {
|
|
53
|
+
const fileData = opts.validateFile ? readAppConfigFile(program) : readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
63
54
|
const hostEnv = captureMappedHostEnv(program);
|
|
64
55
|
const resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
65
56
|
exportConfigToEnv(program, resolved, hostEnv);
|
|
@@ -88,7 +79,6 @@ function readSensitiveLine(): string {
|
|
|
88
79
|
}
|
|
89
80
|
const byte = buf[0];
|
|
90
81
|
if (byte === 3) {
|
|
91
|
-
// Raw mode delivers Ctrl+C as ETX instead of SIGINT.
|
|
92
82
|
process.stderr.write("\n");
|
|
93
83
|
process.exit(130);
|
|
94
84
|
}
|
|
@@ -130,10 +120,7 @@ function readPromptLine(mask: boolean): string {
|
|
|
130
120
|
}
|
|
131
121
|
|
|
132
122
|
/** Whether this setting already has a non-empty value in the user's shell environment. */
|
|
133
|
-
function resolvedFromEnv(
|
|
134
|
-
entry: CliAppConfigEntry,
|
|
135
|
-
hostEnv: Record<string, string | undefined>,
|
|
136
|
-
): boolean {
|
|
123
|
+
function resolvedFromEnv(entry: CliAppConfigEntry, hostEnv: Record<string, string | undefined>): boolean {
|
|
137
124
|
if (!entry.env) {
|
|
138
125
|
return false;
|
|
139
126
|
}
|
|
@@ -165,7 +152,7 @@ function promptConfigKey(
|
|
|
165
152
|
if (hasCurrent) {
|
|
166
153
|
process.stderr.write(` Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
|
|
167
154
|
const acceptPrompt = resolvedFromEnv(entry, hostEnv)
|
|
168
|
-
? ` Value (Enter to
|
|
155
|
+
? ` Value (Enter to use env)${valueHintSuffix}: `
|
|
169
156
|
: ` Value (Enter to keep)${valueHintSuffix}: `;
|
|
170
157
|
process.stderr.write(acceptPrompt);
|
|
171
158
|
} else {
|
|
@@ -202,6 +189,16 @@ function shouldShowConfigureSetupHeading(program: CliProgram): boolean {
|
|
|
202
189
|
return Object.keys(program.appConfig.entries).length > 0;
|
|
203
190
|
}
|
|
204
191
|
|
|
192
|
+
function isPresent(value: unknown): boolean {
|
|
193
|
+
if (value === undefined || value === null) return false;
|
|
194
|
+
if (typeof value === "string" && value.length === 0) return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function bindingsDiffer(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
|
199
|
+
return JSON.stringify(readBindings(a)) !== JSON.stringify(readBindings(b));
|
|
200
|
+
}
|
|
201
|
+
|
|
205
202
|
/** Ask the user for each required setting that is still empty; returns updates to save to the config file. */
|
|
206
203
|
function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
207
204
|
const appConfig = program.appConfig;
|
|
@@ -212,31 +209,29 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
|
212
209
|
const jsonSchema = effectiveJsonSchema(program);
|
|
213
210
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
214
211
|
const hostEnv = captureMappedHostEnv(program);
|
|
215
|
-
const
|
|
212
|
+
const path = resolveAppConfigPath(program);
|
|
213
|
+
const fileData = readAppConfigFileRaw(path);
|
|
214
|
+
const resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
216
215
|
let headingWritten = false;
|
|
217
216
|
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
218
217
|
if (!configEntryRequired(key, entry, fromSchema)) {
|
|
219
218
|
continue;
|
|
220
219
|
}
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
220
|
+
const bindings = readBindings(fileData);
|
|
221
|
+
if (bindings[key] === "env" && !isPresent(resolved[key])) {
|
|
222
|
+
// Re-prompt when env binding is broken.
|
|
223
|
+
} else if (isKeyAddressed(key, fileData, entry) && isPresent(resolved[key])) {
|
|
223
224
|
continue;
|
|
224
225
|
}
|
|
226
|
+
const current = resolved[key];
|
|
225
227
|
if (!headingWritten) {
|
|
226
228
|
writeConfigureSetupHeading();
|
|
227
229
|
headingWritten = true;
|
|
228
230
|
}
|
|
229
|
-
const { value, userTyped } = promptConfigKey(
|
|
230
|
-
|
|
231
|
-
entry,
|
|
232
|
-
undefined,
|
|
233
|
-
false,
|
|
234
|
-
fromSchema,
|
|
235
|
-
hostEnv,
|
|
236
|
-
jsonSchema,
|
|
237
|
-
);
|
|
238
|
-
if (value !== undefined && String(value).length > 0) {
|
|
231
|
+
const { value, userTyped } = promptConfigKey(key, entry, current, false, fromSchema, hostEnv, jsonSchema);
|
|
232
|
+
if (userTyped && value !== undefined && String(value).length > 0) {
|
|
239
233
|
updates[key] = value;
|
|
234
|
+
Object.assign(updates, setBinding(updates, key, "file"));
|
|
240
235
|
}
|
|
241
236
|
}
|
|
242
237
|
return updates;
|
|
@@ -267,7 +262,7 @@ export function runConfigure(
|
|
|
267
262
|
const jsonSchema = effectiveJsonSchema(program);
|
|
268
263
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
269
264
|
const resolved = resolveAppConfig(program, existing, hostEnv);
|
|
270
|
-
|
|
265
|
+
let next: Record<string, unknown> = { ...existing };
|
|
271
266
|
let changed = false;
|
|
272
267
|
|
|
273
268
|
if (opts.showHeading !== false && shouldShowConfigureSetupHeading(program)) {
|
|
@@ -275,35 +270,51 @@ export function runConfigure(
|
|
|
275
270
|
}
|
|
276
271
|
|
|
277
272
|
for (const [key, entry] of Object.entries(program.appConfig.entries)) {
|
|
273
|
+
const bindings = readBindings(existing);
|
|
274
|
+
if (bindings[key] === "env" && !isPresent(resolved[key])) {
|
|
275
|
+
// Re-prompt when env binding is broken.
|
|
276
|
+
} else if (isKeyAddressed(key, existing, entry)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
278
280
|
const before = next[key];
|
|
281
|
+
const bindingsBefore = readBindings(next);
|
|
279
282
|
const current = resolved[key];
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
283
|
+
const required = configEntryRequired(key, entry, fromSchema);
|
|
284
|
+
const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv, jsonSchema);
|
|
285
|
+
|
|
286
|
+
if (userTyped && value !== undefined && String(value).length > 0) {
|
|
287
|
+
if (JSON.stringify(value) !== JSON.stringify(before) || bindingsBefore[key] !== "file") {
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
next = setBinding({ ...next, [key]: value }, key, "file");
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!userTyped && isPresent(current) && resolvedFromEnv(entry, hostEnv)) {
|
|
290
295
|
const storedInFile =
|
|
291
|
-
key in existing &&
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
key in existing && existing[key] !== undefined && existing[key] !== null && String(existing[key]).length > 0;
|
|
297
|
+
if (!storedInFile) {
|
|
298
|
+
const withBinding = setBinding(clearFileValue(next, key), key, "env");
|
|
299
|
+
if (bindingsDiffer(next, withBinding) || key in next) {
|
|
300
|
+
changed = true;
|
|
301
|
+
}
|
|
302
|
+
next = withBinding;
|
|
297
303
|
}
|
|
298
|
-
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!userTyped && !required && !isPresent(current) && inputWasSkipped(value, userTyped)) {
|
|
308
|
+
const withBinding = setBinding(next, key, "skip");
|
|
309
|
+
if (bindingsDiffer(next, withBinding)) {
|
|
299
310
|
changed = true;
|
|
300
311
|
}
|
|
301
|
-
next
|
|
312
|
+
next = withBinding;
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
315
|
|
|
305
316
|
if (changed) {
|
|
306
|
-
writeAppConfigFile(program, next);
|
|
317
|
+
writeAppConfigFile(program, next, { partial: true });
|
|
307
318
|
const updated = resolveAppConfig(program, next, hostEnv);
|
|
308
319
|
exportConfigToEnv(program, updated, hostEnv);
|
|
309
320
|
return { path, changed: true };
|
|
@@ -311,12 +322,16 @@ export function runConfigure(
|
|
|
311
322
|
return { path, changed: false };
|
|
312
323
|
}
|
|
313
324
|
|
|
325
|
+
function inputWasSkipped(value: unknown, userTyped: boolean): boolean {
|
|
326
|
+
return !userTyped && (value === undefined || String(value).length === 0);
|
|
327
|
+
}
|
|
328
|
+
|
|
314
329
|
/** Summary for `configure --status`: config path, whether the file exists, and which required settings are set (never their values). */
|
|
315
330
|
export function appConfigStatus(program: CliProgram):
|
|
316
331
|
| {
|
|
317
332
|
path: string;
|
|
318
333
|
exists: boolean;
|
|
319
|
-
required: Array<{ key: string; set: boolean }>;
|
|
334
|
+
required: Array<{ key: string; set: boolean; binding?: "env" | "file" | "skip" | "missing" }>;
|
|
320
335
|
}
|
|
321
336
|
| undefined {
|
|
322
337
|
if (!program.appConfig) {
|
|
@@ -336,19 +351,19 @@ export function appConfigStatus(program: CliProgram):
|
|
|
336
351
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
337
352
|
const required = Object.entries(program.appConfig.entries)
|
|
338
353
|
.filter(([key, entry]) => configEntryRequired(key, entry, fromSchema))
|
|
339
|
-
.map(([key]) =>
|
|
340
|
-
key
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
354
|
+
.map(([key]) => {
|
|
355
|
+
const set = resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0;
|
|
356
|
+
return {
|
|
357
|
+
key,
|
|
358
|
+
set,
|
|
359
|
+
binding: bindingForKey(key, fileData, set),
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
return { path, exists: appConfigFileExists(program), required };
|
|
345
363
|
}
|
|
346
364
|
|
|
347
365
|
/** Load config at startup, optionally prompt the user, and fail if required settings are still missing. */
|
|
348
|
-
export function ensureAppConfig(
|
|
349
|
-
program: CliProgram,
|
|
350
|
-
opts: EnsureAppConfigOpts,
|
|
351
|
-
): ConfigBootstrapResult | undefined {
|
|
366
|
+
export function ensureAppConfig(program: CliProgram, opts: EnsureAppConfigOpts): ConfigBootstrapResult | undefined {
|
|
352
367
|
if (!program.appConfig) {
|
|
353
368
|
return undefined;
|
|
354
369
|
}
|
|
@@ -377,7 +392,7 @@ export function ensureAppConfig(
|
|
|
377
392
|
const updates = promptMissingRequired(program);
|
|
378
393
|
if (Object.keys(updates).length > 0) {
|
|
379
394
|
const merged = { ...fileData, ...updates };
|
|
380
|
-
writeAppConfigFile(program, merged);
|
|
395
|
+
writeAppConfigFile(program, merged, { partial: true });
|
|
381
396
|
fileData = merged;
|
|
382
397
|
resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
383
398
|
exportConfigToEnv(program, resolved, hostEnv);
|