mnemonad-cli 0.3.1 → 0.3.2

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
@@ -118,7 +118,7 @@ Every command prints the installed CLI version as its first line of output.
118
118
  | `--pinata-jwt <jwt>` | Pinata JWT for IPFS offload of large files (or `PINATA_JWT` env var) |
119
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 |
120
120
  | `--force-snapshot` | Push a full snapshot regardless of prior history (repairs a corrupt stream) |
121
- | `--detach` | `push`: build the next version here, send it from a background process, and return right away. Existing streams only. See "Push in the background" below |
121
+ | `--detach` | `push`: build the next version here, send it from a background process, and return right away. With `--index`, indexing and building move to the background too. Existing streams only. See "Push in the background" below |
122
122
  | `--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 |
123
123
  | `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
124
124
  | `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
@@ -231,7 +231,7 @@ mnemonad pull 123 ~/restored --version 1
231
231
  ### Inspect a stream
232
232
 
233
233
  ```bash
234
- # check your wallet + chain (no stream id needed)
234
+ # check your wallet, its MON balance, and the chain (no stream id needed)
235
235
  mnemonad info
236
236
 
237
237
  # full stream metadata + local sync status
@@ -241,6 +241,7 @@ mnemonad info 123 ~/my-data
241
241
  ```
242
242
  chain: testnet
243
243
  your wallet: 0x06bc6420b37a4898424429dcfa236f0065e12279
244
+ balance: 4.2183 MON
244
245
 
245
246
  stream: 0x68d9736257f0d2da3c848666a4b36c4b9f18eda7e4dfba655599c47560d8a4b5
246
247
  owner: 0x06bc6420b37a4898424429dcfa236f0065e12279 (you)
@@ -389,6 +390,27 @@ mnemonad push ~/my-data --manifest --detach
389
390
  IPFS upload, can't be resumed. A paid but unused upload can be refunded after the gateway's
390
391
  time limit, with the SDK's `MnemonadUploadGateway.refund()` (the CLI has no command for it).
391
392
 
393
+ **With `--index`, indexing goes to the background too.** The updated index is part of the
394
+ version being pushed, so the version can only be built once indexing is done, and sent once
395
+ it is built. `push --detach --index` therefore hands off all three, and returns after only the
396
+ checks that need no folder scan: the signer owns the stream, the credential unlocks it, and the
397
+ wallet isn't empty.
398
+
399
+ ```bash
400
+ mnemonad push ~/my-data --manifest --index --detach
401
+ # stream: 0x68d9…
402
+ # indexing, building and sending in the background: job 3b9e07c1 (pid 41388)
403
+ ```
404
+
405
+ The trade-offs, compared with a plain `--detach`:
406
+
407
+ - No version number, size or cost estimate is printed. `mnemonad info` shows them once the job
408
+ has built the version, and shows "no changes, nothing pushed" if the folder already matched.
409
+ - An indexing error, a model download that fails, and "no changes" show up in the job's status,
410
+ not in the terminal.
411
+ - The folder is not frozen when the command runs. The job reads it when it starts building, so
412
+ edits made before then are part of this version.
413
+
392
414
  Jobs live in `~/.cache/mnemonad/jobs/` (set `MNEMONAD_JOBS_DIR` to move them). Finished ones are
393
415
  removed after 14 days.
394
416
 
package/bin/mnemonad.js CHANGED
@@ -108,7 +108,8 @@ Options:
108
108
  --detach push: build the next version here, send it from a background process,
109
109
  and return right away. Existing streams only (an id, or --manifest).
110
110
  Later pushes and pulls of that stream wait for it; \`mnemonad info\`
111
- shows how it went
111
+ shows how it went. With --index, indexing and building the version
112
+ move to the background too
112
113
  --index push/watch: update the folder's search index (search_index.db)
113
114
  before every push, so the stream is searchable right after a pull.
114
115
  Takes --db/--model/--chunk-size/--chunk-overlap like \`index\`
@@ -1,5 +1,6 @@
1
1
  import Mnemonad, { kekMethodOf } from 'mnemonad';
2
2
  import { MonadSync } from 'monadsync';
3
+ import { formatEther } from 'viem';
3
4
  import { makeChainClient } from '../chainClient.js';
4
5
  import {
5
6
  resolvePath,
@@ -19,6 +20,9 @@ export async function info(args) {
19
20
 
20
21
  console.log(' chain: ', args.chain);
21
22
  console.log(' your wallet: ', client.account?.address || '(none)');
23
+ if (client.account) {
24
+ console.log(' balance: ', await walletBalance(client));
25
+ }
22
26
 
23
27
  // Peek at .mnemonad whenever a path is given, regardless of --manifest and regardless
24
28
  // of whether an id was given — info is read-only, so surfacing what's there costs
@@ -170,3 +174,18 @@ export async function info(args) {
170
174
  console.log(' latest remote: version', totalVersions);
171
175
  }
172
176
  }
177
+
178
+ /**
179
+ * The signing wallet's native balance, in the chain's own unit (MON on Monad, ETH on a local
180
+ * Hardhat node). A failed RPC read is shown as such rather than failing `info` — the balance
181
+ * is a convenience next to the stream report, not something the rest depends on.
182
+ */
183
+ async function walletBalance(client) {
184
+ const symbol = client.chain.nativeCurrency?.symbol || 'MON';
185
+ try {
186
+ const wei = await client.publicClient.getBalance({ address: client.account.address });
187
+ return `${formatEther(wei)} ${symbol}`;
188
+ } catch {
189
+ return '(could not read)';
190
+ }
191
+ }
@@ -60,6 +60,10 @@ export async function push(args) {
60
60
  if (args.rebuild && !args.index) {
61
61
  throw userError('--rebuild only applies with --index (it starts the search index over)');
62
62
  }
63
+ if (args.detach && args.index) {
64
+ await detachPrepare(args, destPath, streamId);
65
+ return;
66
+ }
63
67
  if (args.index) {
64
68
  console.log(args.rebuild ? ' rebuilding search index...' : ' updating search index...');
65
69
  try {
@@ -190,6 +194,85 @@ export async function push(args) {
190
194
  }
191
195
  }
192
196
 
197
+ /**
198
+ * `push --detach --index`: indexing can't run here and still be "detached" — the updated index
199
+ * is part of the version being pushed, so building the patch has to wait for it, and the
200
+ * sending waits for the patch. So this hands off all three, and keeps only the checks that
201
+ * need no folder scan and no chain replay: the signer owns the stream, the credential unlocks
202
+ * it, and the wallet isn't empty. Everything else — "no changes", an indexing error, the
203
+ * version number — shows up in the job (`mnemonad info`).
204
+ *
205
+ * Unlike a plain `--detach`, the folder isn't frozen here: the job reads it when its own turn
206
+ * comes, so edits made before then go into this version.
207
+ */
208
+ async function detachPrepare(args, destPath, streamId) {
209
+ const client = await makeChainClient(args);
210
+ requireWalletClient(client);
211
+ await waitForJobs(
212
+ { signer: client.account.address, chain: args.chain },
213
+ { reason: 'so the two pushes don\'t race for the same wallet nonce' },
214
+ );
215
+
216
+ // Flag combinations are checked here too, not left for the job to trip over.
217
+ resolveEncryptMethod(args);
218
+
219
+ const mn = new Mnemonad({
220
+ publicClient: client.publicClient,
221
+ walletClient: client.walletClient,
222
+ contractAddress: client.contractAddress,
223
+ id: streamId,
224
+ ...resolveOffloadParams(args, client.account),
225
+ });
226
+ await assertStreamOwner(mn, client.account.address);
227
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
228
+
229
+ const balance = await client.publicClient.getBalance({ address: client.account.address });
230
+ if (balance === 0n) {
231
+ throw userError(`${client.account.address} has no MON to pay for the push. Nothing was started.`);
232
+ }
233
+
234
+ const job = await startPushJob(
235
+ {
236
+ mode: 'prepare',
237
+ streamId: formatStreamId(streamId),
238
+ streamKey: streamKey({ chain: args.chain, contractAddress: args.contractAddress, streamId }),
239
+ chain: args.chain,
240
+ signer: client.account.address,
241
+ folder: destPath,
242
+ baseLength: null,
243
+ version: null,
244
+ manifest: args.manifest ? {} : null,
245
+ args: {
246
+ chain: args.chain,
247
+ rpcUrl: args.rpcUrl,
248
+ contractAddress: args.contractAddress,
249
+ gatewayUrl: args.gatewayUrl,
250
+ presignUrl: args.presignUrl,
251
+ exclude: args.exclude,
252
+ compress: args.compress,
253
+ forceSnapshot: args.forceSnapshot,
254
+ searchDb: args.searchDb,
255
+ model: args.model,
256
+ chunkSize: args.chunkSize,
257
+ chunkOverlap: args.chunkOverlap,
258
+ rebuild: args.rebuild,
259
+ },
260
+ },
261
+ null,
262
+ {
263
+ key: args.key || process.env.MNEMONAD_KEY || null,
264
+ phrase: args.phrase,
265
+ password: args.password,
266
+ pinataJwt: args.pinataJwt,
267
+ },
268
+ );
269
+
270
+ console.log(' stream:', formatStreamId(streamId));
271
+ console.log(' indexing, building and sending in the background: job', job.id, '(pid ' + job.pid + ')');
272
+ console.log(' log: ', formatPath(job.dir + '/push.log'));
273
+ console.log(' check: mnemonad info', formatPath(destPath) + (args.manifest ? ' --manifest' : ''));
274
+ }
275
+
193
276
  /**
194
277
  * `push --detach`: everything up to a finished patch runs here, so every error a user can act
195
278
  * on still surfaces before this command exits. Only the sending — transactions, receipts, an
package/lib/jobs.js CHANGED
@@ -29,22 +29,36 @@ import { fileURLToPath } from 'node:url';
29
29
  import Mnemonad from 'mnemonad';
30
30
  import { MonadSync } from 'monadsync';
31
31
  import { makeChainClient, requireWalletClient } from './chainClient.js';
32
+ import { FSFolder } from './FSFolder.js';
32
33
  import {
33
34
  formatStreamId,
34
35
  formatPath,
35
36
  unlockStream,
37
+ assertStreamOwner,
36
38
  resolveOffloadParams,
37
39
  writeManifest,
40
+ makeExcludes,
41
+ hashLocalTree,
42
+ localTreeHash,
38
43
  userError,
39
44
  } from './commands/shared.js';
45
+ import { updateIndex } from './commands/buildIndex.js';
40
46
 
41
47
  const BIN_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'mnemonad.js');
42
48
 
43
49
  /** pending: written, child not started yet · waiting: queued behind an earlier job from the
44
- * same signer · sending: transactions in flight · done / failed: finished. */
45
- export const JOB_STATUS = { PENDING: 'pending', WAITING: 'waiting', SENDING: 'sending', DONE: 'done', FAILED: 'failed' };
50
+ * same signer · preparing: indexing and building the patch (`push --detach --index` only) ·
51
+ * sending: transactions in flight · done / failed: finished. */
52
+ export const JOB_STATUS = {
53
+ PENDING: 'pending', WAITING: 'waiting', PREPARING: 'preparing', SENDING: 'sending', DONE: 'done', FAILED: 'failed',
54
+ };
46
55
 
47
- const ACTIVE = new Set([JOB_STATUS.PENDING, JOB_STATUS.WAITING, JOB_STATUS.SENDING]);
56
+ const ACTIVE = new Set([JOB_STATUS.PENDING, JOB_STATUS.WAITING, JOB_STATUS.PREPARING, JOB_STATUS.SENDING]);
57
+
58
+ /** "version 8", or what a job that hasn't built its version yet is doing instead. */
59
+ function versionLabel(job) {
60
+ return job.version != null ? `version ${job.version}` : 'the next version';
61
+ }
48
62
 
49
63
  /** A job still `pending` with no pid after this long never got its child started. */
50
64
  const SPAWN_GRACE_MS = 60_000;
@@ -176,7 +190,7 @@ export async function waitForJobs(selector, { reason = 'before continuing' } = {
176
190
 
177
191
  const job = first[first.length - 1];
178
192
  console.log(
179
- ` waiting for background push ${job.id} (version ${job.version}) to finish ${reason}...\n` +
193
+ ` waiting for background push ${job.id} (${versionLabel(job)}) to finish ${reason}...\n` +
180
194
  ` log: ${formatPath(join(job.dir, 'push.log'))}`
181
195
  );
182
196
  while (first.length) {
@@ -195,7 +209,7 @@ export function reportFailedJob(key) {
195
209
  const job = latestJob(key);
196
210
  if (!job || job.status !== JOB_STATUS.FAILED || job.reported) return;
197
211
  console.log(
198
- ` warning: background push ${job.id} (version ${job.version}) failed: ${job.error}\n` +
212
+ ` warning: background push ${job.id} (${versionLabel(job)}) failed: ${job.error}\n` +
199
213
  ` nothing from it reached the stream. Log: ${formatPath(join(job.dir, 'push.log'))}`
200
214
  );
201
215
  writeJobSync(job.dir, { ...readJobSync(job.dir), reported: true });
@@ -224,7 +238,7 @@ export async function settleStreamJobs(args, { wait = true, reason } = {}) {
224
238
  if (running.length) {
225
239
  const job = running[running.length - 1];
226
240
  console.log(
227
- ` note: background push ${job.id} (version ${job.version}) is still sending —\n` +
241
+ ` note: background push ${job.id} (${versionLabel(job)}) is still running —\n` +
228
242
  ' this compares against the chain as it is before that version lands'
229
243
  );
230
244
  }
@@ -235,13 +249,17 @@ export function describeJob(job) {
235
249
  const log = formatPath(join(job.dir, 'push.log'));
236
250
  switch (job.status) {
237
251
  case JOB_STATUS.DONE:
238
- return `version ${job.version} sent ${job.finishedAt} (job ${job.id})`;
252
+ return job.noChanges
253
+ ? `no changes, nothing pushed ${job.finishedAt} (job ${job.id})`
254
+ : `version ${job.version} sent ${job.finishedAt} (job ${job.id})`;
239
255
  case JOB_STATUS.FAILED:
240
- return `version ${job.version} FAILED: ${job.error} (job ${job.id}, log: ${log})`;
256
+ return `${versionLabel(job)} FAILED: ${job.error} (job ${job.id}, log: ${log})`;
241
257
  case JOB_STATUS.WAITING:
242
- return `version ${job.version} queued behind another push from the same wallet (job ${job.id}, log: ${log})`;
258
+ return `${versionLabel(job)} queued behind another push from the same wallet (job ${job.id}, log: ${log})`;
259
+ case JOB_STATUS.PREPARING:
260
+ return `indexing and building the next version (job ${job.id}, pid ${job.pid ?? '?'}, log: ${log})`;
243
261
  default:
244
- return `version ${job.version} still sending (job ${job.id}, pid ${job.pid ?? '?'}, log: ${log})`;
262
+ return `${versionLabel(job)} still sending (job ${job.id}, pid ${job.pid ?? '?'}, log: ${log})`;
245
263
  }
246
264
  }
247
265
 
@@ -257,8 +275,9 @@ function pruneFinishedJobs() {
257
275
  /**
258
276
  * Writes a job and starts its detached child.
259
277
  *
260
- * @param {Object} job - everything the child needs except secrets; see push.js's detachPush
261
- * @param {Uint8Array} patchBytes
278
+ * @param {Object} job - everything the child needs except secrets; see push.js's detachPush.
279
+ * `mode: 'prepare'` (push.js's detachPrepare) has the child index and build the patch too.
280
+ * @param {?Uint8Array} patchBytes - the prepared patch; null in `prepare` mode
262
281
  * @param {{key?: ?string, phrase?: ?string, password?: ?string, pinataJwt?: ?string}} secrets
263
282
  * @returns {Promise<{id: string, dir: string, pid: number}>}
264
283
  */
@@ -268,7 +287,7 @@ export async function startPushJob(job, patchBytes, secrets) {
268
287
  const id = randomBytes(4).toString('hex');
269
288
  const dir = join(jobsRoot(), id);
270
289
  await mkdir(dir, { recursive: true });
271
- await writeFile(join(dir, 'patch.bin'), patchBytes);
290
+ if (patchBytes) await writeFile(join(dir, 'patch.bin'), patchBytes);
272
291
  writeJobSync(dir, { ...job, id, status: JOB_STATUS.PENDING, pid: null, createdAt: new Date().toISOString() });
273
292
 
274
293
  const logFd = openSync(join(dir, 'push.log'), 'a');
@@ -331,9 +350,6 @@ export async function runPushJob(dir) {
331
350
  while (earlier().length) await new Promise((r) => setTimeout(r, POLL_MS));
332
351
  }
333
352
 
334
- update({ status: JOB_STATUS.SENDING, startedAt: new Date().toISOString() });
335
- log(`sending version ${job.version} of ${job.streamId} on ${job.chain}...`);
336
-
337
353
  const args = { ...job.args, key: secrets.key ?? null, phrase: secrets.phrase ?? null, password: secrets.password ?? null, pinataJwt: secrets.pinataJwt ?? null, passkey: false };
338
354
  const client = await makeChainClient(args);
339
355
  requireWalletClient(client);
@@ -347,11 +363,31 @@ export async function runPushJob(dir) {
347
363
  });
348
364
  await unlockStream(mn, { password: args.password, signer: client.walletClient });
349
365
 
350
- const patchBytes = new Uint8Array(await readFile(join(dir, 'patch.bin')));
351
- const result = await new MonadSync({ mnemonad: mn }).commitPush({ patchBytes, baseLength: job.baseLength });
366
+ let sync;
367
+ let prepared;
368
+ let files = job.manifest?.files || null;
369
+ if (job.mode === 'prepare') {
370
+ update({ status: JOB_STATUS.PREPARING, startedAt: new Date().toISOString() });
371
+ await assertStreamOwner(mn, client.account.address);
372
+ const built = await prepareInBackground(job, args, mn);
373
+ if (!built) {
374
+ update({ status: JOB_STATUS.DONE, noChanges: true, version: mn.length, finishedAt: new Date().toISOString() });
375
+ log('no changes, nothing to push');
376
+ return;
377
+ }
378
+ ({ sync, prepared, files } = built);
379
+ update({ version: prepared.version, baseLength: prepared.baseLength, kind: prepared.kind, bytes: prepared.patchBytes.length });
380
+ } else {
381
+ sync = new MonadSync({ mnemonad: mn });
382
+ prepared = { patchBytes: new Uint8Array(await readFile(join(dir, 'patch.bin'))), baseLength: job.baseLength };
383
+ }
384
+
385
+ update({ status: JOB_STATUS.SENDING, sendingAt: new Date().toISOString() });
386
+ log(`sending version ${job.version} of ${job.streamId} on ${job.chain}...`);
387
+ const result = await sync.commitPush(prepared);
352
388
 
353
389
  if (job.manifest) {
354
- await writeManifest(job.folder, { streamId: job.streamId, version: result.version, files: job.manifest.files });
390
+ await writeManifest(job.folder, { streamId: job.streamId, version: result.version, files });
355
391
  }
356
392
  update({ status: JOB_STATUS.DONE, version: result.version, finishedAt: new Date().toISOString() });
357
393
  log(`version ${result.version} pushed`);
@@ -367,6 +403,49 @@ export async function runPushJob(dir) {
367
403
  }
368
404
  }
369
405
 
406
+ /**
407
+ * The part of a foreground push that `push --detach --index` hands off along with the sending:
408
+ * update the search index, replay the chain, and build the patch — same steps, same order, as
409
+ * push.js. Runs after any earlier job from the same wallet has landed, so it builds on the
410
+ * real tip.
411
+ *
412
+ * @returns {Promise<?{sync: MonadSync, prepared: Object, files: ?Object}>} null when the folder
413
+ * already matches the latest version
414
+ */
415
+ async function prepareInBackground(job, args, mn) {
416
+ log('updating search index...');
417
+ try {
418
+ await updateIndex(job.folder, args, { quiet: true });
419
+ } catch (err) {
420
+ throw new Error(`indexing failed, so nothing was pushed: ${err.message}`);
421
+ }
422
+
423
+ const fsFolder = new FSFolder(job.folder, makeExcludes(args));
424
+ const sync = new MonadSync({ mnemonad: mn, compress: args.compress === false ? false : 'gzip' });
425
+
426
+ if (args.forceSnapshot) {
427
+ // Same escape hatch push.js uses — see monadsync/README.md "Repairing a corrupt chain".
428
+ await mn.initialize();
429
+ sync._isInitialized = true;
430
+ sync._lastSnapshot = null;
431
+ sync._replayedCount = mn.length;
432
+ } else {
433
+ await sync.initialize();
434
+ if (mn.length > 0) {
435
+ const localHash = await localTreeHash(job.folder);
436
+ const remoteHash = await sync.getTreeHash(mn.length);
437
+ if (localHash && remoteHash.length === localHash.length && localHash.every((b, i) => b === remoteHash[i])) {
438
+ return null;
439
+ }
440
+ }
441
+ }
442
+
443
+ log('building patch...');
444
+ const prepared = await sync.preparePush(fsFolder);
445
+ const files = job.manifest ? await hashLocalTree(fsFolder) : null;
446
+ return { sync, prepared, files };
447
+ }
448
+
370
449
  /** For tests: blocks until a job has finished, and returns it. */
371
450
  export async function waitForJob(dir, { timeoutMs = 60_000 } = {}) {
372
451
  const deadline = Date.now() + timeoutMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnemonad-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CLI to sync local folders to versioned, diffed on-chain streams on Monad, backed by Mnemonad + monadsync.",
5
5
  "repository": {
6
6
  "type": "git",