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 +2 -0
- package/package.json +1 -1
- package/src/commands/cloud.js +263 -123
- package/src/commands/watch.js +6 -3
- package/src/core/backup.js +9 -1
- package/src/core/transport.js +30 -0
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
package/src/commands/cloud.js
CHANGED
|
@@ -3,15 +3,45 @@
|
|
|
3
3
|
const chalk = require('chalk');
|
|
4
4
|
const ora = require('ora');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const
|
|
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
|
|
14
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
52
|
-
return doBrowserSetup(dir,
|
|
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,
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
84
|
-
console.log(chalk.
|
|
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')} ${
|
|
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,
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
153
|
+
// Step 2: Open browser
|
|
110
154
|
const dirName = path.basename(path.resolve(dir));
|
|
111
|
-
const connectUrl = `${
|
|
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
|
-
|
|
166
|
+
// Step 3: Poll for completion
|
|
167
|
+
const pollSpinner = ora('Waiting for browser authorization...').start();
|
|
123
168
|
|
|
124
169
|
try {
|
|
125
|
-
const result = await
|
|
126
|
-
|
|
170
|
+
const result = await pollForCompletion(apiUrl, code, POLL_TIMEOUT);
|
|
171
|
+
pollSpinner.succeed('Authorization received');
|
|
127
172
|
|
|
128
173
|
// Save credentials
|
|
129
|
-
saveCredentials({ apiKey: result.
|
|
174
|
+
saveCredentials({ apiKey: result.api_key, endpoint: apiUrl });
|
|
130
175
|
|
|
131
|
-
//
|
|
176
|
+
// Auto-init if needed
|
|
132
177
|
const claw = new ClawGit(dir);
|
|
133
|
-
if (await claw.isInitialized()) {
|
|
134
|
-
|
|
135
|
-
await
|
|
178
|
+
if (!(await claw.isInitialized())) {
|
|
179
|
+
await claw.init();
|
|
180
|
+
await claw.snap('initial backup');
|
|
181
|
+
}
|
|
136
182
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
183
|
+
// Configure project
|
|
184
|
+
const bm = new BackupManager(claw);
|
|
185
|
+
await bm.setTarget('cloud', { workspace: result.workspace_id, endpoint: apiUrl });
|
|
141
186
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
187
|
+
const password = opts.password || process.env.CLAWKEEP_PASSWORD;
|
|
188
|
+
if (password && !bm.hasPassword()) {
|
|
189
|
+
bm.setPassword(password);
|
|
190
|
+
}
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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
|
-
|
|
209
|
+
pollSpinner.fail(err.message || 'Setup failed');
|
|
159
210
|
process.exit(1);
|
|
160
211
|
}
|
|
161
212
|
}
|
|
162
213
|
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
214
|
+
function showNextSteps(bm, opts) {
|
|
215
|
+
const hasPassword = bm.hasPassword();
|
|
216
|
+
const willWatch = opts.watch;
|
|
166
217
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
const state = url.searchParams.get('state');
|
|
223
|
+
console.log(chalk.bold(' What\'s next:'));
|
|
224
|
+
console.log('');
|
|
178
225
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
resolveResult = resolve;
|
|
249
|
+
const binPath = path.join(__dirname, '../../bin/clawkeep.js');
|
|
250
|
+
const args = ['watch', '--sync', '--daemon', '-d', dir];
|
|
209
251
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}
|
|
294
|
+
});
|
|
218
295
|
});
|
|
219
296
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
package/src/commands/watch.js
CHANGED
|
@@ -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);
|
package/src/core/backup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/core/transport.js
CHANGED
|
@@ -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);
|