clawkeep 0.1.0 → 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.
@@ -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
+ }
@@ -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)}`);
@@ -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
- bucket: options.bucket || null,
110
- prefix: options.prefix || null,
111
- region: options.region || null,
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
- return { ok: false, message: 'ClawKeep Cloud is coming soon' };
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
- return { ok: false, message: 'S3 backup not yet implemented' };
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') throw new Error('Compact only supported for local target');
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 };