@totalreclaw/totalreclaw 3.3.1-rc.9 → 3.3.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/CHANGELOG.md +249 -1
- package/SKILL.md +29 -23
- package/api-client.ts +18 -11
- package/claims-helper.ts +47 -1
- package/config.ts +108 -4
- package/confirm-indexed.ts +191 -0
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +624 -0
- package/dist/config.js +297 -0
- package/dist/confirm-indexed.js +127 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedder-cache.js +185 -0
- package/dist/embedder-loader.js +121 -0
- package/dist/embedder-network.js +301 -0
- package/dist/embedding.js +141 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +725 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5388 -0
- package/dist/llm-client.js +687 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +556 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +409 -0
- package/dist/retype-setscope.js +368 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedder-cache.ts +230 -0
- package/embedder-loader.ts +189 -0
- package/embedder-network.ts +350 -0
- package/embedding.ts +118 -27
- package/fs-helpers.ts +277 -0
- package/gateway-url.ts +57 -9
- package/index.ts +469 -250
- package/llm-client.ts +4 -3
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +24 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/pin.ts +31 -0
- package/qa-bug-report.ts +84 -2
- package/relay-headers.ts +50 -0
- package/reranker.ts +40 -0
- package/retype-setscope.ts +69 -8
- package/skill.json +1 -1
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +15 -10
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.2.0 secure-onboarding CLI wizard.
|
|
3
|
+
*
|
|
4
|
+
* Runs as `openclaw totalreclaw onboard` via OpenClaw's `api.registerCli`
|
|
5
|
+
* plugin hook. Lives on a pure TTY surface — stdout/stderr/stdin — and
|
|
6
|
+
* NEVER routes any of its I/O through the LLM provider, the gateway
|
|
7
|
+
* transcript, or any session-persisted channel. This is the load-bearing
|
|
8
|
+
* property of the 3.2.0 threat model: the recovery phrase is generated,
|
|
9
|
+
* displayed, entered, and persisted ENTIRELY on the user's local machine.
|
|
10
|
+
*
|
|
11
|
+
* Dispatch (registered from `index.ts`):
|
|
12
|
+
* openclaw totalreclaw onboard — interactive generate / import / skip
|
|
13
|
+
* openclaw totalreclaw status — show current onboarding state
|
|
14
|
+
*
|
|
15
|
+
* Scope (per user ratification 2026-04-19):
|
|
16
|
+
* - LOCAL USERS ONLY in 3.2.0. Import on a remote OpenClaw gateway is
|
|
17
|
+
* not supported; QR-pairing is deferred to 3.3.0.
|
|
18
|
+
* - Two paths: generate a new 12-word BIP-39 mnemonic, or import an
|
|
19
|
+
* existing one. Skip exits without side-effects.
|
|
20
|
+
*
|
|
21
|
+
* Non-goals:
|
|
22
|
+
* - This file does NOT touch the gateway's `register()` flow. It neither
|
|
23
|
+
* calls `initialize()` nor drives key derivation. Users re-enter their
|
|
24
|
+
* chat session after running the wizard; `initialize()` reads the new
|
|
25
|
+
* credentials.json on the next `ensureInitialized()` call. Forcing a
|
|
26
|
+
* re-init here would require importing the full plugin key-derivation
|
|
27
|
+
* stack, which this module intentionally avoids.
|
|
28
|
+
*
|
|
29
|
+
* Architectural constraints:
|
|
30
|
+
* - No network. No `process.env` reads. No outbound markers in comments
|
|
31
|
+
* — see skill/scripts/check-scanner.mjs. The module imports nothing
|
|
32
|
+
* that contains a network surface.
|
|
33
|
+
* - Minimal deps. Uses `@scure/bip39` (already a transitive dep via
|
|
34
|
+
* `@totalreclaw/core`) for mnemonic generation + validation. Uses
|
|
35
|
+
* `node:readline/promises` for interactive prompts. No `inquirer`,
|
|
36
|
+
* no `readline-sync`.
|
|
37
|
+
*
|
|
38
|
+
* See docs/plans/2026-04-20-plugin-320-secure-onboarding.md (internal repo,
|
|
39
|
+
* commit dc6bddd) for the full design rationale.
|
|
40
|
+
*/
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import readline from 'node:readline/promises';
|
|
44
|
+
import { generateMnemonic, validateMnemonic } from '@scure/bip39';
|
|
45
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
46
|
+
import { writeCredentialsJson, loadCredentialsJson, writeOnboardingState, loadOnboardingState, } from './fs-helpers.js';
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// User-facing strings (centralised so tests can assert on them + localisation
|
|
49
|
+
// has one place to grow into later).
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* 3.3.1-rc.18 (issue #95) — deprecation warning for the interactive
|
|
53
|
+
* phrase-print branch. Emitted to STDERR (never stdout) so it is visible
|
|
54
|
+
* to humans but does not pollute any pipe consuming the wizard's output.
|
|
55
|
+
*
|
|
56
|
+
* The phrase-print branch will be REMOVED in the next RC after rc.18.
|
|
57
|
+
* Users running on a TTY can still complete the flow in rc.18; agents
|
|
58
|
+
* MUST use `--pair-only` or the `totalreclaw_pair` tool today.
|
|
59
|
+
*/
|
|
60
|
+
export const PHRASE_PRINT_DEPRECATION_WARNING = '\nDEPRECATION (issue #95): the interactive `openclaw totalreclaw onboard` flow\n' +
|
|
61
|
+
' prints your recovery phrase to this terminal. This is being removed in the\n' +
|
|
62
|
+
' next release candidate. For agent / scripted invocation, use:\n' +
|
|
63
|
+
' openclaw totalreclaw onboard --pair-only\n' +
|
|
64
|
+
' which emits ONLY {pair_url, pin} JSON and routes the phrase through the\n' +
|
|
65
|
+
' browser flow (never on stdout).\n\n';
|
|
66
|
+
export const COPY = {
|
|
67
|
+
welcome: '\nTotalReclaw — Secure onboarding\n\n' +
|
|
68
|
+
'TotalReclaw is an end-to-end encrypted memory vault for AI agents.\n' +
|
|
69
|
+
'Your memories are encrypted with a key only you control, stored on-chain\n' +
|
|
70
|
+
'so they persist across sessions and clients.\n',
|
|
71
|
+
menu: 'How would you like to set up TotalReclaw?\n\n' +
|
|
72
|
+
' [1] Generate a new recovery phrase (first-time users)\n' +
|
|
73
|
+
' [2] Import an existing TotalReclaw recovery phrase (returning users)\n' +
|
|
74
|
+
' [3] Skip for now — memory features stay disabled\n',
|
|
75
|
+
menuPrompt: 'Enter 1, 2, or 3: ',
|
|
76
|
+
alreadyActive: '\nTotalReclaw is already set up and active on this machine.\n' +
|
|
77
|
+
'Run `openclaw totalreclaw status` for details, or delete\n' +
|
|
78
|
+
'~/.totalreclaw/credentials.json to start over.\n',
|
|
79
|
+
generateWarning: '\nABOUT YOUR RECOVERY PHRASE\n\n' +
|
|
80
|
+
' - It is the ONLY key to your encrypted memories. TotalReclaw servers\n' +
|
|
81
|
+
' cannot recover it. If you lose it, your memories are gone forever.\n' +
|
|
82
|
+
' - You can import it into other TotalReclaw clients (Hermes, MCP) to\n' +
|
|
83
|
+
' recall the same memories everywhere.\n' +
|
|
84
|
+
' - You can restore your account on a new machine at any time using\n' +
|
|
85
|
+
' this phrase.\n' +
|
|
86
|
+
' - It is NOT a blockchain wallet. Do NOT fund it with crypto, and do\n' +
|
|
87
|
+
' NOT reuse an existing wallet\'s phrase here.\n',
|
|
88
|
+
importWarning: '\nBEFORE YOU PASTE AN EXISTING PHRASE\n\n' +
|
|
89
|
+
' Your TotalReclaw recovery phrase must be DEDICATED to TotalReclaw.\n\n' +
|
|
90
|
+
' - NEVER import a phrase that controls a crypto wallet with funds.\n' +
|
|
91
|
+
' - NEVER import a phrase used with another service (Metamask, Ledger,\n' +
|
|
92
|
+
' Trust, seed backups, etc.).\n' +
|
|
93
|
+
' - If your only copy of a TotalReclaw phrase is on a shared wallet,\n' +
|
|
94
|
+
' STOP NOW, move your funds off the wallet, and pick a new dedicated\n' +
|
|
95
|
+
' phrase.\n',
|
|
96
|
+
importRemoteLimitation: '\nNote: in 3.2.0, import only works when you run OpenClaw locally on the\n' +
|
|
97
|
+
'machine that will hold the credentials. Importing an existing vault on a\n' +
|
|
98
|
+
'remote OpenClaw gateway is not yet supported — that arrives in 3.3.0 via\n' +
|
|
99
|
+
'QR-pairing.\n',
|
|
100
|
+
importPrompt: 'Paste your 12-word recovery phrase (input hidden): ',
|
|
101
|
+
clipboardHint: '\nTip: prefer typing the phrase into a password manager over copy-paste.\n' +
|
|
102
|
+
'OS clipboards can be captured by other apps.\n',
|
|
103
|
+
ackPromptTemplate: 'Type word #%N% from your phrase: ',
|
|
104
|
+
postSuccessGenerate: '\nDone. Your recovery phrase is saved at ~/.totalreclaw/credentials.json\n' +
|
|
105
|
+
'(mode 0600). Memory tools are now active.\n\n' +
|
|
106
|
+
' Next: run `openclaw chat` to start. I will automatically remember\n' +
|
|
107
|
+
' important things and recall relevant context across sessions.\n\n' +
|
|
108
|
+
' To view your phrase again later on this machine, open credentials.json\n' +
|
|
109
|
+
' directly. Keep it safe — on a new machine, run this wizard again and\n' +
|
|
110
|
+
' choose "import" with this phrase.\n',
|
|
111
|
+
postSuccessImport: '\nDone. Your phrase is saved at ~/.totalreclaw/credentials.json (mode 0600).\n' +
|
|
112
|
+
'Memory tools are now active.\n\n' +
|
|
113
|
+
' Next: run `openclaw chat` to start. Existing memories tied to this\n' +
|
|
114
|
+
' phrase will be available via totalreclaw_recall.\n',
|
|
115
|
+
skipped: '\nSkipped. Run `openclaw totalreclaw onboard` anytime to resume.\n' +
|
|
116
|
+
'Memory tools remain disabled until you do.\n',
|
|
117
|
+
ackFailed: '\nWord mismatch. Please write the phrase down carefully and run this\n' +
|
|
118
|
+
'wizard again. No credentials have been written.\n',
|
|
119
|
+
importInvalid: '\nInvalid recovery phrase (12 words required, checksum must match).\n' +
|
|
120
|
+
'No credentials have been written. Run the wizard again with the\n' +
|
|
121
|
+
'correct phrase.\n',
|
|
122
|
+
existingPhraseHint: '\nA recovery phrase already exists at ~/.totalreclaw/credentials.json.\n' +
|
|
123
|
+
'Delete that file first to replace it, or re-import with the same phrase.\n',
|
|
124
|
+
statusHeader: 'TotalReclaw status\n',
|
|
125
|
+
statusFresh: ' onboarding: not complete\n' +
|
|
126
|
+
' next step: run `openclaw totalreclaw onboard` on this machine\n' +
|
|
127
|
+
' note: your recovery phrase will be shown on the terminal, never in chat.\n',
|
|
128
|
+
statusActive: ' onboarding: complete\n' +
|
|
129
|
+
' credentials: ~/.totalreclaw/credentials.json (mode 0600)\n' +
|
|
130
|
+
' state: ~/.totalreclaw/state.json\n',
|
|
131
|
+
};
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// IO factory — default builds a readline-backed prompter over process.stdin/out.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* Build a WizardIo backed by `process.stdin` / `process.stdout`. Hidden
|
|
137
|
+
* input (for the import path) is implemented via direct raw-mode manipulation
|
|
138
|
+
* of the TTY, which is the standard node technique when no prompter library
|
|
139
|
+
* is available. Falls back to non-hidden input if the process is not
|
|
140
|
+
* attached to a TTY (e.g. CI / piped stdin) — the caller's responsibility
|
|
141
|
+
* to decide whether that is OK.
|
|
142
|
+
*/
|
|
143
|
+
export function buildDefaultIo() {
|
|
144
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
145
|
+
const stdout = process.stdout;
|
|
146
|
+
const stderr = process.stderr;
|
|
147
|
+
async function ask(question) {
|
|
148
|
+
return (await rl.question(question)).trim();
|
|
149
|
+
}
|
|
150
|
+
async function askHidden(question) {
|
|
151
|
+
const stdin = process.stdin;
|
|
152
|
+
const isTTY = stdin.isTTY === true;
|
|
153
|
+
if (!isTTY || typeof stdin.setRawMode !== 'function') {
|
|
154
|
+
// No TTY — fall back to visible input with a note. Safer than refusing.
|
|
155
|
+
stdout.write('(input will be visible because stdin is not a TTY)\n');
|
|
156
|
+
return ask(question);
|
|
157
|
+
}
|
|
158
|
+
stdout.write(question);
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const chars = [];
|
|
161
|
+
const onData = (chunk) => {
|
|
162
|
+
const str = chunk.toString('utf-8');
|
|
163
|
+
for (const ch of str) {
|
|
164
|
+
const code = ch.charCodeAt(0);
|
|
165
|
+
if (code === 13 || code === 10) {
|
|
166
|
+
// Enter / newline → finalise
|
|
167
|
+
stdout.write('\n');
|
|
168
|
+
stdin.setRawMode(false);
|
|
169
|
+
stdin.pause();
|
|
170
|
+
stdin.off('data', onData);
|
|
171
|
+
resolve(chars.join('').trim());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (code === 3) {
|
|
175
|
+
// Ctrl-C → propagate SIGINT so the CLI exits cleanly
|
|
176
|
+
stdin.setRawMode(false);
|
|
177
|
+
stdin.pause();
|
|
178
|
+
stdin.off('data', onData);
|
|
179
|
+
process.kill(process.pid, 'SIGINT');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (code === 127 || code === 8) {
|
|
183
|
+
// Backspace / delete
|
|
184
|
+
if (chars.length > 0)
|
|
185
|
+
chars.pop();
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
chars.push(ch);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
stdin.setRawMode(true);
|
|
192
|
+
stdin.resume();
|
|
193
|
+
stdin.on('data', onData);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
stdout,
|
|
198
|
+
stderr,
|
|
199
|
+
ask,
|
|
200
|
+
askHidden,
|
|
201
|
+
close: () => rl.close(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Internals
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
function printMnemonicGrid(mnemonic, out) {
|
|
208
|
+
const words = mnemonic.trim().split(/\s+/);
|
|
209
|
+
const cols = 4;
|
|
210
|
+
const pad = 14;
|
|
211
|
+
const lines = [];
|
|
212
|
+
for (let row = 0; row * cols < words.length; row++) {
|
|
213
|
+
const parts = [];
|
|
214
|
+
for (let col = 0; col < cols; col++) {
|
|
215
|
+
const idx = row * cols + col;
|
|
216
|
+
if (idx >= words.length)
|
|
217
|
+
break;
|
|
218
|
+
const label = `${String(idx + 1).padStart(2, ' ')}. ${words[idx]}`;
|
|
219
|
+
parts.push(label.padEnd(pad, ' '));
|
|
220
|
+
}
|
|
221
|
+
lines.push(' ' + parts.join(''));
|
|
222
|
+
}
|
|
223
|
+
out.write(lines.join('\n') + '\n');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Pick three distinct random word indices (0..11) for the retype-ack challenge.
|
|
227
|
+
* Overridable for tests.
|
|
228
|
+
*/
|
|
229
|
+
function defaultRandomProbeIndices() {
|
|
230
|
+
const pool = [...Array(12).keys()];
|
|
231
|
+
const out = [];
|
|
232
|
+
for (let i = 0; i < 3; i++) {
|
|
233
|
+
const pick = Math.floor(Math.random() * pool.length);
|
|
234
|
+
out.push(pool[pick]);
|
|
235
|
+
pool.splice(pick, 1);
|
|
236
|
+
}
|
|
237
|
+
return out.sort((a, b) => a - b);
|
|
238
|
+
}
|
|
239
|
+
async function runAckChallenge(mnemonic, indices, io) {
|
|
240
|
+
const words = mnemonic.trim().split(/\s+/);
|
|
241
|
+
for (const idx of indices) {
|
|
242
|
+
const ans = await io.ask(COPY.ackPromptTemplate.replace('%N%', String(idx + 1)));
|
|
243
|
+
if (ans.trim().toLowerCase() !== words[idx]) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
function normaliseMnemonic(input) {
|
|
250
|
+
// Collapse whitespace, strip zero-width, lowercase. BIP-39 wordlist is
|
|
251
|
+
// entirely lowercase ASCII; any uppercase the user paste-in gets normalised.
|
|
252
|
+
return input
|
|
253
|
+
.normalize('NFKC')
|
|
254
|
+
.replace(/[\u200B-\u200F\uFEFF]/g, '')
|
|
255
|
+
.toLowerCase()
|
|
256
|
+
.trim()
|
|
257
|
+
.split(/\s+/)
|
|
258
|
+
.join(' ');
|
|
259
|
+
}
|
|
260
|
+
function writeCredsAndState(credentialsPath, statePath, mnemonic, createdBy) {
|
|
261
|
+
const creds = { mnemonic };
|
|
262
|
+
if (!writeCredentialsJson(credentialsPath, creds)) {
|
|
263
|
+
throw new Error(`Could not write credentials.json at ${credentialsPath}. Check that the parent directory is writable.`);
|
|
264
|
+
}
|
|
265
|
+
const state = {
|
|
266
|
+
onboardingState: 'active',
|
|
267
|
+
createdBy,
|
|
268
|
+
credentialsCreatedAt: new Date().toISOString(),
|
|
269
|
+
version: '3.2.0',
|
|
270
|
+
};
|
|
271
|
+
if (!writeOnboardingState(statePath, state)) {
|
|
272
|
+
throw new Error(`Could not write state.json at ${statePath}. Check that the parent directory is writable.`);
|
|
273
|
+
}
|
|
274
|
+
return state;
|
|
275
|
+
}
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Public entry points
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
/**
|
|
280
|
+
* Interactive onboarding wizard. Single shot — caller builds a WizardDeps and
|
|
281
|
+
* awaits. All user-visible output goes to `io.stdout` / `io.stderr`; all
|
|
282
|
+
* input comes from `io.ask` / `io.askHidden`. No console.log calls.
|
|
283
|
+
*
|
|
284
|
+
* Returns a `WizardResult`. Tests can assert on both the result and the
|
|
285
|
+
* stdout buffer.
|
|
286
|
+
*/
|
|
287
|
+
export async function runOnboardingWizard(deps) {
|
|
288
|
+
const { credentialsPath, statePath, io } = deps;
|
|
289
|
+
const genMnemonic = deps.generateMnemonic ?? (() => generateMnemonic(wordlist, 128));
|
|
290
|
+
const validate = deps.validateMnemonic ?? ((p) => validateMnemonic(p, wordlist));
|
|
291
|
+
const probe = deps.randomProbeIndices ?? defaultRandomProbeIndices;
|
|
292
|
+
// If onboarding is already complete on disk, short-circuit instead of
|
|
293
|
+
// letting the user overwrite an existing phrase by accident.
|
|
294
|
+
const existingCreds = loadCredentialsJson(credentialsPath);
|
|
295
|
+
if (existingCreds?.mnemonic && typeof existingCreds.mnemonic === 'string' && existingCreds.mnemonic.trim()) {
|
|
296
|
+
io.stdout.write(COPY.alreadyActive);
|
|
297
|
+
const persisted = loadOnboardingState(statePath);
|
|
298
|
+
return {
|
|
299
|
+
choice: 'skip',
|
|
300
|
+
state: persisted ?? {
|
|
301
|
+
onboardingState: 'active',
|
|
302
|
+
version: '3.2.0',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
io.stdout.write(COPY.welcome);
|
|
307
|
+
io.stdout.write(COPY.menu);
|
|
308
|
+
const choiceRaw = await io.ask(COPY.menuPrompt);
|
|
309
|
+
let choice;
|
|
310
|
+
if (choiceRaw === '1' || choiceRaw.toLowerCase() === 'generate') {
|
|
311
|
+
choice = 'generate';
|
|
312
|
+
}
|
|
313
|
+
else if (choiceRaw === '2' || choiceRaw.toLowerCase() === 'import') {
|
|
314
|
+
choice = 'import';
|
|
315
|
+
}
|
|
316
|
+
else if (choiceRaw === '3' || choiceRaw.toLowerCase() === 'skip') {
|
|
317
|
+
choice = 'skip';
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
io.stderr.write(`\nUnrecognised choice "${choiceRaw}". Aborting.\n`);
|
|
321
|
+
return { choice: 'skip', error: `invalid-choice:${choiceRaw}` };
|
|
322
|
+
}
|
|
323
|
+
if (choice === 'skip') {
|
|
324
|
+
io.stdout.write(COPY.skipped);
|
|
325
|
+
return { choice: 'skip' };
|
|
326
|
+
}
|
|
327
|
+
if (choice === 'generate') {
|
|
328
|
+
// 3.3.1-rc.18 (issue #95) — deprecation banner on stderr ONLY.
|
|
329
|
+
// The phrase-print branch is scheduled for removal in the RC after
|
|
330
|
+
// rc.18; we keep it functional in rc.18 for back-compat with users
|
|
331
|
+
// running the wizard on a real TTY today.
|
|
332
|
+
io.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
333
|
+
io.stdout.write(COPY.generateWarning);
|
|
334
|
+
io.stdout.write(COPY.importRemoteLimitation);
|
|
335
|
+
const mnemonic = genMnemonic();
|
|
336
|
+
if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length !== 12) {
|
|
337
|
+
io.stderr.write('\nInternal error: recovery phrase generator returned an invalid phrase.\n');
|
|
338
|
+
return { choice, error: 'generator-invalid' };
|
|
339
|
+
}
|
|
340
|
+
io.stdout.write('\nYour recovery phrase (WRITE THIS DOWN):\n\n');
|
|
341
|
+
printMnemonicGrid(mnemonic, io.stdout);
|
|
342
|
+
// 3.3.0-rc.2: storage guidance canonical copy — emitted verbatim so
|
|
343
|
+
// the CLI, the browser page, and any future surface share identical
|
|
344
|
+
// wording. See first-run.ts COPY.STORAGE_GUIDANCE.
|
|
345
|
+
io.stdout.write('\n' +
|
|
346
|
+
'Your recovery phrase is 12 words. Store it somewhere safe — a password manager works well. Use it only for TotalReclaw. Don\'t reuse it anywhere else. Don\'t put funds on it.\n');
|
|
347
|
+
io.stdout.write(COPY.clipboardHint);
|
|
348
|
+
io.stdout.write('\n');
|
|
349
|
+
const indices = probe();
|
|
350
|
+
const ok = await runAckChallenge(mnemonic, indices, io);
|
|
351
|
+
if (!ok) {
|
|
352
|
+
io.stderr.write(COPY.ackFailed);
|
|
353
|
+
return { choice, error: 'ack-failed' };
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const state = writeCredsAndState(credentialsPath, statePath, mnemonic, 'generate');
|
|
357
|
+
io.stdout.write(COPY.postSuccessGenerate);
|
|
358
|
+
return { choice, state };
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
362
|
+
io.stderr.write(`\n${msg}\n`);
|
|
363
|
+
return { choice, error: `write-failed:${msg}` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// choice === 'import'
|
|
367
|
+
io.stdout.write(COPY.importWarning);
|
|
368
|
+
io.stdout.write(COPY.importRemoteLimitation);
|
|
369
|
+
const raw = await io.askHidden(COPY.importPrompt);
|
|
370
|
+
const normalised = normaliseMnemonic(raw);
|
|
371
|
+
const words = normalised.split(/\s+/).filter(Boolean);
|
|
372
|
+
if (words.length !== 12 || !validate(normalised)) {
|
|
373
|
+
io.stderr.write(COPY.importInvalid);
|
|
374
|
+
return { choice, error: 'invalid-phrase' };
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const state = writeCredsAndState(credentialsPath, statePath, normalised, 'import');
|
|
378
|
+
io.stdout.write(COPY.postSuccessImport);
|
|
379
|
+
return { choice, state };
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
383
|
+
io.stderr.write(`\n${msg}\n`);
|
|
384
|
+
return { choice, error: `write-failed:${msg}` };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Print the current onboarding status to the given stdout. Safe — never
|
|
389
|
+
* displays the mnemonic; only the state + file paths. Called by both the
|
|
390
|
+
* `openclaw totalreclaw status` CLI subcommand and (if desired) the
|
|
391
|
+
* `totalreclaw_status` tool.
|
|
392
|
+
*/
|
|
393
|
+
export function printStatus(credentialsPath, statePath, out) {
|
|
394
|
+
out.write(COPY.statusHeader);
|
|
395
|
+
const credExists = fs.existsSync(credentialsPath);
|
|
396
|
+
const stateFileExists = fs.existsSync(statePath);
|
|
397
|
+
if (credExists) {
|
|
398
|
+
const creds = loadCredentialsJson(credentialsPath);
|
|
399
|
+
// Accept both canonical `mnemonic` and legacy `recovery_phrase` — same
|
|
400
|
+
// back-compat pattern used by fs-helpers.ts::extractBootstrapMnemonic.
|
|
401
|
+
const hasMnemonic = (typeof creds?.mnemonic === 'string' && creds.mnemonic.trim().length > 0) ||
|
|
402
|
+
(typeof creds?.recovery_phrase === 'string' && creds.recovery_phrase.trim().length > 0);
|
|
403
|
+
if (hasMnemonic) {
|
|
404
|
+
out.write(COPY.statusActive);
|
|
405
|
+
if (stateFileExists) {
|
|
406
|
+
const st = loadOnboardingState(statePath);
|
|
407
|
+
if (st?.credentialsCreatedAt)
|
|
408
|
+
out.write(` created: ${st.credentialsCreatedAt}\n`);
|
|
409
|
+
if (st?.createdBy)
|
|
410
|
+
out.write(` method: ${st.createdBy}\n`);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
out.write(COPY.statusFresh);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Run the onboarding flow without any TTY prompts. Never throws on
|
|
419
|
+
* invalid input — always returns a NonInteractiveOnboardResult so the
|
|
420
|
+
* CLI can render a clear JSON error.
|
|
421
|
+
*
|
|
422
|
+
* Security:
|
|
423
|
+
* - Never writes the phrase to stdout unless `emitPhrase === true`.
|
|
424
|
+
* - Writes credentials.json with mode 0600 via `writeCredentialsJson`.
|
|
425
|
+
* - Does NOT prompt the user. Does NOT call readline.
|
|
426
|
+
*/
|
|
427
|
+
export async function runNonInteractiveOnboard(inputs) {
|
|
428
|
+
const action = inputs.mode;
|
|
429
|
+
const generateFn = inputs.generateMnemonic ?? (() => generateMnemonic(wordlist, 128));
|
|
430
|
+
const validateFn = inputs.validateMnemonic ?? ((p) => validateMnemonic(p, wordlist));
|
|
431
|
+
// Refuse to overwrite an existing active onboarding — matches the
|
|
432
|
+
// interactive wizard's shortcut. Agents can still delete the file
|
|
433
|
+
// themselves if they truly want to re-onboard.
|
|
434
|
+
const existing = loadCredentialsJson(inputs.credentialsPath);
|
|
435
|
+
if (existing?.mnemonic &&
|
|
436
|
+
typeof existing.mnemonic === 'string' &&
|
|
437
|
+
existing.mnemonic.trim().length > 0) {
|
|
438
|
+
return {
|
|
439
|
+
ok: false,
|
|
440
|
+
action,
|
|
441
|
+
error: 'already-active',
|
|
442
|
+
error_detail: `A recovery phrase already exists at ${inputs.credentialsPath}. Delete it first to re-onboard.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
let mnemonic;
|
|
446
|
+
if (action === 'generate') {
|
|
447
|
+
mnemonic = generateFn();
|
|
448
|
+
if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length !== 12) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
action,
|
|
452
|
+
error: 'generator-invalid',
|
|
453
|
+
error_detail: 'generateMnemonic produced an invalid phrase',
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const raw = inputs.phrase ?? '';
|
|
459
|
+
if (!raw.trim()) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
action,
|
|
463
|
+
error: 'missing-phrase',
|
|
464
|
+
error_detail: 'mode=restore requires --phrase <12-or-24-words> or --phrase - (read from stdin).',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const normalised = normaliseMnemonic(raw);
|
|
468
|
+
const words = normalised.split(/\s+/).filter(Boolean);
|
|
469
|
+
if ((words.length !== 12 && words.length !== 24) || !validateFn(normalised)) {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
action,
|
|
473
|
+
error: 'invalid-phrase',
|
|
474
|
+
error_detail: 'phrase must be 12 or 24 BIP-39 words with a valid checksum.',
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
mnemonic = normalised;
|
|
478
|
+
}
|
|
479
|
+
let state;
|
|
480
|
+
try {
|
|
481
|
+
state = writeCredsAndState(inputs.credentialsPath, inputs.statePath, mnemonic, action === 'generate' ? 'generate' : 'import');
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
action,
|
|
487
|
+
error: 'write-failed',
|
|
488
|
+
error_detail: err instanceof Error ? err.message : String(err),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Derive scope address if possible — best-effort, never block success.
|
|
492
|
+
let scopeAddress;
|
|
493
|
+
if (inputs.deriveScopeAddress) {
|
|
494
|
+
try {
|
|
495
|
+
scopeAddress = await inputs.deriveScopeAddress(mnemonic);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
scopeAddress = undefined;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const result = {
|
|
502
|
+
ok: true,
|
|
503
|
+
action,
|
|
504
|
+
credentials_path: inputs.credentialsPath,
|
|
505
|
+
};
|
|
506
|
+
if (scopeAddress)
|
|
507
|
+
result.scope_address = scopeAddress;
|
|
508
|
+
if (inputs.emitPhrase)
|
|
509
|
+
result.mnemonic = mnemonic;
|
|
510
|
+
void state; // Touch for clarity — state is persisted via writeCredsAndState.
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// CLI registration
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
/**
|
|
517
|
+
* Helper the `index.ts` registerCli hook calls to wire both subcommands
|
|
518
|
+
* onto the OpenClaw program. Kept here so the wiring + the wizard live in
|
|
519
|
+
* the same module — index.ts just forwards.
|
|
520
|
+
*
|
|
521
|
+
* `program` is the OpenClaw-provided commander Command. We attach a
|
|
522
|
+
* top-level `totalreclaw` command with `onboard` + `status` subcommands.
|
|
523
|
+
*
|
|
524
|
+
* 3.3.1 — `onboard` now accepts:
|
|
525
|
+
* --non-interactive Fail fast if any input would be prompted for.
|
|
526
|
+
* --json Emit the result as structured JSON.
|
|
527
|
+
* --mode <generate|restore>
|
|
528
|
+
* Skip the menu prompt.
|
|
529
|
+
* --phrase <12-or-24> Required for --mode restore. `-` reads stdin.
|
|
530
|
+
* --emit-phrase Include the plaintext phrase in the JSON payload
|
|
531
|
+
* (NOT recommended — the phrase lives in
|
|
532
|
+
* credentials.json; prefer reading it there).
|
|
533
|
+
*
|
|
534
|
+
* 3.3.1-rc.18 — `onboard` accepts:
|
|
535
|
+
* --pair-only Phrase-safe agent-shell flag (issue #95).
|
|
536
|
+
* Delegates to the pair flow and emits a single
|
|
537
|
+
* line of JSON `{v, pair_url, pin, expires_at_ms}`
|
|
538
|
+
* to stdout. Phrase NEVER touches stdout, stderr,
|
|
539
|
+
* or the logger in this mode. Use this for any
|
|
540
|
+
* agent-driven setup; it is the recommended path
|
|
541
|
+
* when a container-based agent does not have the
|
|
542
|
+
* `totalreclaw_pair` tool injected.
|
|
543
|
+
*
|
|
544
|
+
* Requires `pairSessionsPath` + `renderPairingUrl`
|
|
545
|
+
* to be supplied to `registerOnboardingCli`. If
|
|
546
|
+
* absent, `--pair-only` exits non-zero with a
|
|
547
|
+
* clear message instead of falling through to the
|
|
548
|
+
* phrase-print branch.
|
|
549
|
+
*/
|
|
550
|
+
export function registerOnboardingCli(program, opts) {
|
|
551
|
+
const tr = program
|
|
552
|
+
.command('totalreclaw')
|
|
553
|
+
.description('TotalReclaw encrypted memory — secure onboarding + status');
|
|
554
|
+
tr.command('onboard')
|
|
555
|
+
.description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM). For agent-driven setup prefer --pair-only.')
|
|
556
|
+
.option('--non-interactive', 'Exit non-zero if any input would be prompted for (agent-driven use)')
|
|
557
|
+
.option('--json', 'Emit the result as a structured JSON payload. Only valid with --non-interactive.')
|
|
558
|
+
.option('--mode <mode>', 'generate | restore — skip the menu prompt')
|
|
559
|
+
.option('--phrase <phrase>', 'Recovery phrase for --mode restore. `-` reads from stdin.')
|
|
560
|
+
.option('--emit-phrase', 'Include the plaintext phrase in the JSON payload (not recommended). Default: false.')
|
|
561
|
+
.option('--pair-only', 'Phrase-safe agent-invocation mode (issue #95). Emits ONLY {v,pair_url,pin,expires_at_ms} JSON to stdout via the pair flow. Phrase never touches stdout/stderr/logger. RECOMMENDED for any agent or scripted invocation.')
|
|
562
|
+
.action(async (...actionArgs) => {
|
|
563
|
+
// commander: (options, cmd)
|
|
564
|
+
const cliOpts = (actionArgs[0] ?? {});
|
|
565
|
+
// ---------------------------------------------------------------
|
|
566
|
+
// 3.3.1-rc.18 — `--pair-only` (issue #95)
|
|
567
|
+
//
|
|
568
|
+
// Phrase-safe agent-shell flag. Delegates to the pair flow and
|
|
569
|
+
// emits a single line of JSON `{v, pair_url, pin, expires_at_ms}`
|
|
570
|
+
// to stdout. By construction:
|
|
571
|
+
// - The pair flow is x25519-only — pair-crypto.ts does NOT
|
|
572
|
+
// import @scure/bip39 and never touches a recovery phrase.
|
|
573
|
+
// - No interactive prompts, no readline, no @scure/bip39 import
|
|
574
|
+
// in this code path. Phrase never enters stdout/stderr/logger.
|
|
575
|
+
// - Stays silent on status transitions (the runPairCli
|
|
576
|
+
// `pair-only` output mode suppresses banners, spinners, and
|
|
577
|
+
// all human-readable copy).
|
|
578
|
+
//
|
|
579
|
+
// This MUST be the path agents take when they need to set up
|
|
580
|
+
// TotalReclaw via a shell. The interactive phrase-print branch
|
|
581
|
+
// below is deprecated for that use case and emits a warning when
|
|
582
|
+
// the user falls through to it.
|
|
583
|
+
// ---------------------------------------------------------------
|
|
584
|
+
if (cliOpts.pairOnly) {
|
|
585
|
+
if (!opts.pairSessionsPath || !opts.renderPairingUrl) {
|
|
586
|
+
process.stderr.write('--pair-only is unavailable: this OpenClaw build did not wire the pair flow into the onboard CLI. ' +
|
|
587
|
+
'Use `openclaw totalreclaw pair generate --url-pin-only` instead.\n');
|
|
588
|
+
process.exit(1);
|
|
589
|
+
}
|
|
590
|
+
// Resolve mode. --mode restore is incompatible with --pair-only
|
|
591
|
+
// since pair flow's "import" mode runs in the browser, not in
|
|
592
|
+
// the CLI. Default to 'generate' silently.
|
|
593
|
+
const pairMode = cliOpts.mode === 'restore' || cliOpts.mode === 'import' ? 'import' : 'generate';
|
|
594
|
+
// Lazy import — keeps pair-cli + qrcode-terminal off the
|
|
595
|
+
// onboarding hot path when --pair-only is not used.
|
|
596
|
+
const { runPairCli, defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
|
|
597
|
+
const io = buildDefaultPairCliIo();
|
|
598
|
+
try {
|
|
599
|
+
const outcome = await runPairCli(pairMode, {
|
|
600
|
+
sessionsPath: opts.pairSessionsPath,
|
|
601
|
+
renderPairingUrl: opts.renderPairingUrl,
|
|
602
|
+
renderQr: defaultRenderQr,
|
|
603
|
+
io,
|
|
604
|
+
outputMode: 'pair-only',
|
|
605
|
+
});
|
|
606
|
+
if (outcome.status !== 'completed') {
|
|
607
|
+
process.exit(outcome.status === 'canceled' ? 130 : 1);
|
|
608
|
+
}
|
|
609
|
+
process.exit(0);
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
// CRITICAL: this catch MUST NOT include the phrase, the
|
|
613
|
+
// mnemonic, or any user secret in the message. The pair flow
|
|
614
|
+
// does not produce phrase material, so this is structurally
|
|
615
|
+
// safe — but defense-in-depth: emit a fixed error string.
|
|
616
|
+
opts.logger.error(`pair-only delegation crashed: ${err instanceof Error ? err.message : String(err)}`);
|
|
617
|
+
process.stderr.write('--pair-only failed (see logs).\n');
|
|
618
|
+
process.exit(2);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (cliOpts.nonInteractive) {
|
|
622
|
+
// Non-interactive path — no readline, no prompts.
|
|
623
|
+
const mode = cliOpts.mode === 'generate'
|
|
624
|
+
? 'generate'
|
|
625
|
+
: cliOpts.mode === 'restore' || cliOpts.mode === 'import'
|
|
626
|
+
? 'restore'
|
|
627
|
+
: null;
|
|
628
|
+
if (!mode) {
|
|
629
|
+
const msg = '--non-interactive requires --mode <generate|restore>. ' +
|
|
630
|
+
'Example: openclaw totalreclaw onboard --non-interactive --json --mode generate';
|
|
631
|
+
if (cliOpts.json) {
|
|
632
|
+
process.stdout.write(JSON.stringify({ ok: false, error: 'missing-mode', error_detail: msg }) + '\n');
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
process.stderr.write(msg + '\n');
|
|
636
|
+
}
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
// Resolve phrase input. `-` means read all of stdin.
|
|
640
|
+
let phrase = cliOpts.phrase;
|
|
641
|
+
if (phrase === '-') {
|
|
642
|
+
phrase = await readAllStdin();
|
|
643
|
+
}
|
|
644
|
+
const result = await runNonInteractiveOnboard({
|
|
645
|
+
credentialsPath: opts.credentialsPath,
|
|
646
|
+
statePath: opts.statePath,
|
|
647
|
+
mode,
|
|
648
|
+
phrase,
|
|
649
|
+
emitPhrase: cliOpts.emitPhrase === true,
|
|
650
|
+
deriveScopeAddress: opts.deriveScopeAddress,
|
|
651
|
+
});
|
|
652
|
+
if (cliOpts.json) {
|
|
653
|
+
// 3.3.1-rc.18 (issue #95) — emit deprecation on stderr when
|
|
654
|
+
// the JSON payload is about to include the plaintext phrase.
|
|
655
|
+
// stderr is intentional: stdout must remain a single
|
|
656
|
+
// machine-parseable JSON line.
|
|
657
|
+
if (cliOpts.emitPhrase && result.ok && result.mnemonic) {
|
|
658
|
+
process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
659
|
+
}
|
|
660
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
if (result.ok) {
|
|
664
|
+
if (result.mnemonic) {
|
|
665
|
+
process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
666
|
+
process.stderr.write('WARNING: --emit-phrase was set. The plaintext recovery phrase was returned.\n' +
|
|
667
|
+
'For agent-driven flows, prefer reading ~/.totalreclaw/credentials.json directly ' +
|
|
668
|
+
'in the user\'s terminal instead of echoing the phrase.\n');
|
|
669
|
+
}
|
|
670
|
+
process.stdout.write(`onboarding: ok action=${result.action} ` +
|
|
671
|
+
(result.scope_address ? `scope_address=${result.scope_address} ` : '') +
|
|
672
|
+
`credentials=${result.credentials_path}\n`);
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
process.stderr.write(`onboarding: ${result.error}: ${result.error_detail ?? ''}\n`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
process.exit(result.ok ? 0 : 1);
|
|
679
|
+
}
|
|
680
|
+
if (cliOpts.json) {
|
|
681
|
+
process.stderr.write('--json requires --non-interactive. Use: openclaw totalreclaw onboard --non-interactive --json --mode generate\n');
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
// Interactive path — original 3.2.0 behaviour preserved in full.
|
|
685
|
+
const io = buildDefaultIo();
|
|
686
|
+
try {
|
|
687
|
+
const result = await runOnboardingWizard({
|
|
688
|
+
credentialsPath: opts.credentialsPath,
|
|
689
|
+
statePath: opts.statePath,
|
|
690
|
+
io,
|
|
691
|
+
});
|
|
692
|
+
if (result.error) {
|
|
693
|
+
opts.logger.warn(`onboarding wizard exited with error: ${result.error}`);
|
|
694
|
+
io.close();
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
if (result.choice === 'generate' || result.choice === 'import') {
|
|
698
|
+
opts.logger.info(`onboarding: state=active createdBy=${result.state?.createdBy}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
703
|
+
opts.logger.error(`onboarding wizard crashed: ${msg}`);
|
|
704
|
+
io.close();
|
|
705
|
+
process.exit(2);
|
|
706
|
+
}
|
|
707
|
+
io.close();
|
|
708
|
+
});
|
|
709
|
+
tr.command('status')
|
|
710
|
+
.description('Show TotalReclaw onboarding state — never displays the recovery phrase')
|
|
711
|
+
.action(() => {
|
|
712
|
+
printStatus(opts.credentialsPath, opts.statePath, process.stdout);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// stdin helper — reads until EOF. Used when --phrase=- is given.
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
async function readAllStdin() {
|
|
719
|
+
const chunks = [];
|
|
720
|
+
process.stdin.setEncoding('utf-8');
|
|
721
|
+
return new Promise((resolve) => {
|
|
722
|
+
const onData = (chunk) => chunks.push(chunk);
|
|
723
|
+
const onEnd = () => {
|
|
724
|
+
process.stdin.off('data', onData);
|
|
725
|
+
process.stdin.off('end', onEnd);
|
|
726
|
+
resolve(chunks.join(''));
|
|
727
|
+
};
|
|
728
|
+
process.stdin.on('data', onData);
|
|
729
|
+
process.stdin.on('end', onEnd);
|
|
730
|
+
// If stdin is a TTY with no data (e.g. user forgot to pipe), resolve
|
|
731
|
+
// after a short grace so we don't hang forever. Tests override by
|
|
732
|
+
// piping a string.
|
|
733
|
+
if (process.stdin.isTTY) {
|
|
734
|
+
setTimeout(() => {
|
|
735
|
+
process.stdin.off('data', onData);
|
|
736
|
+
process.stdin.off('end', onEnd);
|
|
737
|
+
resolve(chunks.join(''));
|
|
738
|
+
}, 50);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
// Ensure this module is reachable for import resolution in ESM tests.
|
|
743
|
+
export const __modulePath = (() => {
|
|
744
|
+
try {
|
|
745
|
+
return path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return __dirname;
|
|
749
|
+
}
|
|
750
|
+
})();
|