clawkeep 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,448 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { encryptChunk, decryptChunk } = require('./sync-crypto');
8
+
9
+ /**
10
+ * SyncManager — manages encrypted incremental chunk-based sync.
11
+ *
12
+ * Handles manifest management, git bundle creation, incremental logic,
13
+ * restore from backup, and compaction.
14
+ */
15
+ class SyncManager {
16
+ constructor(clawGit, transport, password) {
17
+ this.claw = clawGit;
18
+ this.transport = transport;
19
+ this.password = password;
20
+ }
21
+
22
+ /**
23
+ * Get or generate workspace ID.
24
+ * Format: <dirname>-<random8hex>
25
+ */
26
+ _getWorkspaceId() {
27
+ const config = this.claw.loadConfig();
28
+ if (config.backup?.workspaceId) return config.backup.workspaceId;
29
+
30
+ const dirname = path.basename(this.claw.dir);
31
+ const suffix = crypto.randomBytes(4).toString('hex');
32
+ const workspaceId = dirname + '-' + suffix;
33
+
34
+ if (!config.backup) config.backup = {};
35
+ config.backup.workspaceId = workspaceId;
36
+ this.claw.saveConfig(config);
37
+ return workspaceId;
38
+ }
39
+
40
+ /**
41
+ * Pad chunk number to 6 digits.
42
+ */
43
+ _chunkName(num) {
44
+ return 'chunk-' + String(num).padStart(6, '0') + '.enc';
45
+ }
46
+
47
+ /**
48
+ * Read and decrypt manifest from remote. Returns null if not found.
49
+ */
50
+ async _readManifest(workspaceId) {
51
+ const manifestPath = workspaceId + '/manifest.enc';
52
+ const exists = await this.transport.exists(manifestPath);
53
+ if (!exists) return null;
54
+
55
+ const encData = await this.transport.readFile(manifestPath);
56
+ const data = decryptChunk(encData, this.password);
57
+ return JSON.parse(data.toString('utf8'));
58
+ }
59
+
60
+ /**
61
+ * Encrypt and write manifest to remote.
62
+ */
63
+ async _writeManifest(workspaceId, manifest) {
64
+ const data = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8');
65
+ const encrypted = encryptChunk(data, this.password);
66
+ await this.transport.writeFile(workspaceId + '/manifest.enc', encrypted);
67
+ }
68
+
69
+ /**
70
+ * Get current HEAD commit hash.
71
+ */
72
+ async _getHead() {
73
+ try {
74
+ const hash = await this.claw.git.revparse(['HEAD']);
75
+ return hash.trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if there are commits between two refs.
83
+ */
84
+ async _hasNewCommits(fromCommit) {
85
+ try {
86
+ const log = await this.claw.git.raw(['log', '--oneline', fromCommit + '..HEAD']);
87
+ return log.trim().length > 0;
88
+ } catch {
89
+ return true; // assume yes if check fails
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Count commits in a range.
95
+ */
96
+ async _countCommits(fromCommit, toCommit) {
97
+ try {
98
+ const range = fromCommit ? fromCommit + '..' + toCommit : toCommit;
99
+ const log = await this.claw.git.raw(['rev-list', '--count', range]);
100
+ return parseInt(log.trim()) || 0;
101
+ } catch {
102
+ return 0;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Create a git bundle file. Returns the buffer.
108
+ * @param {string|null} fromCommit - Base commit (null for full bundle)
109
+ */
110
+ async _createBundle(fromCommit) {
111
+ const tmpDir = os.tmpdir();
112
+ const tmpFile = path.join(tmpDir, 'clawkeep-bundle-' + Date.now() + '.bundle');
113
+
114
+ try {
115
+ if (fromCommit) {
116
+ // Incremental: main ref, only commits after fromCommit
117
+ await this.claw.git.raw(['bundle', 'create', tmpFile, 'main', '^' + fromCommit]);
118
+ } else {
119
+ // Full: entire history
120
+ await this.claw.git.raw(['bundle', 'create', tmpFile, '--all']);
121
+ }
122
+ return fs.readFileSync(tmpFile);
123
+ } finally {
124
+ try { fs.unlinkSync(tmpFile); } catch {}
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Sync to backup target. Creates full or incremental bundle.
130
+ * Returns { synced, chunkCount, totalSize }.
131
+ */
132
+ async sync() {
133
+ const workspaceId = this._getWorkspaceId();
134
+ const headCommit = await this._getHead();
135
+ if (!headCommit) throw new Error('No commits to sync');
136
+
137
+ const config = this.claw.loadConfig();
138
+ const lastSyncCommit = config.backup?.lastSyncCommit || null;
139
+
140
+ // Check if already synced
141
+ if (lastSyncCommit && lastSyncCommit === headCommit) {
142
+ return { synced: false, message: 'Already up to date' };
143
+ }
144
+
145
+ // Check for new commits
146
+ if (lastSyncCommit) {
147
+ const hasNew = await this._hasNewCommits(lastSyncCommit);
148
+ if (!hasNew) {
149
+ return { synced: false, message: 'Already up to date' };
150
+ }
151
+ }
152
+
153
+ // Read existing manifest or create new one
154
+ let manifest = await this._readManifest(workspaceId);
155
+ const isFirstSync = !manifest;
156
+
157
+ if (isFirstSync) {
158
+ manifest = {
159
+ version: 1,
160
+ workspaceId,
161
+ createdAt: new Date().toISOString(),
162
+ chunks: [],
163
+ lastSync: null,
164
+ totalCommits: 0,
165
+ compactedAt: null,
166
+ };
167
+ }
168
+
169
+ // Create bundle
170
+ const fromCommit = isFirstSync ? null : lastSyncCommit;
171
+ const bundleBuffer = await this._createBundle(fromCommit);
172
+
173
+ // Encrypt and write chunk
174
+ const chunkNum = manifest.chunks.length + 1;
175
+ const chunkId = this._chunkName(chunkNum);
176
+ const encrypted = encryptChunk(bundleBuffer, this.password);
177
+ await this.transport.writeFile(workspaceId + '/' + chunkId, encrypted);
178
+
179
+ // Count commits in this chunk
180
+ const commitCount = await this._countCommits(fromCommit, headCommit);
181
+
182
+ // Update manifest
183
+ manifest.chunks.push({
184
+ id: chunkId,
185
+ type: isFirstSync ? 'full' : 'incremental',
186
+ fromCommit: fromCommit || null,
187
+ toCommit: headCommit,
188
+ commitCount,
189
+ size: encrypted.length,
190
+ createdAt: new Date().toISOString(),
191
+ });
192
+ manifest.lastSync = new Date().toISOString();
193
+ manifest.totalCommits += commitCount;
194
+
195
+ await this._writeManifest(workspaceId, manifest);
196
+
197
+ // Update local config
198
+ config.backup.lastSyncCommit = headCommit;
199
+ config.backup.chunkCount = manifest.chunks.length;
200
+ this.claw.saveConfig(config);
201
+
202
+ const totalSize = manifest.chunks.reduce((s, c) => s + c.size, 0);
203
+ return {
204
+ synced: true,
205
+ chunkCount: manifest.chunks.length,
206
+ totalSize,
207
+ lastSync: manifest.lastSync,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Restore from encrypted backup to a destination directory.
213
+ * @param {string} destDir - Where to restore
214
+ */
215
+ async restore(destDir) {
216
+ const workspaceId = this._getWorkspaceId();
217
+ const manifest = await this._readManifest(workspaceId);
218
+ if (!manifest) throw new Error('No backup found');
219
+ if (!manifest.chunks.length) throw new Error('Backup has no chunks');
220
+
221
+ destDir = path.resolve(destDir);
222
+ const tmpDir = path.join(os.tmpdir(), 'clawkeep-restore-' + Date.now());
223
+ fs.mkdirSync(tmpDir, { recursive: true });
224
+
225
+ try {
226
+ // Download and decrypt all chunks
227
+ const bundlePaths = [];
228
+ for (const chunk of manifest.chunks) {
229
+ const encData = await this.transport.readFile(workspaceId + '/' + chunk.id);
230
+ const bundle = decryptChunk(encData, this.password);
231
+ const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
232
+ fs.writeFileSync(bundlePath, bundle);
233
+ bundlePaths.push(bundlePath);
234
+ }
235
+
236
+ // Apply bundles: clone from first (full), then pull incrementals
237
+ const repoDir = path.join(tmpDir, 'repo');
238
+ const simpleGit = require('simple-git');
239
+
240
+ // Clone from first bundle
241
+ await simpleGit().clone(bundlePaths[0], repoDir);
242
+
243
+ // Apply incremental bundles
244
+ const repo = simpleGit(repoDir);
245
+ for (let i = 1; i < bundlePaths.length; i++) {
246
+ await repo.raw(['pull', bundlePaths[i], 'main']);
247
+ }
248
+
249
+ // Copy working tree to destination (exclude .git)
250
+ if (!fs.existsSync(destDir)) {
251
+ fs.mkdirSync(destDir, { recursive: true });
252
+ }
253
+ this._copyDir(repoDir, destDir);
254
+
255
+ return { ok: true, chunks: manifest.chunks.length, totalCommits: manifest.totalCommits };
256
+ } finally {
257
+ // Clean up temp dir
258
+ fs.rmSync(tmpDir, { recursive: true, force: true });
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Static restore: restore from a backup path without needing an initialized repo.
264
+ * Used when restoring to a brand new directory.
265
+ */
266
+ static async restoreFrom(transport, workspaceId, password, destDir) {
267
+ const manifestPath = workspaceId + '/manifest.enc';
268
+ const encManifest = await transport.readFile(manifestPath);
269
+ const manifest = JSON.parse(decryptChunk(encManifest, password).toString('utf8'));
270
+
271
+ if (!manifest.chunks.length) throw new Error('Backup has no chunks');
272
+
273
+ destDir = path.resolve(destDir);
274
+ const tmpDir = path.join(os.tmpdir(), 'clawkeep-restore-' + Date.now());
275
+ fs.mkdirSync(tmpDir, { recursive: true });
276
+
277
+ try {
278
+ const bundlePaths = [];
279
+ for (const chunk of manifest.chunks) {
280
+ const encData = await transport.readFile(workspaceId + '/' + chunk.id);
281
+ const bundle = decryptChunk(encData, password);
282
+ const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
283
+ fs.writeFileSync(bundlePath, bundle);
284
+ bundlePaths.push(bundlePath);
285
+ }
286
+
287
+ const repoDir = path.join(tmpDir, 'repo');
288
+ const simpleGit = require('simple-git');
289
+
290
+ await simpleGit().clone(bundlePaths[0], repoDir);
291
+
292
+ const repo = simpleGit(repoDir);
293
+ for (let i = 1; i < bundlePaths.length; i++) {
294
+ await repo.raw(['pull', bundlePaths[i], 'main']);
295
+ }
296
+
297
+ if (!fs.existsSync(destDir)) {
298
+ fs.mkdirSync(destDir, { recursive: true });
299
+ }
300
+ SyncManager._copyDir(repoDir, destDir);
301
+
302
+ return { ok: true, chunks: manifest.chunks.length, totalCommits: manifest.totalCommits };
303
+ } finally {
304
+ fs.rmSync(tmpDir, { recursive: true, force: true });
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Compact: merge all chunks into a single full bundle.
310
+ */
311
+ async compact() {
312
+ const workspaceId = this._getWorkspaceId();
313
+ const manifest = await this._readManifest(workspaceId);
314
+ if (!manifest) throw new Error('No backup found');
315
+ if (manifest.chunks.length <= 1) {
316
+ return { compacted: false, message: 'Nothing to compact (1 or fewer chunks)' };
317
+ }
318
+
319
+ const tmpDir = path.join(os.tmpdir(), 'clawkeep-compact-' + Date.now());
320
+ fs.mkdirSync(tmpDir, { recursive: true });
321
+
322
+ try {
323
+ // Download and decrypt all chunks
324
+ const bundlePaths = [];
325
+ for (const chunk of manifest.chunks) {
326
+ const encData = await this.transport.readFile(workspaceId + '/' + chunk.id);
327
+ const bundle = decryptChunk(encData, this.password);
328
+ const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
329
+ fs.writeFileSync(bundlePath, bundle);
330
+ bundlePaths.push(bundlePath);
331
+ }
332
+
333
+ // Reconstruct full repo
334
+ const repoDir = path.join(tmpDir, 'repo');
335
+ const simpleGit = require('simple-git');
336
+
337
+ await simpleGit().clone(bundlePaths[0], repoDir);
338
+ const repo = simpleGit(repoDir);
339
+ for (let i = 1; i < bundlePaths.length; i++) {
340
+ await repo.raw(['pull', bundlePaths[i], 'main']);
341
+ }
342
+
343
+ // Create single full bundle
344
+ const fullBundlePath = path.join(tmpDir, 'full.bundle');
345
+ await repo.raw(['bundle', 'create', fullBundlePath, '--all']);
346
+ const fullBundleBuffer = fs.readFileSync(fullBundlePath);
347
+
348
+ // Delete old chunks
349
+ for (const chunk of manifest.chunks) {
350
+ await this.transport.deleteFile(workspaceId + '/' + chunk.id);
351
+ }
352
+
353
+ // Encrypt and write new full chunk
354
+ const encrypted = encryptChunk(fullBundleBuffer, this.password);
355
+ const newChunkId = this._chunkName(1);
356
+ await this.transport.writeFile(workspaceId + '/' + newChunkId, encrypted);
357
+
358
+ // Get final commit hash
359
+ const headHash = (await repo.revparse(['HEAD'])).trim();
360
+ const totalCommits = parseInt((await repo.raw(['rev-list', '--count', 'HEAD'])).trim()) || 0;
361
+
362
+ // Update manifest
363
+ const newManifest = {
364
+ version: 1,
365
+ workspaceId,
366
+ createdAt: manifest.createdAt,
367
+ chunks: [{
368
+ id: newChunkId,
369
+ type: 'full',
370
+ fromCommit: null,
371
+ toCommit: headHash,
372
+ commitCount: totalCommits,
373
+ size: encrypted.length,
374
+ createdAt: new Date().toISOString(),
375
+ }],
376
+ lastSync: new Date().toISOString(),
377
+ totalCommits,
378
+ compactedAt: new Date().toISOString(),
379
+ };
380
+
381
+ await this._writeManifest(workspaceId, newManifest);
382
+
383
+ // Update local config
384
+ const config = this.claw.loadConfig();
385
+ config.backup.chunkCount = 1;
386
+ this.claw.saveConfig(config);
387
+
388
+ return {
389
+ compacted: true,
390
+ oldChunks: manifest.chunks.length,
391
+ newSize: encrypted.length,
392
+ };
393
+ } finally {
394
+ fs.rmSync(tmpDir, { recursive: true, force: true });
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Get sync status from manifest.
400
+ */
401
+ async getStatus() {
402
+ const config = this.claw.loadConfig();
403
+ const workspaceId = config.backup?.workspaceId;
404
+ if (!workspaceId) return { synced: false };
405
+
406
+ try {
407
+ const manifest = await this._readManifest(workspaceId);
408
+ if (!manifest) return { synced: false };
409
+
410
+ const totalSize = manifest.chunks.reduce((s, c) => s + c.size, 0);
411
+ return {
412
+ synced: true,
413
+ chunkCount: manifest.chunks.length,
414
+ totalSize,
415
+ totalCommits: manifest.totalCommits,
416
+ lastSync: manifest.lastSync,
417
+ compactedAt: manifest.compactedAt,
418
+ workspaceId,
419
+ };
420
+ } catch {
421
+ return { synced: false };
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Copy directory recursively, excluding .git.
427
+ */
428
+ _copyDir(src, dest) {
429
+ SyncManager._copyDir(src, dest);
430
+ }
431
+
432
+ static _copyDir(src, dest) {
433
+ const entries = fs.readdirSync(src, { withFileTypes: true });
434
+ for (const entry of entries) {
435
+ if (entry.name === '.git') continue;
436
+ const srcPath = path.join(src, entry.name);
437
+ const destPath = path.join(dest, entry.name);
438
+ if (entry.isDirectory()) {
439
+ fs.mkdirSync(destPath, { recursive: true });
440
+ SyncManager._copyDir(srcPath, destPath);
441
+ } else {
442
+ fs.copyFileSync(srcPath, destPath);
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ module.exports = SyncManager;
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Base transport interface for backup targets.
8
+ * Each target type (local, S3, cloud) implements these methods.
9
+ */
10
+ class BackupTransport {
11
+ async writeFile(remotePath, buffer) { throw new Error('Not implemented'); }
12
+ async readFile(remotePath) { throw new Error('Not implemented'); }
13
+ async deleteFile(remotePath) { throw new Error('Not implemented'); }
14
+ async listFiles(remoteDir) { throw new Error('Not implemented'); }
15
+ async exists(remotePath) { throw new Error('Not implemented'); }
16
+ }
17
+
18
+ /**
19
+ * Local filesystem transport.
20
+ * Works with any mounted path: NAS, USB drive, NFS, SMB.
21
+ */
22
+ class LocalTransport extends BackupTransport {
23
+ constructor(basePath) {
24
+ super();
25
+ this.basePath = path.resolve(basePath);
26
+ }
27
+
28
+ async writeFile(remotePath, buffer) {
29
+ const full = path.join(this.basePath, remotePath);
30
+ const dir = path.dirname(full);
31
+ if (!fs.existsSync(dir)) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ }
34
+ fs.writeFileSync(full, buffer);
35
+ }
36
+
37
+ async readFile(remotePath) {
38
+ const full = path.join(this.basePath, remotePath);
39
+ return fs.readFileSync(full);
40
+ }
41
+
42
+ async deleteFile(remotePath) {
43
+ const full = path.join(this.basePath, remotePath);
44
+ if (fs.existsSync(full)) {
45
+ fs.unlinkSync(full);
46
+ }
47
+ }
48
+
49
+ async listFiles(remoteDir) {
50
+ const full = path.join(this.basePath, remoteDir);
51
+ if (!fs.existsSync(full)) return [];
52
+ return fs.readdirSync(full).filter(f => {
53
+ return fs.statSync(path.join(full, f)).isFile();
54
+ });
55
+ }
56
+
57
+ async exists(remotePath) {
58
+ return fs.existsSync(path.join(this.basePath, remotePath));
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Git remote transport — uses native git push/pull.
64
+ * No chunks needed; git handles incremental natively.
65
+ */
66
+ class GitTransport extends BackupTransport {
67
+ constructor(clawGit) {
68
+ super();
69
+ this.claw = clawGit;
70
+ }
71
+
72
+ async sync() {
73
+ await this.claw.push();
74
+ }
75
+
76
+ async pull() {
77
+ await this.claw.pull();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Factory: create the right transport for a backup config.
83
+ */
84
+ function createTransport(backupConfig, clawGit) {
85
+ const target = backupConfig.target;
86
+ if (target === 'local') {
87
+ if (!backupConfig.local?.path) throw new Error('No local path configured');
88
+ return new LocalTransport(backupConfig.local.path);
89
+ }
90
+ if (target === 'git') {
91
+ return new GitTransport(clawGit);
92
+ }
93
+ if (target === 'cloud') {
94
+ throw new Error('ClawKeep Cloud is coming soon');
95
+ }
96
+ if (target === 's3') {
97
+ throw new Error('S3 backup is not yet implemented');
98
+ }
99
+ throw new Error('Unknown target: ' + target);
100
+ }
101
+
102
+ module.exports = { BackupTransport, LocalTransport, GitTransport, createTransport };
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ const ClawGit = require('./core/git');
4
+ const { exportEncrypted, importEncrypted } = require('./core/crypto');
5
+
6
+ module.exports = {
7
+ ClawGit,
8
+ exportEncrypted,
9
+ importEncrypted,
10
+ };