mnemonad-cli 0.3.0 → 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 +66 -1
- package/bin/mnemonad.js +12 -0
- package/lib/commands/compact.js +2 -0
- package/lib/commands/diff.js +2 -0
- package/lib/commands/info.js +28 -0
- package/lib/commands/pull.js +5 -0
- package/lib/commands/push.js +191 -0
- package/lib/commands/watch.js +2 -0
- package/lib/jobs.js +459 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -118,6 +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. With `--index`, indexing and building move to the background too. Existing streams only. See "Push in the background" below |
|
|
121
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 |
|
|
122
123
|
| `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
|
|
123
124
|
| `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
|
|
@@ -230,7 +231,7 @@ mnemonad pull 123 ~/restored --version 1
|
|
|
230
231
|
### Inspect a stream
|
|
231
232
|
|
|
232
233
|
```bash
|
|
233
|
-
# check your wallet
|
|
234
|
+
# check your wallet, its MON balance, and the chain (no stream id needed)
|
|
234
235
|
mnemonad info
|
|
235
236
|
|
|
236
237
|
# full stream metadata + local sync status
|
|
@@ -240,6 +241,7 @@ mnemonad info 123 ~/my-data
|
|
|
240
241
|
```
|
|
241
242
|
chain: testnet
|
|
242
243
|
your wallet: 0x06bc6420b37a4898424429dcfa236f0065e12279
|
|
244
|
+
balance: 4.2183 MON
|
|
243
245
|
|
|
244
246
|
stream: 0x68d9736257f0d2da3c848666a4b36c4b9f18eda7e4dfba655599c47560d8a4b5
|
|
245
247
|
owner: 0x06bc6420b37a4898424429dcfa236f0065e12279 (you)
|
|
@@ -349,6 +351,69 @@ mnemonad burn 123
|
|
|
349
351
|
mnemonad burn 123 --yes
|
|
350
352
|
```
|
|
351
353
|
|
|
354
|
+
### Push in the background
|
|
355
|
+
|
|
356
|
+
A push spends most of its time waiting for the chain. `--detach` does everything that can
|
|
357
|
+
fail for your own reasons first, here and now: the owner check, unlocking, `--index`, "no
|
|
358
|
+
changes", and the balance against the estimated cost. Then it hands only the sending to a
|
|
359
|
+
background process and returns:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
mnemonad push ~/my-data --manifest --detach
|
|
363
|
+
# version 8 prepared (diff, gzip compressed, 2.1 KB)
|
|
364
|
+
# estimated cost: 0.0041 MON
|
|
365
|
+
# stream: 0x68d9…
|
|
366
|
+
# sending in the background: job 8f3c1a2b (pid 41210)
|
|
367
|
+
# log: ~/.cache/mnemonad/jobs/8f3c1a2b/push.log
|
|
368
|
+
# check: mnemonad info ~/my-data --manifest
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
- **The version is frozen when the command runs.** Edits made after it returns go into the
|
|
372
|
+
next push, not this one.
|
|
373
|
+
- **Existing streams only.** It needs a stream id, or `--manifest` on a folder that has a
|
|
374
|
+
`.mnemonad`. Create a stream with a normal push first. `--passkey` can't be used: the browser
|
|
375
|
+
tab that signs for it closes when the command exits.
|
|
376
|
+
- **The next command waits for it.** A `push`, `pull`, `compact` or `watch` of the same stream
|
|
377
|
+
waits until the background push has finished, so nothing builds on, or restores, the version
|
|
378
|
+
before it. A push from the same wallet to another stream waits too, so the two don't race for
|
|
379
|
+
one nonce. `diff` only notes that a push is still sending.
|
|
380
|
+
- **Results:** `mnemonad info <path>` shows the last background push for the stream, and when
|
|
381
|
+
it failed, why. If one fails, the next command on that stream also prints a warning, once.
|
|
382
|
+
- **Nothing half-lands.** The background process refuses to send a version whose base is gone
|
|
383
|
+
(the stream changed after it was prepared), and the contract rejects any write that doesn't
|
|
384
|
+
land at the index it names. A failed background push leaves the stream as it was. Push again.
|
|
385
|
+
- **Secrets stay in memory.** The key, the password and a Pinata JWT are handed to the
|
|
386
|
+
background process over a pipe. They are never written to the job folder, the log, or the
|
|
387
|
+
process list.
|
|
388
|
+
- If the background process is killed mid-send, the job is reported as failed the next time a
|
|
389
|
+
command looks at it. A large push that was staged over several transactions, or paid for an
|
|
390
|
+
IPFS upload, can't be resumed. A paid but unused upload can be refunded after the gateway's
|
|
391
|
+
time limit, with the SDK's `MnemonadUploadGateway.refund()` (the CLI has no command for it).
|
|
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
|
+
|
|
414
|
+
Jobs live in `~/.cache/mnemonad/jobs/` (set `MNEMONAD_JOBS_DIR` to move them). Finished ones are
|
|
415
|
+
removed after 14 days.
|
|
416
|
+
|
|
352
417
|
### Repair a corrupt stream with a force snapshot
|
|
353
418
|
|
|
354
419
|
If a diff patch was pushed against a stale base (e.g. a race condition in `watch`),
|
package/bin/mnemonad.js
CHANGED
|
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
|
|
|
20
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
22
|
import { closePasskeyBridge } from '../lib/passkeyBridge.js';
|
|
23
|
+
import { runPushJob } from '../lib/jobs.js';
|
|
23
24
|
import defaultConfig from '../mnemonad.config.js';
|
|
24
25
|
|
|
25
26
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -104,6 +105,11 @@ Options:
|
|
|
104
105
|
With no stream-id given, every command recovers it from an
|
|
105
106
|
existing .mnemonad in <path> instead of requiring it explicitly
|
|
106
107
|
--force-snapshot Push a full snapshot regardless of prior history (repairs corrupt streams)
|
|
108
|
+
--detach push: build the next version here, send it from a background process,
|
|
109
|
+
and return right away. Existing streams only (an id, or --manifest).
|
|
110
|
+
Later pushes and pulls of that stream wait for it; \`mnemonad info\`
|
|
111
|
+
shows how it went. With --index, indexing and building the version
|
|
112
|
+
move to the background too
|
|
107
113
|
--index push/watch: update the folder's search index (search_index.db)
|
|
108
114
|
before every push, so the stream is searchable right after a pull.
|
|
109
115
|
Takes --db/--model/--chunk-size/--chunk-overlap like \`index\`
|
|
@@ -162,6 +168,7 @@ function parseArgs(argv) {
|
|
|
162
168
|
index: false,
|
|
163
169
|
rebuild: false,
|
|
164
170
|
forceSnapshot: false,
|
|
171
|
+
detach: false,
|
|
165
172
|
pollInterval: 2,
|
|
166
173
|
debounce: 1000,
|
|
167
174
|
pushOnly: false,
|
|
@@ -281,6 +288,8 @@ function parseArgs(argv) {
|
|
|
281
288
|
args.rebuild = true;
|
|
282
289
|
} else if (flag === '--force-snapshot') {
|
|
283
290
|
args.forceSnapshot = true;
|
|
291
|
+
} else if (flag === '--detach') {
|
|
292
|
+
args.detach = true;
|
|
284
293
|
} else if (flag === '--poll-interval' && i + 1 < raw.length) {
|
|
285
294
|
args.pollInterval = Number(raw[++i]);
|
|
286
295
|
} else if (flag === '--debounce' && i + 1 < raw.length) {
|
|
@@ -363,6 +372,9 @@ try {
|
|
|
363
372
|
await buildIndex(args);
|
|
364
373
|
} else if (args.command === 'search') {
|
|
365
374
|
await search(args);
|
|
375
|
+
} else if (args.command === '__push-job') {
|
|
376
|
+
// The detached half of `push --detach` (lib/jobs.js) — never typed by a person.
|
|
377
|
+
await runPushJob(args.path);
|
|
366
378
|
} else {
|
|
367
379
|
console.error('Unknown command:', args.command);
|
|
368
380
|
console.log(USAGE);
|
package/lib/commands/compact.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveOffloadParams,
|
|
13
13
|
discoverStreamId,
|
|
14
14
|
} from './shared.js';
|
|
15
|
+
import { settleStreamJobs } from '../jobs.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Monad has no rebate/refund concept — truncating a stream's history here gives back no
|
|
@@ -28,6 +29,7 @@ export async function compact(args) {
|
|
|
28
29
|
if (!args.streamId) {
|
|
29
30
|
throw new Error('stream-id is required for compact');
|
|
30
31
|
}
|
|
32
|
+
await settleStreamJobs(args, { reason: 'before replacing the history with a fresh snapshot' });
|
|
31
33
|
|
|
32
34
|
const fsFolder = new FSFolder(destPath, makeExcludes(args));
|
|
33
35
|
|
package/lib/commands/diff.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
folderExists,
|
|
13
13
|
discoverStreamId,
|
|
14
14
|
} from './shared.js';
|
|
15
|
+
import { settleStreamJobs } from '../jobs.js';
|
|
15
16
|
|
|
16
17
|
export function compareFileMaps(localFiles, remoteFiles) {
|
|
17
18
|
const added = [];
|
|
@@ -55,6 +56,7 @@ export async function diff(args) {
|
|
|
55
56
|
if (!args.streamId) {
|
|
56
57
|
throw new Error('stream-id is required for diff');
|
|
57
58
|
}
|
|
59
|
+
await settleStreamJobs(args, { wait: false });
|
|
58
60
|
|
|
59
61
|
const client = await makeChainClient(args);
|
|
60
62
|
|
package/lib/commands/info.js
CHANGED
|
@@ -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,
|
|
@@ -12,12 +13,16 @@ import {
|
|
|
12
13
|
readManifest,
|
|
13
14
|
streamIdsEqual,
|
|
14
15
|
} from './shared.js';
|
|
16
|
+
import { streamKey, latestJob, describeJob } from '../jobs.js';
|
|
15
17
|
|
|
16
18
|
export async function info(args) {
|
|
17
19
|
const client = await makeChainClient(args);
|
|
18
20
|
|
|
19
21
|
console.log(' chain: ', args.chain);
|
|
20
22
|
console.log(' your wallet: ', client.account?.address || '(none)');
|
|
23
|
+
if (client.account) {
|
|
24
|
+
console.log(' balance: ', await walletBalance(client));
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
// Peek at .mnemonad whenever a path is given, regardless of --manifest and regardless
|
|
23
28
|
// of whether an id was given — info is read-only, so surfacing what's there costs
|
|
@@ -45,6 +50,14 @@ export async function info(args) {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
// The last background push (`push --detach`) for this stream, whichever way its id is
|
|
54
|
+
// known — even with no --manifest, the folder's own manifest names the stream.
|
|
55
|
+
const jobStreamId = args.streamId || manifest?.streamId;
|
|
56
|
+
if (jobStreamId) {
|
|
57
|
+
const job = latestJob(streamKey({ chain: args.chain, contractAddress: args.contractAddress, streamId: jobStreamId }));
|
|
58
|
+
if (job) console.log(' background push:', describeJob(job));
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
if (!args.streamId) {
|
|
49
62
|
return;
|
|
50
63
|
}
|
|
@@ -161,3 +174,18 @@ export async function info(args) {
|
|
|
161
174
|
console.log(' latest remote: version', totalVersions);
|
|
162
175
|
}
|
|
163
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
|
+
}
|
package/lib/commands/pull.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatStreamId,
|
|
12
12
|
discoverStreamId,
|
|
13
13
|
} from './shared.js';
|
|
14
|
+
import { settleStreamJobs } from '../jobs.js';
|
|
14
15
|
|
|
15
16
|
export async function pull(args) {
|
|
16
17
|
// Resolved up front (throws if missing) so a missing path fails fast, before any chain
|
|
@@ -24,6 +25,10 @@ export async function pull(args) {
|
|
|
24
25
|
throw new Error('stream-id is required for pull');
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Pulling while a background push of this folder is still sending would restore the
|
|
29
|
+
// version before it — and delete the very files that push is uploading.
|
|
30
|
+
await settleStreamJobs(args, { reason: 'so the pull includes it' });
|
|
31
|
+
|
|
27
32
|
const client = await makeChainClient(args);
|
|
28
33
|
|
|
29
34
|
const mn = new Mnemonad({
|
package/lib/commands/push.js
CHANGED
|
@@ -19,8 +19,12 @@ import {
|
|
|
19
19
|
streamIdsEqual,
|
|
20
20
|
discoverStreamId,
|
|
21
21
|
userError,
|
|
22
|
+
formatBytes,
|
|
23
|
+
formatPath,
|
|
22
24
|
} from './shared.js';
|
|
23
25
|
import { updateIndex, checkIndex, indexDbName } from './buildIndex.js';
|
|
26
|
+
import { streamKey, waitForJobs, settleStreamJobs, startPushJob } from '../jobs.js';
|
|
27
|
+
import { formatEther } from 'viem';
|
|
24
28
|
|
|
25
29
|
export async function push(args) {
|
|
26
30
|
const destPath = resolvePath(args.path, { mustExist: true });
|
|
@@ -31,12 +35,35 @@ export async function push(args) {
|
|
|
31
35
|
await discoverStreamId(args, destPath);
|
|
32
36
|
let streamId = args.streamId;
|
|
33
37
|
|
|
38
|
+
if (args.detach) {
|
|
39
|
+
// Creating a stream prints the id the user has to keep, and needs signatures that have
|
|
40
|
+
// to be seen to succeed — nothing worth handing off. --passkey's signer is a browser
|
|
41
|
+
// tab that closes when this process exits.
|
|
42
|
+
if (!streamId) {
|
|
43
|
+
throw userError(
|
|
44
|
+
'--detach only pushes to an existing stream. Pass its id, or --manifest on a folder\n' +
|
|
45
|
+
' that has a .mnemonad, or push once without --detach to create the stream first.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (args.passkey) {
|
|
49
|
+
throw userError('--detach can\'t be combined with --passkey: the browser tab signing for it closes when this command exits.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A background push still sending to this stream means its next version isn't on chain
|
|
54
|
+
// yet — building on top of the current tip now would only lose the race to it.
|
|
55
|
+
await settleStreamJobs(args, { reason: 'before building the next version on top of it' });
|
|
56
|
+
|
|
34
57
|
// Before anything is scanned or signed: the push below then picks the updated index up
|
|
35
58
|
// like any other file. A failure stops the push outright — a push that silently went out
|
|
36
59
|
// without the index asked for would leave the stream unsearchable with no sign of why.
|
|
37
60
|
if (args.rebuild && !args.index) {
|
|
38
61
|
throw userError('--rebuild only applies with --index (it starts the search index over)');
|
|
39
62
|
}
|
|
63
|
+
if (args.detach && args.index) {
|
|
64
|
+
await detachPrepare(args, destPath, streamId);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
40
67
|
if (args.index) {
|
|
41
68
|
console.log(args.rebuild ? ' rebuilding search index...' : ' updating search index...');
|
|
42
69
|
try {
|
|
@@ -67,6 +94,12 @@ export async function push(args) {
|
|
|
67
94
|
const client = await makeChainClient(args);
|
|
68
95
|
requireWalletClient(client);
|
|
69
96
|
|
|
97
|
+
// Same wallet, different stream: two processes sending at once would race for one nonce.
|
|
98
|
+
await waitForJobs(
|
|
99
|
+
{ signer: client.account.address, chain: args.chain },
|
|
100
|
+
{ reason: 'so the two pushes don\'t race for the same wallet nonce' },
|
|
101
|
+
);
|
|
102
|
+
|
|
70
103
|
const baseParams = {
|
|
71
104
|
publicClient: client.publicClient,
|
|
72
105
|
walletClient: client.walletClient,
|
|
@@ -139,6 +172,11 @@ export async function push(args) {
|
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
174
|
|
|
175
|
+
if (args.detach) {
|
|
176
|
+
await detachPush({ args, mn, monadSync, fsFolder, client, streamId, destPath, existingVersions });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
console.log(' building patch + pushing to chain...');
|
|
143
181
|
const result = await monadSync.push(fsFolder);
|
|
144
182
|
|
|
@@ -156,6 +194,159 @@ export async function push(args) {
|
|
|
156
194
|
}
|
|
157
195
|
}
|
|
158
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
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `push --detach`: everything up to a finished patch runs here, so every error a user can act
|
|
278
|
+
* on still surfaces before this command exits. Only the sending — transactions, receipts, an
|
|
279
|
+
* IPFS upload — goes to a detached child (see lib/jobs.js), and this process exits as soon
|
|
280
|
+
* as it has started.
|
|
281
|
+
*
|
|
282
|
+
* The patch is frozen now: files edited after this command returns go into the next push,
|
|
283
|
+
* not this one. The manifest's file hashes are taken at the same moment for the same reason.
|
|
284
|
+
*/
|
|
285
|
+
async function detachPush({ args, mn, monadSync, fsFolder, client, streamId, destPath, existingVersions }) {
|
|
286
|
+
console.log(' building patch...');
|
|
287
|
+
const prepared = await monadSync.preparePush(fsFolder);
|
|
288
|
+
|
|
289
|
+
// A single-item patch is the common case and the only one estimatePushCost models; a
|
|
290
|
+
// segmented one (over 8 MB) just goes without an estimate rather than a wrong one.
|
|
291
|
+
let cost = null;
|
|
292
|
+
if (prepared.itemCount === 1) {
|
|
293
|
+
try {
|
|
294
|
+
cost = await mn.estimatePushCost(prepared.patchBytes);
|
|
295
|
+
} catch {
|
|
296
|
+
// No estimate is not a reason to stop — the child surfaces any real send error.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (cost) {
|
|
300
|
+
const balance = await client.publicClient.getBalance({ address: client.account.address });
|
|
301
|
+
if (balance < cost.total) {
|
|
302
|
+
throw userError(
|
|
303
|
+
`not enough MON to push: about ${formatEther(cost.total)} needed, ` +
|
|
304
|
+
`${formatEther(balance)} in ${client.account.address}. Nothing was sent.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const files = args.manifest ? await hashLocalTree(fsFolder) : null;
|
|
310
|
+
const job = await startPushJob(
|
|
311
|
+
{
|
|
312
|
+
streamId: formatStreamId(streamId),
|
|
313
|
+
streamKey: streamKey({ chain: args.chain, contractAddress: args.contractAddress, streamId }),
|
|
314
|
+
chain: args.chain,
|
|
315
|
+
signer: client.account.address,
|
|
316
|
+
folder: destPath,
|
|
317
|
+
baseLength: prepared.baseLength,
|
|
318
|
+
version: prepared.version,
|
|
319
|
+
kind: prepared.kind,
|
|
320
|
+
bytes: prepared.patchBytes.length,
|
|
321
|
+
manifest: files ? { files } : null,
|
|
322
|
+
// Everything the child needs to rebuild the same chain client — never a secret.
|
|
323
|
+
args: {
|
|
324
|
+
chain: args.chain,
|
|
325
|
+
rpcUrl: args.rpcUrl,
|
|
326
|
+
contractAddress: args.contractAddress,
|
|
327
|
+
gatewayUrl: args.gatewayUrl,
|
|
328
|
+
presignUrl: args.presignUrl,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
prepared.patchBytes,
|
|
332
|
+
{
|
|
333
|
+
key: args.key || process.env.MNEMONAD_KEY || null,
|
|
334
|
+
phrase: args.phrase,
|
|
335
|
+
password: args.password,
|
|
336
|
+
pinataJwt: args.pinataJwt,
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const patchType = existingVersions === 0 || prepared.kind === 'patch' ? 'full snapshot' : 'diff';
|
|
341
|
+
const compressedNote = args.compress !== false ? ', gzip compressed' : '';
|
|
342
|
+
console.log(' version', prepared.version, 'prepared (' + patchType + compressedNote + ', ' + formatBytes(prepared.patchBytes.length) + ')');
|
|
343
|
+
if (cost) console.log(' estimated cost:', formatEther(cost.total), 'MON');
|
|
344
|
+
console.log(' stream:', formatStreamId(streamId));
|
|
345
|
+
console.log(' sending in the background: job', job.id, '(pid ' + job.pid + ')');
|
|
346
|
+
console.log(' log: ', formatPath(job.dir + '/push.log'));
|
|
347
|
+
console.log(' check: mnemonad info', formatPath(destPath) + (args.manifest ? ' --manifest' : ''));
|
|
348
|
+
}
|
|
349
|
+
|
|
159
350
|
/**
|
|
160
351
|
* A folder that already carries an index, pushed without `--index` after its files changed,
|
|
161
352
|
* would publish an index that no longer matches them — searches keep returning the old text's
|
package/lib/commands/watch.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
discoverStreamId,
|
|
19
19
|
userError,
|
|
20
20
|
} from './shared.js';
|
|
21
|
+
import { settleStreamJobs } from '../jobs.js';
|
|
21
22
|
import { makeIndexer, updateIndex } from './buildIndex.js';
|
|
22
23
|
|
|
23
24
|
function timestamp() {
|
|
@@ -37,6 +38,7 @@ export async function watch(args) {
|
|
|
37
38
|
// no need to require it again.
|
|
38
39
|
await discoverStreamId(args, cwd);
|
|
39
40
|
if (!args.streamId) throw new Error('stream-id is required for watch');
|
|
41
|
+
await settleStreamJobs(args, { reason: 'before watching' });
|
|
40
42
|
const debounceMs = args.debounce ?? 5000;
|
|
41
43
|
const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
|
|
42
44
|
const pushEnabled = !args.pullOnly;
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background push jobs — what `mnemonad push --detach` leaves behind.
|
|
3
|
+
*
|
|
4
|
+
* A detached push builds its patch in the foreground (every check that can fail for the
|
|
5
|
+
* user's own reasons runs there: ownership, unlocking, indexing, "nothing to push", the
|
|
6
|
+
* balance), then hands only the sending to a detached child process and exits. Each job is a
|
|
7
|
+
* folder under `~/.cache/mnemonad/jobs/<id>/`:
|
|
8
|
+
*
|
|
9
|
+
* job.json — what to send where, and the job's status (see JOB_STATUS)
|
|
10
|
+
* patch.bin — the prepared patch, frozen when the command ran; deleted once sent
|
|
11
|
+
* push.log — the child's own output
|
|
12
|
+
*
|
|
13
|
+
* Secrets (a key, a phrase, a password, a Pinata JWT) never touch this folder or the child's
|
|
14
|
+
* argv: the parent writes them to the child's stdin and closes it.
|
|
15
|
+
*
|
|
16
|
+
* Correctness never depends on the locking below. The child refuses to send a patch whose
|
|
17
|
+
* base version is gone (`MonadSync.commitPush`), and the contract rejects any write that
|
|
18
|
+
* doesn't land at the index it names. The waiting here only keeps the next command from
|
|
19
|
+
* racing a job it would lose to anyway — or, for a pull, from overwriting the folder with the
|
|
20
|
+
* version before the one still being sent.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { openSync, closeSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
24
|
+
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
25
|
+
import { randomBytes } from 'node:crypto';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import Mnemonad from 'mnemonad';
|
|
30
|
+
import { MonadSync } from 'monadsync';
|
|
31
|
+
import { makeChainClient, requireWalletClient } from './chainClient.js';
|
|
32
|
+
import { FSFolder } from './FSFolder.js';
|
|
33
|
+
import {
|
|
34
|
+
formatStreamId,
|
|
35
|
+
formatPath,
|
|
36
|
+
unlockStream,
|
|
37
|
+
assertStreamOwner,
|
|
38
|
+
resolveOffloadParams,
|
|
39
|
+
writeManifest,
|
|
40
|
+
makeExcludes,
|
|
41
|
+
hashLocalTree,
|
|
42
|
+
localTreeHash,
|
|
43
|
+
userError,
|
|
44
|
+
} from './commands/shared.js';
|
|
45
|
+
import { updateIndex } from './commands/buildIndex.js';
|
|
46
|
+
|
|
47
|
+
const BIN_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'mnemonad.js');
|
|
48
|
+
|
|
49
|
+
/** pending: written, child not started yet · waiting: queued behind an earlier job from the
|
|
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
|
+
};
|
|
55
|
+
|
|
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
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A job still `pending` with no pid after this long never got its child started. */
|
|
64
|
+
const SPAWN_GRACE_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/** Finished jobs older than this are removed the next time a job is created. */
|
|
67
|
+
const KEEP_FINISHED_MS = 14 * 24 * 60 * 60 * 1000;
|
|
68
|
+
|
|
69
|
+
const POLL_MS = 1000;
|
|
70
|
+
|
|
71
|
+
/** `MNEMONAD_JOBS_DIR` overrides the location (the tests use a temp dir). */
|
|
72
|
+
export function jobsRoot() {
|
|
73
|
+
return process.env.MNEMONAD_JOBS_DIR || join(homedir(), '.cache', 'mnemonad', 'jobs');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Identifies a stream across commands: the same token id is a different stream on another
|
|
78
|
+
* chain or registry.
|
|
79
|
+
*
|
|
80
|
+
* @param {{chain: string, contractAddress: ?string, streamId: bigint|string}} where
|
|
81
|
+
*/
|
|
82
|
+
export function streamKey({ chain, contractAddress, streamId }) {
|
|
83
|
+
return `${chain}|${String(contractAddress || '').toLowerCase()}|${formatStreamId(streamId)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function signerKey({ chain, signer }) {
|
|
87
|
+
return `${chain}|${String(signer || '').toLowerCase()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPidAlive(pid) {
|
|
91
|
+
if (!pid) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// EPERM: the process exists, it's just not ours to signal.
|
|
97
|
+
return err.code === 'EPERM';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeJobSync(dir, job) {
|
|
102
|
+
// Synchronous on purpose: callers read-modify-write job.json from two processes, and a
|
|
103
|
+
// torn async write would leave a half-written file for the other side to parse.
|
|
104
|
+
const tmp = join(dir, `job.json.${process.pid}.tmp`);
|
|
105
|
+
writeFileSync(tmp, JSON.stringify(job, null, 2));
|
|
106
|
+
renameSync(tmp, join(dir, 'job.json'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readJobSync(dir) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(join(dir, 'job.json'), 'utf8'));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Re-reads a job and settles it as failed when it claims to be running but nothing is: the
|
|
119
|
+
* child was killed, the machine slept through a crash, or the spawn never happened.
|
|
120
|
+
*/
|
|
121
|
+
function refreshJob(dir) {
|
|
122
|
+
const job = readJobSync(dir);
|
|
123
|
+
if (!job || !ACTIVE.has(job.status)) return job;
|
|
124
|
+
|
|
125
|
+
const neverStarted = !job.pid && Date.now() - Date.parse(job.createdAt) > SPAWN_GRACE_MS;
|
|
126
|
+
if (neverStarted || (job.pid && !isPidAlive(job.pid))) {
|
|
127
|
+
const settled = {
|
|
128
|
+
...job,
|
|
129
|
+
status: JOB_STATUS.FAILED,
|
|
130
|
+
error: neverStarted
|
|
131
|
+
? 'the background process never started'
|
|
132
|
+
: 'the background process exited before finishing',
|
|
133
|
+
finishedAt: new Date().toISOString(),
|
|
134
|
+
reported: false,
|
|
135
|
+
};
|
|
136
|
+
writeJobSync(dir, settled);
|
|
137
|
+
return settled;
|
|
138
|
+
}
|
|
139
|
+
return job;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Every job on disk, oldest first, with dead ones settled as failed. */
|
|
143
|
+
export function listJobs() {
|
|
144
|
+
let names;
|
|
145
|
+
try {
|
|
146
|
+
names = readdirSync(jobsRoot());
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const jobs = [];
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
const dir = join(jobsRoot(), name);
|
|
153
|
+
const job = refreshJob(dir);
|
|
154
|
+
if (job) jobs.push({ ...job, dir });
|
|
155
|
+
}
|
|
156
|
+
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isActive(job) {
|
|
160
|
+
return ACTIVE.has(job.status);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Jobs still running for a stream (`{streamKey}`) or a signer (`{signer, chain}`). */
|
|
164
|
+
export function activeJobs(selector) {
|
|
165
|
+
return listJobs().filter((job) => isActive(job) && matches(job, selector));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function matches(job, selector) {
|
|
169
|
+
if (selector.streamKey) return job.streamKey === selector.streamKey;
|
|
170
|
+
return signerKey(job) === signerKey(selector);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The most recent job for a stream, active or not. */
|
|
174
|
+
export function latestJob(key) {
|
|
175
|
+
const jobs = listJobs().filter((job) => job.streamKey === key);
|
|
176
|
+
return jobs.length ? jobs[jobs.length - 1] : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Blocks until no job matching `selector` is still running, printing one line while it
|
|
181
|
+
* waits. Returns how many it waited for.
|
|
182
|
+
*
|
|
183
|
+
* @param {{streamKey: string}|{signer: string, chain: string}} selector
|
|
184
|
+
* @param {Object} [opts]
|
|
185
|
+
* @param {string} [opts.reason] - why this command has to wait, for the printed line
|
|
186
|
+
*/
|
|
187
|
+
export async function waitForJobs(selector, { reason = 'before continuing' } = {}) {
|
|
188
|
+
let first = activeJobs(selector);
|
|
189
|
+
if (!first.length) return 0;
|
|
190
|
+
|
|
191
|
+
const job = first[first.length - 1];
|
|
192
|
+
console.log(
|
|
193
|
+
` waiting for background push ${job.id} (${versionLabel(job)}) to finish ${reason}...\n` +
|
|
194
|
+
` log: ${formatPath(join(job.dir, 'push.log'))}`
|
|
195
|
+
);
|
|
196
|
+
while (first.length) {
|
|
197
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
198
|
+
first = activeJobs(selector);
|
|
199
|
+
}
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Prints a failed job's error once — the next command on that stream after the failure —
|
|
205
|
+
* then marks it reported, so a failure is never silent past one run but doesn't nag forever.
|
|
206
|
+
* `mnemonad info` keeps showing it regardless (see describeJob).
|
|
207
|
+
*/
|
|
208
|
+
export function reportFailedJob(key) {
|
|
209
|
+
const job = latestJob(key);
|
|
210
|
+
if (!job || job.status !== JOB_STATUS.FAILED || job.reported) return;
|
|
211
|
+
console.log(
|
|
212
|
+
` warning: background push ${job.id} (${versionLabel(job)}) failed: ${job.error}\n` +
|
|
213
|
+
` nothing from it reached the stream. Log: ${formatPath(join(job.dir, 'push.log'))}`
|
|
214
|
+
);
|
|
215
|
+
writeJobSync(job.dir, { ...readJobSync(job.dir), reported: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* What every command that touches a stream calls once its id is known: reports a failed
|
|
220
|
+
* background push (once), then either waits for a running one (`wait: true` — push, pull,
|
|
221
|
+
* compact, watch: anything that would build on, or overwrite with, the version before it) or
|
|
222
|
+
* just says one is running (diff, which only reads).
|
|
223
|
+
*
|
|
224
|
+
* @param {Object} args - parsed CLI args; needs `chain`, `contractAddress`, `streamId`
|
|
225
|
+
* @param {Object} [opts]
|
|
226
|
+
* @param {boolean} [opts.wait=true]
|
|
227
|
+
* @param {string} [opts.reason] - see waitForJobs
|
|
228
|
+
*/
|
|
229
|
+
export async function settleStreamJobs(args, { wait = true, reason } = {}) {
|
|
230
|
+
if (!args.streamId) return;
|
|
231
|
+
const key = streamKey({ chain: args.chain, contractAddress: args.contractAddress, streamId: args.streamId });
|
|
232
|
+
reportFailedJob(key);
|
|
233
|
+
if (wait) {
|
|
234
|
+
await waitForJobs({ streamKey: key }, { reason });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const running = activeJobs({ streamKey: key });
|
|
238
|
+
if (running.length) {
|
|
239
|
+
const job = running[running.length - 1];
|
|
240
|
+
console.log(
|
|
241
|
+
` note: background push ${job.id} (${versionLabel(job)}) is still running —\n` +
|
|
242
|
+
' this compares against the chain as it is before that version lands'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One line for `mnemonad info`. */
|
|
248
|
+
export function describeJob(job) {
|
|
249
|
+
const log = formatPath(join(job.dir, 'push.log'));
|
|
250
|
+
switch (job.status) {
|
|
251
|
+
case JOB_STATUS.DONE:
|
|
252
|
+
return job.noChanges
|
|
253
|
+
? `no changes, nothing pushed ${job.finishedAt} (job ${job.id})`
|
|
254
|
+
: `version ${job.version} sent ${job.finishedAt} (job ${job.id})`;
|
|
255
|
+
case JOB_STATUS.FAILED:
|
|
256
|
+
return `${versionLabel(job)} FAILED: ${job.error} (job ${job.id}, log: ${log})`;
|
|
257
|
+
case JOB_STATUS.WAITING:
|
|
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})`;
|
|
261
|
+
default:
|
|
262
|
+
return `${versionLabel(job)} still sending (job ${job.id}, pid ${job.pid ?? '?'}, log: ${log})`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function pruneFinishedJobs() {
|
|
267
|
+
for (const job of listJobs()) {
|
|
268
|
+
if (isActive(job) || !job.finishedAt) continue;
|
|
269
|
+
if (Date.now() - Date.parse(job.finishedAt) > KEEP_FINISHED_MS) {
|
|
270
|
+
rmSync(job.dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Writes a job and starts its detached child.
|
|
277
|
+
*
|
|
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
|
|
281
|
+
* @param {{key?: ?string, phrase?: ?string, password?: ?string, pinataJwt?: ?string}} secrets
|
|
282
|
+
* @returns {Promise<{id: string, dir: string, pid: number}>}
|
|
283
|
+
*/
|
|
284
|
+
export async function startPushJob(job, patchBytes, secrets) {
|
|
285
|
+
pruneFinishedJobs();
|
|
286
|
+
|
|
287
|
+
const id = randomBytes(4).toString('hex');
|
|
288
|
+
const dir = join(jobsRoot(), id);
|
|
289
|
+
await mkdir(dir, { recursive: true });
|
|
290
|
+
if (patchBytes) await writeFile(join(dir, 'patch.bin'), patchBytes);
|
|
291
|
+
writeJobSync(dir, { ...job, id, status: JOB_STATUS.PENDING, pid: null, createdAt: new Date().toISOString() });
|
|
292
|
+
|
|
293
|
+
const logFd = openSync(join(dir, 'push.log'), 'a');
|
|
294
|
+
let child;
|
|
295
|
+
try {
|
|
296
|
+
child = spawn(process.execPath, [BIN_PATH, '__push-job', dir], {
|
|
297
|
+
detached: true,
|
|
298
|
+
stdio: ['pipe', logFd, logFd],
|
|
299
|
+
windowsHide: true,
|
|
300
|
+
});
|
|
301
|
+
} finally {
|
|
302
|
+
closeSync(logFd);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
writeJobSync(dir, { ...readJobSync(dir), pid: child.pid });
|
|
306
|
+
child.stdin.end(JSON.stringify(secrets));
|
|
307
|
+
child.unref();
|
|
308
|
+
return { id, dir, pid: child.pid };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readStdin() {
|
|
312
|
+
const parts = [];
|
|
313
|
+
for await (const part of process.stdin) parts.push(part);
|
|
314
|
+
const raw = Buffer.concat(parts).toString('utf8');
|
|
315
|
+
return raw ? JSON.parse(raw) : {};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function log(...parts) {
|
|
319
|
+
console.log(new Date().toISOString(), ...parts);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The detached child: `mnemonad __push-job <dir>`. Never typed by a person — not in --help.
|
|
324
|
+
*
|
|
325
|
+
* Waits its turn behind earlier jobs from the same signer (two processes sending with one key
|
|
326
|
+
* would race for the same nonce), then sends the prepared patch from a fresh, never-replayed
|
|
327
|
+
* Mnemonad instance, and on success writes the folder's .mnemonad exactly like a foreground
|
|
328
|
+
* `push --manifest` would have.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} dir - the job folder
|
|
331
|
+
*/
|
|
332
|
+
export async function runPushJob(dir) {
|
|
333
|
+
if (!dir) throw userError('__push-job needs a job folder');
|
|
334
|
+
const secrets = await readStdin();
|
|
335
|
+
|
|
336
|
+
let job = readJobSync(dir);
|
|
337
|
+
if (!job) throw userError(`no job at ${dir}`);
|
|
338
|
+
const update = (fields) => {
|
|
339
|
+
job = { ...readJobSync(dir), ...fields };
|
|
340
|
+
writeJobSync(dir, job);
|
|
341
|
+
};
|
|
342
|
+
update({ pid: process.pid });
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const earlier = () => activeJobs({ signer: job.signer, chain: job.chain })
|
|
346
|
+
.filter((other) => other.createdAt < job.createdAt || (other.createdAt === job.createdAt && other.id < job.id));
|
|
347
|
+
if (earlier().length) {
|
|
348
|
+
update({ status: JOB_STATUS.WAITING });
|
|
349
|
+
log(`waiting for earlier background push(es) from ${job.signer}...`);
|
|
350
|
+
while (earlier().length) await new Promise((r) => setTimeout(r, POLL_MS));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const args = { ...job.args, key: secrets.key ?? null, phrase: secrets.phrase ?? null, password: secrets.password ?? null, pinataJwt: secrets.pinataJwt ?? null, passkey: false };
|
|
354
|
+
const client = await makeChainClient(args);
|
|
355
|
+
requireWalletClient(client);
|
|
356
|
+
|
|
357
|
+
const mn = new Mnemonad({
|
|
358
|
+
publicClient: client.publicClient,
|
|
359
|
+
walletClient: client.walletClient,
|
|
360
|
+
contractAddress: client.contractAddress,
|
|
361
|
+
id: job.streamId,
|
|
362
|
+
...resolveOffloadParams(args, client.account),
|
|
363
|
+
});
|
|
364
|
+
await unlockStream(mn, { password: args.password, signer: client.walletClient });
|
|
365
|
+
|
|
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);
|
|
388
|
+
|
|
389
|
+
if (job.manifest) {
|
|
390
|
+
await writeManifest(job.folder, { streamId: job.streamId, version: result.version, files });
|
|
391
|
+
}
|
|
392
|
+
update({ status: JOB_STATUS.DONE, version: result.version, finishedAt: new Date().toISOString() });
|
|
393
|
+
log(`version ${result.version} pushed`);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const error = err.code === 'STREAM_MOVED'
|
|
396
|
+
? `the stream changed after this push was prepared (it was built on version ${job.baseLength}) — push again`
|
|
397
|
+
: err.message;
|
|
398
|
+
update({ status: JOB_STATUS.FAILED, error, finishedAt: new Date().toISOString(), reported: false });
|
|
399
|
+
log('failed:', err.stack || err.message);
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
} finally {
|
|
402
|
+
await rm(join(dir, 'patch.bin'), { force: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
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
|
+
|
|
449
|
+
/** For tests: blocks until a job has finished, and returns it. */
|
|
450
|
+
export async function waitForJob(dir, { timeoutMs = 60_000 } = {}) {
|
|
451
|
+
const deadline = Date.now() + timeoutMs;
|
|
452
|
+
for (;;) {
|
|
453
|
+
const job = refreshJob(dir);
|
|
454
|
+
if (job && !isActive(job)) return job;
|
|
455
|
+
if (Date.now() > deadline) throw new Error(`job ${dir} still ${job?.status} after ${timeoutMs} ms`);
|
|
456
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnemonad-cli",
|
|
3
|
-
"version": "0.3.
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@sqliteai/sqlite-vector": "^1.1.2",
|
|
27
27
|
"better-sqlite3": "^13.0.3",
|
|
28
28
|
"mnemonad": "^0.0.1",
|
|
29
|
-
"monadsync": "^0.0.
|
|
29
|
+
"monadsync": "^0.0.2",
|
|
30
30
|
"open": "^11.0.4",
|
|
31
31
|
"viem": "^2.56.3",
|
|
32
32
|
"ws": "^8.22.0"
|