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
package/README.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# mnemonad-cli
|
|
2
|
+
|
|
3
|
+
CLI tool to sync local folders to versioned, diffed on-chain streams on [Monad](https://monad.xyz).
|
|
4
|
+
|
|
5
|
+
Built on top of [`mnemonad`](../js) and [`monadsync`](../monadsync), which layers
|
|
6
|
+
content-defined-chunking folder diffing on top of a Mnemonad stream.
|
|
7
|
+
|
|
8
|
+
Each `push` stores a versioned, gzip-compressed diff or snapshot inside a Mnemonad stream
|
|
9
|
+
on-chain (with large files optionally offloaded to IPFS via Pinata). Any past version can be
|
|
10
|
+
restored at any time with `pull`. Streams can optionally be encrypted with a password or the
|
|
11
|
+
signer's own wallet key.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
This package lives in the monorepo alongside `mnemonad` and `monadsync` and is not yet
|
|
16
|
+
published. From the repo root:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd cli
|
|
20
|
+
pnpm install
|
|
21
|
+
node bin/mnemonad.js --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or link it for a global `mnemonad` command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd cli
|
|
28
|
+
pnpm install
|
|
29
|
+
npm link
|
|
30
|
+
mnemonad --help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`sample/` holds example content (pulled from a stream created via the dApp) used to try the
|
|
34
|
+
CLI out. `pnpm push-sample` pushes it to testnet as a brand-new stream:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export MNEMONAD_KEY=0xabc123...
|
|
38
|
+
pnpm push-sample
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
mnemonad push [stream-id] <path> [options] Sync a folder to a stream (creates one if no id given)
|
|
45
|
+
mnemonad pull <stream-id> <path> [options] Restore stream contents to a folder
|
|
46
|
+
mnemonad info [stream-id] [path] [options] Show chain/wallet info, or full stream metadata
|
|
47
|
+
mnemonad diff <stream-id> <path> [options] List files added/modified/deleted locally vs the on-chain version
|
|
48
|
+
mnemonad watch <stream-id> <path> [options] Watch folder: auto-push on changes, auto-pull on remote updates
|
|
49
|
+
mnemonad compact <stream-id> <path> [options] Truncate old history and push folder as a fresh snapshot
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`<path>` is always required for any command that touches a folder — there is no need to
|
|
53
|
+
`cd` into the folder first, but there's also no falling back to the current working
|
|
54
|
+
directory: an omitted or mistyped path fails fast with an error rather than silently
|
|
55
|
+
operating on wherever the CLI happened to be run from. The only command that can run with
|
|
56
|
+
no path at all is `mnemonad info` with no stream id either (it just checks your configured
|
|
57
|
+
chain/wallet). A stream id is recognized as a bare decimal number (`123`) or a
|
|
58
|
+
`0x`-prefixed hex string; anything else in that position is treated as a path.
|
|
59
|
+
|
|
60
|
+
Every command prints the installed CLI version as its first line of output.
|
|
61
|
+
|
|
62
|
+
### Options
|
|
63
|
+
|
|
64
|
+
| Flag | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `--chain <name>` | Chain: `testnet`, `mainnet`, `local` (default: `testnet`) |
|
|
67
|
+
| `--rpc-url <url>` | Override the resolved RPC endpoint (e.g. a local Hardhat node) |
|
|
68
|
+
| `--contract-address <addr>` | Registry address to use instead of the chain's known deployment (required with `--chain local`) |
|
|
69
|
+
| `--gateway-url <url>` | IPFS gateway for reading externally-offloaded items (default: `https://gateway.pinata.cloud/ipfs/`, or `MNEMONAD_IPFS_GATEWAY` env var) |
|
|
70
|
+
| `--key <privkey>` | Monad private key (or set `MNEMONAD_KEY` env var) |
|
|
71
|
+
| `--phrase <mnemonic>` | Mnemonic phrase instead of a raw key |
|
|
72
|
+
| `--version <n>` | Version to restore or compare against (`pull`, `diff`, `info`; default: latest) |
|
|
73
|
+
| `--exclude <p1,p2>` | Extra exclude patterns (comma-separated) |
|
|
74
|
+
| `--no-compress` | Disable gzip compression |
|
|
75
|
+
| `--encrypt` | Create a new stream encrypted. Only applies when creating; ignored for existing streams |
|
|
76
|
+
| `--encrypt-with <method>` | How to key it: `password` (needs `--password`), or `wallet` — the `--key`/`--phrase` account signs for it, and only that account can ever decrypt it. Implied by `--password` |
|
|
77
|
+
| `--password <pw>` | Password to encrypt a new stream with, or decrypt an existing one |
|
|
78
|
+
| `--pinata-jwt <jwt>` | Pinata JWT for IPFS offload of large files (or `PINATA_JWT` env var) |
|
|
79
|
+
| `--manifest` | Write/use `.mnemonad` manifest for faster change detection |
|
|
80
|
+
| `--force-snapshot` | Push a full snapshot regardless of prior history (repairs a corrupt stream) |
|
|
81
|
+
| `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
|
|
82
|
+
| `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
|
|
83
|
+
| `--push-only` | `watch`: disable auto-pull |
|
|
84
|
+
| `--pull-only` | `watch`: disable auto-push |
|
|
85
|
+
| `--help` | Show help |
|
|
86
|
+
|
|
87
|
+
### Authentication
|
|
88
|
+
|
|
89
|
+
Pass the signing key inline:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
mnemonad push ~/my-data --key 0xabc123...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or export it once and drop the flag from every command:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
export MNEMONAD_KEY=0xabc123...
|
|
99
|
+
mnemonad push ~/my-data
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A mnemonic works instead of a raw key: `--phrase "word1 word2 ..."`. `--key` and
|
|
103
|
+
`MNEMONAD_KEY` both take precedence over `--phrase`.
|
|
104
|
+
|
|
105
|
+
`pull`, `info` and `diff` work without a key for public (unencrypted) streams. A key is
|
|
106
|
+
required for encrypted streams that use wallet-based encryption (not needed for
|
|
107
|
+
password-based encryption — see below), and for any `push`.
|
|
108
|
+
|
|
109
|
+
## Examples
|
|
110
|
+
|
|
111
|
+
The examples below assume the key comes from `MNEMONAD_KEY` and use the default `testnet`
|
|
112
|
+
chain.
|
|
113
|
+
|
|
114
|
+
### Push a folder (first time)
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
mnemonad push ~/my-data
|
|
118
|
+
|
|
119
|
+
# prints the new stream id, e.g.:
|
|
120
|
+
# created: 123
|
|
121
|
+
# version 1 pushed (full snapshot, gzip compressed)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
By default a new stream is public (unencrypted). Encrypting one takes a method — the CLI
|
|
125
|
+
never picks for you, since both are reachable from the same invocation:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Keyed by a password: anyone who knows it can read the stream, from any machine.
|
|
129
|
+
mnemonad push ~/my-data --encrypt --password "correct horse battery staple"
|
|
130
|
+
|
|
131
|
+
# Keyed by the signing account itself: no password to remember or leak, but ONLY this
|
|
132
|
+
# account can ever decrypt it, and there is no recovery if the key is lost.
|
|
133
|
+
mnemonad push ~/my-data --encrypt --encrypt-with wallet
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Reading one back needs whichever credential it was keyed with — `--password` for the first,
|
|
137
|
+
just the owner's `--key`/`--phrase` for the second. `mnemonad info` reports which:
|
|
138
|
+
`encrypted: yes (password)` or `encrypted: yes (owner wallet)`.
|
|
139
|
+
|
|
140
|
+
### Push an update
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
mnemonad push 123 ~/my-data
|
|
144
|
+
|
|
145
|
+
# same stream on mainnet
|
|
146
|
+
mnemonad push 123 ~/my-data --chain mainnet
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Pull the latest version
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
mnemonad pull 123 ~/restored
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Pull a specific version
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
mnemonad pull 123 ~/restored --version 1
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Inspect a stream
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# check your wallet + chain (no stream id needed)
|
|
165
|
+
mnemonad info
|
|
166
|
+
|
|
167
|
+
# full stream metadata + local sync status
|
|
168
|
+
mnemonad info 123 ~/my-data
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
chain: testnet
|
|
173
|
+
your wallet: 0x06bc6420b37a4898424429dcfa236f0065e12279
|
|
174
|
+
|
|
175
|
+
stream: 123
|
|
176
|
+
owner: 0x06bc6420b37a4898424429dcfa236f0065e12279 (you)
|
|
177
|
+
encrypted: no
|
|
178
|
+
versions: 3
|
|
179
|
+
binary size: 385.7 KB
|
|
180
|
+
|
|
181
|
+
local tree hash: 9f2c1a4b8e...
|
|
182
|
+
detecting local version...
|
|
183
|
+
local matches: version 3 of 3
|
|
184
|
+
status: up to date
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### See what changed locally (diff)
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# list files added/modified/deleted locally compared to the latest on-chain version
|
|
191
|
+
mnemonad diff 123 ~/my-data
|
|
192
|
+
|
|
193
|
+
# compare against a specific version
|
|
194
|
+
mnemonad diff 123 ~/my-data --version 2
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Output shows the changes from the local folder's perspective — what a `push` would apply:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
diff: ~/my-data vs 123 (version 3 of 3)
|
|
201
|
+
local changes vs chain (what push would apply):
|
|
202
|
+
added: docs/new-page.md
|
|
203
|
+
modified: src/index.js
|
|
204
|
+
deleted: old-config.json
|
|
205
|
+
|
|
206
|
+
3 change(s): 1 added, 1 modified, 1 deleted
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Fast incremental pushes with a manifest
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
mnemonad push 123 ~/my-data --manifest
|
|
213
|
+
# subsequent pushes are skipped when nothing has changed locally
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Watch a folder (auto push + pull)
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
mnemonad watch 123 ~/my-data
|
|
220
|
+
# pushes local changes 1 s after the last edit
|
|
221
|
+
# pulls remote updates every 2 s
|
|
222
|
+
# Ctrl-C to stop
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Tune the timing:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
mnemonad watch 123 ~/my-data --debounce 3000 --poll-interval 5
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Watch in push-only or pull-only mode:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
mnemonad watch 123 ~/my-data --push-only # no auto-pull
|
|
235
|
+
mnemonad watch 123 ~/my-data --pull-only # no auto-push (read-only mirror)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Compact old history
|
|
239
|
+
|
|
240
|
+
After many incremental syncs, drop old history and push the current folder as a fresh
|
|
241
|
+
single snapshot. This speeds up future `pull`/`info`/`diff` (they replay less history to
|
|
242
|
+
reach the latest version) — Monad storage is not refundable, so this is not a cost rebate,
|
|
243
|
+
just a faster-replay optimization.
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
mnemonad compact 123 ~/my-data
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Repair a corrupt stream with a force snapshot
|
|
250
|
+
|
|
251
|
+
If a diff patch was pushed against a stale base (e.g. a race condition in `watch`),
|
|
252
|
+
subsequent pulls will fail. Fix it by pushing a new full snapshot:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
mnemonad push 123 ~/my-data --force-snapshot
|
|
256
|
+
# skips chain replay and pushes the current folder as a self-contained full snapshot
|
|
257
|
+
# restore() will recover from this snapshot, skipping any corrupt diffs before it
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## End-to-end workflow
|
|
261
|
+
|
|
262
|
+
Create a stream, verify what was stored, then roll back to an earlier version:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
export MNEMONAD_KEY=0xabc123...
|
|
266
|
+
|
|
267
|
+
# 1. first push — creates the stream
|
|
268
|
+
mnemonad push ~/my-data
|
|
269
|
+
# created: 123
|
|
270
|
+
# version 1 pushed (full snapshot, gzip compressed)
|
|
271
|
+
|
|
272
|
+
# 2. verify: restore into a scratch folder and compare against the original
|
|
273
|
+
mnemonad pull 123 ~/verify-tmp
|
|
274
|
+
diff -r ~/my-data ~/verify-tmp
|
|
275
|
+
|
|
276
|
+
# 3. change something and push again
|
|
277
|
+
echo "hello" > ~/my-data/new.txt
|
|
278
|
+
mnemonad push 123 ~/my-data
|
|
279
|
+
# version 2 pushed (diff, gzip compressed)
|
|
280
|
+
|
|
281
|
+
# 4. review the history
|
|
282
|
+
mnemonad info 123 ~/my-data
|
|
283
|
+
# versions: 2
|
|
284
|
+
# local matches: version 2 of 2
|
|
285
|
+
|
|
286
|
+
# 5. roll back: restore version 1 into a separate folder
|
|
287
|
+
mnemonad pull 123 ~/my-data-v1 --version 1
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## How it works
|
|
291
|
+
|
|
292
|
+
1. **push** — scans the current directory, computes a tree hash, and compares it against the
|
|
293
|
+
last stored version. If changes are detected, a compressed diff (or full snapshot on the
|
|
294
|
+
first push) is uploaded on-chain (large items offloaded to IPFS when `--pinata-jwt` is
|
|
295
|
+
set).
|
|
296
|
+
2. **pull** — reads the requested version from the stream (fetching any externally-offloaded
|
|
297
|
+
items through `--gateway-url`), decrypts it if encrypted, and writes only changed files to
|
|
298
|
+
disk. Files absent from the stored version are deleted.
|
|
299
|
+
3. **info** — reads stream metadata from the chain (version count, binary size, owner) and
|
|
300
|
+
checks whether the local folder matches any stored version.
|
|
301
|
+
4. **diff** — restores the requested version in memory (nothing is written to disk), hashes
|
|
302
|
+
every file on both sides, and lists files added, modified, or deleted locally compared to
|
|
303
|
+
the on-chain version.
|
|
304
|
+
5. **watch** — combines push and pull in a loop. A filesystem watcher triggers a debounced
|
|
305
|
+
push on local changes. A poll interval checks the remote stream for new versions and
|
|
306
|
+
pulls them if found. Push and pull never run concurrently. Monad's registry has no
|
|
307
|
+
"expected prior length" guard on push, so `watch` re-checks the remote length itself
|
|
308
|
+
right before pushing and resyncs if it has moved — a best-effort narrowing of the race
|
|
309
|
+
window, not a hard guarantee.
|
|
310
|
+
6. **compact** — drops all existing history on-chain and pushes the current folder as a
|
|
311
|
+
single fresh full snapshot, speeding up future replay after many incremental syncs.
|
|
312
|
+
|
|
313
|
+
### Default excludes
|
|
314
|
+
|
|
315
|
+
The following are always excluded from snapshots: `node_modules`, `.git`, `.env`,
|
|
316
|
+
`.DS_Store`, `.mnemonad`, `.claude`, `pnpm-lock.yaml`, `package-lock.json`. Add more with
|
|
317
|
+
`--exclude`.
|
|
318
|
+
|
|
319
|
+
## Dependencies
|
|
320
|
+
|
|
321
|
+
| Package | Role |
|
|
322
|
+
|---|---|
|
|
323
|
+
| `mnemonad` | On-chain stream primitive (this repo's `js/`) |
|
|
324
|
+
| `monadsync` | Folder-diff / snapshot layer on top of `mnemonad` (this repo's `monadsync/`) |
|
|
325
|
+
| `viem` | Monad client / key management |
|
package/bin/mnemonad.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Intercept process.exit() calls from dependencies so they don't interrupt
|
|
4
|
+
// the top-level await before it settles. Converts them to thrown errors that
|
|
5
|
+
// our try/catch handles, then sets process.exitCode without calling exit().
|
|
6
|
+
// Defensive: harmless if nothing in the dependency chain ever actually calls
|
|
7
|
+
// process.exit() mid-await, but cheap insurance if something eventually does.
|
|
8
|
+
const _realExit = process.exit.bind(process);
|
|
9
|
+
process.exit = (code) => {
|
|
10
|
+
const err = new Error(`process.exit(${code ?? 0})`);
|
|
11
|
+
err._isProcessExit = true;
|
|
12
|
+
err._exitCode = code ?? 0;
|
|
13
|
+
throw err;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { push, pull, info, diff, watch, compact } from '../lib/commands/index.js';
|
|
21
|
+
import defaultConfig from '../mnemonad.config.js';
|
|
22
|
+
|
|
23
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
const VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
28
|
+
} catch {
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
// Project-wide operational defaults — see mnemonad.config.js's own comments for what each
|
|
34
|
+
// value means and why it's safe to commit. Precedence for every value it feeds
|
|
35
|
+
// (`gatewayUrl`, `presignUrl`): CLI flag > env var > that file > the hardcoded fallback
|
|
36
|
+
// below (kept in case the import above ever fails).
|
|
37
|
+
const CONFIG = defaultConfig || {};
|
|
38
|
+
|
|
39
|
+
// Same default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js) — a
|
|
40
|
+
// public read gateway so pulling/diffing/inspecting a stream with IPFS-offloaded items
|
|
41
|
+
// works out of the box, with no setup, for anyone who didn't push it themselves.
|
|
42
|
+
const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://gateway.pinata.cloud/ipfs/';
|
|
43
|
+
|
|
44
|
+
// The deployed presign server (presign-server/) this project runs by default — see its own
|
|
45
|
+
// README for what it does. Pushing an item too big to fit on-chain uses this automatically
|
|
46
|
+
// so a signer needs no Pinata credential of their own; --pinata-jwt still works as a direct
|
|
47
|
+
// alternative for anyone who'd rather supply their own.
|
|
48
|
+
const DEFAULT_PRESIGN_URL = CONFIG.presignUrl || null;
|
|
49
|
+
|
|
50
|
+
const USAGE = `mnemonad v${VERSION} — sync local folders to a versioned, diffed stream on Monad
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
mnemonad push [stream-id] <path> [options] Sync a folder to a stream (creates if no id)
|
|
54
|
+
mnemonad pull <stream-id> <path> [options] Restore stream contents to a folder
|
|
55
|
+
mnemonad info [stream-id] [path] [options] Show stream metadata (path only needed with a stream-id)
|
|
56
|
+
mnemonad diff <stream-id> <path> [options] List local files added/modified/deleted vs the on-chain version
|
|
57
|
+
mnemonad watch <stream-id> <path> [options] Watch folder and auto-push/pull
|
|
58
|
+
mnemonad compact <stream-id> <path> [options] Truncate old history, push a fresh snapshot
|
|
59
|
+
|
|
60
|
+
<path> is always required wherever a command touches a folder — the CLI never assumes the
|
|
61
|
+
current directory. The only exception is \`mnemonad info\` with no stream-id, which just
|
|
62
|
+
checks your configured chain/wallet and needs no folder at all.
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--chain <name> Chain: testnet, mainnet, local (default: testnet)
|
|
66
|
+
--rpc-url <url> Override the resolved RPC endpoint (e.g. a local Hardhat node)
|
|
67
|
+
--contract-address <addr> Registry address to use instead of the chain's known deployment
|
|
68
|
+
(required with --chain local)
|
|
69
|
+
--gateway-url <url> IPFS gateway for reading externally-offloaded items
|
|
70
|
+
(default: https://gateway.pinata.cloud/ipfs/, or MNEMONAD_IPFS_GATEWAY env var)
|
|
71
|
+
--presign-url <url> Presign server for paid IPFS uploads with no Pinata credential
|
|
72
|
+
(default: the deployed server in mnemonad.config.js, or MNEMONAD_PRESIGN_URL env var)
|
|
73
|
+
--key <privkey> Monad private key (or set MNEMONAD_KEY env var)
|
|
74
|
+
--phrase <mnemonic> Mnemonic phrase
|
|
75
|
+
--version <n> Version to restore/compare (pull, diff, info; default: latest)
|
|
76
|
+
--exclude <p1,p2> Extra exclude patterns (comma-separated)
|
|
77
|
+
--no-compress Disable gzip compression
|
|
78
|
+
--encrypt Create a new stream encrypted; only applies on creation
|
|
79
|
+
--encrypt-with <method> How to key it: password, or wallet (the --key/--phrase account
|
|
80
|
+
signs for it). Implied by --password; required otherwise
|
|
81
|
+
--password <pw> Password to encrypt a new stream with, or decrypt an existing one
|
|
82
|
+
--pinata-jwt <jwt> Pinata JWT for IPFS offload of large files (or PINATA_JWT env var)
|
|
83
|
+
--manifest Write/use .mnemonad manifest for faster change detection
|
|
84
|
+
--force-snapshot Push a full snapshot regardless of prior history (repairs corrupt streams)
|
|
85
|
+
--poll-interval <s> Watch: seconds between remote checks (default: 2)
|
|
86
|
+
--debounce <ms> Watch: ms quiet period before pushing after a change (default: 1000)
|
|
87
|
+
--push-only Watch: disable auto-pull
|
|
88
|
+
--pull-only Watch: disable auto-push
|
|
89
|
+
--help Show this help
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
function parseArgs(argv) {
|
|
93
|
+
const args = {
|
|
94
|
+
command: null,
|
|
95
|
+
streamId: null,
|
|
96
|
+
// No cwd fallback — every command that touches a folder requires it explicitly (see
|
|
97
|
+
// shared.js's resolvePath(), which throws a friendly error if it's still null here).
|
|
98
|
+
path: null,
|
|
99
|
+
chain: 'testnet',
|
|
100
|
+
rpcUrl: null,
|
|
101
|
+
contractAddress: null,
|
|
102
|
+
gatewayUrl: process.env.MNEMONAD_IPFS_GATEWAY || DEFAULT_IPFS_GATEWAY,
|
|
103
|
+
presignUrl: process.env.MNEMONAD_PRESIGN_URL || DEFAULT_PRESIGN_URL,
|
|
104
|
+
key: null,
|
|
105
|
+
phrase: null,
|
|
106
|
+
version: null,
|
|
107
|
+
exclude: null,
|
|
108
|
+
compress: 'gzip',
|
|
109
|
+
encrypt: false,
|
|
110
|
+
encryptWith: null,
|
|
111
|
+
password: null,
|
|
112
|
+
pinataJwt: process.env.PINATA_JWT || null,
|
|
113
|
+
manifest: false,
|
|
114
|
+
forceSnapshot: false,
|
|
115
|
+
pollInterval: 2,
|
|
116
|
+
debounce: 1000,
|
|
117
|
+
pushOnly: false,
|
|
118
|
+
pullOnly: false,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const raw = argv.slice(2);
|
|
122
|
+
|
|
123
|
+
if (raw.length === 0 || raw.includes('--help')) {
|
|
124
|
+
console.log(USAGE);
|
|
125
|
+
_realExit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
args.command = raw[0];
|
|
129
|
+
|
|
130
|
+
let i = 1;
|
|
131
|
+
|
|
132
|
+
// Collect up to two positional args: [streamId] [path]. `path` is left null when it
|
|
133
|
+
// isn't given here — never defaulted to cwd — so a command that needs one (every
|
|
134
|
+
// command except a bare `info`) fails fast with a clear error instead of silently
|
|
135
|
+
// operating on whatever directory the CLI happened to be run from.
|
|
136
|
+
// A stream id is a decimal bigint or a 0x-prefixed hex string; anything else is a path.
|
|
137
|
+
const isStreamId = (s) => /^\d+$/.test(s) || /^0x[0-9a-fA-F]+$/.test(s);
|
|
138
|
+
const positionals = [];
|
|
139
|
+
while (i < raw.length && !raw[i].startsWith('--')) {
|
|
140
|
+
positionals.push(raw[i]);
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
if (positionals.length === 2) {
|
|
144
|
+
args.streamId = positionals[0];
|
|
145
|
+
args.path = positionals[1];
|
|
146
|
+
} else if (positionals.length === 1) {
|
|
147
|
+
if (isStreamId(positionals[0])) {
|
|
148
|
+
args.streamId = positionals[0];
|
|
149
|
+
} else {
|
|
150
|
+
args.path = positionals[0];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
while (i < raw.length) {
|
|
155
|
+
const flag = raw[i];
|
|
156
|
+
if (flag === '--chain' && i + 1 < raw.length) {
|
|
157
|
+
args.chain = raw[++i];
|
|
158
|
+
} else if (flag === '--rpc-url' && i + 1 < raw.length) {
|
|
159
|
+
args.rpcUrl = raw[++i];
|
|
160
|
+
} else if (flag === '--contract-address' && i + 1 < raw.length) {
|
|
161
|
+
args.contractAddress = raw[++i];
|
|
162
|
+
} else if (flag === '--gateway-url' && i + 1 < raw.length) {
|
|
163
|
+
args.gatewayUrl = raw[++i];
|
|
164
|
+
} else if (flag === '--presign-url' && i + 1 < raw.length) {
|
|
165
|
+
args.presignUrl = raw[++i];
|
|
166
|
+
} else if (flag === '--key' && i + 1 < raw.length) {
|
|
167
|
+
args.key = raw[++i];
|
|
168
|
+
} else if (flag === '--phrase' && i + 1 < raw.length) {
|
|
169
|
+
args.phrase = raw[++i];
|
|
170
|
+
} else if (flag === '--version' && i + 1 < raw.length) {
|
|
171
|
+
args.version = raw[++i];
|
|
172
|
+
} else if (flag === '--exclude' && i + 1 < raw.length) {
|
|
173
|
+
args.exclude = raw[++i];
|
|
174
|
+
} else if (flag === '--no-compress') {
|
|
175
|
+
args.compress = false;
|
|
176
|
+
} else if (flag === '--encrypt') {
|
|
177
|
+
args.encrypt = true;
|
|
178
|
+
} else if (flag === '--encrypt-with' && i + 1 < raw.length) {
|
|
179
|
+
args.encryptWith = raw[++i];
|
|
180
|
+
} else if (flag === '--password' && i + 1 < raw.length) {
|
|
181
|
+
args.password = raw[++i];
|
|
182
|
+
} else if (flag === '--pinata-jwt' && i + 1 < raw.length) {
|
|
183
|
+
args.pinataJwt = raw[++i];
|
|
184
|
+
} else if (flag === '--manifest') {
|
|
185
|
+
args.manifest = true;
|
|
186
|
+
} else if (flag === '--force-snapshot') {
|
|
187
|
+
args.forceSnapshot = true;
|
|
188
|
+
} else if (flag === '--poll-interval' && i + 1 < raw.length) {
|
|
189
|
+
args.pollInterval = Number(raw[++i]);
|
|
190
|
+
} else if (flag === '--debounce' && i + 1 < raw.length) {
|
|
191
|
+
args.debounce = Number(raw[++i]);
|
|
192
|
+
} else if (flag === '--push-only') {
|
|
193
|
+
args.pushOnly = true;
|
|
194
|
+
} else if (flag === '--pull-only') {
|
|
195
|
+
args.pullOnly = true;
|
|
196
|
+
}
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return args;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const args = parseArgs(process.argv);
|
|
204
|
+
|
|
205
|
+
console.log(`mnemonad v${VERSION}`);
|
|
206
|
+
|
|
207
|
+
// A ref'd interval keeps the event loop alive for the duration of the command, in case
|
|
208
|
+
// anything in the RPC/fetch stack unref's its own I/O (see the process.exit note above).
|
|
209
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
if (args.command === 'push') {
|
|
213
|
+
await push(args);
|
|
214
|
+
} else if (args.command === 'pull') {
|
|
215
|
+
await pull(args);
|
|
216
|
+
} else if (args.command === 'info') {
|
|
217
|
+
await info(args);
|
|
218
|
+
} else if (args.command === 'diff') {
|
|
219
|
+
await diff(args);
|
|
220
|
+
} else if (args.command === 'watch') {
|
|
221
|
+
await watch(args);
|
|
222
|
+
} else if (args.command === 'compact') {
|
|
223
|
+
await compact(args);
|
|
224
|
+
} else {
|
|
225
|
+
console.error('Unknown command:', args.command);
|
|
226
|
+
console.log(USAGE);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err._isProcessExit) {
|
|
231
|
+
process.exitCode = err._exitCode;
|
|
232
|
+
} else if (err._isUserError) {
|
|
233
|
+
// Expected, actionable failure — message only, no stack trace.
|
|
234
|
+
console.error('Error:', err.message);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
} else {
|
|
237
|
+
console.error('Error:', err.message);
|
|
238
|
+
if (err.stack) console.error(err.stack);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
clearInterval(keepAlive);
|
|
243
|
+
}
|
package/lib/FSFile.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
// monadsync re-exports its whole sync-primitives surface so a consumer needs only one
|
|
4
|
+
// dependency, not two — see monadsync/index.js.
|
|
5
|
+
import { DoubleSyncFile as SyncFile } from 'monadsync';
|
|
6
|
+
|
|
7
|
+
export class FSFile extends SyncFile {
|
|
8
|
+
constructor(filePath) {
|
|
9
|
+
super();
|
|
10
|
+
this._path = filePath;
|
|
11
|
+
this._name = basename(filePath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get name() { return this._name; }
|
|
15
|
+
|
|
16
|
+
async getContent() {
|
|
17
|
+
const buf = await readFile(this._path);
|
|
18
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getSize() {
|
|
22
|
+
const s = await stat(this._path);
|
|
23
|
+
return s.size;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/FSFolder.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
// monadsync re-exports its whole sync-primitives surface so a consumer needs only one
|
|
4
|
+
// dependency, not two — see monadsync/index.js.
|
|
5
|
+
import { DoubleSyncFolder as SyncFolder } from 'monadsync';
|
|
6
|
+
import { FSFile } from './FSFile.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
|
|
9
|
+
|
|
10
|
+
export class FSFolder extends SyncFolder {
|
|
11
|
+
constructor(dirPath, excludes = DEFAULT_EXCLUDES) {
|
|
12
|
+
super();
|
|
13
|
+
this._path = dirPath;
|
|
14
|
+
this._name = basename(dirPath);
|
|
15
|
+
this._excludes = excludes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get name() { return this._name; }
|
|
19
|
+
|
|
20
|
+
async list() {
|
|
21
|
+
const entries = await readdir(this._path, { withFileTypes: true });
|
|
22
|
+
const children = [];
|
|
23
|
+
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (this._excludes.includes(entry.name)) continue;
|
|
26
|
+
if (entry.name.startsWith('.')) continue;
|
|
27
|
+
|
|
28
|
+
const fullPath = join(this._path, entry.name);
|
|
29
|
+
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
children.push(new FSFolder(fullPath, this._excludes));
|
|
32
|
+
} else if (entry.isFile()) {
|
|
33
|
+
children.push(new FSFile(fullPath));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return children;
|
|
38
|
+
}
|
|
39
|
+
}
|