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,52 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const ora = require('ora');
5
+ const path = require('path');
6
+ const ClawGit = require('../core/git');
7
+
8
+ module.exports = async function snap(opts) {
9
+ const dir = path.resolve(opts.dir || '.');
10
+ const spinner = opts.quiet ? null : ora('Backing up...').start();
11
+
12
+ try {
13
+ const claw = new ClawGit(dir);
14
+
15
+ if (!(await claw.isInitialized())) {
16
+ spinner?.fail('Not initialized. Run `clawkeep init` first.');
17
+ process.exit(1);
18
+ }
19
+
20
+ const result = await claw.snap(opts.message || null);
21
+
22
+ if (!result) {
23
+ if (spinner) spinner.info(chalk.dim('Nothing changed.'));
24
+ return;
25
+ }
26
+
27
+ const hash = chalk.yellow(result.hash.substring(0, 8));
28
+ const ins = chalk.green('+' + result.summary.insertions);
29
+ const del = chalk.red('-' + result.summary.deletions);
30
+ const count = result.summary.changed;
31
+
32
+ if (spinner) {
33
+ spinner.succeed(`${hash} ${chalk.dim(result.message)}`);
34
+ console.log(` ${ins} ${del} across ${count} file(s)`);
35
+
36
+ // Show changed files (up to 5)
37
+ if (result.files && result.files.length <= 5) {
38
+ for (const f of result.files) {
39
+ const icon = f.status === '?' ? chalk.green('+') : chalk.yellow('~');
40
+ console.log(` ${icon} ${chalk.dim(f.path)}`);
41
+ }
42
+ }
43
+ } else if (opts.quiet) {
44
+ // Machine-readable output
45
+ console.log(result.hash.substring(0, 8));
46
+ }
47
+ } catch (err) {
48
+ spinner?.fail('Backup failed');
49
+ console.error(chalk.red(' ' + err.message));
50
+ process.exit(1);
51
+ }
52
+ };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const ClawGit = require('../core/git');
6
+
7
+ module.exports = async function status(opts) {
8
+ const dir = path.resolve(opts.dir || '.');
9
+
10
+ try {
11
+ const claw = new ClawGit(dir);
12
+
13
+ if (!(await claw.isInitialized())) {
14
+ console.log('');
15
+ console.log(chalk.yellow(' ClawKeep is not initialized here.'));
16
+ console.log(chalk.dim(' Run `clawkeep init` to start tracking.'));
17
+ console.log('');
18
+ return;
19
+ }
20
+
21
+ const config = claw.loadConfig();
22
+ const gitStatus = await claw.status();
23
+ const stats = await claw.getStats();
24
+
25
+ console.log('');
26
+ console.log(chalk.bold(' ClawKeep Status'));
27
+ console.log('');
28
+
29
+ // Stats
30
+ console.log(chalk.dim(' ── Stats ──────────────────────'));
31
+ console.log(` Backups: ${chalk.white(stats.totalSnaps)}`);
32
+ console.log(` Files: ${chalk.white(stats.trackedFiles)}`);
33
+ console.log(` Tracking: ${stats.daysTracked > 0 ? chalk.white(stats.daysTracked + ' day(s)') : chalk.dim('today')}`);
34
+
35
+ if (stats.lastSnap) {
36
+ const ago = _timeAgo(new Date(stats.lastSnap));
37
+ console.log(` Last backup: ${chalk.dim(ago)}`);
38
+ }
39
+
40
+ // Current state
41
+ console.log('');
42
+ console.log(chalk.dim(' ── Changes ────────────────────'));
43
+ if (gitStatus.clean) {
44
+ console.log(` ${chalk.green('●')} Clean — no pending changes`);
45
+ } else {
46
+ console.log(` ${chalk.yellow('●')} ${gitStatus.total} file(s) changed since last backup`);
47
+
48
+ const show = gitStatus.files.slice(0, 8);
49
+ for (const f of show) {
50
+ let icon, color;
51
+ if (f.working_dir === '?' || f.index === '?') {
52
+ icon = '+'; color = chalk.green;
53
+ } else if (f.working_dir === 'D' || f.index === 'D') {
54
+ icon = '-'; color = chalk.red;
55
+ } else {
56
+ icon = '~'; color = chalk.yellow;
57
+ }
58
+ console.log(` ${color(icon)} ${chalk.dim(f.path)}`);
59
+ }
60
+ if (gitStatus.files.length > 8) {
61
+ console.log(chalk.dim(` ... and ${gitStatus.files.length - 8} more`));
62
+ }
63
+ }
64
+
65
+ console.log('');
66
+ } catch (err) {
67
+ console.error(chalk.red(err.message));
68
+ process.exit(1);
69
+ }
70
+ };
71
+
72
+ function _timeAgo(date) {
73
+ const now = new Date();
74
+ const diffMs = now - date;
75
+ const diffMins = Math.floor(diffMs / 60000);
76
+ const diffHours = Math.floor(diffMs / 3600000);
77
+ const diffDays = Math.floor(diffMs / 86400000);
78
+
79
+ if (diffMins < 1) return 'just now';
80
+ if (diffMins < 60) return `${diffMins}m ago`;
81
+ if (diffHours < 24) return `${diffHours}h ago`;
82
+ if (diffDays < 30) return `${diffDays}d ago`;
83
+ return date.toISOString().substring(0, 10);
84
+ }
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const crypto = require('crypto');
5
+ const chalk = require('chalk');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const { spawn } = require('child_process');
9
+ const os = require('os');
10
+ const ClawGit = require('../core/git');
11
+ const BackupManager = require('../core/backup');
12
+
13
+ const PID_FILE = '.clawkeep/ui.pid';
14
+ const TOKEN_FILE = '.clawkeep/ui.token';
15
+ const UI_DIR = path.join(__dirname, '../../ui');
16
+
17
+ const MIME = {
18
+ '.html': 'text/html',
19
+ '.css': 'text/css',
20
+ '.js': 'application/javascript',
21
+ '.json': 'application/json',
22
+ '.png': 'image/png',
23
+ '.svg': 'image/svg+xml',
24
+ '.ico': 'image/x-icon',
25
+ };
26
+
27
+ module.exports = async function ui(opts) {
28
+ const dir = path.resolve(opts.dir || '.');
29
+ const port = parseInt(opts.port) || 3333;
30
+
31
+ if (opts.stop) return stopDaemon(dir);
32
+
33
+ const claw = new ClawGit(dir);
34
+ if (!(await claw.isInitialized())) {
35
+ console.error(chalk.red('Not initialized. Run `clawkeep init` first.'));
36
+ process.exit(1);
37
+ }
38
+
39
+ // Auth token
40
+ const tokenPath = path.join(dir, TOKEN_FILE);
41
+ let token;
42
+ if (fs.existsSync(tokenPath)) {
43
+ token = fs.readFileSync(tokenPath, 'utf8').trim();
44
+ } else {
45
+ token = crypto.randomBytes(16).toString('hex');
46
+ fs.writeFileSync(tokenPath, token);
47
+ }
48
+
49
+ if (opts.daemon) return startDaemon(dir, port, opts);
50
+ startServer(claw, dir, port, token, opts);
51
+ };
52
+
53
+ function startDaemon(dir, port, opts) {
54
+ const pidPath = path.join(dir, PID_FILE);
55
+ if (fs.existsSync(pidPath)) {
56
+ const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'));
57
+ try { process.kill(oldPid, 0); console.log(chalk.yellow(` Already running (PID ${oldPid}).`)); return; }
58
+ catch { fs.unlinkSync(pidPath); }
59
+ }
60
+
61
+ const binPath = path.join(__dirname, '../../bin/clawkeep.js');
62
+ const child = spawn(process.execPath, [binPath, 'ui', '--port', String(port), '-d', dir], {
63
+ detached: true, stdio: 'ignore',
64
+ env: { ...process.env, CLAWKEEP_DAEMON: '1' },
65
+ });
66
+ child.unref();
67
+ fs.writeFileSync(pidPath, String(child.pid));
68
+
69
+ const tokenPath = path.join(dir, TOKEN_FILE);
70
+ const token = fs.readFileSync(tokenPath, 'utf8').trim();
71
+
72
+ console.log('');
73
+ console.log(chalk.bold.cyan(' 🐾 ClawKeep Dashboard (background)'));
74
+ console.log(` ${chalk.dim('URL')} http://localhost:${port}/?token=${token}`);
75
+ console.log(` ${chalk.dim('Stop')} clawkeep ui --stop`);
76
+ console.log('');
77
+ }
78
+
79
+ function stopDaemon(dir) {
80
+ const pidPath = path.join(dir, PID_FILE);
81
+ if (!fs.existsSync(pidPath)) { console.log(chalk.dim(' No daemon running.')); return; }
82
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8'));
83
+ try { process.kill(pid, 'SIGTERM'); console.log(chalk.green(` ✓ Stopped (PID ${pid})`)); }
84
+ catch { console.log(chalk.dim(' Already stopped.')); }
85
+ try { fs.unlinkSync(pidPath); } catch {}
86
+ }
87
+
88
+ function startServer(claw, dir, port, token, opts) {
89
+ function auth(req) {
90
+ const url = new URL(req.url, `http://localhost:${port}`);
91
+ return (url.searchParams.get('token') || req.headers['x-clawkeep-token']) === token;
92
+ }
93
+
94
+ const apiHandlers = {
95
+ 'status': async () => {
96
+ const config = claw.loadConfig();
97
+ const stats = await claw.getStats();
98
+ const gitStatus = await claw.status();
99
+ return { config, stats, gitStatus };
100
+ },
101
+ 'log': async (p) => await claw.log(parseInt(p.get('limit')) || 50),
102
+ 'diff': async () => ({ diff: await claw.diff(false) }),
103
+ 'snap': async (p) => (await claw.snap(p.get('message') || null)) || { message: 'No changes' },
104
+ 'restore': async (p) => {
105
+ const hash = p.get('hash');
106
+ if (!hash) return { error: 'hash required' };
107
+ await claw.restore(hash, false);
108
+ return { ok: true, message: 'Restored to ' + hash.substring(0, 7) };
109
+ },
110
+ 'compare': async (p) => {
111
+ const from = p.get('from'), to = p.get('to');
112
+ if (!from || !to) return { error: 'from and to required' };
113
+ return { diff: await claw.diffBetween(from, to) };
114
+ },
115
+ 'files': async (p) => listFiles(dir, p.get('path') || '.'),
116
+ 'file': async (p) => readFile(dir, p.get('path')),
117
+ 'commit': async (p) => await claw.showCommit(p.get('hash') || 'HEAD'),
118
+ 'commit/diff': async (p) => ({ diff: await claw.commitDiff(p.get('hash') || 'HEAD') }),
119
+ 'file-history': async (p) => await claw.fileHistory(p.get('path') || '.'),
120
+ 'files-at': async (p) => await claw.listFilesAtCommit(p.get('hash') || 'HEAD', p.get('path') || ''),
121
+ 'file-at': async (p) => await claw.showFileAtCommit(p.get('hash') || 'HEAD', p.get('path')),
122
+ 'backup/status': async () => {
123
+ const bm = new BackupManager(claw);
124
+ return bm.getConfig();
125
+ },
126
+ 'backup/set-target': async (p) => {
127
+ const bm = new BackupManager(claw);
128
+ const type = p.get('type');
129
+ const options = JSON.parse(p.get('options') || '{}');
130
+ return await bm.setTarget(type, options);
131
+ },
132
+ 'backup/sync': async (p) => {
133
+ const bm = new BackupManager(claw);
134
+ const password = p.get('password') || process.env.CLAWKEEP_PASSWORD || null;
135
+ return await bm.sync(password);
136
+ },
137
+ 'backup/test': async () => {
138
+ const bm = new BackupManager(claw);
139
+ return await bm.test();
140
+ },
141
+ 'backup/set-password': async (p) => {
142
+ const bm = new BackupManager(claw);
143
+ const password = p.get('password');
144
+ if (!password) return { error: 'password required' };
145
+ bm.setPassword(password);
146
+ return { ok: true };
147
+ },
148
+ 'backup/has-password': async () => {
149
+ const bm = new BackupManager(claw);
150
+ return { set: bm.hasPassword() };
151
+ },
152
+ 'backup/sync-status': async (p) => {
153
+ const bm = new BackupManager(claw);
154
+ const password = p.get('password') || process.env.CLAWKEEP_PASSWORD || null;
155
+ return await bm.getSyncStatus(password);
156
+ },
157
+ 'backup/compact': async (p) => {
158
+ const bm = new BackupManager(claw);
159
+ const password = p.get('password') || process.env.CLAWKEEP_PASSWORD || null;
160
+ return await bm.compact(password);
161
+ },
162
+ 'backup/watch-status': async () => {
163
+ const watchPid = path.join(dir, '.clawkeep/watch.pid');
164
+ if (!fs.existsSync(watchPid)) return { running: false };
165
+ const pid = parseInt(fs.readFileSync(watchPid, 'utf8'));
166
+ try { process.kill(pid, 0); return { running: true, pid }; }
167
+ catch { return { running: false }; }
168
+ },
169
+ 'backup/repo-size': async () => {
170
+ return { size: claw.getRepoSize() };
171
+ },
172
+ };
173
+
174
+ // Special handler for export (streams file, not JSON)
175
+ const handleExport = async (params, req, res) => {
176
+ const password = params.get('password');
177
+ if (!password) {
178
+ res.setHeader('Content-Type', 'application/json');
179
+ res.end(JSON.stringify({ error: 'password required' }));
180
+ return;
181
+ }
182
+ try {
183
+ const { exportEncrypted } = require('../core/crypto');
184
+ const tmpPath = path.join(os.tmpdir(), `clawkeep-export-${Date.now()}.enc`);
185
+ await exportEncrypted(dir, tmpPath, password);
186
+ res.setHeader('Content-Disposition', 'attachment; filename="backup.clawkeep.enc"');
187
+ res.setHeader('Content-Type', 'application/octet-stream');
188
+ const stream = fs.createReadStream(tmpPath);
189
+ stream.pipe(res);
190
+ stream.on('end', () => { setTimeout(() => { try { fs.unlinkSync(tmpPath); } catch {} }, 10000); });
191
+ } catch (err) {
192
+ res.setHeader('Content-Type', 'application/json');
193
+ res.statusCode = 500;
194
+ res.end(JSON.stringify({ error: err.message }));
195
+ }
196
+ };
197
+
198
+ const server = http.createServer(async (req, res) => {
199
+ const url = new URL(req.url, `http://localhost:${port}`);
200
+
201
+ // Allow static assets without auth (CSS, JS, images)
202
+ const isStatic = /\.(css|js|png|svg|ico|woff2?)$/i.test(url.pathname);
203
+
204
+ if (!isStatic && !auth(req)) {
205
+ res.setHeader('Content-Type', 'text/html');
206
+ res.end(authPage());
207
+ return;
208
+ }
209
+
210
+ // API (requires auth)
211
+ if (url.pathname.startsWith('/api/')) {
212
+ if (!auth(req)) {
213
+ res.statusCode = 401;
214
+ res.end('{"error":"unauthorized"}');
215
+ return;
216
+ }
217
+ const route = url.pathname.replace('/api/', '');
218
+ // Special routes that handle their own response
219
+ if (route === 'backup/export') {
220
+ return handleExport(url.searchParams, req, res);
221
+ }
222
+ res.setHeader('Content-Type', 'application/json');
223
+ try {
224
+ const handler = apiHandlers[route];
225
+ if (handler) {
226
+ res.end(JSON.stringify(await handler(url.searchParams)));
227
+ } else {
228
+ res.statusCode = 404;
229
+ res.end('{"error":"not found"}');
230
+ }
231
+ } catch (err) {
232
+ res.statusCode = 500;
233
+ res.end(JSON.stringify({ error: err.message }));
234
+ }
235
+ return;
236
+ }
237
+
238
+ // Static files from ui/
239
+ let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
240
+ const fullPath = path.join(UI_DIR, filePath);
241
+
242
+ // Security: no directory traversal
243
+ if (!fullPath.startsWith(UI_DIR)) {
244
+ res.statusCode = 403;
245
+ res.end('Forbidden');
246
+ return;
247
+ }
248
+
249
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
250
+ const ext = path.extname(fullPath);
251
+ res.setHeader('Content-Type', MIME[ext] || 'text/plain');
252
+ // Append token to inline requests
253
+ let content = fs.readFileSync(fullPath);
254
+ res.end(content);
255
+ } else {
256
+ // Fallback to index.html for SPA
257
+ res.setHeader('Content-Type', 'text/html');
258
+ res.end(fs.readFileSync(path.join(UI_DIR, 'index.html')));
259
+ }
260
+ });
261
+
262
+ const host = opts.host || '0.0.0.0';
263
+ server.listen(port, host, () => {
264
+ const pidPath = path.join(dir, PID_FILE);
265
+ fs.writeFileSync(pidPath, String(process.pid));
266
+
267
+ if (!process.env.CLAWKEEP_DAEMON) {
268
+ console.log('');
269
+ console.log(chalk.bold.cyan(' 🐾 ClawKeep Dashboard'));
270
+ console.log('');
271
+ console.log(` ${chalk.dim('URL')} ${chalk.white(`http://localhost:${port}/?token=${token}`)}`);
272
+ console.log(` ${chalk.dim('Auth')} ${chalk.green('✓ token required')}`);
273
+ console.log('');
274
+ console.log(chalk.dim(' Ctrl+C to stop · --daemon to run in background'));
275
+ console.log('');
276
+ }
277
+ });
278
+
279
+ const cleanup = () => {
280
+ try { fs.unlinkSync(path.join(dir, PID_FILE)); } catch {}
281
+ server.close();
282
+ process.exit(0);
283
+ };
284
+ process.on('SIGINT', cleanup);
285
+ process.on('SIGTERM', cleanup);
286
+ }
287
+
288
+ function listFiles(baseDir, subdir) {
289
+ const fullPath = path.resolve(baseDir, subdir);
290
+ if (!fullPath.startsWith(path.resolve(baseDir))) return { error: 'Access denied' };
291
+ if (!fs.existsSync(fullPath)) return { error: 'Not found' };
292
+
293
+ return fs.readdirSync(fullPath, { withFileTypes: true })
294
+ .filter(e => !e.name.startsWith('.git') && e.name !== 'node_modules' && e.name !== '.clawkeep')
295
+ .map(e => ({
296
+ name: e.name,
297
+ type: e.isDirectory() ? 'dir' : 'file',
298
+ path: path.join(subdir, e.name).replace(/\\/g, '/'),
299
+ size: e.isFile() ? fs.statSync(path.join(fullPath, e.name)).size : null,
300
+ }))
301
+ .sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) : a.name.localeCompare(b.name));
302
+ }
303
+
304
+ function readFile(baseDir, filePath) {
305
+ if (!filePath) return { error: 'path required' };
306
+ const fullPath = path.resolve(baseDir, filePath);
307
+ if (!fullPath.startsWith(path.resolve(baseDir))) return { error: 'Access denied' };
308
+ if (!fs.existsSync(fullPath)) return { error: 'Not found' };
309
+
310
+ const stats = fs.statSync(fullPath);
311
+ if (stats.size > 512 * 1024) return { error: 'File too large', size: stats.size };
312
+
313
+ const ext = path.extname(filePath).toLowerCase();
314
+ const binary = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.db', '.enc', '.gz', '.zip', '.tar'].includes(ext);
315
+ if (binary) return { path: filePath, size: stats.size, binary: true, content: null };
316
+
317
+ return { path: filePath, size: stats.size, binary: false, content: fs.readFileSync(fullPath, 'utf8') };
318
+ }
319
+
320
+ function authPage() {
321
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>ClawKeep</title>
322
+ <style>*{margin:0;padding:0;box-sizing:border-box}body{background:#06080c;color:#f1f5f9;font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh}
323
+ .card{text-align:center;padding:40px;border:1px solid #1e2a3a;border-radius:12px;background:#111820;width:340px}
324
+ h1{font-size:18px;margin-bottom:4px;font-weight:600}h1 span{color:#38bdf8}
325
+ p{color:#6b7b8e;font-size:12px;margin-bottom:20px}
326
+ input{background:#0c1017;border:1px solid #1e2a3a;color:#f1f5f9;padding:9px 14px;border-radius:6px;width:100%;font-size:13px;margin-bottom:10px}
327
+ input:focus{outline:none;border-color:#38bdf8}
328
+ button{background:#38bdf8;color:#06080c;border:none;padding:9px;border-radius:6px;font-weight:600;cursor:pointer;width:100%;font-size:13px}
329
+ button:hover{filter:brightness(1.1)}</style></head>
330
+ <body><div class="card"><h1>🐾 <span>ClawKeep</span></h1><p>Enter your access token</p>
331
+ <form onsubmit="event.preventDefault();location.href='/?token='+document.getElementById('t').value">
332
+ <input id="t" type="password" placeholder="Token" autofocus>
333
+ <button type="submit">Open Dashboard</button></form></div></body></html>`;
334
+ }
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const { spawn } = require('child_process');
7
+ const chokidar = require('chokidar');
8
+ const ClawGit = require('../core/git');
9
+ const BackupManager = require('../core/backup');
10
+
11
+ const PID_FILE = '.clawkeep/watch.pid';
12
+
13
+ module.exports = async function watch(opts) {
14
+ const dir = path.resolve(opts.dir || '.');
15
+ const interval = parseInt(opts.interval) || 5000;
16
+ const autoPush = opts.push || false;
17
+ const quiet = opts.quiet || false;
18
+
19
+ if (opts.stop) return stopDaemon(dir);
20
+
21
+ const claw = new ClawGit(dir);
22
+
23
+ if (!(await claw.isInitialized())) {
24
+ console.error(chalk.red('Not initialized. Run `clawkeep init` first.'));
25
+ process.exit(1);
26
+ }
27
+
28
+ if (opts.daemon) return startDaemon(dir, interval, opts);
29
+ startWatcher(claw, dir, interval, autoPush, quiet);
30
+ };
31
+
32
+ function startDaemon(dir, interval, opts) {
33
+ const pidPath = path.join(dir, PID_FILE);
34
+ if (fs.existsSync(pidPath)) {
35
+ const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'));
36
+ try { process.kill(oldPid, 0); console.log(chalk.yellow(` Already running (PID ${oldPid}).`)); return; }
37
+ catch { fs.unlinkSync(pidPath); }
38
+ }
39
+
40
+ const args = ['watch', '--interval', String(interval), '-d', dir];
41
+ if (opts.push) args.push('--push');
42
+ args.push('-q');
43
+
44
+ const binPath = path.join(__dirname, '../../bin/clawkeep.js');
45
+ const child = spawn(process.execPath, [binPath, ...args], {
46
+ detached: true, stdio: 'ignore',
47
+ env: { ...process.env, CLAWKEEP_WATCH_DAEMON: '1' },
48
+ });
49
+ child.unref();
50
+ fs.writeFileSync(pidPath, String(child.pid));
51
+
52
+ console.log('');
53
+ console.log(chalk.bold.cyan(' 🐾 ClawKeep watching (background)'));
54
+ console.log(` ${chalk.dim('PID')} ${child.pid}`);
55
+ console.log(` ${chalk.dim('Dir')} ${dir}`);
56
+ console.log(` ${chalk.dim('Interval')} ${interval}ms`);
57
+ console.log(` ${chalk.dim('Stop')} clawkeep watch --stop`);
58
+ console.log('');
59
+ }
60
+
61
+ function stopDaemon(dir) {
62
+ const pidPath = path.join(dir, PID_FILE);
63
+ if (!fs.existsSync(pidPath)) { console.log(chalk.dim(' No watcher running.')); return; }
64
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8'));
65
+ try { process.kill(pid, 'SIGTERM'); console.log(chalk.green(` ✓ Watcher stopped (PID ${pid})`)); }
66
+ catch { console.log(chalk.dim(' Already stopped.')); }
67
+ try { fs.unlinkSync(pidPath); } catch {}
68
+ }
69
+
70
+ function startWatcher(claw, dir, interval, autoPush, quiet) {
71
+ const config = claw.loadConfig();
72
+ const pidPath = path.join(dir, PID_FILE);
73
+
74
+ // Write PID file
75
+ fs.writeFileSync(pidPath, String(process.pid));
76
+
77
+ if (!quiet) {
78
+ console.log('');
79
+ console.log(chalk.bold.cyan(' 🐾 ClawKeep watching...'));
80
+ console.log('');
81
+ console.log(` ${chalk.dim('Directory')} ${dir}`);
82
+ console.log(` ${chalk.dim('Debounce')} ${interval}ms`);
83
+ console.log(` ${chalk.dim('Auto-push')} ${autoPush ? chalk.green('on') : chalk.dim('off')}`);
84
+ console.log('');
85
+ console.log(chalk.dim(' Waiting for changes... (Ctrl+C to stop · --daemon to run in background)'));
86
+ console.log('');
87
+ }
88
+
89
+ let debounceTimer = null;
90
+ let changedFiles = new Set();
91
+ let snapCount = 0;
92
+
93
+ // Load .clawkeepignore patterns for chokidar + hardcoded essentials
94
+ const clawIgnore = claw._loadIgnorePatterns().map(p => {
95
+ if (p.endsWith('/')) return '**/' + p + '**';
96
+ if (!p.includes('/') && !p.includes('*')) return '**/' + p;
97
+ return '**/' + p;
98
+ });
99
+ const ignored = [
100
+ '**/.git/**',
101
+ '**/.clawkeep/**',
102
+ ...(config.ignore || []),
103
+ ...clawIgnore,
104
+ ];
105
+
106
+ const watcher = chokidar.watch(dir, {
107
+ ignored,
108
+ persistent: true,
109
+ ignoreInitial: true,
110
+ awaitWriteFinish: {
111
+ stabilityThreshold: 500,
112
+ pollInterval: 100,
113
+ },
114
+ });
115
+
116
+ const doSnap = async () => {
117
+ const files = Array.from(changedFiles);
118
+ changedFiles.clear();
119
+ if (files.length === 0) return;
120
+
121
+ try {
122
+ const result = await claw.snap();
123
+
124
+ if (result) {
125
+ snapCount++;
126
+ const now = new Date().toISOString().substring(11, 19);
127
+ const hash = chalk.yellow(result.hash.substring(0, 8));
128
+
129
+ if (!quiet) {
130
+ console.log(` ${chalk.dim(now)} ${chalk.green('⬤')} ${hash} ${chalk.dim(result.message)}`);
131
+ }
132
+
133
+ if (autoPush && config.remote) {
134
+ try {
135
+ await claw.push();
136
+ if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.blue('↑')} ${chalk.dim('pushed')}`);
137
+ } catch (pushErr) {
138
+ if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.yellow('⚠')} ${chalk.dim('push failed: ' + pushErr.message)}`);
139
+ }
140
+ }
141
+
142
+ // Auto-sync to backup target
143
+ if (config.backup && config.backup.autoSync && config.backup.target) {
144
+ try {
145
+ const bm = new BackupManager(claw);
146
+ await bm.sync();
147
+ if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.blue('↑')} ${chalk.dim('synced to ' + (config.backup.targetLabel || config.backup.target))}`);
148
+ } catch (syncErr) {
149
+ if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.yellow('⚠')} ${chalk.dim('sync failed: ' + syncErr.message)}`);
150
+ }
151
+ }
152
+ }
153
+ } catch (err) {
154
+ if (!quiet) console.error(chalk.red(` Error: ${err.message}`));
155
+ }
156
+ };
157
+
158
+ const onFileChange = (eventType, filePath) => {
159
+ const relative = path.relative(dir, filePath);
160
+ changedFiles.add(relative);
161
+ if (debounceTimer) clearTimeout(debounceTimer);
162
+ debounceTimer = setTimeout(doSnap, interval);
163
+ };
164
+
165
+ watcher.on('add', (p) => onFileChange('add', p));
166
+ watcher.on('change', (p) => onFileChange('change', p));
167
+ watcher.on('unlink', (p) => onFileChange('unlink', p));
168
+
169
+ const cleanup = () => {
170
+ if (!quiet) {
171
+ console.log('');
172
+ console.log(chalk.dim(` Stopped. ${snapCount} backup(s) taken this session.`));
173
+ }
174
+ try { fs.unlinkSync(pidPath); } catch {}
175
+ watcher.close();
176
+ process.exit(0);
177
+ };
178
+ process.on('SIGINT', cleanup);
179
+ process.on('SIGTERM', cleanup);
180
+ }