@did-btcr2/cli 0.14.0 → 0.16.0

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 (83) hide show
  1. package/README.md +106 -17
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +1160 -131
  4. package/dist/esm/src/cli.js +29 -5
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +130 -18
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +13 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +16 -2
  11. package/dist/esm/src/commands/deactivate.js.map +1 -1
  12. package/dist/esm/src/commands/index.js +2 -0
  13. package/dist/esm/src/commands/index.js.map +1 -1
  14. package/dist/esm/src/commands/init.js +63 -0
  15. package/dist/esm/src/commands/init.js.map +1 -0
  16. package/dist/esm/src/commands/keystore.js +81 -0
  17. package/dist/esm/src/commands/keystore.js.map +1 -0
  18. package/dist/esm/src/commands/profile.js +6 -4
  19. package/dist/esm/src/commands/profile.js.map +1 -1
  20. package/dist/esm/src/commands/update.js +16 -2
  21. package/dist/esm/src/commands/update.js.map +1 -1
  22. package/dist/esm/src/config-schema.js +149 -0
  23. package/dist/esm/src/config-schema.js.map +1 -0
  24. package/dist/esm/src/config.js +579 -55
  25. package/dist/esm/src/config.js.map +1 -1
  26. package/dist/esm/src/keystore/file-key-store.js +340 -32
  27. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  28. package/dist/esm/src/keystore/passphrase.js +40 -10
  29. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  30. package/dist/esm/src/keystore/paths.js +6 -17
  31. package/dist/esm/src/keystore/paths.js.map +1 -1
  32. package/dist/esm/src/output.js +49 -0
  33. package/dist/esm/src/output.js.map +1 -1
  34. package/dist/esm/src/paths.js +59 -0
  35. package/dist/esm/src/paths.js.map +1 -0
  36. package/dist/esm/src/types.js +11 -0
  37. package/dist/esm/src/types.js.map +1 -1
  38. package/dist/types/src/cli.d.ts.map +1 -1
  39. package/dist/types/src/commands/config.d.ts.map +1 -1
  40. package/dist/types/src/commands/create.d.ts.map +1 -1
  41. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  42. package/dist/types/src/commands/index.d.ts +2 -0
  43. package/dist/types/src/commands/index.d.ts.map +1 -1
  44. package/dist/types/src/commands/init.d.ts +13 -0
  45. package/dist/types/src/commands/init.d.ts.map +1 -0
  46. package/dist/types/src/commands/keystore.d.ts +10 -0
  47. package/dist/types/src/commands/keystore.d.ts.map +1 -0
  48. package/dist/types/src/commands/profile.d.ts.map +1 -1
  49. package/dist/types/src/commands/update.d.ts.map +1 -1
  50. package/dist/types/src/config-schema.d.ts +24 -0
  51. package/dist/types/src/config-schema.d.ts.map +1 -0
  52. package/dist/types/src/config.d.ts +252 -14
  53. package/dist/types/src/config.d.ts.map +1 -1
  54. package/dist/types/src/keystore/file-key-store.d.ts +86 -10
  55. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  56. package/dist/types/src/keystore/passphrase.d.ts +16 -1
  57. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  58. package/dist/types/src/keystore/paths.d.ts +6 -10
  59. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  60. package/dist/types/src/output.d.ts +22 -0
  61. package/dist/types/src/output.d.ts.map +1 -1
  62. package/dist/types/src/paths.d.ts +54 -0
  63. package/dist/types/src/paths.d.ts.map +1 -0
  64. package/dist/types/src/types.d.ts +70 -0
  65. package/dist/types/src/types.d.ts.map +1 -1
  66. package/package.json +5 -5
  67. package/src/cli.ts +32 -4
  68. package/src/commands/config.ts +143 -18
  69. package/src/commands/create.ts +16 -1
  70. package/src/commands/deactivate.ts +24 -2
  71. package/src/commands/index.ts +2 -0
  72. package/src/commands/init.ts +74 -0
  73. package/src/commands/keystore.ts +98 -0
  74. package/src/commands/profile.ts +6 -4
  75. package/src/commands/update.ts +24 -2
  76. package/src/config-schema.ts +178 -0
  77. package/src/config.ts +752 -58
  78. package/src/keystore/file-key-store.ts +455 -43
  79. package/src/keystore/passphrase.ts +48 -8
  80. package/src/keystore/paths.ts +6 -18
  81. package/src/output.ts +53 -0
  82. package/src/paths.ts +79 -0
  83. package/src/types.ts +34 -0
@@ -0,0 +1,74 @@
1
+ import type { Command } from 'commander';
2
+ import { existsSync } from 'node:fs';
3
+ import { defaultConfigPath, resolveKeystorePath, writeDefaultConfigFile } from '../config.js';
4
+ import { ensureDir } from '../keystore/atomic.js';
5
+ import { initKeystore, keystoreSummary } from '../keystore/file-key-store.js';
6
+ import { acquirePassphrase } from '../keystore/passphrase.js';
7
+ import { formatResult } from '../output.js';
8
+ import { resolveHome } from '../paths.js';
9
+ import type { CommandResult, GlobalOptions } from '../types.js';
10
+
11
+ /**
12
+ * Registers the top-level `btcr2 init`: the one-command entry point that creates
13
+ * the btcr2 home (ADR 079), writes a default config if none exists, and
14
+ * establishes the keystore if none exists (encrypted with a confirmed passphrase
15
+ * by default, or `--dev` for an unencrypted testnet keystore). Idempotent:
16
+ * existing files are left untouched unless `--force` is given. Establishing the
17
+ * passphrase here, up front and confirmed, is what keeps the first `key generate`
18
+ * off the accidental-first-seal path (ADR 080).
19
+ */
20
+ export function registerInitCommand(program: Command, globals: () => GlobalOptions): void {
21
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
22
+
23
+ program
24
+ .command('init')
25
+ .description('Set up the btcr2 home: create the directory, a default config, and establish the keystore.')
26
+ .option('--dev', 'Establish an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only.', false)
27
+ .option('--force', 'Re-create the config and keystore even if they already exist.', false)
28
+ .action((options: { dev?: boolean; force?: boolean }) => {
29
+ const g = globals();
30
+ const home = resolveHome(g);
31
+ const configPath = g.config ?? defaultConfigPath(g);
32
+ const keystorePath = resolveKeystorePath(g);
33
+ ensureDir(home, 0o700);
34
+
35
+ const created: string[] = [];
36
+
37
+ // The config is regenerable, so --force may re-scaffold it.
38
+ if (!existsSync(configPath) || options.force) {
39
+ writeDefaultConfigFile(configPath);
40
+ created.push('config');
41
+ }
42
+
43
+ // The keystore holds unrecoverable secret keys, so `init` never overwrites
44
+ // an existing one, even with --force: re-establishing a keystore is the
45
+ // explicit, deliberate `keystore init --force`. `init` only establishes a
46
+ // keystore when none exists.
47
+ const keystoreExists = existsSync(keystorePath);
48
+ if (keystoreExists && options.force && !g.quiet) {
49
+ process.stderr.write(
50
+ `note: a keystore already exists at ${keystorePath} and was left intact. `
51
+ + 'To re-establish it (discarding its keys), run "btcr2 keystore init --force".\n',
52
+ );
53
+ }
54
+ if (!keystoreExists) {
55
+ if (options.dev && !g.quiet) {
56
+ process.stderr.write(
57
+ 'warning: establishing an UNENCRYPTED dev keystore. Keys are stored in plaintext. '
58
+ + 'Use it only for disposable testnet material; mainnet operations will be refused.\n',
59
+ );
60
+ }
61
+ initKeystore(keystorePath, {
62
+ protection : options.dev ? 'none' : 'passphrase',
63
+ getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
64
+ });
65
+ created.push('keystore');
66
+ }
67
+
68
+ const protection = keystoreSummary(keystorePath).protection;
69
+ print({ action: 'init', data: { home, config: configPath, keystore: keystorePath, created, protection } });
70
+ if (!g.quiet && g.output !== 'json') {
71
+ process.stderr.write(`btcr2 home ready at ${home}. Next: btcr2 key generate --set-active\n`);
72
+ }
73
+ });
74
+ }
@@ -0,0 +1,98 @@
1
+ import type { Command } from 'commander';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolveKeystorePath } from '../config.js';
4
+ import { CLIError } from '../error.js';
5
+ import { changeKeystorePassphrase, initKeystore, keystoreSummary } from '../keystore/file-key-store.js';
6
+ import { acquirePassphrase } from '../keystore/passphrase.js';
7
+ import { formatResult } from '../output.js';
8
+ import type { CommandResult, GlobalOptions } from '../types.js';
9
+
10
+ /**
11
+ * Registers the `keystore` command group: establish, inspect, and re-key the
12
+ * encrypted keystore (ADR 080). These operate on the keystore file directly (no
13
+ * Bitcoin connection or KeyManager) and never decrypt a key except when
14
+ * re-sealing during `change-passphrase`.
15
+ */
16
+ export function registerKeystoreCommand(program: Command, globals: () => GlobalOptions): void {
17
+ const keystore = program.command('keystore').description('Establish, inspect, and re-key the keystore.');
18
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
19
+
20
+ keystore
21
+ .command('init')
22
+ .description('Establish the keystore (encrypted by default). Prompts for a passphrase and confirms it.')
23
+ .option('--dev', 'Create an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only; mainnet operations are refused.', false)
24
+ .option('--force', 'Re-establish even if a keystore already exists (discards its keys).', false)
25
+ .action((options: { dev?: boolean; force?: boolean }) => {
26
+ const g = globals();
27
+ const path = resolveKeystorePath(g);
28
+ if (existsSync(path) && !options.force) {
29
+ throw new CLIError(
30
+ `A keystore already exists at ${path}. Use --force to re-establish it (this discards its keys).`,
31
+ 'INVALID_ARGUMENT_ERROR',
32
+ { path },
33
+ );
34
+ }
35
+ // --force re-establishes over an existing keystore; make the key loss loud.
36
+ if (existsSync(path) && options.force && !g.quiet) {
37
+ const { keyCount } = keystoreSummary(path);
38
+ if (keyCount > 0) {
39
+ process.stderr.write(
40
+ `warning: re-establishing the keystore at ${path} permanently discards its ${keyCount} existing key(s).\n`,
41
+ );
42
+ }
43
+ }
44
+ if (options.dev && !g.quiet) {
45
+ process.stderr.write(
46
+ 'warning: creating an UNENCRYPTED dev keystore. Keys are stored in plaintext. '
47
+ + 'Use it only for disposable testnet material; mainnet operations will be refused.\n',
48
+ );
49
+ }
50
+ initKeystore(path, {
51
+ protection : options.dev ? 'none' : 'passphrase',
52
+ getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
53
+ });
54
+ print({ action: 'keystore-init', data: { path, protection: options.dev ? 'dev' : 'encrypted' } });
55
+ });
56
+
57
+ keystore
58
+ .command('status')
59
+ .description('Show the keystore path, protection mode, and key count. Never decrypts or prompts.')
60
+ .action(() => {
61
+ const g = globals();
62
+ // Diagnostic command: report status even when the config is malformed,
63
+ // rather than crashing on the config you ran this to inspect.
64
+ const path = resolveKeystorePath(g, { lenient: true });
65
+ const summary = keystoreSummary(path);
66
+ if (summary.protection === 'dev' && !g.quiet && g.output !== 'json') {
67
+ process.stderr.write('warning: this is an UNENCRYPTED dev keystore; keys are stored in plaintext.\n');
68
+ }
69
+ print({ action: 'keystore-status', data: { path, ...summary } });
70
+ });
71
+
72
+ keystore
73
+ .command('change-passphrase')
74
+ .alias('passwd')
75
+ .description('Change the keystore passphrase, re-sealing every key under the new one. Encrypted keystores only.')
76
+ .action(() => {
77
+ const g = globals();
78
+ const path = resolveKeystorePath(g);
79
+ const summary = keystoreSummary(path);
80
+ if (summary.protection === 'absent') {
81
+ throw new CLIError(`No keystore at ${path}. Run "btcr2 keystore init" first.`, 'INVALID_ARGUMENT_ERROR', { path });
82
+ }
83
+ if (summary.protection === 'dev') {
84
+ throw new CLIError(
85
+ `The keystore at ${path} is an unencrypted dev keystore; there is no passphrase to change.`,
86
+ 'INVALID_ARGUMENT_ERROR',
87
+ { path },
88
+ );
89
+ }
90
+ // Current passphrase may come from the env var / file (unattended); the new
91
+ // one must be entered fresh (forcePrompt) so it cannot be silently satisfied
92
+ // by the same source and make the change a no-op.
93
+ const oldPassphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Current keystore passphrase: ' });
94
+ const newPassphrase = acquirePassphrase({ forcePrompt: true, confirm: true, prompt: 'New keystore passphrase: ' });
95
+ const rekeyed = changeKeystorePassphrase(path, oldPassphrase, newPassphrase);
96
+ print({ action: 'keystore-change-passphrase', data: { path, rekeyed } });
97
+ });
98
+ }
@@ -1,13 +1,13 @@
1
1
  import type { Command } from 'commander';
2
2
  import { defaultConfigPath, readConfigFile, writeConfigFile } from '../config.js';
3
3
  import { CLIError } from '../error.js';
4
- import { formatResult } from '../output.js';
4
+ import { formatResult, redactSecrets } from '../output.js';
5
5
  import type { CommandResult, GlobalOptions } from '../types.js';
6
6
 
7
7
  /** Registers the `profile` command group for managing configuration profiles. */
8
8
  export function registerProfileCommand(program: Command, globals: () => GlobalOptions): void {
9
9
  const profile = program.command('profile').description('Manage configuration profiles.');
10
- const path = (): string => globals().config ?? defaultConfigPath();
10
+ const path = (): string => globals().config ?? defaultConfigPath(globals());
11
11
  const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
12
12
 
13
13
  profile
@@ -37,7 +37,8 @@ export function registerProfileCommand(program: Command, globals: () => GlobalOp
37
37
  profile
38
38
  .command('show [name]')
39
39
  .description('Show a profile (defaults to the active profile).')
40
- .action((name?: string) => {
40
+ .option('--show-secrets', 'Reveal secret values (RPC password, etc.) instead of redacting them.', false)
41
+ .action((name: string | undefined, opts: { showSecrets?: boolean }) => {
41
42
  const file = readConfigFile(path()) ?? {};
42
43
  const target = name ?? file.defaults?.profile;
43
44
  if (!target) {
@@ -47,7 +48,8 @@ export function registerProfileCommand(program: Command, globals: () => GlobalOp
47
48
  if (!data) {
48
49
  throw new CLIError(`Profile "${target}" not found.`, 'INVALID_ARGUMENT_ERROR', { profile: target });
49
50
  }
50
- print({ action: 'profile-show', data: { profile: target, ...data } });
51
+ const payload = { profile: target, ...data };
52
+ print({ action: 'profile-show', data: opts.showSecrets ? payload : redactSecrets(payload) });
51
53
  });
52
54
 
53
55
  profile
@@ -1,7 +1,7 @@
1
1
  import type { PublishToCasMode } from '@did-btcr2/api';
2
2
  import { KeyManagerSigner } from '@did-btcr2/key-manager';
3
3
  import type { Command } from 'commander';
4
- import { deriveNetwork, type ApiFactory } from '../config.js';
4
+ import { assertKeystoreAllowedForNetwork, deriveNetwork, resolveBroadcastOptions, resolveSigningKeyRef, type ApiFactory } from '../config.js';
5
5
  import { CLIError } from '../error.js';
6
6
  import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
7
7
  import { formatResult } from '../output.js';
@@ -45,6 +45,16 @@ export function registerUpdateCommand(
45
45
  parsePublishToCasMode,
46
46
  'never',
47
47
  )
48
+ .option(
49
+ '--fee-rate <satsPerVByte>',
50
+ 'Fee rate in sats/vByte for the beacon transaction (default: 5). '
51
+ + 'Raise it under congestion so the transaction confirms.',
52
+ )
53
+ .option(
54
+ '--change-address <address>',
55
+ 'Send transaction change to this address instead of the beacon address, '
56
+ + 'so a DID\'s announcements are not linked on-chain (ADR 044).',
57
+ )
48
58
  .action(async (options: {
49
59
  sourceDocument : unknown;
50
60
  sourceVersionId : string;
@@ -52,6 +62,8 @@ export function registerUpdateCommand(
52
62
  verificationMethodId : string;
53
63
  beaconId : unknown;
54
64
  publishToCas : PublishToCasMode;
65
+ feeRate? : string;
66
+ changeAddress? : string;
55
67
  }) => {
56
68
  if (!/^\d+$/.test(options.sourceVersionId)) {
57
69
  throw new CLIError(
@@ -76,9 +88,18 @@ export function registerUpdateCommand(
76
88
  );
77
89
  }
78
90
  const network = deriveNetwork(did);
91
+ // Refuse to sign a mainnet update with an unencrypted dev keystore (ADR 080).
92
+ assertKeystoreAllowedForNetwork(network, globals());
79
93
  const api = factory(network, globals());
80
- const keyId = resolveKeyRef(api.kms.kms, globals().signingKey);
94
+ const keyId = resolveKeyRef(api.kms.kms, resolveSigningKeyRef(globals()));
81
95
  const signer = new KeyManagerSigner(api.kms.kms, keyId);
96
+ // Resolve fee-rate/change-address through the flag -> env -> profile chain
97
+ // into beacon broadcast options. Undefined when neither is set, so the
98
+ // SDK defaults (5 sat/vB, change back to the beacon address) still apply.
99
+ const broadcastOptions = resolveBroadcastOptions(network, globals(), {
100
+ feeRate : options.feeRate,
101
+ changeAddress : options.changeAddress,
102
+ });
82
103
  // CAS publication is optional and never required: every beacon update can
83
104
  // be completed and shared via sidecar alone. It is opt-in and defaults to
84
105
  // 'never'; pass --publish-to-cas auto|always to publish the signed update
@@ -93,6 +114,7 @@ export function registerUpdateCommand(
93
114
  beaconId : parsed.beaconId,
94
115
  signer,
95
116
  publishToCas : options.publishToCas,
117
+ ...(broadcastOptions ? { broadcastOptions } : {}),
96
118
  });
97
119
  console.log(formatResult({ action: 'update', data }, globals()));
98
120
  });
@@ -0,0 +1,178 @@
1
+ import { CLIError } from './error.js';
2
+ import { SUPPORTED_NETWORKS } from './types.js';
3
+
4
+ /**
5
+ * Declarative schema of the known config-file paths, used by both the write-time
6
+ * validation in `config set` and the strict `config validate` check so the two
7
+ * cannot disagree. Leaves name a value kind: `'string'`, `'number'`, `'object'`
8
+ * (a free-form map, e.g. headers), or `'enum:<name>'`. The `'*'` key under
9
+ * `profiles` matches any profile name.
10
+ */
11
+ const CONFIG_SCHEMA: SchemaNode = {
12
+ schemaVersion : 'number',
13
+ defaults : {
14
+ profile : 'string',
15
+ network : 'enum:network',
16
+ output : 'enum:output',
17
+ },
18
+ profiles : {
19
+ '*' : {
20
+ network : 'enum:network',
21
+ btc : {
22
+ rest : 'string',
23
+ rpcUrl : 'string',
24
+ rpcUser : 'string',
25
+ rpcPass : 'string',
26
+ feeRate : 'number',
27
+ changeAddress : 'string',
28
+ timeoutMs : 'number',
29
+ headers : 'object',
30
+ wallet : 'string',
31
+ rpcHeaders : 'object',
32
+ },
33
+ cas : {
34
+ gateway : 'string',
35
+ rpcUrl : 'string',
36
+ timeoutMs : 'number',
37
+ },
38
+ identity : {
39
+ keystore : 'string',
40
+ default : 'string',
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ type SchemaLeaf = 'string' | 'number' | 'object' | `enum:${string}`;
47
+ type SchemaNode = { [key: string]: SchemaLeaf | SchemaNode };
48
+
49
+ /** One problem found in a config file: the dotted path and a human-readable reason. */
50
+ export interface ConfigIssue {
51
+ path : string;
52
+ issue : string;
53
+ }
54
+
55
+ /**
56
+ * Resolves a dotted config path to its schema node: a leaf kind string, a nested
57
+ * {@link SchemaNode}, or `undefined` when the path is not part of the known
58
+ * schema. A segment under `profiles` matches the `'*'` template.
59
+ */
60
+ function lookupSchemaNode(dotted: string): SchemaLeaf | SchemaNode | undefined {
61
+ let node: SchemaLeaf | SchemaNode | undefined = CONFIG_SCHEMA;
62
+ for (const segment of dotted.split('.')) {
63
+ if (typeof node !== 'object') return undefined;
64
+ // Own-property checks only: `in` would match inherited Object.prototype names
65
+ // (`toString`, `constructor`, `__proto__`), treating a builtin as a known key.
66
+ if (Object.hasOwn(node, segment)) {
67
+ node = node[segment];
68
+ } else if (Object.hasOwn(node, '*')) {
69
+ node = node['*'];
70
+ } else {
71
+ return undefined;
72
+ }
73
+ }
74
+ return node;
75
+ }
76
+
77
+ /** Whether a dotted path is part of the known config schema (leaf or intermediate). */
78
+ export function isKnownConfigPath(dotted: string): boolean {
79
+ return lookupSchemaNode(dotted) !== undefined;
80
+ }
81
+
82
+ /**
83
+ * Validates a value against a leaf kind, throwing a {@link CLIError} for a value
84
+ * that does not match: an out-of-range enum, a non-number for a `number` leaf, or
85
+ * a non-object for an `object` leaf (a map such as `headers`). A no-op for
86
+ * `string` leaves and for intermediate nodes.
87
+ */
88
+ function assertLeafValue(kind: SchemaLeaf, dotted: string, value: unknown): void {
89
+ if (kind === 'enum:network' && !SUPPORTED_NETWORKS.includes(value as never)) {
90
+ throw new CLIError(
91
+ `Invalid value for ${dotted}: "${String(value)}". Expected one of ${SUPPORTED_NETWORKS.join(', ')}.`,
92
+ 'INVALID_ARGUMENT_ERROR',
93
+ { path: dotted, value },
94
+ );
95
+ }
96
+ if (kind === 'enum:output' && value !== 'json' && value !== 'text') {
97
+ throw new CLIError(
98
+ `Invalid value for ${dotted}: "${String(value)}". Expected "json" or "text".`,
99
+ 'INVALID_ARGUMENT_ERROR',
100
+ { path: dotted, value },
101
+ );
102
+ }
103
+ if (kind === 'number' && typeof value !== 'number') {
104
+ throw new CLIError(
105
+ `Invalid value for ${dotted}: expected a number, got ${typeof value}. `
106
+ + 'Pass a bare number (e.g. `config set ' + dotted + ' 5`).',
107
+ 'INVALID_ARGUMENT_ERROR',
108
+ { path: dotted, value },
109
+ );
110
+ }
111
+ if (kind === 'object' && (typeof value !== 'object' || value === null || Array.isArray(value))) {
112
+ throw new CLIError(
113
+ `Invalid value for ${dotted}: expected a JSON object (e.g. \`config set ${dotted} '{"Key":"Value"}'\`).`,
114
+ 'INVALID_ARGUMENT_ERROR',
115
+ { path: dotted, value },
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Write-time validation for `config set`. An enum, number, or object leaf whose
122
+ * value has the wrong kind is a hard rejection (throws). An unknown path is
123
+ * permitted but reported, so `config set` can warn and still write (keeping
124
+ * forward-compatible and third-party keys usable). Returns `{ unknownPath }`.
125
+ */
126
+ export function validateConfigSet(dotted: string, value: unknown): { unknownPath: boolean } {
127
+ const node = lookupSchemaNode(dotted);
128
+ if (node === undefined) return { unknownPath: true };
129
+ if (typeof node === 'string') {
130
+ assertLeafValue(node, dotted, value);
131
+ }
132
+ return { unknownPath: false };
133
+ }
134
+
135
+ /**
136
+ * Strict validation for `config validate`: walks a parsed config and collects
137
+ * every unknown key and out-of-enum value, plus a `schemaVersion` that is newer
138
+ * than this CLI supports. Never throws; returns the full list so the caller can
139
+ * report all problems at once.
140
+ */
141
+ export function findConfigIssues(config: Record<string, unknown>, supportedSchemaVersion: number): ConfigIssue[] {
142
+ const issues: ConfigIssue[] = [];
143
+
144
+ const version = config.schemaVersion;
145
+ if (typeof version === 'number' && version > supportedSchemaVersion) {
146
+ issues.push({
147
+ path : 'schemaVersion',
148
+ issue : `newer than supported (${version} > ${supportedSchemaVersion}); upgrade the CLI to use this file`,
149
+ });
150
+ }
151
+
152
+ walk(config, [], issues);
153
+ return issues;
154
+ }
155
+
156
+ /** Recursively collects unknown-key and bad-enum issues under `prefix`. */
157
+ function walk(obj: Record<string, unknown>, prefix: string[], issues: ConfigIssue[]): void {
158
+ for (const [ key, value ] of Object.entries(obj)) {
159
+ const path = [ ...prefix, key ];
160
+ const dotted = path.join('.');
161
+ const node = lookupSchemaNode(dotted);
162
+
163
+ if (node === undefined) {
164
+ issues.push({ path: dotted, issue: 'unknown key' });
165
+ continue; // Do not descend into an unknown subtree.
166
+ }
167
+
168
+ if (typeof node === 'string') {
169
+ try {
170
+ assertLeafValue(node, dotted, value);
171
+ } catch (error) {
172
+ issues.push({ path: dotted, issue: (error as Error).message });
173
+ }
174
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
175
+ walk(value as Record<string, unknown>, path, issues);
176
+ }
177
+ }
178
+ }