argsbarg 5.1.8 → 5.1.10

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/docs/cli-program.md +3 -1
  3. package/docs/config-schema.md +5 -3
  4. package/docs/configure.md +13 -7
  5. package/docs/distribution-homebrew.md +17 -3
  6. package/examples/full-example/justfile +6 -5
  7. package/examples/full-example/scripts/formula-shared.test.ts +68 -0
  8. package/examples/full-example/scripts/formula-shared.ts +39 -1
  9. package/examples/full-example/scripts/release.ts +212 -0
  10. package/index.d.ts +10 -1
  11. package/package.json +2 -1
  12. package/src/builtins/builtins.test.ts +1 -3
  13. package/src/builtins/completion-bash.ts +6 -27
  14. package/src/builtins/completion-group.ts +1 -2
  15. package/src/builtins/completion-simulate-shared.ts +2 -10
  16. package/src/builtins/completion-zsh.ts +7 -33
  17. package/src/builtins/config.test.ts +28 -0
  18. package/src/builtins/config.ts +40 -10
  19. package/src/builtins/configure-copy.ts +3 -6
  20. package/src/builtins/dispatch.ts +2 -10
  21. package/src/builtins/export.ts +1 -3
  22. package/src/builtins/presentation.ts +2 -7
  23. package/src/builtins/registry.ts +1 -5
  24. package/src/cli-tool/create.test.ts +3 -6
  25. package/src/cli-tool/create.ts +5 -22
  26. package/src/cli-tool/post-create.ts +1 -3
  27. package/src/cli-tool/program.ts +1 -2
  28. package/src/cli-tool/run-create.ts +2 -8
  29. package/src/cli.ts +6 -27
  30. package/src/config/bindings.test.ts +72 -0
  31. package/src/config/bindings.ts +115 -0
  32. package/src/config/bootstrap.ts +80 -65
  33. package/src/config/context.test.ts +45 -0
  34. package/src/config/context.ts +74 -14
  35. package/src/config/entry.ts +1 -3
  36. package/src/config/file.test.ts +46 -3
  37. package/src/config/file.ts +59 -19
  38. package/src/config/resolve.ts +1 -4
  39. package/src/config/schema.ts +1 -3
  40. package/src/config/validate.test.ts +3 -11
  41. package/src/config/validate.ts +30 -30
  42. package/src/config.integration.test.ts +1 -5
  43. package/src/configure/configure.test.ts +19 -5
  44. package/src/configure/index.ts +17 -15
  45. package/src/docs/api-guide.ts +1 -6
  46. package/src/docs/builtin.ts +1 -5
  47. package/src/docs/docs.test.ts +2 -6
  48. package/src/docs/mcp-guide.ts +3 -13
  49. package/src/docs/mcp-resources.test.ts +1 -3
  50. package/src/docs/mcp-resources.ts +1 -6
  51. package/src/docs/save.ts +2 -10
  52. package/src/formats.test.ts +1 -7
  53. package/src/formats.ts +1 -5
  54. package/src/headless.test.ts +5 -17
  55. package/src/help.ts +9 -51
  56. package/src/hidden-mcpb.test.ts +3 -12
  57. package/src/hidden.ts +1 -3
  58. package/src/install/binary-placement.test.ts +2 -6
  59. package/src/install/binary-placement.ts +1 -4
  60. package/src/install/gh-release-update.ts +5 -22
  61. package/src/install/mcp-codex.ts +1 -2
  62. package/src/install/mcp-config.ts +2 -12
  63. package/src/install/mcp-opencode.test.ts +1 -3
  64. package/src/install/mcp-opencode.ts +3 -15
  65. package/src/install/plan.ts +2 -10
  66. package/src/install/status.ts +3 -5
  67. package/src/install/target-base.ts +2 -11
  68. package/src/install/target-detect.ts +1 -5
  69. package/src/install/target-effective.ts +3 -13
  70. package/src/install/target-mcp-cli.ts +4 -26
  71. package/src/install/target-mcp-json.ts +3 -14
  72. package/src/install/target-plan-build.ts +1 -5
  73. package/src/install/target-registry.ts +13 -21
  74. package/src/install/target-scope.ts +8 -25
  75. package/src/install/target-types.ts +1 -6
  76. package/src/install/targets/app.ts +2 -2
  77. package/src/install/targets/opencode-mcp.ts +1 -6
  78. package/src/install/targets.test.ts +3 -10
  79. package/src/install/uninstall.ts +2 -9
  80. package/src/invoke.test.ts +1 -3
  81. package/src/mcp/bundle.ts +4 -19
  82. package/src/mcp/claude.test.ts +3 -3
  83. package/src/mcp/claude.ts +4 -17
  84. package/src/mcp/env.ts +1 -3
  85. package/src/mcp/server.ts +3 -12
  86. package/src/mcp/tools.ts +2 -11
  87. package/src/mcp.integration.test.ts +4 -10
  88. package/src/parse.test.ts +9 -34
  89. package/src/parse.ts +10 -44
  90. package/src/schema.ts +2 -10
  91. package/src/skill/hint.ts +1 -5
  92. package/src/skill/install.ts +1 -5
  93. package/src/test-fixtures.ts +1 -3
  94. package/src/validate.ts +29 -90
@@ -0,0 +1,72 @@
1
+ /*
2
+ Tests for config/bindings module.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { CliAppConfigEntry } from "../types.ts";
7
+ import {
8
+ bindingForKey,
9
+ CONFIG_BINDINGS_KEY,
10
+ clearBinding,
11
+ clearFileValue,
12
+ isKeyAddressed,
13
+ readBindings,
14
+ setBinding,
15
+ } from "./bindings.ts";
16
+
17
+ const optionalEntry: CliAppConfigEntry = { description: "Optional.", required: false };
18
+ const requiredEntry: CliAppConfigEntry = { description: "Required.", env: "API_TOKEN" };
19
+
20
+ describe("config/bindings", () => {
21
+ test("readBindings returns empty when absent", () => {
22
+ expect(readBindings({})).toEqual({});
23
+ });
24
+
25
+ test("setBinding and readBindings round-trip", () => {
26
+ const next = setBinding({}, "apiToken", "env");
27
+ expect(readBindings(next)).toEqual({ apiToken: "env" });
28
+ expect(next[CONFIG_BINDINGS_KEY]).toEqual({ apiToken: "env" });
29
+ });
30
+
31
+ test("clearBinding removes entry", () => {
32
+ const withBinding = setBinding({}, "apiToken", "env");
33
+ const cleared = clearBinding(withBinding, "apiToken");
34
+ expect(readBindings(cleared)).toEqual({});
35
+ expect(CONFIG_BINDINGS_KEY in cleared).toBe(false);
36
+ });
37
+
38
+ test("clearFileValue removes literal", () => {
39
+ expect(clearFileValue({ apiToken: "x" }, "apiToken")).toEqual({});
40
+ });
41
+
42
+ test("isKeyAddressed with binding env", () => {
43
+ const data = setBinding({}, "apiToken", "env");
44
+ expect(isKeyAddressed("apiToken", data, requiredEntry)).toBe(true);
45
+ });
46
+
47
+ test("isKeyAddressed with literal file value", () => {
48
+ expect(isKeyAddressed("apiToken", { apiToken: "tok" }, requiredEntry)).toBe(true);
49
+ });
50
+
51
+ test("isKeyAddressed false when unaddressed", () => {
52
+ expect(isKeyAddressed("apiToken", {}, requiredEntry)).toBe(false);
53
+ });
54
+
55
+ test("isKeyAddressed skip binding", () => {
56
+ const data = setBinding({}, "note", "skip");
57
+ expect(isKeyAddressed("note", data, optionalEntry)).toBe(true);
58
+ });
59
+
60
+ test("bindingForKey prefers explicit binding", () => {
61
+ const data = setBinding({}, "apiToken", "env");
62
+ expect(bindingForKey("apiToken", data, true)).toBe("env");
63
+ });
64
+
65
+ test("bindingForKey infers file from literal", () => {
66
+ expect(bindingForKey("apiToken", { apiToken: "x" }, true)).toBe("file");
67
+ });
68
+
69
+ test("bindingForKey missing when unset", () => {
70
+ expect(bindingForKey("apiToken", {}, false)).toBe("missing");
71
+ });
72
+ });
@@ -0,0 +1,115 @@
1
+ /*
2
+ Per-key config binding metadata (`_bindings`) — records how each schema key is satisfied.
3
+ */
4
+
5
+ import type { CliAppConfigEntry } from "../types.ts";
6
+
7
+ /** Reserved top-level config file key for per-entry binding metadata. */
8
+ export const CONFIG_BINDINGS_KEY = "_bindings";
9
+
10
+ export type ConfigBinding = "env" | "file" | "skip";
11
+
12
+ const BINDING_VALUES = new Set<string>(["env", "file", "skip"]);
13
+
14
+ /** True for argsbarg-reserved top-level keys (e.g. `_bindings`). */
15
+ export function isFrameworkConfigKey(key: string): boolean {
16
+ return key.startsWith("_");
17
+ }
18
+
19
+ function isPresent(value: unknown): boolean {
20
+ if (value === undefined || value === null) return false;
21
+ if (typeof value === "string" && value.length === 0) return false;
22
+ return true;
23
+ }
24
+
25
+ /** Read `_bindings` from raw file data; returns `{}` when absent or invalid. */
26
+ export function readBindings(fileData: Record<string, unknown>): Record<string, ConfigBinding> {
27
+ const raw = fileData[CONFIG_BINDINGS_KEY];
28
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
29
+ return {};
30
+ }
31
+ const out: Record<string, ConfigBinding> = {};
32
+ for (const [key, val] of Object.entries(raw)) {
33
+ if (typeof val === "string" && BINDING_VALUES.has(val)) {
34
+ out[key] = val as ConfigBinding;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Validate `_bindings` shape when present. */
41
+ export function validateBindingsShape(fileData: Record<string, unknown>, pathPrefix = "$"): string[] {
42
+ const raw = fileData[CONFIG_BINDINGS_KEY];
43
+ if (raw === undefined) return [];
44
+ const errors: string[] = [];
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
46
+ errors.push(`${pathPrefix}.${CONFIG_BINDINGS_KEY}: must be object`);
47
+ return errors;
48
+ }
49
+ for (const [key, val] of Object.entries(raw)) {
50
+ if (typeof val !== "string" || !BINDING_VALUES.has(val)) {
51
+ errors.push(`${pathPrefix}.${CONFIG_BINDINGS_KEY}.${key}: must be "env", "file", or "skip"`);
52
+ }
53
+ }
54
+ return errors;
55
+ }
56
+
57
+ /** Return a new file object with an updated binding for `key`. */
58
+ export function setBinding(
59
+ fileData: Record<string, unknown>,
60
+ key: string,
61
+ binding: ConfigBinding,
62
+ ): Record<string, unknown> {
63
+ const bindings = { ...readBindings(fileData), [key]: binding };
64
+ return { ...fileData, [CONFIG_BINDINGS_KEY]: bindings };
65
+ }
66
+
67
+ /** Return a new file object without a binding for `key`. */
68
+ export function clearBinding(fileData: Record<string, unknown>, key: string): Record<string, unknown> {
69
+ const bindings = readBindings(fileData);
70
+ if (!(key in bindings)) {
71
+ return fileData;
72
+ }
73
+ const nextBindings = { ...bindings };
74
+ delete nextBindings[key];
75
+ const next = { ...fileData };
76
+ if (Object.keys(nextBindings).length === 0) {
77
+ delete next[CONFIG_BINDINGS_KEY];
78
+ } else {
79
+ next[CONFIG_BINDINGS_KEY] = nextBindings;
80
+ }
81
+ return next;
82
+ }
83
+
84
+ /** Remove a literal value from file data (used when binding to env). */
85
+ export function clearFileValue(fileData: Record<string, unknown>, key: string): Record<string, unknown> {
86
+ if (!(key in fileData)) {
87
+ return fileData;
88
+ }
89
+ const next = { ...fileData };
90
+ delete next[key];
91
+ return next;
92
+ }
93
+
94
+ /** Whether the user already addressed this key (skip wizard re-prompt). */
95
+ export function isKeyAddressed(key: string, fileData: Record<string, unknown>, _entry: CliAppConfigEntry): boolean {
96
+ const bindings = readBindings(fileData);
97
+ if (bindings[key] === "skip" || bindings[key] === "env" || bindings[key] === "file") {
98
+ return true;
99
+ }
100
+ return isPresent(fileData[key]);
101
+ }
102
+
103
+ /** Binding for status display; `missing` when unset. */
104
+ export function bindingForKey(
105
+ key: string,
106
+ fileData: Record<string, unknown>,
107
+ resolvedPresent: boolean,
108
+ ): ConfigBinding | "missing" {
109
+ const bindings = readBindings(fileData);
110
+ const b = bindings[key];
111
+ if (b) return b;
112
+ if (isPresent(fileData[key])) return "file";
113
+ if (resolvedPresent) return "env";
114
+ return "missing";
115
+ }
@@ -6,14 +6,10 @@ settings are present before the CLI or MCP server handles a request.
6
6
  import { readSync } from "node:fs";
7
7
  import { readPromptLine as readStdinLine } from "../prompt.ts";
8
8
  import type { CliAppConfigEntry, CliProgram } from "../types.ts";
9
+ import { bindingForKey, clearFileValue, isKeyAddressed, readBindings, setBinding } from "./bindings.ts";
10
+ import { configEntryRequired, configEntrySensitive, defaultConfigEntryTitle, jsonSchemaRequiredKeys } from "./entry.ts";
9
11
  import {
10
- configEntryRequired,
11
- configEntrySensitive,
12
- defaultConfigEntryTitle,
13
- jsonSchemaRequiredKeys,
14
- } from "./entry.ts";
15
- import {
16
- appConfigInstalled,
12
+ appConfigFileExists,
17
13
  displayAppConfigPath,
18
14
  readAppConfigFile,
19
15
  readAppConfigFileRaw,
@@ -53,13 +49,8 @@ export interface ConfigBootstrapResult {
53
49
  }
54
50
 
55
51
  /** Read the config file, merge env overrides, and export mapped values into `process.env`. */
56
- export function bootstrapAppConfig(
57
- program: CliProgram,
58
- opts: { validateFile: boolean },
59
- ): ConfigBootstrapResult {
60
- const fileData = opts.validateFile
61
- ? readAppConfigFile(program)
62
- : readAppConfigFileRaw(resolveAppConfigPath(program));
52
+ export function bootstrapAppConfig(program: CliProgram, opts: { validateFile: boolean }): ConfigBootstrapResult {
53
+ const fileData = opts.validateFile ? readAppConfigFile(program) : readAppConfigFileRaw(resolveAppConfigPath(program));
63
54
  const hostEnv = captureMappedHostEnv(program);
64
55
  const resolved = resolveAppConfig(program, fileData, hostEnv);
65
56
  exportConfigToEnv(program, resolved, hostEnv);
@@ -88,7 +79,6 @@ function readSensitiveLine(): string {
88
79
  }
89
80
  const byte = buf[0];
90
81
  if (byte === 3) {
91
- // Raw mode delivers Ctrl+C as ETX instead of SIGINT.
92
82
  process.stderr.write("\n");
93
83
  process.exit(130);
94
84
  }
@@ -130,10 +120,7 @@ function readPromptLine(mask: boolean): string {
130
120
  }
131
121
 
132
122
  /** Whether this setting already has a non-empty value in the user's shell environment. */
133
- function resolvedFromEnv(
134
- entry: CliAppConfigEntry,
135
- hostEnv: Record<string, string | undefined>,
136
- ): boolean {
123
+ function resolvedFromEnv(entry: CliAppConfigEntry, hostEnv: Record<string, string | undefined>): boolean {
137
124
  if (!entry.env) {
138
125
  return false;
139
126
  }
@@ -165,7 +152,7 @@ function promptConfigKey(
165
152
  if (hasCurrent) {
166
153
  process.stderr.write(` Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
167
154
  const acceptPrompt = resolvedFromEnv(entry, hostEnv)
168
- ? ` Value (Enter to copy from env)${valueHintSuffix}: `
155
+ ? ` Value (Enter to use env)${valueHintSuffix}: `
169
156
  : ` Value (Enter to keep)${valueHintSuffix}: `;
170
157
  process.stderr.write(acceptPrompt);
171
158
  } else {
@@ -202,6 +189,16 @@ function shouldShowConfigureSetupHeading(program: CliProgram): boolean {
202
189
  return Object.keys(program.appConfig.entries).length > 0;
203
190
  }
204
191
 
192
+ function isPresent(value: unknown): boolean {
193
+ if (value === undefined || value === null) return false;
194
+ if (typeof value === "string" && value.length === 0) return false;
195
+ return true;
196
+ }
197
+
198
+ function bindingsDiffer(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
199
+ return JSON.stringify(readBindings(a)) !== JSON.stringify(readBindings(b));
200
+ }
201
+
205
202
  /** Ask the user for each required setting that is still empty; returns updates to save to the config file. */
206
203
  function promptMissingRequired(program: CliProgram): Record<string, unknown> {
207
204
  const appConfig = program.appConfig;
@@ -212,31 +209,29 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
212
209
  const jsonSchema = effectiveJsonSchema(program);
213
210
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
214
211
  const hostEnv = captureMappedHostEnv(program);
215
- const { resolved } = bootstrapAppConfig(program, { validateFile: false });
212
+ const path = resolveAppConfigPath(program);
213
+ const fileData = readAppConfigFileRaw(path);
214
+ const resolved = resolveAppConfig(program, fileData, hostEnv);
216
215
  let headingWritten = false;
217
216
  for (const [key, entry] of Object.entries(appConfig.entries)) {
218
217
  if (!configEntryRequired(key, entry, fromSchema)) {
219
218
  continue;
220
219
  }
221
- const current = resolved[key];
222
- if (current !== undefined && current !== null && String(current).length > 0) {
220
+ const bindings = readBindings(fileData);
221
+ if (bindings[key] === "env" && !isPresent(resolved[key])) {
222
+ // Re-prompt when env binding is broken.
223
+ } else if (isKeyAddressed(key, fileData, entry) && isPresent(resolved[key])) {
223
224
  continue;
224
225
  }
226
+ const current = resolved[key];
225
227
  if (!headingWritten) {
226
228
  writeConfigureSetupHeading();
227
229
  headingWritten = true;
228
230
  }
229
- const { value, userTyped } = promptConfigKey(
230
- key,
231
- entry,
232
- undefined,
233
- false,
234
- fromSchema,
235
- hostEnv,
236
- jsonSchema,
237
- );
238
- if (value !== undefined && String(value).length > 0) {
231
+ const { value, userTyped } = promptConfigKey(key, entry, current, false, fromSchema, hostEnv, jsonSchema);
232
+ if (userTyped && value !== undefined && String(value).length > 0) {
239
233
  updates[key] = value;
234
+ Object.assign(updates, setBinding(updates, key, "file"));
240
235
  }
241
236
  }
242
237
  return updates;
@@ -267,7 +262,7 @@ export function runConfigure(
267
262
  const jsonSchema = effectiveJsonSchema(program);
268
263
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
269
264
  const resolved = resolveAppConfig(program, existing, hostEnv);
270
- const next: Record<string, unknown> = { ...existing };
265
+ let next: Record<string, unknown> = { ...existing };
271
266
  let changed = false;
272
267
 
273
268
  if (opts.showHeading !== false && shouldShowConfigureSetupHeading(program)) {
@@ -275,35 +270,51 @@ export function runConfigure(
275
270
  }
276
271
 
277
272
  for (const [key, entry] of Object.entries(program.appConfig.entries)) {
273
+ const bindings = readBindings(existing);
274
+ if (bindings[key] === "env" && !isPresent(resolved[key])) {
275
+ // Re-prompt when env binding is broken.
276
+ } else if (isKeyAddressed(key, existing, entry)) {
277
+ continue;
278
+ }
279
+
278
280
  const before = next[key];
281
+ const bindingsBefore = readBindings(next);
279
282
  const current = resolved[key];
280
- const { value, userTyped } = promptConfigKey(
281
- key,
282
- entry,
283
- current,
284
- true,
285
- fromSchema,
286
- hostEnv,
287
- jsonSchema,
288
- );
289
- if (value !== undefined && String(value).length > 0) {
283
+ const required = configEntryRequired(key, entry, fromSchema);
284
+ const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv, jsonSchema);
285
+
286
+ if (userTyped && value !== undefined && String(value).length > 0) {
287
+ if (JSON.stringify(value) !== JSON.stringify(before) || bindingsBefore[key] !== "file") {
288
+ changed = true;
289
+ }
290
+ next = setBinding({ ...next, [key]: value }, key, "file");
291
+ continue;
292
+ }
293
+
294
+ if (!userTyped && isPresent(current) && resolvedFromEnv(entry, hostEnv)) {
290
295
  const storedInFile =
291
- key in existing &&
292
- existing[key] !== undefined &&
293
- existing[key] !== null &&
294
- String(existing[key]).length > 0;
295
- if (!userTyped && !storedInFile) {
296
- continue;
296
+ key in existing && existing[key] !== undefined && existing[key] !== null && String(existing[key]).length > 0;
297
+ if (!storedInFile) {
298
+ const withBinding = setBinding(clearFileValue(next, key), key, "env");
299
+ if (bindingsDiffer(next, withBinding) || key in next) {
300
+ changed = true;
301
+ }
302
+ next = withBinding;
297
303
  }
298
- if (JSON.stringify(value) !== JSON.stringify(before)) {
304
+ continue;
305
+ }
306
+
307
+ if (!userTyped && !required && !isPresent(current) && inputWasSkipped(value, userTyped)) {
308
+ const withBinding = setBinding(next, key, "skip");
309
+ if (bindingsDiffer(next, withBinding)) {
299
310
  changed = true;
300
311
  }
301
- next[key] = value;
312
+ next = withBinding;
302
313
  }
303
314
  }
304
315
 
305
316
  if (changed) {
306
- writeAppConfigFile(program, next);
317
+ writeAppConfigFile(program, next, { partial: true });
307
318
  const updated = resolveAppConfig(program, next, hostEnv);
308
319
  exportConfigToEnv(program, updated, hostEnv);
309
320
  return { path, changed: true };
@@ -311,12 +322,16 @@ export function runConfigure(
311
322
  return { path, changed: false };
312
323
  }
313
324
 
325
+ function inputWasSkipped(value: unknown, userTyped: boolean): boolean {
326
+ return !userTyped && (value === undefined || String(value).length === 0);
327
+ }
328
+
314
329
  /** Summary for `configure --status`: config path, whether the file exists, and which required settings are set (never their values). */
315
330
  export function appConfigStatus(program: CliProgram):
316
331
  | {
317
332
  path: string;
318
333
  exists: boolean;
319
- required: Array<{ key: string; set: boolean }>;
334
+ required: Array<{ key: string; set: boolean; binding?: "env" | "file" | "skip" | "missing" }>;
320
335
  }
321
336
  | undefined {
322
337
  if (!program.appConfig) {
@@ -336,19 +351,19 @@ export function appConfigStatus(program: CliProgram):
336
351
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
337
352
  const required = Object.entries(program.appConfig.entries)
338
353
  .filter(([key, entry]) => configEntryRequired(key, entry, fromSchema))
339
- .map(([key]) => ({
340
- key,
341
- set:
342
- resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0,
343
- }));
344
- return { path, exists: appConfigInstalled(program), required };
354
+ .map(([key]) => {
355
+ const set = resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0;
356
+ return {
357
+ key,
358
+ set,
359
+ binding: bindingForKey(key, fileData, set),
360
+ };
361
+ });
362
+ return { path, exists: appConfigFileExists(program), required };
345
363
  }
346
364
 
347
365
  /** Load config at startup, optionally prompt the user, and fail if required settings are still missing. */
348
- export function ensureAppConfig(
349
- program: CliProgram,
350
- opts: EnsureAppConfigOpts,
351
- ): ConfigBootstrapResult | undefined {
366
+ export function ensureAppConfig(program: CliProgram, opts: EnsureAppConfigOpts): ConfigBootstrapResult | undefined {
352
367
  if (!program.appConfig) {
353
368
  return undefined;
354
369
  }
@@ -377,7 +392,7 @@ export function ensureAppConfig(
377
392
  const updates = promptMissingRequired(program);
378
393
  if (Object.keys(updates).length > 0) {
379
394
  const merged = { ...fileData, ...updates };
380
- writeAppConfigFile(program, merged);
395
+ writeAppConfigFile(program, merged, { partial: true });
381
396
  fileData = merged;
382
397
  resolved = resolveAppConfig(program, fileData, hostEnv);
383
398
  exportConfigToEnv(program, resolved, hostEnv);
@@ -79,4 +79,49 @@ describe("config/context", () => {
79
79
  expect(ctx.dir).toBe(resolveAppConfigDir(program));
80
80
  expect(ctx.dir).toBe(dirname(ctx.path));
81
81
  });
82
+
83
+ test("AppConfigSnapshot unsafe read/write", () => {
84
+ const dir = mkdtempSync(join(tmpdir(), "ctx-unsafe-"));
85
+ const prevHome = process.env.HOME;
86
+ process.env.HOME = dir;
87
+ const prevToken = process.env.API_TOKEN;
88
+ delete process.env.API_TOKEN;
89
+ try {
90
+ const fileData = { apiToken: "tok" };
91
+ const resolved = resolveAppConfig(program, fileData);
92
+ const ctx = createAppConfigSnapshot(program, fileData, resolved);
93
+ expect(ctx.getUnsafe("apiToken")).toBe("tok");
94
+ expect(ctx.readUnsafe().apiToken).toBe("tok");
95
+ ctx.setUnsafe("note", "raw");
96
+ expect(ctx.getUnsafe("note")).toBe("raw");
97
+ } finally {
98
+ if (prevHome === undefined) delete process.env.HOME;
99
+ else process.env.HOME = prevHome;
100
+ if (prevToken === undefined) delete process.env.API_TOKEN;
101
+ else process.env.API_TOKEN = prevToken;
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test("EmptyAppConfigSnapshot unsafe read/write", () => {
107
+ const dir = mkdtempSync(join(tmpdir(), "ctx-empty-unsafe-"));
108
+ const prevHome = process.env.HOME;
109
+ process.env.HOME = dir;
110
+ try {
111
+ const programWithoutConfig: CliProgram = {
112
+ key: "rawapp",
113
+ version: "1.0.0",
114
+ description: "No config.",
115
+ handler: () => {},
116
+ };
117
+ const empty = createAppConfigSnapshot(programWithoutConfig, {}, {});
118
+ empty.setUnsafe("custom", 42);
119
+ expect(empty.getUnsafe("custom")).toBe(42);
120
+ expect(empty.readUnsafe().custom).toBe(42);
121
+ } finally {
122
+ if (prevHome === undefined) delete process.env.HOME;
123
+ else process.env.HOME = prevHome;
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ });
82
127
  });
@@ -3,13 +3,36 @@ Handler-facing resolved app config snapshot (ctx.appConfig).
3
3
  */
4
4
 
5
5
  import type { CliProgram } from "../types.ts";
6
- import { resolveAppConfigDir, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
6
+ import { isFrameworkConfigKey, setBinding } from "./bindings.ts";
7
+ import {
8
+ readAppConfigFileRaw,
9
+ resolveAppConfigDir,
10
+ resolveAppConfigPath,
11
+ writeAppConfigFile,
12
+ writeAppConfigFileRaw,
13
+ } from "./file.ts";
7
14
  import type { ResolvedConfig } from "./resolve.ts";
8
15
  import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
16
+ import { configPropertySchema, effectiveJsonSchema } from "./schema.ts";
17
+ import { validateParsedConfigValue } from "./validate.ts";
18
+
19
+ function rebuildResolved(program: CliProgram, fileData: Record<string, unknown>): ResolvedConfig {
20
+ const hostEnv = captureMappedHostEnv(program);
21
+ const resolved = resolveAppConfig(program, fileData, hostEnv);
22
+ exportConfigToEnv(program, resolved, hostEnv);
23
+ return resolved;
24
+ }
9
25
 
10
26
  /** Empty snapshot when program.appConfig is not set. */
11
27
  export class EmptyAppConfigSnapshot {
12
- constructor(private readonly program: CliProgram) {}
28
+ private fileData: Record<string, unknown>;
29
+
30
+ constructor(
31
+ private readonly program: CliProgram,
32
+ fileData?: Record<string, unknown>,
33
+ ) {
34
+ this.fileData = fileData ?? readAppConfigFileRaw(resolveAppConfigPath(program));
35
+ }
13
36
 
14
37
  get(_key: string): undefined {
15
38
  return undefined;
@@ -27,6 +50,20 @@ export class EmptyAppConfigSnapshot {
27
50
  return {};
28
51
  }
29
52
 
53
+ readUnsafe(): Record<string, unknown> {
54
+ return { ...this.fileData };
55
+ }
56
+
57
+ getUnsafe(key: string): unknown {
58
+ return this.fileData[key];
59
+ }
60
+
61
+ setUnsafe(key: string, value: unknown): void {
62
+ const next = { ...this.fileData, [key]: value };
63
+ writeAppConfigFileRaw(this.program, next);
64
+ this.fileData = next;
65
+ }
66
+
30
67
  /** Resolved absolute path to the app JSON config file (OS default from `program.key`). */
31
68
  get path(): string {
32
69
  return resolveAppConfigPath(this.program);
@@ -59,11 +96,7 @@ export class AppConfigSnapshot {
59
96
 
60
97
  require(key: string): unknown {
61
98
  const value = this.get(key);
62
- if (
63
- value === undefined ||
64
- value === null ||
65
- (typeof value === "string" && value.length === 0)
66
- ) {
99
+ if (value === undefined || value === null || (typeof value === "string" && value.length === 0)) {
67
100
  throw new Error(`Missing required configuration: ${key}`);
68
101
  }
69
102
  return value;
@@ -71,18 +104,34 @@ export class AppConfigSnapshot {
71
104
 
72
105
  set(key: string, value: unknown): void {
73
106
  this.assertEntryKey(key);
74
- const hostEnv = captureMappedHostEnv(this.program);
75
- const next = { ...this.fileData, [key]: value };
76
- writeAppConfigFile(this.program, next);
77
- this.fileData = next;
78
- this.snapshot = resolveAppConfig(this.program, next, hostEnv);
79
- exportConfigToEnv(this.program, this.snapshot, hostEnv);
107
+ const jsonSchema = effectiveJsonSchema(this.program);
108
+ if (!jsonSchema) {
109
+ throw new Error("Internal error: missing effective jsonSchema.");
110
+ }
111
+ const propSchema = configPropertySchema(jsonSchema, key);
112
+ validateParsedConfigValue(value, propSchema, jsonSchema);
113
+ const next = setBinding({ ...this.fileData, [key]: value }, key, "file");
114
+ this.persistFileData(next);
80
115
  }
81
116
 
82
117
  read(): ResolvedConfig {
83
118
  return { ...this.snapshot };
84
119
  }
85
120
 
121
+ readUnsafe(): Record<string, unknown> {
122
+ return { ...this.fileData };
123
+ }
124
+
125
+ getUnsafe(key: string): unknown {
126
+ return this.fileData[key];
127
+ }
128
+
129
+ setUnsafe(key: string, value: unknown): void {
130
+ this.assertUnsafeKey(key);
131
+ const next = { ...this.fileData, [key]: value };
132
+ this.persistFileData(next);
133
+ }
134
+
86
135
  /** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
87
136
  get path(): string {
88
137
  return resolveAppConfigPath(this.program);
@@ -99,12 +148,23 @@ export class AppConfigSnapshot {
99
148
  this.snapshot = { ...resolved };
100
149
  }
101
150
 
151
+ private persistFileData(next: Record<string, unknown>): void {
152
+ writeAppConfigFile(this.program, next, { partial: true });
153
+ this.fileData = next;
154
+ this.snapshot = rebuildResolved(this.program, next);
155
+ }
156
+
102
157
  private assertEntryKey(key: string): void {
103
158
  const entries = this.program.appConfig?.entries;
104
159
  if (!entries || !(key in entries)) {
105
160
  throw new Error(`Unknown configuration key: ${key}`);
106
161
  }
107
162
  }
163
+
164
+ private assertUnsafeKey(key: string): void {
165
+ if (isFrameworkConfigKey(key)) return;
166
+ this.assertEntryKey(key);
167
+ }
108
168
  }
109
169
 
110
170
  export type AnyAppConfigSnapshot = AppConfigSnapshot | EmptyAppConfigSnapshot;
@@ -115,7 +175,7 @@ export function createAppConfigSnapshot(
115
175
  resolved: ResolvedConfig,
116
176
  ): AnyAppConfigSnapshot {
117
177
  if (!program.appConfig) {
118
- return new EmptyAppConfigSnapshot(program);
178
+ return new EmptyAppConfigSnapshot(program, fileData);
119
179
  }
120
180
  return new AppConfigSnapshot(program, fileData, resolved);
121
181
  }
@@ -46,9 +46,7 @@ export function configUserConfigKey(key: string): string {
46
46
  }
47
47
 
48
48
  /** Required keys from a JSON Schema object root. */
49
- export function jsonSchemaRequiredKeys(
50
- jsonSchema: Record<string, unknown>,
51
- ): Set<string> | undefined {
49
+ export function jsonSchemaRequiredKeys(jsonSchema: Record<string, unknown>): Set<string> | undefined {
52
50
  const required = jsonSchema.required;
53
51
  if (!Array.isArray(required)) {
54
52
  return undefined;