argsbarg 4.1.0 → 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 +48 -1
- package/README.md +90 -84
- package/docs/README.md +6 -6
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +5 -3
- package/docs/config-schema.md +36 -9
- package/docs/developing.md +7 -7
- package/docs/distribution-homebrew.md +103 -0
- package/docs/install.md +105 -189
- package/docs/mcp.md +2 -3
- 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/nested.ts +1 -3
- package/index.d.ts +27 -66
- package/package.json +2 -2
- package/src/builtins/builtins.test.ts +22 -23
- package/src/builtins/completion-group.ts +17 -15
- package/src/builtins/dispatch.ts +25 -1
- package/src/builtins/install.ts +22 -52
- package/src/builtins/registry.ts +2 -0
- package/src/builtins/uninstall.ts +80 -0
- package/src/capabilities.ts +1 -3
- 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/cli.ts +0 -2
- package/src/config/bootstrap.ts +16 -9
- package/src/config/resolve.test.ts +167 -0
- package/src/config/resolve.ts +50 -6
- 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/index.ts +3 -10
- package/src/install/binary-placement.test.ts +101 -0
- package/src/install/binary-placement.ts +47 -0
- package/src/install/index.ts +117 -123
- package/src/install/install.test.ts +89 -105
- package/src/install/normalize-uninstall.ts +11 -0
- package/src/install/normalize.ts +4 -19
- package/src/install/paths.ts +0 -22
- package/src/install/plan.ts +14 -6
- package/src/install/shell.ts +0 -14
- package/src/install/status.test.ts +6 -6
- package/src/install/status.ts +0 -6
- package/src/install/target-effective.ts +0 -2
- package/src/install/target-scope.ts +15 -28
- package/src/install/target-types.ts +0 -16
- package/src/install/targets/app.ts +19 -28
- package/src/install/targets/configure.ts +5 -1
- package/src/install/targets/index.ts +0 -3
- package/src/install/targets.test.ts +24 -42
- package/src/mcp/env.test.ts +92 -0
- package/src/mcp/env.ts +15 -14
- package/src/parse.test.ts +3 -2
- package/src/prompt.ts +10 -0
- package/src/schema.ts +9 -1
- package/src/types.ts +27 -9
- package/src/validate.ts +5 -11
- 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 -78
- package/examples/config-app/schema.ts +0 -37
- package/examples/config-app/types.ts +0 -19
- package/examples/consumer-app/README.md +0 -56
- package/examples/consumer-app/src/main.ts +0 -15
- package/examples/consumer-app/src/program.ts +0 -108
- package/src/install/app.ts +0 -94
- package/src/install/bootstrap.ts +0 -22
- package/src/install/completions.ts +0 -56
- package/src/install/targets/completions.ts +0 -133
- package/src/install/update.test.ts +0 -123
- package/src/install/update.ts +0 -54
- /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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** `argsbarg create` command orchestration. */
|
|
2
|
+
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
applyCreate,
|
|
7
|
+
type CreateOptions,
|
|
8
|
+
classNameFromKey,
|
|
9
|
+
diffCreateDetails,
|
|
10
|
+
parseCreateArgv,
|
|
11
|
+
printCreateDiffs,
|
|
12
|
+
renderCreateTree,
|
|
13
|
+
resolveCreateOptions,
|
|
14
|
+
} from "./create.ts";
|
|
15
|
+
import { printPostCreatePlan, runPostCreate } from "./post-create.ts";
|
|
16
|
+
import { promptConfirm, promptOptional, promptRequired } from "./prompt.ts";
|
|
17
|
+
|
|
18
|
+
function isInteractiveTty(): boolean {
|
|
19
|
+
return Boolean(process.stdin.isTTY);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectInteractiveOptions(
|
|
23
|
+
partial: Partial<CreateOptions>,
|
|
24
|
+
dir: string,
|
|
25
|
+
): { opts: CreateOptions; baseDir: string } {
|
|
26
|
+
process.stderr.write("Argsbarg create — bootstrap a new CLI from full-example\n\n");
|
|
27
|
+
const targetDir = promptOptional("Target directory", dir) ?? dir;
|
|
28
|
+
const baseDir = resolve(process.cwd(), targetDir);
|
|
29
|
+
const key = promptRequired("CLI key (binary name)", partial.key);
|
|
30
|
+
const className =
|
|
31
|
+
promptOptional("Formula class name", partial.className ?? classNameFromKey(key)) ??
|
|
32
|
+
classNameFromKey(key);
|
|
33
|
+
const releaseRepo =
|
|
34
|
+
promptOptional("GitHub release repo (org/repo)", partial.releaseRepo ?? `example/${key}`) ??
|
|
35
|
+
`example/${key}`;
|
|
36
|
+
const homepage =
|
|
37
|
+
promptOptional("Homepage URL", partial.homepage ?? `https://github.com/${releaseRepo}`) ??
|
|
38
|
+
`https://github.com/${releaseRepo}`;
|
|
39
|
+
const tap =
|
|
40
|
+
promptOptional("Homebrew tap (org/repo)", partial.tap ?? `local/${key}`) ?? `local/${key}`;
|
|
41
|
+
const desc =
|
|
42
|
+
promptOptional("Formula description", partial.desc ?? `${className} CLI`) ?? `${className} CLI`;
|
|
43
|
+
|
|
44
|
+
const opts = resolveCreateOptions(
|
|
45
|
+
{
|
|
46
|
+
...partial,
|
|
47
|
+
key,
|
|
48
|
+
className,
|
|
49
|
+
tap,
|
|
50
|
+
homepage,
|
|
51
|
+
releaseRepo,
|
|
52
|
+
desc,
|
|
53
|
+
force: partial.force ?? false,
|
|
54
|
+
},
|
|
55
|
+
baseDir,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
process.stderr.write(`\nTarget: ${baseDir}\n`);
|
|
59
|
+
process.stderr.write(`Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap}\n\n`);
|
|
60
|
+
const tree = renderCreateTree(opts);
|
|
61
|
+
process.stderr.write(`Files (${tree.size}):\n`);
|
|
62
|
+
for (const rel of [...tree.keys()].sort()) {
|
|
63
|
+
process.stderr.write(` ${rel}\n`);
|
|
64
|
+
}
|
|
65
|
+
process.stderr.write("\n");
|
|
66
|
+
printPostCreatePlan();
|
|
67
|
+
process.stderr.write("\n");
|
|
68
|
+
|
|
69
|
+
if (!promptConfirm("Proceed")) {
|
|
70
|
+
throw new Error("Aborted.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { opts, baseDir };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runCreate(input: Partial<CreateOptions> & { dir?: string }): Promise<number> {
|
|
77
|
+
try {
|
|
78
|
+
const baseDir = resolve(process.cwd(), input.dir ?? ".");
|
|
79
|
+
const partial = { ...input };
|
|
80
|
+
delete (partial as { dir?: string }).dir;
|
|
81
|
+
|
|
82
|
+
if (partial.check || partial.diff) {
|
|
83
|
+
const drifts = diffCreateDetails(baseDir, partial);
|
|
84
|
+
if (drifts.length > 0) {
|
|
85
|
+
process.stderr.write(`Create drift in ${baseDir}:\n`);
|
|
86
|
+
for (const d of drifts) process.stderr.write(` ${d.rel}\n`);
|
|
87
|
+
if (partial.diff) printCreateDiffs(drifts, baseDir);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(`Create OK: ${baseDir}\n`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let opts: CreateOptions;
|
|
95
|
+
if (!partial.yes && isInteractiveTty()) {
|
|
96
|
+
const collected = collectInteractiveOptions(partial, input.dir ?? ".");
|
|
97
|
+
opts = collected.opts;
|
|
98
|
+
return runCreateApply(collected.baseDir, opts, partial.dryRun ?? false);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!partial.yes && !isInteractiveTty()) {
|
|
102
|
+
throw new Error("Refusing to proceed without --yes (stdin is not a TTY).");
|
|
103
|
+
}
|
|
104
|
+
if (!partial.key) {
|
|
105
|
+
throw new Error("--key is required in non-interactive mode.");
|
|
106
|
+
}
|
|
107
|
+
opts = resolveCreateOptions(partial, baseDir);
|
|
108
|
+
return runCreateApply(baseDir, opts, partial.dryRun ?? false);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runCreateApply(
|
|
116
|
+
baseDir: string,
|
|
117
|
+
opts: CreateOptions,
|
|
118
|
+
dryRun: boolean,
|
|
119
|
+
): Promise<number> {
|
|
120
|
+
if (dryRun) {
|
|
121
|
+
const written = applyCreate(baseDir, { ...opts, dryRun: true, check: false });
|
|
122
|
+
process.stdout.write(`Would write ${written.length} file(s) under ${baseDir}\n`);
|
|
123
|
+
for (const w of written) process.stdout.write(` ${relative(baseDir, w) || w}\n`);
|
|
124
|
+
printPostCreatePlan();
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
mkdirSync(baseDir, { recursive: true });
|
|
129
|
+
const written = applyCreate(baseDir, {
|
|
130
|
+
...opts,
|
|
131
|
+
dryRun: false,
|
|
132
|
+
check: false,
|
|
133
|
+
force: opts.force,
|
|
134
|
+
});
|
|
135
|
+
process.stdout.write(`Created ${written.length} file(s) under ${baseDir}\n`);
|
|
136
|
+
for (const w of written) {
|
|
137
|
+
process.stdout.write(` ${relative(baseDir, w) || w}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await runPostCreate(baseDir, false);
|
|
141
|
+
process.stdout.write("Done.\n");
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parse argv and run create (for tests and direct script invocation). */
|
|
146
|
+
export async function runCreateCommand(rest: string[]): Promise<number> {
|
|
147
|
+
const { dir, opts } = parseCreateArgv(rest);
|
|
148
|
+
return runCreate({ ...opts, dir });
|
|
149
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { type AnyAppConfigSnapshot, createAppConfigSnapshot } from "./config/con
|
|
|
20
20
|
import { effectiveJsonSchema } from "./config/schema.ts";
|
|
21
21
|
import { CliContext } from "./context.ts";
|
|
22
22
|
import { cliHelpRender } from "./help.ts";
|
|
23
|
-
import { maybeBootstrapInstallArgv } from "./install/bootstrap.ts";
|
|
24
23
|
import { bootstrapMcpEnv } from "./mcp/env.ts";
|
|
25
24
|
import { mcpServeStdioLoop } from "./mcp/server.ts";
|
|
26
25
|
import { ParseKind, type ParseResult, parse, postParseValidate } from "./parse.ts";
|
|
@@ -95,7 +94,6 @@ export class Cli {
|
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
async run(argv: string[] = process.argv.slice(2)): Promise<never> {
|
|
98
|
-
argv = maybeBootstrapInstallArgv(argv, this.program);
|
|
99
97
|
assertBuiltinAllowed(argv, this.caps);
|
|
100
98
|
|
|
101
99
|
const prep = this.prepareDispatch(argv);
|
package/src/config/bootstrap.ts
CHANGED
|
@@ -4,6 +4,7 @@ settings are present before the CLI or MCP server handles a request.
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readSync } from "node:fs";
|
|
7
|
+
import { readPromptLine as readStdinLine } from "../prompt.ts";
|
|
7
8
|
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
8
9
|
import {
|
|
9
10
|
configEntryRequired,
|
|
@@ -124,9 +125,7 @@ function readPromptLine(mask: boolean): string {
|
|
|
124
125
|
if (mask) {
|
|
125
126
|
return readSensitiveLine();
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
-
const n = readSync(0, buf, { length: 4096 });
|
|
129
|
-
return buf.toString("utf8", 0, n).replace(/\r?\n$/, "");
|
|
128
|
+
return readStdinLine();
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
/** Whether this setting already has a non-empty value in the user's shell environment. */
|
|
@@ -149,7 +148,7 @@ function promptConfigKey(
|
|
|
149
148
|
configure: boolean,
|
|
150
149
|
jsonSchemaRequired: Set<string> | undefined,
|
|
151
150
|
hostEnv: Record<string, string | undefined>,
|
|
152
|
-
): { value: unknown } {
|
|
151
|
+
): { value: unknown; userTyped: boolean } {
|
|
153
152
|
const baseTitle = entry.title ?? defaultConfigEntryTitle(key);
|
|
154
153
|
const titleWithEnv = entry.env ? `${baseTitle} (${entry.env})` : baseTitle;
|
|
155
154
|
const required = configEntryRequired(key, entry, jsonSchemaRequired);
|
|
@@ -169,12 +168,12 @@ function promptConfigKey(
|
|
|
169
168
|
}
|
|
170
169
|
const input = readPromptLine(sensitive);
|
|
171
170
|
if (input.length === 0 && hasCurrent) {
|
|
172
|
-
return { value: current };
|
|
171
|
+
return { value: current, userTyped: false };
|
|
173
172
|
}
|
|
174
173
|
if (input.length > 0) {
|
|
175
|
-
return { value: input };
|
|
174
|
+
return { value: input, userTyped: true };
|
|
176
175
|
}
|
|
177
|
-
return { value: undefined };
|
|
176
|
+
return { value: undefined, userTyped: false };
|
|
178
177
|
}
|
|
179
178
|
|
|
180
179
|
/** Options for the interactive `install --configure` wizard. */
|
|
@@ -218,7 +217,7 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
|
218
217
|
writeConfigureSetupHeading();
|
|
219
218
|
headingWritten = true;
|
|
220
219
|
}
|
|
221
|
-
const { value } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
|
|
220
|
+
const { value, userTyped } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
|
|
222
221
|
if (value !== undefined && String(value).length > 0) {
|
|
223
222
|
updates[key] = value;
|
|
224
223
|
}
|
|
@@ -261,8 +260,16 @@ export function runInstallConfigure(
|
|
|
261
260
|
for (const [key, entry] of Object.entries(program.appConfig.entries)) {
|
|
262
261
|
const before = next[key];
|
|
263
262
|
const current = resolved[key];
|
|
264
|
-
const { value } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
|
|
263
|
+
const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
|
|
265
264
|
if (value !== undefined && String(value).length > 0) {
|
|
265
|
+
const storedInFile =
|
|
266
|
+
key in existing &&
|
|
267
|
+
existing[key] !== undefined &&
|
|
268
|
+
existing[key] !== null &&
|
|
269
|
+
String(existing[key]).length > 0;
|
|
270
|
+
if (!userTyped && !storedInFile) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
266
273
|
if (JSON.stringify(value) !== JSON.stringify(before)) {
|
|
267
274
|
changed = true;
|
|
268
275
|
}
|
|
@@ -109,4 +109,171 @@ describe("config/resolve", () => {
|
|
|
109
109
|
else process.env.API_TOKEN = prev;
|
|
110
110
|
}
|
|
111
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
|
+
});
|
|
112
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";
|
|
@@ -93,6 +93,38 @@ export function resolveAppConfig(
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
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
|
+
|
|
96
128
|
function resolveConfigKey(
|
|
97
129
|
program: CliProgram,
|
|
98
130
|
key: string,
|
|
@@ -100,15 +132,27 @@ function resolveConfigKey(
|
|
|
100
132
|
fileData: AppConfigFileData,
|
|
101
133
|
hostEnv?: Record<string, string | undefined>,
|
|
102
134
|
): unknown {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return coerceEnvValue(program, key, fromEnv);
|
|
107
|
-
}
|
|
135
|
+
const fromEnv = tryEnvOverride(program, key, entry, hostEnv);
|
|
136
|
+
if (fromEnv !== undefined) {
|
|
137
|
+
return fromEnv;
|
|
108
138
|
}
|
|
109
139
|
if (key in fileData && isPresent(fileData[key])) {
|
|
110
140
|
return fileData[key];
|
|
111
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
|
+
}
|
|
112
156
|
const def = schemaDefaultForKey(program, key);
|
|
113
157
|
if (def !== undefined) {
|
|
114
158
|
return def;
|
|
@@ -71,21 +71,20 @@ test("generateApiGuide resolves program key in install notes", () => {
|
|
|
71
71
|
};
|
|
72
72
|
const md = generateApiGuide(fixture);
|
|
73
73
|
expect(md).not.toContain("{argsbarg:program}");
|
|
74
|
-
expect(md).toContain("
|
|
74
|
+
expect(md).toContain("brew install");
|
|
75
75
|
expect(md).not.toContain("Upgrade to latest release");
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
test("generateApiGuide
|
|
78
|
+
test("generateApiGuide mentions Homebrew upgrade", () => {
|
|
79
79
|
const fixture: CliProgram = {
|
|
80
80
|
key: "myapp",
|
|
81
81
|
version: "1.0.0",
|
|
82
82
|
description: "Demo app.",
|
|
83
|
-
install: { updateGetLatest: async () => ({ path: process.execPath }) },
|
|
84
83
|
commands: [{ key: "run", description: "Run.", handler: () => {} }],
|
|
85
84
|
};
|
|
86
85
|
const md = generateApiGuide(fixture);
|
|
87
|
-
expect(md).toContain("
|
|
88
|
-
expect(md).toContain("
|
|
86
|
+
expect(md).toContain("brew upgrade");
|
|
87
|
+
expect(md).not.toContain("install --update");
|
|
89
88
|
});
|
|
90
89
|
|
|
91
90
|
test("generateApiGuide resolves {argsbarg:program} in consumer notes", () => {
|
package/src/docs/docs.test.ts
CHANGED
|
@@ -217,7 +217,8 @@ test("generateMcpGuide includes schema URI and install targets", () => {
|
|
|
217
217
|
expect(guide).toContain("claude_desktop_config.json");
|
|
218
218
|
expect(guide).toContain("## Installation");
|
|
219
219
|
expect(guide).toContain("## Running directly");
|
|
220
|
-
expect(guide).toContain("install --
|
|
220
|
+
expect(guide).toContain("install --mcp");
|
|
221
|
+
expect(guide).toContain("brew install myapp");
|
|
221
222
|
expect(guide).toContain("OpenAI Codex");
|
|
222
223
|
expect(guide).toContain("ChatGPT");
|
|
223
224
|
});
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
111
111
|
|
|
112
112
|
if (caps.install) {
|
|
113
113
|
lines.push(
|
|
114
|
-
`Install the CLI first so \`${root.key}\` is on your PATH (e.g.
|
|
114
|
+
`Install the CLI first so \`${root.key}\` is on your PATH (e.g. \`brew install ${root.key}\`). Host configs reference the app by name.`,
|
|
115
115
|
"",
|
|
116
116
|
);
|
|
117
117
|
} else {
|
|
@@ -167,13 +167,12 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
167
167
|
"",
|
|
168
168
|
);
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
170
|
+
lines.push(
|
|
171
|
+
"## Environment",
|
|
172
|
+
"",
|
|
173
|
+
"- **`shellEnv`** — on by default; captures login-shell environment at MCP startup (PATH, toolchain shims, exports). Opt out with `shellEnv: false`.",
|
|
174
|
+
"",
|
|
175
|
+
);
|
|
177
176
|
|
|
178
177
|
if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
|
|
179
178
|
lines.push("## Configuration", "");
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ module layout.
|
|
|
9
9
|
|
|
10
10
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "./cli.ts";
|
|
11
11
|
export { cliErrWithHelp } from "./cli-errors.ts";
|
|
12
|
+
export { displayAppConfigPath, resolveAppConfigPath } from "./config/file.ts";
|
|
12
13
|
export type { CliLeafInputs } from "./context.ts";
|
|
13
14
|
export { CliContext } from "./context.ts";
|
|
14
15
|
export {
|
|
@@ -26,19 +27,13 @@ export {
|
|
|
26
27
|
shouldRunHeadlessWithYes,
|
|
27
28
|
wantsExplicitJson,
|
|
28
29
|
} from "./headless.ts";
|
|
29
|
-
export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
|
|
30
|
-
export {
|
|
31
|
-
createGhFetchLatest,
|
|
32
|
-
createGhVersionCheck,
|
|
33
|
-
ghReleaseUpdateGetLatest,
|
|
34
|
-
isAlreadyCurrent,
|
|
35
|
-
parseReleaseTag,
|
|
36
|
-
} from "./install/gh-release-update.ts";
|
|
37
30
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
38
31
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
39
32
|
export type {
|
|
40
33
|
CliAppConfig,
|
|
41
34
|
CliAppConfigEntry,
|
|
35
|
+
CliAppConfigResolveContext,
|
|
36
|
+
CliAppConfigResolveFn,
|
|
42
37
|
CliDocsConfig,
|
|
43
38
|
CliDocsTopic,
|
|
44
39
|
CliHandler,
|
|
@@ -52,8 +47,6 @@ export type {
|
|
|
52
47
|
CliOption,
|
|
53
48
|
CliPositional,
|
|
54
49
|
CliProgram,
|
|
55
|
-
CliUpdateArtifact,
|
|
56
|
-
CliUpdateGetLatest,
|
|
57
50
|
InstallAgentIntegration,
|
|
58
51
|
InstallTargetSpec,
|
|
59
52
|
ResolvedInstallTarget,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, 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 {
|
|
7
|
+
isAppInstalled,
|
|
8
|
+
isExternallyManagedBinary,
|
|
9
|
+
resolvePathCommand,
|
|
10
|
+
} from "./binary-placement.ts";
|
|
11
|
+
|
|
12
|
+
const program: CliProgram = {
|
|
13
|
+
key: "placementapp",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
description: "x",
|
|
16
|
+
handler: () => {},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let tmp: string;
|
|
20
|
+
let prevPath: string | undefined;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmp = mkdtempSync(join(tmpdir(), "argsbarg-placement-"));
|
|
24
|
+
prevPath = process.env.PATH;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (prevPath === undefined) delete process.env.PATH;
|
|
29
|
+
else process.env.PATH = prevPath;
|
|
30
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isExternallyManagedBinary", () => {
|
|
34
|
+
test("false when command is not on PATH", () => {
|
|
35
|
+
process.env.PATH = tmp;
|
|
36
|
+
expect(isExternallyManagedBinary("placementapp-not-on-path")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("true when PATH resolves to execPath", () => {
|
|
40
|
+
const bin = join(tmp, "placementapp");
|
|
41
|
+
writeFileSync(bin, "#!/bin/sh\n", "utf8");
|
|
42
|
+
chmodSync(bin, 0o755);
|
|
43
|
+
process.env.PATH = tmp;
|
|
44
|
+
expect(isExternallyManagedBinary("placementapp", bin)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("true when PATH entry is a symlink to execPath", () => {
|
|
48
|
+
const bin = join(tmp, "placementapp");
|
|
49
|
+
const target = join(tmp, "real-binary");
|
|
50
|
+
writeFileSync(target, "fake", "utf8");
|
|
51
|
+
symlinkSync(target, bin);
|
|
52
|
+
process.env.PATH = tmp;
|
|
53
|
+
expect(isExternallyManagedBinary("placementapp", target)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("false when PATH points at a different binary", () => {
|
|
57
|
+
const bin = join(tmp, "placementapp");
|
|
58
|
+
const other = join(tmp, "other");
|
|
59
|
+
writeFileSync(bin, "a", "utf8");
|
|
60
|
+
writeFileSync(other, "b", "utf8");
|
|
61
|
+
process.env.PATH = tmp;
|
|
62
|
+
expect(isExternallyManagedBinary("placementapp", other)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isAppInstalled", () => {
|
|
67
|
+
test("true when externally managed", () => {
|
|
68
|
+
const bin = join(tmp, program.key);
|
|
69
|
+
const prevExec = process.execPath;
|
|
70
|
+
writeFileSync(bin, "x", "utf8");
|
|
71
|
+
chmodSync(bin, 0o755);
|
|
72
|
+
process.env.PATH = tmp;
|
|
73
|
+
process.execPath = bin;
|
|
74
|
+
try {
|
|
75
|
+
expect(isAppInstalled(program)).toBe(true);
|
|
76
|
+
} finally {
|
|
77
|
+
process.execPath = prevExec;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("false when not on PATH and no local copy", () => {
|
|
82
|
+
const home = mkdtempSync(join(tmpdir(), "argsbarg-placement-home-"));
|
|
83
|
+
const prevHome = process.env.HOME;
|
|
84
|
+
process.env.HOME = home;
|
|
85
|
+
process.env.PATH = tmp;
|
|
86
|
+
try {
|
|
87
|
+
expect(isAppInstalled(program)).toBe(false);
|
|
88
|
+
} finally {
|
|
89
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
90
|
+
else process.env.HOME = prevHome;
|
|
91
|
+
rmSync(home, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("resolvePathCommand", () => {
|
|
97
|
+
test("returns undefined when missing", () => {
|
|
98
|
+
process.env.PATH = tmp;
|
|
99
|
+
expect(resolvePathCommand("missing-cmd")).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|