@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.
- package/README.md +47 -6
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +563 -117
- package/dist/esm/src/cli.js +7 -4
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/config.js +11 -15
- package/dist/esm/src/commands/config.js.map +1 -1
- package/dist/esm/src/commands/create.js +4 -1
- package/dist/esm/src/commands/create.js.map +1 -1
- package/dist/esm/src/commands/deactivate.js +3 -1
- 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 +1 -1
- package/dist/esm/src/commands/profile.js.map +1 -1
- package/dist/esm/src/commands/update.js +3 -1
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config.js +85 -28
- 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 -18
- package/dist/esm/src/keystore/paths.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.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/update.d.ts.map +1 -1
- package/dist/types/src/config.d.ts +38 -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/paths.d.ts +54 -0
- package/dist/types/src/paths.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +38 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +8 -3
- package/src/commands/config.ts +12 -14
- package/src/commands/create.ts +4 -1
- package/src/commands/deactivate.ts +3 -1
- 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 +1 -1
- package/src/commands/update.ts +3 -1
- package/src/config.ts +93 -32
- package/src/keystore/file-key-store.ts +455 -43
- package/src/keystore/passphrase.ts +48 -8
- package/src/keystore/paths.ts +6 -19
- package/src/paths.ts +79 -0
- package/src/types.ts +13 -1
|
@@ -12,6 +12,13 @@ export type PassphraseOptions = {
|
|
|
12
12
|
prompt?: string;
|
|
13
13
|
/** When true, prompt twice and require the entries to match (for a new keystore). */
|
|
14
14
|
confirm?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* When true, skip the environment variable and passphrase file and require a
|
|
17
|
+
* fresh terminal entry. Used for the *new* passphrase in `change-passphrase`,
|
|
18
|
+
* where the env var / file holds the *current* passphrase and must not silently
|
|
19
|
+
* satisfy the new one (which would make the change a no-op).
|
|
20
|
+
*/
|
|
21
|
+
forcePrompt?: boolean;
|
|
15
22
|
};
|
|
16
23
|
|
|
17
24
|
/**
|
|
@@ -19,16 +26,19 @@ export type PassphraseOptions = {
|
|
|
19
26
|
* (which would leak into process listings and shell history). Resolution order:
|
|
20
27
|
* the {@link ENV_KEYSTORE_PASSPHRASE} environment variable, a passphrase file,
|
|
21
28
|
* then a non-echoing terminal prompt. Throws if none is available and standard
|
|
22
|
-
* input is not a terminal.
|
|
29
|
+
* input is not a terminal. When `forcePrompt` is set, the env var and file are
|
|
30
|
+
* skipped and a terminal entry is required.
|
|
23
31
|
*/
|
|
24
32
|
export function acquirePassphrase(options: PassphraseOptions = {}): string {
|
|
25
33
|
// All sources are normalized identically (at most one trailing newline
|
|
26
34
|
// removed) so the KDF input is source-independent.
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
if (!options.forcePrompt) {
|
|
36
|
+
const fromEnv = process.env[ENV_KEYSTORE_PASSPHRASE];
|
|
37
|
+
if (fromEnv) return assertNonEmpty(fromEnv.replace(/\r?\n$/, ''));
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
if (options.passphraseFile) {
|
|
40
|
+
return assertNonEmpty(readFileSync(options.passphraseFile, 'utf-8').replace(/\r?\n$/, ''));
|
|
41
|
+
}
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
if (!process.stdin.isTTY) {
|
|
@@ -56,9 +66,34 @@ function assertNonEmpty(passphrase: string): string {
|
|
|
56
66
|
return passphrase;
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A 4-byte shared buffer used only as an {@link Atomics.wait} target. It lets
|
|
71
|
+
* {@link promptHidden} block briefly on an empty non-blocking TTY instead of
|
|
72
|
+
* busy-spinning. It is never written to, so the wait always times out.
|
|
73
|
+
*/
|
|
74
|
+
const IDLE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Milliseconds to block on each empty read. Imperceptible to a typist yet long
|
|
78
|
+
* enough that an open prompt sits idle rather than pegging a CPU core.
|
|
79
|
+
*/
|
|
80
|
+
const IDLE_POLL_MS = 20;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Removes the last whole UTF-8 character from an accumulating byte array in
|
|
84
|
+
* place: pops any trailing continuation bytes (0b10xxxxxx) then the leading
|
|
85
|
+
* byte. Exported for testing; a backspace mid-entry must not strand a fragment
|
|
86
|
+
* that later decodes to U+FFFD.
|
|
87
|
+
*/
|
|
88
|
+
export function dropLastUtf8Char(bytes: number[]): void {
|
|
89
|
+
while (bytes.length > 0 && (bytes[bytes.length - 1] & 0xc0) === 0x80) bytes.pop();
|
|
90
|
+
bytes.pop();
|
|
91
|
+
}
|
|
92
|
+
|
|
59
93
|
/**
|
|
60
94
|
* Reads a line from the terminal synchronously without echoing keystrokes.
|
|
61
|
-
* Bytes are accumulated and decoded as UTF-8 so multibyte passphrases survive
|
|
95
|
+
* Bytes are accumulated and decoded as UTF-8 so multibyte passphrases survive,
|
|
96
|
+
* including a backspace that spans a whole multibyte character.
|
|
62
97
|
* This path runs only when standard input is a terminal.
|
|
63
98
|
*/
|
|
64
99
|
function promptHidden(label: string): string {
|
|
@@ -75,7 +110,12 @@ function promptHidden(label: string): string {
|
|
|
75
110
|
read = readSync(stdin.fd, byte, 0, 1, null);
|
|
76
111
|
} catch (error) {
|
|
77
112
|
const code = (error as { code?: string }).code;
|
|
78
|
-
if (code === 'EAGAIN')
|
|
113
|
+
if (code === 'EAGAIN') {
|
|
114
|
+
// No byte ready yet on a non-blocking TTY. Block briefly instead of
|
|
115
|
+
// spinning so an open prompt does not peg a CPU core while it waits.
|
|
116
|
+
Atomics.wait(IDLE_WAIT, 0, 0, IDLE_POLL_MS);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
79
119
|
if (code === 'EOF') break;
|
|
80
120
|
throw error;
|
|
81
121
|
}
|
|
@@ -86,7 +126,7 @@ function promptHidden(label: string): string {
|
|
|
86
126
|
throw new KeyStoreError('Passphrase entry aborted.', 'PASSPHRASE_REQUIRED_ERROR');
|
|
87
127
|
}
|
|
88
128
|
if (ch === 0x7f || ch === 0x08) { // DEL or backspace
|
|
89
|
-
bytes
|
|
129
|
+
dropLastUtf8Char(bytes);
|
|
90
130
|
continue;
|
|
91
131
|
}
|
|
92
132
|
bytes.push(ch);
|
package/src/keystore/paths.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { blankToUndef } from '../types.js';
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Resolution order:
|
|
12
|
-
* 1. `$XDG_DATA_HOME/btcr2/keystore.json`
|
|
13
|
-
* 2. `%LOCALAPPDATA%/btcr2/keystore.json` (Windows)
|
|
14
|
-
* 3. `~/.local/share/btcr2/keystore.json` (fallback)
|
|
2
|
+
* The default keystore path now lives alongside the config file under a single
|
|
3
|
+
* CLI home root (`<home>/keystore.json`, ADR 079). The implementation lives in
|
|
4
|
+
* `../paths.ts` (the single source of truth for on-disk state locations); it is
|
|
5
|
+
* re-exported here so existing `./paths.js` importers in the keystore layer keep
|
|
6
|
+
* their import surface.
|
|
15
7
|
*/
|
|
16
|
-
export
|
|
17
|
-
const base = blankToUndef(process.env.XDG_DATA_HOME)
|
|
18
|
-
?? blankToUndef(process.env.LOCALAPPDATA)
|
|
19
|
-
?? join(homedir(), '.local', 'share');
|
|
20
|
-
return join(base, 'btcr2', 'keystore.json');
|
|
21
|
-
}
|
|
8
|
+
export { defaultKeystorePath } from '../paths.js';
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { blankToUndef } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* State-location overrides that influence where the CLI keeps its home
|
|
7
|
+
* directory and its two state files. A subset of the broader
|
|
8
|
+
* `ConnectionOverrides`, restated here so this module (the single source of
|
|
9
|
+
* truth for on-disk locations) has no runtime dependency on `config.ts`.
|
|
10
|
+
*/
|
|
11
|
+
export interface PathOverrides {
|
|
12
|
+
/** Explicit home root from the `--home` flag. Wins over `$BTCR2_HOME`. */
|
|
13
|
+
home? : string;
|
|
14
|
+
/** Explicit config-file path from the `--config` flag. Overrides the home default. */
|
|
15
|
+
config? : string;
|
|
16
|
+
/** Explicit keystore path from the `--keystore` flag. Overrides the home default. */
|
|
17
|
+
keystore? : string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Environment variable naming the CLI home directory (all state colocated). */
|
|
21
|
+
export const ENV_HOME = 'BTCR2_HOME';
|
|
22
|
+
|
|
23
|
+
/** The config and keystore file names, kept side by side under the home root. */
|
|
24
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
25
|
+
export const KEYSTORE_FILENAME = 'keystore.json';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the CLI home directory: the single root that holds `config.json` and
|
|
29
|
+
* `keystore.json` side by side (ADR 079). Resolution order, highest wins:
|
|
30
|
+
*
|
|
31
|
+
* 1. `--home <dir>` (the {@link PathOverrides.home} flag)
|
|
32
|
+
* 2. `$BTCR2_HOME`
|
|
33
|
+
* 3. the platform default (see {@link platformDefaultHome})
|
|
34
|
+
*
|
|
35
|
+
* A blank value at any layer defers to the next, mirroring the `blankToUndef`
|
|
36
|
+
* treatment every other precedence layer uses, so an exported-but-empty
|
|
37
|
+
* `BTCR2_HOME` does not resolve the home to a bare relative path.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveHome(overrides?: PathOverrides): string {
|
|
40
|
+
return blankToUndef(overrides?.home)
|
|
41
|
+
?? blankToUndef(process.env[ENV_HOME])
|
|
42
|
+
?? platformDefaultHome();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The default home when no `--home` / `$BTCR2_HOME` override is present, chosen
|
|
47
|
+
* per OS so the location is idiomatic while staying a single colocated dir:
|
|
48
|
+
*
|
|
49
|
+
* - Windows: `%LOCALAPPDATA%\btcr2` (fallback `%APPDATA%\btcr2`, then the user
|
|
50
|
+
* profile), the native place for per-user application state.
|
|
51
|
+
* - Linux / macOS: `~/.btcr2`, the short, teachable dot-directory in the same
|
|
52
|
+
* family as `~/.ssh`, `~/.aws`, and `~/.gnupg`.
|
|
53
|
+
*/
|
|
54
|
+
export function platformDefaultHome(): string {
|
|
55
|
+
if (process.platform === 'win32') {
|
|
56
|
+
const base = blankToUndef(process.env.LOCALAPPDATA)
|
|
57
|
+
?? blankToUndef(process.env.APPDATA)
|
|
58
|
+
?? homedir();
|
|
59
|
+
return join(base, 'btcr2');
|
|
60
|
+
}
|
|
61
|
+
return join(homedir(), '.btcr2');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default config-file path: `<home>/config.json`. The `--config` flag, when
|
|
66
|
+
* present, overrides it wholesale (it names a specific file, not a home).
|
|
67
|
+
*/
|
|
68
|
+
export function defaultConfigPath(overrides?: PathOverrides): string {
|
|
69
|
+
return join(resolveHome(overrides), CONFIG_FILENAME);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Default keystore path: `<home>/keystore.json`. The `--keystore` flag and a
|
|
74
|
+
* profile's `identity.keystore` (resolved in `config.ts`) override it; this
|
|
75
|
+
* function is the final fallback in that chain.
|
|
76
|
+
*/
|
|
77
|
+
export function defaultKeystorePath(overrides?: PathOverrides): string {
|
|
78
|
+
return join(resolveHome(overrides), KEYSTORE_FILENAME);
|
|
79
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,13 @@ import type { ConfigIssue } from './config-schema.js';
|
|
|
8
8
|
export type NetworkOption = 'bitcoin' | 'testnet3' | 'testnet4' | 'signet' | 'mutinynet' | 'regtest';
|
|
9
9
|
export type OutputFormat = 'json' | 'text';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* How a keystore protects its secrets, as reported by `keystore status` and
|
|
13
|
+
* `btcr2 init`: `encrypted` (passphrase-sealed), `dev` (plaintext, testnet-only),
|
|
14
|
+
* or `absent` (no keystore file yet).
|
|
15
|
+
*/
|
|
16
|
+
export type KeystoreProtectionLabel = 'encrypted' | 'dev' | 'absent';
|
|
17
|
+
|
|
11
18
|
export const SUPPORTED_NETWORKS: NetworkOption[] = [
|
|
12
19
|
'bitcoin', 'testnet3', 'testnet4', 'signet', 'mutinynet', 'regtest'
|
|
13
20
|
];
|
|
@@ -48,6 +55,7 @@ export type CommandResult =
|
|
|
48
55
|
| { action: 'key-export'; data: { keyId: string; publicKey?: string; secretWrittenTo?: string } }
|
|
49
56
|
| { action: 'key-delete'; data: { keyId: string; deleted: true } }
|
|
50
57
|
| { action: 'key-use'; data: { keyId: string; active: true } }
|
|
58
|
+
| { action: 'init'; data: { home: string; config: string; keystore: string; created: string[]; protection: KeystoreProtectionLabel } }
|
|
51
59
|
| { action: 'config-init'; data: { path: string } }
|
|
52
60
|
| { action: 'config-get'; data: unknown }
|
|
53
61
|
| { action: 'config-set'; data: { path: string } }
|
|
@@ -55,8 +63,11 @@ export type CommandResult =
|
|
|
55
63
|
| { action: 'config-list'; data: unknown }
|
|
56
64
|
| { action: 'config-validate'; data: { ok: boolean; issues: ConfigIssue[] } }
|
|
57
65
|
| { action: 'config-effective'; data: EffectiveConfig }
|
|
58
|
-
| { action: 'config-path'; data: { config: string; keystore: string } }
|
|
66
|
+
| { action: 'config-path'; data: { home: string; config: string; keystore: string } }
|
|
59
67
|
| { action: 'config-doctor'; data: DoctorReport }
|
|
68
|
+
| { action: 'keystore-init'; data: { path: string; protection: 'encrypted' | 'dev' } }
|
|
69
|
+
| { action: 'keystore-status'; data: { path: string; protection: KeystoreProtectionLabel; established: boolean; keyCount: number; active: string | undefined } }
|
|
70
|
+
| { action: 'keystore-change-passphrase'; data: { path: string; rekeyed: number } }
|
|
60
71
|
| { action: 'profile-add'; data: { profile: string } }
|
|
61
72
|
| { action: 'profile-use'; data: { profile: string } }
|
|
62
73
|
| { action: 'profile-show'; data: unknown }
|
|
@@ -66,6 +77,7 @@ export interface GlobalOptions {
|
|
|
66
77
|
output : OutputFormat;
|
|
67
78
|
verbose : boolean;
|
|
68
79
|
quiet : boolean;
|
|
80
|
+
home? : string;
|
|
69
81
|
config? : string;
|
|
70
82
|
profile? : string;
|
|
71
83
|
btcRest? : string;
|