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/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 |
@@ -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
+ }
@@ -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
+ }