@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.
- package/README.md +106 -17
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +1160 -131
- package/dist/esm/src/cli.js +29 -5
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/config.js +130 -18
- package/dist/esm/src/commands/config.js.map +1 -1
- package/dist/esm/src/commands/create.js +13 -1
- package/dist/esm/src/commands/create.js.map +1 -1
- package/dist/esm/src/commands/deactivate.js +16 -2
- package/dist/esm/src/commands/deactivate.js.map +1 -1
- package/dist/esm/src/commands/index.js +2 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/init.js +63 -0
- package/dist/esm/src/commands/init.js.map +1 -0
- package/dist/esm/src/commands/keystore.js +81 -0
- package/dist/esm/src/commands/keystore.js.map +1 -0
- package/dist/esm/src/commands/profile.js +6 -4
- package/dist/esm/src/commands/profile.js.map +1 -1
- package/dist/esm/src/commands/update.js +16 -2
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config-schema.js +149 -0
- package/dist/esm/src/config-schema.js.map +1 -0
- package/dist/esm/src/config.js +579 -55
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/keystore/file-key-store.js +340 -32
- package/dist/esm/src/keystore/file-key-store.js.map +1 -1
- package/dist/esm/src/keystore/passphrase.js +40 -10
- package/dist/esm/src/keystore/passphrase.js.map +1 -1
- package/dist/esm/src/keystore/paths.js +6 -17
- package/dist/esm/src/keystore/paths.js.map +1 -1
- package/dist/esm/src/output.js +49 -0
- package/dist/esm/src/output.js.map +1 -1
- package/dist/esm/src/paths.js +59 -0
- package/dist/esm/src/paths.js.map +1 -0
- package/dist/esm/src/types.js +11 -0
- package/dist/esm/src/types.js.map +1 -1
- package/dist/types/src/cli.d.ts.map +1 -1
- package/dist/types/src/commands/config.d.ts.map +1 -1
- package/dist/types/src/commands/create.d.ts.map +1 -1
- package/dist/types/src/commands/deactivate.d.ts.map +1 -1
- package/dist/types/src/commands/index.d.ts +2 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/init.d.ts +13 -0
- package/dist/types/src/commands/init.d.ts.map +1 -0
- package/dist/types/src/commands/keystore.d.ts +10 -0
- package/dist/types/src/commands/keystore.d.ts.map +1 -0
- package/dist/types/src/commands/profile.d.ts.map +1 -1
- package/dist/types/src/commands/update.d.ts.map +1 -1
- package/dist/types/src/config-schema.d.ts +24 -0
- package/dist/types/src/config-schema.d.ts.map +1 -0
- package/dist/types/src/config.d.ts +252 -14
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/keystore/file-key-store.d.ts +86 -10
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
- package/dist/types/src/keystore/passphrase.d.ts +16 -1
- package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
- package/dist/types/src/keystore/paths.d.ts +6 -10
- package/dist/types/src/keystore/paths.d.ts.map +1 -1
- package/dist/types/src/output.d.ts +22 -0
- package/dist/types/src/output.d.ts.map +1 -1
- package/dist/types/src/paths.d.ts +54 -0
- package/dist/types/src/paths.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +70 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +32 -4
- package/src/commands/config.ts +143 -18
- package/src/commands/create.ts +16 -1
- package/src/commands/deactivate.ts +24 -2
- package/src/commands/index.ts +2 -0
- package/src/commands/init.ts +74 -0
- package/src/commands/keystore.ts +98 -0
- package/src/commands/profile.ts +6 -4
- package/src/commands/update.ts +24 -2
- package/src/config-schema.ts +178 -0
- package/src/config.ts +752 -58
- package/src/keystore/file-key-store.ts +455 -43
- package/src/keystore/passphrase.ts +48 -8
- package/src/keystore/paths.ts +6 -18
- package/src/output.ts +53 -0
- package/src/paths.ts +79 -0
- 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
|
+
}
|
package/src/commands/profile.ts
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
|
|
51
|
+
const payload = { profile: target, ...data };
|
|
52
|
+
print({ action: 'profile-show', data: opts.showSecrets ? payload : redactSecrets(payload) });
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
profile
|
package/src/commands/update.ts
CHANGED
|
@@ -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()
|
|
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
|
+
}
|