@masslessai/push-todo 3.0.0 → 3.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/.claude-plugin/plugin.json +1 -1
- package/bin/push-keychain-helper +0 -0
- package/hooks/session-end.js +1 -1
- package/hooks/session-start.js +61 -4
- package/lib/api.js +59 -16
- package/lib/certainty.js +434 -0
- package/lib/cli.js +310 -4
- package/lib/connect.js +1120 -200
- package/lib/daemon-health.js +193 -0
- package/lib/daemon.js +1369 -0
- package/lib/fetch.js +16 -1
- package/lib/utils/git.js +43 -0
- package/lib/utils/screenshots.js +65 -0
- package/lib/watch.js +13 -2
- package/natives/KeychainHelper.swift +310 -93
- package/package.json +2 -1
- package/scripts/postinstall.js +306 -14
- package/scripts/preuninstall.js +66 -0
package/lib/connect.js
CHANGED
|
@@ -1,62 +1,611 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Connect and authentication module for Push CLI.
|
|
2
|
+
* Connect and authentication module for Push CLI (Doctor Mode).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Comprehensive health check and connect tool:
|
|
5
|
+
* - Version check: Compare local vs remote plugin version
|
|
6
|
+
* - API validation: Verify API key is still valid
|
|
7
|
+
* - Project registration: Register current project with keywords
|
|
8
|
+
* - Authentication: Handle initial auth or re-auth when needed
|
|
9
|
+
* - E2EE setup: Compile Swift helper, import encryption key
|
|
10
|
+
* - Machine validation: Multi-Mac coordination
|
|
11
|
+
*
|
|
12
|
+
* Ported from: plugins/push-todo/scripts/connect.py (1866 lines)
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
|
-
import { execSync } from 'child_process';
|
|
9
|
-
import {
|
|
15
|
+
import { execSync, spawnSync, spawn } from 'child_process';
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'fs';
|
|
17
|
+
import { setTimeout as sleep } from 'timers/promises';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import * as readline from 'readline';
|
|
10
22
|
import * as api from './api.js';
|
|
11
|
-
import { getApiKey, saveCredentials, getConfigValue } from './config.js';
|
|
23
|
+
import { getApiKey, saveCredentials, clearCredentials, getConfigValue, getEmail } from './config.js';
|
|
12
24
|
import { getMachineId, getMachineName } from './machine-id.js';
|
|
13
25
|
import { getRegistry } from './project-registry.js';
|
|
14
|
-
import { getGitRemote, isGitRepo, getGitRoot } from './utils/git.js';
|
|
26
|
+
import { getGitRemote, isGitRepo, getGitRoot, normalizeGitRemote } from './utils/git.js';
|
|
15
27
|
import { isE2EEAvailable } from './encryption.js';
|
|
16
|
-
import {
|
|
28
|
+
import { ensureDaemonRunning } from './daemon-health.js';
|
|
29
|
+
import { bold, green, yellow, red, cyan, dim } from './utils/colors.js';
|
|
30
|
+
|
|
31
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
+
const __dirname = dirname(__filename);
|
|
17
33
|
|
|
18
34
|
// Supabase anonymous key for auth flow
|
|
19
35
|
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imp4dXpxY2JxaGlheG1maXR6eGxvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzI0ODA5MjIsImV4cCI6MjA0ODA1NjkyMn0.Qxov5qJTVLWmseyFNhBQBJN7-t5sXlHZyzFKhSN_e5g';
|
|
36
|
+
const API_BASE = 'https://jxuzqcbqhiaxmfitzxlo.supabase.co/functions/v1';
|
|
20
37
|
|
|
21
|
-
|
|
38
|
+
// Remote URLs for updates
|
|
39
|
+
const REMOTE_PACKAGE_JSON_URL = 'https://raw.githubusercontent.com/MasslessAI/push-todo-cli/main/npm/push-todo/package.json';
|
|
40
|
+
|
|
41
|
+
// Get version from package.json
|
|
42
|
+
function getVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
45
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
46
|
+
return pkg.version || '3.0.0';
|
|
47
|
+
} catch {
|
|
48
|
+
return '3.0.0';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VERSION = getVersion();
|
|
53
|
+
|
|
54
|
+
// Client types
|
|
55
|
+
const CLIENT_NAMES = {
|
|
56
|
+
'claude-code': 'Claude Code',
|
|
57
|
+
'openai-codex': 'OpenAI Codex',
|
|
58
|
+
'clawdbot': 'Clawdbot'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// INSTALLATION METHOD DETECTION
|
|
63
|
+
// ============================================================================
|
|
22
64
|
|
|
23
65
|
/**
|
|
24
|
-
*
|
|
66
|
+
* Detect how the package was installed.
|
|
25
67
|
*
|
|
26
|
-
*
|
|
68
|
+
* Returns:
|
|
69
|
+
* "npm-global" - Installed via npm install -g
|
|
70
|
+
* "npm-local" - Installed locally in node_modules
|
|
71
|
+
* "development" - Linked for development
|
|
72
|
+
*/
|
|
73
|
+
function getInstallationMethod() {
|
|
74
|
+
const pkgPath = join(__dirname, '..');
|
|
75
|
+
|
|
76
|
+
// Check if it's a symlink (development setup)
|
|
77
|
+
try {
|
|
78
|
+
const stats = statSync(pkgPath, { throwIfNoEntry: false });
|
|
79
|
+
if (stats?.isSymbolicLink?.()) {
|
|
80
|
+
return 'development';
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
// Check if in node_modules (local install)
|
|
85
|
+
if (pkgPath.includes('node_modules')) {
|
|
86
|
+
return 'npm-local';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Default to global npm install
|
|
90
|
+
return 'npm-global';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// E2EE SETUP (End-to-End Encryption)
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the plugin/package root directory.
|
|
99
|
+
*/
|
|
100
|
+
function getPluginRoot() {
|
|
101
|
+
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
|
102
|
+
return process.env.CLAUDE_PLUGIN_ROOT;
|
|
103
|
+
}
|
|
104
|
+
return join(__dirname, '..');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get path to the Swift keychain helper binary.
|
|
109
|
+
*/
|
|
110
|
+
function getSwiftHelperPath() {
|
|
111
|
+
return join(getPluginRoot(), 'bin', 'push-keychain-helper');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get path to the Swift keychain helper source.
|
|
116
|
+
*/
|
|
117
|
+
function getSwiftSourcePath() {
|
|
118
|
+
return join(getPluginRoot(), 'natives', 'KeychainHelper.swift');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if Swift compiler is available.
|
|
123
|
+
*/
|
|
124
|
+
function checkSwiftcAvailable() {
|
|
125
|
+
try {
|
|
126
|
+
const result = spawnSync('which', ['swiftc'], { timeout: 5000 });
|
|
127
|
+
return result.status === 0;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if E2EE key exists in keychain.
|
|
135
|
+
*/
|
|
136
|
+
function checkE2EEKeyExists() {
|
|
137
|
+
const helperPath = getSwiftHelperPath();
|
|
138
|
+
if (!existsSync(helperPath)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const result = spawnSync(helperPath, ['--check'], { timeout: 5000 });
|
|
144
|
+
return result.status === 0;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compile the Swift keychain helper from source.
|
|
152
|
+
*/
|
|
153
|
+
function compileSwiftHelper() {
|
|
154
|
+
const sourcePath = getSwiftSourcePath();
|
|
155
|
+
const binDir = join(getPluginRoot(), 'bin');
|
|
156
|
+
const helperPath = getSwiftHelperPath();
|
|
157
|
+
|
|
158
|
+
// Check if source exists
|
|
159
|
+
if (!existsSync(sourcePath)) {
|
|
160
|
+
return {
|
|
161
|
+
status: 'no_source',
|
|
162
|
+
message: `Swift source not found at ${sourcePath}`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check for Swift compiler
|
|
167
|
+
if (!checkSwiftcAvailable()) {
|
|
168
|
+
return {
|
|
169
|
+
status: 'no_swiftc',
|
|
170
|
+
message: 'Swift compiler not found. Install Xcode Command Line Tools.'
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Create bin directory
|
|
175
|
+
mkdirSync(binDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
// Compile
|
|
178
|
+
try {
|
|
179
|
+
const result = spawnSync('swiftc', ['-O', sourcePath, '-o', helperPath], {
|
|
180
|
+
timeout: 60000,
|
|
181
|
+
encoding: 'utf8'
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (result.status === 0) {
|
|
185
|
+
return {
|
|
186
|
+
status: 'success',
|
|
187
|
+
message: 'Compiled encryption helper from source',
|
|
188
|
+
path: helperPath
|
|
189
|
+
};
|
|
190
|
+
} else {
|
|
191
|
+
return {
|
|
192
|
+
status: 'compile_error',
|
|
193
|
+
message: `Compilation failed: ${result.stderr}`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
status: 'compile_error',
|
|
199
|
+
message: `Compilation error: ${error.message}`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Set up E2EE support for the CLI.
|
|
206
|
+
*/
|
|
207
|
+
function setupE2EE() {
|
|
208
|
+
const helperPath = getSwiftHelperPath();
|
|
209
|
+
const sourcePath = getSwiftSourcePath();
|
|
210
|
+
|
|
211
|
+
// Case 1: Helper already exists
|
|
212
|
+
if (existsSync(helperPath)) {
|
|
213
|
+
const keyExists = checkE2EEKeyExists();
|
|
214
|
+
if (keyExists) {
|
|
215
|
+
return {
|
|
216
|
+
status: 'ready',
|
|
217
|
+
message: 'E2EE ready',
|
|
218
|
+
keyAvailable: true
|
|
219
|
+
};
|
|
220
|
+
} else {
|
|
221
|
+
return {
|
|
222
|
+
status: 'not_enabled',
|
|
223
|
+
message: 'E2EE helper ready, but no key found (enable in iOS app)',
|
|
224
|
+
keyAvailable: false
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Case 2: Need to compile helper
|
|
230
|
+
if (existsSync(sourcePath)) {
|
|
231
|
+
if (checkSwiftcAvailable()) {
|
|
232
|
+
const compileResult = compileSwiftHelper();
|
|
233
|
+
|
|
234
|
+
if (compileResult.status === 'success') {
|
|
235
|
+
const keyExists = checkE2EEKeyExists();
|
|
236
|
+
return {
|
|
237
|
+
status: 'compiled',
|
|
238
|
+
message: 'Compiled encryption helper from source',
|
|
239
|
+
keyAvailable: keyExists,
|
|
240
|
+
sourcePath
|
|
241
|
+
};
|
|
242
|
+
} else {
|
|
243
|
+
return {
|
|
244
|
+
status: 'error',
|
|
245
|
+
message: compileResult.message,
|
|
246
|
+
keyAvailable: false
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
return {
|
|
251
|
+
status: 'needs_setup',
|
|
252
|
+
message: 'Swift compiler not found',
|
|
253
|
+
keyAvailable: false,
|
|
254
|
+
options: [
|
|
255
|
+
'Install Xcode Command Line Tools: xcode-select --install',
|
|
256
|
+
'Or use pre-signed binary (downloaded during npm install)'
|
|
257
|
+
],
|
|
258
|
+
sourcePath
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Case 3: No source file - check if binary was downloaded
|
|
264
|
+
return {
|
|
265
|
+
status: 'error',
|
|
266
|
+
message: 'E2EE helper not found. Run: npm rebuild @masslessai/push-todo',
|
|
267
|
+
keyAvailable: false
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Store E2EE key directly without interactive prompt.
|
|
273
|
+
*/
|
|
274
|
+
function storeE2EEKeyDirect(keyInput) {
|
|
275
|
+
keyInput = keyInput.trim();
|
|
276
|
+
|
|
277
|
+
// Validate format (should be base64, 44 chars for 32 bytes)
|
|
278
|
+
try {
|
|
279
|
+
const keyData = Buffer.from(keyInput, 'base64');
|
|
280
|
+
if (keyData.length !== 32) {
|
|
281
|
+
return {
|
|
282
|
+
status: 'error',
|
|
283
|
+
message: `Invalid key size: expected 32 bytes, got ${keyData.length}`
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
return {
|
|
288
|
+
status: 'error',
|
|
289
|
+
message: 'Invalid base64 encoding'
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Store via Swift helper
|
|
294
|
+
let helperPath = getSwiftHelperPath();
|
|
295
|
+
if (!existsSync(helperPath)) {
|
|
296
|
+
const compileResult = compileSwiftHelper();
|
|
297
|
+
if (compileResult.status !== 'success') {
|
|
298
|
+
return {
|
|
299
|
+
status: 'error',
|
|
300
|
+
message: `Cannot compile helper: ${compileResult.message}`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const result = spawnSync(helperPath, ['--store'], {
|
|
307
|
+
input: keyInput,
|
|
308
|
+
timeout: 10000,
|
|
309
|
+
encoding: 'utf8'
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (result.status === 0) {
|
|
313
|
+
return {
|
|
314
|
+
status: 'success',
|
|
315
|
+
message: 'Key stored in macOS Keychain'
|
|
316
|
+
};
|
|
317
|
+
} else {
|
|
318
|
+
return {
|
|
319
|
+
status: 'error',
|
|
320
|
+
message: `Failed to store key: ${result.stderr?.trim() || 'Unknown error'}`
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
status: 'error',
|
|
326
|
+
message: `Error storing key: ${error.message}`
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check if running in an interactive terminal.
|
|
333
|
+
*/
|
|
334
|
+
function isInteractive() {
|
|
335
|
+
return process.stdin.isTTY && process.stdout.isTTY;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check if user has any encrypted todos.
|
|
340
|
+
*/
|
|
341
|
+
async function checkUserHasEncryptedTodos() {
|
|
342
|
+
try {
|
|
343
|
+
const apiKey = getApiKey();
|
|
344
|
+
if (!apiKey) return false;
|
|
345
|
+
|
|
346
|
+
const response = await fetch(
|
|
347
|
+
`${API_BASE}/synced-todos?is_encrypted=true&limit=1`,
|
|
348
|
+
{
|
|
349
|
+
headers: {
|
|
350
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
351
|
+
'Content-Type': 'application/json'
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (!response.ok) return false;
|
|
357
|
+
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
const todos = data.todos || data;
|
|
360
|
+
return Array.isArray(todos) && todos.length > 0;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Interactive E2EE key import (for TTY only).
|
|
368
|
+
*/
|
|
369
|
+
async function importE2EEKey() {
|
|
370
|
+
if (!isInteractive()) {
|
|
371
|
+
console.log('');
|
|
372
|
+
console.log(' E2EE_KEY_IMPORT_AVAILABLE');
|
|
373
|
+
console.log(' Use: push-todo connect --store-e2ee-key <base64_key>');
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
console.log('');
|
|
378
|
+
console.log(' 🔐 Import Encryption Key');
|
|
379
|
+
console.log(' ' + '-'.repeat(38));
|
|
380
|
+
console.log('');
|
|
381
|
+
console.log(' Your Push account has E2EE enabled.');
|
|
382
|
+
console.log(' To decrypt tasks on this Mac, import your encryption key.');
|
|
383
|
+
console.log('');
|
|
384
|
+
console.log(' On your iPhone:');
|
|
385
|
+
console.log(' 1. Open Push app');
|
|
386
|
+
console.log(' 2. Go to Settings > End-to-End Encryption');
|
|
387
|
+
console.log(" 3. Tap 'Export Encryption Key'");
|
|
388
|
+
console.log(' 4. Copy the key');
|
|
389
|
+
console.log('');
|
|
390
|
+
|
|
391
|
+
// Prompt for key
|
|
392
|
+
const rl = readline.createInterface({
|
|
393
|
+
input: process.stdin,
|
|
394
|
+
output: process.stdout
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
rl.question(' Paste your encryption key (or press Enter to skip): ', (keyInput) => {
|
|
399
|
+
rl.close();
|
|
400
|
+
|
|
401
|
+
keyInput = keyInput.trim();
|
|
402
|
+
if (!keyInput) {
|
|
403
|
+
console.log(' Skipped key import.');
|
|
404
|
+
resolve(false);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const result = storeE2EEKeyDirect(keyInput);
|
|
409
|
+
if (result.status === 'success') {
|
|
410
|
+
console.log(' ✓ Key stored in macOS Keychain');
|
|
411
|
+
resolve(true);
|
|
412
|
+
} else {
|
|
413
|
+
console.log(` ✗ ${result.message}`);
|
|
414
|
+
resolve(false);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Show E2EE status and optionally prompt for import.
|
|
422
|
+
*/
|
|
423
|
+
async function showE2EEStatus(promptForImport = true) {
|
|
424
|
+
const e2eeStatus = setupE2EE();
|
|
425
|
+
|
|
426
|
+
console.log('');
|
|
427
|
+
console.log(' E2EE Status');
|
|
428
|
+
console.log(' ' + '-'.repeat(38));
|
|
429
|
+
|
|
430
|
+
if (e2eeStatus.status === 'ready') {
|
|
431
|
+
console.log(' ✓ End-to-end encryption ready');
|
|
432
|
+
console.log(' ✓ Encryption key available');
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (e2eeStatus.status === 'compiled') {
|
|
437
|
+
console.log(' ✓ Compiled encryption helper from source');
|
|
438
|
+
if (e2eeStatus.sourcePath) {
|
|
439
|
+
console.log(` 📄 Source: ${e2eeStatus.sourcePath}`);
|
|
440
|
+
}
|
|
441
|
+
if (e2eeStatus.keyAvailable) {
|
|
442
|
+
console.log(' ✓ Encryption key available');
|
|
443
|
+
} else {
|
|
444
|
+
console.log(' ⚠️ No encryption key found');
|
|
445
|
+
if (promptForImport && await checkUserHasEncryptedTodos()) {
|
|
446
|
+
if (await importE2EEKey()) {
|
|
447
|
+
console.log(' ✓ E2EE setup complete!');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (e2eeStatus.status === 'not_enabled') {
|
|
455
|
+
console.log(' ✓ Encryption helper ready');
|
|
456
|
+
const hasEncrypted = await checkUserHasEncryptedTodos();
|
|
457
|
+
if (hasEncrypted) {
|
|
458
|
+
console.log(' ⚠️ No encryption key found (E2EE enabled on account)');
|
|
459
|
+
if (promptForImport) {
|
|
460
|
+
if (await importE2EEKey()) {
|
|
461
|
+
console.log(' ✓ E2EE setup complete!');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
console.log(' ℹ️ E2EE not enabled (no encrypted todos)');
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (e2eeStatus.status === 'needs_setup') {
|
|
471
|
+
console.log(' ⚠️ E2EE setup needed');
|
|
472
|
+
console.log(' Swift compiler not found. To enable E2EE:');
|
|
473
|
+
console.log(' → Run: xcode-select --install');
|
|
474
|
+
console.log(' → Then run: push-todo connect');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
console.log(` ⚠️ E2EE error: ${e2eeStatus.message}`);
|
|
479
|
+
|
|
480
|
+
// Trust-building info
|
|
481
|
+
if (e2eeStatus.sourcePath && ['ready', 'compiled'].includes(e2eeStatus.status)) {
|
|
482
|
+
console.log('');
|
|
483
|
+
console.log(' 🔐 Your encryption key:');
|
|
484
|
+
console.log(' • Stored securely in macOS Keychain');
|
|
485
|
+
console.log(' • Never sent to our servers');
|
|
486
|
+
console.log(' • Only your devices can decrypt');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// VERSION CHECK
|
|
492
|
+
// ============================================================================
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Get remote version from npm/GitHub.
|
|
496
|
+
*/
|
|
497
|
+
async function getRemoteVersion() {
|
|
498
|
+
try {
|
|
499
|
+
const response = await fetch(REMOTE_PACKAGE_JSON_URL, {
|
|
500
|
+
headers: { 'User-Agent': 'push-cli/1.0' }
|
|
501
|
+
});
|
|
502
|
+
if (!response.ok) return null;
|
|
503
|
+
const data = await response.json();
|
|
504
|
+
return data.version || null;
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Parse version string into comparable tuple.
|
|
512
|
+
*/
|
|
513
|
+
function parseVersion(versionStr) {
|
|
514
|
+
try {
|
|
515
|
+
return versionStr.split('.').map(p => parseInt(p, 10));
|
|
516
|
+
} catch {
|
|
517
|
+
return [0, 0, 0];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Check if an update is available.
|
|
27
523
|
*/
|
|
28
524
|
async function checkVersion() {
|
|
29
|
-
const
|
|
525
|
+
const method = getInstallationMethod();
|
|
526
|
+
|
|
527
|
+
// Dev installation
|
|
528
|
+
if (method === 'development') {
|
|
529
|
+
return {
|
|
530
|
+
status: 'dev_installation',
|
|
531
|
+
current: VERSION,
|
|
532
|
+
latest: null,
|
|
533
|
+
updateAvailable: false,
|
|
534
|
+
message: `Dev installation (v${VERSION}) - use git pull to update`
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const remote = await getRemoteVersion();
|
|
30
539
|
|
|
31
|
-
if (!
|
|
32
|
-
return {
|
|
540
|
+
if (!remote) {
|
|
541
|
+
return {
|
|
542
|
+
status: 'unknown',
|
|
543
|
+
current: VERSION,
|
|
544
|
+
latest: null,
|
|
545
|
+
updateAvailable: false,
|
|
546
|
+
message: 'Could not fetch remote version (network error)'
|
|
547
|
+
};
|
|
33
548
|
}
|
|
34
549
|
|
|
35
|
-
const
|
|
36
|
-
const
|
|
550
|
+
const localParts = parseVersion(VERSION);
|
|
551
|
+
const remoteParts = parseVersion(remote);
|
|
37
552
|
|
|
38
553
|
let updateAvailable = false;
|
|
39
554
|
for (let i = 0; i < 3; i++) {
|
|
40
|
-
if (
|
|
555
|
+
if ((remoteParts[i] || 0) > (localParts[i] || 0)) {
|
|
41
556
|
updateAvailable = true;
|
|
42
557
|
break;
|
|
43
|
-
} else if (
|
|
558
|
+
} else if ((remoteParts[i] || 0) < (localParts[i] || 0)) {
|
|
44
559
|
break;
|
|
45
560
|
}
|
|
46
561
|
}
|
|
47
562
|
|
|
48
|
-
return {
|
|
563
|
+
return {
|
|
564
|
+
status: updateAvailable ? 'update_available' : 'up_to_date',
|
|
565
|
+
current: VERSION,
|
|
566
|
+
latest: remote,
|
|
567
|
+
updateAvailable,
|
|
568
|
+
message: updateAvailable
|
|
569
|
+
? `Update available: ${VERSION} → ${remote}`
|
|
570
|
+
: `Plugin is up to date (v${VERSION})`
|
|
571
|
+
};
|
|
49
572
|
}
|
|
50
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Update the package to latest version.
|
|
576
|
+
*/
|
|
577
|
+
async function doUpdate() {
|
|
578
|
+
const method = getInstallationMethod();
|
|
579
|
+
|
|
580
|
+
if (method === 'development') {
|
|
581
|
+
return {
|
|
582
|
+
status: 'skipped',
|
|
583
|
+
message: 'Development installation - use git pull instead'
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
console.log('Updating @masslessai/push-todo...');
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
execSync('npm update -g @masslessai/push-todo', { stdio: 'inherit' });
|
|
591
|
+
return { status: 'success', message: 'Updated successfully' };
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return { status: 'error', message: error.message };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ============================================================================
|
|
598
|
+
// VALIDATION FUNCTIONS
|
|
599
|
+
// ============================================================================
|
|
600
|
+
|
|
51
601
|
/**
|
|
52
602
|
* Validate the current API key.
|
|
53
|
-
*
|
|
54
|
-
* @returns {Promise<Object>} Validation result
|
|
55
603
|
*/
|
|
56
604
|
async function validateApiKeyStatus() {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
605
|
+
let apiKey;
|
|
606
|
+
try {
|
|
607
|
+
apiKey = getApiKey();
|
|
608
|
+
} catch {
|
|
60
609
|
return { status: 'missing', message: 'No API key configured' };
|
|
61
610
|
}
|
|
62
611
|
|
|
@@ -71,8 +620,6 @@ async function validateApiKeyStatus() {
|
|
|
71
620
|
|
|
72
621
|
/**
|
|
73
622
|
* Validate machine registration.
|
|
74
|
-
*
|
|
75
|
-
* @returns {Promise<Object>} Validation result
|
|
76
623
|
*/
|
|
77
624
|
async function validateMachineStatus() {
|
|
78
625
|
const machineId = getMachineId();
|
|
@@ -97,9 +644,72 @@ async function validateMachineStatus() {
|
|
|
97
644
|
}
|
|
98
645
|
|
|
99
646
|
/**
|
|
100
|
-
* Validate project registration.
|
|
101
|
-
|
|
102
|
-
|
|
647
|
+
* Validate project registration (full validation with warnings).
|
|
648
|
+
*/
|
|
649
|
+
function validateProjectInfo() {
|
|
650
|
+
const warnings = [];
|
|
651
|
+
const projectPath = process.cwd();
|
|
652
|
+
const gitRemoteRaw = getGitRemote();
|
|
653
|
+
const gitRemote = gitRemoteRaw ? normalizeGitRemote(gitRemoteRaw) : null;
|
|
654
|
+
|
|
655
|
+
// Check if git repo
|
|
656
|
+
const isGit = isGitRepo();
|
|
657
|
+
if (!isGit) {
|
|
658
|
+
warnings.push('Not a git repository (no .git folder)');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Check git remote
|
|
662
|
+
if (isGit && !gitRemoteRaw) {
|
|
663
|
+
warnings.push("Git repo has no 'origin' remote configured");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (gitRemoteRaw && !gitRemote) {
|
|
667
|
+
warnings.push(`Could not normalize git remote: ${gitRemoteRaw}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Check local registry
|
|
671
|
+
let localRegistryStatus = 'not_registered';
|
|
672
|
+
if (gitRemote) {
|
|
673
|
+
const registry = getRegistry();
|
|
674
|
+
const registeredPath = registry.getPathWithoutUpdate(gitRemote);
|
|
675
|
+
if (registeredPath) {
|
|
676
|
+
if (registeredPath === projectPath) {
|
|
677
|
+
localRegistryStatus = 'registered';
|
|
678
|
+
} else {
|
|
679
|
+
localRegistryStatus = 'path_mismatch';
|
|
680
|
+
warnings.push(`Local registry has different path: ${registeredPath}`);
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
warnings.push("Project not in local registry (daemon won't route tasks here)");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Determine overall status
|
|
688
|
+
let status;
|
|
689
|
+
if (!isGit || !gitRemote) {
|
|
690
|
+
status = 'warnings';
|
|
691
|
+
} else if (warnings.length > 0) {
|
|
692
|
+
status = 'warnings';
|
|
693
|
+
} else {
|
|
694
|
+
status = 'valid';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
status,
|
|
699
|
+
projectPath,
|
|
700
|
+
isGitRepo: isGit,
|
|
701
|
+
gitRemote,
|
|
702
|
+
gitRemoteRaw,
|
|
703
|
+
localRegistryStatus,
|
|
704
|
+
warnings,
|
|
705
|
+
message: status === 'valid'
|
|
706
|
+
? `Project valid: ${gitRemote}`
|
|
707
|
+
: `Project has ${warnings.length} warning(s)`
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Simple project validation (JSON output).
|
|
103
713
|
*/
|
|
104
714
|
function validateProjectStatus() {
|
|
105
715
|
if (!isGitRepo()) {
|
|
@@ -111,270 +721,580 @@ function validateProjectStatus() {
|
|
|
111
721
|
return { status: 'no_remote', message: 'No git remote configured' };
|
|
112
722
|
}
|
|
113
723
|
|
|
724
|
+
const normalized = normalizeGitRemote(gitRemote);
|
|
114
725
|
const registry = getRegistry();
|
|
115
|
-
const isRegistered = registry.isRegistered(
|
|
116
|
-
const localPath = registry.getPathWithoutUpdate(
|
|
726
|
+
const isRegistered = registry.isRegistered(normalized);
|
|
727
|
+
const localPath = registry.getPathWithoutUpdate(normalized);
|
|
117
728
|
|
|
118
729
|
return {
|
|
119
730
|
status: isRegistered ? 'registered' : 'unregistered',
|
|
120
|
-
gitRemote,
|
|
731
|
+
gitRemote: normalized,
|
|
121
732
|
localPath,
|
|
122
733
|
gitRoot: getGitRoot()
|
|
123
734
|
};
|
|
124
735
|
}
|
|
125
736
|
|
|
737
|
+
// ============================================================================
|
|
738
|
+
// AUTH FLOW
|
|
739
|
+
// ============================================================================
|
|
740
|
+
|
|
126
741
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* @returns {string} 6-character alphanumeric code
|
|
742
|
+
* Get device name for registration.
|
|
130
743
|
*/
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
744
|
+
function getDeviceName() {
|
|
745
|
+
try {
|
|
746
|
+
return require('os').hostname() || 'Unknown Device';
|
|
747
|
+
} catch {
|
|
748
|
+
return 'Unknown Device';
|
|
136
749
|
}
|
|
137
|
-
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Initiate device code flow.
|
|
754
|
+
*/
|
|
755
|
+
async function initiateDeviceFlow(clientType = 'claude-code') {
|
|
756
|
+
const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
|
|
757
|
+
|
|
758
|
+
const response = await fetch(`${API_BASE}/device-auth/init`, {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
headers: {
|
|
761
|
+
'Content-Type': 'application/json',
|
|
762
|
+
'apikey': ANON_KEY
|
|
763
|
+
},
|
|
764
|
+
body: JSON.stringify({
|
|
765
|
+
client_name: clientName,
|
|
766
|
+
client_type: clientType,
|
|
767
|
+
client_version: VERSION,
|
|
768
|
+
device_name: getDeviceName(),
|
|
769
|
+
project_path: process.cwd(),
|
|
770
|
+
git_remote: getGitRemote()
|
|
771
|
+
})
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
if (!response.ok) {
|
|
775
|
+
throw new Error(`Failed to initiate auth: ${response.status}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return response.json();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Poll for authorization status.
|
|
783
|
+
*/
|
|
784
|
+
async function pollStatus(deviceCode) {
|
|
785
|
+
const response = await fetch(`${API_BASE}/device-auth/poll`, {
|
|
786
|
+
method: 'POST',
|
|
787
|
+
headers: {
|
|
788
|
+
'Content-Type': 'application/json',
|
|
789
|
+
'apikey': ANON_KEY
|
|
790
|
+
},
|
|
791
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (!response.ok) {
|
|
795
|
+
const body = await response.json().catch(() => ({}));
|
|
796
|
+
if (body.error === 'slow_down') {
|
|
797
|
+
return { status: 'slow_down', interval: body.interval || 10 };
|
|
798
|
+
}
|
|
799
|
+
throw new Error(`Poll failed: ${response.status}`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return response.json();
|
|
138
803
|
}
|
|
139
804
|
|
|
140
805
|
/**
|
|
141
806
|
* Open a URL in the default browser.
|
|
142
|
-
*
|
|
143
|
-
* @param {string} url - URL to open
|
|
144
807
|
*/
|
|
145
808
|
function openBrowser(url) {
|
|
146
809
|
try {
|
|
147
810
|
if (process.platform === 'darwin') {
|
|
148
811
|
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
812
|
+
return true;
|
|
149
813
|
} else if (process.platform === 'linux') {
|
|
150
814
|
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
815
|
+
return true;
|
|
151
816
|
} else if (process.platform === 'win32') {
|
|
152
817
|
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
818
|
+
return true;
|
|
153
819
|
}
|
|
154
|
-
} catch {
|
|
155
|
-
|
|
156
|
-
}
|
|
820
|
+
} catch {}
|
|
821
|
+
return false;
|
|
157
822
|
}
|
|
158
823
|
|
|
159
824
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* @param {string} authCode - The auth code to poll for
|
|
163
|
-
* @param {number} timeout - Timeout in seconds
|
|
164
|
-
* @returns {Promise<Object|null>} Credentials or null if timeout
|
|
825
|
+
* Full device auth flow with browser sign-in.
|
|
165
826
|
*/
|
|
166
|
-
async function
|
|
827
|
+
async function doFullDeviceAuth(clientType = 'claude-code') {
|
|
828
|
+
const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
|
|
829
|
+
|
|
830
|
+
console.log(' Initializing...');
|
|
831
|
+
|
|
832
|
+
let deviceData;
|
|
833
|
+
try {
|
|
834
|
+
deviceData = await initiateDeviceFlow(clientType);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
console.error(` Error: Failed to initiate connection: ${error.message}`);
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const deviceCode = deviceData.device_code;
|
|
841
|
+
const expiresIn = deviceData.expires_in;
|
|
842
|
+
let pollInterval = deviceData.interval || 5;
|
|
843
|
+
|
|
844
|
+
const authUrl = deviceData.verification_uri_complete ||
|
|
845
|
+
`https://pushto.do/auth/cli?code=${deviceCode}`;
|
|
846
|
+
|
|
847
|
+
console.log('');
|
|
848
|
+
console.log(' Opening browser for Sign in with Apple...');
|
|
849
|
+
console.log('');
|
|
850
|
+
|
|
851
|
+
const browserOpened = openBrowser(authUrl);
|
|
852
|
+
|
|
853
|
+
if (browserOpened) {
|
|
854
|
+
console.log(" If the browser didn't open, visit:");
|
|
855
|
+
} else {
|
|
856
|
+
console.log(' Open this URL in your browser:');
|
|
857
|
+
}
|
|
858
|
+
console.log(` ${authUrl}`);
|
|
859
|
+
console.log('');
|
|
860
|
+
console.log(` Waiting for authorization (${Math.floor(expiresIn / 60)} min timeout)...`);
|
|
861
|
+
console.log(' Press Ctrl+C to cancel');
|
|
862
|
+
console.log('');
|
|
863
|
+
|
|
167
864
|
const startTime = Date.now();
|
|
168
|
-
const pollInterval = 2000; // 2 seconds
|
|
169
865
|
|
|
170
|
-
while (
|
|
866
|
+
while (true) {
|
|
867
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
868
|
+
if (elapsed > expiresIn) {
|
|
869
|
+
console.log('');
|
|
870
|
+
console.log(' Error: Authorization timed out. Please run connect again.');
|
|
871
|
+
console.log('');
|
|
872
|
+
process.exit(1);
|
|
873
|
+
}
|
|
874
|
+
|
|
171
875
|
try {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
);
|
|
876
|
+
const result = await pollStatus(deviceCode);
|
|
877
|
+
|
|
878
|
+
if (result.status === 'authorized') {
|
|
879
|
+
const apiKeyResult = result.api_key;
|
|
880
|
+
const email = result.email || 'Unknown';
|
|
881
|
+
const actionName = result.normalized_name || result.action_name || clientName;
|
|
181
882
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
883
|
+
if (apiKeyResult) {
|
|
884
|
+
return {
|
|
885
|
+
api_key: apiKeyResult,
|
|
886
|
+
email,
|
|
887
|
+
action_name: actionName
|
|
888
|
+
};
|
|
889
|
+
} else {
|
|
890
|
+
console.log('');
|
|
891
|
+
console.log(' Error: Authorization succeeded but no API key received.');
|
|
892
|
+
console.log('');
|
|
893
|
+
process.exit(1);
|
|
186
894
|
}
|
|
187
895
|
}
|
|
188
|
-
|
|
189
|
-
|
|
896
|
+
|
|
897
|
+
if (result.status === 'denied') {
|
|
898
|
+
console.log('');
|
|
899
|
+
console.log(' Authorization denied.');
|
|
900
|
+
console.log('');
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (result.status === 'expired') {
|
|
905
|
+
console.log('');
|
|
906
|
+
console.log(' Error: Authorization expired. Please run connect again.');
|
|
907
|
+
console.log('');
|
|
908
|
+
process.exit(1);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (result.status === 'slow_down') {
|
|
912
|
+
pollInterval = result.interval || pollInterval + 5;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Still pending
|
|
916
|
+
const remaining = Math.floor(expiresIn - elapsed);
|
|
917
|
+
const mins = Math.floor(remaining / 60);
|
|
918
|
+
const secs = remaining % 60;
|
|
919
|
+
process.stdout.write(`\r Waiting... (${mins}:${secs.toString().padStart(2, '0')} remaining) `);
|
|
920
|
+
|
|
921
|
+
} catch (error) {
|
|
922
|
+
process.stdout.write(`\r Error: ${error.message}. Retrying... `);
|
|
190
923
|
}
|
|
191
924
|
|
|
192
|
-
await
|
|
925
|
+
await sleep(pollInterval * 1000);
|
|
193
926
|
}
|
|
194
|
-
|
|
195
|
-
return null;
|
|
196
927
|
}
|
|
197
928
|
|
|
198
929
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* @returns {Promise<boolean>} True if successful
|
|
930
|
+
* Register project with backend.
|
|
202
931
|
*/
|
|
203
|
-
async function
|
|
204
|
-
const
|
|
205
|
-
const authUrl = `https://pushto.do/connect?code=${authCode}`;
|
|
932
|
+
async function registerProjectWithBackend(apiKey, clientType = 'claude-code', keywords = '', description = '') {
|
|
933
|
+
const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
|
|
206
934
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
console.log(dim('Waiting for authentication...'));
|
|
935
|
+
const payload = {
|
|
936
|
+
client_type: clientType,
|
|
937
|
+
client_name: clientName,
|
|
938
|
+
device_name: getDeviceName(),
|
|
939
|
+
project_path: process.cwd(),
|
|
940
|
+
git_remote: getGitRemote()
|
|
941
|
+
};
|
|
215
942
|
|
|
216
|
-
|
|
943
|
+
if (keywords) payload.keywords = keywords;
|
|
944
|
+
if (description) payload.description = description;
|
|
217
945
|
|
|
218
|
-
const
|
|
946
|
+
const response = await fetch(`${API_BASE}/register-project`, {
|
|
947
|
+
method: 'POST',
|
|
948
|
+
headers: {
|
|
949
|
+
'Content-Type': 'application/json',
|
|
950
|
+
'apikey': ANON_KEY,
|
|
951
|
+
'Authorization': `Bearer ${apiKey}`
|
|
952
|
+
},
|
|
953
|
+
body: JSON.stringify(payload)
|
|
954
|
+
});
|
|
219
955
|
|
|
220
|
-
if (!
|
|
221
|
-
|
|
222
|
-
|
|
956
|
+
if (!response.ok) {
|
|
957
|
+
if (response.status === 401) {
|
|
958
|
+
return { status: 'unauthorized', message: 'API key invalid or revoked' };
|
|
959
|
+
}
|
|
960
|
+
const body = await response.json().catch(() => ({}));
|
|
961
|
+
return { status: 'error', message: body.error_description || `HTTP ${response.status}` };
|
|
223
962
|
}
|
|
224
963
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
964
|
+
const data = await response.json();
|
|
965
|
+
if (data.success) {
|
|
966
|
+
return {
|
|
967
|
+
status: 'success',
|
|
968
|
+
action_name: data.normalized_name || data.action_name || 'Unknown',
|
|
969
|
+
created: data.created !== false,
|
|
970
|
+
message: data.message || ''
|
|
971
|
+
};
|
|
972
|
+
}
|
|
231
973
|
|
|
232
|
-
return
|
|
974
|
+
return { status: 'error', message: 'Unknown error' };
|
|
233
975
|
}
|
|
234
976
|
|
|
235
977
|
/**
|
|
236
|
-
* Register
|
|
237
|
-
*
|
|
238
|
-
* @param {string[]} keywords - Project keywords
|
|
239
|
-
* @param {string} description - Project description
|
|
240
|
-
* @returns {Promise<boolean>} True if successful
|
|
978
|
+
* Register project in local registry for daemon routing.
|
|
241
979
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const gitRoot = getGitRoot();
|
|
980
|
+
function registerProjectLocally(gitRemoteRaw, localPath) {
|
|
981
|
+
if (!gitRemoteRaw) return false;
|
|
245
982
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
983
|
+
const gitRemote = normalizeGitRemote(gitRemoteRaw);
|
|
984
|
+
if (!gitRemote) return false;
|
|
250
985
|
|
|
251
|
-
// Register locally
|
|
252
986
|
const registry = getRegistry();
|
|
253
|
-
|
|
987
|
+
return registry.register(gitRemote, localPath);
|
|
988
|
+
}
|
|
254
989
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
990
|
+
/**
|
|
991
|
+
* Show migration hint for legacy installations.
|
|
992
|
+
*/
|
|
993
|
+
function showMigrationHint() {
|
|
994
|
+
const method = getInstallationMethod();
|
|
995
|
+
|
|
996
|
+
if (method === 'npm-local') {
|
|
997
|
+
console.log('');
|
|
998
|
+
console.log(' ' + '-'.repeat(50));
|
|
999
|
+
console.log(' TIP: You have a local installation.');
|
|
1000
|
+
console.log(' For global access, install globally:');
|
|
1001
|
+
console.log('');
|
|
1002
|
+
console.log(' npm install -g @masslessai/push-todo');
|
|
1003
|
+
console.log('');
|
|
1004
|
+
console.log(' ' + '-'.repeat(50));
|
|
263
1005
|
}
|
|
264
1006
|
}
|
|
265
1007
|
|
|
1008
|
+
// ============================================================================
|
|
1009
|
+
// STATUS DISPLAY
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
|
|
266
1012
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* @param {string} icon - Status icon
|
|
270
|
-
* @param {string} label - Status label
|
|
271
|
-
* @param {string} value - Status value
|
|
1013
|
+
* Show current connection status.
|
|
272
1014
|
*/
|
|
273
|
-
function
|
|
274
|
-
console.log(
|
|
1015
|
+
async function showStatus() {
|
|
1016
|
+
console.log('');
|
|
1017
|
+
console.log(' Push Connection Status');
|
|
1018
|
+
console.log(' ' + '='.repeat(40));
|
|
1019
|
+
console.log('');
|
|
1020
|
+
|
|
1021
|
+
let existingKey, existingEmail;
|
|
1022
|
+
try {
|
|
1023
|
+
existingKey = getApiKey();
|
|
1024
|
+
} catch {}
|
|
1025
|
+
existingEmail = getEmail();
|
|
1026
|
+
|
|
1027
|
+
if (existingKey && existingEmail) {
|
|
1028
|
+
console.log(` ✓ Connected as ${existingEmail}`);
|
|
1029
|
+
console.log(` ✓ API key: ${existingKey.slice(0, 16)}...`);
|
|
1030
|
+
console.log('');
|
|
1031
|
+
console.log(' Current project:');
|
|
1032
|
+
const gitRemote = getGitRemote();
|
|
1033
|
+
if (gitRemote) {
|
|
1034
|
+
console.log(` Git remote: ${gitRemote}`);
|
|
1035
|
+
} else {
|
|
1036
|
+
console.log(` Path: ${process.cwd()}`);
|
|
1037
|
+
}
|
|
1038
|
+
console.log('');
|
|
1039
|
+
console.log(" Run 'push-todo connect' to register this project.");
|
|
1040
|
+
console.log(" Run 'push-todo connect --reauth' to re-authenticate.");
|
|
1041
|
+
} else if (existingKey) {
|
|
1042
|
+
console.log(' ⚠ Partial config (missing email)');
|
|
1043
|
+
console.log(` API key: ${existingKey.slice(0, 16)}...`);
|
|
1044
|
+
console.log('');
|
|
1045
|
+
console.log(" Run 'push-todo connect --reauth' to fix.");
|
|
1046
|
+
} else {
|
|
1047
|
+
console.log(' ✗ Not connected');
|
|
1048
|
+
console.log('');
|
|
1049
|
+
console.log(" Run 'push-todo connect' to connect your Push account.");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
console.log('');
|
|
275
1053
|
}
|
|
276
1054
|
|
|
1055
|
+
// ============================================================================
|
|
1056
|
+
// MAIN CONNECT FLOW
|
|
1057
|
+
// ============================================================================
|
|
1058
|
+
|
|
277
1059
|
/**
|
|
278
1060
|
* Run the connect/doctor flow.
|
|
279
1061
|
*
|
|
280
1062
|
* @param {Object} options - Options from CLI
|
|
281
|
-
* @returns {Promise<void>}
|
|
282
1063
|
*/
|
|
283
1064
|
export async function runConnect(options = {}) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
console.log(dim('=' .repeat(40)));
|
|
287
|
-
console.log('');
|
|
1065
|
+
// Self-healing: ensure daemon is running
|
|
1066
|
+
ensureDaemonRunning();
|
|
288
1067
|
|
|
289
|
-
|
|
1068
|
+
// Auto-detect client type from installation method
|
|
1069
|
+
let clientType = options.client || 'claude-code';
|
|
1070
|
+
const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
|
|
290
1071
|
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
console.log(dim(` Update: npm update -g @masslessai/push-todo`));
|
|
297
|
-
allPassed = false;
|
|
298
|
-
} else {
|
|
299
|
-
printStatus(green('✓'), 'Version', `${versionStatus.current} (latest)`);
|
|
1072
|
+
// Handle --check-version (JSON output)
|
|
1073
|
+
if (options['check-version'] || options.checkVersion) {
|
|
1074
|
+
const result = await checkVersion();
|
|
1075
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1076
|
+
return;
|
|
300
1077
|
}
|
|
301
|
-
console.log('');
|
|
302
1078
|
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
1079
|
+
// Handle --update
|
|
1080
|
+
if (options.update) {
|
|
1081
|
+
const result = await doUpdate();
|
|
1082
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
306
1085
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
1086
|
+
// Handle --validate-key (JSON output)
|
|
1087
|
+
if (options['validate-key'] || options.validateKey) {
|
|
1088
|
+
const result = await validateApiKeyStatus();
|
|
1089
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
310
1092
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
allPassed = false;
|
|
317
|
-
}
|
|
1093
|
+
// Handle --validate-machine (JSON output)
|
|
1094
|
+
if (options['validate-machine'] || options.validateMachine) {
|
|
1095
|
+
const result = await validateMachineStatus();
|
|
1096
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1097
|
+
return;
|
|
318
1098
|
}
|
|
319
1099
|
|
|
320
|
-
|
|
321
|
-
|
|
1100
|
+
// Handle --validate-project (JSON output)
|
|
1101
|
+
if (options['validate-project'] || options.validateProject) {
|
|
1102
|
+
const result = validateProjectStatus();
|
|
1103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1104
|
+
return;
|
|
322
1105
|
}
|
|
323
|
-
console.log('');
|
|
324
1106
|
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
1107
|
+
// Handle --store-e2ee-key
|
|
1108
|
+
if (options['store-e2ee-key'] || options.storeE2eeKey) {
|
|
1109
|
+
const key = options['store-e2ee-key'] || options.storeE2eeKey;
|
|
1110
|
+
const result = storeE2EEKeyDirect(key);
|
|
1111
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Handle --status (show status without registering)
|
|
1116
|
+
if (options.status) {
|
|
1117
|
+
await showStatus();
|
|
1118
|
+
return;
|
|
334
1119
|
}
|
|
1120
|
+
|
|
1121
|
+
// Handle --reauth
|
|
1122
|
+
if (options.reauth) {
|
|
1123
|
+
console.log(' Forcing re-authentication...');
|
|
1124
|
+
clearCredentials();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1128
|
+
// FULL DOCTOR FLOW
|
|
1129
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
console.log('');
|
|
1132
|
+
console.log(` Push Voice Tasks Connect`);
|
|
1133
|
+
console.log(' ' + '='.repeat(40));
|
|
335
1134
|
console.log('');
|
|
336
1135
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1136
|
+
let existingKey, existingEmail;
|
|
1137
|
+
try {
|
|
1138
|
+
existingKey = getApiKey();
|
|
1139
|
+
} catch {}
|
|
1140
|
+
existingEmail = getEmail();
|
|
1141
|
+
|
|
1142
|
+
const keywords = options.keywords || '';
|
|
1143
|
+
const description = options.description || '';
|
|
340
1144
|
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
console.log('');
|
|
1145
|
+
if (existingKey && existingEmail && !options.reauth) {
|
|
1146
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1147
|
+
// FAST PATH: Already authenticated, just register project
|
|
1148
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1149
|
+
console.log(` Connected as ${existingEmail}`);
|
|
1150
|
+
console.log(' Registering project...');
|
|
1151
|
+
|
|
1152
|
+
const result = await registerProjectWithBackend(existingKey, clientType, keywords, description);
|
|
1153
|
+
|
|
1154
|
+
if (result.status === 'success') {
|
|
1155
|
+
// Register in local project registry for global daemon routing
|
|
1156
|
+
const gitRemoteRaw = getGitRemote();
|
|
1157
|
+
const localPath = process.cwd();
|
|
1158
|
+
const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
|
|
1159
|
+
|
|
1160
|
+
console.log('');
|
|
1161
|
+
console.log(' ' + '='.repeat(40));
|
|
1162
|
+
if (result.created) {
|
|
1163
|
+
console.log(` Created action: "${result.action_name}"`);
|
|
1164
|
+
} else {
|
|
1165
|
+
console.log(` Found existing action: "${result.action_name}"`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Validate and show project info
|
|
1169
|
+
const projectInfo = validateProjectInfo();
|
|
1170
|
+
if (projectInfo.gitRemote) {
|
|
1171
|
+
console.log(` Git remote: ${projectInfo.gitRemote}`);
|
|
1172
|
+
}
|
|
1173
|
+
for (const warning of projectInfo.warnings) {
|
|
1174
|
+
console.log(` ⚠️ ${warning}`);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Show local registry status
|
|
1178
|
+
if (gitRemoteRaw) {
|
|
1179
|
+
if (isNewLocal) {
|
|
1180
|
+
console.log(` Local path registered: ${localPath}`);
|
|
1181
|
+
} else {
|
|
1182
|
+
console.log(` Local path updated: ${localPath}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Validate and show machine ID
|
|
1187
|
+
const machineInfo = await validateMachineStatus();
|
|
1188
|
+
if (machineInfo.status === 'valid') {
|
|
1189
|
+
console.log(` Machine: ${machineInfo.machineName}`);
|
|
1190
|
+
} else {
|
|
1191
|
+
console.log(` ⚠️ Machine ID: ${machineInfo.message}`);
|
|
1192
|
+
}
|
|
1193
|
+
console.log(' ' + '='.repeat(40));
|
|
1194
|
+
console.log('');
|
|
1195
|
+
|
|
1196
|
+
if (result.created) {
|
|
1197
|
+
console.log(' Your iOS app will sync this automatically.');
|
|
1198
|
+
} else {
|
|
1199
|
+
console.log(' This project is already configured.');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Show E2EE status
|
|
1203
|
+
await showE2EEStatus();
|
|
347
1204
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1205
|
+
// Show migration hint
|
|
1206
|
+
showMigrationHint();
|
|
1207
|
+
console.log('');
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (result.status === 'unauthorized') {
|
|
1212
|
+
console.log('');
|
|
1213
|
+
console.log(' Session expired, re-authenticating...');
|
|
1214
|
+
console.log('');
|
|
1215
|
+
clearCredentials();
|
|
1216
|
+
// Fall through to full auth
|
|
1217
|
+
} else {
|
|
1218
|
+
console.log('');
|
|
1219
|
+
console.log(` Registration failed: ${result.message || 'Unknown error'}`);
|
|
1220
|
+
console.log(' Trying full connection...');
|
|
1221
|
+
console.log('');
|
|
1222
|
+
// Fall through to full auth
|
|
353
1223
|
}
|
|
354
|
-
} else if (projectStatus.status === 'no_remote') {
|
|
355
|
-
printStatus(yellow('⚠'), 'Project', 'No git remote configured');
|
|
356
|
-
allPassed = false;
|
|
357
|
-
} else {
|
|
358
|
-
printStatus(dim('·'), 'Project', 'Not in a git repository');
|
|
359
1224
|
}
|
|
360
|
-
console.log('');
|
|
361
1225
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1226
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1227
|
+
// SLOW PATH: First time or re-auth needed
|
|
1228
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1229
|
+
const isReauth = existingKey !== undefined;
|
|
1230
|
+
|
|
1231
|
+
const authResult = await doFullDeviceAuth(clientType);
|
|
1232
|
+
|
|
1233
|
+
// Save credentials
|
|
1234
|
+
saveCredentials(authResult.api_key, authResult.email);
|
|
1235
|
+
|
|
1236
|
+
// Register in local project registry for global daemon routing
|
|
1237
|
+
const gitRemoteRaw = getGitRemote();
|
|
1238
|
+
const localPath = process.cwd();
|
|
1239
|
+
const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
|
|
1240
|
+
|
|
1241
|
+
// Show success
|
|
1242
|
+
console.log('');
|
|
1243
|
+
console.log(' ' + '='.repeat(40));
|
|
1244
|
+
if (isReauth) {
|
|
1245
|
+
console.log(` Re-connected as ${authResult.email}`);
|
|
367
1246
|
} else {
|
|
368
|
-
|
|
1247
|
+
console.log(` Connected as ${authResult.email}`);
|
|
1248
|
+
}
|
|
1249
|
+
console.log(` Created action: "${authResult.action_name}"`);
|
|
1250
|
+
|
|
1251
|
+
// Validate and show project info
|
|
1252
|
+
const projectInfo = validateProjectInfo();
|
|
1253
|
+
if (projectInfo.gitRemote) {
|
|
1254
|
+
console.log(` Git remote: ${projectInfo.gitRemote}`);
|
|
1255
|
+
}
|
|
1256
|
+
for (const warning of projectInfo.warnings) {
|
|
1257
|
+
console.log(` ⚠️ ${warning}`);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Show local registry status
|
|
1261
|
+
if (gitRemoteRaw) {
|
|
1262
|
+
if (isNewLocal) {
|
|
1263
|
+
console.log(` Local path registered: ${localPath}`);
|
|
1264
|
+
} else {
|
|
1265
|
+
console.log(` Local path updated: ${localPath}`);
|
|
1266
|
+
}
|
|
369
1267
|
}
|
|
370
|
-
console.log('');
|
|
371
1268
|
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
if (
|
|
375
|
-
console.log(
|
|
1269
|
+
// Validate and show machine ID
|
|
1270
|
+
const machineInfo = await validateMachineStatus();
|
|
1271
|
+
if (machineInfo.status === 'valid') {
|
|
1272
|
+
console.log(` Machine: ${machineInfo.machineName}`);
|
|
376
1273
|
} else {
|
|
377
|
-
console.log(
|
|
1274
|
+
console.log(` ⚠️ Machine ID: ${machineInfo.message}`);
|
|
378
1275
|
}
|
|
1276
|
+
console.log(' ' + '='.repeat(40));
|
|
1277
|
+
console.log('');
|
|
1278
|
+
console.log(' Your iOS app will sync this automatically.');
|
|
1279
|
+
|
|
1280
|
+
// Show E2EE status
|
|
1281
|
+
await showE2EEStatus();
|
|
1282
|
+
|
|
1283
|
+
// Show migration hint
|
|
1284
|
+
showMigrationHint();
|
|
379
1285
|
console.log('');
|
|
380
1286
|
}
|
|
1287
|
+
|
|
1288
|
+
export {
|
|
1289
|
+
checkVersion,
|
|
1290
|
+
doUpdate,
|
|
1291
|
+
validateApiKeyStatus,
|
|
1292
|
+
validateMachineStatus,
|
|
1293
|
+
validateProjectStatus,
|
|
1294
|
+
validateProjectInfo,
|
|
1295
|
+
setupE2EE,
|
|
1296
|
+
storeE2EEKeyDirect,
|
|
1297
|
+
showStatus,
|
|
1298
|
+
getInstallationMethod,
|
|
1299
|
+
VERSION
|
|
1300
|
+
};
|