clawkeep 0.1.1 → 0.2.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.
- package/README.md +26 -3
- package/bin/clawkeep.js +17 -0
- package/package.json +1 -1
- package/src/commands/backup.js +21 -5
- package/src/commands/cloud.js +292 -0
- package/src/commands/watch.js +1 -1
- package/src/core/backup.js +49 -13
- package/src/core/credentials.js +66 -0
- package/src/core/s3-client.js +234 -0
- package/src/core/transport.js +160 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="assets/banner.jpg" alt="ClawKeep" width="100%" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
<h1 align="center"
|
|
5
|
+
<h1 align="center">ClawKeep</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>Private, encrypted backups that just work.</strong><br>
|
|
@@ -108,9 +108,32 @@ Your backup target receives **encrypted chunks only**. No metadata. No history.
|
|
|
108
108
|
| Target | Status | Description |
|
|
109
109
|
|---|---|---|
|
|
110
110
|
| **Local path** | ✅ Available | Any mounted folder — NAS, USB drive, network share |
|
|
111
|
-
| **S3 / R2** |
|
|
111
|
+
| **S3 / R2** | ✅ Available | Object storage (Cloudflare R2, AWS S3, MinIO, Backblaze B2, Wasabi) |
|
|
112
112
|
| **ClawKeep Cloud** | 🔜 Coming soon | Managed zero-knowledge backup |
|
|
113
113
|
|
|
114
|
+
### S3 / R2 Setup
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Configure S3-compatible target
|
|
118
|
+
clawkeep backup s3 \
|
|
119
|
+
--endpoint https://your-account.r2.cloudflarestorage.com \
|
|
120
|
+
--bucket my-backups \
|
|
121
|
+
--access-key AKIAIOSFODNN7EXAMPLE \
|
|
122
|
+
--secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
|
|
123
|
+
--region auto \
|
|
124
|
+
--prefix clawkeep/
|
|
125
|
+
|
|
126
|
+
# Or use environment variables for credentials
|
|
127
|
+
export CLAWKEEP_S3_ACCESS_KEY=your-access-key
|
|
128
|
+
export CLAWKEEP_S3_SECRET_KEY=your-secret-key
|
|
129
|
+
clawkeep backup s3 --endpoint https://... --bucket my-backups
|
|
130
|
+
|
|
131
|
+
# Sync encrypted chunks to S3
|
|
132
|
+
clawkeep backup sync
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Works with any S3-compatible service: **Cloudflare R2** (zero egress fees), **AWS S3**, **Backblaze B2**, **MinIO**, **Wasabi**, and more.
|
|
136
|
+
|
|
114
137
|
## Commands
|
|
115
138
|
|
|
116
139
|
| Command | What it does |
|
|
@@ -264,7 +287,7 @@ const oldContent = await claw.showFileAtCommit('abc123', 'config.yaml');
|
|
|
264
287
|
|
|
265
288
|
## Roadmap
|
|
266
289
|
|
|
267
|
-
- [
|
|
290
|
+
- [x] S3 / R2 / MinIO backend
|
|
268
291
|
- [ ] `clawkeep.com` — zero-knowledge cloud backup
|
|
269
292
|
- [ ] End-to-end encrypted team sharing
|
|
270
293
|
- [ ] Webhooks on file changes
|
package/bin/clawkeep.js
CHANGED
|
@@ -89,12 +89,29 @@ program
|
|
|
89
89
|
.description('Manage backup target (local, cloud, s3, git)')
|
|
90
90
|
.option('-d, --dir <path>', 'Target directory', '.')
|
|
91
91
|
.option('-p, --password <pass>', 'Encryption password (or CLAWKEEP_PASSWORD env)')
|
|
92
|
+
.option('--endpoint <url>', 'S3 endpoint URL')
|
|
93
|
+
.option('--bucket <name>', 'S3 bucket name')
|
|
94
|
+
.option('--access-key <key>', 'S3 access key ID')
|
|
95
|
+
.option('--secret-key <key>', 'S3 secret access key')
|
|
96
|
+
.option('--region <region>', 'S3 region (default: auto)')
|
|
97
|
+
.option('--prefix <prefix>', 'S3 key prefix')
|
|
92
98
|
.action((subcommand, targetPath, opts) => {
|
|
93
99
|
opts.args = targetPath ? [targetPath] : [];
|
|
94
100
|
opts.path = targetPath;
|
|
95
101
|
require('../src/commands/backup')(subcommand, opts.args, opts);
|
|
96
102
|
});
|
|
97
103
|
|
|
104
|
+
// cloud
|
|
105
|
+
program
|
|
106
|
+
.command('cloud [subcommand]')
|
|
107
|
+
.description('Connect to ClawKeep Cloud')
|
|
108
|
+
.option('-d, --dir <path>', 'Target directory', '.')
|
|
109
|
+
.option('--api-key <key>', 'API key (headless)')
|
|
110
|
+
.option('--workspace <id>', 'Workspace ID (headless)')
|
|
111
|
+
.option('--endpoint <url>', 'API endpoint')
|
|
112
|
+
.option('-p, --password <pass>', 'Encryption password')
|
|
113
|
+
.action((sub, opts) => require('../src/commands/cloud')(sub, opts));
|
|
114
|
+
|
|
98
115
|
// watch
|
|
99
116
|
program
|
|
100
117
|
.command('watch')
|
package/package.json
CHANGED
package/src/commands/backup.js
CHANGED
|
@@ -69,7 +69,7 @@ async function showStatus(bm) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Encryption status
|
|
72
|
-
if (cfg.target === 'local') {
|
|
72
|
+
if (cfg.target === 'local' || cfg.target === 's3' || cfg.target === 'cloud') {
|
|
73
73
|
console.log(` Encrypted: ${cfg.passwordSet ? chalk.green('\u2713 yes') : chalk.yellow('\u26a0 password not set')}`);
|
|
74
74
|
if (cfg.chunkCount > 0) {
|
|
75
75
|
console.log(` Chunks: ${cfg.chunkCount}`);
|
|
@@ -106,6 +106,21 @@ async function setTarget(bm, typeOrArgs, opts) {
|
|
|
106
106
|
process.exit(1);
|
|
107
107
|
}
|
|
108
108
|
options.url = url;
|
|
109
|
+
} else if (type === 's3') {
|
|
110
|
+
const endpoint = opts.endpoint || process.env.CLAWKEEP_S3_ENDPOINT;
|
|
111
|
+
const bucket = opts.bucket || process.env.CLAWKEEP_S3_BUCKET;
|
|
112
|
+
const accessKey = opts.accessKey || process.env.CLAWKEEP_S3_ACCESS_KEY;
|
|
113
|
+
const secretKey = opts.secretKey || process.env.CLAWKEEP_S3_SECRET_KEY;
|
|
114
|
+
const region = opts.region || process.env.CLAWKEEP_S3_REGION || 'auto';
|
|
115
|
+
const prefix = opts.prefix || process.env.CLAWKEEP_S3_PREFIX || '';
|
|
116
|
+
|
|
117
|
+
if (!endpoint || !bucket || !accessKey || !secretKey) {
|
|
118
|
+
console.error(chalk.red(' Missing S3 config. Required: --endpoint, --bucket, --access-key, --secret-key'));
|
|
119
|
+
console.error(chalk.dim(' Or use env vars: CLAWKEEP_S3_ENDPOINT, CLAWKEEP_S3_BUCKET, CLAWKEEP_S3_ACCESS_KEY, CLAWKEEP_S3_SECRET_KEY'));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
options = { endpoint, bucket, accessKey, secretKey, region, prefix };
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
const spinner = ora('Setting up backup target...').start();
|
|
@@ -123,8 +138,8 @@ async function setTarget(bm, typeOrArgs, opts) {
|
|
|
123
138
|
console.log(` ${chalk.yellow('\u26a0')} ${test.message}`);
|
|
124
139
|
}
|
|
125
140
|
|
|
126
|
-
// Remind about password for
|
|
127
|
-
if (type === 'local' && !bm.hasPassword()) {
|
|
141
|
+
// Remind about password for encrypted targets
|
|
142
|
+
if ((type === 'local' || type === 's3' || type === 'cloud') && !bm.hasPassword()) {
|
|
128
143
|
console.log('');
|
|
129
144
|
console.log(chalk.yellow(' \u26a0 Set a password before syncing:'));
|
|
130
145
|
console.log(chalk.dim(' $ clawkeep backup set-password'));
|
|
@@ -161,9 +176,10 @@ async function doSetPassword(bm, opts) {
|
|
|
161
176
|
|
|
162
177
|
async function doSync(bm, opts) {
|
|
163
178
|
const cfg = bm.getConfig();
|
|
164
|
-
const
|
|
179
|
+
const needsPassword = cfg.target === 'local' || cfg.target === 's3' || cfg.target === 'cloud';
|
|
180
|
+
const password = needsPassword ? getPassword(opts) : null;
|
|
165
181
|
|
|
166
|
-
if (
|
|
182
|
+
if (needsPassword && !password) {
|
|
167
183
|
console.error(chalk.red(' Password required for encrypted sync.'));
|
|
168
184
|
console.error(chalk.dim(' Use: CLAWKEEP_PASSWORD=xxx clawkeep backup sync'));
|
|
169
185
|
process.exit(1);
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const { exec } = require('child_process');
|
|
9
|
+
const ClawGit = require('../core/git');
|
|
10
|
+
const BackupManager = require('../core/backup');
|
|
11
|
+
const { loadCredentials, saveCredentials, clearCredentials } = require('../core/credentials');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_ENDPOINT = 'https://clawkeep.com';
|
|
14
|
+
const CALLBACK_TIMEOUT = 120000;
|
|
15
|
+
|
|
16
|
+
module.exports = async function cloud(subcommand, opts) {
|
|
17
|
+
if (!subcommand || subcommand === 'setup') {
|
|
18
|
+
return doSetup(opts);
|
|
19
|
+
} else if (subcommand === 'status') {
|
|
20
|
+
return doStatus(opts);
|
|
21
|
+
} else if (subcommand === 'logout') {
|
|
22
|
+
return doLogout();
|
|
23
|
+
} else {
|
|
24
|
+
console.error(chalk.red(` Unknown subcommand: ${subcommand}`));
|
|
25
|
+
console.error(chalk.dim(' Usage: clawkeep cloud [setup|status|logout]'));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function doSetup(opts) {
|
|
31
|
+
const dir = path.resolve(opts.dir || '.');
|
|
32
|
+
const endpoint = (opts.endpoint || DEFAULT_ENDPOINT).replace(/\/$/, '');
|
|
33
|
+
const apiKey = opts.apiKey || process.env.CLAWKEEP_API_KEY;
|
|
34
|
+
const workspace = opts.workspace;
|
|
35
|
+
|
|
36
|
+
// Headless mode: --api-key and --workspace provided directly
|
|
37
|
+
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);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Browser callback flow
|
|
52
|
+
return doBrowserSetup(dir, endpoint, opts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function doHeadlessSetup(dir, apiKey, workspace, endpoint, opts) {
|
|
56
|
+
const spinner = ora('Configuring ClawKeep Cloud...').start();
|
|
57
|
+
try {
|
|
58
|
+
// Save global credentials
|
|
59
|
+
saveCredentials({ apiKey, endpoint });
|
|
60
|
+
|
|
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 });
|
|
66
|
+
|
|
67
|
+
// Set password if provided
|
|
68
|
+
const password = opts.password || process.env.CLAWKEEP_PASSWORD;
|
|
69
|
+
if (password && !bm.hasPassword()) {
|
|
70
|
+
bm.setPassword(password);
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
spinner.succeed('Credentials saved');
|
|
84
|
+
console.log(chalk.dim(' Run `clawkeep init` in a project directory, then `clawkeep cloud setup` again.'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log('');
|
|
88
|
+
console.log(` ${chalk.dim('API Key')} ${maskKey(apiKey)}`);
|
|
89
|
+
console.log(` ${chalk.dim('Workspace')} ${workspace}`);
|
|
90
|
+
console.log(` ${chalk.dim('Endpoint')} ${endpoint}`);
|
|
91
|
+
console.log('');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
spinner.fail('Setup failed');
|
|
94
|
+
console.error(chalk.red(' ' + err.message));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function doBrowserSetup(dir, endpoint, opts) {
|
|
100
|
+
const state = crypto.randomBytes(24).toString('hex');
|
|
101
|
+
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(chalk.bold.cyan(' \ud83d\udc3e ClawKeep Cloud Setup'));
|
|
104
|
+
console.log('');
|
|
105
|
+
|
|
106
|
+
// Start temporary callback server
|
|
107
|
+
const { port, promise } = await startCallbackServer(state);
|
|
108
|
+
|
|
109
|
+
// Build connect URL
|
|
110
|
+
const dirName = path.basename(path.resolve(dir));
|
|
111
|
+
const connectUrl = `${endpoint}/connect?callback_port=${port}&state=${state}&dir_name=${encodeURIComponent(dirName)}`;
|
|
112
|
+
|
|
113
|
+
console.log(chalk.dim(' Opening browser...'));
|
|
114
|
+
console.log('');
|
|
115
|
+
console.log(` ${chalk.dim('If it doesn\'t open, visit:')} `);
|
|
116
|
+
console.log(` ${chalk.cyan(connectUrl)}`);
|
|
117
|
+
console.log('');
|
|
118
|
+
|
|
119
|
+
// Open browser
|
|
120
|
+
openBrowser(connectUrl);
|
|
121
|
+
|
|
122
|
+
const spinner = ora('Waiting for browser authorization...').start();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = await promise;
|
|
126
|
+
spinner.succeed('Authorization received');
|
|
127
|
+
|
|
128
|
+
// Save credentials
|
|
129
|
+
saveCredentials({ apiKey: result.apiKey, endpoint });
|
|
130
|
+
|
|
131
|
+
// Configure project
|
|
132
|
+
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 });
|
|
136
|
+
|
|
137
|
+
const password = opts.password || process.env.CLAWKEEP_PASSWORD;
|
|
138
|
+
if (password && !bm.hasPassword()) {
|
|
139
|
+
bm.setPassword(password);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const test = await bm.test();
|
|
143
|
+
if (test.ok) {
|
|
144
|
+
console.log(` ${chalk.green('\u2713')} ${test.message}${test.latencyMs ? ` (${test.latencyMs}ms)` : ''}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
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
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(` ${chalk.dim('Workspace')} ${result.workspace}`);
|
|
156
|
+
console.log('');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
spinner.fail(err.message || 'Setup failed');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startCallbackServer(expectedState) {
|
|
164
|
+
return new Promise((resolveSetup) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
|
|
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
|
+
}
|
|
174
|
+
|
|
175
|
+
const apiKey = url.searchParams.get('api_key');
|
|
176
|
+
const workspace = url.searchParams.get('workspace');
|
|
177
|
+
const state = url.searchParams.get('state');
|
|
178
|
+
|
|
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
|
+
}
|
|
184
|
+
|
|
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
|
+
}
|
|
190
|
+
|
|
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
|
+
});
|
|
205
|
+
|
|
206
|
+
let resolveResult;
|
|
207
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
208
|
+
resolveResult = resolve;
|
|
209
|
+
|
|
210
|
+
// Timeout
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
if (!settled) {
|
|
213
|
+
settled = true;
|
|
214
|
+
server.close();
|
|
215
|
+
reject(new Error('Timed out waiting for browser authorization (120s)'));
|
|
216
|
+
}
|
|
217
|
+
}, CALLBACK_TIMEOUT);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
server.listen(0, '127.0.0.1', () => {
|
|
221
|
+
const port = server.address().port;
|
|
222
|
+
resolveSetup({ port, promise: resultPromise });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function openBrowser(url) {
|
|
228
|
+
const platform = process.platform;
|
|
229
|
+
let cmd;
|
|
230
|
+
if (platform === 'darwin') {
|
|
231
|
+
cmd = `open "${url}"`;
|
|
232
|
+
} else if (platform === 'win32') {
|
|
233
|
+
cmd = `start "" "${url}"`;
|
|
234
|
+
} else {
|
|
235
|
+
cmd = `xdg-open "${url}"`;
|
|
236
|
+
}
|
|
237
|
+
exec(cmd, () => {});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function doStatus(opts) {
|
|
241
|
+
const creds = loadCredentials();
|
|
242
|
+
const dir = path.resolve(opts.dir || '.');
|
|
243
|
+
|
|
244
|
+
console.log('');
|
|
245
|
+
console.log(chalk.bold(' ClawKeep Cloud'));
|
|
246
|
+
console.log('');
|
|
247
|
+
|
|
248
|
+
if (!creds) {
|
|
249
|
+
console.log(` ${chalk.yellow('\u25cf')} Not connected`);
|
|
250
|
+
console.log('');
|
|
251
|
+
console.log(chalk.dim(' Run: clawkeep cloud setup'));
|
|
252
|
+
console.log('');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(` ${chalk.dim('API Key')} ${maskKey(creds.apiKey)}`);
|
|
257
|
+
console.log(` ${chalk.dim('Endpoint')} ${creds.endpoint}`);
|
|
258
|
+
|
|
259
|
+
// Show project-level info if initialized
|
|
260
|
+
try {
|
|
261
|
+
const claw = new ClawGit(dir);
|
|
262
|
+
if (await claw.isInitialized()) {
|
|
263
|
+
const bm = new BackupManager(claw);
|
|
264
|
+
const cfg = bm.getConfig();
|
|
265
|
+
if (cfg.target === 'cloud') {
|
|
266
|
+
console.log(` ${chalk.dim('Workspace')} ${cfg.workspaceId}`);
|
|
267
|
+
console.log(` ${chalk.dim('Last sync')} ${cfg.lastSync || 'never'}`);
|
|
268
|
+
console.log(` ${chalk.dim('Encrypted')} ${cfg.passwordSet ? chalk.green('\u2713 yes') : chalk.yellow('\u26a0 no password set')}`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log('');
|
|
271
|
+
console.log(chalk.dim(' This project is not targeting cloud. Run `clawkeep cloud setup` in this directory.'));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Not initialized, just show global info
|
|
276
|
+
}
|
|
277
|
+
console.log('');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function doLogout() {
|
|
281
|
+
clearCredentials();
|
|
282
|
+
console.log('');
|
|
283
|
+
console.log(chalk.green(' \u2713 Logged out of ClawKeep Cloud'));
|
|
284
|
+
console.log(chalk.dim(' Credentials removed. Project backup configs unchanged.'));
|
|
285
|
+
console.log('');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function maskKey(key) {
|
|
289
|
+
if (!key) return chalk.dim('none');
|
|
290
|
+
if (key.length <= 12) return key.slice(0, 4) + '***';
|
|
291
|
+
return key.slice(0, 12) + '***' + key.slice(-4);
|
|
292
|
+
}
|
package/src/commands/watch.js
CHANGED
|
@@ -143,7 +143,7 @@ function startWatcher(claw, dir, interval, autoPush, quiet) {
|
|
|
143
143
|
if (config.backup && config.backup.autoSync && config.backup.target) {
|
|
144
144
|
try {
|
|
145
145
|
const bm = new BackupManager(claw);
|
|
146
|
-
await bm.sync();
|
|
146
|
+
await bm.sync(process.env.CLAWKEEP_PASSWORD || null);
|
|
147
147
|
if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.blue('↑')} ${chalk.dim('synced to ' + (config.backup.targetLabel || config.backup.target))}`);
|
|
148
148
|
} catch (syncErr) {
|
|
149
149
|
if (!quiet) console.log(` ${chalk.dim(now)} ${chalk.yellow('⚠')} ${chalk.dim('sync failed: ' + syncErr.message)}`);
|
package/src/core/backup.js
CHANGED
|
@@ -106,10 +106,25 @@ class BackupManager {
|
|
|
106
106
|
await this.claw.setRemote(options.url);
|
|
107
107
|
} else if (type === 's3') {
|
|
108
108
|
config.backup.s3 = {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
region: options.region ||
|
|
109
|
+
endpoint: options.endpoint,
|
|
110
|
+
bucket: options.bucket,
|
|
111
|
+
region: options.region || 'auto',
|
|
112
|
+
accessKey: options.accessKey,
|
|
113
|
+
secretKey: options.secretKey,
|
|
114
|
+
prefix: options.prefix || '',
|
|
112
115
|
};
|
|
116
|
+
// Generate workspace ID if not set
|
|
117
|
+
if (!config.backup.workspaceId) {
|
|
118
|
+
const dirname = path.basename(this.dir);
|
|
119
|
+
const suffix = crypto.randomBytes(4).toString('hex');
|
|
120
|
+
config.backup.workspaceId = dirname + '-' + suffix;
|
|
121
|
+
}
|
|
122
|
+
} else if (type === 'cloud') {
|
|
123
|
+
config.backup.cloud = {
|
|
124
|
+
workspace: options.workspace,
|
|
125
|
+
endpoint: options.endpoint || 'https://api.clawkeep.com',
|
|
126
|
+
};
|
|
127
|
+
config.backup.workspaceId = options.workspace;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
this.claw.saveConfig(config);
|
|
@@ -125,8 +140,8 @@ class BackupManager {
|
|
|
125
140
|
if (!target) throw new Error('No backup target configured');
|
|
126
141
|
|
|
127
142
|
let result;
|
|
128
|
-
if (target === 'local') {
|
|
129
|
-
// Encrypted incremental sync
|
|
143
|
+
if (target === 'local' || target === 's3' || target === 'cloud') {
|
|
144
|
+
// Encrypted incremental sync (local, S3, or cloud)
|
|
130
145
|
if (!password) throw new Error('Password required for encrypted sync');
|
|
131
146
|
const transport = createTransport(backup, this.claw);
|
|
132
147
|
const sm = new SyncManager(this.claw, transport, password);
|
|
@@ -134,10 +149,6 @@ class BackupManager {
|
|
|
134
149
|
} else if (target === 'git') {
|
|
135
150
|
await this.claw.push();
|
|
136
151
|
result = { ok: true, target: 'git', synced: true };
|
|
137
|
-
} else if (target === 'cloud') {
|
|
138
|
-
throw new Error('ClawKeep Cloud is coming soon');
|
|
139
|
-
} else if (target === 's3') {
|
|
140
|
-
throw new Error('S3 backup is not yet implemented');
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
// Reload config (SyncManager may have saved changes)
|
|
@@ -195,9 +206,32 @@ class BackupManager {
|
|
|
195
206
|
return { ok: false, message: 'Remote unreachable: ' + e.message };
|
|
196
207
|
}
|
|
197
208
|
} else if (target === 'cloud') {
|
|
198
|
-
|
|
209
|
+
try {
|
|
210
|
+
const transport = createTransport(backup, this.claw);
|
|
211
|
+
await transport._ensureCredentials();
|
|
212
|
+
return { ok: true, message: 'Connected to ClawKeep Cloud', latencyMs: Date.now() - start };
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return { ok: false, message: 'Cloud unreachable: ' + e.message };
|
|
215
|
+
}
|
|
199
216
|
} else if (target === 's3') {
|
|
200
|
-
|
|
217
|
+
const s3Config = backup.s3;
|
|
218
|
+
if (!s3Config?.endpoint || !s3Config?.bucket) {
|
|
219
|
+
return { ok: false, message: 'S3 not fully configured' };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const S3Client = require('./s3-client');
|
|
223
|
+
const s3 = new S3Client({
|
|
224
|
+
endpoint: s3Config.endpoint,
|
|
225
|
+
bucket: s3Config.bucket,
|
|
226
|
+
region: s3Config.region || 'auto',
|
|
227
|
+
accessKey: s3Config.accessKey || process.env.CLAWKEEP_S3_ACCESS_KEY,
|
|
228
|
+
secretKey: s3Config.secretKey || process.env.CLAWKEEP_S3_SECRET_KEY,
|
|
229
|
+
});
|
|
230
|
+
await s3.listObjects('');
|
|
231
|
+
return { ok: true, message: 'Connected', latencyMs: Date.now() - start };
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return { ok: false, message: 'S3 unreachable: ' + e.message };
|
|
234
|
+
}
|
|
201
235
|
}
|
|
202
236
|
|
|
203
237
|
return { ok: false, message: 'Unknown target: ' + target };
|
|
@@ -207,7 +241,9 @@ class BackupManager {
|
|
|
207
241
|
async compact(password) {
|
|
208
242
|
const config = this.claw.loadConfig();
|
|
209
243
|
const backup = config.backup || {};
|
|
210
|
-
if (backup.target !== 'local'
|
|
244
|
+
if (backup.target !== 'local' && backup.target !== 's3' && backup.target !== 'cloud') {
|
|
245
|
+
throw new Error('Compact only supported for local, s3, and cloud targets');
|
|
246
|
+
}
|
|
211
247
|
if (!password) throw new Error('Password required for compact');
|
|
212
248
|
|
|
213
249
|
const transport = createTransport(backup, this.claw);
|
|
@@ -219,7 +255,7 @@ class BackupManager {
|
|
|
219
255
|
async getSyncStatus(password) {
|
|
220
256
|
const config = this.claw.loadConfig();
|
|
221
257
|
const backup = config.backup || {};
|
|
222
|
-
if (backup.target !== 'local' || !password) {
|
|
258
|
+
if ((backup.target !== 'local' && backup.target !== 's3' && backup.target !== 'cloud') || !password) {
|
|
223
259
|
return {
|
|
224
260
|
synced: false,
|
|
225
261
|
chunkCount: backup.chunkCount || 0,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// Mutable for testing
|
|
8
|
+
let _credsDir = path.join(os.homedir(), '.clawkeep');
|
|
9
|
+
let _credsFile = path.join(_credsDir, 'credentials.json');
|
|
10
|
+
|
|
11
|
+
function _getDir() { return _credsDir; }
|
|
12
|
+
function _getFile() { return _credsFile; }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load global credentials (API key + endpoint).
|
|
16
|
+
* Returns null if no credentials file or corrupted JSON.
|
|
17
|
+
*/
|
|
18
|
+
function loadCredentials() {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(_getFile())) return null;
|
|
21
|
+
const data = JSON.parse(fs.readFileSync(_getFile(), 'utf8'));
|
|
22
|
+
if (!data.apiKey) return null;
|
|
23
|
+
return {
|
|
24
|
+
apiKey: data.apiKey,
|
|
25
|
+
endpoint: data.endpoint || 'https://api.clawkeep.com',
|
|
26
|
+
};
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Save global credentials.
|
|
34
|
+
* @param {{ apiKey: string, endpoint?: string }} creds
|
|
35
|
+
*/
|
|
36
|
+
function saveCredentials(creds) {
|
|
37
|
+
if (!fs.existsSync(_getDir())) {
|
|
38
|
+
fs.mkdirSync(_getDir(), { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
const data = {
|
|
41
|
+
apiKey: creds.apiKey,
|
|
42
|
+
endpoint: creds.endpoint || 'https://api.clawkeep.com',
|
|
43
|
+
};
|
|
44
|
+
fs.writeFileSync(_getFile(), JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remove global credentials file.
|
|
49
|
+
*/
|
|
50
|
+
function clearCredentials() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(_getFile())) fs.unlinkSync(_getFile());
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Override paths for testing.
|
|
60
|
+
*/
|
|
61
|
+
function _setTestPaths(dir, file) {
|
|
62
|
+
_credsDir = dir;
|
|
63
|
+
_credsFile = file;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { loadCredentials, saveCredentials, clearCredentials, _setTestPaths };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
const EMPTY_HASH = crypto.createHash('sha256').update('').digest('hex');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lightweight S3 client with AWS Signature V4 signing.
|
|
11
|
+
* Zero external dependencies — uses Node.js built-in crypto + https.
|
|
12
|
+
* Works with: Cloudflare R2, AWS S3, Backblaze B2, MinIO, Wasabi.
|
|
13
|
+
*/
|
|
14
|
+
class S3Client {
|
|
15
|
+
constructor({ endpoint, bucket, region, accessKey, secretKey }) {
|
|
16
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
17
|
+
this.bucket = bucket;
|
|
18
|
+
this.region = region || 'auto';
|
|
19
|
+
this.accessKey = accessKey;
|
|
20
|
+
this.secretKey = secretKey;
|
|
21
|
+
|
|
22
|
+
const url = new URL(this.endpoint);
|
|
23
|
+
this.host = url.host;
|
|
24
|
+
this.isHttps = url.protocol === 'https:';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- AWS Signature V4 ---
|
|
28
|
+
|
|
29
|
+
_sha256(data) {
|
|
30
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_hmac(key, data) {
|
|
34
|
+
return crypto.createHmac('sha256', key).update(data).digest();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_signingKey(dateStamp) {
|
|
38
|
+
const kDate = this._hmac('AWS4' + this.secretKey, dateStamp);
|
|
39
|
+
const kRegion = this._hmac(kDate, this.region);
|
|
40
|
+
const kService = this._hmac(kRegion, 's3');
|
|
41
|
+
return this._hmac(kService, 'aws4_request');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_encodePath(p) {
|
|
45
|
+
return p.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_sign(method, objectPath, query, headers, payloadHash) {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
51
|
+
const amzDate = dateStamp + 'T' +
|
|
52
|
+
now.toISOString().slice(11, 19).replace(/:/g, '') + 'Z';
|
|
53
|
+
|
|
54
|
+
headers['x-amz-date'] = amzDate;
|
|
55
|
+
headers['x-amz-content-sha256'] = payloadHash;
|
|
56
|
+
|
|
57
|
+
// Sort headers by lowercase name
|
|
58
|
+
const sorted = Object.keys(headers)
|
|
59
|
+
.map(k => [k.toLowerCase(), headers[k]])
|
|
60
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
61
|
+
|
|
62
|
+
const signedHeaders = sorted.map(([k]) => k).join(';');
|
|
63
|
+
const canonicalHeaders = sorted
|
|
64
|
+
.map(([k, v]) => k + ':' + String(v).trim())
|
|
65
|
+
.join('\n') + '\n';
|
|
66
|
+
|
|
67
|
+
// Query string (sorted by key)
|
|
68
|
+
let canonicalQuery = '';
|
|
69
|
+
if (query && Object.keys(query).length > 0) {
|
|
70
|
+
canonicalQuery = Object.entries(query)
|
|
71
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
72
|
+
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
|
|
73
|
+
.join('&');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const canonicalRequest = [
|
|
77
|
+
method,
|
|
78
|
+
this._encodePath(objectPath),
|
|
79
|
+
canonicalQuery,
|
|
80
|
+
canonicalHeaders,
|
|
81
|
+
signedHeaders,
|
|
82
|
+
payloadHash,
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
const scope = dateStamp + '/' + this.region + '/s3/aws4_request';
|
|
86
|
+
const stringToSign = [
|
|
87
|
+
'AWS4-HMAC-SHA256',
|
|
88
|
+
amzDate,
|
|
89
|
+
scope,
|
|
90
|
+
this._sha256(canonicalRequest),
|
|
91
|
+
].join('\n');
|
|
92
|
+
|
|
93
|
+
const signature = this._hmac(this._signingKey(dateStamp), stringToSign)
|
|
94
|
+
.toString('hex');
|
|
95
|
+
|
|
96
|
+
headers['authorization'] =
|
|
97
|
+
'AWS4-HMAC-SHA256 Credential=' + this.accessKey + '/' + scope +
|
|
98
|
+
', SignedHeaders=' + signedHeaders +
|
|
99
|
+
', Signature=' + signature;
|
|
100
|
+
|
|
101
|
+
return canonicalQuery;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- HTTP ---
|
|
105
|
+
|
|
106
|
+
async _request(method, key, opts = {}) {
|
|
107
|
+
const { query, body, headers: extra, retries = 3 } = opts;
|
|
108
|
+
|
|
109
|
+
const objectPath = key
|
|
110
|
+
? '/' + this.bucket + '/' + key
|
|
111
|
+
: '/' + this.bucket;
|
|
112
|
+
|
|
113
|
+
const payloadHash = body ? this._sha256(body) : EMPTY_HASH;
|
|
114
|
+
|
|
115
|
+
const headers = { host: this.host, ...(extra || {}) };
|
|
116
|
+
if (body) {
|
|
117
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const canonicalQuery = this._sign(method, objectPath, query, headers, payloadHash);
|
|
121
|
+
const urlStr = this.endpoint + objectPath +
|
|
122
|
+
(canonicalQuery ? '?' + canonicalQuery : '');
|
|
123
|
+
|
|
124
|
+
let lastError;
|
|
125
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
126
|
+
if (attempt > 0) {
|
|
127
|
+
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 500));
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const result = await this._doRequest(method, urlStr, headers, body);
|
|
131
|
+
if (result.statusCode >= 500 || result.statusCode === 429) {
|
|
132
|
+
lastError = new Error(`S3 ${method} ${key || '/'}: HTTP ${result.statusCode}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
lastError = err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw lastError;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_doRequest(method, url, headers, body) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const mod = this.isHttps ? https : http;
|
|
146
|
+
const req = mod.request(url, { method, headers }, (res) => {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
res.on('data', c => chunks.push(c));
|
|
149
|
+
res.on('end', () => {
|
|
150
|
+
resolve({
|
|
151
|
+
statusCode: res.statusCode,
|
|
152
|
+
headers: res.headers,
|
|
153
|
+
body: Buffer.concat(chunks),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
req.on('error', reject);
|
|
158
|
+
req.setTimeout(60000, () => req.destroy(new Error('Request timeout')));
|
|
159
|
+
if (body) req.write(body);
|
|
160
|
+
req.end();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- S3 Operations ---
|
|
165
|
+
|
|
166
|
+
async putObject(key, body, contentType = 'application/octet-stream') {
|
|
167
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
168
|
+
const res = await this._request('PUT', key, {
|
|
169
|
+
body: buf,
|
|
170
|
+
headers: { 'content-type': contentType },
|
|
171
|
+
});
|
|
172
|
+
if (res.statusCode >= 300) throw this._error('PUT', key, res);
|
|
173
|
+
return { etag: res.headers['etag'] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getObject(key) {
|
|
177
|
+
const res = await this._request('GET', key);
|
|
178
|
+
if (res.statusCode >= 300) throw this._error('GET', key, res);
|
|
179
|
+
return res.body;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async headObject(key) {
|
|
183
|
+
const res = await this._request('HEAD', key, { retries: 1 });
|
|
184
|
+
if (res.statusCode === 404) return null;
|
|
185
|
+
if (res.statusCode >= 300) throw this._error('HEAD', key, res);
|
|
186
|
+
return {
|
|
187
|
+
size: parseInt(res.headers['content-length']) || 0,
|
|
188
|
+
lastModified: res.headers['last-modified'],
|
|
189
|
+
etag: res.headers['etag'],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async listObjects(prefix) {
|
|
194
|
+
const res = await this._request('GET', '', {
|
|
195
|
+
query: { 'list-type': '2', prefix: prefix || '' },
|
|
196
|
+
});
|
|
197
|
+
if (res.statusCode >= 300) throw this._error('LIST', prefix || '/', res);
|
|
198
|
+
return this._parseList(res.body.toString('utf8'));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async deleteObject(key) {
|
|
202
|
+
const res = await this._request('DELETE', key);
|
|
203
|
+
if (res.statusCode >= 300 && res.statusCode !== 404) {
|
|
204
|
+
throw this._error('DELETE', key, res);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Helpers ---
|
|
209
|
+
|
|
210
|
+
_error(op, key, res) {
|
|
211
|
+
const body = res.body ? res.body.toString('utf8') : '';
|
|
212
|
+
const code = (body.match(/<Code>(.*?)<\/Code>/) || [])[1] || '';
|
|
213
|
+
const message = (body.match(/<Message>(.*?)<\/Message>/) || [])[1] || '';
|
|
214
|
+
const detail = code ? `${code}: ${message}` : `HTTP ${res.statusCode}`;
|
|
215
|
+
return new Error(`S3 ${op} ${key || '/'} failed \u2014 ${detail}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_parseList(xml) {
|
|
219
|
+
const objects = [];
|
|
220
|
+
const re = /<Contents>([\s\S]*?)<\/Contents>/g;
|
|
221
|
+
let m;
|
|
222
|
+
while ((m = re.exec(xml)) !== null) {
|
|
223
|
+
const c = m[1];
|
|
224
|
+
objects.push({
|
|
225
|
+
Key: (c.match(/<Key>(.*?)<\/Key>/) || [])[1] || '',
|
|
226
|
+
Size: parseInt((c.match(/<Size>(.*?)<\/Size>/) || [])[1] || '0'),
|
|
227
|
+
LastModified: (c.match(/<LastModified>(.*?)<\/LastModified>/) || [])[1] || '',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return objects;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = S3Client;
|
package/src/core/transport.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Base transport interface for backup targets.
|
|
@@ -59,6 +61,144 @@ class LocalTransport extends BackupTransport {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* S3 transport — works with any S3-compatible service.
|
|
66
|
+
* Cloudflare R2, AWS S3, Backblaze B2, MinIO, Wasabi.
|
|
67
|
+
*/
|
|
68
|
+
class S3Transport extends BackupTransport {
|
|
69
|
+
constructor(s3Client, prefix) {
|
|
70
|
+
super();
|
|
71
|
+
this.s3 = s3Client;
|
|
72
|
+
this.prefix = prefix || '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async writeFile(remotePath, buffer) {
|
|
76
|
+
await this.s3.putObject(this.prefix + remotePath, buffer);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async readFile(remotePath) {
|
|
80
|
+
return await this.s3.getObject(this.prefix + remotePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async deleteFile(remotePath) {
|
|
84
|
+
await this.s3.deleteObject(this.prefix + remotePath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async listFiles(remoteDir) {
|
|
88
|
+
const prefix = this.prefix + remoteDir + (remoteDir.endsWith('/') ? '' : '/');
|
|
89
|
+
const objects = await this.s3.listObjects(prefix);
|
|
90
|
+
return objects
|
|
91
|
+
.map(obj => {
|
|
92
|
+
const key = obj.Key;
|
|
93
|
+
return key.startsWith(prefix) ? key.slice(prefix.length) : key;
|
|
94
|
+
})
|
|
95
|
+
.filter(f => f && !f.includes('/'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async exists(remotePath) {
|
|
99
|
+
const head = await this.s3.headObject(this.prefix + remotePath);
|
|
100
|
+
return head !== null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cloud transport — wraps S3Transport with auto-fetched R2 credentials.
|
|
106
|
+
* Credentials are fetched from the ClawKeep Cloud API and cached until near-expiry.
|
|
107
|
+
*/
|
|
108
|
+
class CloudTransport extends BackupTransport {
|
|
109
|
+
constructor({ apiKey, workspace, endpoint }) {
|
|
110
|
+
super();
|
|
111
|
+
this.apiKey = apiKey;
|
|
112
|
+
this.workspace = workspace;
|
|
113
|
+
this.endpoint = (endpoint || 'https://api.clawkeep.com').replace(/\/$/, '');
|
|
114
|
+
this._inner = null;
|
|
115
|
+
this._credsExpiry = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async _fetchCredentials() {
|
|
119
|
+
const url = `${this.endpoint}/api/workspaces/${this.workspace}/credentials`;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const parsed = new URL(url);
|
|
122
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
123
|
+
const req = mod.request(url, {
|
|
124
|
+
method: 'GET',
|
|
125
|
+
headers: {
|
|
126
|
+
'Authorization': 'Bearer ' + this.apiKey,
|
|
127
|
+
'Accept': 'application/json',
|
|
128
|
+
},
|
|
129
|
+
}, (res) => {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
res.on('data', c => chunks.push(c));
|
|
132
|
+
res.on('end', () => {
|
|
133
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
134
|
+
if (res.statusCode >= 400) {
|
|
135
|
+
let msg = `Cloud API error: HTTP ${res.statusCode}`;
|
|
136
|
+
try { msg = JSON.parse(body).error?.message || msg; } catch {}
|
|
137
|
+
reject(new Error(msg));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
resolve(JSON.parse(body));
|
|
142
|
+
} catch {
|
|
143
|
+
reject(new Error('Invalid JSON from cloud API'));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
req.on('error', reject);
|
|
148
|
+
req.setTimeout(30000, () => req.destroy(new Error('Cloud API request timeout')));
|
|
149
|
+
req.end();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async _ensureCredentials() {
|
|
154
|
+
// Refresh if no inner transport or within 1 hour of expiry
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (this._inner && this._credsExpiry - now > 3600000) return;
|
|
157
|
+
|
|
158
|
+
const data = await this._fetchCredentials();
|
|
159
|
+
const creds = data.credentials || data;
|
|
160
|
+
|
|
161
|
+
const S3Client = require('./s3-client');
|
|
162
|
+
const s3 = new S3Client({
|
|
163
|
+
endpoint: creds.endpoint,
|
|
164
|
+
bucket: creds.bucket,
|
|
165
|
+
region: creds.region || 'auto',
|
|
166
|
+
accessKey: creds.access_key_id,
|
|
167
|
+
secretKey: creds.secret_access_key,
|
|
168
|
+
});
|
|
169
|
+
this._inner = new S3Transport(s3, creds.prefix || `workspaces/${this.workspace}/`);
|
|
170
|
+
const expiresAt = creds.expires_at || data.expires_at;
|
|
171
|
+
this._credsExpiry = expiresAt
|
|
172
|
+
? new Date(expiresAt).getTime()
|
|
173
|
+
: now + 3600000;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async writeFile(remotePath, buffer) {
|
|
177
|
+
await this._ensureCredentials();
|
|
178
|
+
return this._inner.writeFile(remotePath, buffer);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async readFile(remotePath) {
|
|
182
|
+
await this._ensureCredentials();
|
|
183
|
+
return this._inner.readFile(remotePath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async deleteFile(remotePath) {
|
|
187
|
+
await this._ensureCredentials();
|
|
188
|
+
return this._inner.deleteFile(remotePath);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async listFiles(remoteDir) {
|
|
192
|
+
await this._ensureCredentials();
|
|
193
|
+
return this._inner.listFiles(remoteDir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async exists(remotePath) {
|
|
197
|
+
await this._ensureCredentials();
|
|
198
|
+
return this._inner.exists(remotePath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
62
202
|
/**
|
|
63
203
|
* Git remote transport — uses native git push/pull.
|
|
64
204
|
* No chunks needed; git handles incremental natively.
|
|
@@ -91,12 +231,29 @@ function createTransport(backupConfig, clawGit) {
|
|
|
91
231
|
return new GitTransport(clawGit);
|
|
92
232
|
}
|
|
93
233
|
if (target === 'cloud') {
|
|
94
|
-
|
|
234
|
+
const { loadCredentials } = require('./credentials');
|
|
235
|
+
const creds = loadCredentials();
|
|
236
|
+
const apiKey = process.env.CLAWKEEP_API_KEY || creds?.apiKey;
|
|
237
|
+
const endpoint = backupConfig.cloud?.endpoint || creds?.endpoint || 'https://api.clawkeep.com';
|
|
238
|
+
const workspace = backupConfig.workspaceId;
|
|
239
|
+
if (!apiKey) throw new Error('No API key found. Run `clawkeep cloud setup` or set CLAWKEEP_API_KEY');
|
|
240
|
+
if (!workspace) throw new Error('No workspace configured. Run `clawkeep cloud setup`');
|
|
241
|
+
return new CloudTransport({ apiKey, workspace, endpoint });
|
|
95
242
|
}
|
|
96
243
|
if (target === 's3') {
|
|
97
|
-
|
|
244
|
+
const s3Config = backupConfig.s3;
|
|
245
|
+
if (!s3Config) throw new Error('No S3 config found');
|
|
246
|
+
const S3Client = require('./s3-client');
|
|
247
|
+
const s3 = new S3Client({
|
|
248
|
+
endpoint: s3Config.endpoint,
|
|
249
|
+
bucket: s3Config.bucket,
|
|
250
|
+
region: s3Config.region || 'auto',
|
|
251
|
+
accessKey: s3Config.accessKey || process.env.CLAWKEEP_S3_ACCESS_KEY,
|
|
252
|
+
secretKey: s3Config.secretKey || process.env.CLAWKEEP_S3_SECRET_KEY,
|
|
253
|
+
});
|
|
254
|
+
return new S3Transport(s3, s3Config.prefix || '');
|
|
98
255
|
}
|
|
99
256
|
throw new Error('Unknown target: ' + target);
|
|
100
257
|
}
|
|
101
258
|
|
|
102
|
-
module.exports = { BackupTransport, LocalTransport, GitTransport, createTransport };
|
|
259
|
+
module.exports = { BackupTransport, LocalTransport, S3Transport, CloudTransport, GitTransport, createTransport };
|