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,426 @@
1
+ 'use strict';
2
+
3
+ const simpleGit = require('simple-git');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ /**
8
+ * Core git operations for ClawKeep.
9
+ * Wraps simple-git for versioned backups.
10
+ * Linear history only — no branches.
11
+ */
12
+ class ClawGit {
13
+ constructor(dir) {
14
+ this.dir = path.resolve(dir);
15
+ this.git = simpleGit(this.dir);
16
+ }
17
+
18
+ /** Check if clawkeep is initialized in this directory */
19
+ async isInitialized() {
20
+ const configPath = path.join(this.dir, '.clawkeep', 'config.json');
21
+ return fs.existsSync(configPath);
22
+ }
23
+
24
+ /** Initialize a new clawkeep repo */
25
+ async init(config = {}) {
26
+ const clawkeepDir = path.join(this.dir, '.clawkeep');
27
+
28
+ if (!fs.existsSync(clawkeepDir)) {
29
+ fs.mkdirSync(clawkeepDir, { recursive: true });
30
+ }
31
+
32
+ // Initialize git if not already a repo
33
+ const isRepo = await this.git.checkIsRepo().catch(() => false);
34
+ if (!isRepo) {
35
+ await this.git.init();
36
+ await this.git.checkout(['-b', 'main']).catch(() => {});
37
+ }
38
+
39
+ // Set git config
40
+ await this.git.addConfig('user.name', 'ClawKeep');
41
+ await this.git.addConfig('user.email', 'backup@clawkeep.com');
42
+
43
+ // Write config — minimal, no agent semantics
44
+ const clawConfig = {
45
+ version: '0.1.0',
46
+ createdAt: new Date().toISOString(),
47
+ remote: config.remote || null,
48
+ watchInterval: config.watchInterval || 5000,
49
+ ignore: config.ignore || [],
50
+ };
51
+
52
+ fs.writeFileSync(
53
+ path.join(clawkeepDir, 'config.json'),
54
+ JSON.stringify(clawConfig, null, 2)
55
+ );
56
+
57
+ // Write default .clawkeepignore with sensible defaults
58
+ const ignorePath = path.join(this.dir, '.clawkeepignore');
59
+ if (!fs.existsSync(ignorePath)) {
60
+ fs.writeFileSync(
61
+ ignorePath,
62
+ [
63
+ '# ClawKeep ignore — patterns here are synced to .gitignore',
64
+ '# Add anything you don\'t want versioned',
65
+ '',
66
+ '# Dependencies',
67
+ 'node_modules/',
68
+ 'vendor/',
69
+ '.venv/',
70
+ '__pycache__/',
71
+ '*.pyc',
72
+ '',
73
+ '# Build output',
74
+ 'dist/',
75
+ 'build/',
76
+ '.next/',
77
+ '',
78
+ '# Environment & secrets',
79
+ '.env',
80
+ '.env.*',
81
+ '*.pem',
82
+ '*.key',
83
+ '',
84
+ '# Logs & temp',
85
+ '*.log',
86
+ 'tmp/',
87
+ '.cache/',
88
+ '',
89
+ '# ClawKeep internals',
90
+ '.clawkeep/ui.pid',
91
+ '.clawkeep/ui.token',
92
+ '.clawkeep/watch.pid',
93
+ '',
94
+ ].join('\n')
95
+ );
96
+ }
97
+
98
+ // Sync ignore patterns to .gitignore so git respects them
99
+ this._syncIgnore();
100
+
101
+ return clawConfig;
102
+ }
103
+
104
+ /** Load clawkeep config */
105
+ loadConfig() {
106
+ const configPath = path.join(this.dir, '.clawkeep', 'config.json');
107
+ if (!fs.existsSync(configPath)) return null;
108
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
109
+ }
110
+
111
+ /** Save clawkeep config */
112
+ saveConfig(config) {
113
+ const configPath = path.join(this.dir, '.clawkeep', 'config.json');
114
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
115
+ }
116
+
117
+ /** Stage all changes and commit */
118
+ async snap(message) {
119
+ this._syncIgnore();
120
+ await this.git.add('-A');
121
+
122
+ const status = await this.git.status();
123
+ if (status.staged.length === 0 && status.files.length === 0) {
124
+ return null;
125
+ }
126
+
127
+ if (!message) {
128
+ message = this._autoMessage(status);
129
+ }
130
+
131
+ const result = await this.git.commit(message);
132
+
133
+ return {
134
+ hash: result.commit,
135
+ message,
136
+ summary: {
137
+ changed: status.files.length,
138
+ insertions: result.summary.insertions,
139
+ deletions: result.summary.deletions,
140
+ },
141
+ files: status.files.map((f) => ({
142
+ path: f.path,
143
+ status: f.working_dir || f.index,
144
+ })),
145
+ };
146
+ }
147
+
148
+ /** Get diff since last snap */
149
+ async diff(statOnly = false) {
150
+ this._syncIgnore();
151
+ await this.git.add('-A');
152
+ const args = ['--cached'];
153
+ if (statOnly) args.push('--stat');
154
+ const result = await this.git.diff(args);
155
+ await this.git.reset();
156
+ return result;
157
+ }
158
+
159
+ /** Get backup history */
160
+ async log(limit = 20) {
161
+ try {
162
+ const result = await this.git.log({ maxCount: limit });
163
+ return result.all.map((entry) => ({
164
+ hash: entry.hash,
165
+ date: entry.date,
166
+ message: entry.message,
167
+ author: entry.author_name,
168
+ }));
169
+ } catch (e) {
170
+ if (e.message.includes('does not have any commits')) return [];
171
+ throw e;
172
+ }
173
+ }
174
+
175
+ /** Get current status */
176
+ async status() {
177
+ const status = await this.git.status();
178
+ return {
179
+ modified: status.modified,
180
+ added: status.not_added,
181
+ deleted: status.deleted,
182
+ renamed: status.renamed,
183
+ staged: status.staged,
184
+ total: status.files.length,
185
+ clean: status.isClean(),
186
+ files: status.files,
187
+ };
188
+ }
189
+
190
+ /** Get full stats — computed from git log, no config dependency */
191
+ async getStats() {
192
+ let totalCommits = 0;
193
+ let firstDate = null;
194
+ let lastDate = null;
195
+ try {
196
+ const log = await this.git.log();
197
+ totalCommits = log.total;
198
+ if (log.latest) lastDate = log.latest.date;
199
+ if (log.all.length) firstDate = log.all[log.all.length - 1].date;
200
+ } catch (e) {
201
+ // no commits
202
+ }
203
+
204
+ let daysTracked = 0;
205
+ if (firstDate) {
206
+ daysTracked = Math.ceil((Date.now() - new Date(firstDate).getTime()) / (1000 * 60 * 60 * 24));
207
+ }
208
+
209
+ let trackedFiles = 0;
210
+ try {
211
+ const files = await this.git.raw(['ls-files']);
212
+ trackedFiles = files.trim().split('\n').filter(Boolean).length;
213
+ } catch (e) {
214
+ // ignore
215
+ }
216
+
217
+ return {
218
+ totalSnaps: totalCommits,
219
+ daysTracked,
220
+ trackedFiles,
221
+ firstSnap: firstDate,
222
+ lastSnap: lastDate,
223
+ };
224
+ }
225
+
226
+ /** Get files changed in a specific commit */
227
+ async showCommit(hash) {
228
+ try {
229
+ const stat = await this.git.show([hash, '--stat', '--format=%H|%ai|%s|%an']);
230
+ const lines = stat.trim().split('\n');
231
+ const [h, date, message, author] = (lines[0] || '').split('|');
232
+ const files = lines.slice(1).filter(l => l.trim() && !l.includes('changed')).map(l => {
233
+ const match = l.trim().match(/^(.+?)\s+\|\s+(\d+)/);
234
+ return match ? { path: match[1].trim(), changes: parseInt(match[2]) } : null;
235
+ }).filter(Boolean);
236
+ const summary = lines[lines.length - 1] || '';
237
+ return { hash: h, date, message, author, files, summary };
238
+ } catch (e) {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /** Get diff for a specific commit */
244
+ async commitDiff(hash) {
245
+ try {
246
+ return await this.git.show([hash, '--format=']);
247
+ } catch (e) {
248
+ return '';
249
+ }
250
+ }
251
+
252
+ /** Get diff between any two commits */
253
+ async diffBetween(hash1, hash2) {
254
+ try {
255
+ return await this.git.diff([hash1, hash2]);
256
+ } catch (e) {
257
+ return '';
258
+ }
259
+ }
260
+
261
+ /** Get last commit info for files in a directory */
262
+ async fileHistory(dir) {
263
+ try {
264
+ const files = await this.git.raw(['ls-tree', '--name-only', 'HEAD', dir ? dir + '/' : '']);
265
+ const result = {};
266
+ const names = files.trim().split('\n').filter(Boolean).slice(0, 50);
267
+ for (const f of names) {
268
+ try {
269
+ const log = await this.git.log({ maxCount: 1, file: f });
270
+ if (log.latest) {
271
+ result[f] = { hash: log.latest.hash, date: log.latest.date, message: log.latest.message };
272
+ }
273
+ } catch {}
274
+ }
275
+ return result;
276
+ } catch {
277
+ return {};
278
+ }
279
+ }
280
+
281
+ /** List files/dirs at a specific commit (for time-travel browsing) */
282
+ async listFilesAtCommit(hash, dir) {
283
+ try {
284
+ const treePath = dir ? hash + ':' + dir : hash;
285
+ const raw = await this.git.raw(['ls-tree', treePath]);
286
+ const lines = raw.trim().split('\n').filter(Boolean);
287
+ const entries = lines.map(line => {
288
+ // Format: <mode> <type> <hash>\t<name>
289
+ const [info, name] = line.split('\t');
290
+ const type = info.split(/\s+/)[1]; // 'tree' or 'blob'
291
+ const fullPath = dir ? dir + '/' + name : name;
292
+ return { name, type: type === 'tree' ? 'dir' : 'file', path: fullPath };
293
+ });
294
+ return entries.sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) : a.name.localeCompare(b.name));
295
+ } catch {
296
+ return [];
297
+ }
298
+ }
299
+
300
+ /** Get file content at a specific commit */
301
+ async showFileAtCommit(hash, filePath) {
302
+ try {
303
+ const content = await this.git.show([hash + ':' + filePath]);
304
+ return { path: filePath, content, binary: false };
305
+ } catch (e) {
306
+ if (e.message.includes('binary')) {
307
+ return { path: filePath, content: null, binary: true };
308
+ }
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /** Restore to a specific point */
314
+ async restore(ref, hard = false) {
315
+ if (hard) {
316
+ await this.git.reset(['--hard', ref]);
317
+ } else {
318
+ await this.git.checkout(ref, ['--', '.']);
319
+ await this.snap(`restore: reverted to ${ref.substring(0, 8)}`);
320
+ }
321
+ }
322
+
323
+ /** Set up remote */
324
+ async setRemote(url) {
325
+ const remotes = await this.git.getRemotes();
326
+ const hasOrigin = remotes.some((r) => r.name === 'origin');
327
+
328
+ if (hasOrigin) {
329
+ await this.git.remote(['set-url', 'origin', url]);
330
+ } else {
331
+ await this.git.addRemote('origin', url);
332
+ }
333
+
334
+ const config = this.loadConfig();
335
+ if (config) {
336
+ config.remote = url;
337
+ this.saveConfig(config);
338
+ }
339
+ }
340
+
341
+ /** Push to remote */
342
+ async push() {
343
+ try {
344
+ await this.git.push('origin', 'main', ['--set-upstream']);
345
+ } catch (e) {
346
+ const branch = await this.git.revparse(['--abbrev-ref', 'HEAD']);
347
+ await this.git.push('origin', branch.trim(), ['--set-upstream']);
348
+ }
349
+ }
350
+
351
+ /** Pull from remote */
352
+ async pull() {
353
+ await this.git.pull('origin', 'main', { '--rebase': 'true' });
354
+ }
355
+
356
+ /** Get total repo size (git directory) */
357
+ getRepoSize() {
358
+ const gitDir = path.join(this.dir, '.git');
359
+ let total = 0;
360
+ const walk = (d) => {
361
+ try {
362
+ const entries = fs.readdirSync(d, { withFileTypes: true });
363
+ for (const e of entries) {
364
+ const p = path.join(d, e.name);
365
+ if (e.isDirectory()) walk(p);
366
+ else total += fs.statSync(p).size;
367
+ }
368
+ } catch {}
369
+ };
370
+ walk(gitDir);
371
+ return total;
372
+ }
373
+
374
+ /** Load .clawkeepignore patterns (parsed, no comments/blanks) */
375
+ _loadIgnorePatterns() {
376
+ const ignorePath = path.join(this.dir, '.clawkeepignore');
377
+ if (!fs.existsSync(ignorePath)) return [];
378
+ return fs
379
+ .readFileSync(ignorePath, 'utf8')
380
+ .split('\n')
381
+ .map((l) => l.trim())
382
+ .filter((l) => l && !l.startsWith('#'));
383
+ }
384
+
385
+ /** Sync .clawkeepignore patterns into .gitignore (managed section) */
386
+ _syncIgnore() {
387
+ const patterns = this._loadIgnorePatterns();
388
+ if (!patterns.length) return;
389
+
390
+ const START = '# clawkeep-start';
391
+ const END = '# clawkeep-end';
392
+ const managed = [START, ...patterns, END].join('\n');
393
+
394
+ const gitignorePath = path.join(this.dir, '.gitignore');
395
+ let existing = '';
396
+ if (fs.existsSync(gitignorePath)) {
397
+ existing = fs.readFileSync(gitignorePath, 'utf8');
398
+ }
399
+
400
+ // Replace existing managed section or append
401
+ const startIdx = existing.indexOf(START);
402
+ const endIdx = existing.indexOf(END);
403
+ let updated;
404
+ if (startIdx !== -1 && endIdx !== -1) {
405
+ updated = existing.substring(0, startIdx) + managed + existing.substring(endIdx + END.length);
406
+ } else {
407
+ updated = existing.trimEnd() + '\n\n' + managed + '\n';
408
+ }
409
+
410
+ // Only write if changed
411
+ if (updated !== existing) {
412
+ fs.writeFileSync(gitignorePath, updated);
413
+ }
414
+ }
415
+
416
+ /** Generate simple auto-message */
417
+ _autoMessage(status) {
418
+ const n = status.files.length;
419
+ if (n === 1) {
420
+ return path.basename(status.files[0].path) + ' updated';
421
+ }
422
+ return 'backup — ' + n + ' files changed';
423
+ }
424
+ }
425
+
426
+ module.exports = ClawGit;
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const MAGIC = Buffer.from('CK01');
6
+ const SALT_LENGTH = 32;
7
+ const NONCE_LENGTH = 12;
8
+ const KEY_LENGTH = 32;
9
+ const TAG_LENGTH = 16;
10
+ const ALGORITHM = 'aes-256-gcm';
11
+ const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + NONCE_LENGTH; // 4 + 32 + 12 = 48
12
+
13
+ /**
14
+ * Derive encryption key from password using scrypt.
15
+ */
16
+ function deriveKey(password, salt) {
17
+ return crypto.scryptSync(password, salt, KEY_LENGTH, { N: 16384, r: 8, p: 1 });
18
+ }
19
+
20
+ /**
21
+ * Encrypt a buffer using AES-256-GCM.
22
+ * Output format: [4B magic "CK01"][32B salt][12B nonce][NB ciphertext][16B auth tag]
23
+ */
24
+ function encryptChunk(buffer, password) {
25
+ const salt = crypto.randomBytes(SALT_LENGTH);
26
+ const nonce = crypto.randomBytes(NONCE_LENGTH);
27
+ const key = deriveKey(password, salt);
28
+
29
+ const cipher = crypto.createCipheriv(ALGORITHM, key, nonce);
30
+ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
31
+ const tag = cipher.getAuthTag();
32
+
33
+ return Buffer.concat([MAGIC, salt, nonce, encrypted, tag]);
34
+ }
35
+
36
+ /**
37
+ * Decrypt a CK01-format encrypted buffer.
38
+ * Returns the decrypted plaintext buffer.
39
+ */
40
+ function decryptChunk(encBuffer, password) {
41
+ if (encBuffer.length < HEADER_LENGTH + TAG_LENGTH) {
42
+ throw new Error('Invalid chunk: too small');
43
+ }
44
+
45
+ const magic = encBuffer.subarray(0, 4);
46
+ if (!magic.equals(MAGIC)) {
47
+ throw new Error('Invalid chunk: bad magic (expected CK01)');
48
+ }
49
+
50
+ const salt = encBuffer.subarray(4, 4 + SALT_LENGTH);
51
+ const nonce = encBuffer.subarray(4 + SALT_LENGTH, HEADER_LENGTH);
52
+ const ciphertext = encBuffer.subarray(HEADER_LENGTH, encBuffer.length - TAG_LENGTH);
53
+ const tag = encBuffer.subarray(encBuffer.length - TAG_LENGTH);
54
+
55
+ const key = deriveKey(password, salt);
56
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce);
57
+ decipher.setAuthTag(tag);
58
+
59
+ try {
60
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
61
+ } catch (e) {
62
+ throw new Error('Decryption failed — wrong password or corrupted chunk');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Hash a password for storage (verification only, not the password itself).
68
+ * Format: $scrypt$<salt-hex>$<hash-hex>
69
+ */
70
+ function hashPassword(password) {
71
+ const salt = crypto.randomBytes(16);
72
+ const hash = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
73
+ return '$scrypt$' + salt.toString('hex') + '$' + hash.toString('hex');
74
+ }
75
+
76
+ /**
77
+ * Verify a password against a stored hash.
78
+ */
79
+ function verifyPassword(password, storedHash) {
80
+ if (!storedHash || !storedHash.startsWith('$scrypt$')) return false;
81
+ const parts = storedHash.split('$');
82
+ // parts: ['', 'scrypt', '<salt>', '<hash>']
83
+ if (parts.length !== 4) return false;
84
+ const salt = Buffer.from(parts[2], 'hex');
85
+ const expected = Buffer.from(parts[3], 'hex');
86
+ const derived = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
87
+ return crypto.timingEqual(derived, expected);
88
+ }
89
+
90
+ module.exports = { encryptChunk, decryptChunk, hashPassword, verifyPassword };