mnemonad-cli 0.1.1 → 0.3.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.
@@ -0,0 +1,91 @@
1
+ import { join } from 'node:path';
2
+ import {
3
+ Searcher, VectorIndex, DEFAULT_DB_NAME, createProvider, resolveModelName, MissingExtensionError,
4
+ } from '../search/index.js';
5
+ import { resolvePath, folderExists } from './shared.js';
6
+
7
+ export async function search(args) {
8
+ if (!args.query) {
9
+ throw userError('a search query is required (e.g. `mnemonad search /path/to/folder "how does chunking work"`)');
10
+ }
11
+ const destPath = resolvePath(args.path, { mustExist: true });
12
+ const dbName = args.searchDb || DEFAULT_DB_NAME;
13
+ const dbPath = join(destPath, dbName);
14
+
15
+ if (!folderExists(destPath) || !(await _fileExists(dbPath))) {
16
+ throw userError(
17
+ `no ${dbName} found in ${destPath}\n` +
18
+ ' Run `mnemonad index <path>` first — either yours, or whoever pushed this stream\'s\n' +
19
+ ' (a pull already brings theirs down with the rest of the folder).'
20
+ );
21
+ }
22
+
23
+ // The query has to be embedded by the model that built the index — which the index records,
24
+ // so there's nothing to choose. An explicit --model that disagrees is a mistake worth
25
+ // naming rather than a preference to honour.
26
+ const indexModel = VectorIndex.readMeta(dbPath)?.model;
27
+ if (args.model && indexModel && resolveModelName(args.model) !== indexModel) {
28
+ throw userError(
29
+ `${dbName} was built with ${indexModel}; a search has to use the same model — drop --model\n` +
30
+ ' (search always uses the index\'s own), or rebuild the index with the other one:\n' +
31
+ ` mnemonad index ${destPath} --model ${args.model} --rebuild`
32
+ );
33
+ }
34
+
35
+ const searcher = new Searcher({ createProvider });
36
+ const k = args.limit ? Number(args.limit) : 5;
37
+ let hits;
38
+ try {
39
+ hits = await searcher.search(dbPath, args.query, k);
40
+ } catch (err) {
41
+ if (err instanceof MissingExtensionError) {
42
+ throw userError(
43
+ `this folder's index was built with ${err.model}, which ${err.message.slice(err.message.indexOf('needs'))}\n` +
44
+ ' Or rebuild the index with the built-in model:\n' +
45
+ ` mnemonad index ${destPath} --rebuild`
46
+ );
47
+ }
48
+ throw err;
49
+ }
50
+
51
+ if (hits.length === 0) {
52
+ console.log(' no matches');
53
+ return;
54
+ }
55
+
56
+ for (const [i, hit] of hits.entries()) {
57
+ console.log(`\n${i + 1}. ${hit.path} (chunk ${hit.chunkIndex}, distance ${hit.distance.toFixed(4)})`);
58
+ const snippet = await _readSnippet(destPath, hit);
59
+ if (snippet) console.log(' ' + snippet.replace(/\s+/g, ' ').slice(0, 200));
60
+ }
61
+ }
62
+
63
+ async function _fileExists(path) {
64
+ try {
65
+ const { stat } = await import('node:fs/promises');
66
+ const s = await stat(path);
67
+ return s.isFile();
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /** Re-reads the hit's snippet straight from the already-pulled source file — the index
74
+ * deliberately doesn't store chunk text itself (see ../search/VectorIndex.js),
75
+ * so this is the one place that cost is paid, and only for hits actually shown. */
76
+ async function _readSnippet(destPath, hit) {
77
+ try {
78
+ const { readFile } = await import('node:fs/promises');
79
+ const bytes = await readFile(join(destPath, hit.path));
80
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
81
+ return text.slice(hit.startOffset, hit.endOffset);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function userError(message) {
88
+ const err = new Error(message);
89
+ err._isUserError = true;
90
+ return err;
91
+ }
@@ -1,4 +1,5 @@
1
1
  import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { createHash } from 'node:crypto';
4
5
  import { homedir } from 'node:os';
@@ -11,7 +12,7 @@ import { DoubleSync as SyncEngine, CDCStore, DoubleSyncSnapshot as SyncSnapshot
11
12
  import { FSFolder } from '../FSFolder.js';
12
13
  import { createPresignProvider } from '../presignProvider.js';
13
14
 
14
- const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
15
+ export const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
15
16
 
16
17
  export function makeExcludes(args) {
17
18
  if (!args.exclude) return undefined;
@@ -56,15 +57,112 @@ export function resolveOffloadParams(args, account) {
56
57
  * `p` is missing — every command that touches a folder requires the caller to name it
57
58
  * explicitly, so a typo'd or omitted `path` never silently operates on whatever directory
58
59
  * the CLI happened to be run from.
60
+ *
61
+ * `mustExist` additionally requires the folder to be there already. Every command that
62
+ * *reads* the local folder passes it, because the alternative is worse than an error in
63
+ * both directions: `push`/`compact` used to surface a raw ENOENT stack trace from deep
64
+ * inside `readdir`, and `diff` used to treat a missing folder as an empty one and report
65
+ * every file in the stream as locally deleted — which reads as "your data is gone" rather
66
+ * than "you typed the wrong path". `pull` is the one command that leaves it off, since
67
+ * creating the destination is exactly its job.
68
+ *
69
+ * @param {?string} p
70
+ * @param {Object} [options]
71
+ * @param {boolean} [options.mustExist=false]
59
72
  */
60
- export function resolvePath(p) {
73
+ export function resolvePath(p, { mustExist = false } = {}) {
61
74
  if (!p) {
62
- const err = new Error('a folder path is required (e.g. `mnemonad <command> ... /path/to/folder`)');
63
- err._isUserError = true;
64
- throw err;
75
+ throw userError('a folder path is required (e.g. `mnemonad <command> ... /path/to/folder`)');
76
+ }
77
+
78
+ const resolved = p.startsWith('~') ? join(homedir(), p.slice(1)) : resolve(p);
79
+ if (!mustExist) return resolved;
80
+
81
+ const stat = statOrNull(resolved);
82
+ if (!stat) {
83
+ throw userError(
84
+ `no such folder: ${resolved}\n` +
85
+ ' Create it first, or use `mnemonad pull <stream-id> <path>` to fetch one into it.'
86
+ );
87
+ }
88
+ if (!stat.isDirectory()) {
89
+ throw userError(`not a folder: ${resolved}\n This command syncs a directory, not a single file.`);
90
+ }
91
+ return resolved;
92
+ }
93
+
94
+ /** @returns {?import('node:fs').Stats} null when the path doesn't exist or isn't readable. */
95
+ function statOrNull(path) {
96
+ try {
97
+ return statSync(path);
98
+ } catch {
99
+ return null;
65
100
  }
66
- if (p.startsWith('~')) return join(homedir(), p.slice(1));
67
- return resolve(p);
101
+ }
102
+
103
+ /** Whether `path` is an existing directory. For a command that reports on one rather than
104
+ * refusing to run without it (see info.js). */
105
+ export function folderExists(path) {
106
+ return statOrNull(path)?.isDirectory() === true;
107
+ }
108
+
109
+ /**
110
+ * Canonical display form for a stream id: `0x` + the full 32-byte token id, zero-padded,
111
+ * lowercase. Matches the dApp's own formatting (explorer/src/includes/formatStreamId.js),
112
+ * unelided — a CLI command needs the whole id to actually reuse, where the dApp's UI only
113
+ * needs enough of it to recognize at a glance.
114
+ *
115
+ * Takes whatever a stream id shows up as in this codebase — a bigint (`mn.id`), or a raw
116
+ * CLI arg that's either a decimal or a `0x`-hex string — since `BigInt()` parses all three
117
+ * natively; see bin/mnemonad.js's own `isStreamId` for why both string forms are accepted
118
+ * as input in the first place.
119
+ *
120
+ * @param {bigint|string} id
121
+ * @returns {string}
122
+ */
123
+ export function formatStreamId(id) {
124
+ return '0x' + BigInt(id).toString(16).padStart(64, '0');
125
+ }
126
+
127
+ /**
128
+ * Whether two stream ids refer to the same token, regardless of which form either was
129
+ * written in (decimal, hex, differently-padded hex) — a plain `===` on the raw strings
130
+ * would wrongly call `123` and `0x7b` different streams.
131
+ */
132
+ export function streamIdsEqual(a, b) {
133
+ if (a == null || b == null) return a === b;
134
+ return BigInt(a) === BigInt(b);
135
+ }
136
+
137
+ /** Networks a qualified stream id can name — see `parseQualifiedStreamId` below. Kept in
138
+ * sync by hand with `chainClient.js`'s own `CHAINS` map (which also lists `local`, never
139
+ * meaningful here — no URL is ever built with it) and with the explorer's
140
+ * `src/settings.js` `NETWORKS` map, the source these prefixes actually come from. */
141
+ export const NETWORK_PREFIXES = ['testnet', 'mainnet'];
142
+
143
+ /**
144
+ * Splits the network-qualified stream-id form the explorer's URLs use — `<network>-0x<hex>`
145
+ * (or `<network>-<decimal>`), e.g. `testnet-0x648f18…` — into its network and bare id. Same
146
+ * split the explorer's own `streamRouteId()`/`parseStreamRouteId()`
147
+ * (`explorer/src/includes/streamRoute.js`) do, so an id copied straight out of a
148
+ * `/stream/<id>` link parses here too, instead of failing `BigInt()` on the whole string.
149
+ *
150
+ * Returns null for anything that isn't this exact shape — an unqualified id, a malformed
151
+ * one, or a prefix that isn't a known network (not a stream on some network this build
152
+ * hasn't heard of, just not this form).
153
+ *
154
+ * @param {string} s
155
+ * @returns {{network: string, id: string}|null}
156
+ */
157
+ export function parseQualifiedStreamId(s) {
158
+ if (typeof s !== 'string') return null;
159
+ const dash = s.indexOf('-');
160
+ if (dash === -1) return null;
161
+ const prefix = s.slice(0, dash).toLowerCase();
162
+ if (!NETWORK_PREFIXES.includes(prefix)) return null;
163
+ const rest = s.slice(dash + 1);
164
+ if (!/^0x[0-9a-fA-F]+$/.test(rest) && !/^\d+$/.test(rest)) return null;
165
+ return { network: prefix, id: rest };
68
166
  }
69
167
 
70
168
  export function formatBytes(bytes) {
@@ -107,9 +205,10 @@ export async function assertStreamOwner(mn, signerAddress) {
107
205
  if (getAddress(owner) === getAddress(signerAddress)) return;
108
206
 
109
207
  const err = new Error(
110
- `stream ${mn.id} is owned by ${owner}\n` +
208
+ `stream ${formatStreamId(mn.id)} is owned by ${owner}\n` +
111
209
  ` but you are signing as ${signerAddress}.\n` +
112
- ` Nothing was uploaded. Use the owner's key (--key / --phrase / MNEMONAD_KEY),\n` +
210
+ ` Nothing was uploaded. Use the owner's key (--key, --phrase, or the MNEMONAD_KEY\n` +
211
+ ` env var, set to the private key),\n` +
113
212
  ` or run push without a stream id to create your own stream.`
114
213
  );
115
214
  err._isUserError = true;
@@ -199,14 +298,15 @@ export async function unlockStream(mn, credentials = {}) {
199
298
  if (!signer) {
200
299
  throw userError(
201
300
  'This stream is encrypted to its owner\'s wallet. Provide the owner\'s key with\n' +
202
- ' --key / --phrase / MNEMONAD_KEY to decrypt it.'
301
+ ' --key, --phrase, or the MNEMONAD_KEY env var (set it to the private key) to decrypt it.'
203
302
  );
204
303
  }
205
304
  await mn.unlockWithSignature(signer);
206
305
  return true;
207
306
  }
208
307
 
209
- function userError(message) {
308
+ /** An error the CLI prints as a plain message (no stack trace) — see bin/mnemonad.js. */
309
+ export function userError(message) {
210
310
  const err = new Error(message);
211
311
  err._isUserError = true;
212
312
  return err;
@@ -244,6 +344,32 @@ export async function writeManifest(destPath, manifest) {
244
344
  await writeFile(join(destPath, '.mnemonad'), JSON.stringify(manifest, null, 2));
245
345
  }
246
346
 
347
+ /**
348
+ * Recovers a stream id from the folder's own `.mnemonad` manifest when `--manifest` was
349
+ * passed and no id was given on the command line — the whole point of writing the manifest
350
+ * is that the id doesn't have to be remembered or re-typed for every later command on the
351
+ * same folder. Mutates `args.streamId` in place (every call site reads it back off `args`
352
+ * afterward) and is a no-op whenever an id was already given or `--manifest` wasn't passed —
353
+ * so it's safe to call unconditionally right after resolving the folder path.
354
+ *
355
+ * Silent when there's nothing to find (no `.mnemonad` yet, e.g. a folder's very first push,
356
+ * or a manifest with no `streamId`) — the caller's own "stream-id is required" error covers
357
+ * that case with the wording appropriate to its command.
358
+ *
359
+ * @param {Object} args - parsed CLI args, mutated in place
360
+ * @param {string} destPath - resolved folder path to look for `.mnemonad` in
361
+ * @returns {Promise<?string>} the resolved stream id, or whatever `args.streamId` already was
362
+ */
363
+ export async function discoverStreamId(args, destPath) {
364
+ if (args.streamId || !args.manifest) return args.streamId;
365
+ const manifest = await readManifest(destPath);
366
+ if (manifest?.streamId) {
367
+ console.log(' using stream id from .mnemonad manifest:', formatStreamId(manifest.streamId));
368
+ args.streamId = manifest.streamId;
369
+ }
370
+ return args.streamId;
371
+ }
372
+
247
373
  export const DISK_EXCLUDES = new Set(['.mnemonad', '.git', '.DS_Store', 'node_modules']);
248
374
 
249
375
  export async function hashDiskTree(dirPath, prefix = []) {
@@ -14,7 +14,11 @@ import {
14
14
  assertStreamOwner,
15
15
  unlockStream,
16
16
  resolveOffloadParams,
17
+ formatStreamId,
18
+ discoverStreamId,
19
+ userError,
17
20
  } from './shared.js';
21
+ import { makeIndexer, updateIndex } from './buildIndex.js';
18
22
 
19
23
  function timestamp() {
20
24
  return new Date().toTimeString().slice(0, 8);
@@ -27,17 +31,31 @@ function hashesEqual(a, b) {
27
31
  }
28
32
 
29
33
  export async function watch(args) {
30
- if (!args.streamId) throw new Error('stream-id is required for watch');
34
+ const cwd = resolvePath(args.path, { mustExist: true });
31
35
 
32
- const cwd = resolvePath(args.path);
36
+ // With --manifest and no id, a folder pulled/pushed before already has one recorded —
37
+ // no need to require it again.
38
+ await discoverStreamId(args, cwd);
39
+ if (!args.streamId) throw new Error('stream-id is required for watch');
33
40
  const debounceMs = args.debounce ?? 5000;
34
41
  const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
35
42
  const pushEnabled = !args.pullOnly;
36
43
  const pullEnabled = !args.pushOnly;
37
44
 
45
+ if (args.index && !pushEnabled) {
46
+ throw userError('--index only applies when watch pushes — drop --pull-only to use it');
47
+ }
48
+ if (args.rebuild && !args.index) {
49
+ throw userError('--rebuild only applies with --index (it starts the search index over)');
50
+ }
51
+ // One indexer for the whole session: the embedding model loads on the first change and
52
+ // then stays in memory, so every later re-index costs only the files that changed.
53
+ // Built now so bad chunking flags or a missing extension fail before watching starts.
54
+ const indexer = args.index ? await makeIndexer(cwd, args) : null;
55
+
38
56
  const excludes = makeExcludes(args);
39
57
 
40
- const client = makeChainClient(args);
58
+ const client = await makeChainClient(args);
41
59
  if (pushEnabled) requireWalletClient(client);
42
60
 
43
61
  // Only a push-enabled session needs write-side offload capability (pinata-jwt or
@@ -70,10 +88,11 @@ export async function watch(args) {
70
88
  let debounceTimer = null;
71
89
 
72
90
  console.log(`watching ${formatPath(cwd)}`);
73
- console.log(` stream: ${args.streamId}`);
91
+ console.log(` stream: ${formatStreamId(args.streamId)}`);
74
92
  console.log(` remote version: ${remoteVersion}`);
75
93
  console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
76
94
  console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
95
+ if (indexer) console.log(' index: updated before every push');
77
96
  console.log('press Ctrl+C to stop\n');
78
97
 
79
98
  async function runPush() {
@@ -83,13 +102,34 @@ export async function watch(args) {
83
102
 
84
103
  pushInFlight = true;
85
104
  try {
86
- // Best-effort race guard, not an atomic one: Mnemonad's own contract has no
87
- // "expected prior length" check — no atomic on-chain compare-and-abort.
88
- // Re-checking here narrows the window where a concurrent push (another `watch`
89
- // session, or a manual push) could make MonadSync build a diff against a base
90
- // the chain has since moved past — the same failure mode monadsync's own
91
- // snapshot-recovery tests exercise — but doesn't close it entirely; only an
92
- // on-chain guard could.
105
+ // Before the push, so the push carries it. The index file's own write then fires
106
+ // the watcher again, but by then lastSyncedHash (taken after indexing, below)
107
+ // already includes it, so that second pass finds nothing new and doesn't push.
108
+ // A failed index skips the push rather than publishing a stale index; the next
109
+ // change retries both.
110
+ let hashToSync = newHash;
111
+ if (indexer) {
112
+ try {
113
+ // Never a rebuild here — `--rebuild` applies once, to the startup pass below.
114
+ await updateIndex(cwd, args, { indexer, quiet: true, rebuild: false });
115
+ } catch (err) {
116
+ console.error(`[${timestamp()}] index update failed, not pushing:`, err.message);
117
+ return;
118
+ }
119
+ hashToSync = await localTreeHash(cwd);
120
+ }
121
+
122
+ // Best-effort race guard, not an atomic one. Mnemonad's contract *does* check an
123
+ // `expectedLength` atomically (Mnemonad.sol's `_checkLength`/`LengthMismatch`) —
124
+ // that closes the index race outright: two writers can never land at the same
125
+ // position, one just reverts. What it can't check is content: it stores opaque
126
+ // bytes, with no way to verify a diff patch is actually consistent with the real
127
+ // previous item. Re-checking here narrows the separate window where a concurrent
128
+ // push (another `watch` session, or a manual push) lands at the *correct* index
129
+ // but MonadSync still builds its diff against a snapshot the chain has since
130
+ // moved past — the same failure mode monadsync's own snapshot-recovery tests
131
+ // exercise. Confirmed live as a real corruption, not just theoretical — see
132
+ // monadsync/README.md's "Repairing a corrupt chain".
93
133
  mn.reInitialize();
94
134
  await mn.initialize();
95
135
  if (mn.length !== remoteVersion) {
@@ -102,7 +142,7 @@ export async function watch(args) {
102
142
  const fsFolder = new FSFolder(cwd, excludes);
103
143
  console.log(`[${timestamp()}] change detected — pushing...`);
104
144
  const result = await monadSync.push(fsFolder);
105
- lastSyncedHash = newHash;
145
+ lastSyncedHash = hashToSync;
106
146
  remoteVersion = result.version;
107
147
  console.log(`[${timestamp()}] pushed version ${result.version}`);
108
148
  } catch (err) {
@@ -164,6 +204,20 @@ export async function watch(args) {
164
204
 
165
205
  const poller = setInterval(runPoll, pollIntervalMs);
166
206
 
207
+ // An index that's already out of date when the session starts (files edited, or indexed
208
+ // for the first time) would otherwise only catch up on the next local change.
209
+ if (indexer) {
210
+ try {
211
+ const result = await updateIndex(cwd, args, { indexer, quiet: true, rebuild: !!args.rebuild });
212
+ if (result.filesChanged || result.filesRemoved) {
213
+ console.log(`[${timestamp()}] index was out of date — pushing the update`);
214
+ debounceTimer = setTimeout(runPush, 0);
215
+ }
216
+ } catch (err) {
217
+ console.error(`[${timestamp()}] index update failed:`, err.message);
218
+ }
219
+ }
220
+
167
221
  let shutdownResolve;
168
222
  const shutdownPromise = new Promise((resolve) => {
169
223
  shutdownResolve = resolve;
@@ -0,0 +1,203 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { WebSocketServer } from 'ws';
4
+ import open from 'open';
5
+ import { toAccount } from 'viem/accounts';
6
+ import { userError } from './commands/shared.js';
7
+ import { encodeForBridge } from './bridgeCodec.js';
8
+
9
+ const CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
10
+
11
+ // One bridge per CLI process — `--passkey` resolves at most one signing account per
12
+ // invocation (see resolveAccount in chainClient.js), so there's nothing to key this by.
13
+ let active = null;
14
+
15
+ /** A one-line "what's this for" the browser tab shows while it waits — just the command
16
+ * shape, not the flags, so it stays short. */
17
+ function describeCommand(args) {
18
+ const parts = ['mnemonad', args.command];
19
+ if (args.streamId) parts.push(args.streamId);
20
+ if (args.path) parts.push(args.path);
21
+ return parts.join(' ');
22
+ }
23
+
24
+ /**
25
+ * The CLI half of `--passkey`: opens the explorer's `/cli-auth` page in the system browser
26
+ * to run the WebAuthn ceremony there (this process has no `navigator.credentials` — passkeys
27
+ * only exist in a browser), then relays every signing request to that tab for the rest of
28
+ * this command. The raw key never enters this process, only signatures do — see
29
+ * docs/wallet/passkey-accounts.md's CLI section for why it has to be that domain specifically
30
+ * (rp.id) and why this is a one-shot-per-command bridge rather than something that persists
31
+ * across separate CLI invocations.
32
+ *
33
+ * @param {Object} args - parsed CLI args: `authOrigin` (the explorer deployment to open, e.g.
34
+ * https://mnemonad.vercel.app), plus `command`/`streamId`/`path` to build the one-line
35
+ * description the page shows while it waits.
36
+ * @returns {Promise<Object>} a viem Account whose signMessage/signTransaction proxy to the tab
37
+ */
38
+ export async function openPasskeyBridge(args) {
39
+ if (active) return active.account;
40
+
41
+ const token = randomBytes(24).toString('hex');
42
+ // Read by /ping below — the one thing the page can't get from the WS (it doesn't exist
43
+ // yet at that point, see docs/wallet/passkey-accounts.md's CLI section) but still wants
44
+ // live: how much of the connect-timeout is actually left, and confirmation the CLI is
45
+ // still there at all.
46
+ const deadlineAt = Date.now() + CONNECT_TIMEOUT_MS;
47
+ const httpServer = createServer((req, res) => {
48
+ const url = new URL(req.url, 'http://127.0.0.1');
49
+ if (url.pathname === '/ping' && url.searchParams.get('token') === token) {
50
+ res.writeHead(200, {
51
+ 'Content-Type': 'application/json',
52
+ // The page is served from the explorer's own origin, not this loopback one —
53
+ // an ordinary fetch() response is otherwise unreadable cross-origin. The token
54
+ // above is what actually gates this, same as the WS connection itself; the
55
+ // wildcard just matches that (no meaningful "origin" to restrict to when the
56
+ // caller could be any explorer deployment this token was ever handed to).
57
+ 'Access-Control-Allow-Origin': '*',
58
+ });
59
+ res.end(JSON.stringify({ ttl: Math.max(0, Math.round((deadlineAt - Date.now()) / 1000)) }));
60
+ return;
61
+ }
62
+ res.writeHead(404);
63
+ res.end();
64
+ });
65
+ const wss = new WebSocketServer({ server: httpServer });
66
+
67
+ await new Promise((resolve, reject) => {
68
+ httpServer.once('error', reject);
69
+ httpServer.listen(0, '127.0.0.1', resolve);
70
+ });
71
+ const port = httpServer.address().port;
72
+
73
+ const pending = new Map();
74
+ let nextId = 1;
75
+ let socket = null;
76
+
77
+ const connected = new Promise((resolve, reject) => {
78
+ const timer = setTimeout(() => {
79
+ // Nothing ever reached the point (below) that assigns `active`, so
80
+ // closePasskeyBridge() has nothing to close on this path — without this, the
81
+ // server/socket this function opened above would sit open forever, and the CLI
82
+ // process would hang right after printing the error below rather than exiting.
83
+ wss.close();
84
+ httpServer.close();
85
+ reject(userError(
86
+ 'Timed out waiting for the browser passkey sign-in. Complete the prompt in the\n' +
87
+ ' tab that opened, or re-run with --passkey to try again.'
88
+ ));
89
+ }, CONNECT_TIMEOUT_MS);
90
+
91
+ wss.on('connection', (ws, req) => {
92
+ const url = new URL(req.url, 'http://127.0.0.1');
93
+ if (url.searchParams.get('token') !== token) {
94
+ ws.close(4001, 'bad token');
95
+ return;
96
+ }
97
+ socket = ws;
98
+
99
+ ws.on('message', (raw) => {
100
+ let msg;
101
+ try {
102
+ msg = JSON.parse(raw.toString());
103
+ } catch {
104
+ return;
105
+ }
106
+ if (msg.type === 'hello') {
107
+ clearTimeout(timer);
108
+ resolve(msg.address);
109
+ } else if (msg.type === 'sign-result' || msg.type === 'sign-error') {
110
+ const p = pending.get(msg.id);
111
+ if (!p) return;
112
+ pending.delete(msg.id);
113
+ if (msg.type === 'sign-result') p.resolve(msg.result);
114
+ else p.reject(new Error(msg.error || 'signing failed'));
115
+ }
116
+ });
117
+
118
+ ws.on('close', () => {
119
+ if (socket === ws) socket = null;
120
+ for (const p of pending.values()) p.reject(userError('Browser tab disconnected before signing finished.'));
121
+ pending.clear();
122
+ });
123
+ });
124
+ });
125
+
126
+ const desc = describeCommand(args);
127
+ const ttl = Math.floor(CONNECT_TIMEOUT_MS / 1000);
128
+ // Lets the result screen turn a stream id in its summary line into a link to the
129
+ // explorer's own stream page — only for a real deployment id (testnet/mainnet); --chain
130
+ // local has no explorer route to link to, so the page just falls back to plain text.
131
+ const network = args.chain === 'testnet' || args.chain === 'mainnet' ? `&network=${args.chain}` : '';
132
+ const authUrl = `${args.authOrigin.replace(/\/$/, '')}/cli-auth?port=${port}&token=${token}&desc=${encodeURIComponent(desc)}&ttl=${ttl}${network}`;
133
+ console.log(' opening', authUrl, 'to sign in with a passkey...');
134
+ try {
135
+ await open(authUrl);
136
+ } catch {
137
+ console.log(' could not open a browser automatically — open this URL yourself:', authUrl);
138
+ }
139
+
140
+ const address = await connected;
141
+ console.log(' connected:', address);
142
+
143
+ function request(type, payload) {
144
+ if (!socket) {
145
+ return Promise.reject(userError('Browser tab disconnected. Re-run with --passkey to reconnect.'));
146
+ }
147
+ return new Promise((resolve, reject) => {
148
+ const id = nextId++;
149
+ pending.set(id, { resolve, reject });
150
+ socket.send(JSON.stringify({ type, id, ...payload }));
151
+ });
152
+ }
153
+
154
+ const account = toAccount({
155
+ address,
156
+ async signMessage({ message }) {
157
+ return request('sign-message', { message });
158
+ },
159
+ async signTransaction(transaction) {
160
+ return request('sign-transaction', { transaction: encodeForBridge(transaction) });
161
+ },
162
+ async signTypedData() {
163
+ // Not used anywhere in this codebase (personal_sign only — see the "Wallet-signature
164
+ // encryption keys" plan's locked-in decision) — a real relay implementation would
165
+ // need the same BigInt-safe encoding sign-transaction already has, not worth building
166
+ // for a path nothing calls.
167
+ throw userError('Signing typed data is not supported over --passkey.');
168
+ },
169
+ });
170
+
171
+ active = { wss, httpServer, account };
172
+ return account;
173
+ }
174
+
175
+ /**
176
+ * Called once, at the very end of the CLI's command dispatch (bin/mnemonad.js), whether the
177
+ * command succeeded or failed — a no-op when --passkey was never used. Tells the browser tab
178
+ * it's done so it can end its session and zero the key, mirroring the explorer's own
179
+ * disconnect() lifecycle instead of just abandoning the connection.
180
+ *
181
+ * `result` lets the tab show something more useful than a bare "done" before it closes —
182
+ * whether the command actually succeeded, and the one line of output that says what
183
+ * happened (bin/mnemonad.js captures its own last console.log line for this rather than
184
+ * every command needing to return a summary explicitly).
185
+ *
186
+ * @param {{ok: boolean, summary: string}} [result]
187
+ */
188
+ export function closePasskeyBridge(result) {
189
+ if (!active) return;
190
+ const { wss, httpServer } = active;
191
+ active = null;
192
+ const payload = JSON.stringify({ type: 'done', ok: result?.ok ?? true, summary: result?.summary || '' });
193
+ for (const ws of wss.clients) {
194
+ try {
195
+ ws.send(payload);
196
+ } catch {
197
+ // Nothing to tell a socket that's already gone.
198
+ }
199
+ ws.close();
200
+ }
201
+ wss.close();
202
+ httpServer.close();
203
+ }