@did-btcr2/cli 0.17.0 → 0.18.1
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 +33 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +421 -177
- package/dist/esm/src/cli.js +2 -1
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/create.js +4 -0
- package/dist/esm/src/commands/create.js.map +1 -1
- package/dist/esm/src/commands/deactivate.js +2 -0
- package/dist/esm/src/commands/deactivate.js.map +1 -1
- package/dist/esm/src/commands/index.js +1 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/init.js +87 -44
- package/dist/esm/src/commands/init.js.map +1 -1
- package/dist/esm/src/commands/keystore.js +61 -42
- package/dist/esm/src/commands/keystore.js.map +1 -1
- package/dist/esm/src/commands/quickstart.js +158 -0
- package/dist/esm/src/commands/quickstart.js.map +1 -0
- package/dist/esm/src/commands/update.js +2 -0
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config.js +66 -0
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/hints.js +54 -0
- package/dist/esm/src/hints.js.map +1 -0
- package/dist/types/src/cli.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 +1 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/init.d.ts +52 -5
- package/dist/types/src/commands/init.d.ts.map +1 -1
- package/dist/types/src/commands/keystore.d.ts +37 -1
- package/dist/types/src/commands/keystore.d.ts.map +1 -1
- package/dist/types/src/commands/quickstart.d.ts +12 -0
- package/dist/types/src/commands/quickstart.d.ts.map +1 -0
- package/dist/types/src/commands/update.d.ts.map +1 -1
- package/dist/types/src/config.d.ts +35 -0
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/hints.d.ts +24 -0
- package/dist/types/src/hints.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +17 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +2 -0
- package/src/commands/create.ts +4 -0
- package/src/commands/deactivate.ts +2 -0
- package/src/commands/index.ts +1 -0
- package/src/commands/init.ts +146 -54
- package/src/commands/keystore.ts +95 -55
- package/src/commands/quickstart.ts +209 -0
- package/src/commands/update.ts +2 -0
- package/src/config.ts +75 -0
- package/src/hints.ts +51 -0
- package/src/types.ts +12 -1
package/src/commands/keystore.ts
CHANGED
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
MAX_SESSION_TTL_MS,
|
|
18
18
|
parseTtlToMs,
|
|
19
19
|
readSessionStatus,
|
|
20
|
+
type SessionFile,
|
|
20
21
|
writeSession,
|
|
21
22
|
} from '../keystore/session.js';
|
|
22
23
|
import { formatResult } from '../output.js';
|
|
23
24
|
import { defaultSessionPath } from '../paths.js';
|
|
24
|
-
import { blankToUndef, type CommandResult, type GlobalOptions } from '../types.js';
|
|
25
|
+
import { blankToUndef, type CommandResult, type GlobalOptions, type NetworkOption } from '../types.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Registers the `keystore` command group: establish, inspect, and re-key the
|
|
@@ -129,62 +130,17 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
|
|
|
129
130
|
.action((options: { ttl?: string; allowMainnet?: boolean }) => {
|
|
130
131
|
const g = globals();
|
|
131
132
|
const path = resolveKeystorePath(g);
|
|
132
|
-
const summary = keystoreSummary(path);
|
|
133
|
-
if (summary.protection === 'absent') {
|
|
134
|
-
throw new CLIError(`No keystore at ${path}. Run "btcr2 init" or "btcr2 keystore init" first.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
135
|
-
}
|
|
136
|
-
if (summary.protection === 'dev') {
|
|
137
|
-
throw new CLIError(
|
|
138
|
-
`The keystore at ${path} is an unencrypted dev keystore; it has no passphrase to cache, so no unlock is needed.`,
|
|
139
|
-
'INVALID_ARGUMENT_ERROR',
|
|
140
|
-
{ path },
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
if (!summary.established) {
|
|
144
|
-
throw new CLIError(
|
|
145
|
-
`The keystore at ${path} has no passphrase established yet. `
|
|
146
|
-
+ 'Establish one with "btcr2 keystore init" or the first "btcr2 key generate".',
|
|
147
|
-
'INVALID_ARGUMENT_ERROR',
|
|
148
|
-
{ path },
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
// An unlocked encrypted keystore signs prompt-free for the whole TTL,
|
|
152
|
-
// silently removing per-use passphrase auth. Two guards, both keyed to
|
|
153
|
-
// --allow-mainnet (ADR 081): this early refusal when the *configured* default
|
|
154
|
-
// network is mainnet (a clear signal before caching anything), plus the
|
|
155
|
-
// authoritative one at consumption, where the session records `allowMainnet`
|
|
156
|
-
// (below) and a `bitcoin` operation, whose network is derived from the DID
|
|
157
|
-
// rather than the config, is withheld from a session that lacks it. The
|
|
158
|
-
// active network defaults to a testnet, so this early refusal never fires
|
|
159
|
-
// for the demo.
|
|
160
|
-
if (!options.allowMainnet && resolveDefaultNetwork(g) === 'bitcoin') {
|
|
161
|
-
throw new CLIError(
|
|
162
|
-
`Refusing to unlock for a mainnet (bitcoin) context: caching the passphrase suspends per-use `
|
|
163
|
-
+ 'authentication for the session. Pass --allow-mainnet to override, or keep signing mainnet '
|
|
164
|
-
+ 'updates with a per-use passphrase prompt.',
|
|
165
|
-
'MAINNET_UNLOCK_REFUSED_ERROR',
|
|
166
|
-
{ path },
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
133
|
const ttlMs = resolveSessionTtl(options.ttl);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
const verifierId = keystoreVerifierId(path);
|
|
178
|
-
if (!verifierId) {
|
|
179
|
-
// An established keystore always carries a verifier; defensive guard.
|
|
180
|
-
throw new CLIError(`The keystore at ${path} has no verifier to bind a session to.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
181
|
-
}
|
|
182
|
-
const session = writeSession(defaultSessionPath(g), {
|
|
134
|
+
// The op network for the mainnet gate is the configured default here; the
|
|
135
|
+
// session records `allowMainnet` and the authoritative check happens at
|
|
136
|
+
// consumption, where a `bitcoin` operation (network derived from the DID) is
|
|
137
|
+
// withheld from a session that lacks it.
|
|
138
|
+
const session = unlockSession({
|
|
139
|
+
g,
|
|
183
140
|
keystorePath : path,
|
|
184
|
-
|
|
185
|
-
passphrase,
|
|
186
|
-
ttlMs,
|
|
141
|
+
network : resolveDefaultNetwork(g),
|
|
187
142
|
allowMainnet : !!options.allowMainnet,
|
|
143
|
+
ttlMs,
|
|
188
144
|
});
|
|
189
145
|
print({ action: 'keystore-unlock', data: { keystore: path, expiresAt: session.expiresAt, ttlSeconds: session.ttlSeconds } });
|
|
190
146
|
});
|
|
@@ -202,13 +158,97 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
|
|
|
202
158
|
});
|
|
203
159
|
}
|
|
204
160
|
|
|
161
|
+
/** Input for the shared {@link unlockSession} step. */
|
|
162
|
+
export interface UnlockSessionInput {
|
|
163
|
+
g : GlobalOptions;
|
|
164
|
+
/** The resolved keystore path to unlock. */
|
|
165
|
+
keystorePath : string;
|
|
166
|
+
/** The operation network for the mainnet gate, passed explicitly (never re-derived). */
|
|
167
|
+
network : NetworkOption;
|
|
168
|
+
/** Whether a mainnet (bitcoin) unlock is permitted (`--allow-mainnet`). */
|
|
169
|
+
allowMainnet : boolean;
|
|
170
|
+
/** Session lifetime in milliseconds (already resolved via {@link resolveSessionTtl}). */
|
|
171
|
+
ttlMs : number;
|
|
172
|
+
/**
|
|
173
|
+
* A pre-acquired passphrase to reuse instead of prompting (the establish-time
|
|
174
|
+
* passphrase from `quickstart --unlock` on a fresh keystore). Still verified
|
|
175
|
+
* against the keystore verifier before caching.
|
|
176
|
+
*/
|
|
177
|
+
passphrase? : string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The shared unlock step behind `keystore unlock` and `quickstart --unlock` (ADR
|
|
182
|
+
* 081/083): validate the keystore, enforce the mainnet gate against the passed
|
|
183
|
+
* `network`, acquire (or reuse) and verify the passphrase, and write the session.
|
|
184
|
+
* Refuses an absent, dev, or unestablished keystore. A wrong passphrase writes no
|
|
185
|
+
* session file. Returns the written {@link SessionFile}. The op network is passed
|
|
186
|
+
* in rather than re-derived so the mainnet gate is order-independent even when a
|
|
187
|
+
* caller has just written `defaults.network`.
|
|
188
|
+
*/
|
|
189
|
+
export function unlockSession(input: UnlockSessionInput): SessionFile {
|
|
190
|
+
const { g, keystorePath: path, network, allowMainnet, ttlMs } = input;
|
|
191
|
+
const summary = keystoreSummary(path);
|
|
192
|
+
if (summary.protection === 'absent') {
|
|
193
|
+
throw new CLIError(`No keystore at ${path}. Run "btcr2 init" or "btcr2 keystore init" first.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
194
|
+
}
|
|
195
|
+
if (summary.protection === 'dev') {
|
|
196
|
+
throw new CLIError(
|
|
197
|
+
`The keystore at ${path} is an unencrypted dev keystore; it has no passphrase to cache, so no unlock is needed.`,
|
|
198
|
+
'INVALID_ARGUMENT_ERROR',
|
|
199
|
+
{ path },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!summary.established) {
|
|
203
|
+
throw new CLIError(
|
|
204
|
+
`The keystore at ${path} has no passphrase established yet. `
|
|
205
|
+
+ 'Establish one with "btcr2 keystore init" or the first "btcr2 key generate".',
|
|
206
|
+
'INVALID_ARGUMENT_ERROR',
|
|
207
|
+
{ path },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
// An unlocked encrypted keystore signs prompt-free for the whole TTL, silently
|
|
211
|
+
// removing per-use passphrase auth. Refuse a bitcoin context unless allowed; the
|
|
212
|
+
// authoritative per-use check still happens at consumption from the session's
|
|
213
|
+
// recorded `allowMainnet` (ADR 081).
|
|
214
|
+
if (!allowMainnet && network === 'bitcoin') {
|
|
215
|
+
throw new CLIError(
|
|
216
|
+
'Refusing to unlock for a mainnet (bitcoin) context: caching the passphrase suspends per-use '
|
|
217
|
+
+ 'authentication for the session. Pass --allow-mainnet to override, or keep signing mainnet '
|
|
218
|
+
+ 'updates with a per-use passphrase prompt.',
|
|
219
|
+
'MAINNET_UNLOCK_REFUSED_ERROR',
|
|
220
|
+
{ path },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
// Acquire the passphrase directly (env / file / prompt) with NO session
|
|
224
|
+
// consultation and NO confirm, or reuse a caller-provided one, then verify it
|
|
225
|
+
// against the keystore verifier before caching. A wrong passphrase writes no
|
|
226
|
+
// session file.
|
|
227
|
+
const passphrase = input.passphrase ?? acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Keystore passphrase: ' });
|
|
228
|
+
if (!verifyKeystorePassphrase(path, passphrase)) {
|
|
229
|
+
throw new CLIError(`Incorrect passphrase for the keystore at ${path}; no session was created.`, 'DECRYPT_ERROR', { path });
|
|
230
|
+
}
|
|
231
|
+
const verifierId = keystoreVerifierId(path);
|
|
232
|
+
if (!verifierId) {
|
|
233
|
+
// An established keystore always carries a verifier; defensive guard.
|
|
234
|
+
throw new CLIError(`The keystore at ${path} has no verifier to bind a session to.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
235
|
+
}
|
|
236
|
+
return writeSession(defaultSessionPath(g), {
|
|
237
|
+
keystorePath : path,
|
|
238
|
+
verifierId,
|
|
239
|
+
passphrase,
|
|
240
|
+
ttlMs,
|
|
241
|
+
allowMainnet,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
/**
|
|
206
246
|
* Resolves the session TTL in milliseconds from the `--ttl` flag, then
|
|
207
247
|
* `$BTCR2_KEYSTORE_TTL`, then the one-hour default. Rejects a non-positive,
|
|
208
248
|
* malformed, or over-24h value with a {@link CLIError} that names the actual
|
|
209
249
|
* source (the flag or the env var) so the operator fixes the right input.
|
|
210
250
|
*/
|
|
211
|
-
function resolveSessionTtl(flag?: string): number {
|
|
251
|
+
export function resolveSessionTtl(flag?: string): number {
|
|
212
252
|
const fromFlag = blankToUndef(flag);
|
|
213
253
|
const raw = fromFlag ?? blankToUndef(process.env[ENV_KEYSTORE_TTL]);
|
|
214
254
|
if (raw === undefined) return DEFAULT_SESSION_TTL_MS;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { faucetUrl } from '@did-btcr2/api';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import {
|
|
4
|
+
assertSupportedNetwork,
|
|
5
|
+
readConfiguredDefaultNetwork,
|
|
6
|
+
runDoctor,
|
|
7
|
+
type DoctorReport,
|
|
8
|
+
} from '../config.js';
|
|
9
|
+
import { CLIError } from '../error.js';
|
|
10
|
+
import { keystoreVerifierId } from '../keystore/file-key-store.js';
|
|
11
|
+
import { ENV_KEYSTORE_TTL, readSessionStatus } from '../keystore/session.js';
|
|
12
|
+
import { formatResult } from '../output.js';
|
|
13
|
+
import { defaultSessionPath } from '../paths.js';
|
|
14
|
+
import type { CommandResult, GlobalOptions, NetworkOption } from '../types.js';
|
|
15
|
+
import { runInit, type RunInitResult } from './init.js';
|
|
16
|
+
import { resolveSessionTtl, unlockSession } from './keystore.js';
|
|
17
|
+
|
|
18
|
+
/** The opinionated default network for `quickstart`: zero local infra, a free faucet, 30s blocks. */
|
|
19
|
+
const QUICKSTART_DEFAULT_NETWORK: NetworkOption = 'mutinynet';
|
|
20
|
+
|
|
21
|
+
/** The session sub-object reported in the quickstart envelope. */
|
|
22
|
+
type SessionReport = { expiresAt: number; ttlSeconds: number };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Registers the top-level `btcr2 quickstart` (ADR 083): a one-command onboarding
|
|
26
|
+
* that COMPOSES the existing primitives - the {@link runInit} scaffold, the
|
|
27
|
+
* network record, the optional {@link unlockSession} cache, and the advisory
|
|
28
|
+
* {@link runDoctor} probe - into a single step for a workshop follow-along.
|
|
29
|
+
* Reimplements nothing; the ADR 080/081 keystore and session guarantees hold by
|
|
30
|
+
* construction.
|
|
31
|
+
*/
|
|
32
|
+
export function registerQuickstartCommand(program: Command, globals: () => GlobalOptions): void {
|
|
33
|
+
const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command('quickstart')
|
|
37
|
+
.description('One-command onboarding: create the home + config + keystore, record the network, and (optionally) cache the session and probe endpoints.')
|
|
38
|
+
.option(
|
|
39
|
+
'-n, --network <network>',
|
|
40
|
+
'Bitcoin network to set up <bitcoin|testnet3|testnet4|signet|mutinynet|regtest> (default: mutinynet)',
|
|
41
|
+
)
|
|
42
|
+
.option('--dev', 'Establish an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only.', false)
|
|
43
|
+
.option('--unlock', 'Cache the passphrase for the session so later commands do not re-prompt (ADR 081).', false)
|
|
44
|
+
.option('--ttl <duration>', `Session lifetime with --unlock: bare seconds or an s/m/h suffix (default 1h, max 24h). Also $${ENV_KEYSTORE_TTL}.`)
|
|
45
|
+
.option('--no-doctor', 'Skip the endpoint reachability probe.')
|
|
46
|
+
.option('--allow-mainnet', 'Permit -n bitcoin (records mainnet as the default; dev keystores are still refused).', false)
|
|
47
|
+
.option('--force', 'Re-create the config even if it already exists (never the keystore).', false)
|
|
48
|
+
.action(async (options: {
|
|
49
|
+
network? : string;
|
|
50
|
+
dev? : boolean;
|
|
51
|
+
unlock? : boolean;
|
|
52
|
+
ttl? : string;
|
|
53
|
+
doctor : boolean; // commander sets false for --no-doctor, true otherwise
|
|
54
|
+
allowMainnet? : boolean;
|
|
55
|
+
force? : boolean;
|
|
56
|
+
}) => {
|
|
57
|
+
const g = globals();
|
|
58
|
+
const explicit = options.network ? assertSupportedNetwork(options.network) : undefined;
|
|
59
|
+
// The network quickstart will operate on, computed BEFORE any write so the
|
|
60
|
+
// mainnet guard sees the real target: explicit -n, else an existing
|
|
61
|
+
// defaults.network, else the mutinynet default. runInit resolves to the
|
|
62
|
+
// same value.
|
|
63
|
+
const network = explicit ?? readConfiguredDefaultNetwork(g) ?? QUICKSTART_DEFAULT_NETWORK;
|
|
64
|
+
|
|
65
|
+
// Mainnet is guarded before any files are written (ADR 083). A dev keystore
|
|
66
|
+
// never operates on mainnet; an encrypted mainnet setup needs the explicit
|
|
67
|
+
// opt-in that also gates the session-unlock mainnet suspension.
|
|
68
|
+
if (network === 'bitcoin') {
|
|
69
|
+
if (options.dev) {
|
|
70
|
+
throw new CLIError(
|
|
71
|
+
'Refusing to quickstart a mainnet (bitcoin) dev keystore: dev keystores store keys in plaintext '
|
|
72
|
+
+ 'and never operate on mainnet. Drop --dev, or choose a testnet with -n.',
|
|
73
|
+
'MAINNET_QUICKSTART_REFUSED_ERROR',
|
|
74
|
+
{ network },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (!options.allowMainnet) {
|
|
78
|
+
throw new CLIError(
|
|
79
|
+
'Refusing to quickstart on mainnet (bitcoin) without --allow-mainnet. Pass --allow-mainnet to '
|
|
80
|
+
+ 'record mainnet as the default, or choose a testnet with -n (the default is mutinynet).',
|
|
81
|
+
'MAINNET_QUICKSTART_REFUSED_ERROR',
|
|
82
|
+
{ network },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 1-2. Scaffold and record the network. An explicit -n always writes; a
|
|
88
|
+
// merely-defaulted mutinynet writes only when defaults.network is unset.
|
|
89
|
+
const init = runInit(g, {
|
|
90
|
+
dev : options.dev,
|
|
91
|
+
force : options.force,
|
|
92
|
+
network : explicit,
|
|
93
|
+
fallbackNetwork : explicit ? undefined : QUICKSTART_DEFAULT_NETWORK,
|
|
94
|
+
captureEstablishedPassphrase : !!options.unlock && !options.dev,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 3. Optionally cache the session (ADR 081 opt-in; never on a dev keystore).
|
|
98
|
+
let unlocked = false;
|
|
99
|
+
let session: SessionReport | undefined;
|
|
100
|
+
if (options.unlock && !options.dev) {
|
|
101
|
+
const outcome = cacheSession(g, init, options.ttl, !!options.allowMainnet);
|
|
102
|
+
unlocked = outcome.unlocked;
|
|
103
|
+
session = outcome.session;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Advisory endpoint probe (on by default; a failed probe warns, exit 0).
|
|
107
|
+
let doctor: DoctorReport | undefined;
|
|
108
|
+
if (options.doctor) {
|
|
109
|
+
doctor = await runDoctor(init.network, g);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
print({
|
|
113
|
+
action : 'quickstart',
|
|
114
|
+
data : {
|
|
115
|
+
home : init.home,
|
|
116
|
+
config : init.config,
|
|
117
|
+
keystore : init.keystore,
|
|
118
|
+
network : init.network,
|
|
119
|
+
created : init.created,
|
|
120
|
+
protection : init.protection,
|
|
121
|
+
unlocked,
|
|
122
|
+
...(session ? { session } : {}),
|
|
123
|
+
...(doctor ? { doctor } : {}),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!g.quiet && g.output !== 'json') {
|
|
128
|
+
printNextSteps(init, unlocked, session, doctor);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Caches the session for `quickstart --unlock`. On a fresh keystore, reuses the
|
|
135
|
+
* establish-time confirmed passphrase (no second prompt). On an existing keystore
|
|
136
|
+
* with a live matching session, skips (idempotent re-run). Otherwise acquires and
|
|
137
|
+
* verifies the passphrase. In a non-interactive context with no passphrase source
|
|
138
|
+
* on an existing keystore, the step is a non-fatal skip (warn, `unlocked: false`),
|
|
139
|
+
* so `quickstart` still exits 0 (ADR 083).
|
|
140
|
+
*/
|
|
141
|
+
function cacheSession(
|
|
142
|
+
g : GlobalOptions,
|
|
143
|
+
init : RunInitResult,
|
|
144
|
+
ttlFlag : string | undefined,
|
|
145
|
+
allowMainnet : boolean,
|
|
146
|
+
): { unlocked: boolean; session?: SessionReport } {
|
|
147
|
+
const ttlMs = resolveSessionTtl(ttlFlag);
|
|
148
|
+
const freshlyEstablished = init.created.includes('keystore');
|
|
149
|
+
|
|
150
|
+
// Existing encrypted keystore already unlocked: report it and skip re-writing.
|
|
151
|
+
if (!freshlyEstablished) {
|
|
152
|
+
const status = readSessionStatus(defaultSessionPath(g), init.keystore, keystoreVerifierId(init.keystore));
|
|
153
|
+
if (status.active && status.expiresAt !== undefined) {
|
|
154
|
+
return { unlocked: true, session: { expiresAt: status.expiresAt, ttlSeconds: status.secondsRemaining ?? 0 } };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const written = unlockSession({
|
|
160
|
+
g,
|
|
161
|
+
keystorePath : init.keystore,
|
|
162
|
+
network : init.network,
|
|
163
|
+
allowMainnet,
|
|
164
|
+
ttlMs,
|
|
165
|
+
// Reuse the establish-time passphrase on a fresh keystore: no second prompt.
|
|
166
|
+
passphrase : freshlyEstablished ? init.establishedPassphrase : undefined,
|
|
167
|
+
});
|
|
168
|
+
return { unlocked: true, session: { expiresAt: written.expiresAt, ttlSeconds: written.ttlSeconds } };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// On an EXISTING keystore with no passphrase source and no terminal, caching
|
|
171
|
+
// is a non-fatal skip: the scaffold already succeeded (ADR 083). A fresh
|
|
172
|
+
// keystore cannot reach here (its passphrase was just captured), and an
|
|
173
|
+
// interactive wrong passphrase still propagates.
|
|
174
|
+
const type = (error as { type?: string }).type;
|
|
175
|
+
if (!freshlyEstablished && !process.stdin.isTTY && type === 'PASSPHRASE_REQUIRED_ERROR') {
|
|
176
|
+
if (!g.quiet) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
'note: no passphrase source and no terminal; skipped caching the session. '
|
|
179
|
+
+ 'Run "btcr2 keystore unlock" later to cache it.\n',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { unlocked: false };
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Prints the text-mode next-step hints after quickstart (ADR 082/083). */
|
|
189
|
+
function printNextSteps(
|
|
190
|
+
init : RunInitResult,
|
|
191
|
+
unlocked : boolean,
|
|
192
|
+
session : SessionReport | undefined,
|
|
193
|
+
doctor : DoctorReport | undefined,
|
|
194
|
+
): void {
|
|
195
|
+
const lines: string[] = [`btcr2 home ready at ${init.home} on ${init.network}.`];
|
|
196
|
+
if (unlocked && session) {
|
|
197
|
+
lines.push(`Session cached until ${new Date(session.expiresAt).toISOString()}; signing will not re-prompt until it expires.`);
|
|
198
|
+
}
|
|
199
|
+
if (init.protection === 'dev') {
|
|
200
|
+
lines.push('Dev keystore: keys are stored in plaintext; mainnet operations are refused.');
|
|
201
|
+
}
|
|
202
|
+
if (doctor && doctor.checks.some((c) => !c.ok)) {
|
|
203
|
+
lines.push('Warning: one or more endpoints were unreachable (see the doctor report). Re-run "btcr2 config doctor" for detail.');
|
|
204
|
+
}
|
|
205
|
+
lines.push('Next: btcr2 key generate --name demo --set-active');
|
|
206
|
+
const faucet = faucetUrl(init.network);
|
|
207
|
+
if (faucet) lines.push(`Faucet (fund your beacon after "btcr2 create"): ${faucet}`);
|
|
208
|
+
process.stderr.write(`${lines.join('\n')}\n`);
|
|
209
|
+
}
|
package/src/commands/update.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { KeyManagerSigner } from '@did-btcr2/key-manager';
|
|
|
3
3
|
import type { Command } from 'commander';
|
|
4
4
|
import { assertKeystoreAllowedForNetwork, deriveNetwork, resolveBroadcastOptions, resolveSigningKeyRef, type ApiFactory } from '../config.js';
|
|
5
5
|
import { CLIError } from '../error.js';
|
|
6
|
+
import { printWatchHint } from '../hints.js';
|
|
6
7
|
import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
|
|
7
8
|
import { formatResult } from '../output.js';
|
|
8
9
|
import type { GlobalOptions, UpdateCommandOptions } from '../types.js';
|
|
@@ -117,6 +118,7 @@ export function registerUpdateCommand(
|
|
|
117
118
|
...(broadcastOptions ? { broadcastOptions } : {}),
|
|
118
119
|
});
|
|
119
120
|
console.log(formatResult({ action: 'update', data }, globals()));
|
|
121
|
+
printWatchHint(globals(), network, data.txid);
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
|
package/src/config.ts
CHANGED
|
@@ -398,6 +398,81 @@ export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkO
|
|
|
398
398
|
return 'regtest';
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The network recorded at `defaults.network` in the config file, validated, or
|
|
403
|
+
* `undefined` when the file is absent, malformed, or the value is unset/unknown.
|
|
404
|
+
* Unlike {@link resolveDefaultNetwork} this consults ONLY the raw
|
|
405
|
+
* `defaults.network` (no profile fallback, no regtest default), so `quickstart`
|
|
406
|
+
* can distinguish "the operator set a default" from "there is none yet" before
|
|
407
|
+
* it writes (ADR 083). Never throws: a malformed config is surfaced loudly by
|
|
408
|
+
* the write path, so this pre-write read stays quiet.
|
|
409
|
+
*/
|
|
410
|
+
export function readConfiguredDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption | undefined {
|
|
411
|
+
const configPath = overrides?.config ?? defaultConfigPath(overrides);
|
|
412
|
+
let raw: Record<string, unknown> | undefined;
|
|
413
|
+
try {
|
|
414
|
+
raw = parseConfigFileRaw(configPath);
|
|
415
|
+
} catch {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const value = raw ? getConfigPath(raw, 'defaults.network') : undefined;
|
|
419
|
+
return typeof value === 'string' && SUPPORTED_NETWORKS.includes(value as NetworkOption)
|
|
420
|
+
? value as NetworkOption
|
|
421
|
+
: undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Persists `defaults.network` idempotently and returns the resolved network,
|
|
426
|
+
* the shared network-recording step behind `btcr2 init -n` and `btcr2 quickstart`
|
|
427
|
+
* (ADR 083). Writes when `explicit` (an explicit `-n`) is given, or when a
|
|
428
|
+
* `fallback` is given and the raw config has no `defaults.network` yet. Keyed on
|
|
429
|
+
* the **raw** file value (not {@link resolveDefaultNetwork}, which never returns
|
|
430
|
+
* undefined), so a defaulted re-run never clobbers a network the operator set
|
|
431
|
+
* earlier. When neither condition writes, the existing default is returned
|
|
432
|
+
* unchanged. Assumes `configPath` names a parseable config (the caller has just
|
|
433
|
+
* scaffolded one, or an existing one that a malformed-JSON read surfaces loudly).
|
|
434
|
+
*/
|
|
435
|
+
export function persistDefaultNetwork(
|
|
436
|
+
configPath : string,
|
|
437
|
+
opts : { explicit?: NetworkOption; fallback?: NetworkOption; overrides?: ConnectionOverrides },
|
|
438
|
+
): { network: NetworkOption; wrote: boolean } {
|
|
439
|
+
const raw = parseConfigFileRaw(configPath);
|
|
440
|
+
const rawValue = raw ? getConfigPath(raw, 'defaults.network') : undefined;
|
|
441
|
+
const rawNetwork = typeof rawValue === 'string' && SUPPORTED_NETWORKS.includes(rawValue as NetworkOption)
|
|
442
|
+
? rawValue as NetworkOption
|
|
443
|
+
: undefined;
|
|
444
|
+
|
|
445
|
+
if (opts.explicit) {
|
|
446
|
+
if (opts.explicit !== rawNetwork) {
|
|
447
|
+
writeConfigFile(configPath, (r) => setConfigPath(r, 'defaults.network', opts.explicit));
|
|
448
|
+
return { network: opts.explicit, wrote: true };
|
|
449
|
+
}
|
|
450
|
+
return { network: opts.explicit, wrote: false };
|
|
451
|
+
}
|
|
452
|
+
if (rawNetwork) return { network: rawNetwork, wrote: false };
|
|
453
|
+
if (opts.fallback) {
|
|
454
|
+
writeConfigFile(configPath, (r) => setConfigPath(r, 'defaults.network', opts.fallback));
|
|
455
|
+
return { network: opts.fallback, wrote: true };
|
|
456
|
+
}
|
|
457
|
+
return { network: resolveDefaultNetwork(opts.overrides), wrote: false };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Validates an explicit network string against {@link SUPPORTED_NETWORKS},
|
|
462
|
+
* returning it typed as a {@link NetworkOption} or throwing a {@link CLIError}.
|
|
463
|
+
* Shared by the `-n/--network` flags on `init` and `quickstart` (ADR 083).
|
|
464
|
+
*/
|
|
465
|
+
export function assertSupportedNetwork(value: string): NetworkOption {
|
|
466
|
+
if (!SUPPORTED_NETWORKS.includes(value as NetworkOption)) {
|
|
467
|
+
throw new CLIError(
|
|
468
|
+
`Invalid network "${value}". Must be one of ${SUPPORTED_NETWORKS.join(', ')}.`,
|
|
469
|
+
'INVALID_ARGUMENT_ERROR',
|
|
470
|
+
{ network: value },
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return value as NetworkOption;
|
|
474
|
+
}
|
|
475
|
+
|
|
401
476
|
/**
|
|
402
477
|
* Reports a coherence conflict between the network a `create` run is about to
|
|
403
478
|
* encode and the network the active profile declares, so the CLI can warn
|
package/src/hints.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { explorerAddressUrl, explorerTxUrl, faucetUrl } from '@did-btcr2/api';
|
|
2
|
+
import { BeaconUtils } from '@did-btcr2/method';
|
|
3
|
+
import type { GlobalOptions, NetworkOption } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Text-mode stderr hints derived from the per-network presets (ADR 082). All of
|
|
7
|
+
* these are suppressed under `--quiet` and `--output json` so machine output is
|
|
8
|
+
* never touched, and they never throw: a presentation hint must never break the
|
|
9
|
+
* command that produced the real result.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Prints a funding hint after a KEY `create` on a network with a public faucet:
|
|
14
|
+
* the derived initial P2WPKH beacon address next to the faucet and explorer
|
|
15
|
+
* links the operator would otherwise hand-copy. A no-op on a network without a
|
|
16
|
+
* faucet (regtest/mainnet), which also keeps mainnet from showing a fund-me
|
|
17
|
+
* affordance. The beacon address is derived from the DID string alone via
|
|
18
|
+
* {@link BeaconUtils.createBeaconService}, so it matches the resolver's
|
|
19
|
+
* `#initialP2WPKH` service rather than a divergent re-derivation.
|
|
20
|
+
*/
|
|
21
|
+
export function printCreateFundingHint(g: GlobalOptions, network: NetworkOption, did: string): void {
|
|
22
|
+
if (g.quiet || g.output === 'json') return;
|
|
23
|
+
const faucet = faucetUrl(network);
|
|
24
|
+
if (!faucet) return;
|
|
25
|
+
let beaconAddress: string;
|
|
26
|
+
try {
|
|
27
|
+
const { serviceEndpoint } = BeaconUtils.createBeaconService(did, 'p2wpkh', 'SingletonBeacon');
|
|
28
|
+
beaconAddress = serviceEndpoint.replace(/^bitcoin:/, '');
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const explorer = explorerAddressUrl(network, beaconAddress);
|
|
33
|
+
const lines = [
|
|
34
|
+
'Fund the initial beacon to anchor updates:',
|
|
35
|
+
` Beacon: ${beaconAddress}`,
|
|
36
|
+
` Faucet: ${faucet}`,
|
|
37
|
+
];
|
|
38
|
+
if (explorer) lines.push(` Explorer: ${explorer}`);
|
|
39
|
+
process.stderr.write(`${lines.join('\n')}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prints a watch link after an `update`/`deactivate` broadcast: the
|
|
44
|
+
* block-explorer URL for the signal txid. A no-op on a network without an
|
|
45
|
+
* explorer (regtest).
|
|
46
|
+
*/
|
|
47
|
+
export function printWatchHint(g: GlobalOptions, network: NetworkOption, txid: string): void {
|
|
48
|
+
if (g.quiet || g.output === 'json') return;
|
|
49
|
+
const url = explorerTxUrl(network, txid);
|
|
50
|
+
if (url) process.stderr.write(`Watch: ${url}\n`);
|
|
51
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -56,7 +56,18 @@ export type CommandResult =
|
|
|
56
56
|
| { action: 'key-export'; data: { keyId: string; publicKey?: string; secretWrittenTo?: string } }
|
|
57
57
|
| { action: 'key-delete'; data: { keyId: string; deleted: true } }
|
|
58
58
|
| { action: 'key-use'; data: { keyId: string; active: true } }
|
|
59
|
-
| { action: 'init'; data: { home: string; config: string; keystore: string; created: string[]; protection: KeystoreProtectionLabel } }
|
|
59
|
+
| { action: 'init'; data: { home: string; config: string; keystore: string; network: NetworkOption; created: string[]; protection: KeystoreProtectionLabel } }
|
|
60
|
+
| { action: 'quickstart'; data: {
|
|
61
|
+
home : string;
|
|
62
|
+
config : string;
|
|
63
|
+
keystore : string;
|
|
64
|
+
network : NetworkOption;
|
|
65
|
+
created : string[];
|
|
66
|
+
protection : KeystoreProtectionLabel;
|
|
67
|
+
unlocked : boolean;
|
|
68
|
+
session? : { expiresAt: number; ttlSeconds: number };
|
|
69
|
+
doctor? : DoctorReport;
|
|
70
|
+
} }
|
|
60
71
|
| { action: 'config-init'; data: { path: string } }
|
|
61
72
|
| { action: 'config-get'; data: unknown }
|
|
62
73
|
| { action: 'config-set'; data: { path: string } }
|