argsbarg 3.6.4 → 4.0.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 +32 -2
- package/README.md +21 -9
- package/docs/README.md +12 -8
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +62 -2
- package/docs/config-schema.md +192 -0
- package/docs/developing.md +13 -0
- package/docs/install.md +38 -1
- package/docs/mcp.md +43 -19
- package/docs/output-schema.md +74 -52
- package/docs/templates/cursor/rules/cli-program.mdc +10 -5
- package/examples/config-app/main.ts +20 -0
- package/examples/config-app/program.ts +81 -0
- package/examples/config-app/schema.ts +37 -0
- package/examples/config-app/types.ts +19 -0
- package/examples/consumer-app/README.md +56 -0
- package/examples/consumer-app/bun.lock +75 -0
- package/examples/consumer-app/capabilities.test.ts +69 -0
- package/examples/consumer-app/package.json +17 -0
- package/examples/consumer-app/schemas/configSchemas.ts +6 -0
- package/examples/consumer-app/schemas/generated/app-config.json +40 -0
- package/examples/consumer-app/schemas/generated/status.json +28 -0
- package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
- package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
- package/examples/consumer-app/scripts/schemagen.ts +76 -0
- package/examples/consumer-app/src/commands/status/types.ts +11 -0
- package/examples/consumer-app/src/main.ts +15 -0
- package/examples/consumer-app/src/program.ts +116 -0
- package/examples/consumer-app/src/types.ts +23 -0
- package/examples/consumer-app/tsconfig.json +14 -0
- package/examples/formats.ts +10 -3
- package/examples/mcp-test.ts +27 -8
- package/examples/minimal.ts +4 -3
- package/examples/nested.ts +5 -4
- package/examples/option-required.ts +8 -4
- package/index.d.ts +158 -75
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +13 -0
- package/src/builtins/config.test.ts +82 -0
- package/src/builtins/config.ts +220 -0
- package/src/builtins/dispatch.ts +18 -9
- package/src/builtins/export.ts +8 -33
- package/src/builtins/index.ts +1 -0
- package/src/builtins/install.ts +13 -0
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/presentation.ts +2 -17
- package/src/builtins/registry.ts +40 -0
- package/src/capabilities.ts +46 -0
- package/src/cli-errors.ts +15 -0
- package/src/cli.ts +389 -0
- package/src/config/bootstrap.ts +265 -0
- package/src/config/context.test.ts +79 -0
- package/src/config/context.ts +110 -0
- package/src/config/entry.ts +81 -0
- package/src/config/file.test.ts +112 -0
- package/src/config/file.ts +120 -0
- package/src/config/manifest.ts +62 -0
- package/src/config/resolve.test.ts +88 -0
- package/src/config/resolve.ts +167 -0
- package/src/config/schema.ts +101 -0
- package/src/config/validate.test.ts +63 -0
- package/src/config/validate.ts +292 -0
- package/src/config.integration.test.ts +100 -0
- package/src/context.ts +5 -0
- package/src/docs/docs.test.ts +16 -16
- package/src/docs/mcp-guide.ts +35 -11
- package/src/hidden-mcpb.test.ts +40 -2
- package/src/index.ts +4 -3
- package/src/install/index.ts +46 -3
- package/src/install/paths.ts +5 -20
- package/src/install/plan.ts +6 -0
- package/src/install/status.ts +12 -0
- package/src/install/uninstall.ts +11 -0
- package/src/install/update.test.ts +5 -5
- package/src/invoke.test.ts +207 -0
- package/src/mcp/bundle.ts +9 -116
- package/src/mcp/claude.test.ts +73 -0
- package/src/mcp/claude.ts +168 -0
- package/src/mcp/env.ts +3 -37
- package/src/mcp/server.ts +18 -10
- package/src/mcp/tools.ts +3 -7
- package/src/mcp/zip.ts +82 -0
- package/src/mcp.integration.test.ts +502 -0
- package/src/{index.test.ts → parse.test.ts} +24 -935
- package/src/paths/host.ts +40 -0
- package/src/schema.ts +1 -1
- package/src/skill/generate.ts +18 -5
- package/src/skill/hint.ts +18 -0
- package/src/skill/install.ts +1 -5
- package/src/test-fixtures.ts +192 -0
- package/src/types.ts +39 -13
- package/src/validate.ts +70 -0
- package/src/completion.ts +0 -13
- package/src/invoke.ts +0 -217
- package/src/mcp.ts +0 -28
- package/src/runtime.ts +0 -134
package/src/cli.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Runtime entry point: validate program, cache derived state, run / invoke / MCP serve.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { format } from "node:util";
|
|
6
|
+
import { builtinInterceptRoot, dispatchBuiltin } from "./builtins/dispatch.ts";
|
|
7
|
+
import { cliParseRoot, cliPresentationRoot } from "./builtins/presentation.ts";
|
|
8
|
+
import { assertBuiltinAllowed, type CliCapabilities, resolveCapabilities } from "./capabilities.ts";
|
|
9
|
+
import {
|
|
10
|
+
bootstrapAppConfig,
|
|
11
|
+
type EnsureAppConfigOpts,
|
|
12
|
+
ensureAppConfig,
|
|
13
|
+
} from "./config/bootstrap.ts";
|
|
14
|
+
import { type AnyAppConfigSnapshot, createAppConfigSnapshot } from "./config/context.ts";
|
|
15
|
+
import { effectiveJsonSchema } from "./config/schema.ts";
|
|
16
|
+
import { CliContext } from "./context.ts";
|
|
17
|
+
import { cliHelpRender } from "./help.ts";
|
|
18
|
+
import { bootstrapMcpEnv } from "./mcp/env.ts";
|
|
19
|
+
import { mcpServeStdioLoop } from "./mcp/server.ts";
|
|
20
|
+
import { ParseKind, type ParseResult, parse, postParseValidate } from "./parse.ts";
|
|
21
|
+
import { type CliSchemaExport, cliSchemaExport } from "./schema.ts";
|
|
22
|
+
import type { CliHandler, CliLeaf, CliNode, CliProgram, CliRouter } from "./types.ts";
|
|
23
|
+
import { isCliLeaf, isCliRouter } from "./types.ts";
|
|
24
|
+
import { cliValidateProgram } from "./validate.ts";
|
|
25
|
+
|
|
26
|
+
/** Outcome of a non-exiting CLI invocation. */
|
|
27
|
+
export type CliInvokeKind = "ok" | "help" | "error";
|
|
28
|
+
|
|
29
|
+
/** Result of Cli.invoke: captured output and exit metadata without process.exit. */
|
|
30
|
+
export interface CliInvokeResult {
|
|
31
|
+
kind: CliInvokeKind;
|
|
32
|
+
exitCode: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
errorMsg?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class CliInvokeExit extends Error {
|
|
39
|
+
readonly code: number;
|
|
40
|
+
|
|
41
|
+
constructor(code: number) {
|
|
42
|
+
super(`process.exit(${code})`);
|
|
43
|
+
this.name = "CliInvokeExit";
|
|
44
|
+
this.code = code;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PreparedDispatch {
|
|
49
|
+
pr: ParseResult;
|
|
50
|
+
parseRoot: CliNode;
|
|
51
|
+
completionParseRoot: CliRouter;
|
|
52
|
+
isLeafCompletionIntercept: boolean;
|
|
53
|
+
leaf: CliLeaf & { handler: CliHandler };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Argsbarg runtime for a validated, frozen {@link CliProgram}. */
|
|
57
|
+
export class Cli {
|
|
58
|
+
readonly program: CliProgram;
|
|
59
|
+
readonly caps: CliCapabilities;
|
|
60
|
+
private readonly parseRootMerged: CliRouter;
|
|
61
|
+
private readonly presentationRoot: CliRouter;
|
|
62
|
+
private _appConfig?: AnyAppConfigSnapshot;
|
|
63
|
+
|
|
64
|
+
constructor(program: CliProgram) {
|
|
65
|
+
cliValidateProgram(program);
|
|
66
|
+
Object.freeze(program);
|
|
67
|
+
this.program = program;
|
|
68
|
+
this.caps = resolveCapabilities(program);
|
|
69
|
+
this.parseRootMerged = cliParseRoot(program);
|
|
70
|
+
this.presentationRoot = cliPresentationRoot(program);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get appConfig(): AnyAppConfigSnapshot {
|
|
74
|
+
if (this._appConfig === undefined) {
|
|
75
|
+
this._appConfig = this.buildAppConfigSnapshot({
|
|
76
|
+
exitOnMissing: false,
|
|
77
|
+
interactive: false,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return this._appConfig;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
exportCommandSchema(): CliSchemaExport {
|
|
84
|
+
return cliSchemaExport(this.program);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
exportAppConfigSchema(): Record<string, unknown> | undefined {
|
|
88
|
+
return effectiveJsonSchema(this.program);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async run(argv: string[] = process.argv.slice(2)): Promise<never> {
|
|
92
|
+
assertBuiltinAllowed(argv, this.caps);
|
|
93
|
+
|
|
94
|
+
const prep = this.prepareDispatch(argv);
|
|
95
|
+
if ("error" in prep) {
|
|
96
|
+
if (prep.error.kind === ParseKind.Help) {
|
|
97
|
+
process.stdout.write(cliHelpRender(this.parseRootMerged, prep.error.helpPath, false));
|
|
98
|
+
process.exit(prep.error.helpExplicit ? 0 : 1);
|
|
99
|
+
}
|
|
100
|
+
const color = process.stderr.isTTY;
|
|
101
|
+
const msg = color ? `\u001B[31m${prep.error.errorMsg}\u001B[0m` : prep.error.errorMsg;
|
|
102
|
+
process.stderr.write(`${msg}\n`);
|
|
103
|
+
process.stderr.write(cliHelpRender(this.presentationRoot, prep.error.errorHelpPath, true));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { pr, completionParseRoot, isLeafCompletionIntercept, leaf } = prep;
|
|
108
|
+
|
|
109
|
+
if (pr.kind === ParseKind.Ok) {
|
|
110
|
+
await dispatchBuiltin(this.program, pr, {
|
|
111
|
+
isLeafCompletionIntercept,
|
|
112
|
+
parseRoot: completionParseRoot,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isConfigCmd = pr.path[0] === "config" && this.caps.configCommands;
|
|
117
|
+
const snapshot = this.buildAppConfigSnapshot({
|
|
118
|
+
interactive: !isConfigCmd && !!process.stdin.isTTY,
|
|
119
|
+
exitOnMissing: !isConfigCmd,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const ctx = new CliContext(
|
|
123
|
+
this.program.key,
|
|
124
|
+
pr.path,
|
|
125
|
+
pr.args,
|
|
126
|
+
pr.opts,
|
|
127
|
+
this.program,
|
|
128
|
+
"cli",
|
|
129
|
+
snapshot,
|
|
130
|
+
);
|
|
131
|
+
try {
|
|
132
|
+
await Promise.resolve(leaf.handler(ctx));
|
|
133
|
+
process.exit(0);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof Error) {
|
|
136
|
+
process.stderr.write(`${err.message}\n`);
|
|
137
|
+
}
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async invoke(argv: string[]): Promise<CliInvokeResult> {
|
|
143
|
+
const prep = this.prepareDispatch(argv, { presentationFallback: true });
|
|
144
|
+
if ("error" in prep) {
|
|
145
|
+
if (prep.error.kind === ParseKind.Help) {
|
|
146
|
+
return {
|
|
147
|
+
kind: "help",
|
|
148
|
+
exitCode: 1,
|
|
149
|
+
stdout: "",
|
|
150
|
+
stderr: "",
|
|
151
|
+
errorMsg: "Help is not available via MCP tool calls.",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
kind: "error",
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
stdout: "",
|
|
158
|
+
stderr: prep.error.errorMsg,
|
|
159
|
+
errorMsg: prep.error.errorMsg,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { pr, completionParseRoot, isLeafCompletionIntercept, leaf } = prep;
|
|
164
|
+
const snapshot = this.buildAppConfigSnapshot({
|
|
165
|
+
interactive: false,
|
|
166
|
+
exitOnMissing: false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const ctx = new CliContext(
|
|
170
|
+
this.program.key,
|
|
171
|
+
pr.path,
|
|
172
|
+
pr.args,
|
|
173
|
+
pr.opts,
|
|
174
|
+
this.program,
|
|
175
|
+
"mcp",
|
|
176
|
+
snapshot,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
let stdout = "";
|
|
180
|
+
let stderr = "";
|
|
181
|
+
const origExit = process.exit;
|
|
182
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
183
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
184
|
+
const origConsoleLog = console.log;
|
|
185
|
+
const origConsoleError = console.error;
|
|
186
|
+
const origConsoleInfo = console.info;
|
|
187
|
+
const origConsoleWarn = console.warn;
|
|
188
|
+
|
|
189
|
+
process.exit = ((code?: number) => {
|
|
190
|
+
throw new CliInvokeExit(code ?? 0);
|
|
191
|
+
}) as typeof process.exit;
|
|
192
|
+
|
|
193
|
+
process.stdout.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
|
|
194
|
+
stdout += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
195
|
+
if (typeof args[0] === "function") {
|
|
196
|
+
(args[0] as () => void)();
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}) as typeof process.stdout.write;
|
|
200
|
+
|
|
201
|
+
process.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
|
|
202
|
+
stderr += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
203
|
+
if (typeof args[0] === "function") {
|
|
204
|
+
(args[0] as () => void)();
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}) as typeof process.stderr.write;
|
|
208
|
+
|
|
209
|
+
console.log = (...args: unknown[]) => {
|
|
210
|
+
stdout += `${format(...args)}\n`;
|
|
211
|
+
};
|
|
212
|
+
console.info = (...args: unknown[]) => {
|
|
213
|
+
stdout += `${format(...args)}\n`;
|
|
214
|
+
};
|
|
215
|
+
console.warn = (...args: unknown[]) => {
|
|
216
|
+
stderr += `${format(...args)}\n`;
|
|
217
|
+
};
|
|
218
|
+
console.error = (...args: unknown[]) => {
|
|
219
|
+
stderr += `${format(...args)}\n`;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (pr.kind === ParseKind.Ok) {
|
|
224
|
+
await dispatchBuiltin(this.program, pr, {
|
|
225
|
+
isLeafCompletionIntercept,
|
|
226
|
+
parseRoot: completionParseRoot,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await Promise.resolve(leaf.handler(ctx));
|
|
231
|
+
return { kind: "ok", exitCode: 0, stdout, stderr };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err instanceof CliInvokeExit) {
|
|
234
|
+
if (err.code === 0) {
|
|
235
|
+
return { kind: "ok", exitCode: 0, stdout, stderr };
|
|
236
|
+
}
|
|
237
|
+
const msg = stderr.trim() || `Exit code ${err.code}`;
|
|
238
|
+
return { kind: "error", exitCode: err.code, stdout, stderr, errorMsg: msg };
|
|
239
|
+
}
|
|
240
|
+
if (err instanceof Error) {
|
|
241
|
+
return {
|
|
242
|
+
kind: "error",
|
|
243
|
+
exitCode: 1,
|
|
244
|
+
stdout,
|
|
245
|
+
stderr: `${err.message}\n`,
|
|
246
|
+
errorMsg: err.message,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
kind: "error",
|
|
251
|
+
exitCode: 1,
|
|
252
|
+
stdout,
|
|
253
|
+
stderr: "Unknown error\n",
|
|
254
|
+
errorMsg: "Unknown error",
|
|
255
|
+
};
|
|
256
|
+
} finally {
|
|
257
|
+
process.exit = origExit;
|
|
258
|
+
process.stdout.write = origStdoutWrite;
|
|
259
|
+
process.stderr.write = origStderrWrite;
|
|
260
|
+
console.log = origConsoleLog;
|
|
261
|
+
console.error = origConsoleError;
|
|
262
|
+
console.info = origConsoleInfo;
|
|
263
|
+
console.warn = origConsoleWarn;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async serveMcp(): Promise<never> {
|
|
268
|
+
try {
|
|
269
|
+
if (this.program.mcpServer) {
|
|
270
|
+
bootstrapMcpEnv(this.program.mcpServer);
|
|
271
|
+
}
|
|
272
|
+
bootstrapAppConfig(this.program, { validateFile: false });
|
|
273
|
+
await mcpServeStdioLoop(this);
|
|
274
|
+
process.exit(0);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (err instanceof Error) {
|
|
277
|
+
process.stderr.write(`${err.message}\n`);
|
|
278
|
+
} else {
|
|
279
|
+
process.stderr.write("MCP server error.\n");
|
|
280
|
+
}
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private prepareDispatch(
|
|
286
|
+
argv: string[],
|
|
287
|
+
opts?: { presentationFallback?: boolean },
|
|
288
|
+
): PreparedDispatch | { error: ParseResult } {
|
|
289
|
+
const program = this.program;
|
|
290
|
+
let parseRoot: CliNode;
|
|
291
|
+
let completionParseRoot: CliRouter = opts?.presentationFallback
|
|
292
|
+
? this.presentationRoot
|
|
293
|
+
: this.parseRootMerged;
|
|
294
|
+
let isLeafCompletionIntercept = false;
|
|
295
|
+
|
|
296
|
+
if (isCliLeaf(program)) {
|
|
297
|
+
const intercept = builtinInterceptRoot(program, argv);
|
|
298
|
+
if (intercept.isLeafCompletionIntercept || intercept.parseRoot !== program) {
|
|
299
|
+
parseRoot = intercept.parseRoot;
|
|
300
|
+
completionParseRoot = isCliRouter(intercept.parseRoot)
|
|
301
|
+
? intercept.parseRoot
|
|
302
|
+
: opts?.presentationFallback
|
|
303
|
+
? this.presentationRoot
|
|
304
|
+
: this.parseRootMerged;
|
|
305
|
+
isLeafCompletionIntercept = intercept.isLeafCompletionIntercept;
|
|
306
|
+
} else {
|
|
307
|
+
parseRoot = program;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
parseRoot = this.parseRootMerged;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let pr = parse(parseRoot, argv);
|
|
314
|
+
pr = postParseValidate(parseRoot, pr);
|
|
315
|
+
|
|
316
|
+
if (pr.kind !== ParseKind.Ok) {
|
|
317
|
+
return { error: pr };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let current: CliNode = parseRoot;
|
|
321
|
+
for (const seg of pr.path) {
|
|
322
|
+
if (!isCliRouter(current)) {
|
|
323
|
+
const msg = "Internal error: missing handler for path.";
|
|
324
|
+
return {
|
|
325
|
+
error: {
|
|
326
|
+
kind: ParseKind.Error,
|
|
327
|
+
path: pr.path,
|
|
328
|
+
args: pr.args,
|
|
329
|
+
opts: pr.opts,
|
|
330
|
+
helpExplicit: false,
|
|
331
|
+
helpPath: [],
|
|
332
|
+
errorMsg: msg,
|
|
333
|
+
errorHelpPath: pr.path,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const ch = current.commands.find((candidate) => candidate.key === seg);
|
|
338
|
+
if (!ch) {
|
|
339
|
+
const msg = "Internal error: missing handler for path.";
|
|
340
|
+
return {
|
|
341
|
+
error: {
|
|
342
|
+
kind: ParseKind.Error,
|
|
343
|
+
path: pr.path,
|
|
344
|
+
args: pr.args,
|
|
345
|
+
opts: pr.opts,
|
|
346
|
+
helpExplicit: false,
|
|
347
|
+
helpPath: [],
|
|
348
|
+
errorMsg: msg,
|
|
349
|
+
errorHelpPath: pr.path,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
current = ch;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!isCliLeaf(current) || !current.handler) {
|
|
357
|
+
const msg = "Internal error: missing handler for path.";
|
|
358
|
+
return {
|
|
359
|
+
error: {
|
|
360
|
+
kind: ParseKind.Error,
|
|
361
|
+
path: pr.path,
|
|
362
|
+
args: pr.args,
|
|
363
|
+
opts: pr.opts,
|
|
364
|
+
helpExplicit: false,
|
|
365
|
+
helpPath: [],
|
|
366
|
+
errorMsg: msg,
|
|
367
|
+
errorHelpPath: pr.path,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
pr,
|
|
374
|
+
parseRoot,
|
|
375
|
+
completionParseRoot,
|
|
376
|
+
isLeafCompletionIntercept,
|
|
377
|
+
leaf: current,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private buildAppConfigSnapshot(opts: EnsureAppConfigOpts): AnyAppConfigSnapshot {
|
|
382
|
+
const bootstrap = ensureAppConfig(this.program, opts);
|
|
383
|
+
const snapshot = bootstrap
|
|
384
|
+
? createAppConfigSnapshot(this.program, bootstrap.fileData, bootstrap.resolved)
|
|
385
|
+
: createAppConfigSnapshot(this.program, {}, {});
|
|
386
|
+
this._appConfig = snapshot;
|
|
387
|
+
return snapshot;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Bootstrap: load config, validate, resolve, export, TTY prompts, install --configure.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readSync } from "node:fs";
|
|
6
|
+
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
7
|
+
import {
|
|
8
|
+
configEntryRequired,
|
|
9
|
+
configEntrySensitive,
|
|
10
|
+
defaultConfigEntryTitle,
|
|
11
|
+
jsonSchemaRequiredKeys,
|
|
12
|
+
} from "./entry.ts";
|
|
13
|
+
import {
|
|
14
|
+
displayAppConfigPath,
|
|
15
|
+
readAppConfigFile,
|
|
16
|
+
readAppConfigFileRaw,
|
|
17
|
+
resolveAppConfigPath,
|
|
18
|
+
writeAppConfigFile,
|
|
19
|
+
} from "./file.ts";
|
|
20
|
+
import type { ResolvedConfig } from "./resolve.ts";
|
|
21
|
+
import {
|
|
22
|
+
exportConfigToEnv,
|
|
23
|
+
formatMissingConfigMessage,
|
|
24
|
+
missingRequiredConfig,
|
|
25
|
+
resolveAppConfig,
|
|
26
|
+
stringifyConfigValue,
|
|
27
|
+
} from "./resolve.ts";
|
|
28
|
+
import { effectiveJsonSchema } from "./schema.ts";
|
|
29
|
+
|
|
30
|
+
export interface EnsureAppConfigOpts {
|
|
31
|
+
interactive: boolean;
|
|
32
|
+
exitOnMissing: boolean;
|
|
33
|
+
configure?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ConfigBootstrapResult {
|
|
37
|
+
fileData: Record<string, unknown>;
|
|
38
|
+
resolved: ResolvedConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Load, validate, resolve, and export config. */
|
|
42
|
+
export function bootstrapAppConfig(
|
|
43
|
+
program: CliProgram,
|
|
44
|
+
opts: { validateFile: boolean },
|
|
45
|
+
): ConfigBootstrapResult {
|
|
46
|
+
const path = resolveAppConfigPath(program);
|
|
47
|
+
const fileData = opts.validateFile ? readAppConfigFile(program) : readAppConfigFileRaw(path);
|
|
48
|
+
const resolved = resolveAppConfig(program, fileData);
|
|
49
|
+
exportConfigToEnv(program, resolved);
|
|
50
|
+
return { fileData, resolved };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readPromptLine(mask: boolean): string {
|
|
54
|
+
if (!mask) {
|
|
55
|
+
const buf = Buffer.alloc(4096);
|
|
56
|
+
const n = readSync(0, buf, { length: 4096 });
|
|
57
|
+
return buf.toString("utf8", 0, n).replace(/\r?\n$/, "");
|
|
58
|
+
}
|
|
59
|
+
let result = "";
|
|
60
|
+
const buf = Buffer.alloc(1);
|
|
61
|
+
while (true) {
|
|
62
|
+
const n = readSync(0, buf, { length: 1 });
|
|
63
|
+
if (n <= 0) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const byte = buf[0];
|
|
67
|
+
if (byte === undefined) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (byte === 10 || byte === 13) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (byte === 127 || byte === 8) {
|
|
74
|
+
result = result.slice(0, -1);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
result += String.fromCharCode(byte);
|
|
78
|
+
process.stderr.write("*");
|
|
79
|
+
}
|
|
80
|
+
process.stderr.write("\n");
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function promptConfigKey(
|
|
85
|
+
key: string,
|
|
86
|
+
entry: CliAppConfigEntry,
|
|
87
|
+
current: unknown,
|
|
88
|
+
configure: boolean,
|
|
89
|
+
jsonSchemaRequired: Set<string> | undefined,
|
|
90
|
+
): unknown {
|
|
91
|
+
const title = entry.title ?? defaultConfigEntryTitle(key);
|
|
92
|
+
const required = configEntryRequired(key, entry, jsonSchemaRequired);
|
|
93
|
+
const heading = required || !configure ? title : `${title} (optional)`;
|
|
94
|
+
process.stderr.write(`${heading}\n`);
|
|
95
|
+
process.stderr.write(` ${entry.description}\n`);
|
|
96
|
+
const hasCurrent = current !== undefined && current !== null && String(current).length > 0;
|
|
97
|
+
if (hasCurrent) {
|
|
98
|
+
const sensitive = configEntrySensitive(key, entry);
|
|
99
|
+
process.stderr.write(`Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
|
|
100
|
+
process.stderr.write("Value (Enter to keep): ");
|
|
101
|
+
} else {
|
|
102
|
+
process.stderr.write("Value: ");
|
|
103
|
+
}
|
|
104
|
+
const input = readPromptLine(hasCurrent && configEntrySensitive(key, entry));
|
|
105
|
+
if (input.length === 0 && hasCurrent) {
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
return input.length > 0 ? input : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
112
|
+
const appConfig = program.appConfig;
|
|
113
|
+
const updates: Record<string, unknown> = {};
|
|
114
|
+
if (!appConfig) {
|
|
115
|
+
return updates;
|
|
116
|
+
}
|
|
117
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
118
|
+
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
119
|
+
const { resolved } = bootstrapAppConfig(program, { validateFile: false });
|
|
120
|
+
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
121
|
+
if (!configEntryRequired(key, entry, fromSchema)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const current = resolved[key];
|
|
125
|
+
if (current !== undefined && current !== null && String(current).length > 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const value = promptConfigKey(key, entry, undefined, false, fromSchema);
|
|
129
|
+
if (value !== undefined && String(value).length > 0) {
|
|
130
|
+
updates[key] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return updates;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Interactive `install --configure`. */
|
|
137
|
+
export function runInstallConfigure(program: CliProgram): { path: string; changed: boolean } {
|
|
138
|
+
if (!program.appConfig) {
|
|
139
|
+
throw new Error("install --configure requires program.appConfig on the program root.");
|
|
140
|
+
}
|
|
141
|
+
if (!process.stdin.isTTY) {
|
|
142
|
+
const { resolved } = bootstrapAppConfig(program, { validateFile: false });
|
|
143
|
+
const missing = missingRequiredConfig(program, resolved);
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
process.stderr.write(`${formatMissingConfigMessage(program, missing)}\n`);
|
|
146
|
+
} else {
|
|
147
|
+
process.stderr.write("install --configure requires an interactive terminal.\n");
|
|
148
|
+
}
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const path = resolveAppConfigPath(program);
|
|
153
|
+
const existing = readAppConfigFileRaw(path);
|
|
154
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
155
|
+
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
156
|
+
const { resolved } = bootstrapAppConfig(program, { validateFile: false });
|
|
157
|
+
const next: Record<string, unknown> = { ...existing };
|
|
158
|
+
let changed = false;
|
|
159
|
+
|
|
160
|
+
for (const [key, entry] of Object.entries(program.appConfig.entries)) {
|
|
161
|
+
const current = resolved[key];
|
|
162
|
+
const before = next[key];
|
|
163
|
+
const value = promptConfigKey(key, entry, current, true, fromSchema);
|
|
164
|
+
if (value !== undefined && String(value).length > 0) {
|
|
165
|
+
if (JSON.stringify(value) !== JSON.stringify(before)) {
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
next[key] = value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (changed) {
|
|
173
|
+
writeAppConfigFile(program, next);
|
|
174
|
+
const updated = resolveAppConfig(program, next);
|
|
175
|
+
exportConfigToEnv(program, updated);
|
|
176
|
+
return { path, changed: true };
|
|
177
|
+
}
|
|
178
|
+
return { path, changed: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Config status for install --status (values never included). */
|
|
182
|
+
export function appConfigStatus(program: CliProgram):
|
|
183
|
+
| {
|
|
184
|
+
path: string;
|
|
185
|
+
exists: boolean;
|
|
186
|
+
required: Array<{ key: string; set: boolean }>;
|
|
187
|
+
}
|
|
188
|
+
| undefined {
|
|
189
|
+
if (!program.appConfig) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const path = resolveAppConfigPath(program);
|
|
193
|
+
let fileData: Record<string, unknown> = {};
|
|
194
|
+
try {
|
|
195
|
+
fileData = readAppConfigFile(program);
|
|
196
|
+
} catch {
|
|
197
|
+
fileData = readAppConfigFileRaw(path);
|
|
198
|
+
}
|
|
199
|
+
const resolved = resolveAppConfig(program, fileData);
|
|
200
|
+
exportConfigToEnv(program, resolved);
|
|
201
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
202
|
+
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
203
|
+
const required = Object.entries(program.appConfig.entries)
|
|
204
|
+
.filter(([key, entry]) => configEntryRequired(key, entry, fromSchema))
|
|
205
|
+
.map(([key]) => ({
|
|
206
|
+
key,
|
|
207
|
+
set:
|
|
208
|
+
resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0,
|
|
209
|
+
}));
|
|
210
|
+
return { path, exists: existsSync(path), required };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Loads config, optionally prompts, and enforces required keys. */
|
|
214
|
+
export function ensureAppConfig(
|
|
215
|
+
program: CliProgram,
|
|
216
|
+
opts: EnsureAppConfigOpts,
|
|
217
|
+
): ConfigBootstrapResult | undefined {
|
|
218
|
+
if (!program.appConfig) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let fileData: Record<string, unknown>;
|
|
223
|
+
try {
|
|
224
|
+
fileData = readAppConfigFile(program);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
227
|
+
process.stderr.write(`${msg}\n`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let resolved = resolveAppConfig(program, fileData);
|
|
232
|
+
exportConfigToEnv(program, resolved);
|
|
233
|
+
|
|
234
|
+
if (opts.interactive && process.stdin.isTTY) {
|
|
235
|
+
if (opts.configure) {
|
|
236
|
+
runInstallConfigure(program);
|
|
237
|
+
fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
238
|
+
resolved = resolveAppConfig(program, fileData);
|
|
239
|
+
exportConfigToEnv(program, resolved);
|
|
240
|
+
return { fileData, resolved };
|
|
241
|
+
}
|
|
242
|
+
const updates = promptMissingRequired(program);
|
|
243
|
+
if (Object.keys(updates).length > 0) {
|
|
244
|
+
const merged = { ...fileData, ...updates };
|
|
245
|
+
writeAppConfigFile(program, merged);
|
|
246
|
+
fileData = merged;
|
|
247
|
+
resolved = resolveAppConfig(program, fileData);
|
|
248
|
+
exportConfigToEnv(program, resolved);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const missing = missingRequiredConfig(program, resolved);
|
|
253
|
+
if (missing.length === 0) {
|
|
254
|
+
return { fileData, resolved };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (opts.exitOnMissing) {
|
|
258
|
+
process.stderr.write(`${formatMissingConfigMessage(program, missing)}\n`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { fileData, resolved };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { displayAppConfigPath };
|