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.
- package/README.md +173 -19
- package/bin/mnemonad.js +164 -11
- package/lib/bridgeCodec.js +12 -0
- package/lib/chainClient.js +28 -7
- package/lib/commands/buildIndex.js +171 -0
- package/lib/commands/burn.js +59 -0
- package/lib/commands/compact.js +7 -2
- package/lib/commands/diff.js +25 -6
- package/lib/commands/index.js +3 -0
- package/lib/commands/info.js +42 -2
- package/lib/commands/pull.js +13 -6
- package/lib/commands/push.js +61 -7
- package/lib/commands/search.js +91 -0
- package/lib/commands/shared.js +137 -11
- package/lib/commands/watch.js +66 -12
- package/lib/passkeyBridge.js +203 -0
- package/lib/search/Indexer.js +204 -0
- package/lib/search/Searcher.js +68 -0
- package/lib/search/VectorIndex.js +225 -0
- package/lib/search/browser/BrowserSearcher.js +131 -0
- package/lib/search/browser/WasmIndexReader.js +93 -0
- package/lib/search/browser.js +13 -0
- package/lib/search/chunking/ChunkingStrategy.js +23 -0
- package/lib/search/chunking/TextWindowChunkingStrategy.js +69 -0
- package/lib/search/embeddings/EmbeddingProvider.js +40 -0
- package/lib/search/embeddings/StaticEmbeddingProvider.js +180 -0
- package/lib/search/embeddings/modelFiles.browser.js +28 -0
- package/lib/search/embeddings/modelFiles.node.js +41 -0
- package/lib/search/embeddings/modelFiles.shared.js +44 -0
- package/lib/search/index.js +18 -0
- package/lib/search/providers.js +153 -0
- package/lib/search/schema.js +103 -0
- package/mnemonad.config.js +19 -5
- package/package.json +18 -3
|
@@ -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
|
+
}
|
package/lib/commands/shared.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = []) {
|
package/lib/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
34
|
+
const cwd = resolvePath(args.path, { mustExist: true });
|
|
31
35
|
|
|
32
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
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 =
|
|
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
|
+
}
|