argsbarg 4.0.4 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -1
- package/README.md +91 -85
- package/docs/README.md +6 -6
- package/docs/ai-skills.md +8 -5
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +9 -7
- package/docs/config-schema.md +37 -13
- package/docs/developing.md +8 -8
- package/docs/distribution-homebrew.md +103 -0
- package/docs/install.md +143 -106
- package/docs/mcp.md +23 -12
- package/docs/output-schema.md +1 -1
- package/examples/full-example/Formula/.gitkeep +0 -0
- package/examples/full-example/README.md +98 -0
- package/examples/full-example/biome.json +22 -0
- package/examples/{consumer-app → full-example}/bun.lock +2 -0
- package/examples/full-example/justfile +134 -0
- package/examples/{consumer-app → full-example}/package.json +10 -3
- package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
- package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
- package/examples/full-example/scripts/create-identity.ts +11 -0
- package/examples/full-example/scripts/formula-shared.ts +73 -0
- package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
- package/examples/full-example/scripts/print-identity.ts +27 -0
- package/examples/full-example/src/commands/echo/command.ts +21 -0
- package/examples/full-example/src/commands/status/command.test.ts +10 -0
- package/examples/full-example/src/commands/status/command.ts +36 -0
- package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
- package/examples/full-example/src/index.ts +10 -0
- package/examples/full-example/src/program.ts +57 -0
- package/examples/{consumer-app → full-example}/src/types.ts +1 -1
- package/examples/mcp-test.ts +8 -24
- package/examples/nested.ts +1 -3
- package/index.d.ts +81 -65
- package/package.json +2 -2
- package/src/builtins/builtins.test.ts +37 -22
- package/src/builtins/completion-group.ts +17 -15
- package/src/builtins/config.test.ts +31 -25
- package/src/builtins/config.ts +4 -3
- package/src/builtins/dispatch.ts +25 -1
- package/src/builtins/install.ts +45 -82
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/registry.ts +2 -0
- package/src/builtins/uninstall.ts +80 -0
- package/src/capabilities.ts +5 -7
- package/src/cli-tool/cli-smoke.test.ts +19 -0
- package/src/cli-tool/create.test.ts +119 -0
- package/src/cli-tool/create.ts +380 -0
- package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
- package/src/cli-tool/main.ts +8 -0
- package/src/cli-tool/post-create.ts +111 -0
- package/src/cli-tool/program.ts +82 -0
- package/src/cli-tool/prompt.ts +28 -0
- package/src/cli-tool/run-create.ts +149 -0
- package/src/config/bootstrap.ts +174 -66
- package/src/config/context.test.ts +22 -36
- package/src/config/context.ts +5 -4
- package/src/config/file.test.ts +66 -56
- package/src/config/file.ts +33 -25
- package/src/config/resolve.test.ts +192 -1
- package/src/config/resolve.ts +92 -13
- package/src/config.integration.test.ts +17 -10
- package/src/docs/api-guide.test.ts +4 -5
- package/src/docs/docs.test.ts +2 -1
- package/src/docs/mcp-guide.ts +7 -8
- package/src/hidden-mcpb.test.ts +41 -1
- package/src/index.ts +7 -10
- package/src/install/binary-placement.test.ts +101 -0
- package/src/install/binary-placement.ts +47 -0
- package/src/install/detect-installed.ts +2 -97
- package/src/install/index.ts +239 -168
- package/src/install/install-validate.test.ts +61 -0
- package/src/install/install.test.ts +170 -90
- package/src/install/mcp-openclaw.test.ts +40 -0
- package/src/install/mcp-openclaw.ts +106 -0
- package/src/install/normalize-uninstall.ts +11 -0
- package/src/install/normalize.ts +20 -0
- package/src/install/paths.ts +18 -26
- package/src/install/plan.ts +40 -261
- package/src/install/shell.ts +0 -14
- package/src/install/status.test.ts +85 -0
- package/src/install/status.ts +22 -15
- package/src/install/target-base.ts +93 -0
- package/src/install/target-detect.ts +20 -0
- package/src/install/target-effective.ts +129 -0
- package/src/install/target-mcp-cli.ts +149 -0
- package/src/install/target-mcp-json.ts +130 -0
- package/src/install/target-plan-build.ts +67 -0
- package/src/install/target-registry.ts +57 -0
- package/src/install/target-scope.ts +253 -0
- package/src/install/target-skill.ts +104 -0
- package/src/install/target-types.ts +129 -0
- package/src/install/targets/app.ts +60 -0
- package/src/install/targets/chatgpt-mcp.ts +12 -0
- package/src/install/targets/claude-code-mcp.ts +15 -0
- package/src/install/targets/claude-desktop-mcp.ts +12 -0
- package/src/install/targets/claude-skill.ts +16 -0
- package/src/install/targets/codex-mcp.ts +25 -0
- package/src/install/targets/codex-skill.ts +14 -0
- package/src/install/targets/configure.ts +63 -0
- package/src/install/targets/cursor-mcp.ts +15 -0
- package/src/install/targets/cursor-skill.ts +16 -0
- package/src/install/targets/index.ts +50 -0
- package/src/install/targets/openclaw-mcp.ts +25 -0
- package/src/install/targets/openclaw-skill.ts +17 -0
- package/src/install/targets/opencode-mcp.ts +101 -0
- package/src/install/targets/opencode-skill.ts +15 -0
- package/src/install/targets.test.ts +118 -0
- package/src/install/uninstall.ts +16 -152
- package/src/invoke.test.ts +7 -1
- package/src/mcp/bundle.ts +16 -4
- package/src/mcp/claude.test.ts +14 -1
- package/src/mcp/claude.ts +11 -4
- package/src/mcp/env.test.ts +92 -0
- package/src/mcp/env.ts +15 -14
- package/src/mcp/zip.test.ts +17 -0
- package/src/mcp/zip.ts +62 -9
- package/src/mcp.integration.test.ts +1 -1
- package/src/parse.test.ts +14 -2
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- package/src/prompt.ts +10 -0
- package/src/schema.ts +9 -1
- package/src/skill/generate.ts +18 -4
- package/src/skill/install.ts +33 -6
- package/src/skill/naming.ts +28 -0
- package/src/types.ts +86 -7
- package/src/validate.ts +73 -9
- package/docs/templates/cursor/rules/cli-program.mdc +0 -31
- package/examples/config-app/main.ts +0 -20
- package/examples/config-app/program.ts +0 -81
- package/examples/config-app/schema.ts +0 -37
- package/examples/config-app/types.ts +0 -19
- package/examples/consumer-app/README.md +0 -57
- package/examples/consumer-app/src/main.ts +0 -15
- package/examples/consumer-app/src/program.ts +0 -108
- package/src/install/binary.ts +0 -94
- package/src/install/completions.ts +0 -56
- package/src/install/update.test.ts +0 -108
- package/src/install/update.ts +0 -57
- /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
- /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
|
@@ -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/config/bootstrap.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
2
|
+
Loads the app config file, runs setup prompts when needed, and checks that required
|
|
3
|
+
settings are present before the CLI or MCP server handles a request.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
+
import { readSync } from "node:fs";
|
|
7
|
+
import { readPromptLine as readStdinLine } from "../prompt.ts";
|
|
6
8
|
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
7
9
|
import {
|
|
8
10
|
configEntryRequired,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
jsonSchemaRequiredKeys,
|
|
12
14
|
} from "./entry.ts";
|
|
13
15
|
import {
|
|
16
|
+
appConfigInstalled,
|
|
14
17
|
displayAppConfigPath,
|
|
15
18
|
readAppConfigFile,
|
|
16
19
|
readAppConfigFileRaw,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
} from "./file.ts";
|
|
20
23
|
import type { ResolvedConfig } from "./resolve.ts";
|
|
21
24
|
import {
|
|
25
|
+
captureMappedHostEnv,
|
|
22
26
|
exportConfigToEnv,
|
|
23
27
|
formatMissingConfigMessage,
|
|
24
28
|
missingRequiredConfig,
|
|
@@ -27,87 +31,169 @@ import {
|
|
|
27
31
|
} from "./resolve.ts";
|
|
28
32
|
import { effectiveJsonSchema } from "./schema.ts";
|
|
29
33
|
|
|
34
|
+
export { displayAppConfigPath } from "./file.ts";
|
|
35
|
+
|
|
36
|
+
/** Tells ensureAppConfig whether to prompt the user and how strictly to require settings. */
|
|
30
37
|
export interface EnsureAppConfigOpts {
|
|
38
|
+
/** In a terminal, ask for any required settings that are still empty (or run the full configure wizard). */
|
|
31
39
|
interactive: boolean;
|
|
40
|
+
/** Stop the program with an error if required settings are still missing after loading. */
|
|
32
41
|
exitOnMissing: boolean;
|
|
42
|
+
/** With `interactive`, walk through every setting instead of only the missing required ones. */
|
|
33
43
|
configure?: boolean;
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
/** Config as read from disk, plus the final values after env vars and defaults are applied. */
|
|
36
47
|
export interface ConfigBootstrapResult {
|
|
48
|
+
/** Key/value pairs stored in the config file. */
|
|
37
49
|
fileData: Record<string, unknown>;
|
|
50
|
+
/** Effective values the app will use (file, defaults, and shell env combined). */
|
|
38
51
|
resolved: ResolvedConfig;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
/**
|
|
54
|
+
/** Read the config file, merge env overrides, and export mapped values into `process.env`. */
|
|
42
55
|
export function bootstrapAppConfig(
|
|
43
56
|
program: CliProgram,
|
|
44
57
|
opts: { validateFile: boolean },
|
|
45
58
|
): ConfigBootstrapResult {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const fileData = opts.validateFile
|
|
60
|
+
? readAppConfigFile(program)
|
|
61
|
+
: readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
62
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
63
|
+
const resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
64
|
+
exportConfigToEnv(program, resolved, hostEnv);
|
|
50
65
|
return { fileData, resolved };
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (n <= 0) {
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
66
|
-
const byte = buf[0];
|
|
67
|
-
if (byte === undefined) {
|
|
68
|
-
continue;
|
|
68
|
+
/** Read a line from the terminal without showing what the user types (for tokens and passwords). */
|
|
69
|
+
function readSensitiveLine(): string {
|
|
70
|
+
const stdin = process.stdin;
|
|
71
|
+
const canRaw = stdin.isTTY && typeof stdin.setRawMode === "function";
|
|
72
|
+
const wasRaw = canRaw && stdin.isRaw;
|
|
73
|
+
if (canRaw) {
|
|
74
|
+
try {
|
|
75
|
+
stdin.setRawMode(true);
|
|
76
|
+
} catch {
|
|
77
|
+
// Best-effort: read still works if raw mode is unavailable.
|
|
69
78
|
}
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
let result = "";
|
|
82
|
+
const buf = Buffer.alloc(1);
|
|
83
|
+
while (true) {
|
|
84
|
+
const n = readSync(0, buf, { length: 1 });
|
|
85
|
+
if (n <= 0) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const byte = buf[0];
|
|
89
|
+
if (byte === 3) {
|
|
90
|
+
// Raw mode delivers Ctrl+C as ETX instead of SIGINT.
|
|
91
|
+
process.stderr.write("\n");
|
|
92
|
+
process.exit(130);
|
|
93
|
+
}
|
|
94
|
+
if (byte === 4) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (byte === 10 || byte === 13) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (byte === 127 || byte === 8) {
|
|
101
|
+
if (result.length > 0) {
|
|
102
|
+
result = result.slice(0, -1);
|
|
103
|
+
process.stderr.write("\b \b");
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
result += String.fromCharCode(byte);
|
|
108
|
+
process.stderr.write("*");
|
|
72
109
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
110
|
+
process.stderr.write("\n");
|
|
111
|
+
return result;
|
|
112
|
+
} finally {
|
|
113
|
+
if (canRaw) {
|
|
114
|
+
try {
|
|
115
|
+
stdin.setRawMode(!!wasRaw);
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore restore failures.
|
|
118
|
+
}
|
|
76
119
|
}
|
|
77
|
-
result += String.fromCharCode(byte);
|
|
78
|
-
process.stderr.write("*");
|
|
79
120
|
}
|
|
80
|
-
process.stderr.write("\n");
|
|
81
|
-
return result;
|
|
82
121
|
}
|
|
83
122
|
|
|
123
|
+
/** Read one line of user input; sensitive settings use a hidden prompt. */
|
|
124
|
+
function readPromptLine(mask: boolean): string {
|
|
125
|
+
if (mask) {
|
|
126
|
+
return readSensitiveLine();
|
|
127
|
+
}
|
|
128
|
+
return readStdinLine();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether this setting already has a non-empty value in the user's shell environment. */
|
|
132
|
+
function resolvedFromEnv(
|
|
133
|
+
entry: CliAppConfigEntry,
|
|
134
|
+
hostEnv: Record<string, string | undefined>,
|
|
135
|
+
): boolean {
|
|
136
|
+
if (!entry.env) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const val = hostEnv[entry.env];
|
|
140
|
+
return val !== undefined && val.length > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ask the user for one setting and return what they chose (or nothing if they skipped it). */
|
|
84
144
|
function promptConfigKey(
|
|
85
145
|
key: string,
|
|
86
146
|
entry: CliAppConfigEntry,
|
|
87
147
|
current: unknown,
|
|
88
148
|
configure: boolean,
|
|
89
149
|
jsonSchemaRequired: Set<string> | undefined,
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
hostEnv: Record<string, string | undefined>,
|
|
151
|
+
): { value: unknown; userTyped: boolean } {
|
|
152
|
+
const baseTitle = entry.title ?? defaultConfigEntryTitle(key);
|
|
153
|
+
const titleWithEnv = entry.env ? `${baseTitle} (${entry.env})` : baseTitle;
|
|
92
154
|
const required = configEntryRequired(key, entry, jsonSchemaRequired);
|
|
93
|
-
const heading = required || !configure ?
|
|
155
|
+
const heading = required || !configure ? titleWithEnv : `${titleWithEnv} (optional)`;
|
|
94
156
|
process.stderr.write(`${heading}\n`);
|
|
95
157
|
process.stderr.write(` ${entry.description}\n`);
|
|
96
158
|
const hasCurrent = current !== undefined && current !== null && String(current).length > 0;
|
|
159
|
+
const sensitive = configEntrySensitive(key, entry);
|
|
97
160
|
if (hasCurrent) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
161
|
+
process.stderr.write(` Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
|
|
162
|
+
const acceptPrompt = resolvedFromEnv(entry, hostEnv)
|
|
163
|
+
? ` Value (Enter to copy from env): `
|
|
164
|
+
: ` Value (Enter to keep): `;
|
|
165
|
+
process.stderr.write(acceptPrompt);
|
|
101
166
|
} else {
|
|
102
|
-
process.stderr.write(
|
|
167
|
+
process.stderr.write(` Value: `);
|
|
103
168
|
}
|
|
104
|
-
const input = readPromptLine(
|
|
169
|
+
const input = readPromptLine(sensitive);
|
|
105
170
|
if (input.length === 0 && hasCurrent) {
|
|
106
|
-
return current;
|
|
171
|
+
return { value: current, userTyped: false };
|
|
107
172
|
}
|
|
108
|
-
|
|
173
|
+
if (input.length > 0) {
|
|
174
|
+
return { value: input, userTyped: true };
|
|
175
|
+
}
|
|
176
|
+
return { value: undefined, userTyped: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Options for the interactive `install --configure` wizard. */
|
|
180
|
+
export interface RunInstallConfigureOpts {
|
|
181
|
+
/** Where the wizard was started from (standalone command vs right after install). */
|
|
182
|
+
context?: "standalone" | "after-install";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Print the "Configuration Setup" heading before the configure prompts. */
|
|
186
|
+
function writeConfigureSetupHeading(): void {
|
|
187
|
+
process.stderr.write("\nConfiguration Setup\n\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether this app has any settings worth prompting during configure. */
|
|
191
|
+
function shouldShowConfigureSetupHeading(program: CliProgram): boolean {
|
|
192
|
+
if (!program.appConfig) return false;
|
|
193
|
+
return Object.keys(program.appConfig.entries).length > 0;
|
|
109
194
|
}
|
|
110
195
|
|
|
196
|
+
/** Ask the user for each required setting that is still empty; returns updates to save to the config file. */
|
|
111
197
|
function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
112
198
|
const appConfig = program.appConfig;
|
|
113
199
|
const updates: Record<string, unknown> = {};
|
|
@@ -116,7 +202,9 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
|
116
202
|
}
|
|
117
203
|
const jsonSchema = effectiveJsonSchema(program);
|
|
118
204
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
205
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
119
206
|
const { resolved } = bootstrapAppConfig(program, { validateFile: false });
|
|
207
|
+
let headingWritten = false;
|
|
120
208
|
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
121
209
|
if (!configEntryRequired(key, entry, fromSchema)) {
|
|
122
210
|
continue;
|
|
@@ -125,7 +213,11 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
|
125
213
|
if (current !== undefined && current !== null && String(current).length > 0) {
|
|
126
214
|
continue;
|
|
127
215
|
}
|
|
128
|
-
|
|
216
|
+
if (!headingWritten) {
|
|
217
|
+
writeConfigureSetupHeading();
|
|
218
|
+
headingWritten = true;
|
|
219
|
+
}
|
|
220
|
+
const { value, userTyped } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
|
|
129
221
|
if (value !== undefined && String(value).length > 0) {
|
|
130
222
|
updates[key] = value;
|
|
131
223
|
}
|
|
@@ -133,8 +225,11 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
|
|
|
133
225
|
return updates;
|
|
134
226
|
}
|
|
135
227
|
|
|
136
|
-
/**
|
|
137
|
-
export function runInstallConfigure(
|
|
228
|
+
/** Run the full interactive configure wizard (`install --configure`). */
|
|
229
|
+
export function runInstallConfigure(
|
|
230
|
+
program: CliProgram,
|
|
231
|
+
_opts: RunInstallConfigureOpts = {},
|
|
232
|
+
): { path: string; changed: boolean } {
|
|
138
233
|
if (!program.appConfig) {
|
|
139
234
|
throw new Error("install --configure requires program.appConfig on the program root.");
|
|
140
235
|
}
|
|
@@ -151,17 +246,30 @@ export function runInstallConfigure(program: CliProgram): { path: string; change
|
|
|
151
246
|
|
|
152
247
|
const path = resolveAppConfigPath(program);
|
|
153
248
|
const existing = readAppConfigFileRaw(path);
|
|
249
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
154
250
|
const jsonSchema = effectiveJsonSchema(program);
|
|
155
251
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
156
|
-
const
|
|
252
|
+
const resolved = resolveAppConfig(program, existing, hostEnv);
|
|
157
253
|
const next: Record<string, unknown> = { ...existing };
|
|
158
254
|
let changed = false;
|
|
159
255
|
|
|
256
|
+
if (shouldShowConfigureSetupHeading(program)) {
|
|
257
|
+
writeConfigureSetupHeading();
|
|
258
|
+
}
|
|
259
|
+
|
|
160
260
|
for (const [key, entry] of Object.entries(program.appConfig.entries)) {
|
|
161
|
-
const current = resolved[key];
|
|
162
261
|
const before = next[key];
|
|
163
|
-
const
|
|
262
|
+
const current = resolved[key];
|
|
263
|
+
const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
|
|
164
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
|
+
}
|
|
165
273
|
if (JSON.stringify(value) !== JSON.stringify(before)) {
|
|
166
274
|
changed = true;
|
|
167
275
|
}
|
|
@@ -171,14 +279,14 @@ export function runInstallConfigure(program: CliProgram): { path: string; change
|
|
|
171
279
|
|
|
172
280
|
if (changed) {
|
|
173
281
|
writeAppConfigFile(program, next);
|
|
174
|
-
const updated = resolveAppConfig(program, next);
|
|
175
|
-
exportConfigToEnv(program, updated);
|
|
282
|
+
const updated = resolveAppConfig(program, next, hostEnv);
|
|
283
|
+
exportConfigToEnv(program, updated, hostEnv);
|
|
176
284
|
return { path, changed: true };
|
|
177
285
|
}
|
|
178
286
|
return { path, changed: false };
|
|
179
287
|
}
|
|
180
288
|
|
|
181
|
-
/**
|
|
289
|
+
/** Summary for `install --status`: config path, whether the file exists, and which required settings are set (never their values). */
|
|
182
290
|
export function appConfigStatus(program: CliProgram):
|
|
183
291
|
| {
|
|
184
292
|
path: string;
|
|
@@ -189,15 +297,16 @@ export function appConfigStatus(program: CliProgram):
|
|
|
189
297
|
if (!program.appConfig) {
|
|
190
298
|
return undefined;
|
|
191
299
|
}
|
|
192
|
-
const path =
|
|
300
|
+
const path = displayAppConfigPath(program);
|
|
193
301
|
let fileData: Record<string, unknown> = {};
|
|
194
302
|
try {
|
|
195
303
|
fileData = readAppConfigFile(program);
|
|
196
304
|
} catch {
|
|
197
|
-
fileData = readAppConfigFileRaw(
|
|
305
|
+
fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
198
306
|
}
|
|
199
|
-
const
|
|
200
|
-
|
|
307
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
308
|
+
const resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
309
|
+
exportConfigToEnv(program, resolved, hostEnv);
|
|
201
310
|
const jsonSchema = effectiveJsonSchema(program);
|
|
202
311
|
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
203
312
|
const required = Object.entries(program.appConfig.entries)
|
|
@@ -207,10 +316,10 @@ export function appConfigStatus(program: CliProgram):
|
|
|
207
316
|
set:
|
|
208
317
|
resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0,
|
|
209
318
|
}));
|
|
210
|
-
return { path, exists:
|
|
319
|
+
return { path, exists: appConfigInstalled(program), required };
|
|
211
320
|
}
|
|
212
321
|
|
|
213
|
-
/**
|
|
322
|
+
/** Load config at startup, optionally prompt the user, and fail if required settings are still missing. */
|
|
214
323
|
export function ensureAppConfig(
|
|
215
324
|
program: CliProgram,
|
|
216
325
|
opts: EnsureAppConfigOpts,
|
|
@@ -228,15 +337,16 @@ export function ensureAppConfig(
|
|
|
228
337
|
process.exit(1);
|
|
229
338
|
}
|
|
230
339
|
|
|
231
|
-
|
|
232
|
-
|
|
340
|
+
const hostEnv = captureMappedHostEnv(program);
|
|
341
|
+
let resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
342
|
+
exportConfigToEnv(program, resolved, hostEnv);
|
|
233
343
|
|
|
234
344
|
if (opts.interactive && process.stdin.isTTY) {
|
|
235
345
|
if (opts.configure) {
|
|
236
|
-
runInstallConfigure(program);
|
|
346
|
+
runInstallConfigure(program, { context: "standalone" });
|
|
237
347
|
fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
|
|
238
|
-
resolved = resolveAppConfig(program, fileData);
|
|
239
|
-
exportConfigToEnv(program, resolved);
|
|
348
|
+
resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
349
|
+
exportConfigToEnv(program, resolved, hostEnv);
|
|
240
350
|
return { fileData, resolved };
|
|
241
351
|
}
|
|
242
352
|
const updates = promptMissingRequired(program);
|
|
@@ -244,8 +354,8 @@ export function ensureAppConfig(
|
|
|
244
354
|
const merged = { ...fileData, ...updates };
|
|
245
355
|
writeAppConfigFile(program, merged);
|
|
246
356
|
fileData = merged;
|
|
247
|
-
resolved = resolveAppConfig(program, fileData);
|
|
248
|
-
exportConfigToEnv(program, resolved);
|
|
357
|
+
resolved = resolveAppConfig(program, fileData, hostEnv);
|
|
358
|
+
exportConfigToEnv(program, resolved, hostEnv);
|
|
249
359
|
}
|
|
250
360
|
}
|
|
251
361
|
|
|
@@ -261,5 +371,3 @@ export function ensureAppConfig(
|
|
|
261
371
|
|
|
262
372
|
return { fileData, resolved };
|
|
263
373
|
}
|
|
264
|
-
|
|
265
|
-
export { displayAppConfigPath };
|
|
@@ -4,33 +4,31 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import type { CliProgram } from "../types.ts";
|
|
6
6
|
import { createAppConfigSnapshot } from "./context.ts";
|
|
7
|
-
import { resolveAppConfigDir } from "./file.ts";
|
|
7
|
+
import { resolveAppConfigDir, resolveAppConfigPath } from "./file.ts";
|
|
8
8
|
import { resolveAppConfig } from "./resolve.ts";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
apiToken: { description: "Token.", env: "API_TOKEN" },
|
|
19
|
-
note: { description: "Note.", required: false },
|
|
20
|
-
},
|
|
10
|
+
const program: CliProgram = {
|
|
11
|
+
key: "ctx-test",
|
|
12
|
+
version: "1.0.0",
|
|
13
|
+
description: "Context test.",
|
|
14
|
+
appConfig: {
|
|
15
|
+
entries: {
|
|
16
|
+
apiToken: { description: "Token.", env: "API_TOKEN" },
|
|
17
|
+
note: { description: "Note.", required: false },
|
|
21
18
|
},
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
}
|
|
19
|
+
},
|
|
20
|
+
handler: () => {},
|
|
21
|
+
};
|
|
25
22
|
|
|
26
23
|
describe("config/context", () => {
|
|
27
24
|
test("AppConfigSnapshot get, require, read, set", () => {
|
|
28
25
|
const dir = mkdtempSync(join(tmpdir(), "ctx-test-"));
|
|
29
|
-
const
|
|
30
|
-
|
|
26
|
+
const prevHome = process.env.HOME;
|
|
27
|
+
process.env.HOME = dir;
|
|
31
28
|
const prevToken = process.env.API_TOKEN;
|
|
32
29
|
delete process.env.API_TOKEN;
|
|
33
30
|
try {
|
|
31
|
+
const path = resolveAppConfigPath(program);
|
|
34
32
|
const fileData = { apiToken: "tok", note: "hello" };
|
|
35
33
|
const resolved = resolveAppConfig(program, fileData);
|
|
36
34
|
const ctx = createAppConfigSnapshot(program, fileData, resolved);
|
|
@@ -44,6 +42,8 @@ describe("config/context", () => {
|
|
|
44
42
|
expect(ctx.get("note")).toBe("updated");
|
|
45
43
|
expect(ctx.read().note).toBe("updated");
|
|
46
44
|
} finally {
|
|
45
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
46
|
+
else process.env.HOME = prevHome;
|
|
47
47
|
if (prevToken === undefined) delete process.env.API_TOKEN;
|
|
48
48
|
else process.env.API_TOKEN = prevToken;
|
|
49
49
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -51,39 +51,25 @@ describe("config/context", () => {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
test("EmptyAppConfigSnapshot when program.appConfig unset", () => {
|
|
54
|
-
const
|
|
54
|
+
const programWithoutConfig: CliProgram = {
|
|
55
55
|
key: "x",
|
|
56
56
|
version: "1.0.0",
|
|
57
57
|
description: "No config.",
|
|
58
58
|
handler: () => {},
|
|
59
59
|
};
|
|
60
|
-
const empty = createAppConfigSnapshot(
|
|
60
|
+
const empty = createAppConfigSnapshot(programWithoutConfig, {}, {});
|
|
61
61
|
expect(empty.get("any")).toBeUndefined();
|
|
62
62
|
expect(() => empty.set("any", "v")).toThrow(/program.appConfig is not set/);
|
|
63
63
|
expect(empty.path).toContain("x");
|
|
64
|
-
expect(empty.path.endsWith("/config") || empty.path.endsWith("\\config")).toBe(true);
|
|
64
|
+
expect(empty.path.endsWith("/config.json") || empty.path.endsWith("\\config.json")).toBe(true);
|
|
65
65
|
expect(empty.dir).toBe(dirname(empty.path));
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
test("AppConfigSnapshot path uses OS default
|
|
69
|
-
const program: CliProgram = {
|
|
70
|
-
key: "ctx-test",
|
|
71
|
-
version: "1.0.0",
|
|
72
|
-
description: "Context test.",
|
|
73
|
-
appConfig: {
|
|
74
|
-
entries: { note: { description: "Note." } },
|
|
75
|
-
},
|
|
76
|
-
handler: () => {},
|
|
77
|
-
};
|
|
68
|
+
test("AppConfigSnapshot path uses OS default from program key", () => {
|
|
78
69
|
const ctx = createAppConfigSnapshot(program, {}, {});
|
|
79
70
|
expect(ctx.path).toContain("ctx_test");
|
|
80
|
-
expect(ctx.path.endsWith("/config") || ctx.path.endsWith("\\config")).toBe(true);
|
|
71
|
+
expect(ctx.path.endsWith("/config.json") || ctx.path.endsWith("\\config.json")).toBe(true);
|
|
81
72
|
expect(ctx.dir).toBe(resolveAppConfigDir(program));
|
|
82
73
|
expect(ctx.dir).toBe(dirname(ctx.path));
|
|
83
74
|
});
|
|
84
|
-
|
|
85
|
-
test("resolveAppConfigDir honors custom program.appConfig.path", () => {
|
|
86
|
-
const program = configProgram("/tmp/custom/settings.json");
|
|
87
|
-
expect(resolveAppConfigDir(program)).toBe("/tmp/custom");
|
|
88
|
-
});
|
|
89
75
|
});
|
package/src/config/context.ts
CHANGED
|
@@ -5,7 +5,7 @@ Handler-facing resolved app config snapshot (ctx.appConfig).
|
|
|
5
5
|
import type { CliProgram } from "../types.ts";
|
|
6
6
|
import { resolveAppConfigDir, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
|
|
7
7
|
import type { ResolvedConfig } from "./resolve.ts";
|
|
8
|
-
import { exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
|
|
8
|
+
import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
|
|
9
9
|
|
|
10
10
|
/** Empty snapshot when program.appConfig is not set. */
|
|
11
11
|
export class EmptyAppConfigSnapshot {
|
|
@@ -71,18 +71,19 @@ export class AppConfigSnapshot {
|
|
|
71
71
|
|
|
72
72
|
set(key: string, value: unknown): void {
|
|
73
73
|
this.assertEntryKey(key);
|
|
74
|
+
const hostEnv = captureMappedHostEnv(this.program);
|
|
74
75
|
const next = { ...this.fileData, [key]: value };
|
|
75
76
|
writeAppConfigFile(this.program, next);
|
|
76
77
|
this.fileData = next;
|
|
77
|
-
this.snapshot = resolveAppConfig(this.program, next);
|
|
78
|
-
exportConfigToEnv(this.program, this.snapshot);
|
|
78
|
+
this.snapshot = resolveAppConfig(this.program, next, hostEnv);
|
|
79
|
+
exportConfigToEnv(this.program, this.snapshot, hostEnv);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
read(): ResolvedConfig {
|
|
82
83
|
return { ...this.snapshot };
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
/** Resolved absolute path to the app JSON config file (
|
|
86
|
+
/** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
|
|
86
87
|
get path(): string {
|
|
87
88
|
return resolveAppConfigPath(this.program);
|
|
88
89
|
}
|