mnemonad-cli 0.1.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/LICENSE +661 -0
- package/README.md +325 -0
- package/bin/mnemonad.js +243 -0
- package/lib/FSFile.js +25 -0
- package/lib/FSFolder.js +39 -0
- package/lib/chainClient.js +112 -0
- package/lib/commands/compact.js +85 -0
- package/lib/commands/diff.js +112 -0
- package/lib/commands/index.js +6 -0
- package/lib/commands/info.js +123 -0
- package/lib/commands/pull.js +64 -0
- package/lib/commands/push.js +127 -0
- package/lib/commands/shared.js +385 -0
- package/lib/commands/watch.js +183 -0
- package/lib/presignProvider.js +83 -0
- package/mnemonad.config.js +21 -0
- package/package.json +27 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { getAddress } from 'viem';
|
|
6
|
+
import { MnemonadPinataProvider, kekMethodOf } from 'mnemonad';
|
|
7
|
+
|
|
8
|
+
// monadsync re-exports its whole sync-primitives surface (see monadsync/index.js), so
|
|
9
|
+
// these come from there rather than adding a second, lower-level dependency.
|
|
10
|
+
import { DoubleSync as SyncEngine, CDCStore, DoubleSyncSnapshot as SyncSnapshot } from 'monadsync';
|
|
11
|
+
import { FSFolder } from '../FSFolder.js';
|
|
12
|
+
import { createPresignProvider } from '../presignProvider.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
|
|
15
|
+
|
|
16
|
+
export function makeExcludes(args) {
|
|
17
|
+
if (!args.exclude) return undefined;
|
|
18
|
+
const extra = args.exclude.split(',').map(s => s.trim());
|
|
19
|
+
return [...DEFAULT_FS_EXCLUDES, ...extra];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves how a writing command (push, watch, compact) offloads large items, as extra
|
|
24
|
+
* fields to spread into a `Mnemonad`/`Mnemonad.create` params object. Precedence:
|
|
25
|
+
*
|
|
26
|
+
* 1. `--pinata-jwt` — a direct Pinata credential, no signing needed. Goes through
|
|
27
|
+
* `MnemonadPinataProvider` (not a bare `pinningJwt` field): the default `MnemonadIpfs`
|
|
28
|
+
* Mnemonad would otherwise build needs an explicit `pinningUrl` too, which
|
|
29
|
+
* `MnemonadPinataProvider` supplies — passing `pinningJwt` alone to the plain
|
|
30
|
+
* constructor path silently never enables write capability at all.
|
|
31
|
+
* 2. `--presign-url` (or its config-file/env-var default — see bin/mnemonad.js) — no
|
|
32
|
+
* credential needed; the account itself signs a request proving it paid the on-chain
|
|
33
|
+
* escrow, and the presign server exchanges that for a one-shot upload URL (see
|
|
34
|
+
* presign-server/README.md). Requires a signing `account` (the caller only reaches
|
|
35
|
+
* this branch from a write path, which already has one).
|
|
36
|
+
* 3. Neither configured — just `gatewayUrl`, which is read-only: no offload capability at
|
|
37
|
+
* all, so a push with an oversized item fails with a clear error instead of silently
|
|
38
|
+
* falling back to something the caller didn't ask for.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} args - parsed CLI args
|
|
41
|
+
* @param {Object} account - viem account signing this command's writes
|
|
42
|
+
* @returns {{offloadingProvider: Object} | {gatewayUrl: ?string}}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveOffloadParams(args, account) {
|
|
45
|
+
if (args.pinataJwt) {
|
|
46
|
+
return { offloadingProvider: new MnemonadPinataProvider({ pinningJwt: args.pinataJwt, gatewayUrl: args.gatewayUrl }) };
|
|
47
|
+
}
|
|
48
|
+
if (args.presignUrl) {
|
|
49
|
+
return { offloadingProvider: createPresignProvider({ presignUrl: args.presignUrl, gatewayUrl: args.gatewayUrl, account }) };
|
|
50
|
+
}
|
|
51
|
+
return { gatewayUrl: args.gatewayUrl };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves a folder path argument. Deliberately does NOT fall back to `process.cwd()` when
|
|
56
|
+
* `p` is missing — every command that touches a folder requires the caller to name it
|
|
57
|
+
* explicitly, so a typo'd or omitted `path` never silently operates on whatever directory
|
|
58
|
+
* the CLI happened to be run from.
|
|
59
|
+
*/
|
|
60
|
+
export function resolvePath(p) {
|
|
61
|
+
if (!p) {
|
|
62
|
+
const err = new Error('a folder path is required (e.g. `mnemonad <command> ... /path/to/folder`)');
|
|
63
|
+
err._isUserError = true;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (p.startsWith('~')) return join(homedir(), p.slice(1));
|
|
67
|
+
return resolve(p);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatBytes(bytes) {
|
|
71
|
+
if (bytes < 1024) return bytes + ' B';
|
|
72
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
73
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatPath(path) {
|
|
77
|
+
const home = homedir();
|
|
78
|
+
if (path.startsWith(home)) {
|
|
79
|
+
return '~' + path.slice(home.length);
|
|
80
|
+
}
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Address that owns the stream, or null when it can't be determined (RPC failure, or the
|
|
86
|
+
* stream doesn't exist yet — `ownerOf` on a nonexistent token id reverts).
|
|
87
|
+
*/
|
|
88
|
+
export async function getStreamOwner(mn) {
|
|
89
|
+
try {
|
|
90
|
+
return await mn.owner();
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fail before any push when the signer doesn't own the stream — otherwise on-chain storage
|
|
98
|
+
* (and any IPFS pin) gets paid for and only the final write is rejected. Stays quiet when
|
|
99
|
+
* ownership can't be determined and lets the chain decide.
|
|
100
|
+
*
|
|
101
|
+
* An EVM address comparison via viem's `getAddress` (checksum-normalizes both sides) is all
|
|
102
|
+
* that's needed here.
|
|
103
|
+
*/
|
|
104
|
+
export async function assertStreamOwner(mn, signerAddress) {
|
|
105
|
+
const owner = await getStreamOwner(mn);
|
|
106
|
+
if (!owner || !signerAddress) return;
|
|
107
|
+
if (getAddress(owner) === getAddress(signerAddress)) return;
|
|
108
|
+
|
|
109
|
+
const err = new Error(
|
|
110
|
+
`stream ${mn.id} is owned by ${owner}\n` +
|
|
111
|
+
` but you are signing as ${signerAddress}.\n` +
|
|
112
|
+
` Nothing was uploaded. Use the owner's key (--key / --phrase / MNEMONAD_KEY),\n` +
|
|
113
|
+
` or run push without a stream id to create your own stream.`
|
|
114
|
+
);
|
|
115
|
+
err._isUserError = true;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Decides how a NEW stream should be keyed, from the encryption flags given.
|
|
121
|
+
*
|
|
122
|
+
* `--encrypt` alone is rejected rather than guessed at: both methods are reachable from the
|
|
123
|
+
* same invocation (the `--key` account can always sign), and picking one silently would
|
|
124
|
+
* decide, unprompted, whether the stream is recoverable by a password or bound forever to
|
|
125
|
+
* whatever key happened to be in the environment.
|
|
126
|
+
*
|
|
127
|
+
* @param {Object} args - parsed CLI args
|
|
128
|
+
* @returns {?('password'|'wallet')} `null` when the stream should stay unencrypted
|
|
129
|
+
*/
|
|
130
|
+
export function resolveEncryptMethod(args) {
|
|
131
|
+
const requested = args.encryptWith || (args.password ? 'password' : null);
|
|
132
|
+
|
|
133
|
+
if (!args.encrypt) {
|
|
134
|
+
if (args.encryptWith) {
|
|
135
|
+
throw userError('--encrypt-with needs --encrypt (it only applies when creating a stream)');
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (!requested) {
|
|
140
|
+
throw userError(
|
|
141
|
+
'--encrypt needs a method: --encrypt-with password (with --password), or\n' +
|
|
142
|
+
' --encrypt-with wallet to key it to the --key/--phrase account\'s signature'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (requested !== 'password' && requested !== 'wallet') {
|
|
146
|
+
throw userError(`unknown --encrypt-with '${requested}', expected password or wallet`);
|
|
147
|
+
}
|
|
148
|
+
if (requested === 'password' && !args.password) {
|
|
149
|
+
throw userError('--encrypt-with password needs --password');
|
|
150
|
+
}
|
|
151
|
+
if (requested === 'wallet' && args.password) {
|
|
152
|
+
throw userError(
|
|
153
|
+
'--encrypt-with wallet takes no --password — a wallet-keyed stream is unwrapped by\n' +
|
|
154
|
+
' the owner\'s signature, and the two methods cannot both key one stream'
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return requested;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Unlocks `mn` for reading/writing an encrypted stream, whichever way it was keyed.
|
|
162
|
+
*
|
|
163
|
+
* Which credential to try comes from the stream's own published `kek` block via
|
|
164
|
+
* `kekMethodOf` — but only as a *hint*: stream metadata is written once at creation and can
|
|
165
|
+
* never be corrected, while the owner can rewrap under a different method at any time. The
|
|
166
|
+
* GCM tag on the wrapped key is the authority, and a mismatch surfaces as the SDK's typed
|
|
167
|
+
* `KEK_MISMATCH` error either way.
|
|
168
|
+
*
|
|
169
|
+
* Neither credential is ever passed to the `Mnemonad` constructor here. For a password that
|
|
170
|
+
* would silently derive a key from a *fresh* salt instead of the stream's published one, and
|
|
171
|
+
* every later read/write would fail deep inside decryption (or, worse, succeed and wrap a
|
|
172
|
+
* key nothing else can ever unwrap). `unlockWithPassword`/`unlockWithSignature` are the only
|
|
173
|
+
* paths that read what the stream actually published.
|
|
174
|
+
*
|
|
175
|
+
* @param {import('mnemonad').default} mn
|
|
176
|
+
* @param {Object} [credentials]
|
|
177
|
+
* @param {?string} [credentials.password]
|
|
178
|
+
* @param {?Object} [credentials.signer] - viem WalletClient/Account, for a wallet-keyed stream
|
|
179
|
+
* @returns {Promise<boolean>} whether the stream was encrypted (and, if so, is now unlocked)
|
|
180
|
+
*/
|
|
181
|
+
export async function unlockStream(mn, credentials = {}) {
|
|
182
|
+
const { password = null, signer = null } = credentials;
|
|
183
|
+
|
|
184
|
+
await mn.initialize();
|
|
185
|
+
if (!(await mn.isEncrypted())) return false;
|
|
186
|
+
|
|
187
|
+
const method = kekMethodOf({ kek: mn.streamMeta.kek, encrypted: true });
|
|
188
|
+
|
|
189
|
+
if (method === 'password') {
|
|
190
|
+
if (!password) {
|
|
191
|
+
throw userError('This stream is encrypted with a password. Provide --password to decrypt.');
|
|
192
|
+
}
|
|
193
|
+
await mn.unlockWithPassword(password);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 'signature' — including every stream created before the method marker existed, which
|
|
198
|
+
// published no kek block at all.
|
|
199
|
+
if (!signer) {
|
|
200
|
+
throw userError(
|
|
201
|
+
'This stream is encrypted to its owner\'s wallet. Provide the owner\'s key with\n' +
|
|
202
|
+
' --key / --phrase / MNEMONAD_KEY to decrypt it.'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
await mn.unlockWithSignature(signer);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function userError(message) {
|
|
210
|
+
const err = new Error(message);
|
|
211
|
+
err._isUserError = true;
|
|
212
|
+
return err;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function countTree(folder) {
|
|
216
|
+
let files = 0;
|
|
217
|
+
let folders = 0;
|
|
218
|
+
const children = await folder.list();
|
|
219
|
+
for (const child of children) {
|
|
220
|
+
if (child.getContent) {
|
|
221
|
+
files++;
|
|
222
|
+
} else {
|
|
223
|
+
folders++;
|
|
224
|
+
const sub = await countTree(child);
|
|
225
|
+
files += sub.files;
|
|
226
|
+
folders += sub.folders;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { files, folders };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function hashContent(bytes) {
|
|
233
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function readManifest(destPath) {
|
|
237
|
+
try {
|
|
238
|
+
const raw = await readFile(join(destPath, '.mnemonad'), 'utf8');
|
|
239
|
+
return JSON.parse(raw);
|
|
240
|
+
} catch { return null; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function writeManifest(destPath, manifest) {
|
|
244
|
+
await writeFile(join(destPath, '.mnemonad'), JSON.stringify(manifest, null, 2));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const DISK_EXCLUDES = new Set(['.mnemonad', '.git', '.DS_Store', 'node_modules']);
|
|
248
|
+
|
|
249
|
+
export async function hashDiskTree(dirPath, prefix = []) {
|
|
250
|
+
const files = {};
|
|
251
|
+
let entries;
|
|
252
|
+
try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return files; }
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
if (DISK_EXCLUDES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
255
|
+
const relParts = [...prefix, entry.name];
|
|
256
|
+
const relPath = relParts.join('/');
|
|
257
|
+
const fullPath = join(dirPath, entry.name);
|
|
258
|
+
if (entry.isFile()) {
|
|
259
|
+
const buf = await readFile(fullPath);
|
|
260
|
+
files[relPath] = hashContent(buf);
|
|
261
|
+
} else if (entry.isDirectory()) {
|
|
262
|
+
Object.assign(files, await hashDiskTree(fullPath, relParts));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return files;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function localTreeHash(dirPath) {
|
|
269
|
+
const fsFolder = new FSFolder(dirPath);
|
|
270
|
+
const children = await fsFolder.list();
|
|
271
|
+
if (children.length === 0) return null;
|
|
272
|
+
|
|
273
|
+
const sync = new SyncEngine();
|
|
274
|
+
const store = new CDCStore();
|
|
275
|
+
const snapshotBytes = await sync.buildSnapshot({ root: fsFolder, store });
|
|
276
|
+
return new SyncSnapshot(snapshotBytes).treeHash;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function syncMemoryFolderToDisk(folder, destPath, oldFiles) {
|
|
280
|
+
const newFiles = {};
|
|
281
|
+
const newDirs = new Set();
|
|
282
|
+
const stats = { written: 0, skipped: 0, deleted: 0, folders: 0, bytes: 0 };
|
|
283
|
+
|
|
284
|
+
// walkAndSync only mkdir's a child directory as it descends into it — a root-level file
|
|
285
|
+
// (no subfolder ever created first) would otherwise fail to write into a destPath that
|
|
286
|
+
// doesn't exist yet, e.g. the very first `pull` into a brand-new directory.
|
|
287
|
+
await mkdir(destPath, { recursive: true });
|
|
288
|
+
await walkAndSync(folder, destPath, [], oldFiles, newFiles, newDirs, stats);
|
|
289
|
+
|
|
290
|
+
if (oldFiles) {
|
|
291
|
+
for (const relPath of Object.keys(oldFiles)) {
|
|
292
|
+
if (!(relPath in newFiles)) {
|
|
293
|
+
const absPath = join(destPath, ...relPath.split('/'));
|
|
294
|
+
await rm(absPath, { force: true });
|
|
295
|
+
stats.deleted++;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await removeStaleDirectories(destPath, newDirs);
|
|
301
|
+
|
|
302
|
+
return { stats, newFiles };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function hashMapsEqual(a, b) {
|
|
306
|
+
const keysA = Object.keys(a).sort();
|
|
307
|
+
const keysB = Object.keys(b).sort();
|
|
308
|
+
if (keysA.length !== keysB.length) return false;
|
|
309
|
+
return keysA.every((k, i) => k === keysB[i] && a[k] === b[k]);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function hashLocalTree(fsFolder, prefix = []) {
|
|
313
|
+
const files = {};
|
|
314
|
+
const children = await fsFolder.list();
|
|
315
|
+
for (const child of children) {
|
|
316
|
+
const relParts = [...prefix, child.name];
|
|
317
|
+
const relPath = relParts.join('/');
|
|
318
|
+
if (child.getContent) {
|
|
319
|
+
const content = await child.getContent();
|
|
320
|
+
files[relPath] = hashContent(content);
|
|
321
|
+
} else {
|
|
322
|
+
Object.assign(files, await hashLocalTree(child, relParts));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return files;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function walkAndSync(folder, destPath, prefix, oldFiles, newFiles, newDirs, stats) {
|
|
329
|
+
const children = await folder.list();
|
|
330
|
+
|
|
331
|
+
for (const child of children) {
|
|
332
|
+
const relParts = [...prefix, child.name];
|
|
333
|
+
const relPath = relParts.join('/');
|
|
334
|
+
const absPath = join(destPath, child.name);
|
|
335
|
+
|
|
336
|
+
if (child.getContent) {
|
|
337
|
+
const content = await child.getContent();
|
|
338
|
+
const hash = hashContent(content);
|
|
339
|
+
newFiles[relPath] = hash;
|
|
340
|
+
|
|
341
|
+
if (oldFiles && oldFiles[relPath] === hash) {
|
|
342
|
+
stats.skipped++;
|
|
343
|
+
} else {
|
|
344
|
+
await writeFile(absPath, content);
|
|
345
|
+
stats.written++;
|
|
346
|
+
stats.bytes += content.length;
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
await mkdir(absPath, { recursive: true });
|
|
350
|
+
newDirs.add(relPath);
|
|
351
|
+
stats.folders++;
|
|
352
|
+
await walkAndSync(child, absPath, relParts, oldFiles, newFiles, newDirs, stats);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function removeStaleDirectories(destPath, newDirs) {
|
|
358
|
+
let entries;
|
|
359
|
+
try { entries = await readdir(destPath, { withFileTypes: true }); } catch { return; }
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
if (!entry.isDirectory()) continue;
|
|
362
|
+
if (DISK_EXCLUDES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
363
|
+
const fullPath = join(destPath, entry.name);
|
|
364
|
+
if (!newDirs.has(entry.name)) {
|
|
365
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
366
|
+
} else {
|
|
367
|
+
await removeStaleSubdirs(fullPath, entry.name, newDirs);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function removeStaleSubdirs(dirPath, prefix, newDirs) {
|
|
373
|
+
let entries;
|
|
374
|
+
try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return; }
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
if (!entry.isDirectory()) continue;
|
|
377
|
+
const relPath = prefix + '/' + entry.name;
|
|
378
|
+
const fullPath = join(dirPath, entry.name);
|
|
379
|
+
if (!newDirs.has(relPath)) {
|
|
380
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
381
|
+
} else {
|
|
382
|
+
await removeStaleSubdirs(fullPath, relPath, newDirs);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { watch as fsWatch } from 'node:fs';
|
|
2
|
+
import Mnemonad from 'mnemonad';
|
|
3
|
+
import { MonadSync } from 'monadsync';
|
|
4
|
+
import { FSFolder } from '../FSFolder.js';
|
|
5
|
+
import { makeChainClient, requireWalletClient } from '../chainClient.js';
|
|
6
|
+
import {
|
|
7
|
+
resolvePath,
|
|
8
|
+
makeExcludes,
|
|
9
|
+
formatPath,
|
|
10
|
+
localTreeHash,
|
|
11
|
+
hashDiskTree,
|
|
12
|
+
syncMemoryFolderToDisk,
|
|
13
|
+
DISK_EXCLUDES,
|
|
14
|
+
assertStreamOwner,
|
|
15
|
+
unlockStream,
|
|
16
|
+
resolveOffloadParams,
|
|
17
|
+
} from './shared.js';
|
|
18
|
+
|
|
19
|
+
function timestamp() {
|
|
20
|
+
return new Date().toTimeString().slice(0, 8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hashesEqual(a, b) {
|
|
24
|
+
if (a === null && b === null) return true;
|
|
25
|
+
if (a === null || b === null) return false;
|
|
26
|
+
return a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function watch(args) {
|
|
30
|
+
if (!args.streamId) throw new Error('stream-id is required for watch');
|
|
31
|
+
|
|
32
|
+
const cwd = resolvePath(args.path);
|
|
33
|
+
const debounceMs = args.debounce ?? 5000;
|
|
34
|
+
const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
|
|
35
|
+
const pushEnabled = !args.pullOnly;
|
|
36
|
+
const pullEnabled = !args.pushOnly;
|
|
37
|
+
|
|
38
|
+
const excludes = makeExcludes(args);
|
|
39
|
+
|
|
40
|
+
const client = makeChainClient(args);
|
|
41
|
+
if (pushEnabled) requireWalletClient(client);
|
|
42
|
+
|
|
43
|
+
// Only a push-enabled session needs write-side offload capability (pinata-jwt or
|
|
44
|
+
// presign) — a pull-only mirror only ever reads, so it gets just gatewayUrl.
|
|
45
|
+
const offloadParams = pushEnabled ? resolveOffloadParams(args, client.account) : { gatewayUrl: args.gatewayUrl };
|
|
46
|
+
|
|
47
|
+
// No `password` at construction — see shared.js's unlockStream() for why; unlocked
|
|
48
|
+
// explicitly right below instead, once for the whole watch session.
|
|
49
|
+
const mn = new Mnemonad({
|
|
50
|
+
publicClient: client.publicClient,
|
|
51
|
+
walletClient: pushEnabled ? client.walletClient : undefined,
|
|
52
|
+
contractAddress: client.contractAddress,
|
|
53
|
+
id: args.streamId,
|
|
54
|
+
...offloadParams,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await unlockStream(mn, { password: args.password, signer: client.walletClient });
|
|
58
|
+
|
|
59
|
+
// Fail now rather than on the first local change, hours into a watch session.
|
|
60
|
+
if (pushEnabled) {
|
|
61
|
+
await assertStreamOwner(mn, client.account.address);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const monadSync = new MonadSync({ mnemonad: mn, compress: args.compress || 'gzip' });
|
|
65
|
+
await monadSync.initialize();
|
|
66
|
+
|
|
67
|
+
let remoteVersion = mn.length;
|
|
68
|
+
let lastSyncedHash = await localTreeHash(cwd);
|
|
69
|
+
let pushInFlight = false;
|
|
70
|
+
let debounceTimer = null;
|
|
71
|
+
|
|
72
|
+
console.log(`watching ${formatPath(cwd)}`);
|
|
73
|
+
console.log(` stream: ${args.streamId}`);
|
|
74
|
+
console.log(` remote version: ${remoteVersion}`);
|
|
75
|
+
console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
|
|
76
|
+
console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
|
|
77
|
+
console.log('press Ctrl+C to stop\n');
|
|
78
|
+
|
|
79
|
+
async function runPush() {
|
|
80
|
+
if (!pushEnabled || pushInFlight) return;
|
|
81
|
+
const newHash = await localTreeHash(cwd);
|
|
82
|
+
if (hashesEqual(newHash, lastSyncedHash)) return;
|
|
83
|
+
|
|
84
|
+
pushInFlight = true;
|
|
85
|
+
try {
|
|
86
|
+
// Best-effort race guard, not an atomic one: Mnemonad's own contract has no
|
|
87
|
+
// "expected prior length" check — no atomic on-chain compare-and-abort.
|
|
88
|
+
// Re-checking here narrows the window where a concurrent push (another `watch`
|
|
89
|
+
// session, or a manual push) could make MonadSync build a diff against a base
|
|
90
|
+
// the chain has since moved past — the same failure mode monadsync's own
|
|
91
|
+
// snapshot-recovery tests exercise — but doesn't close it entirely; only an
|
|
92
|
+
// on-chain guard could.
|
|
93
|
+
mn.reInitialize();
|
|
94
|
+
await mn.initialize();
|
|
95
|
+
if (mn.length !== remoteVersion) {
|
|
96
|
+
console.log(`[${timestamp()}] remote changed while preparing push — resyncing before pushing`);
|
|
97
|
+
monadSync.reInitialize();
|
|
98
|
+
await monadSync.initialize();
|
|
99
|
+
remoteVersion = mn.length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const fsFolder = new FSFolder(cwd, excludes);
|
|
103
|
+
console.log(`[${timestamp()}] change detected — pushing...`);
|
|
104
|
+
const result = await monadSync.push(fsFolder);
|
|
105
|
+
lastSyncedHash = newHash;
|
|
106
|
+
remoteVersion = result.version;
|
|
107
|
+
console.log(`[${timestamp()}] pushed version ${result.version}`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(`[${timestamp()}] push error:`, err.message);
|
|
110
|
+
} finally {
|
|
111
|
+
pushInFlight = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runPoll() {
|
|
116
|
+
if (!pullEnabled || pushInFlight) return;
|
|
117
|
+
try {
|
|
118
|
+
mn.reInitialize();
|
|
119
|
+
await mn.initialize();
|
|
120
|
+
if (mn.length <= remoteVersion) return;
|
|
121
|
+
|
|
122
|
+
const newRemoteVersion = mn.length;
|
|
123
|
+
|
|
124
|
+
// Always resync monadSync when remote has advanced. Without this, its own
|
|
125
|
+
// internal snapshot/replay state stays at the old version while mn.length has
|
|
126
|
+
// moved on — the next push would build a diff against the wrong base,
|
|
127
|
+
// corrupting the chain.
|
|
128
|
+
monadSync.reInitialize();
|
|
129
|
+
|
|
130
|
+
const localNow = await localTreeHash(cwd);
|
|
131
|
+
|
|
132
|
+
if (!hashesEqual(localNow, lastSyncedHash)) {
|
|
133
|
+
console.log(`[${timestamp()}] remote has ${newRemoteVersion - remoteVersion} new version(s) but local has unpushed changes — skipping pull`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`[${timestamp()}] remote advanced to version ${newRemoteVersion} — pulling...`);
|
|
138
|
+
let folder;
|
|
139
|
+
try {
|
|
140
|
+
folder = await monadSync.restore();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`[${timestamp()}] pull failed (${err.message}) — skipping to version ${newRemoteVersion}; run \`pull\` manually`);
|
|
143
|
+
remoteVersion = newRemoteVersion;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const oldFiles = await hashDiskTree(cwd);
|
|
147
|
+
const { stats } = await syncMemoryFolderToDisk(folder, cwd, oldFiles);
|
|
148
|
+
lastSyncedHash = await localTreeHash(cwd);
|
|
149
|
+
remoteVersion = newRemoteVersion;
|
|
150
|
+
console.log(`[${timestamp()}] pulled version ${remoteVersion} (${stats.written} written, ${stats.skipped} unchanged, ${stats.deleted} deleted)`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error(`[${timestamp()}] poll error:`, err.message);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const watcher = fsWatch(cwd, { recursive: true }, (eventType, filename) => {
|
|
157
|
+
if (!pushEnabled) return;
|
|
158
|
+
if (!filename) return;
|
|
159
|
+
const base = filename.split('/')[0];
|
|
160
|
+
if (DISK_EXCLUDES.has(base) || base.startsWith('.')) return;
|
|
161
|
+
clearTimeout(debounceTimer);
|
|
162
|
+
debounceTimer = setTimeout(runPush, debounceMs);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const poller = setInterval(runPoll, pollIntervalMs);
|
|
166
|
+
|
|
167
|
+
let shutdownResolve;
|
|
168
|
+
const shutdownPromise = new Promise((resolve) => {
|
|
169
|
+
shutdownResolve = resolve;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function shutdown() {
|
|
173
|
+
console.log('\nstopping watch...');
|
|
174
|
+
clearTimeout(debounceTimer);
|
|
175
|
+
clearInterval(poller);
|
|
176
|
+
watcher.close();
|
|
177
|
+
shutdownResolve();
|
|
178
|
+
}
|
|
179
|
+
process.once('SIGINT', shutdown);
|
|
180
|
+
process.once('SIGTERM', shutdown);
|
|
181
|
+
|
|
182
|
+
await shutdownPromise;
|
|
183
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node/viem port of explorer/src/mnemonad/presignProvider.js's `createPresignProvider`.
|
|
3
|
+
*
|
|
4
|
+
* Same off-chain-upload flow, same server (see `presign-server/README.md`'s diagram): a
|
|
5
|
+
* paid on-chain escrow (handled automatically by Mnemonad's own `push()` — see
|
|
6
|
+
* `Mnemonad._pushExternalAuto`) is proven with an EIP-712 signature, the server checks it
|
|
7
|
+
* against the escrow and hands back a one-shot Pinata upload URL. No Pinata credential ever
|
|
8
|
+
* touches this process, unlike `--pinata-jwt`.
|
|
9
|
+
*
|
|
10
|
+
* The only real difference from the browser version: the explorer signs with `wagmi`'s
|
|
11
|
+
* `signTypedData` against a connected wallet; here the CLI already holds a local viem
|
|
12
|
+
* account (from `chainClient.js`), and viem's local accounts sign EIP-712 data directly via
|
|
13
|
+
* `account.signTypedData(...)` — no wallet client or connected extension needed.
|
|
14
|
+
*/
|
|
15
|
+
import { MnemonadPresignProvider } from 'mnemonad';
|
|
16
|
+
|
|
17
|
+
const SIGNATURE_TTL_SECONDS = 120;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} params
|
|
21
|
+
* @param {string} params.presignUrl - base URL of the presign server (no trailing slash)
|
|
22
|
+
* @param {string} params.gatewayUrl - public IPFS gateway for reads (same one every other
|
|
23
|
+
* read path in this CLI uses — see chainClient.js's own default)
|
|
24
|
+
* @param {import('viem/accounts').LocalAccount} params.account - signs the upload request;
|
|
25
|
+
* must be the same address that paid the on-chain escrow (`payer` in the signed message)
|
|
26
|
+
* @returns {MnemonadPresignProvider}
|
|
27
|
+
*/
|
|
28
|
+
export function createPresignProvider({ presignUrl, gatewayUrl, account }) {
|
|
29
|
+
const baseUrl = presignUrl.replace(/\/$/, '');
|
|
30
|
+
let configPromise = null;
|
|
31
|
+
|
|
32
|
+
function fetchConfig() {
|
|
33
|
+
if (!configPromise) {
|
|
34
|
+
configPromise = fetch(`${baseUrl}/config`)
|
|
35
|
+
.then(async (res) => {
|
|
36
|
+
if (!res.ok) throw new Error(`presign server /config failed (HTTP ${res.status})`);
|
|
37
|
+
return await res.json();
|
|
38
|
+
})
|
|
39
|
+
.catch((err) => {
|
|
40
|
+
configPromise = null; // don't cache a failure — the server may just be starting
|
|
41
|
+
throw err;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return configPromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return new MnemonadPresignProvider({
|
|
48
|
+
gatewayUrl,
|
|
49
|
+
requestUploadUrl: async ({ hash, size }) => {
|
|
50
|
+
const serverConfig = await fetchConfig();
|
|
51
|
+
const deadline = Math.floor(Date.now() / 1000) + SIGNATURE_TTL_SECONDS;
|
|
52
|
+
|
|
53
|
+
// Signed with the domain/types the server published, not a local copy — see
|
|
54
|
+
// presign-server/README.md's own reasoning for why (drift between two hardcoded
|
|
55
|
+
// copies would eventually produce signature failures that look like account bugs).
|
|
56
|
+
const signature = await account.signTypedData({
|
|
57
|
+
domain: serverConfig.eip712.domain,
|
|
58
|
+
types: serverConfig.eip712.types,
|
|
59
|
+
primaryType: serverConfig.eip712.primaryType,
|
|
60
|
+
message: {
|
|
61
|
+
payer: account.address,
|
|
62
|
+
contentHash: hash,
|
|
63
|
+
size: BigInt(size),
|
|
64
|
+
deadline: BigInt(deadline),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const res = await fetch(`${baseUrl}/upload-url`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ payer: account.address, contentHash: hash, size, deadline, signature }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const body = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
// The server's error codes are actionable (e.g. "no_escrow" means the payment
|
|
77
|
+
// step was skipped) — surface its message rather than a generic failure.
|
|
78
|
+
throw new Error(body.message || `presign server refused the upload (HTTP ${res.status})`);
|
|
79
|
+
}
|
|
80
|
+
return { url: body.url };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-wide operational defaults for the CLI — non-secret URLs only, safe to commit.
|
|
3
|
+
* Never put a key or JWT in here: those stay in `--key`/`MNEMONAD_KEY`/`PINATA_JWT`, which
|
|
4
|
+
* this file has no bearing on.
|
|
5
|
+
*
|
|
6
|
+
* Precedence bin/mnemonad.js applies for every value here: CLI flag > env var > this file >
|
|
7
|
+
* a hardcoded fallback (kept in case this file goes missing or fails to parse).
|
|
8
|
+
*/
|
|
9
|
+
export default {
|
|
10
|
+
// The presign server (../presign-server/) this project runs by default — see its own
|
|
11
|
+
// README for what it does. Pushing an item too big to fit on-chain (over the registry's
|
|
12
|
+
// maxChunkBytes) uses this automatically, so a signer needs no Pinata credential of
|
|
13
|
+
// their own. Overridable per-run with --presign-url or MNEMONAD_PRESIGN_URL.
|
|
14
|
+
presignUrl: 'https://presign-server.vercel.app',
|
|
15
|
+
|
|
16
|
+
// Public IPFS gateway for *reading* externally-offloaded items (pull/diff/info/watch)
|
|
17
|
+
// — same default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js), so
|
|
18
|
+
// a stream pushed through either tool reads back the same way with no setup. Overridable
|
|
19
|
+
// per-run with --gateway-url or MNEMONAD_IPFS_GATEWAY.
|
|
20
|
+
gatewayUrl: 'https://gateway.pinata.cloud/ipfs/',
|
|
21
|
+
};
|