@fizzyflow/wdoublesync_cli 1.0.2 → 1.0.3
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 +45 -14
- package/SKILL.md +297 -0
- package/bin/wdoublesync.js +45 -10
- package/lib/WalrusSealClient.js +377 -0
- package/lib/commands/index.js +5 -0
- package/lib/commands/info.js +113 -0
- package/lib/commands/pull.js +63 -0
- package/lib/commands/push.js +100 -0
- package/lib/commands/rebate.js +81 -0
- package/lib/commands/shared.js +237 -0
- package/lib/commands/watch.js +181 -0
- package/lib/commands.js +49 -15
- package/package.json +3 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import EndlessVector from '@fizzyflow/endless-vector';
|
|
2
|
+
import { WDoubleSync } from '@fizzyflow/wdoublesync';
|
|
3
|
+
import { CDCStore } from '@fizzyflow/doublesync';
|
|
4
|
+
import { DoubleSyncFSFolder } from '../DoubleSyncFSFolder.js';
|
|
5
|
+
import {
|
|
6
|
+
makeWalrusSealClient,
|
|
7
|
+
resolvePath,
|
|
8
|
+
makeExcludes,
|
|
9
|
+
formatBytes,
|
|
10
|
+
countTree,
|
|
11
|
+
} from './shared.js';
|
|
12
|
+
|
|
13
|
+
export async function rebate(args) {
|
|
14
|
+
if (!args.vectorId) {
|
|
15
|
+
throw new Error('vector-id is required for rebate');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const destPath = resolvePath(args.path);
|
|
19
|
+
const fsFolder = new DoubleSyncFSFolder(destPath, makeExcludes(args));
|
|
20
|
+
|
|
21
|
+
const counts = await countTree(fsFolder);
|
|
22
|
+
console.log(' scanning...', counts.files, 'files,', counts.folders, 'folders');
|
|
23
|
+
|
|
24
|
+
const wsc = await makeWalrusSealClient(args);
|
|
25
|
+
|
|
26
|
+
const evParams = {
|
|
27
|
+
suiClient: wsc.suiClient,
|
|
28
|
+
id: args.vectorId,
|
|
29
|
+
packageId: args.chain,
|
|
30
|
+
signAndExecuteTransaction: (tx) => wsc.signAndExecuteTransaction(tx),
|
|
31
|
+
walrusClient: wsc.walrusClient,
|
|
32
|
+
aggregatorUrl: wsc.aggregatorUrl,
|
|
33
|
+
senderAddress: wsc.suiMaster.address,
|
|
34
|
+
signer: wsc.suiMaster._keypair,
|
|
35
|
+
};
|
|
36
|
+
if (wsc.sealClient && wsc.suiMaster) {
|
|
37
|
+
evParams.sealClient = wsc.sealClient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ev = new EndlessVector(evParams);
|
|
41
|
+
await ev.initialize();
|
|
42
|
+
|
|
43
|
+
console.log('step 1: archiving history...');
|
|
44
|
+
await ev.archive();
|
|
45
|
+
console.log(' archived');
|
|
46
|
+
|
|
47
|
+
console.log('step 2: burning archives...');
|
|
48
|
+
await ev.burnArchive();
|
|
49
|
+
console.log(' burned');
|
|
50
|
+
|
|
51
|
+
// archive() and burnArchive() both call ev.reInitialize() — refetch fresh state
|
|
52
|
+
await ev.initialize();
|
|
53
|
+
|
|
54
|
+
console.log('step 3: pushing local state as fresh snapshot...');
|
|
55
|
+
|
|
56
|
+
const wdsync = new WDoubleSync({
|
|
57
|
+
endlessVector: ev,
|
|
58
|
+
compress: args.compress || 'gzip',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Skip wdsync.initialize() replay entirely: rebate always pushes a full snapshot,
|
|
62
|
+
// so there's no need to replay the existing (now partly burned) chain.
|
|
63
|
+
// _lastSnapshot = null forces a full snapshot; _isInitialized = true bypasses replay.
|
|
64
|
+
wdsync._senderStore = new CDCStore({ copyBytes: false });
|
|
65
|
+
wdsync._receiverMirror = new CDCStore();
|
|
66
|
+
wdsync._lastSnapshot = null;
|
|
67
|
+
wdsync._replayedCount = ev.length;
|
|
68
|
+
wdsync._isInitialized = true;
|
|
69
|
+
|
|
70
|
+
const result = await wdsync.push(fsFolder);
|
|
71
|
+
|
|
72
|
+
ev.reInitialize();
|
|
73
|
+
await ev.initialize();
|
|
74
|
+
|
|
75
|
+
console.log(' pushed version', result.version);
|
|
76
|
+
console.log(' [debug] after push: length=', ev.length, 'burned=', ev.burnedArchiveCount, 'version=', result.version);
|
|
77
|
+
|
|
78
|
+
console.log('\nrebate complete:');
|
|
79
|
+
console.log(' new version:', result.version);
|
|
80
|
+
console.log(' new on-chain size:', formatBytes(ev.binaryLength));
|
|
81
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
import { SuiMaster } from 'suidouble';
|
|
7
|
+
import { DoubleSync, CDCStore, DoubleSyncSnapshot } from '@fizzyflow/doublesync';
|
|
8
|
+
import WalrusSealClient from '../WalrusSealClient.js';
|
|
9
|
+
import { DoubleSyncFSFolder } from '../DoubleSyncFSFolder.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.wdoublesync', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
|
|
12
|
+
|
|
13
|
+
export function makeExcludes(args) {
|
|
14
|
+
if (!args.exclude) return undefined;
|
|
15
|
+
const extra = args.exclude.split(',').map(s => s.trim());
|
|
16
|
+
return [...DEFAULT_FS_EXCLUDES, ...extra];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolvePath(p) {
|
|
20
|
+
if (p.startsWith('~')) return join(homedir(), p.slice(1));
|
|
21
|
+
return resolve(p);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatBytes(bytes) {
|
|
25
|
+
if (bytes < 1024) return bytes + ' B';
|
|
26
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
27
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatPath(path) {
|
|
31
|
+
const home = homedir();
|
|
32
|
+
if (path.startsWith(home)) {
|
|
33
|
+
return '~' + path.slice(home.length);
|
|
34
|
+
}
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function makeWalrusSealClient(args) {
|
|
39
|
+
const params = { client: args.chain };
|
|
40
|
+
const key = args.key || process.env.WDOUBLESYNC_KEY;
|
|
41
|
+
if (key) {
|
|
42
|
+
params.privateKey = key;
|
|
43
|
+
} else if (args.phrase) {
|
|
44
|
+
params.phrase = args.phrase;
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error('No signing key. Use --key, --phrase, or WDOUBLESYNC_KEY env var');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const suiMaster = new SuiMaster(params);
|
|
50
|
+
await suiMaster.initialize();
|
|
51
|
+
|
|
52
|
+
const client = new WalrusSealClient({ network: args.chain, suiMaster });
|
|
53
|
+
await client.initialize();
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function makeReadOnlyWalrusSealClient(chain) {
|
|
58
|
+
const client = new WalrusSealClient({ network: chain });
|
|
59
|
+
await client.initialize();
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function makeClientForRead(args) {
|
|
64
|
+
const key = args.key || process.env.WDOUBLESYNC_KEY;
|
|
65
|
+
if (key || args.phrase) {
|
|
66
|
+
return makeWalrusSealClient(args);
|
|
67
|
+
}
|
|
68
|
+
return makeReadOnlyWalrusSealClient(args.chain);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function countTree(folder) {
|
|
72
|
+
let files = 0;
|
|
73
|
+
let folders = 0;
|
|
74
|
+
const children = await folder.list();
|
|
75
|
+
for (const child of children) {
|
|
76
|
+
if (child.getContent) {
|
|
77
|
+
files++;
|
|
78
|
+
} else {
|
|
79
|
+
folders++;
|
|
80
|
+
const sub = await countTree(child);
|
|
81
|
+
files += sub.files;
|
|
82
|
+
folders += sub.folders;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { files, folders };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hashContent(bytes) {
|
|
89
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function readManifest(destPath) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = await readFile(join(destPath, '.wdoublesync'), 'utf8');
|
|
95
|
+
return JSON.parse(raw);
|
|
96
|
+
} catch { return null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function writeManifest(destPath, manifest) {
|
|
100
|
+
await writeFile(join(destPath, '.wdoublesync'), JSON.stringify(manifest, null, 2));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const DISK_EXCLUDES = new Set(['.wdoublesync', '.git', '.DS_Store', 'node_modules']);
|
|
104
|
+
|
|
105
|
+
export async function hashDiskTree(dirPath, prefix = []) {
|
|
106
|
+
const files = {};
|
|
107
|
+
let entries;
|
|
108
|
+
try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return files; }
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (DISK_EXCLUDES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
111
|
+
const relParts = [...prefix, entry.name];
|
|
112
|
+
const relPath = relParts.join('/');
|
|
113
|
+
const fullPath = join(dirPath, entry.name);
|
|
114
|
+
if (entry.isFile()) {
|
|
115
|
+
const buf = await readFile(fullPath);
|
|
116
|
+
files[relPath] = hashContent(buf);
|
|
117
|
+
} else if (entry.isDirectory()) {
|
|
118
|
+
Object.assign(files, await hashDiskTree(fullPath, relParts));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return files;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function localTreeHash(dirPath) {
|
|
125
|
+
const fsFolder = new DoubleSyncFSFolder(dirPath);
|
|
126
|
+
const children = await fsFolder.list();
|
|
127
|
+
if (children.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
const sync = new DoubleSync();
|
|
130
|
+
const store = new CDCStore();
|
|
131
|
+
const snapshotBytes = await sync.buildSnapshot({ root: fsFolder, store });
|
|
132
|
+
return new DoubleSyncSnapshot(snapshotBytes).treeHash;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function syncMemoryFolderToDisk(folder, destPath, oldFiles) {
|
|
136
|
+
const newFiles = {};
|
|
137
|
+
const newDirs = new Set();
|
|
138
|
+
const stats = { written: 0, skipped: 0, deleted: 0, folders: 0, bytes: 0 };
|
|
139
|
+
|
|
140
|
+
await walkAndSync(folder, destPath, [], oldFiles, newFiles, newDirs, stats);
|
|
141
|
+
|
|
142
|
+
if (oldFiles) {
|
|
143
|
+
for (const relPath of Object.keys(oldFiles)) {
|
|
144
|
+
if (!(relPath in newFiles)) {
|
|
145
|
+
const absPath = join(destPath, ...relPath.split('/'));
|
|
146
|
+
await rm(absPath, { force: true });
|
|
147
|
+
stats.deleted++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await removeStaleDirectories(destPath, newDirs);
|
|
153
|
+
|
|
154
|
+
return { stats, newFiles };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hashMapsEqual(a, b) {
|
|
158
|
+
const keysA = Object.keys(a).sort();
|
|
159
|
+
const keysB = Object.keys(b).sort();
|
|
160
|
+
if (keysA.length !== keysB.length) return false;
|
|
161
|
+
return keysA.every((k, i) => k === keysB[i] && a[k] === b[k]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function hashLocalTree(fsFolder, prefix = []) {
|
|
165
|
+
const files = {};
|
|
166
|
+
const children = await fsFolder.list();
|
|
167
|
+
for (const child of children) {
|
|
168
|
+
const relParts = [...prefix, child.name];
|
|
169
|
+
const relPath = relParts.join('/');
|
|
170
|
+
if (child.getContent) {
|
|
171
|
+
const content = await child.getContent();
|
|
172
|
+
files[relPath] = hashContent(content);
|
|
173
|
+
} else {
|
|
174
|
+
Object.assign(files, await hashLocalTree(child, relParts));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return files;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function walkAndSync(folder, destPath, prefix, oldFiles, newFiles, newDirs, stats) {
|
|
181
|
+
const children = await folder.list();
|
|
182
|
+
|
|
183
|
+
for (const child of children) {
|
|
184
|
+
const relParts = [...prefix, child.name];
|
|
185
|
+
const relPath = relParts.join('/');
|
|
186
|
+
const absPath = join(destPath, child.name);
|
|
187
|
+
|
|
188
|
+
if (child.getContent) {
|
|
189
|
+
const content = await child.getContent();
|
|
190
|
+
const hash = hashContent(content);
|
|
191
|
+
newFiles[relPath] = hash;
|
|
192
|
+
|
|
193
|
+
if (oldFiles && oldFiles[relPath] === hash) {
|
|
194
|
+
stats.skipped++;
|
|
195
|
+
} else {
|
|
196
|
+
await writeFile(absPath, content);
|
|
197
|
+
stats.written++;
|
|
198
|
+
stats.bytes += content.length;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
await mkdir(absPath, { recursive: true });
|
|
202
|
+
newDirs.add(relPath);
|
|
203
|
+
stats.folders++;
|
|
204
|
+
await walkAndSync(child, absPath, relParts, oldFiles, newFiles, newDirs, stats);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function removeStaleDirectories(destPath, newDirs) {
|
|
210
|
+
let entries;
|
|
211
|
+
try { entries = await readdir(destPath, { withFileTypes: true }); } catch { return; }
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
if (!entry.isDirectory()) continue;
|
|
214
|
+
if (DISK_EXCLUDES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
215
|
+
const fullPath = join(destPath, entry.name);
|
|
216
|
+
if (!newDirs.has(entry.name)) {
|
|
217
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
218
|
+
} else {
|
|
219
|
+
await removeStaleSubdirs(fullPath, entry.name, newDirs);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function removeStaleSubdirs(dirPath, prefix, newDirs) {
|
|
225
|
+
let entries;
|
|
226
|
+
try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return; }
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (!entry.isDirectory()) continue;
|
|
229
|
+
const relPath = prefix + '/' + entry.name;
|
|
230
|
+
const fullPath = join(dirPath, entry.name);
|
|
231
|
+
if (!newDirs.has(relPath)) {
|
|
232
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
233
|
+
} else {
|
|
234
|
+
await removeStaleSubdirs(fullPath, relPath, newDirs);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { watch as fsWatch } from 'node:fs';
|
|
2
|
+
import EndlessVector from '@fizzyflow/endless-vector';
|
|
3
|
+
import { WDoubleSync } from '@fizzyflow/wdoublesync';
|
|
4
|
+
import { DoubleSyncFSFolder } from '../DoubleSyncFSFolder.js';
|
|
5
|
+
import {
|
|
6
|
+
makeWalrusSealClient,
|
|
7
|
+
makeClientForRead,
|
|
8
|
+
resolvePath,
|
|
9
|
+
makeExcludes,
|
|
10
|
+
formatPath,
|
|
11
|
+
localTreeHash,
|
|
12
|
+
hashDiskTree,
|
|
13
|
+
syncMemoryFolderToDisk,
|
|
14
|
+
DISK_EXCLUDES,
|
|
15
|
+
} from './shared.js';
|
|
16
|
+
|
|
17
|
+
function timestamp() {
|
|
18
|
+
return new Date().toTimeString().slice(0, 8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hashesEqual(a, b) {
|
|
22
|
+
if (a === null && b === null) return true;
|
|
23
|
+
if (a === null || b === null) return false;
|
|
24
|
+
return a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isEnsureLengthError(err) {
|
|
28
|
+
return err?.message?.includes('abort code: 98') ||
|
|
29
|
+
err?.message?.includes('ensure_length') ||
|
|
30
|
+
err?.message?.includes('EUnexpectedLength');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function watch(args) {
|
|
34
|
+
if (!args.vectorId) throw new Error('vector-id is required for watch');
|
|
35
|
+
|
|
36
|
+
const cwd = resolvePath(args.path);
|
|
37
|
+
const chain = args.chain;
|
|
38
|
+
const debounceMs = args.debounce ?? 5000;
|
|
39
|
+
const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
|
|
40
|
+
const pushEnabled = !args.pullOnly;
|
|
41
|
+
const pullEnabled = !args.pushOnly;
|
|
42
|
+
|
|
43
|
+
const excludes = makeExcludes(args);
|
|
44
|
+
|
|
45
|
+
const wsc = pushEnabled
|
|
46
|
+
? await makeWalrusSealClient(args)
|
|
47
|
+
: await makeClientForRead(args);
|
|
48
|
+
|
|
49
|
+
const evParams = {
|
|
50
|
+
suiClient: wsc.suiClient,
|
|
51
|
+
id: args.vectorId,
|
|
52
|
+
packageId: chain,
|
|
53
|
+
walrusClient: wsc.walrusClient,
|
|
54
|
+
aggregatorUrl: wsc.aggregatorUrl,
|
|
55
|
+
};
|
|
56
|
+
if (wsc.sealClient && wsc.suiMaster) {
|
|
57
|
+
evParams.sealClient = wsc.sealClient;
|
|
58
|
+
evParams.senderAddress = wsc.suiMaster.address;
|
|
59
|
+
evParams.signer = wsc.suiMaster._keypair;
|
|
60
|
+
if (pushEnabled) {
|
|
61
|
+
evParams.signAndExecuteTransaction = (tx) => wsc.signAndExecuteTransaction(tx);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ev = new EndlessVector({ ...evParams });
|
|
66
|
+
const wdsync = new WDoubleSync({
|
|
67
|
+
endlessVector: ev,
|
|
68
|
+
compress: args.compress || 'gzip',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await wdsync.initialize();
|
|
72
|
+
|
|
73
|
+
let remoteVersion = ev.length;
|
|
74
|
+
let lastSyncedHash = await localTreeHash(cwd);
|
|
75
|
+
let pushInFlight = false;
|
|
76
|
+
let debounceTimer = null;
|
|
77
|
+
let dirty = false;
|
|
78
|
+
|
|
79
|
+
console.log(`watching ${formatPath(cwd)}`);
|
|
80
|
+
console.log(` vector: ${args.vectorId}`);
|
|
81
|
+
console.log(` remote version: ${remoteVersion}`);
|
|
82
|
+
console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
|
|
83
|
+
console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
|
|
84
|
+
console.log('press Ctrl+C to stop\n');
|
|
85
|
+
|
|
86
|
+
async function runPush() {
|
|
87
|
+
if (!pushEnabled || pushInFlight) return;
|
|
88
|
+
dirty = false;
|
|
89
|
+
const newHash = await localTreeHash(cwd);
|
|
90
|
+
if (hashesEqual(newHash, lastSyncedHash)) return;
|
|
91
|
+
|
|
92
|
+
pushInFlight = true;
|
|
93
|
+
try {
|
|
94
|
+
const fsFolder = new DoubleSyncFSFolder(cwd, excludes);
|
|
95
|
+
console.log(`[${timestamp()}] change detected — pushing...`);
|
|
96
|
+
const result = await wdsync.push(fsFolder);
|
|
97
|
+
lastSyncedHash = newHash;
|
|
98
|
+
remoteVersion = result.version;
|
|
99
|
+
console.log(`[${timestamp()}] pushed version ${result.version}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isEnsureLengthError(err)) {
|
|
102
|
+
console.log(`[${timestamp()}] conflict: remote was updated while pushing — skipping push, will pull on next poll`);
|
|
103
|
+
dirty = true;
|
|
104
|
+
} else {
|
|
105
|
+
console.error(`[${timestamp()}] push error:`, err.message);
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
pushInFlight = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runPoll() {
|
|
113
|
+
if (!pullEnabled || pushInFlight) return;
|
|
114
|
+
try {
|
|
115
|
+
ev.reInitialize();
|
|
116
|
+
await ev.initialize();
|
|
117
|
+
if (ev.length <= remoteVersion) return;
|
|
118
|
+
|
|
119
|
+
const newRemoteVersion = ev.length;
|
|
120
|
+
|
|
121
|
+
// Always resync wdsync when remote has advanced. Without this,
|
|
122
|
+
// wdsync._lastSnapshot stays at the old version while ev.length has
|
|
123
|
+
// advanced — the next push would build a diff against the wrong base
|
|
124
|
+
// snapshot, corrupting the chain.
|
|
125
|
+
wdsync.reInitialize();
|
|
126
|
+
|
|
127
|
+
const localNow = await localTreeHash(cwd);
|
|
128
|
+
|
|
129
|
+
if (!hashesEqual(localNow, lastSyncedHash)) {
|
|
130
|
+
console.log(`[${timestamp()}] remote has ${newRemoteVersion - remoteVersion} new version(s) but local has unpushed changes — skipping pull`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`[${timestamp()}] remote advanced to version ${newRemoteVersion} — pulling...`);
|
|
135
|
+
let folder;
|
|
136
|
+
try {
|
|
137
|
+
folder = await wdsync.restore();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[${timestamp()}] pull failed (${err.message}) — skipping to version ${newRemoteVersion}; run \`pull\` manually`);
|
|
140
|
+
remoteVersion = newRemoteVersion;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const oldFiles = await hashDiskTree(cwd);
|
|
144
|
+
const { stats } = await syncMemoryFolderToDisk(folder, cwd, oldFiles);
|
|
145
|
+
lastSyncedHash = await localTreeHash(cwd);
|
|
146
|
+
remoteVersion = newRemoteVersion;
|
|
147
|
+
console.log(`[${timestamp()}] pulled version ${remoteVersion} (${stats.written} written, ${stats.skipped} unchanged, ${stats.deleted} deleted)`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`[${timestamp()}] poll error:`, err.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const watcher = fsWatch(cwd, { recursive: true }, (eventType, filename) => {
|
|
154
|
+
if (!pushEnabled) return;
|
|
155
|
+
if (!filename) return;
|
|
156
|
+
const base = filename.split('/')[0];
|
|
157
|
+
if (DISK_EXCLUDES.has(base) || base.startsWith('.')) return;
|
|
158
|
+
dirty = true;
|
|
159
|
+
clearTimeout(debounceTimer);
|
|
160
|
+
debounceTimer = setTimeout(runPush, debounceMs);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const poller = setInterval(runPoll, pollIntervalMs);
|
|
164
|
+
|
|
165
|
+
let shutdownResolve;
|
|
166
|
+
const shutdownPromise = new Promise((resolve) => {
|
|
167
|
+
shutdownResolve = resolve;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function shutdown() {
|
|
171
|
+
console.log('\nstopping watch...');
|
|
172
|
+
clearTimeout(debounceTimer);
|
|
173
|
+
clearInterval(poller);
|
|
174
|
+
watcher.close();
|
|
175
|
+
shutdownResolve();
|
|
176
|
+
}
|
|
177
|
+
process.once('SIGINT', shutdown);
|
|
178
|
+
process.once('SIGTERM', shutdown);
|
|
179
|
+
|
|
180
|
+
await shutdownPromise;
|
|
181
|
+
}
|
package/lib/commands.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { watch as fsWatch } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
function resolvePath(p) {
|
|
8
|
+
if (p.startsWith('~')) return join(homedir(), p.slice(1));
|
|
9
|
+
return resolve(p);
|
|
10
|
+
}
|
|
6
11
|
import { SuiMaster } from 'suidouble';
|
|
7
12
|
import EndlessVector from '@fizzyflow/endless-vector';
|
|
8
13
|
import { WDoubleSync } from '@fizzyflow/wdoublesync';
|
|
9
14
|
import { DoubleSync, CDCStore, DoubleSyncSnapshot } from '@fizzyflow/doublesync';
|
|
10
|
-
import
|
|
15
|
+
import WalrusSealClient from './WalrusSealClient.js';
|
|
11
16
|
import { DoubleSyncFSFolder } from './DoubleSyncFSFolder.js';
|
|
12
17
|
|
|
13
18
|
async function makeWalrusSealClient(args) {
|
|
@@ -81,7 +86,7 @@ export async function push(args) {
|
|
|
81
86
|
const excludes = args.exclude
|
|
82
87
|
? args.exclude.split(',').map(s => s.trim())
|
|
83
88
|
: undefined;
|
|
84
|
-
const destPath =
|
|
89
|
+
const destPath = resolvePath(args.path);
|
|
85
90
|
const fsFolder = new DoubleSyncFSFolder(destPath, excludes);
|
|
86
91
|
|
|
87
92
|
const counts = await countTree(fsFolder);
|
|
@@ -202,7 +207,7 @@ export async function pull(args) {
|
|
|
202
207
|
throw err;
|
|
203
208
|
}
|
|
204
209
|
|
|
205
|
-
const destPath =
|
|
210
|
+
const destPath = resolvePath(args.path);
|
|
206
211
|
const oldFiles = await hashDiskTree(destPath);
|
|
207
212
|
|
|
208
213
|
const { stats, newFiles } = await syncMemoryFolderToDisk(folder, destPath, oldFiles);
|
|
@@ -218,12 +223,16 @@ export async function pull(args) {
|
|
|
218
223
|
}
|
|
219
224
|
|
|
220
225
|
export async function info(args) {
|
|
226
|
+
const wsc = await makeClientForRead(args);
|
|
227
|
+
|
|
228
|
+
console.log('wdoublesync');
|
|
229
|
+
console.log(' chain: ', args.chain);
|
|
230
|
+
console.log(' your wallet: ', wsc.suiMaster?.address || '(none)');
|
|
231
|
+
|
|
221
232
|
if (!args.vectorId) {
|
|
222
|
-
|
|
233
|
+
return;
|
|
223
234
|
}
|
|
224
235
|
|
|
225
|
-
const wsc = await makeClientForRead(args);
|
|
226
|
-
|
|
227
236
|
const evParams = {
|
|
228
237
|
suiClient: wsc.suiClient,
|
|
229
238
|
id: args.vectorId,
|
|
@@ -244,14 +253,35 @@ export async function info(args) {
|
|
|
244
253
|
|
|
245
254
|
const totalVersions = ev.length;
|
|
246
255
|
|
|
247
|
-
console.log('
|
|
248
|
-
|
|
256
|
+
console.log('\nEndlessVector:', args.vectorId);
|
|
257
|
+
|
|
258
|
+
let owner = '(unknown)';
|
|
259
|
+
if (wsc.suiMaster) {
|
|
260
|
+
try {
|
|
261
|
+
const obj = await wsc.suiMaster.getObject(args.vectorId);
|
|
262
|
+
if (obj?._owner) {
|
|
263
|
+
const ownerData = obj._owner;
|
|
264
|
+
if (typeof ownerData === 'string') {
|
|
265
|
+
owner = ownerData;
|
|
266
|
+
} else if (ownerData.AddressOwner) {
|
|
267
|
+
owner = ownerData.AddressOwner;
|
|
268
|
+
} else if (ownerData.ObjectOwner) {
|
|
269
|
+
owner = ownerData.ObjectOwner;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// silently fail, owner stays unknown
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(' owner: ', owner);
|
|
278
|
+
console.log(' encrypted: ', ev.sealEncryptedKey ? 'yes (Seal)' : 'no');
|
|
249
279
|
console.log(' versions: ', totalVersions);
|
|
250
280
|
console.log(' binary size: ', formatBytes(ev.binaryLength));
|
|
251
281
|
console.log(' history items: ', ev.historyItemsCount);
|
|
252
282
|
console.log(' archives: ', ev.archiveItemsCount);
|
|
253
283
|
|
|
254
|
-
const destPath =
|
|
284
|
+
const destPath = resolvePath(args.path);
|
|
255
285
|
const localHash = await localTreeHash(destPath);
|
|
256
286
|
|
|
257
287
|
if (!localHash) {
|
|
@@ -451,7 +481,7 @@ async function removeStaleSubdirs(dirPath, prefix, newDirs) {
|
|
|
451
481
|
export async function watch(args) {
|
|
452
482
|
if (!args.vectorId) throw new Error('vector-id is required for watch');
|
|
453
483
|
|
|
454
|
-
const cwd =
|
|
484
|
+
const cwd = resolvePath(args.path);
|
|
455
485
|
const chain = args.chain;
|
|
456
486
|
const debounceMs = args.debounce ?? 5000;
|
|
457
487
|
const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
|
|
@@ -624,7 +654,7 @@ export async function rebate(args) {
|
|
|
624
654
|
throw new Error('vector-id is required for rebate');
|
|
625
655
|
}
|
|
626
656
|
|
|
627
|
-
const destPath =
|
|
657
|
+
const destPath = resolvePath(args.path);
|
|
628
658
|
const excludes = args.exclude
|
|
629
659
|
? args.exclude.split(',').map(s => s.trim())
|
|
630
660
|
: undefined;
|
|
@@ -663,6 +693,9 @@ export async function rebate(args) {
|
|
|
663
693
|
await ev.burnArchive();
|
|
664
694
|
console.log(' burned');
|
|
665
695
|
|
|
696
|
+
// archive() and burnArchive() both call ev.reInitialize() — refetch fresh state
|
|
697
|
+
await ev.initialize();
|
|
698
|
+
|
|
666
699
|
console.log('step 3: pushing local state as fresh snapshot...');
|
|
667
700
|
|
|
668
701
|
const wdsync = new WDoubleSync({
|
|
@@ -670,12 +703,13 @@ export async function rebate(args) {
|
|
|
670
703
|
compress: args.compress || 'gzip',
|
|
671
704
|
});
|
|
672
705
|
|
|
673
|
-
//
|
|
706
|
+
// Skip wdsync.initialize() replay: rebate pushes a full snapshot, no need to replay chain.
|
|
707
|
+
// _lastSnapshot = null forces a full snapshot; _isInitialized = true bypasses replay.
|
|
674
708
|
wdsync._senderStore = new CDCStore({ copyBytes: false });
|
|
675
709
|
wdsync._receiverMirror = new CDCStore();
|
|
676
710
|
wdsync._lastSnapshot = null;
|
|
677
|
-
wdsync._replayedCount =
|
|
678
|
-
wdsync._isInitialized =
|
|
711
|
+
wdsync._replayedCount = ev.length;
|
|
712
|
+
wdsync._isInitialized = true;
|
|
679
713
|
|
|
680
714
|
const result = await wdsync.push(fsFolder);
|
|
681
715
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fizzyflow/wdoublesync_cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool built on the wdoublesync library to sync local folders to Walrus decentralised storage on the Sui network. Stores versioned gzip-compressed snapshots and diffs inside an EndlessVector on-chain object. Supports Seal encryption, manifest-based fast change detection, and full version history with point-in-time restore.",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,8 @@
|
|
|
27
27
|
"@fizzyflow/wdoublesync": "^0.1.7",
|
|
28
28
|
"@fizzyflow/endless-vector": "^0.0.13",
|
|
29
29
|
"@mysten/seal": "^1.1.3",
|
|
30
|
+
"@mysten/sui": "^2.19.0",
|
|
31
|
+
"@mysten/walrus": "^1.2.1",
|
|
30
32
|
"@noble/ciphers": "^2.2.0"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"walrus-seal-client-with-local": "file:../seal_walrus_localnet"
|
|
34
33
|
}
|
|
35
34
|
}
|