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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Monad's auth model is just a viem account, so no client wrapper class is needed here,
3
+ * only a couple of plain functions. Follows the exact pattern already established in
4
+ * `scripts/deploy.js` and `js/test/fixture.js`'s `makeClients()`: `viem/chains`'
5
+ * `monad`/`monadTestnet` + `privateKeyToAccount`/`mnemonicToAccount` +
6
+ * `createPublicClient`/`createWalletClient` with an `http()` transport.
7
+ */
8
+ import { createPublicClient, createWalletClient, defineChain, http } from 'viem';
9
+ import { privateKeyToAccount, mnemonicToAccount } from 'viem/accounts';
10
+ import { monad, monadTestnet } from 'viem/chains';
11
+
12
+ /**
13
+ * CLI `--chain` name -> { viem chain object, Mnemonad network shorthand for
14
+ * `contractAddress` }. The two disagree on the mainnet name for the same reason
15
+ * `scripts/deploy.js`'s own `IDS_KEY` map does: `monad` matches viem/chains, `monadMainnet`
16
+ * is what `js/ids.js` already uses — reconciled here rather than renaming either side.
17
+ */
18
+ const CHAINS = {
19
+ testnet: { chain: monadTestnet, contractAddress: 'monadTestnet' },
20
+ mainnet: { chain: monad, contractAddress: 'monadMainnet' },
21
+ };
22
+
23
+ /**
24
+ * `--chain local` is not a real Mnemonad deployment — it's for running against a local
25
+ * Hardhat node (`js/test/fixture.js`'s own `startNode()`, same chain id 31337 that node
26
+ * defaults to), used by this package's own tests and by manual smoke-testing during
27
+ * development. Unlike `testnet`/`mainnet`, its viem chain object depends on `--rpc-url`
28
+ * (there's no fixed default endpoint for "some local node"), and it has no known
29
+ * `contractAddress` shorthand — the caller must always pass `--contract-address` for a
30
+ * freshly-deployed local registry, since `js/ids.js` only lists testnet/mainnet.
31
+ */
32
+ function localChain(rpcUrl) {
33
+ if (!rpcUrl) {
34
+ throw new Error("--chain local requires --rpc-url pointing at the local node");
35
+ }
36
+ return defineChain({
37
+ id: 31337,
38
+ name: 'Hardhat',
39
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
40
+ rpcUrls: { default: { http: [rpcUrl] } },
41
+ });
42
+ }
43
+
44
+ function normalizeKey(key) {
45
+ return key.startsWith('0x') ? key : `0x${key}`;
46
+ }
47
+
48
+ function resolveAccount(args) {
49
+ const key = args.key || process.env.MNEMONAD_KEY;
50
+ if (key) return privateKeyToAccount(normalizeKey(key));
51
+ if (args.phrase) return mnemonicToAccount(args.phrase);
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Resolves `--chain`/`--rpc-url` into a viem chain + the Mnemonad network shorthand
57
+ * `contractAddress` accepts directly, and `--key`/`--phrase`/`MNEMONAD_KEY` into an
58
+ * optional signing account. Never throws for a missing key — read-only commands (`pull`,
59
+ * `info`, `diff`) are fine without one; commands that need to sign call
60
+ * `requireWalletClient` themselves, so the "no key" error only surfaces where it's
61
+ * actually required.
62
+ *
63
+ * @param {Object} args - parsed CLI args: `chain`, `rpcUrl`, `key`, `phrase`, `contractAddress`
64
+ * @returns {{
65
+ * publicClient: Object,
66
+ * walletClient: ?Object,
67
+ * account: ?Object,
68
+ * contractAddress: string,
69
+ * chain: Object,
70
+ * }}
71
+ */
72
+ export function makeChainClient(args) {
73
+ let chain, contractAddress;
74
+ if (args.chain === 'local') {
75
+ // Dev/test only — see localChain() above.
76
+ chain = localChain(args.rpcUrl);
77
+ contractAddress = args.contractAddress;
78
+ if (!contractAddress) {
79
+ throw new Error('--chain local requires --contract-address (no known deployment to default to)');
80
+ }
81
+ } else {
82
+ const known = CHAINS[args.chain];
83
+ if (!known) {
84
+ throw new Error(`unknown --chain '${args.chain}', expected one of ${[...Object.keys(CHAINS), 'local'].join(', ')}`);
85
+ }
86
+ chain = known.chain;
87
+ // --contract-address overrides the network's own known deployment — mainly useful
88
+ // for pointing at a different/newer registry without needing a code change to
89
+ // js/ids.js first.
90
+ contractAddress = args.contractAddress || known.contractAddress;
91
+ }
92
+
93
+ // http(undefined) falls back to the chain's own default RPC URL — no need to clone or
94
+ // mutate the chain object just to point at a different endpoint.
95
+ const transport = http(args.rpcUrl);
96
+ const publicClient = createPublicClient({ chain, transport });
97
+
98
+ const account = resolveAccount(args);
99
+ const walletClient = account ? createWalletClient({ chain, transport, account }) : null;
100
+
101
+ return { publicClient, walletClient, account, contractAddress, chain };
102
+ }
103
+
104
+ /**
105
+ * Fails fast with an actionable message when a command needs to sign (push, watch with
106
+ * push enabled, compact) but no key was configured.
107
+ */
108
+ export function requireWalletClient(client) {
109
+ if (!client.walletClient) {
110
+ throw new Error('No signing key. Use --key, --phrase, or MNEMONAD_KEY env var');
111
+ }
112
+ }
@@ -0,0 +1,85 @@
1
+ import Mnemonad from 'mnemonad';
2
+ import { MonadSync } from 'monadsync';
3
+ import { FSFolder } from '../FSFolder.js';
4
+ import { makeChainClient, requireWalletClient } from '../chainClient.js';
5
+ import {
6
+ resolvePath,
7
+ makeExcludes,
8
+ formatBytes,
9
+ countTree,
10
+ assertStreamOwner,
11
+ unlockStream,
12
+ resolveOffloadParams,
13
+ } from './shared.js';
14
+
15
+ /**
16
+ * Monad has no rebate/refund concept — truncating a stream's history here gives back no
17
+ * gas, only a shorter replay for future `restore()`/`initialize()` calls. This drops old
18
+ * history, then forces a full snapshot, without pretending there's a cost refund attached
19
+ * to it.
20
+ */
21
+ export async function compact(args) {
22
+ if (!args.streamId) {
23
+ throw new Error('stream-id is required for compact');
24
+ }
25
+
26
+ const destPath = resolvePath(args.path);
27
+ const fsFolder = new FSFolder(destPath, makeExcludes(args));
28
+
29
+ const counts = await countTree(fsFolder);
30
+ console.log(' scanning...', counts.files, 'files,', counts.folders, 'folders');
31
+
32
+ const client = makeChainClient(args);
33
+ requireWalletClient(client);
34
+
35
+ // No `password` at construction — see shared.js's unlockStream() for why.
36
+ const mn = new Mnemonad({
37
+ publicClient: client.publicClient,
38
+ walletClient: client.walletClient,
39
+ contractAddress: client.contractAddress,
40
+ id: args.streamId,
41
+ ...resolveOffloadParams(args, client.account),
42
+ });
43
+
44
+ await assertStreamOwner(mn, client.account.address);
45
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
46
+
47
+ console.log('step 1: truncating existing history...');
48
+ const droppedCount = mn.length - mn.firstAvailableIndex;
49
+ if (droppedCount > 0) {
50
+ // Drops everything up to (not including) the stream's current length — the
51
+ // upcoming full-snapshot push becomes the new sole baseline for restore/replay.
52
+ await mn.truncateFrom(mn.length);
53
+ console.log(' dropped', droppedCount, 'history entries');
54
+ } else {
55
+ console.log(' nothing to drop (stream is empty)');
56
+ }
57
+
58
+ console.log('step 2: pushing local state as fresh snapshot...');
59
+
60
+ const monadSync = new MonadSync({ mnemonad: mn, compress: args.compress || 'gzip' });
61
+
62
+ // Skip monadSync's own replay entirely: compact always pushes a full snapshot, so
63
+ // there's nothing to replay against. Same escape hatch monadsync/README.md's
64
+ // "Repairing a corrupt chain" section documents.
65
+ mn.reInitialize();
66
+ await mn.initialize();
67
+ monadSync._isInitialized = true;
68
+ monadSync._lastSnapshot = null;
69
+ monadSync._replayedCount = mn.length;
70
+
71
+ const result = await monadSync.push(fsFolder);
72
+
73
+ mn.reInitialize();
74
+ await mn.initialize();
75
+
76
+ console.log(' pushed version', result.version);
77
+
78
+ console.log('\ncompact complete:');
79
+ console.log(' new version: ', result.version);
80
+ console.log(' new on-chain size:', formatBytes(mn.binaryLength));
81
+ if (droppedCount > 0) {
82
+ console.log(' future restore()/initialize() no longer replay the', droppedCount, 'dropped entries —',
83
+ 'faster reads, not a cost refund.');
84
+ }
85
+ }
@@ -0,0 +1,112 @@
1
+ import Mnemonad from 'mnemonad';
2
+ import { MonadSync } from 'monadsync';
3
+ import { FSFolder } from '../FSFolder.js';
4
+ import { makeChainClient } from '../chainClient.js';
5
+ import {
6
+ resolvePath,
7
+ makeExcludes,
8
+ formatPath,
9
+ hashLocalTree,
10
+ unlockStream,
11
+ } from './shared.js';
12
+
13
+ export function compareFileMaps(localFiles, remoteFiles) {
14
+ const added = [];
15
+ const modified = [];
16
+ const deleted = [];
17
+
18
+ for (const path of Object.keys(localFiles)) {
19
+ if (!(path in remoteFiles)) {
20
+ added.push(path);
21
+ } else if (localFiles[path] !== remoteFiles[path]) {
22
+ modified.push(path);
23
+ }
24
+ }
25
+ for (const path of Object.keys(remoteFiles)) {
26
+ if (!(path in localFiles)) {
27
+ deleted.push(path);
28
+ }
29
+ }
30
+
31
+ added.sort();
32
+ modified.sort();
33
+ deleted.sort();
34
+
35
+ return { added, modified, deleted };
36
+ }
37
+
38
+ export async function diff(args) {
39
+ if (!args.streamId) {
40
+ throw new Error('stream-id is required for diff');
41
+ }
42
+ // Resolved up front (throws if missing) so a missing path fails fast, before any chain
43
+ // work — see resolvePath()'s own comment for why this is never defaulted to cwd.
44
+ const destPath = resolvePath(args.path);
45
+
46
+ const client = makeChainClient(args);
47
+
48
+ const mn = new Mnemonad({
49
+ publicClient: client.publicClient,
50
+ walletClient: client.walletClient,
51
+ contractAddress: client.contractAddress,
52
+ id: args.streamId,
53
+ gatewayUrl: args.gatewayUrl,
54
+ });
55
+ const monadSync = new MonadSync({ mnemonad: mn });
56
+
57
+ // See shared.js's unlockStream() for why `password` isn't passed to the constructor
58
+ // above — this fetches the stream's own already-published salt instead of deriving a
59
+ // key from a fresh one.
60
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
61
+ const totalVersions = mn.length;
62
+
63
+ if (totalVersions === 0) {
64
+ console.log('stream', args.streamId, 'has no versions yet — nothing to diff');
65
+ return;
66
+ }
67
+
68
+ const version = args.version != null ? Number(args.version) : totalVersions;
69
+ if (!Number.isInteger(version) || version < 1 || version > totalVersions) {
70
+ throw new Error('version must be between 1 and ' + totalVersions);
71
+ }
72
+
73
+ console.log('diff:', formatPath(destPath), 'vs', args.streamId, '(version ' + version + ' of ' + totalVersions + ')');
74
+ console.log(' restoring chain version...');
75
+
76
+ let remoteFolder;
77
+ try {
78
+ remoteFolder = await monadSync.restore(version === totalVersions ? undefined : version);
79
+ } catch (err) {
80
+ if (err.code === 'KEK_MISMATCH' || err.message?.includes('decrypt') || err.message?.includes('invalid tag')) {
81
+ throw new Error('Could not decrypt this stream: ' + err.message);
82
+ }
83
+ throw err;
84
+ }
85
+
86
+ const remoteFiles = await hashLocalTree(remoteFolder);
87
+ const fsFolder = new FSFolder(destPath, makeExcludes(args));
88
+ let localFiles = {};
89
+ try {
90
+ localFiles = await hashLocalTree(fsFolder);
91
+ } catch {
92
+ // local folder missing or unreadable — treat as empty
93
+ }
94
+
95
+ const { added, modified, deleted } = compareFileMaps(localFiles, remoteFiles);
96
+ const total = added.length + modified.length + deleted.length;
97
+
98
+ if (total === 0) {
99
+ console.log(' no changes — local folder matches version', version);
100
+ return;
101
+ }
102
+
103
+ console.log('\nlocal changes vs chain (what push would apply):');
104
+ for (const path of added) console.log(' added: ', path);
105
+ for (const path of modified) console.log(' modified: ', path);
106
+ for (const path of deleted) console.log(' deleted: ', path);
107
+
108
+ console.log('\n ' + total + ' change(s): ' +
109
+ added.length + ' added, ' +
110
+ modified.length + ' modified, ' +
111
+ deleted.length + ' deleted');
112
+ }
@@ -0,0 +1,6 @@
1
+ export { push } from './push.js';
2
+ export { pull } from './pull.js';
3
+ export { info } from './info.js';
4
+ export { diff } from './diff.js';
5
+ export { watch } from './watch.js';
6
+ export { compact } from './compact.js';
@@ -0,0 +1,123 @@
1
+ import Mnemonad, { kekMethodOf } from 'mnemonad';
2
+ import { MonadSync } from 'monadsync';
3
+ import { makeChainClient } from '../chainClient.js';
4
+ import {
5
+ resolvePath,
6
+ formatBytes,
7
+ localTreeHash,
8
+ unlockStream,
9
+ } from './shared.js';
10
+
11
+ export async function info(args) {
12
+ const client = makeChainClient(args);
13
+
14
+ console.log(' chain: ', args.chain);
15
+ console.log(' your wallet: ', client.account?.address || '(none)');
16
+
17
+ if (!args.streamId) {
18
+ return;
19
+ }
20
+
21
+ // No `password` passed to the constructor — see shared.js's unlockStream() for why:
22
+ // this instance doesn't yet know whether the stream is even encrypted, let alone its
23
+ // published salt, so it's unlocked explicitly below once isEncrypted() is known.
24
+ const mn = new Mnemonad({
25
+ publicClient: client.publicClient,
26
+ walletClient: client.walletClient,
27
+ contractAddress: client.contractAddress,
28
+ id: args.streamId,
29
+ gatewayUrl: args.gatewayUrl,
30
+ });
31
+
32
+ await mn.initialize();
33
+
34
+ const totalVersions = mn.length;
35
+
36
+ console.log('\nstream:', args.streamId);
37
+
38
+ const owner = await mn.owner().catch(() => null) || '(unknown)';
39
+ const isOwner = client.account && owner !== '(unknown)' && client.account.address.toLowerCase() === owner.toLowerCase();
40
+
41
+ const isEncrypted = await mn.isEncrypted();
42
+ const kekMethod = kekMethodOf({ kek: mn.streamMeta.kek, encrypted: isEncrypted });
43
+ // Unlike pull/diff (which have nothing useful to do at all without decrypting), info
44
+ // still has metadata worth showing on a stream it can't open — so this unlocks when the
45
+ // right credential happens to be at hand and otherwise just notes it, rather than
46
+ // throwing. `unlocked` then gates the local-version comparison further down.
47
+ let unlocked = false;
48
+ if (isEncrypted) {
49
+ try {
50
+ unlocked = await unlockStream(mn, { password: args.password, signer: client.walletClient });
51
+ } catch {
52
+ // Missing or wrong credential — reported below, not fatal for `info`.
53
+ }
54
+ }
55
+ console.log(' owner: ', isOwner ? owner + ' (you)' : owner);
56
+ console.log(
57
+ ' encrypted: ',
58
+ isEncrypted ? (kekMethod === 'password' ? 'yes (password)' : 'yes (owner wallet)') : 'no'
59
+ );
60
+ console.log(' versions: ', totalVersions);
61
+ console.log(' binary size: ', formatBytes(mn.binaryLength));
62
+ if (mn.firstAvailableIndex > 0) {
63
+ console.log(' history starts: ', 'index', mn.firstAvailableIndex, '(earlier history truncated — see `compact`)');
64
+ }
65
+
66
+ const destPath = resolvePath(args.path);
67
+ const localHash = await localTreeHash(destPath);
68
+
69
+ if (!localHash) {
70
+ console.log('\n local folder: empty (run pull to fetch)');
71
+ return;
72
+ }
73
+
74
+ // Same reasoning as pull.js/diff.js: an un-unlocked stream decrypts to nothing useful,
75
+ // so the tree-hash detection loop below would only ever compare garbage against garbage
76
+ // — better to say what's missing up front and stop.
77
+ if (isEncrypted && !unlocked) {
78
+ console.log(
79
+ kekMethod === 'password'
80
+ ? '\n (encrypted — provide --password to compare against the local folder)'
81
+ : '\n (encrypted to the owner\'s wallet — provide their --key / --phrase to\n' +
82
+ ' compare against the local folder)'
83
+ );
84
+ return;
85
+ }
86
+
87
+ const localHex = Buffer.from(localHash).toString('hex').slice(0, 12);
88
+ console.log('\n local tree hash: ', localHex + '...');
89
+ console.log(' detecting local version...');
90
+
91
+ const monadSync = new MonadSync({ mnemonad: mn });
92
+ let matchedVersion = null;
93
+
94
+ for (let v = totalVersions; v >= Math.max(mn.firstAvailableIndex, 1); v--) {
95
+ process.stdout.write(' checking version ' + v + '/' + totalVersions + '... \r');
96
+ let remoteHash;
97
+ try {
98
+ remoteHash = await monadSync.getTreeHash(v);
99
+ } catch (err) {
100
+ if (err.code === 'KEK_MISMATCH' || err.message?.includes('decrypt') || err.message?.includes('invalid tag')) {
101
+ throw new Error('Could not decrypt this stream: ' + err.message);
102
+ }
103
+ throw err;
104
+ }
105
+ if (localHash.length === remoteHash.length && localHash.every((b, i) => b === remoteHash[i])) {
106
+ matchedVersion = v;
107
+ break;
108
+ }
109
+ }
110
+
111
+ process.stdout.write(' \r');
112
+ if (matchedVersion !== null) {
113
+ console.log(' local matches: version', matchedVersion, 'of', totalVersions);
114
+ if (matchedVersion < totalVersions) {
115
+ console.log(' behind by: ', totalVersions - matchedVersion, 'version(s)');
116
+ } else {
117
+ console.log(' status: up to date');
118
+ }
119
+ } else {
120
+ console.log(' local matches: no known version (local modifications?)');
121
+ console.log(' latest remote: version', totalVersions);
122
+ }
123
+ }
@@ -0,0 +1,64 @@
1
+ import Mnemonad from 'mnemonad';
2
+ import { MonadSync } from 'monadsync';
3
+ import { makeChainClient } from '../chainClient.js';
4
+ import {
5
+ resolvePath,
6
+ formatBytes,
7
+ hashDiskTree,
8
+ syncMemoryFolderToDisk,
9
+ writeManifest,
10
+ unlockStream,
11
+ } from './shared.js';
12
+
13
+ export async function pull(args) {
14
+ if (!args.streamId) {
15
+ throw new Error('stream-id is required for pull');
16
+ }
17
+ // Resolved up front (throws if missing) so a missing path fails fast, before any chain
18
+ // work — see resolvePath()'s own comment for why this is never defaulted to cwd.
19
+ const destPath = resolvePath(args.path);
20
+
21
+ const client = makeChainClient(args);
22
+
23
+ const mn = new Mnemonad({
24
+ publicClient: client.publicClient,
25
+ walletClient: client.walletClient,
26
+ contractAddress: client.contractAddress,
27
+ id: args.streamId,
28
+ gatewayUrl: args.gatewayUrl,
29
+ });
30
+ const monadSync = new MonadSync({ mnemonad: mn });
31
+
32
+ // See unlockStream()'s own comment for why `password` isn't passed to the constructor
33
+ // above: reopening an existing encrypted stream needs its own already-published salt,
34
+ // which only unlockWithPassword() (called inside unlockStream) fetches correctly.
35
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
36
+
37
+ const version = args.version != null ? Number(args.version) : undefined;
38
+ console.log('restoring', args.streamId, version != null ? 'at version ' + version : '(latest)', '...');
39
+
40
+ let folder;
41
+ try {
42
+ folder = await monadSync.restore(version);
43
+ } catch (err) {
44
+ if (err.code === 'KEK_MISMATCH' || err.message?.includes('decrypt') || err.message?.includes('invalid tag')) {
45
+ // unlockStream() above already demanded the right *kind* of credential, so
46
+ // reaching here means the one supplied didn't actually open this stream.
47
+ throw new Error('Could not decrypt this stream: ' + err.message);
48
+ }
49
+ throw err;
50
+ }
51
+
52
+ const oldFiles = await hashDiskTree(destPath);
53
+
54
+ const { stats, newFiles } = await syncMemoryFolderToDisk(folder, destPath, oldFiles);
55
+
56
+ if (args.manifest) {
57
+ await mn.initialize();
58
+ const pulledVersion = version ?? mn.length;
59
+ await writeManifest(destPath, { streamId: args.streamId, version: pulledVersion, files: newFiles });
60
+ }
61
+
62
+ console.log(' ', stats.written, 'written,', stats.skipped, 'unchanged,',
63
+ stats.deleted, 'deleted', '(' + formatBytes(stats.bytes) + ')');
64
+ }
@@ -0,0 +1,127 @@
1
+ import Mnemonad from 'mnemonad';
2
+ import { MonadSync } from 'monadsync';
3
+ import { FSFolder } from '../FSFolder.js';
4
+ import { makeChainClient, requireWalletClient } from '../chainClient.js';
5
+ import {
6
+ resolvePath,
7
+ makeExcludes,
8
+ countTree,
9
+ localTreeHash,
10
+ readManifest,
11
+ writeManifest,
12
+ hashLocalTree,
13
+ hashMapsEqual,
14
+ assertStreamOwner,
15
+ unlockStream,
16
+ resolveEncryptMethod,
17
+ resolveOffloadParams,
18
+ } from './shared.js';
19
+
20
+ export async function push(args) {
21
+ let streamId = args.streamId;
22
+
23
+ const destPath = resolvePath(args.path);
24
+ const fsFolder = new FSFolder(destPath, makeExcludes(args));
25
+
26
+ const counts = await countTree(fsFolder);
27
+ console.log(' scanning...', counts.files, 'files,', counts.folders, 'folders');
28
+
29
+ if (args.manifest) {
30
+ const manifest = await readManifest(destPath);
31
+ const localFiles = await hashLocalTree(fsFolder);
32
+ if (streamId && manifest && manifest.streamId === streamId && manifest.files) {
33
+ if (hashMapsEqual(localFiles, manifest.files)) {
34
+ console.log(' no changes since last pull, nothing to push');
35
+ return;
36
+ }
37
+ }
38
+ }
39
+
40
+ const client = makeChainClient(args);
41
+ requireWalletClient(client);
42
+
43
+ const baseParams = {
44
+ publicClient: client.publicClient,
45
+ walletClient: client.walletClient,
46
+ contractAddress: client.contractAddress,
47
+ ...resolveOffloadParams(args, client.account),
48
+ };
49
+
50
+ // Validated up front, so a bad flag combination fails before any chain work — including
51
+ // on the push-to-existing path, where the encryption flags don't apply at all.
52
+ const encryptMethod = resolveEncryptMethod(args);
53
+
54
+ let mn;
55
+ if (!streamId) {
56
+ // The credential is only ever passed to the constructor *here*, on the instance
57
+ // calling `create()` itself: it generates and publishes whatever a later reader needs
58
+ // (a password salt, or the method marker), so there's nothing existing to fetch. See
59
+ // unlockStream()'s own comment for why every other path must NOT do this.
60
+ const createParams = { ...baseParams };
61
+ if (encryptMethod === 'password') {
62
+ createParams.encrypt = true;
63
+ createParams.password = args.password;
64
+ } else if (encryptMethod === 'wallet') {
65
+ createParams.encrypt = true;
66
+ // The same account that signs the create transaction also signs for the key —
67
+ // so this asks the wallet twice, and only this account can ever decrypt it.
68
+ createParams.signer = client.walletClient;
69
+ }
70
+ const how = encryptMethod === 'password' ? '(encrypted with a password)'
71
+ : encryptMethod === 'wallet' ? '(encrypted to ' + client.account.address + ')'
72
+ : '(public)';
73
+ console.log('creating new stream on', args.chain, how, '...');
74
+ mn = await Mnemonad.create(createParams);
75
+ streamId = mn.id.toString();
76
+ console.log(' created:', streamId);
77
+ } else {
78
+ mn = new Mnemonad({ ...baseParams, id: streamId });
79
+ await assertStreamOwner(mn, client.account.address);
80
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
81
+ if (args.encrypt) {
82
+ console.log(' note: --encrypt only applies when creating a new stream; pushing to an existing stream keeps its current encryption');
83
+ }
84
+ }
85
+
86
+ console.log('syncing ./ →', streamId);
87
+
88
+ const monadSync = new MonadSync({ mnemonad: mn, compress: args.compress || 'gzip' });
89
+
90
+ let existingVersions = 0;
91
+ if (args.forceSnapshot) {
92
+ console.log(' skipping chain replay — pushing full snapshot...');
93
+ await mn.initialize();
94
+ // See monadsync/README.md's "Repairing a corrupt chain" section — this is the
95
+ // documented escape hatch for forcing a full snapshot instead of a diff.
96
+ monadSync._isInitialized = true;
97
+ monadSync._lastSnapshot = null;
98
+ monadSync._replayedCount = mn.length;
99
+ } else {
100
+ await monadSync.initialize();
101
+ existingVersions = await monadSync.length();
102
+
103
+ if (streamId && existingVersions > 0 && !args.manifest) {
104
+ console.log(' comparing tree hashes...');
105
+ const localHash = await localTreeHash(destPath);
106
+ const remoteHash = await monadSync.getTreeHash(existingVersions);
107
+ if (localHash && remoteHash.length === localHash.length &&
108
+ localHash.every((b, i) => b === remoteHash[i])) {
109
+ console.log(' no changes, nothing to push');
110
+ return;
111
+ }
112
+ }
113
+ }
114
+
115
+ console.log(' building patch + pushing to chain...');
116
+ const result = await monadSync.push(fsFolder);
117
+
118
+ const patchType = args.forceSnapshot || existingVersions === 0 ? 'full snapshot' : 'diff';
119
+ const compressedNote = args.compress !== false ? ', gzip compressed' : '';
120
+ console.log(' version', result.version, 'pushed (' + patchType + compressedNote + ')');
121
+ console.log(' stream:', streamId);
122
+
123
+ if (args.manifest) {
124
+ const localFiles = await hashLocalTree(fsFolder);
125
+ await writeManifest(destPath, { streamId, version: result.version, files: localFiles });
126
+ }
127
+ }