clawkeep 0.2.1 → 0.2.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/bin/clawkeep.js CHANGED
@@ -110,6 +110,7 @@ program
110
110
  .option('--workspace <id>', 'Workspace ID (headless)')
111
111
  .option('--endpoint <url>', 'API endpoint')
112
112
  .option('-p, --password <pass>', 'Encryption password')
113
+ .option('--watch', 'Start watcher with --sync after setup')
113
114
  .action((sub, opts) => require('../src/commands/cloud')(sub, opts));
114
115
 
115
116
  // watch
@@ -119,6 +120,7 @@ program
119
120
  .option('-d, --dir <path>', 'Target directory', '.')
120
121
  .option('--interval <ms>', 'Debounce interval in ms', '5000')
121
122
  .option('--push', 'Auto-push after each snap', false)
123
+ .option('--sync', 'Auto-sync to backup target after each snap', false)
122
124
  .option('--daemon', 'Run in background')
123
125
  .option('--stop', 'Stop background watcher')
124
126
  .option('-q, --quiet', 'Minimal output', false)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawkeep",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Private, encrypted backups with time-travel restore. Zero-knowledge protection for AI agents, configs, and everything you care about.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,15 +3,45 @@
3
3
  const chalk = require('chalk');
4
4
  const ora = require('ora');
5
5
  const path = require('path');
6
- const crypto = require('crypto');
6
+ const https = require('https');
7
7
  const http = require('http');
8
- const { exec } = require('child_process');
8
+ const { exec, spawn } = require('child_process');
9
9
  const ClawGit = require('../core/git');
10
10
  const BackupManager = require('../core/backup');
11
11
  const { loadCredentials, saveCredentials, clearCredentials } = require('../core/credentials');
12
12
 
13
- const DEFAULT_ENDPOINT = 'https://clawkeep.com';
14
- const CALLBACK_TIMEOUT = 120000;
13
+ const DEFAULT_WEB_URL = 'https://clawkeep.com';
14
+ const DEFAULT_API_URL = 'https://api.clawkeep.com';
15
+ const POLL_INTERVAL = 2000;
16
+ const POLL_TIMEOUT = 600000; // 10 minutes (matches KV TTL)
17
+
18
+ /**
19
+ * Derive web and API URLs from an --endpoint flag.
20
+ * - No flag → defaults
21
+ * - https://clawkeep.com → fix to api.clawkeep.com
22
+ * - https://api.clawkeep.com → derive clawkeep.com
23
+ * - Custom → self-hosted, same URL for both
24
+ */
25
+ function deriveUrls(endpoint) {
26
+ if (!endpoint) {
27
+ return { webUrl: DEFAULT_WEB_URL, apiUrl: DEFAULT_API_URL };
28
+ }
29
+ const clean = endpoint.replace(/\/$/, '');
30
+ try {
31
+ const url = new URL(clean);
32
+ // Official domain variants
33
+ if (url.hostname === 'clawkeep.com' || url.hostname === 'www.clawkeep.com') {
34
+ return { webUrl: DEFAULT_WEB_URL, apiUrl: DEFAULT_API_URL };
35
+ }
36
+ if (url.hostname === 'api.clawkeep.com') {
37
+ return { webUrl: DEFAULT_WEB_URL, apiUrl: DEFAULT_API_URL };
38
+ }
39
+ // Self-hosted: use same URL for both
40
+ return { webUrl: clean, apiUrl: clean };
41
+ } catch {
42
+ return { webUrl: clean, apiUrl: clean };
43
+ }
44
+ }
15
45
 
16
46
  module.exports = async function cloud(subcommand, opts) {
17
47
  if (!subcommand || subcommand === 'setup') {
@@ -29,66 +59,66 @@ module.exports = async function cloud(subcommand, opts) {
29
59
 
30
60
  async function doSetup(opts) {
31
61
  const dir = path.resolve(opts.dir || '.');
32
- const endpoint = (opts.endpoint || DEFAULT_ENDPOINT).replace(/\/$/, '');
62
+ const { webUrl, apiUrl } = deriveUrls(opts.endpoint);
33
63
  const apiKey = opts.apiKey || process.env.CLAWKEEP_API_KEY;
34
64
  const workspace = opts.workspace;
35
65
 
36
66
  // Headless mode: --api-key and --workspace provided directly
37
67
  if (apiKey && workspace) {
38
- return doHeadlessSetup(dir, apiKey, workspace, endpoint, opts);
39
- }
40
-
41
- // SSH detection
42
- if (process.env.SSH_CLIENT && !apiKey) {
43
- console.log('');
44
- console.log(chalk.yellow(' Detected SSH session. Browser flow may not work.'));
45
- console.log(chalk.dim(' Use headless mode instead:'));
46
- console.log(chalk.dim(' $ clawkeep cloud setup --api-key ck_live_xxx --workspace ws_xxx'));
47
- console.log('');
48
- process.exit(1);
68
+ return doHeadlessSetup(dir, apiKey, workspace, apiUrl, opts);
49
69
  }
50
70
 
51
- // Browser callback flow
52
- return doBrowserSetup(dir, endpoint, opts);
71
+ // Browser polling flow (works everywhere including SSH)
72
+ return doBrowserSetup(dir, webUrl, apiUrl, opts);
53
73
  }
54
74
 
55
- async function doHeadlessSetup(dir, apiKey, workspace, endpoint, opts) {
75
+ async function doHeadlessSetup(dir, apiKey, workspace, apiUrl, opts) {
56
76
  const spinner = ora('Configuring ClawKeep Cloud...').start();
57
77
  try {
78
+ // Auto-init if directory not initialized
79
+ const claw = new ClawGit(dir);
80
+ if (!(await claw.isInitialized())) {
81
+ await claw.init();
82
+ await claw.snap('initial backup');
83
+ spinner.text = 'Initialized and configuring cloud...';
84
+ }
85
+
58
86
  // Save global credentials
59
- saveCredentials({ apiKey, endpoint });
87
+ saveCredentials({ apiKey, endpoint: apiUrl });
60
88
 
61
- // Configure project if initialized
62
- const claw = new ClawGit(dir);
63
- if (await claw.isInitialized()) {
64
- const bm = new BackupManager(claw);
65
- await bm.setTarget('cloud', { workspace, endpoint });
89
+ // Configure project
90
+ const bm = new BackupManager(claw);
91
+ await bm.setTarget('cloud', { workspace, endpoint: apiUrl });
66
92
 
67
- // Set password if provided
68
- const password = opts.password || process.env.CLAWKEEP_PASSWORD;
69
- if (password && !bm.hasPassword()) {
70
- bm.setPassword(password);
71
- }
93
+ // Set password if provided
94
+ const password = opts.password || process.env.CLAWKEEP_PASSWORD;
95
+ if (password && !bm.hasPassword()) {
96
+ bm.setPassword(password);
97
+ }
72
98
 
73
- // Test connection
74
- const test = await bm.test();
75
- if (test.ok) {
76
- spinner.succeed('Connected to ClawKeep Cloud');
77
- console.log(` ${chalk.green('\u2713')} ${test.message}${test.latencyMs ? ` (${test.latencyMs}ms)` : ''}`);
78
- } else {
79
- spinner.warn('Credentials saved but connection test failed');
80
- console.log(` ${chalk.yellow('\u26a0')} ${test.message}`);
81
- }
99
+ // Test connection
100
+ const test = await bm.test();
101
+ if (test.ok) {
102
+ spinner.succeed('Connected to ClawKeep Cloud');
103
+ console.log(` ${chalk.green('\u2713')} ${test.message}${test.latencyMs ? ` (${test.latencyMs}ms)` : ''}`);
82
104
  } else {
83
- spinner.succeed('Credentials saved');
84
- console.log(chalk.dim(' Run `clawkeep init` in a project directory, then `clawkeep cloud setup` again.'));
105
+ spinner.warn('Credentials saved but connection test failed');
106
+ console.log(` ${chalk.yellow('\u26a0')} ${test.message}`);
85
107
  }
86
108
 
87
109
  console.log('');
88
110
  console.log(` ${chalk.dim('API Key')} ${maskKey(apiKey)}`);
89
111
  console.log(` ${chalk.dim('Workspace')} ${workspace}`);
90
- console.log(` ${chalk.dim('Endpoint')} ${endpoint}`);
112
+ console.log(` ${chalk.dim('Endpoint')} ${apiUrl}`);
91
113
  console.log('');
114
+
115
+ // Show what's next
116
+ showNextSteps(bm, opts);
117
+
118
+ // Auto-start watcher if --watch
119
+ if (opts.watch) {
120
+ startSyncWatcher(dir);
121
+ }
92
122
  } catch (err) {
93
123
  spinner.fail('Setup failed');
94
124
  console.error(chalk.red(' ' + err.message));
@@ -96,131 +126,241 @@ async function doHeadlessSetup(dir, apiKey, workspace, endpoint, opts) {
96
126
  }
97
127
  }
98
128
 
99
- async function doBrowserSetup(dir, endpoint, opts) {
100
- const state = crypto.randomBytes(24).toString('hex');
101
-
129
+ async function doBrowserSetup(dir, webUrl, apiUrl, opts) {
102
130
  console.log('');
103
131
  console.log(chalk.bold.cyan(' \ud83d\udc3e ClawKeep Cloud Setup'));
104
132
  console.log('');
105
133
 
106
- // Start temporary callback server
107
- const { port, promise } = await startCallbackServer(state);
134
+ if (process.env.SSH_CLIENT) {
135
+ console.log(chalk.dim(' SSH session detected polling mode (no localhost needed).'));
136
+ console.log('');
137
+ }
138
+
139
+ // Step 1: Create session on API
140
+ const sessionSpinner = ora('Creating connect session...').start();
141
+ let code;
142
+ try {
143
+ const dirName = path.basename(path.resolve(dir));
144
+ const response = await httpPost(`${apiUrl}/api/connect/session`, { dir_name: dirName });
145
+ code = response.code;
146
+ sessionSpinner.succeed(`Session code: ${chalk.bold(code)}`);
147
+ } catch (err) {
148
+ sessionSpinner.fail('Failed to create session');
149
+ console.error(chalk.red(' ' + err.message));
150
+ process.exit(1);
151
+ }
108
152
 
109
- // Build connect URL
153
+ // Step 2: Open browser
110
154
  const dirName = path.basename(path.resolve(dir));
111
- const connectUrl = `${endpoint}/connect?callback_port=${port}&state=${state}&dir_name=${encodeURIComponent(dirName)}`;
155
+ const connectUrl = `${webUrl}/connect?code=${code}&dir_name=${encodeURIComponent(dirName)}`;
112
156
 
157
+ console.log('');
113
158
  console.log(chalk.dim(' Opening browser...'));
114
159
  console.log('');
115
160
  console.log(` ${chalk.dim('If it doesn\'t open, visit:')} `);
116
161
  console.log(` ${chalk.cyan(connectUrl)}`);
117
162
  console.log('');
118
163
 
119
- // Open browser
120
164
  openBrowser(connectUrl);
121
165
 
122
- const spinner = ora('Waiting for browser authorization...').start();
166
+ // Step 3: Poll for completion
167
+ const pollSpinner = ora('Waiting for browser authorization...').start();
123
168
 
124
169
  try {
125
- const result = await promise;
126
- spinner.succeed('Authorization received');
170
+ const result = await pollForCompletion(apiUrl, code, POLL_TIMEOUT);
171
+ pollSpinner.succeed('Authorization received');
127
172
 
128
173
  // Save credentials
129
- saveCredentials({ apiKey: result.apiKey, endpoint });
174
+ saveCredentials({ apiKey: result.api_key, endpoint: apiUrl });
130
175
 
131
- // Configure project
176
+ // Auto-init if needed
132
177
  const claw = new ClawGit(dir);
133
- if (await claw.isInitialized()) {
134
- const bm = new BackupManager(claw);
135
- await bm.setTarget('cloud', { workspace: result.workspace, endpoint });
178
+ if (!(await claw.isInitialized())) {
179
+ await claw.init();
180
+ await claw.snap('initial backup');
181
+ }
136
182
 
137
- const password = opts.password || process.env.CLAWKEEP_PASSWORD;
138
- if (password && !bm.hasPassword()) {
139
- bm.setPassword(password);
140
- }
183
+ // Configure project
184
+ const bm = new BackupManager(claw);
185
+ await bm.setTarget('cloud', { workspace: result.workspace_id, endpoint: apiUrl });
141
186
 
142
- const test = await bm.test();
143
- if (test.ok) {
144
- console.log(` ${chalk.green('\u2713')} ${test.message}${test.latencyMs ? ` (${test.latencyMs}ms)` : ''}`);
145
- }
187
+ const password = opts.password || process.env.CLAWKEEP_PASSWORD;
188
+ if (password && !bm.hasPassword()) {
189
+ bm.setPassword(password);
190
+ }
146
191
 
147
- if (!bm.hasPassword()) {
148
- console.log('');
149
- console.log(chalk.yellow(' \u26a0 Set a password before syncing:'));
150
- console.log(chalk.dim(' $ clawkeep backup set-password'));
151
- }
192
+ const test = await bm.test();
193
+ if (test.ok) {
194
+ console.log(` ${chalk.green('\u2713')} ${test.message}${test.latencyMs ? ` (${test.latencyMs}ms)` : ''}`);
152
195
  }
153
196
 
154
197
  console.log('');
155
- console.log(` ${chalk.dim('Workspace')} ${result.workspace}`);
198
+ console.log(` ${chalk.dim('Workspace')} ${result.workspace_id}`);
156
199
  console.log('');
200
+
201
+ // Show what's next
202
+ showNextSteps(bm, opts);
203
+
204
+ // Auto-start watcher if --watch
205
+ if (opts.watch) {
206
+ startSyncWatcher(dir);
207
+ }
157
208
  } catch (err) {
158
- spinner.fail(err.message || 'Setup failed');
209
+ pollSpinner.fail(err.message || 'Setup failed');
159
210
  process.exit(1);
160
211
  }
161
212
  }
162
213
 
163
- function startCallbackServer(expectedState) {
164
- return new Promise((resolveSetup) => {
165
- let settled = false;
214
+ function showNextSteps(bm, opts) {
215
+ const hasPassword = bm.hasPassword();
216
+ const willWatch = opts.watch;
166
217
 
167
- const server = http.createServer((req, res) => {
168
- const url = new URL(req.url, 'http://localhost');
169
- if (url.pathname !== '/callback') {
170
- res.writeHead(404);
171
- res.end('Not found');
172
- return;
173
- }
218
+ if (hasPassword && willWatch) {
219
+ // Everything is set up, nothing more to do
220
+ return;
221
+ }
174
222
 
175
- const apiKey = url.searchParams.get('api_key');
176
- const workspace = url.searchParams.get('workspace');
177
- const state = url.searchParams.get('state');
223
+ console.log(chalk.bold(' What\'s next:'));
224
+ console.log('');
178
225
 
179
- if (state !== expectedState) {
180
- res.writeHead(400, { 'Content-Type': 'text/html' });
181
- res.end('<html><body><h2>Error: Invalid state parameter</h2><p>Please try again from the CLI.</p></body></html>');
182
- return;
183
- }
226
+ if (!hasPassword) {
227
+ console.log(` ${chalk.yellow('1.')} Set an encryption password:`);
228
+ console.log(chalk.dim(' $ clawkeep backup set-password'));
229
+ console.log('');
230
+ }
184
231
 
185
- if (!apiKey || !workspace) {
186
- res.writeHead(400, { 'Content-Type': 'text/html' });
187
- res.end('<html><body><h2>Error: Missing parameters</h2><p>Please try again from the CLI.</p></body></html>');
188
- return;
189
- }
232
+ if (!willWatch) {
233
+ const step = hasPassword ? '1.' : '2.';
234
+ console.log(` ${chalk.yellow(step)} Start auto-sync watcher:`);
235
+ console.log(chalk.dim(' $ clawkeep watch --sync --daemon'));
236
+ console.log('');
237
+ }
190
238
 
191
- // Success
192
- res.writeHead(200, { 'Content-Type': 'text/html' });
193
- res.end(`<html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0a0a0a;color:#fafafa">
194
- <div style="text-align:center">
195
- <h1 style="font-size:3rem;margin-bottom:0.5rem">\u2713</h1>
196
- <h2>Connected!</h2>
197
- <p style="color:#888">You can close this tab and return to the terminal.</p>
198
- </div>
199
- </body></html>`);
200
-
201
- settled = true;
202
- server.close();
203
- resolveResult({ apiKey, workspace });
204
- });
239
+ if (!hasPassword) {
240
+ console.log(chalk.dim(' Or do it all in one line:'));
241
+ console.log(chalk.dim(' $ clawkeep backup set-password && clawkeep watch --sync --daemon'));
242
+ console.log('');
243
+ }
244
+ }
245
+
246
+ function startSyncWatcher(dir) {
247
+ console.log(chalk.dim(' Starting background watcher with --sync...'));
205
248
 
206
- let resolveResult;
207
- const resultPromise = new Promise((resolve, reject) => {
208
- resolveResult = resolve;
249
+ const binPath = path.join(__dirname, '../../bin/clawkeep.js');
250
+ const args = ['watch', '--sync', '--daemon', '-d', dir];
209
251
 
210
- // Timeout
211
- setTimeout(() => {
212
- if (!settled) {
213
- settled = true;
214
- server.close();
215
- reject(new Error('Timed out waiting for browser authorization (120s)'));
252
+ const child = spawn(process.execPath, [binPath, ...args], {
253
+ detached: true,
254
+ stdio: 'ignore',
255
+ });
256
+ child.unref();
257
+
258
+ console.log(` ${chalk.green('\u2713')} Watcher started (PID ${child.pid})`);
259
+ console.log(chalk.dim(' Stop with: clawkeep watch --stop'));
260
+ console.log('');
261
+ }
262
+
263
+ // ── HTTP helpers (zero deps) ─────────────────────────────────────────
264
+
265
+ function httpPost(url, body) {
266
+ return new Promise((resolve, reject) => {
267
+ const parsed = new URL(url);
268
+ const mod = parsed.protocol === 'https:' ? https : http;
269
+ const data = JSON.stringify(body);
270
+
271
+ const req = mod.request({
272
+ hostname: parsed.hostname,
273
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
274
+ path: parsed.pathname + parsed.search,
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ 'Content-Length': Buffer.byteLength(data),
279
+ },
280
+ }, (res) => {
281
+ let buf = '';
282
+ res.on('data', (chunk) => buf += chunk);
283
+ res.on('end', () => {
284
+ try {
285
+ const json = JSON.parse(buf);
286
+ if (res.statusCode >= 400) {
287
+ reject(new Error(json.error?.message || `HTTP ${res.statusCode}`));
288
+ } else {
289
+ resolve(json);
290
+ }
291
+ } catch {
292
+ reject(new Error(`Invalid response from server (HTTP ${res.statusCode})`));
216
293
  }
217
- }, CALLBACK_TIMEOUT);
294
+ });
218
295
  });
219
296
 
220
- server.listen(0, '127.0.0.1', () => {
221
- const port = server.address().port;
222
- resolveSetup({ port, promise: resultPromise });
297
+ req.on('error', (err) => reject(new Error(`Connection failed: ${err.message}`)));
298
+ req.write(data);
299
+ req.end();
300
+ });
301
+ }
302
+
303
+ function httpGet(url) {
304
+ return new Promise((resolve, reject) => {
305
+ const parsed = new URL(url);
306
+ const mod = parsed.protocol === 'https:' ? https : http;
307
+
308
+ const req = mod.request({
309
+ hostname: parsed.hostname,
310
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
311
+ path: parsed.pathname + parsed.search,
312
+ method: 'GET',
313
+ }, (res) => {
314
+ let buf = '';
315
+ res.on('data', (chunk) => buf += chunk);
316
+ res.on('end', () => {
317
+ try {
318
+ const json = JSON.parse(buf);
319
+ if (res.statusCode >= 400) {
320
+ reject(new Error(json.error?.message || `HTTP ${res.statusCode}`));
321
+ } else {
322
+ resolve(json);
323
+ }
324
+ } catch {
325
+ reject(new Error(`Invalid response from server (HTTP ${res.statusCode})`));
326
+ }
327
+ });
223
328
  });
329
+
330
+ req.on('error', (err) => reject(new Error(`Connection failed: ${err.message}`)));
331
+ req.end();
332
+ });
333
+ }
334
+
335
+ function pollForCompletion(apiUrl, code, timeoutMs) {
336
+ return new Promise((resolve, reject) => {
337
+ const start = Date.now();
338
+
339
+ const poll = async () => {
340
+ if (Date.now() - start > timeoutMs) {
341
+ reject(new Error('Timed out waiting for browser authorization (10m)'));
342
+ return;
343
+ }
344
+
345
+ try {
346
+ const result = await httpGet(`${apiUrl}/api/connect/poll/${code}`);
347
+ if (result.status === 'completed') {
348
+ resolve(result);
349
+ return;
350
+ }
351
+ } catch (err) {
352
+ // Session expired or other error
353
+ if (err.message.includes('not found') || err.message.includes('expired')) {
354
+ reject(new Error('Session expired. Please try again.'));
355
+ return;
356
+ }
357
+ // Network errors are transient, keep polling
358
+ }
359
+
360
+ setTimeout(poll, POLL_INTERVAL);
361
+ };
362
+
363
+ poll();
224
364
  });
225
365
  }
226
366
 
@@ -14,6 +14,7 @@ module.exports = async function watch(opts) {
14
14
  const dir = path.resolve(opts.dir || '.');
15
15
  const interval = parseInt(opts.interval) || 5000;
16
16
  const autoPush = opts.push || false;
17
+ const autoSync = opts.sync || false;
17
18
  const quiet = opts.quiet || false;
18
19
 
19
20
  if (opts.stop) return stopDaemon(dir);
@@ -26,7 +27,7 @@ module.exports = async function watch(opts) {
26
27
  }
27
28
 
28
29
  if (opts.daemon) return startDaemon(dir, interval, opts);
29
- startWatcher(claw, dir, interval, autoPush, quiet);
30
+ startWatcher(claw, dir, interval, autoPush, autoSync, quiet);
30
31
  };
31
32
 
32
33
  function startDaemon(dir, interval, opts) {
@@ -39,6 +40,7 @@ function startDaemon(dir, interval, opts) {
39
40
 
40
41
  const args = ['watch', '--interval', String(interval), '-d', dir];
41
42
  if (opts.push) args.push('--push');
43
+ if (opts.sync) args.push('--sync');
42
44
  args.push('-q');
43
45
 
44
46
  const binPath = path.join(__dirname, '../../bin/clawkeep.js');
@@ -67,7 +69,7 @@ function stopDaemon(dir) {
67
69
  try { fs.unlinkSync(pidPath); } catch {}
68
70
  }
69
71
 
70
- function startWatcher(claw, dir, interval, autoPush, quiet) {
72
+ function startWatcher(claw, dir, interval, autoPush, autoSync, quiet) {
71
73
  const config = claw.loadConfig();
72
74
  const pidPath = path.join(dir, PID_FILE);
73
75
 
@@ -81,6 +83,7 @@ function startWatcher(claw, dir, interval, autoPush, quiet) {
81
83
  console.log(` ${chalk.dim('Directory')} ${dir}`);
82
84
  console.log(` ${chalk.dim('Debounce')} ${interval}ms`);
83
85
  console.log(` ${chalk.dim('Auto-push')} ${autoPush ? chalk.green('on') : chalk.dim('off')}`);
86
+ console.log(` ${chalk.dim('Auto-sync')} ${(autoSync || config.backup?.autoSync) ? chalk.green('on') : chalk.dim('off')}`);
84
87
  console.log('');
85
88
  console.log(chalk.dim(' Waiting for changes... (Ctrl+C to stop · --daemon to run in background)'));
86
89
  console.log('');
@@ -140,7 +143,7 @@ function startWatcher(claw, dir, interval, autoPush, quiet) {
140
143
  }
141
144
 
142
145
  // Auto-sync to backup target
143
- if (config.backup && config.backup.autoSync && config.backup.target) {
146
+ if (config.backup && (config.backup.autoSync || autoSync) && config.backup.target) {
144
147
  try {
145
148
  const bm = new BackupManager(claw);
146
149
  await bm.sync(process.env.CLAWKEEP_PASSWORD || null);
@@ -140,10 +140,11 @@ class BackupManager {
140
140
  if (!target) throw new Error('No backup target configured');
141
141
 
142
142
  let result;
143
+ let transport;
143
144
  if (target === 'local' || target === 's3' || target === 'cloud') {
144
145
  // Encrypted incremental sync (local, S3, or cloud)
145
146
  if (!password) throw new Error('Password required for encrypted sync');
146
- const transport = createTransport(backup, this.claw);
147
+ transport = createTransport(backup, this.claw);
147
148
  const sm = new SyncManager(this.claw, transport, password);
148
149
  result = await sm.sync();
149
150
  } else if (target === 'git') {
@@ -156,6 +157,13 @@ class BackupManager {
156
157
  freshConfig.backup.lastSync = new Date().toISOString();
157
158
  this.claw.saveConfig(freshConfig);
158
159
 
160
+ // Report sync stats to cloud API (fire-and-forget)
161
+ if (target === 'cloud' && transport.reportSync) {
162
+ const chunkCount = freshConfig.backup.chunkCount || result.chunkCount || 0;
163
+ const totalSize = result.totalSize || 0;
164
+ transport.reportSync({ chunkCount, totalSize }).catch(() => {});
165
+ }
166
+
159
167
  return { ...result, lastSync: freshConfig.backup.lastSync };
160
168
  }
161
169
 
@@ -181,6 +181,36 @@ class CloudTransport extends BackupTransport {
181
181
  : now + 3600000;
182
182
  }
183
183
 
184
+ /**
185
+ * Report sync stats to the cloud API (fire-and-forget).
186
+ */
187
+ async reportSync({ chunkCount, totalSize }) {
188
+ const url = `${this.endpoint}/api/workspaces/${this.workspace}/sync-report`;
189
+ const body = JSON.stringify({ chunk_count: chunkCount, storage_bytes: totalSize });
190
+ try {
191
+ await new Promise((resolve, reject) => {
192
+ const parsed = new URL(url);
193
+ const mod = parsed.protocol === 'https:' ? https : http;
194
+ const req = mod.request(url, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'Authorization': 'Bearer ' + this.apiKey,
198
+ 'Content-Type': 'application/json',
199
+ 'Accept': 'application/json',
200
+ },
201
+ }, (res) => {
202
+ res.resume(); // drain response
203
+ res.on('end', resolve);
204
+ });
205
+ req.on('error', reject);
206
+ req.setTimeout(15000, () => req.destroy(new Error('Sync report timeout')));
207
+ req.end(body);
208
+ });
209
+ } catch {
210
+ // Fire-and-forget: don't fail the sync if report fails
211
+ }
212
+ }
213
+
184
214
  async writeFile(remotePath, buffer) {
185
215
  await this._ensureCredentials();
186
216
  return this._inner.writeFile(remotePath, buffer);