mnemonad-cli 0.1.0 → 0.2.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 CHANGED
@@ -12,8 +12,23 @@ signer's own wallet key.
12
12
 
13
13
  ## Installation
14
14
 
15
- This package lives in the monorepo alongside `mnemonad` and `monadsync` and is not yet
16
- published. From the repo root:
15
+ ### From npm (recommended)
16
+
17
+ ```bash
18
+ npm install -g mnemonad-cli
19
+ mnemonad --help
20
+ ```
21
+
22
+ To pick up a new release later:
23
+
24
+ ```bash
25
+ npm update -g mnemonad-cli
26
+ ```
27
+
28
+ ### From this repo (for development)
29
+
30
+ This package lives in the monorepo alongside `mnemonad` and `monadsync`, linked through a
31
+ pnpm workspace at the repo root. From the repo root:
17
32
 
18
33
  ```bash
19
34
  cd cli
@@ -21,7 +36,7 @@ pnpm install
21
36
  node bin/mnemonad.js --help
22
37
  ```
23
38
 
24
- Or link it for a global `mnemonad` command:
39
+ Or link it for a global `mnemonad` command that tracks your local checkout:
25
40
 
26
41
  ```bash
27
42
  cd cli
@@ -47,15 +62,22 @@ mnemonad info [stream-id] [path] [options] Show chain/wallet info, or full s
47
62
  mnemonad diff <stream-id> <path> [options] List files added/modified/deleted locally vs the on-chain version
48
63
  mnemonad watch <stream-id> <path> [options] Watch folder: auto-push on changes, auto-pull on remote updates
49
64
  mnemonad compact <stream-id> <path> [options] Truncate old history and push folder as a fresh snapshot
65
+ mnemonad burn <stream-id> --yes [options] Permanently destroy a stream — irreversible
50
66
  ```
51
67
 
52
68
  `<path>` is always required for any command that touches a folder — there is no need to
53
69
  `cd` into the folder first, but there's also no falling back to the current working
54
70
  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.
71
+ operating on wherever the CLI happened to be run from. The commands that can run with no
72
+ path at all are `mnemonad info` with no stream id either (it just checks your configured
73
+ chain/wallet) and `mnemonad burn`, which never takes one. A stream id is recognized as a
74
+ bare decimal number (`123`), a
75
+ `0x`-prefixed hex string, or the network-qualified form the
76
+ [explorer dApp](https://mnemonad.vercel.app)'s own URLs use —
77
+ `testnet-0x648f18…` — pasted straight out of a `/stream/<id>` link. That form also sets
78
+ `--chain` to match; pass `--chain` too only if it agrees, or the command refuses rather than
79
+ guess which one you meant. Every command's own output always prints the bare id in hex
80
+ (`0x` + the full 32-byte token id), matching the explorer's own display.
59
81
 
60
82
  Every command prints the installed CLI version as its first line of output.
61
83
 
@@ -76,12 +98,13 @@ Every command prints the installed CLI version as its first line of output.
76
98
  | `--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
99
  | `--password <pw>` | Password to encrypt a new stream with, or decrypt an existing one |
78
100
  | `--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 |
101
+ | `--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 |
80
102
  | `--force-snapshot` | Push a full snapshot regardless of prior history (repairs a corrupt stream) |
81
103
  | `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
82
104
  | `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
83
105
  | `--push-only` | `watch`: disable auto-pull |
84
106
  | `--pull-only` | `watch`: disable auto-push |
107
+ | `--yes` | Required by `burn` — explicit opt-in for an irreversible action, never assumed |
85
108
  | `--help` | Show help |
86
109
 
87
110
  ### Authentication
@@ -116,13 +139,19 @@ chain.
116
139
  ```bash
117
140
  mnemonad push ~/my-data
118
141
 
119
- # prints the new stream id, e.g.:
120
- # created: 123
142
+ # prints the new stream id in hex, e.g.:
143
+ # created: 0x68d9736257f0d2da3c848666a4b36c4b9f18eda7e4dfba655599c47560d8a4b5
121
144
  # version 1 pushed (full snapshot, gzip compressed)
145
+
146
+ # examples below use `123` as a stand-in for whatever id you actually got back —
147
+ # decimal or hex both work as input, see "Usage" above
122
148
  ```
123
149
 
124
150
  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:
151
+ never picks for you, since both are reachable from the same invocation. See
152
+ [`../docs/encryption/encryption.md`](../docs/encryption/encryption.md) (and its
153
+ [password](../docs/encryption/password.md)/[signature](../docs/encryption/signature.md)
154
+ pages) for how each actually works and the trade-off each makes:
126
155
 
127
156
  ```bash
128
157
  # Keyed by a password: anyone who knows it can read the stream, from any machine.
@@ -172,7 +201,7 @@ mnemonad info 123 ~/my-data
172
201
  chain: testnet
173
202
  your wallet: 0x06bc6420b37a4898424429dcfa236f0065e12279
174
203
 
175
- stream: 123
204
+ stream: 0x68d9736257f0d2da3c848666a4b36c4b9f18eda7e4dfba655599c47560d8a4b5
176
205
  owner: 0x06bc6420b37a4898424429dcfa236f0065e12279 (you)
177
206
  encrypted: no
178
207
  versions: 3
@@ -213,6 +242,23 @@ mnemonad push 123 ~/my-data --manifest
213
242
  # subsequent pushes are skipped when nothing has changed locally
214
243
  ```
215
244
 
245
+ `--manifest` writes `.mnemonad` into the folder, recording the stream id, version and file
246
+ hashes. With it, later commands on the same folder don't need the id typed again:
247
+
248
+ ```bash
249
+ mnemonad push ~/my-data --manifest # no id — read from .mnemonad
250
+ mnemonad pull ~/my-data --manifest # same
251
+ mnemonad diff ~/my-data --manifest # same
252
+ ```
253
+
254
+ If no id is given and there's no `.mnemonad` yet (or `--manifest` wasn't passed), the usual
255
+ `stream-id is required` error is thrown.
256
+
257
+ `info` is the one exception that doesn't require `--manifest` to react to a manifest: being
258
+ read-only, it always peeks at `.mnemonad` in `<path>` and reports what it finds — a note that
259
+ one's available (with its id) if you left `--manifest` off, or a note if the id you gave
260
+ doesn't match the one recorded in the folder's own manifest.
261
+
216
262
  ### Watch a folder (auto push + pull)
217
263
 
218
264
  ```bash
@@ -246,6 +292,23 @@ just a faster-replay optimization.
246
292
  mnemonad compact 123 ~/my-data
247
293
  ```
248
294
 
295
+ ### Burn a stream
296
+
297
+ Permanently destroys the stream: the ERC-721 token and its metadata are gone, and — because
298
+ ids are deterministic — re-creating a stream with the same id afterward does **not** recover
299
+ the old data; the contract deliberately marks a burned id as permanently used. Deployed
300
+ CODE-tier item bytes physically survive on-chain regardless — this removes the stream as a
301
+ usable, readable thing, not necessarily every trace of its bytes. Requires **`--yes`**: run
302
+ without it first to see what would be destroyed (owner, version count, on-chain size) before
303
+ committing.
304
+
305
+ ```bash
306
+ mnemonad burn 123
307
+ # prints stream/chain/versions/size, then refuses to proceed without --yes
308
+
309
+ mnemonad burn 123 --yes
310
+ ```
311
+
249
312
  ### Repair a corrupt stream with a force snapshot
250
313
 
251
314
  If a diff patch was pushed against a stale base (e.g. a race condition in `watch`),
@@ -266,8 +329,10 @@ export MNEMONAD_KEY=0xabc123...
266
329
 
267
330
  # 1. first push — creates the stream
268
331
  mnemonad push ~/my-data
269
- # created: 123
332
+ # created: 0x68d9736257f0d2da3c848666a4b36c4b9f18eda7e4dfba655599c47560d8a4b5
270
333
  # version 1 pushed (full snapshot, gzip compressed)
334
+ # (the rest of this walkthrough keeps using 123 as a stand-in for that id — both
335
+ # forms work as input, see "Usage" above)
271
336
 
272
337
  # 2. verify: restore into a scratch folder and compare against the original
273
338
  mnemonad pull 123 ~/verify-tmp
package/bin/mnemonad.js CHANGED
@@ -17,7 +17,8 @@ import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join } from 'node:path';
19
19
 
20
- import { push, pull, info, diff, watch, compact } from '../lib/commands/index.js';
20
+ import { push, pull, info, diff, watch, compact, burn } from '../lib/commands/index.js';
21
+ import { parseQualifiedStreamId } from '../lib/commands/shared.js';
21
22
  import defaultConfig from '../mnemonad.config.js';
22
23
 
23
24
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -56,10 +57,15 @@ Usage:
56
57
  mnemonad diff <stream-id> <path> [options] List local files added/modified/deleted vs the on-chain version
57
58
  mnemonad watch <stream-id> <path> [options] Watch folder and auto-push/pull
58
59
  mnemonad compact <stream-id> <path> [options] Truncate old history, push a fresh snapshot
60
+ mnemonad burn <stream-id> --yes [options] Permanently destroy a stream (irreversible)
59
61
 
60
62
  <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
+ current directory. The exceptions are \`mnemonad info\` with no stream-id (just checks your
64
+ configured chain/wallet) and \`mnemonad burn\`, which takes no folder at all.
65
+
66
+ <stream-id> also accepts the network-qualified form shown in the explorer's URLs —
67
+ testnet-0x648f18… — paste it straight out of a /stream/<id> link. It sets --chain to match;
68
+ pass --chain too only if it agrees, or the command refuses rather than guess which one you meant.
63
69
 
64
70
  Options:
65
71
  --chain <name> Chain: testnet, mainnet, local (default: testnet)
@@ -80,12 +86,15 @@ Options:
80
86
  signs for it). Implied by --password; required otherwise
81
87
  --password <pw> Password to encrypt a new stream with, or decrypt an existing one
82
88
  --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
89
+ --manifest Write/use .mnemonad manifest for faster change detection.
90
+ With no stream-id given, every command recovers it from an
91
+ existing .mnemonad in <path> instead of requiring it explicitly
84
92
  --force-snapshot Push a full snapshot regardless of prior history (repairs corrupt streams)
85
93
  --poll-interval <s> Watch: seconds between remote checks (default: 2)
86
94
  --debounce <ms> Watch: ms quiet period before pushing after a change (default: 1000)
87
95
  --push-only Watch: disable auto-pull
88
96
  --pull-only Watch: disable auto-push
97
+ --yes Required by burn — explicit opt-in for an irreversible action
89
98
  --help Show this help
90
99
  `;
91
100
 
@@ -116,6 +125,7 @@ function parseArgs(argv) {
116
125
  debounce: 1000,
117
126
  pushOnly: false,
118
127
  pullOnly: false,
128
+ yes: false,
119
129
  };
120
130
 
121
131
  const raw = argv.slice(2);
@@ -134,27 +144,48 @@ function parseArgs(argv) {
134
144
  // command except a bare `info`) fails fast with a clear error instead of silently
135
145
  // operating on whatever directory the CLI happened to be run from.
136
146
  // 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);
147
+ //
148
+ // Also accepted: the network-qualified form the explorer's own URLs use —
149
+ // `<network>-0x<hex>`, e.g. `testnet-0x648f18…` — since that's what an agent naturally
150
+ // has in hand after pulling an id straight out of a /stream/<id> link. A qualified id
151
+ // names the chain the same way the URL does; parseQualifiedStreamId (shared.js, same
152
+ // split the explorer's own parseStreamRouteId does) records which one (chainFromId,
153
+ // below) so it can settle --chain once flags are parsed too.
154
+ const isStreamId = (s) => /^\d+$/.test(s) || /^0x[0-9a-fA-F]+$/.test(s) || !!parseQualifiedStreamId(s);
155
+
156
+ let chainFromId = null;
157
+ // Strips a recognized `<network>-` prefix and records the network it named, so every
158
+ // downstream command only ever sees the bare id it already knows how to parse
159
+ // (formatStreamId, `new Mnemonad({id})`, etc. never learn this form exists).
160
+ const takeStreamId = (arg) => {
161
+ const qualified = parseQualifiedStreamId(arg);
162
+ if (!qualified) return arg;
163
+ chainFromId = qualified.network;
164
+ return qualified.id;
165
+ };
166
+
138
167
  const positionals = [];
139
168
  while (i < raw.length && !raw[i].startsWith('--')) {
140
169
  positionals.push(raw[i]);
141
170
  i++;
142
171
  }
143
172
  if (positionals.length === 2) {
144
- args.streamId = positionals[0];
173
+ args.streamId = takeStreamId(positionals[0]);
145
174
  args.path = positionals[1];
146
175
  } else if (positionals.length === 1) {
147
176
  if (isStreamId(positionals[0])) {
148
- args.streamId = positionals[0];
177
+ args.streamId = takeStreamId(positionals[0]);
149
178
  } else {
150
179
  args.path = positionals[0];
151
180
  }
152
181
  }
153
182
 
183
+ let sawChainFlag = false;
154
184
  while (i < raw.length) {
155
185
  const flag = raw[i];
156
186
  if (flag === '--chain' && i + 1 < raw.length) {
157
187
  args.chain = raw[++i];
188
+ sawChainFlag = true;
158
189
  } else if (flag === '--rpc-url' && i + 1 < raw.length) {
159
190
  args.rpcUrl = raw[++i];
160
191
  } else if (flag === '--contract-address' && i + 1 < raw.length) {
@@ -193,10 +224,24 @@ function parseArgs(argv) {
193
224
  args.pushOnly = true;
194
225
  } else if (flag === '--pull-only') {
195
226
  args.pullOnly = true;
227
+ } else if (flag === '--yes') {
228
+ args.yes = true;
196
229
  }
197
230
  i++;
198
231
  }
199
232
 
233
+ if (chainFromId) {
234
+ if (sawChainFlag && args.chain !== chainFromId) {
235
+ console.error(
236
+ `Error: the stream id names network '${chainFromId}', but --chain '${args.chain}' was\n` +
237
+ ` also given — they disagree. Drop --chain to use the id's network, or pass\n` +
238
+ ` --chain ${chainFromId} to match it.`
239
+ );
240
+ _realExit(1);
241
+ }
242
+ args.chain = chainFromId;
243
+ }
244
+
200
245
  return args;
201
246
  }
202
247
 
@@ -221,6 +266,8 @@ try {
221
266
  await watch(args);
222
267
  } else if (args.command === 'compact') {
223
268
  await compact(args);
269
+ } else if (args.command === 'burn') {
270
+ await burn(args);
224
271
  } else {
225
272
  console.error('Unknown command:', args.command);
226
273
  console.log(USAGE);
@@ -107,6 +107,13 @@ export function makeChainClient(args) {
107
107
  */
108
108
  export function requireWalletClient(client) {
109
109
  if (!client.walletClient) {
110
- throw new Error('No signing key. Use --key, --phrase, or MNEMONAD_KEY env var');
110
+ // Marked as a user error so the CLI boundary prints the message alone: this is the
111
+ // first thing anyone hits before configuring a key, and a stack trace through
112
+ // chainClient makes an actionable one-liner look like a crash.
113
+ const err = new Error(
114
+ 'No signing key. Set the MNEMONAD_KEY env var to a private key, or pass --key / --phrase.'
115
+ );
116
+ err._isUserError = true;
117
+ throw err;
111
118
  }
112
119
  }
@@ -0,0 +1,59 @@
1
+ import Mnemonad from 'mnemonad';
2
+ import { makeChainClient, requireWalletClient } from '../chainClient.js';
3
+ import { assertStreamOwner, formatStreamId, formatBytes } from './shared.js';
4
+
5
+ /**
6
+ * Destroys a stream permanently: burns the ERC-721 token and clears its metadata and
7
+ * wrapped encryption key (`contracts/src/Mnemonad.sol`'s own `burn()`). Irreversible, and
8
+ * re-creating a stream with the same id afterward does NOT resurrect the old data — ids are
9
+ * deterministic, so the contract deliberately marks a burned id as permanently used rather
10
+ * than letting a fresh `create()` silently inherit old item records.
11
+ *
12
+ * Deployed CODE-tier item bytes physically survive on-chain regardless of burn — this
13
+ * removes the stream as a usable, readable thing, not necessarily every trace of its bytes.
14
+ *
15
+ * Requires `--yes`: the one command here that destroys something with no way back, so it
16
+ * refuses to run without an explicit, unambiguous opt-in rather than an interactive prompt
17
+ * a script could accidentally click through.
18
+ */
19
+ export async function burn(args) {
20
+ if (!args.streamId) {
21
+ throw new Error('stream-id is required for burn');
22
+ }
23
+
24
+ const client = makeChainClient(args);
25
+ requireWalletClient(client);
26
+
27
+ const mn = new Mnemonad({
28
+ publicClient: client.publicClient,
29
+ walletClient: client.walletClient,
30
+ contractAddress: client.contractAddress,
31
+ id: args.streamId,
32
+ });
33
+
34
+ // Ownership checked before printing anything about the stream's contents — same
35
+ // reasoning as every other write command's own assertStreamOwner call.
36
+ await assertStreamOwner(mn, client.account.address);
37
+ await mn.initialize();
38
+
39
+ console.log('stream: ', formatStreamId(mn.id));
40
+ console.log('chain: ', args.chain);
41
+ console.log('versions: ', mn.length);
42
+ console.log('binary size: ', formatBytes(mn.binaryLength));
43
+
44
+ if (!args.yes) {
45
+ const err = new Error(
46
+ 'This permanently destroys the stream above: the ERC-721 token and its metadata\n' +
47
+ ' are gone, and re-creating a stream with this id afterward does NOT recover the\n' +
48
+ ' old data — there is no undo. (Deployed CODE-tier chunks physically survive on\n' +
49
+ ' chain regardless; this only removes the stream as a usable, readable thing.)\n' +
50
+ ' Re-run with --yes to actually burn it.'
51
+ );
52
+ err._isUserError = true;
53
+ throw err;
54
+ }
55
+
56
+ console.log('burning...');
57
+ await mn.burn();
58
+ console.log(' done —', formatStreamId(mn.id), 'is burned.');
59
+ }
@@ -10,6 +10,7 @@ import {
10
10
  assertStreamOwner,
11
11
  unlockStream,
12
12
  resolveOffloadParams,
13
+ discoverStreamId,
13
14
  } from './shared.js';
14
15
 
15
16
  /**
@@ -19,11 +20,15 @@ import {
19
20
  * to it.
20
21
  */
21
22
  export async function compact(args) {
23
+ const destPath = resolvePath(args.path, { mustExist: true });
24
+
25
+ // With --manifest and no id, a folder pulled/pushed before already has one recorded —
26
+ // no need to require it again.
27
+ await discoverStreamId(args, destPath);
22
28
  if (!args.streamId) {
23
29
  throw new Error('stream-id is required for compact');
24
30
  }
25
31
 
26
- const destPath = resolvePath(args.path);
27
32
  const fsFolder = new FSFolder(destPath, makeExcludes(args));
28
33
 
29
34
  const counts = await countTree(fsFolder);
@@ -8,6 +8,9 @@ import {
8
8
  formatPath,
9
9
  hashLocalTree,
10
10
  unlockStream,
11
+ formatStreamId,
12
+ folderExists,
13
+ discoverStreamId,
11
14
  } from './shared.js';
12
15
 
13
16
  export function compareFileMaps(localFiles, remoteFiles) {
@@ -36,13 +39,23 @@ export function compareFileMaps(localFiles, remoteFiles) {
36
39
  }
37
40
 
38
41
  export async function diff(args) {
39
- if (!args.streamId) {
40
- throw new Error('stream-id is required for diff');
41
- }
42
42
  // Resolved up front (throws if missing) so a missing path fails fast, before any chain
43
43
  // work — see resolvePath()'s own comment for why this is never defaulted to cwd.
44
+ //
45
+ // Deliberately NOT `{ mustExist: true }`, unlike push/compact/watch: diffing a stream
46
+ // against a folder you haven't pulled yet is a legitimate question ("what would I get?"),
47
+ // and this command writes nothing either way. It is called out explicitly below though —
48
+ // "everything deleted" for a folder that never existed reads as data loss rather than as
49
+ // a wrong path.
44
50
  const destPath = resolvePath(args.path);
45
51
 
52
+ // With --manifest and no id, a folder pulled/pushed before already has one recorded —
53
+ // no need to require it again.
54
+ await discoverStreamId(args, destPath);
55
+ if (!args.streamId) {
56
+ throw new Error('stream-id is required for diff');
57
+ }
58
+
46
59
  const client = makeChainClient(args);
47
60
 
48
61
  const mn = new Mnemonad({
@@ -61,7 +74,7 @@ export async function diff(args) {
61
74
  const totalVersions = mn.length;
62
75
 
63
76
  if (totalVersions === 0) {
64
- console.log('stream', args.streamId, 'has no versions yet — nothing to diff');
77
+ console.log('stream', formatStreamId(args.streamId), 'has no versions yet — nothing to diff');
65
78
  return;
66
79
  }
67
80
 
@@ -70,7 +83,13 @@ export async function diff(args) {
70
83
  throw new Error('version must be between 1 and ' + totalVersions);
71
84
  }
72
85
 
73
- console.log('diff:', formatPath(destPath), 'vs', args.streamId, '(version ' + version + ' of ' + totalVersions + ')');
86
+ console.log('diff:', formatPath(destPath), 'vs', formatStreamId(args.streamId), '(version ' + version + ' of ' + totalVersions + ')');
87
+ // Said up front, before the file list: without it, a mistyped path produces a list of
88
+ // every file in the stream marked "deleted", which reads as data loss rather than as
89
+ // a folder that was never there.
90
+ if (!folderExists(destPath)) {
91
+ console.log(' note: local folder does not exist — everything below is listed as deleted');
92
+ }
74
93
  console.log(' restoring chain version...');
75
94
 
76
95
  let remoteFolder;
@@ -4,3 +4,4 @@ export { info } from './info.js';
4
4
  export { diff } from './diff.js';
5
5
  export { watch } from './watch.js';
6
6
  export { compact } from './compact.js';
7
+ export { burn } from './burn.js';
@@ -6,6 +6,11 @@ import {
6
6
  formatBytes,
7
7
  localTreeHash,
8
8
  unlockStream,
9
+ formatStreamId,
10
+ folderExists,
11
+ discoverStreamId,
12
+ readManifest,
13
+ streamIdsEqual,
9
14
  } from './shared.js';
10
15
 
11
16
  export async function info(args) {
@@ -14,6 +19,32 @@ export async function info(args) {
14
19
  console.log(' chain: ', args.chain);
15
20
  console.log(' your wallet: ', client.account?.address || '(none)');
16
21
 
22
+ // Peek at .mnemonad whenever a path is given, regardless of --manifest and regardless
23
+ // of whether an id was given — info is read-only, so surfacing what's there costs
24
+ // nothing, and it's the only command where "you have a manifest but forgot the flag" or
25
+ // "you gave an id that doesn't match your own manifest" are worth a note rather than a
26
+ // silent mismatch.
27
+ const manifest = args.path ? await readManifest(resolvePath(args.path)) : null;
28
+
29
+ if (manifest?.streamId) {
30
+ if (!args.streamId) {
31
+ if (args.manifest) {
32
+ // Logs its own "using stream id from .mnemonad manifest: ..." line.
33
+ await discoverStreamId(args, resolvePath(args.path));
34
+ } else {
35
+ console.log(
36
+ ' manifest: ', formatStreamId(manifest.streamId),
37
+ '(.mnemonad found — pass --manifest to use it without an id)'
38
+ );
39
+ }
40
+ } else if (!streamIdsEqual(manifest.streamId, args.streamId)) {
41
+ console.log(
42
+ ' manifest: ', formatStreamId(manifest.streamId),
43
+ '(differs from the id given:', formatStreamId(args.streamId) + ')'
44
+ );
45
+ }
46
+ }
47
+
17
48
  if (!args.streamId) {
18
49
  return;
19
50
  }
@@ -33,7 +64,7 @@ export async function info(args) {
33
64
 
34
65
  const totalVersions = mn.length;
35
66
 
36
- console.log('\nstream:', args.streamId);
67
+ console.log('\nstream:', formatStreamId(args.streamId));
37
68
 
38
69
  const owner = await mn.owner().catch(() => null) || '(unknown)';
39
70
  const isOwner = client.account && owner !== '(unknown)' && client.account.address.toLowerCase() === owner.toLowerCase();
@@ -63,7 +94,16 @@ export async function info(args) {
63
94
  console.log(' history starts: ', 'index', mn.firstAvailableIndex, '(earlier history truncated — see `compact`)');
64
95
  }
65
96
 
97
+ // Deliberately NOT `{ mustExist: true }` like every other command that reads a folder:
98
+ // `info` is a read-only report, and "you haven't pulled this anywhere yet" is a state
99
+ // worth describing rather than refusing over — the stream metadata above is already
100
+ // printed and still useful on its own.
66
101
  const destPath = resolvePath(args.path);
102
+ if (!folderExists(destPath)) {
103
+ console.log('\n local folder: missing (run `mnemonad pull` to fetch it)');
104
+ return;
105
+ }
106
+
67
107
  const localHash = await localTreeHash(destPath);
68
108
 
69
109
  if (!localHash) {
@@ -8,16 +8,22 @@ import {
8
8
  syncMemoryFolderToDisk,
9
9
  writeManifest,
10
10
  unlockStream,
11
+ formatStreamId,
12
+ discoverStreamId,
11
13
  } from './shared.js';
12
14
 
13
15
  export async function pull(args) {
14
- if (!args.streamId) {
15
- throw new Error('stream-id is required for pull');
16
- }
17
16
  // Resolved up front (throws if missing) so a missing path fails fast, before any chain
18
17
  // work — see resolvePath()'s own comment for why this is never defaulted to cwd.
19
18
  const destPath = resolvePath(args.path);
20
19
 
20
+ // With --manifest and no id, a folder pulled/pushed before already has one recorded —
21
+ // no need to require it again.
22
+ await discoverStreamId(args, destPath);
23
+ if (!args.streamId) {
24
+ throw new Error('stream-id is required for pull');
25
+ }
26
+
21
27
  const client = makeChainClient(args);
22
28
 
23
29
  const mn = new Mnemonad({
@@ -35,7 +41,7 @@ export async function pull(args) {
35
41
  await unlockStream(mn, { password: args.password, signer: client.walletClient });
36
42
 
37
43
  const version = args.version != null ? Number(args.version) : undefined;
38
- console.log('restoring', args.streamId, version != null ? 'at version ' + version : '(latest)', '...');
44
+ console.log('restoring', formatStreamId(args.streamId), version != null ? 'at version ' + version : '(latest)', '...');
39
45
 
40
46
  let folder;
41
47
  try {
@@ -56,7 +62,8 @@ export async function pull(args) {
56
62
  if (args.manifest) {
57
63
  await mn.initialize();
58
64
  const pulledVersion = version ?? mn.length;
59
- await writeManifest(destPath, { streamId: args.streamId, version: pulledVersion, files: newFiles });
65
+ // Canonical form stored — see push.js's own writeManifest call for why.
66
+ await writeManifest(destPath, { streamId: formatStreamId(args.streamId), version: pulledVersion, files: newFiles });
60
67
  }
61
68
 
62
69
  console.log(' ', stats.written, 'written,', stats.skipped, 'unchanged,',
@@ -15,12 +15,20 @@ import {
15
15
  unlockStream,
16
16
  resolveEncryptMethod,
17
17
  resolveOffloadParams,
18
+ formatStreamId,
19
+ streamIdsEqual,
20
+ discoverStreamId,
18
21
  } from './shared.js';
19
22
 
20
23
  export async function push(args) {
24
+ const destPath = resolvePath(args.path, { mustExist: true });
25
+
26
+ // With --manifest and no id, a folder pushed before already has one recorded — reuse it
27
+ // rather than falling into the "no id" branch below and creating a second, unrelated
28
+ // stream for the same folder.
29
+ await discoverStreamId(args, destPath);
21
30
  let streamId = args.streamId;
22
31
 
23
- const destPath = resolvePath(args.path);
24
32
  const fsFolder = new FSFolder(destPath, makeExcludes(args));
25
33
 
26
34
  const counts = await countTree(fsFolder);
@@ -29,7 +37,7 @@ export async function push(args) {
29
37
  if (args.manifest) {
30
38
  const manifest = await readManifest(destPath);
31
39
  const localFiles = await hashLocalTree(fsFolder);
32
- if (streamId && manifest && manifest.streamId === streamId && manifest.files) {
40
+ if (streamId && manifest && streamIdsEqual(manifest.streamId, streamId) && manifest.files) {
33
41
  if (hashMapsEqual(localFiles, manifest.files)) {
34
42
  console.log(' no changes since last pull, nothing to push');
35
43
  return;
@@ -73,7 +81,7 @@ export async function push(args) {
73
81
  console.log('creating new stream on', args.chain, how, '...');
74
82
  mn = await Mnemonad.create(createParams);
75
83
  streamId = mn.id.toString();
76
- console.log(' created:', streamId);
84
+ console.log(' created:', formatStreamId(streamId));
77
85
  } else {
78
86
  mn = new Mnemonad({ ...baseParams, id: streamId });
79
87
  await assertStreamOwner(mn, client.account.address);
@@ -83,7 +91,7 @@ export async function push(args) {
83
91
  }
84
92
  }
85
93
 
86
- console.log('syncing ./ →', streamId);
94
+ console.log('syncing ./ →', formatStreamId(streamId));
87
95
 
88
96
  const monadSync = new MonadSync({ mnemonad: mn, compress: args.compress || 'gzip' });
89
97
 
@@ -118,10 +126,13 @@ export async function push(args) {
118
126
  const patchType = args.forceSnapshot || existingVersions === 0 ? 'full snapshot' : 'diff';
119
127
  const compressedNote = args.compress !== false ? ', gzip compressed' : '';
120
128
  console.log(' version', result.version, 'pushed (' + patchType + compressedNote + ')');
121
- console.log(' stream:', streamId);
129
+ console.log(' stream:', formatStreamId(streamId));
122
130
 
123
131
  if (args.manifest) {
124
132
  const localFiles = await hashLocalTree(fsFolder);
125
- await writeManifest(destPath, { streamId, version: result.version, files: localFiles });
133
+ // Stored canonical (see formatStreamId) rather than whatever form the caller typed —
134
+ // so a manifest written by a hex-id push and read back by a decimal-id one (or vice
135
+ // versa) still matches; streamIdsEqual() above is the belt to this suspenders.
136
+ await writeManifest(destPath, { streamId: formatStreamId(streamId), version: result.version, files: localFiles });
126
137
  }
127
138
  }
@@ -1,4 +1,5 @@
1
1
  import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { createHash } from 'node:crypto';
4
5
  import { homedir } from 'node:os';
@@ -56,15 +57,112 @@ export function resolveOffloadParams(args, account) {
56
57
  * `p` is missing — every command that touches a folder requires the caller to name it
57
58
  * explicitly, so a typo'd or omitted `path` never silently operates on whatever directory
58
59
  * the CLI happened to be run from.
60
+ *
61
+ * `mustExist` additionally requires the folder to be there already. Every command that
62
+ * *reads* the local folder passes it, because the alternative is worse than an error in
63
+ * both directions: `push`/`compact` used to surface a raw ENOENT stack trace from deep
64
+ * inside `readdir`, and `diff` used to treat a missing folder as an empty one and report
65
+ * every file in the stream as locally deleted — which reads as "your data is gone" rather
66
+ * than "you typed the wrong path". `pull` is the one command that leaves it off, since
67
+ * creating the destination is exactly its job.
68
+ *
69
+ * @param {?string} p
70
+ * @param {Object} [options]
71
+ * @param {boolean} [options.mustExist=false]
59
72
  */
60
- export function resolvePath(p) {
73
+ export function resolvePath(p, { mustExist = false } = {}) {
61
74
  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;
75
+ throw userError('a folder path is required (e.g. `mnemonad <command> ... /path/to/folder`)');
76
+ }
77
+
78
+ const resolved = p.startsWith('~') ? join(homedir(), p.slice(1)) : resolve(p);
79
+ if (!mustExist) return resolved;
80
+
81
+ const stat = statOrNull(resolved);
82
+ if (!stat) {
83
+ throw userError(
84
+ `no such folder: ${resolved}\n` +
85
+ ' Create it first, or use `mnemonad pull <stream-id> <path>` to fetch one into it.'
86
+ );
87
+ }
88
+ if (!stat.isDirectory()) {
89
+ throw userError(`not a folder: ${resolved}\n This command syncs a directory, not a single file.`);
90
+ }
91
+ return resolved;
92
+ }
93
+
94
+ /** @returns {?import('node:fs').Stats} null when the path doesn't exist or isn't readable. */
95
+ function statOrNull(path) {
96
+ try {
97
+ return statSync(path);
98
+ } catch {
99
+ return null;
65
100
  }
66
- if (p.startsWith('~')) return join(homedir(), p.slice(1));
67
- return resolve(p);
101
+ }
102
+
103
+ /** Whether `path` is an existing directory. For a command that reports on one rather than
104
+ * refusing to run without it (see info.js). */
105
+ export function folderExists(path) {
106
+ return statOrNull(path)?.isDirectory() === true;
107
+ }
108
+
109
+ /**
110
+ * Canonical display form for a stream id: `0x` + the full 32-byte token id, zero-padded,
111
+ * lowercase. Matches the dApp's own formatting (explorer/src/includes/formatStreamId.js),
112
+ * unelided — a CLI command needs the whole id to actually reuse, where the dApp's UI only
113
+ * needs enough of it to recognize at a glance.
114
+ *
115
+ * Takes whatever a stream id shows up as in this codebase — a bigint (`mn.id`), or a raw
116
+ * CLI arg that's either a decimal or a `0x`-hex string — since `BigInt()` parses all three
117
+ * natively; see bin/mnemonad.js's own `isStreamId` for why both string forms are accepted
118
+ * as input in the first place.
119
+ *
120
+ * @param {bigint|string} id
121
+ * @returns {string}
122
+ */
123
+ export function formatStreamId(id) {
124
+ return '0x' + BigInt(id).toString(16).padStart(64, '0');
125
+ }
126
+
127
+ /**
128
+ * Whether two stream ids refer to the same token, regardless of which form either was
129
+ * written in (decimal, hex, differently-padded hex) — a plain `===` on the raw strings
130
+ * would wrongly call `123` and `0x7b` different streams.
131
+ */
132
+ export function streamIdsEqual(a, b) {
133
+ if (a == null || b == null) return a === b;
134
+ return BigInt(a) === BigInt(b);
135
+ }
136
+
137
+ /** Networks a qualified stream id can name — see `parseQualifiedStreamId` below. Kept in
138
+ * sync by hand with `chainClient.js`'s own `CHAINS` map (which also lists `local`, never
139
+ * meaningful here — no URL is ever built with it) and with the explorer's
140
+ * `src/settings.js` `NETWORKS` map, the source these prefixes actually come from. */
141
+ export const NETWORK_PREFIXES = ['testnet', 'mainnet'];
142
+
143
+ /**
144
+ * Splits the network-qualified stream-id form the explorer's URLs use — `<network>-0x<hex>`
145
+ * (or `<network>-<decimal>`), e.g. `testnet-0x648f18…` — into its network and bare id. Same
146
+ * split the explorer's own `streamRouteId()`/`parseStreamRouteId()`
147
+ * (`explorer/src/includes/streamRoute.js`) do, so an id copied straight out of a
148
+ * `/stream/<id>` link parses here too, instead of failing `BigInt()` on the whole string.
149
+ *
150
+ * Returns null for anything that isn't this exact shape — an unqualified id, a malformed
151
+ * one, or a prefix that isn't a known network (not a stream on some network this build
152
+ * hasn't heard of, just not this form).
153
+ *
154
+ * @param {string} s
155
+ * @returns {{network: string, id: string}|null}
156
+ */
157
+ export function parseQualifiedStreamId(s) {
158
+ if (typeof s !== 'string') return null;
159
+ const dash = s.indexOf('-');
160
+ if (dash === -1) return null;
161
+ const prefix = s.slice(0, dash).toLowerCase();
162
+ if (!NETWORK_PREFIXES.includes(prefix)) return null;
163
+ const rest = s.slice(dash + 1);
164
+ if (!/^0x[0-9a-fA-F]+$/.test(rest) && !/^\d+$/.test(rest)) return null;
165
+ return { network: prefix, id: rest };
68
166
  }
69
167
 
70
168
  export function formatBytes(bytes) {
@@ -107,9 +205,10 @@ export async function assertStreamOwner(mn, signerAddress) {
107
205
  if (getAddress(owner) === getAddress(signerAddress)) return;
108
206
 
109
207
  const err = new Error(
110
- `stream ${mn.id} is owned by ${owner}\n` +
208
+ `stream ${formatStreamId(mn.id)} is owned by ${owner}\n` +
111
209
  ` but you are signing as ${signerAddress}.\n` +
112
- ` Nothing was uploaded. Use the owner's key (--key / --phrase / MNEMONAD_KEY),\n` +
210
+ ` Nothing was uploaded. Use the owner's key (--key, --phrase, or the MNEMONAD_KEY\n` +
211
+ ` env var, set to the private key),\n` +
113
212
  ` or run push without a stream id to create your own stream.`
114
213
  );
115
214
  err._isUserError = true;
@@ -199,7 +298,7 @@ export async function unlockStream(mn, credentials = {}) {
199
298
  if (!signer) {
200
299
  throw userError(
201
300
  'This stream is encrypted to its owner\'s wallet. Provide the owner\'s key with\n' +
202
- ' --key / --phrase / MNEMONAD_KEY to decrypt it.'
301
+ ' --key, --phrase, or the MNEMONAD_KEY env var (set it to the private key) to decrypt it.'
203
302
  );
204
303
  }
205
304
  await mn.unlockWithSignature(signer);
@@ -244,6 +343,32 @@ export async function writeManifest(destPath, manifest) {
244
343
  await writeFile(join(destPath, '.mnemonad'), JSON.stringify(manifest, null, 2));
245
344
  }
246
345
 
346
+ /**
347
+ * Recovers a stream id from the folder's own `.mnemonad` manifest when `--manifest` was
348
+ * passed and no id was given on the command line — the whole point of writing the manifest
349
+ * is that the id doesn't have to be remembered or re-typed for every later command on the
350
+ * same folder. Mutates `args.streamId` in place (every call site reads it back off `args`
351
+ * afterward) and is a no-op whenever an id was already given or `--manifest` wasn't passed —
352
+ * so it's safe to call unconditionally right after resolving the folder path.
353
+ *
354
+ * Silent when there's nothing to find (no `.mnemonad` yet, e.g. a folder's very first push,
355
+ * or a manifest with no `streamId`) — the caller's own "stream-id is required" error covers
356
+ * that case with the wording appropriate to its command.
357
+ *
358
+ * @param {Object} args - parsed CLI args, mutated in place
359
+ * @param {string} destPath - resolved folder path to look for `.mnemonad` in
360
+ * @returns {Promise<?string>} the resolved stream id, or whatever `args.streamId` already was
361
+ */
362
+ export async function discoverStreamId(args, destPath) {
363
+ if (args.streamId || !args.manifest) return args.streamId;
364
+ const manifest = await readManifest(destPath);
365
+ if (manifest?.streamId) {
366
+ console.log(' using stream id from .mnemonad manifest:', formatStreamId(manifest.streamId));
367
+ args.streamId = manifest.streamId;
368
+ }
369
+ return args.streamId;
370
+ }
371
+
247
372
  export const DISK_EXCLUDES = new Set(['.mnemonad', '.git', '.DS_Store', 'node_modules']);
248
373
 
249
374
  export async function hashDiskTree(dirPath, prefix = []) {
@@ -14,6 +14,8 @@ import {
14
14
  assertStreamOwner,
15
15
  unlockStream,
16
16
  resolveOffloadParams,
17
+ formatStreamId,
18
+ discoverStreamId,
17
19
  } from './shared.js';
18
20
 
19
21
  function timestamp() {
@@ -27,9 +29,12 @@ function hashesEqual(a, b) {
27
29
  }
28
30
 
29
31
  export async function watch(args) {
30
- if (!args.streamId) throw new Error('stream-id is required for watch');
32
+ const cwd = resolvePath(args.path, { mustExist: true });
31
33
 
32
- const cwd = resolvePath(args.path);
34
+ // With --manifest and no id, a folder pulled/pushed before already has one recorded —
35
+ // no need to require it again.
36
+ await discoverStreamId(args, cwd);
37
+ if (!args.streamId) throw new Error('stream-id is required for watch');
33
38
  const debounceMs = args.debounce ?? 5000;
34
39
  const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
35
40
  const pushEnabled = !args.pullOnly;
@@ -70,7 +75,7 @@ export async function watch(args) {
70
75
  let debounceTimer = null;
71
76
 
72
77
  console.log(`watching ${formatPath(cwd)}`);
73
- console.log(` stream: ${args.streamId}`);
78
+ console.log(` stream: ${formatStreamId(args.streamId)}`);
74
79
  console.log(` remote version: ${remoteVersion}`);
75
80
  console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
76
81
  console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "mnemonad-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI to sync local folders to versioned, diffed on-chain streams on Monad, backed by Mnemonad + monadsync.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jeka911/mnemonad.git",
8
+ "directory": "cli"
9
+ },
5
10
  "type": "module",
6
11
  "license": "AGPL-3.0",
7
12
  "bin": {