@did-btcr2/cli 0.15.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 (71) hide show
  1. package/README.md +47 -6
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +563 -117
  4. package/dist/esm/src/cli.js +7 -4
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +11 -15
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +4 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +3 -1
  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 +1 -1
  19. package/dist/esm/src/commands/profile.js.map +1 -1
  20. package/dist/esm/src/commands/update.js +3 -1
  21. package/dist/esm/src/commands/update.js.map +1 -1
  22. package/dist/esm/src/config.js +85 -28
  23. package/dist/esm/src/config.js.map +1 -1
  24. package/dist/esm/src/keystore/file-key-store.js +340 -32
  25. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  26. package/dist/esm/src/keystore/passphrase.js +40 -10
  27. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  28. package/dist/esm/src/keystore/paths.js +6 -18
  29. package/dist/esm/src/keystore/paths.js.map +1 -1
  30. package/dist/esm/src/paths.js +59 -0
  31. package/dist/esm/src/paths.js.map +1 -0
  32. package/dist/esm/src/types.js.map +1 -1
  33. package/dist/types/src/cli.d.ts.map +1 -1
  34. package/dist/types/src/commands/config.d.ts.map +1 -1
  35. package/dist/types/src/commands/create.d.ts.map +1 -1
  36. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  37. package/dist/types/src/commands/index.d.ts +2 -0
  38. package/dist/types/src/commands/index.d.ts.map +1 -1
  39. package/dist/types/src/commands/init.d.ts +13 -0
  40. package/dist/types/src/commands/init.d.ts.map +1 -0
  41. package/dist/types/src/commands/keystore.d.ts +10 -0
  42. package/dist/types/src/commands/keystore.d.ts.map +1 -0
  43. package/dist/types/src/commands/update.d.ts.map +1 -1
  44. package/dist/types/src/config.d.ts +38 -14
  45. package/dist/types/src/config.d.ts.map +1 -1
  46. package/dist/types/src/keystore/file-key-store.d.ts +86 -10
  47. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  48. package/dist/types/src/keystore/passphrase.d.ts +16 -1
  49. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  50. package/dist/types/src/keystore/paths.d.ts +6 -10
  51. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  52. package/dist/types/src/paths.d.ts +54 -0
  53. package/dist/types/src/paths.d.ts.map +1 -0
  54. package/dist/types/src/types.d.ts +38 -0
  55. package/dist/types/src/types.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/cli.ts +8 -3
  58. package/src/commands/config.ts +12 -14
  59. package/src/commands/create.ts +4 -1
  60. package/src/commands/deactivate.ts +3 -1
  61. package/src/commands/index.ts +2 -0
  62. package/src/commands/init.ts +74 -0
  63. package/src/commands/keystore.ts +98 -0
  64. package/src/commands/profile.ts +1 -1
  65. package/src/commands/update.ts +3 -1
  66. package/src/config.ts +93 -32
  67. package/src/keystore/file-key-store.ts +455 -43
  68. package/src/keystore/passphrase.ts +48 -8
  69. package/src/keystore/paths.ts +6 -19
  70. package/src/paths.ts +79 -0
  71. package/src/types.ts +13 -1
package/src/config.ts CHANGED
@@ -3,14 +3,17 @@ import type { KeyManager } from '@did-btcr2/key-manager';
3
3
  import { StaticFeeEstimator } from '@did-btcr2/method';
4
4
  import type { BroadcastOptions } from '@did-btcr2/method';
5
5
  import { readFileSync } from 'node:fs';
6
- import { homedir } from 'node:os';
7
- import { dirname, join } from 'node:path';
6
+ import { dirname } from 'node:path';
8
7
  import { CLIError } from './error.js';
9
8
  import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
10
9
  import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
10
+ import { keystoreProtection } from './keystore/file-key-store.js';
11
11
  import { defaultKeystorePath } from './keystore/paths.js';
12
12
  import { acquirePassphrase } from './keystore/passphrase.js';
13
- import { blankToUndef, SUPPORTED_NETWORKS, type NetworkOption, type OutputFormat } from './types.js';
13
+ import { defaultConfigPath } from './paths.js';
14
+ import { blankToUndef, SUPPORTED_NETWORKS, type KeystoreProtectionLabel, type NetworkOption, type OutputFormat } from './types.js';
15
+
16
+ export { defaultConfigPath };
14
17
 
15
18
  /**
16
19
  * Endpoint overrides provided via CLI flags, env vars, or config file.
@@ -39,9 +42,11 @@ export type ConnectionOverrides = {
39
42
  btcRpcWallet? : string;
40
43
  /** Extra Bitcoin Core RPC headers as raw `Key: Value` flag values (repeatable). */
41
44
  btcRpcHeader? : string[];
45
+ /** CLI home root from `--home`. Colocates config.json + keystore.json (ADR 079). */
46
+ home? : string;
42
47
  config? : string;
43
48
  profile? : string;
44
- /** Keystore file path. Overrides the default `$XDG_DATA_HOME/btcr2/keystore.json`. */
49
+ /** Keystore file path. Overrides the home default `<home>/keystore.json`. */
45
50
  keystore? : string;
46
51
  /** Path to a file holding the keystore passphrase (for unattended use). */
47
52
  passphraseFile? : string;
@@ -144,6 +149,22 @@ export function writeConfigFile(path: string, mutate: (raw: Record<string, unkno
144
149
  writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
145
150
  }
146
151
 
152
+ /**
153
+ * Writes a default config scaffold to `path`: schema version, a `text` output
154
+ * default, and one empty profile per supported network. Shared by `config init`
155
+ * and `btcr2 init` so the seeded config is identical. Writes atomically (file
156
+ * 0600, dir 0700); the caller decides whether to overwrite an existing file.
157
+ */
158
+ export function writeDefaultConfigFile(path: string): void {
159
+ const scaffold = {
160
+ schemaVersion : CONFIG_SCHEMA_VERSION,
161
+ defaults : { output: 'text' },
162
+ profiles : Object.fromEntries(SUPPORTED_NETWORKS.map(n => [ n, {} ])),
163
+ };
164
+ ensureDir(dirname(path), 0o700);
165
+ writeFileAtomic(path, `${JSON.stringify(scaffold, null, 2)}\n`, 0o600);
166
+ }
167
+
147
168
  /** Reads the value at a dotted path (e.g. `profiles.regtest.btc.rest`). */
148
169
  export function getConfigPath(config: Record<string, unknown>, path: string): unknown {
149
170
  return path.split('.').reduce<unknown>(
@@ -244,21 +265,6 @@ export function readEnvOverrides(): ConnectionOverrides {
244
265
  };
245
266
  }
246
267
 
247
- /**
248
- * Default config file path following the XDG Base Directory Specification.
249
- *
250
- * Resolution order:
251
- * 1. `$XDG_CONFIG_HOME/btcr2/config.json`
252
- * 2. `%APPDATA%/btcr2/config.json` (Windows)
253
- * 3. `~/.config/btcr2/config.json` (fallback)
254
- */
255
- export function defaultConfigPath(): string {
256
- const base = blankToUndef(process.env.XDG_CONFIG_HOME)
257
- ?? blankToUndef(process.env.APPDATA)
258
- ?? join(homedir(), '.config');
259
- return join(base, 'btcr2', 'config.json');
260
- }
261
-
262
268
  /**
263
269
  * Reads and JSON-parses a config file without applying the schema-version
264
270
  * ceiling check. Returns `undefined` only for a genuinely absent file (ENOENT).
@@ -379,7 +385,7 @@ export function resolveActiveProfile(
379
385
  * identifier encodes.
380
386
  */
381
387
  export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption {
382
- const configPath = overrides?.config ?? defaultConfigPath();
388
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
383
389
  const file = readConfigFile(configPath);
384
390
 
385
391
  const explicit = file?.defaults?.network;
@@ -407,7 +413,7 @@ export function profileNetworkMismatch(
407
413
  // command that actually resolves a connection.
408
414
  let file: ConfigFile | undefined;
409
415
  try {
410
- file = readConfigFile(overrides?.config ?? defaultConfigPath());
416
+ file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
411
417
  } catch {
412
418
  return undefined;
413
419
  }
@@ -422,13 +428,13 @@ export function profileNetworkMismatch(
422
428
  * then `'text'`. A malformed config never blocks output resolution (the command's
423
429
  * own read path surfaces it); output format falls back to `'text'` instead.
424
430
  */
425
- export function resolveOutputFormat(options: { output?: string; config?: string }): OutputFormat {
431
+ export function resolveOutputFormat(options: { output?: string; config?: string; home?: string }): OutputFormat {
426
432
  const candidates: Array<string | undefined> = [
427
433
  blankToUndef(options.output),
428
434
  process.env.BTCR2_OUTPUT || undefined,
429
435
  ];
430
436
  try {
431
- candidates.push(readConfigFile(options.config ?? defaultConfigPath())?.defaults?.output);
437
+ candidates.push(readConfigFile(options.config ?? defaultConfigPath(options))?.defaults?.output);
432
438
  } catch {
433
439
  // Output format is cosmetic; a broken config is reported by the command
434
440
  // itself rather than aborting here (which would block a recovery command).
@@ -501,7 +507,7 @@ export function resolveConnectionConfig(
501
507
  // Layer 1: Config file profile (lowest precedence of the three override layers).
502
508
  // The active-profile name is resolved through the same shared helper as
503
509
  // resolveDefaultNetwork so the two cannot disagree about which profile is live.
504
- const configPath = overrides?.config ?? defaultConfigPath();
510
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
505
511
  const file = readConfigFile(configPath);
506
512
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
507
513
  const profileName = activeProfile ?? network;
@@ -708,7 +714,7 @@ export function resolveBroadcastOptions(
708
714
  overrides: ConnectionOverrides | undefined,
709
715
  flags : { feeRate?: string; changeAddress?: string },
710
716
  ): BroadcastOptions | undefined {
711
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
717
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
712
718
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
713
719
  const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
714
720
 
@@ -778,7 +784,7 @@ export interface EffectiveConfig {
778
784
  * and each `source` is derived by the same precedence order the merge uses.
779
785
  */
780
786
  export function resolveEffectiveConfig(network: NetworkOption, overrides?: ConnectionOverrides): EffectiveConfig {
781
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
787
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
782
788
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
783
789
  const profileName = activeProfile ?? network;
784
790
  const fileOv = file ? profileToOverrides(file, profileName) : {};
@@ -944,17 +950,65 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
944
950
  function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
945
951
  return new FileBackedKeyManager({
946
952
  path : resolveKeystorePath(overrides),
947
- getPassphrase : () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
953
+ // The store decides when to confirm: it passes `confirm: true` only while
954
+ // establishing a fresh keystore's passphrase, so a first-key typo is caught
955
+ // by a second entry (ADR 080). confirm is a no-op for env/file sources.
956
+ getPassphrase : (opts) => acquirePassphrase({ passphraseFile: overrides?.passphraseFile, confirm: opts?.confirm }),
948
957
  });
949
958
  }
950
959
 
960
+ /**
961
+ * The protection mode of the resolved keystore, read without decrypting or
962
+ * prompting: `encrypted`, `dev` (plaintext), or `absent`. Used by `keystore
963
+ * status`, `config path`, and the mainnet guard.
964
+ */
965
+ export function resolveKeystoreProtection(overrides?: ConnectionOverrides): KeystoreProtectionLabel {
966
+ return keystoreProtection(resolveKeystorePath(overrides));
967
+ }
968
+
969
+ /**
970
+ * Hard-refuses using an unencrypted dev keystore for a mainnet operation (ADR
971
+ * 080). A plaintext key must never sign or seal a `bitcoin` did:btcr2; the check
972
+ * reads only the keystore's protection header, so it never decrypts or prompts.
973
+ * A no-op for every other network and for encrypted/absent keystores.
974
+ */
975
+ export function assertKeystoreAllowedForNetwork(network: NetworkOption, overrides?: ConnectionOverrides): void {
976
+ if (network !== 'bitcoin') return;
977
+ if (resolveKeystoreProtection(overrides) !== 'dev') return;
978
+ const path = resolveKeystorePath(overrides);
979
+ throw new CLIError(
980
+ `Refusing a mainnet (bitcoin) operation with the unencrypted dev keystore at ${path}. `
981
+ + 'Dev keystores hold plaintext keys and are for testnet/regtest throwaway material only. '
982
+ + 'Establish an encrypted keystore (btcr2 keystore init) for mainnet keys.',
983
+ 'DEV_KEYSTORE_MAINNET_ERROR',
984
+ { path, network },
985
+ );
986
+ }
987
+
951
988
  /**
952
989
  * Resolves the keystore file path: the `--keystore` flag, else the active
953
- * profile's `identity.keystore`, else the default XDG keystore path. The flag
954
- * always wins over the profile default.
990
+ * profile's `identity.keystore`, else the default `<home>/keystore.json` (ADR
991
+ * 079). The flag always wins over the profile default and never reads the config.
992
+ *
993
+ * A malformed config aborts loudly by default so a keystore-mutating command
994
+ * never silently reads or writes the wrong store. Pass `lenient: true` only for
995
+ * diagnostic/recovery commands (`config path`, `keystore status`) that must still
996
+ * report a path instead of crashing on the very config you ran them to fix; those
997
+ * fall back to the home default when the profile identity cannot be read.
955
998
  */
956
- export function resolveKeystorePath(overrides?: ConnectionOverrides): string {
957
- return overrides?.keystore ?? activeProfileIdentity(overrides)?.keystore ?? defaultKeystorePath();
999
+ export function resolveKeystorePath(overrides?: ConnectionOverrides, options?: { lenient?: boolean }): string {
1000
+ // The flag wins outright and short-circuits before any config read. A blank
1001
+ // flag defers to the profile, and a blank profile `identity.keystore` defers to
1002
+ // the default, so neither resolves the keystore to an empty path.
1003
+ const fromFlag = blankToUndef(overrides?.keystore);
1004
+ if (fromFlag) return fromFlag;
1005
+ let identity: { keystore?: string; default?: string } | undefined;
1006
+ try {
1007
+ identity = activeProfileIdentity(overrides);
1008
+ } catch (error) {
1009
+ if (!options?.lenient) throw error;
1010
+ }
1011
+ return blankToUndef(identity?.keystore) ?? defaultKeystorePath(overrides);
958
1012
  }
959
1013
 
960
1014
  /**
@@ -963,7 +1017,14 @@ export function resolveKeystorePath(overrides?: ConnectionOverrides): string {
963
1017
  * selected by `--profile` or the config's `defaults.profile`.
964
1018
  */
965
1019
  function activeProfileIdentity(overrides?: ConnectionOverrides): { keystore?: string; default?: string } | undefined {
966
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
1020
+ // Intentionally propagates a malformed-config error rather than swallowing it.
1021
+ // This feeds resolveKeystorePath for keystore-mutating commands (key generate/
1022
+ // import, keystore init/change-passphrase) and the mainnet dev-keystore guard,
1023
+ // so a broken config must abort loudly instead of silently resolving to the
1024
+ // default keystore and stranding key material there. Diagnostic-only commands
1025
+ // opt into a graceful fallback via resolveKeystorePath's `lenient` option; do
1026
+ // not add a try/catch here (it would re-hide the keystore misdirection).
1027
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
967
1028
  const { name } = resolveActiveProfile(file, overrides);
968
1029
  return name ? file?.profiles?.[name]?.identity : undefined;
969
1030
  }