mnemonad-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +325 -0
- package/bin/mnemonad.js +243 -0
- package/lib/FSFile.js +25 -0
- package/lib/FSFolder.js +39 -0
- package/lib/chainClient.js +112 -0
- package/lib/commands/compact.js +85 -0
- package/lib/commands/diff.js +112 -0
- package/lib/commands/index.js +6 -0
- package/lib/commands/info.js +123 -0
- package/lib/commands/pull.js +64 -0
- package/lib/commands/push.js +127 -0
- package/lib/commands/shared.js +385 -0
- package/lib/commands/watch.js +183 -0
- package/lib/presignProvider.js +83 -0
- package/mnemonad.config.js +21 -0
- package/package.json +27 -0
|
@@ -0,0 +1,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,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
|
+
}
|