mnemonad-cli 0.2.0 → 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.
package/README.md CHANGED
@@ -15,16 +15,30 @@ signer's own wallet key.
15
15
  ### From npm (recommended)
16
16
 
17
17
  ```bash
18
- npm install -g mnemonad-cli
18
+ npm install -g mnemonad-cli@latest
19
19
  mnemonad --help
20
20
  ```
21
21
 
22
- To pick up a new release later:
22
+ To pick up a new release later, prefer reinstalling `@latest` over `npm update -g` — `update`
23
+ has known quirks where it doesn't reliably bump an already-installed global package even when
24
+ a newer version is published:
23
25
 
24
26
  ```bash
25
- npm update -g mnemonad-cli
27
+ npm install -g mnemonad-cli@latest
28
+ mnemonad --help # banner shows the version — confirm it actually moved
26
29
  ```
27
30
 
31
+ If it still reports the old version, you likely have two installs shadowing each other on
32
+ `PATH` (common with nvm, after switching Node versions):
33
+
34
+ ```bash
35
+ which mnemonad
36
+ npm ls -g mnemonad-cli
37
+ ```
38
+
39
+ If `which mnemonad` doesn't resolve into the `npm root -g` you just installed into, remove
40
+ the stale one — `npm uninstall -g mnemonad-cli`, then reinstall.
41
+
28
42
  ### From this repo (for development)
29
43
 
30
44
  This package lives in the monorepo alongside `mnemonad` and `monadsync`, linked through a
@@ -63,6 +77,8 @@ mnemonad diff <stream-id> <path> [options] List files added/modified/deleted
63
77
  mnemonad watch <stream-id> <path> [options] Watch folder: auto-push on changes, auto-pull on remote updates
64
78
  mnemonad compact <stream-id> <path> [options] Truncate old history and push folder as a fresh snapshot
65
79
  mnemonad burn <stream-id> --yes [options] Permanently destroy a stream — irreversible
80
+ mnemonad index <path> [options] Build/update a local vector-search index (optional)
81
+ mnemonad search <path> <query> [options] Search a folder's vector index
66
82
  ```
67
83
 
68
84
  `<path>` is always required for any command that touches a folder — there is no need to
@@ -88,9 +104,11 @@ Every command prints the installed CLI version as its first line of output.
88
104
  | `--chain <name>` | Chain: `testnet`, `mainnet`, `local` (default: `testnet`) |
89
105
  | `--rpc-url <url>` | Override the resolved RPC endpoint (e.g. a local Hardhat node) |
90
106
  | `--contract-address <addr>` | Registry address to use instead of the chain's known deployment (required with `--chain local`) |
91
- | `--gateway-url <url>` | IPFS gateway for reading externally-offloaded items (default: `https://gateway.pinata.cloud/ipfs/`, or `MNEMONAD_IPFS_GATEWAY` env var) |
107
+ | `--gateway-url <url>` | IPFS gateway for reading externally-offloaded items (default: this project's Pinata dedicated gateway, or `MNEMONAD_IPFS_GATEWAY` env var) |
92
108
  | `--key <privkey>` | Monad private key (or set `MNEMONAD_KEY` env var) |
93
109
  | `--phrase <mnemonic>` | Mnemonic phrase instead of a raw key |
110
+ | `--passkey` | Sign with a passkey instead: opens the explorer in your browser to sign in with WebAuthn, then relays signing requests to that tab for this command. Interactive only — not for scripts/CI. Cannot be combined with `--key`/`--phrase` |
111
+ | `--auth-origin <url>` | Explorer deployment `--passkey` opens (default: this project's explorer deployment, or `MNEMONAD_AUTH_ORIGIN` env var) |
94
112
  | `--version <n>` | Version to restore or compare against (`pull`, `diff`, `info`; default: latest) |
95
113
  | `--exclude <p1,p2>` | Extra exclude patterns (comma-separated) |
96
114
  | `--no-compress` | Disable gzip compression |
@@ -100,11 +118,18 @@ Every command prints the installed CLI version as its first line of output.
100
118
  | `--pinata-jwt <jwt>` | Pinata JWT for IPFS offload of large files (or `PINATA_JWT` env var) |
101
119
  | `--manifest` | Write/use `.mnemonad` manifest for faster change detection. With no stream-id given, every command recovers it from an existing `.mnemonad` in the folder instead of requiring it explicitly |
102
120
  | `--force-snapshot` | Push a full snapshot regardless of prior history (repairs a corrupt stream) |
121
+ | `--index` | `push`/`watch`: update the folder's search index before every push (takes `--db`/`--model`/`--chunk-size`/`--chunk-overlap`); if indexing fails, nothing is pushed |
103
122
  | `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
104
123
  | `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
105
124
  | `--push-only` | `watch`: disable auto-pull |
106
125
  | `--pull-only` | `watch`: disable auto-push |
107
126
  | `--yes` | Required by `burn` — explicit opt-in for an irreversible action, never assumed |
127
+ | `--db <name>` | `index`/`search`: vector-index filename (default: `search_index.db`) |
128
+ | `--model <name>` | `index` / `--index`: embedding model — `potion` (built in, the default for a new index) or `minilm` (needs `npm install -g mnemonad-search-transformers`), or a full model id. Without it, an existing index keeps its own model; `search` always uses the index's model |
129
+ | `--rebuild` | `index` / `--index`: start the index over — the way to switch models (the next push uploads the whole index again) |
130
+ | `--chunk-size <n>` | `index`: target characters per chunk (default: `1000`) |
131
+ | `--chunk-overlap <n>` | `index`: characters of overlap between chunks (default: `150`) |
132
+ | `--limit <n>`, `-k <n>` | `search`: number of results (default: `5`) |
108
133
  | `--help` | Show help |
109
134
 
110
135
  ### Authentication
@@ -125,6 +150,21 @@ mnemonad push ~/my-data
125
150
  A mnemonic works instead of a raw key: `--phrase "word1 word2 ..."`. `--key` and
126
151
  `MNEMONAD_KEY` both take precedence over `--phrase`.
127
152
 
153
+ Or sign in with a passkey instead of any key at all:
154
+
155
+ ```bash
156
+ mnemonad push ~/my-data --passkey
157
+ ```
158
+
159
+ This opens the explorer's `/cli-auth` page in your default browser to complete a WebAuthn
160
+ sign-in — no key ever touches this machine's disk or environment, only signatures relayed
161
+ back for the duration of that one command. It needs a passkey already registered through the
162
+ explorer (`--passkey` only signs in, it doesn't create one), a browser to complete the
163
+ prompt in, and someone available to click through it — so it's a good fit for a human running
164
+ a command by hand, not for scripts or CI (use `--key`/`MNEMONAD_KEY` there instead). See
165
+ [`docs/wallet/passkey-accounts.md`](../docs/wallet/passkey-accounts.md) for how the account
166
+ is derived and what this flow does and doesn't send anywhere.
167
+
128
168
  `pull`, `info` and `diff` work without a key for public (unencrypted) streams. A key is
129
169
  required for encrypted streams that use wallet-based encryption (not needed for
130
170
  password-based encryption — see below), and for any `push`.
@@ -320,6 +360,63 @@ mnemonad push 123 ~/my-data --force-snapshot
320
360
  # restore() will recover from this snapshot, skipping any corrupt diffs before it
321
361
  ```
322
362
 
363
+ ### Vector search (optional)
364
+
365
+ Builds a local, plain (non-dot-prefixed) `search_index.db` at the folder root, using a local
366
+ embedding model — no API key, nothing sent anywhere. Because it's a plain file, it rides
367
+ through the next `push` like anything else in the folder, so anyone who later `pull`s the
368
+ stream gets a ready-to-search index with no re-embedding of their own.
369
+
370
+ ```bash
371
+ mnemonad index ~/my-data
372
+ # embeds every changed text file since the last run (skips unchanged ones); binary files
373
+ # are skipped entirely
374
+
375
+ mnemonad search ~/my-data "how does chunking work"
376
+ # 1. chunks.txt (chunk 0, distance 0.5838)
377
+ # one byte slips inward the boundaries hold their ground only one chunk moves
378
+
379
+ mnemonad push ~/my-data
380
+ # search_index.db is included automatically — it's just a file in the folder
381
+ ```
382
+
383
+ The built-in model is `minishlab/potion-base-8M`: static embeddings in plain JS, no native
384
+ code, a 31 MB download on first use, and a whole folder embedded in milliseconds. For better
385
+ results on paraphrased queries, MiniLM is available as an optional extension (about 450 MB
386
+ installed, most of it ONNX Runtime):
387
+
388
+ ```bash
389
+ npm install -g mnemonad-search-transformers
390
+ mnemonad index ~/my-data --model minilm
391
+ ```
392
+
393
+ An index keeps the model it was built with, and `search` always uses it. To switch an existing
394
+ index to another model, add `--rebuild`. See `docs/search.md` in the repository for how the two
395
+ compare on this project's own data.
396
+
397
+ `push` can also keep the index current for you: `--index` runs the same update right before pushing,
398
+ with the push's own `--exclude`, so the index always covers exactly what the push uploads.
399
+ If indexing fails (the first-time model download, say), nothing is pushed:
400
+
401
+ ```bash
402
+ mnemonad push ~/my-data --index
403
+ # updating search index...
404
+ # index: 1 file(s) re-embedded, 0 removed (3 chunks)
405
+
406
+ mnemonad watch <stream-id> ~/my-data --index
407
+ # re-indexes before every automatic push; the model loads once for the whole session
408
+ ```
409
+
410
+ A plain `push` of a folder that has an index checks it first. It doesn't load the model and
411
+ doesn't touch the file. If files changed since the index was built, it says so and suggests
412
+ `--index`, because a stale index would keep returning the old text's passages.
413
+
414
+ See [`../docs/search.md`](../docs/search.md) for the whole process, and why the index stays
415
+ diff-friendly on repeated pushes.
416
+
417
+ A pushed index also turns on a search box in the explorer's file browser for that stream.
418
+ The search runs in the browser on the same file and gives the same ranking as `mnemonad search`.
419
+
323
420
  ## End-to-end workflow
324
421
 
325
422
  Create a stream, verify what was stored, then roll back to an earlier version:
@@ -368,10 +465,13 @@ mnemonad pull 123 ~/my-data-v1 --version 1
368
465
  the on-chain version.
369
466
  5. **watch** — combines push and pull in a loop. A filesystem watcher triggers a debounced
370
467
  push on local changes. A poll interval checks the remote stream for new versions and
371
- pulls them if found. Push and pull never run concurrently. Monad's registry has no
372
- "expected prior length" guard on push, so `watch` re-checks the remote length itself
373
- right before pushing and resyncs if it has moved — a best-effort narrowing of the race
374
- window, not a hard guarantee.
468
+ pulls them if found. Push and pull never run concurrently. The contract *does* check an
469
+ `expectedLength` atomically on every write, so two writers can never land at the same
470
+ index — one just reverts. What it can't check is content: it stores opaque bytes, with no
471
+ way to verify a diff patch is actually consistent with the real previous item. `watch`
472
+ re-checks the remote length and resyncs before pushing specifically to narrow that
473
+ separate window — a best-effort narrowing, not a hard guarantee, since only an atomic
474
+ on-chain check (which it already has, for the index) could close it outright.
375
475
  6. **compact** — drops all existing history on-chain and pushes the current folder as a
376
476
  single fresh full snapshot, speeding up future replay after many incremental syncs.
377
477
 
@@ -379,7 +479,8 @@ mnemonad pull 123 ~/my-data-v1 --version 1
379
479
 
380
480
  The following are always excluded from snapshots: `node_modules`, `.git`, `.env`,
381
481
  `.DS_Store`, `.mnemonad`, `.claude`, `pnpm-lock.yaml`, `package-lock.json`. Add more with
382
- `--exclude`.
482
+ `--exclude`. `search_index.db` (see "Vector search" above) is deliberately **not** on this
483
+ list — it's a plain file, meant to be pushed like any other.
383
484
 
384
485
  ## Dependencies
385
486
 
@@ -387,4 +488,7 @@ The following are always excluded from snapshots: `node_modules`, `.git`, `.env`
387
488
  |---|---|
388
489
  | `mnemonad` | On-chain stream primitive (this repo's `js/`) |
389
490
  | `monadsync` | Folder-diff / snapshot layer on top of `mnemonad` (this repo's `monadsync/`) |
491
+ | `better-sqlite3`, `@sqliteai/sqlite-vector` | The search index: SQLite with vector search (`lib/search/`) |
492
+ | `@huggingface/tokenizers` | Tokenizer for the built-in embedding model (pure JS) |
493
+ | `mnemonad-search-transformers` | *Not a dependency* — the optional MiniLM extension, installed separately and found at runtime |
390
494
  | `viem` | Monad client / key management |
package/bin/mnemonad.js CHANGED
@@ -17,8 +17,9 @@ import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join } from 'node:path';
19
19
 
20
- import { push, pull, info, diff, watch, compact, burn } from '../lib/commands/index.js';
20
+ import { push, pull, info, diff, watch, compact, burn, buildIndex, search } from '../lib/commands/index.js';
21
21
  import { parseQualifiedStreamId } from '../lib/commands/shared.js';
22
+ import { closePasskeyBridge } from '../lib/passkeyBridge.js';
22
23
  import defaultConfig from '../mnemonad.config.js';
23
24
 
24
25
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -40,7 +41,7 @@ const CONFIG = defaultConfig || {};
40
41
  // Same default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js) — a
41
42
  // public read gateway so pulling/diffing/inspecting a stream with IPFS-offloaded items
42
43
  // works out of the box, with no setup, for anyone who didn't push it themselves.
43
- const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://gateway.pinata.cloud/ipfs/';
44
+ const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://azure-casual-firefly-850.mypinata.cloud/ipfs/';
44
45
 
45
46
  // The deployed presign server (presign-server/) this project runs by default — see its own
46
47
  // README for what it does. Pushing an item too big to fit on-chain uses this automatically
@@ -48,6 +49,10 @@ const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://gateway.pinata.cloud/
48
49
  // alternative for anyone who'd rather supply their own.
49
50
  const DEFAULT_PRESIGN_URL = CONFIG.presignUrl || null;
50
51
 
52
+ // See mnemonad.config.js's own comment on why this has to be a real deployed domain, not a
53
+ // placeholder — --passkey opens it to run the WebAuthn ceremony there.
54
+ const DEFAULT_AUTH_ORIGIN = CONFIG.authOrigin || 'https://mnemonad.vercel.app';
55
+
51
56
  const USAGE = `mnemonad v${VERSION} — sync local folders to a versioned, diffed stream on Monad
52
57
 
53
58
  Usage:
@@ -58,6 +63,9 @@ Usage:
58
63
  mnemonad watch <stream-id> <path> [options] Watch folder and auto-push/pull
59
64
  mnemonad compact <stream-id> <path> [options] Truncate old history, push a fresh snapshot
60
65
  mnemonad burn <stream-id> --yes [options] Permanently destroy a stream (irreversible)
66
+ mnemonad index <path> [options] Build/update a local vector-search index
67
+ for the folder (optional; see below)
68
+ mnemonad search <path> <query> [options] Search a folder's vector index
61
69
 
62
70
  <path> is always required wherever a command touches a folder — the CLI never assumes the
63
71
  current directory. The exceptions are \`mnemonad info\` with no stream-id (just checks your
@@ -73,11 +81,17 @@ Options:
73
81
  --contract-address <addr> Registry address to use instead of the chain's known deployment
74
82
  (required with --chain local)
75
83
  --gateway-url <url> IPFS gateway for reading externally-offloaded items
76
- (default: https://gateway.pinata.cloud/ipfs/, or MNEMONAD_IPFS_GATEWAY env var)
84
+ (default: this project's Pinata dedicated gateway, or MNEMONAD_IPFS_GATEWAY env var)
77
85
  --presign-url <url> Presign server for paid IPFS uploads with no Pinata credential
78
86
  (default: the deployed server in mnemonad.config.js, or MNEMONAD_PRESIGN_URL env var)
79
87
  --key <privkey> Monad private key (or set MNEMONAD_KEY env var)
80
88
  --phrase <mnemonic> Mnemonic phrase
89
+ --passkey Sign with a passkey instead of --key/--phrase: opens the explorer
90
+ in your browser to sign in with WebAuthn, then relays signing
91
+ requests to that tab for this command. Interactive only — not for
92
+ scripts/CI. See docs/wallet/passkey-accounts.md
93
+ --auth-origin <url> Explorer deployment --passkey opens (default: this project's
94
+ explorer deployment, or MNEMONAD_AUTH_ORIGIN env var)
81
95
  --version <n> Version to restore/compare (pull, diff, info; default: latest)
82
96
  --exclude <p1,p2> Extra exclude patterns (comma-separated)
83
97
  --no-compress Disable gzip compression
@@ -90,12 +104,35 @@ Options:
90
104
  With no stream-id given, every command recovers it from an
91
105
  existing .mnemonad in <path> instead of requiring it explicitly
92
106
  --force-snapshot Push a full snapshot regardless of prior history (repairs corrupt streams)
107
+ --index push/watch: update the folder's search index (search_index.db)
108
+ before every push, so the stream is searchable right after a pull.
109
+ Takes --db/--model/--chunk-size/--chunk-overlap like \`index\`
93
110
  --poll-interval <s> Watch: seconds between remote checks (default: 2)
94
111
  --debounce <ms> Watch: ms quiet period before pushing after a change (default: 1000)
95
112
  --push-only Watch: disable auto-pull
96
113
  --pull-only Watch: disable auto-push
97
114
  --yes Required by burn — explicit opt-in for an irreversible action
115
+ --db <name> index/search: vector-index filename (default: search_index.db)
116
+ --model <name> index / --index: embedding model. \`potion\` (built in, the default for
117
+ a new index) or \`minilm\` (better on paraphrased queries; needs the
118
+ optional extension: npm install -g mnemonad-search-transformers),
119
+ or a full model id. Without it, an existing index keeps its own
120
+ model. \`search\` always uses the index's own model. All local —
121
+ no API key, nothing sent anywhere
122
+ --rebuild index / --index: start the index over (the way to switch models);
123
+ the next push uploads the whole index again
124
+ --chunk-size <n> index: target characters per chunk (default: 1000)
125
+ --chunk-overlap <n> index: characters of overlap between chunks (default: 150)
126
+ --limit, -k <n> search: number of results (default: 5)
98
127
  --help Show this help
128
+
129
+ index/search are optional and independent of push/pull — a folder with no search_index.db
130
+ just has no search capability. \`index\` writes a plain (non-dot-prefixed) file at the
131
+ folder's root, so it rides through the next \`push\` like any other file: whoever pulls the
132
+ stream gets the index already built, with no re-embedding of their own. \`push --index\` and
133
+ \`watch --index\` run \`index\` for you before each push, and a plain \`push\` of a folder whose
134
+ index has fallen behind its files says so. See docs/search.md in the repository for how the
135
+ index works and stays diff-friendly on repeated pushes.
99
136
  `;
100
137
 
101
138
  function parseArgs(argv) {
@@ -112,6 +149,8 @@ function parseArgs(argv) {
112
149
  presignUrl: process.env.MNEMONAD_PRESIGN_URL || DEFAULT_PRESIGN_URL,
113
150
  key: null,
114
151
  phrase: null,
152
+ passkey: false,
153
+ authOrigin: process.env.MNEMONAD_AUTH_ORIGIN || DEFAULT_AUTH_ORIGIN,
115
154
  version: null,
116
155
  exclude: null,
117
156
  compress: 'gzip',
@@ -120,12 +159,20 @@ function parseArgs(argv) {
120
159
  password: null,
121
160
  pinataJwt: process.env.PINATA_JWT || null,
122
161
  manifest: false,
162
+ index: false,
163
+ rebuild: false,
123
164
  forceSnapshot: false,
124
165
  pollInterval: 2,
125
166
  debounce: 1000,
126
167
  pushOnly: false,
127
168
  pullOnly: false,
128
169
  yes: false,
170
+ query: null,
171
+ searchDb: null,
172
+ model: null,
173
+ chunkSize: null,
174
+ chunkOverlap: null,
175
+ limit: null,
129
176
  };
130
177
 
131
178
  const raw = argv.slice(2);
@@ -164,12 +211,22 @@ function parseArgs(argv) {
164
211
  return qualified.id;
165
212
  };
166
213
 
214
+ // `-k` is the one short flag — it has to end the positionals too, or `search`'s
215
+ // join-the-rest query below swallows it (`search ./f slow and steady -k 3` once searched
216
+ // for "slow and steady -k 3").
217
+ const isFlag = (arg) => arg.startsWith('--') || arg === '-k';
167
218
  const positionals = [];
168
- while (i < raw.length && !raw[i].startsWith('--')) {
219
+ while (i < raw.length && !isFlag(raw[i])) {
169
220
  positionals.push(raw[i]);
170
221
  i++;
171
222
  }
172
- if (positionals.length === 2) {
223
+ if (args.command === 'search') {
224
+ // <path> <query...> — everything after the path is the query, joined back with spaces
225
+ // so an unquoted multi-word query (`mnemonad search ./docs how does chunking work`)
226
+ // works the same as a quoted one. Never a stream id — `search` doesn't take one.
227
+ args.path = positionals[0] || null;
228
+ args.query = positionals.length > 1 ? positionals.slice(1).join(' ') : null;
229
+ } else if (positionals.length === 2) {
173
230
  args.streamId = takeStreamId(positionals[0]);
174
231
  args.path = positionals[1];
175
232
  } else if (positionals.length === 1) {
@@ -198,6 +255,10 @@ function parseArgs(argv) {
198
255
  args.key = raw[++i];
199
256
  } else if (flag === '--phrase' && i + 1 < raw.length) {
200
257
  args.phrase = raw[++i];
258
+ } else if (flag === '--passkey') {
259
+ args.passkey = true;
260
+ } else if (flag === '--auth-origin' && i + 1 < raw.length) {
261
+ args.authOrigin = raw[++i];
201
262
  } else if (flag === '--version' && i + 1 < raw.length) {
202
263
  args.version = raw[++i];
203
264
  } else if (flag === '--exclude' && i + 1 < raw.length) {
@@ -214,6 +275,10 @@ function parseArgs(argv) {
214
275
  args.pinataJwt = raw[++i];
215
276
  } else if (flag === '--manifest') {
216
277
  args.manifest = true;
278
+ } else if (flag === '--index') {
279
+ args.index = true;
280
+ } else if (flag === '--rebuild') {
281
+ args.rebuild = true;
217
282
  } else if (flag === '--force-snapshot') {
218
283
  args.forceSnapshot = true;
219
284
  } else if (flag === '--poll-interval' && i + 1 < raw.length) {
@@ -226,6 +291,16 @@ function parseArgs(argv) {
226
291
  args.pullOnly = true;
227
292
  } else if (flag === '--yes') {
228
293
  args.yes = true;
294
+ } else if (flag === '--db' && i + 1 < raw.length) {
295
+ args.searchDb = raw[++i];
296
+ } else if (flag === '--model' && i + 1 < raw.length) {
297
+ args.model = raw[++i];
298
+ } else if (flag === '--chunk-size' && i + 1 < raw.length) {
299
+ args.chunkSize = Number(raw[++i]);
300
+ } else if (flag === '--chunk-overlap' && i + 1 < raw.length) {
301
+ args.chunkOverlap = Number(raw[++i]);
302
+ } else if ((flag === '--limit' || flag === '-k') && i + 1 < raw.length) {
303
+ args.limit = Number(raw[++i]);
229
304
  }
230
305
  i++;
231
306
  }
@@ -253,6 +328,22 @@ console.log(`mnemonad v${VERSION}`);
253
328
  // anything in the RPC/fetch stack unref's its own I/O (see the process.exit note above).
254
329
  const keepAlive = setInterval(() => {}, 60_000);
255
330
 
331
+ // --passkey's browser tab shows a result line when the command finishes (see
332
+ // closePasskeyBridge()) rather than a bare "done" — commands don't return a summary
333
+ // explicitly, so this captures their own last console.log line instead, the same thing this
334
+ // terminal already sees. Only wired up when --passkey is in play; every other run's
335
+ // console.log is untouched.
336
+ const logLines = [];
337
+ const originalConsoleLog = console.log;
338
+ if (args.passkey) {
339
+ console.log = (...parts) => {
340
+ originalConsoleLog(...parts);
341
+ logLines.push(parts.join(' '));
342
+ };
343
+ }
344
+ let succeeded = true;
345
+ let errorMessage = null;
346
+
256
347
  try {
257
348
  if (args.command === 'push') {
258
349
  await push(args);
@@ -268,6 +359,10 @@ try {
268
359
  await compact(args);
269
360
  } else if (args.command === 'burn') {
270
361
  await burn(args);
362
+ } else if (args.command === 'index') {
363
+ await buildIndex(args);
364
+ } else if (args.command === 'search') {
365
+ await search(args);
271
366
  } else {
272
367
  console.error('Unknown command:', args.command);
273
368
  console.log(USAGE);
@@ -276,15 +371,26 @@ try {
276
371
  } catch (err) {
277
372
  if (err._isProcessExit) {
278
373
  process.exitCode = err._exitCode;
374
+ succeeded = err._exitCode === 0;
279
375
  } else if (err._isUserError) {
280
376
  // Expected, actionable failure — message only, no stack trace.
281
377
  console.error('Error:', err.message);
282
378
  process.exitCode = 1;
379
+ succeeded = false;
283
380
  } else {
284
381
  console.error('Error:', err.message);
285
382
  if (err.stack) console.error(err.stack);
286
383
  process.exitCode = 1;
384
+ succeeded = false;
287
385
  }
386
+ errorMessage = err.message;
288
387
  } finally {
388
+ console.log = originalConsoleLog;
389
+ // No-op when --passkey was never used — see passkeyBridge.js. Runs whether the command
390
+ // succeeded or failed, so a browser tab left open on a failed push doesn't linger.
391
+ closePasskeyBridge({
392
+ ok: succeeded,
393
+ summary: succeeded ? (logLines.filter(Boolean).pop() || 'Done.') : (errorMessage || 'Failed.'),
394
+ });
289
395
  clearInterval(keepAlive);
290
396
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * JSON can't carry BigInt, and viem's transaction-request objects use BigInt for gas/value/
3
+ * fee fields — so a transaction going out over `--passkey`'s loopback WebSocket bridge
4
+ * (passkeyBridge.js) to the browser tab is wrapped through this first.
5
+ *
6
+ * Must produce exactly what explorer/src/wallet/bridgeCodec.js's `decodeFromBridge` expects
7
+ * — the two sides are never bundled together (separate packages), so keep them in sync by
8
+ * hand if either changes.
9
+ */
10
+ export function encodeForBridge(value) {
11
+ return JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? { __bigint__: v.toString() } : v)));
12
+ }
@@ -8,6 +8,8 @@
8
8
  import { createPublicClient, createWalletClient, defineChain, http } from 'viem';
9
9
  import { privateKeyToAccount, mnemonicToAccount } from 'viem/accounts';
10
10
  import { monad, monadTestnet } from 'viem/chains';
11
+ import { openPasskeyBridge } from './passkeyBridge.js';
12
+ import { userError } from './commands/shared.js';
11
13
 
12
14
  /**
13
15
  * CLI `--chain` name -> { viem chain object, Mnemonad network shorthand for
@@ -45,10 +47,20 @@ function normalizeKey(key) {
45
47
  return key.startsWith('0x') ? key : `0x${key}`;
46
48
  }
47
49
 
48
- function resolveAccount(args) {
50
+ /**
51
+ * `--key`/`--phrase`/`MNEMONAD_KEY` resolve synchronously; `--passkey` doesn't (it opens a
52
+ * browser tab and waits for a human to complete a WebAuthn prompt) — see
53
+ * passkeyBridge.js. That's the one thing that makes this function, and therefore
54
+ * `makeChainClient`, async rather than the plain synchronous lookup it used to be.
55
+ */
56
+ async function resolveAccount(args) {
49
57
  const key = args.key || process.env.MNEMONAD_KEY;
58
+ if (args.passkey && (key || args.phrase)) {
59
+ throw userError('--passkey cannot be combined with --key/--phrase/MNEMONAD_KEY — pick one signing method.');
60
+ }
50
61
  if (key) return privateKeyToAccount(normalizeKey(key));
51
62
  if (args.phrase) return mnemonicToAccount(args.phrase);
63
+ if (args.passkey) return openPasskeyBridge(args);
52
64
  return null;
53
65
  }
54
66
 
@@ -60,16 +72,17 @@ function resolveAccount(args) {
60
72
  * `requireWalletClient` themselves, so the "no key" error only surfaces where it's
61
73
  * actually required.
62
74
  *
63
- * @param {Object} args - parsed CLI args: `chain`, `rpcUrl`, `key`, `phrase`, `contractAddress`
64
- * @returns {{
75
+ * @param {Object} args - parsed CLI args: `chain`, `rpcUrl`, `key`, `phrase`, `passkey`,
76
+ * `authOrigin`, `contractAddress`
77
+ * @returns {Promise<{
65
78
  * publicClient: Object,
66
79
  * walletClient: ?Object,
67
80
  * account: ?Object,
68
81
  * contractAddress: string,
69
82
  * chain: Object,
70
- * }}
83
+ * }>}
71
84
  */
72
- export function makeChainClient(args) {
85
+ export async function makeChainClient(args) {
73
86
  let chain, contractAddress;
74
87
  if (args.chain === 'local') {
75
88
  // Dev/test only — see localChain() above.
@@ -95,7 +108,7 @@ export function makeChainClient(args) {
95
108
  const transport = http(args.rpcUrl);
96
109
  const publicClient = createPublicClient({ chain, transport });
97
110
 
98
- const account = resolveAccount(args);
111
+ const account = await resolveAccount(args);
99
112
  const walletClient = account ? createWalletClient({ chain, transport, account }) : null;
100
113
 
101
114
  return { publicClient, walletClient, account, contractAddress, chain };
@@ -111,7 +124,8 @@ export function requireWalletClient(client) {
111
124
  // first thing anyone hits before configuring a key, and a stack trace through
112
125
  // chainClient makes an actionable one-liner look like a crash.
113
126
  const err = new Error(
114
- 'No signing key. Set the MNEMONAD_KEY env var to a private key, or pass --key / --phrase.'
127
+ 'No signing key. Set the MNEMONAD_KEY env var to a private key, pass --key / --phrase, ' +
128
+ 'or pass --passkey to sign in with a passkey via the browser.'
115
129
  );
116
130
  err._isUserError = true;
117
131
  throw err;