@masslessai/push-todo 3.0.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/lib/config.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Configuration file helpers for Push CLI.
3
+ *
4
+ * Reads/writes config from ~/.config/push/config
5
+ * Format: bash-style exports (export PUSH_KEY="value")
6
+ *
7
+ * Compatible with the Python version's config format.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import { join, dirname } from 'path';
13
+
14
+ const CONFIG_DIR = join(homedir(), '.config', 'push');
15
+ const CONFIG_FILE = join(CONFIG_DIR, 'config');
16
+
17
+ /**
18
+ * Get a configuration value from the config file.
19
+ *
20
+ * @param {string} key - Config key name (without PUSH_ prefix)
21
+ * @param {string} defaultValue - Default value if not found
22
+ * @returns {string} The config value or default
23
+ */
24
+ export function getConfigValue(key, defaultValue = '') {
25
+ const fullKey = `PUSH_${key}`;
26
+
27
+ if (!existsSync(CONFIG_FILE)) {
28
+ return defaultValue;
29
+ }
30
+
31
+ try {
32
+ const content = readFileSync(CONFIG_FILE, 'utf8');
33
+ for (const line of content.split('\n')) {
34
+ const trimmed = line.trim();
35
+ if (trimmed.startsWith(`export ${fullKey}=`)) {
36
+ // Extract value after = and remove quotes
37
+ let value = trimmed.split('=')[1] || '';
38
+ value = value.trim();
39
+ // Remove surrounding quotes (single or double)
40
+ if ((value.startsWith('"') && value.endsWith('"')) ||
41
+ (value.startsWith("'") && value.endsWith("'"))) {
42
+ value = value.slice(1, -1);
43
+ }
44
+ return value;
45
+ }
46
+ }
47
+ } catch {
48
+ // Config file exists but couldn't read
49
+ }
50
+
51
+ return defaultValue;
52
+ }
53
+
54
+ /**
55
+ * Set a configuration value in the config file.
56
+ *
57
+ * @param {string} key - Config key name (without PUSH_ prefix)
58
+ * @param {string} value - Value to set
59
+ * @returns {boolean} True if successful
60
+ */
61
+ export function setConfigValue(key, value) {
62
+ const fullKey = `PUSH_${key}`;
63
+
64
+ // Ensure config directory exists
65
+ mkdirSync(CONFIG_DIR, { recursive: true });
66
+
67
+ // Read existing config
68
+ let lines = [];
69
+ if (existsSync(CONFIG_FILE)) {
70
+ try {
71
+ lines = readFileSync(CONFIG_FILE, 'utf8').split('\n');
72
+ } catch {
73
+ lines = [];
74
+ }
75
+ }
76
+
77
+ // Update or add the key
78
+ let found = false;
79
+ for (let i = 0; i < lines.length; i++) {
80
+ if (lines[i].trim().startsWith(`export ${fullKey}=`)) {
81
+ lines[i] = `export ${fullKey}="${value}"`;
82
+ found = true;
83
+ break;
84
+ }
85
+ }
86
+
87
+ if (!found) {
88
+ lines.push(`export ${fullKey}="${value}"`);
89
+ }
90
+
91
+ // Write back
92
+ try {
93
+ writeFileSync(CONFIG_FILE, lines.join('\n') + '\n');
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get the API key from environment or config file.
102
+ *
103
+ * Priority:
104
+ * 1. PUSH_API_KEY environment variable
105
+ * 2. Config file at ~/.config/push/config
106
+ *
107
+ * @returns {string} The API key
108
+ * @throws {Error} If API key is not found
109
+ */
110
+ export function getApiKey() {
111
+ // 1. Try environment first (for CI/testing)
112
+ const envKey = process.env.PUSH_API_KEY;
113
+ if (envKey) {
114
+ return envKey;
115
+ }
116
+
117
+ // 2. Try config file
118
+ const configKey = getConfigValue('API_KEY');
119
+ if (configKey) {
120
+ return configKey;
121
+ }
122
+
123
+ // 3. Not found
124
+ throw new Error(
125
+ 'PUSH_API_KEY not configured.\n' +
126
+ 'Run: push-todo connect\n' +
127
+ 'Or manually add to ~/.config/push/config:\n' +
128
+ ' export PUSH_API_KEY="your-key-here"'
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Check if auto-commit is enabled.
134
+ * Default: true (enabled by default)
135
+ *
136
+ * @returns {boolean}
137
+ */
138
+ export function getAutoCommitEnabled() {
139
+ const value = getConfigValue('AUTO_COMMIT', 'true');
140
+ return value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === 'yes';
141
+ }
142
+
143
+ /**
144
+ * Set auto-commit setting.
145
+ *
146
+ * @param {boolean} enabled
147
+ * @returns {boolean} True if successful
148
+ */
149
+ export function setAutoCommitEnabled(enabled) {
150
+ return setConfigValue('AUTO_COMMIT', enabled ? 'true' : 'false');
151
+ }
152
+
153
+ /**
154
+ * Get the maximum batch size for queuing tasks.
155
+ * Default: 5
156
+ *
157
+ * @returns {number}
158
+ */
159
+ export function getMaxBatchSize() {
160
+ const value = getConfigValue('MAX_BATCH_SIZE', '5');
161
+ const parsed = parseInt(value, 10);
162
+ return isNaN(parsed) ? 5 : parsed;
163
+ }
164
+
165
+ /**
166
+ * Set the maximum batch size.
167
+ *
168
+ * @param {number} size - Must be 1-20
169
+ * @returns {boolean} True if successful
170
+ */
171
+ export function setMaxBatchSize(size) {
172
+ if (size < 1 || size > 20) {
173
+ return false;
174
+ }
175
+ return setConfigValue('MAX_BATCH_SIZE', String(size));
176
+ }
177
+
178
+ /**
179
+ * Get the email from config.
180
+ *
181
+ * @returns {string|null}
182
+ */
183
+ export function getEmail() {
184
+ const email = getConfigValue('EMAIL');
185
+ return email || null;
186
+ }
187
+
188
+ /**
189
+ * Save API key and email to config.
190
+ *
191
+ * @param {string} apiKey
192
+ * @param {string} email
193
+ */
194
+ export function saveCredentials(apiKey, email) {
195
+ mkdirSync(CONFIG_DIR, { recursive: true });
196
+ setConfigValue('API_KEY', apiKey);
197
+ setConfigValue('EMAIL', email);
198
+ }
199
+
200
+ /**
201
+ * Clear all credentials from config.
202
+ */
203
+ export function clearCredentials() {
204
+ if (existsSync(CONFIG_FILE)) {
205
+ try {
206
+ const content = readFileSync(CONFIG_FILE, 'utf8');
207
+ const lines = content.split('\n').filter(line => {
208
+ const trimmed = line.trim();
209
+ return !trimmed.startsWith('export PUSH_API_KEY=') &&
210
+ !trimmed.startsWith('export PUSH_EMAIL=');
211
+ });
212
+ writeFileSync(CONFIG_FILE, lines.join('\n'));
213
+ } catch {
214
+ // Ignore errors
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Show all settings.
221
+ */
222
+ export function showSettings() {
223
+ console.log();
224
+ console.log(' Push Settings');
225
+ console.log(' ' + '='.repeat(40));
226
+ console.log();
227
+
228
+ const autoCommit = getAutoCommitEnabled();
229
+ const batchSize = getMaxBatchSize();
230
+
231
+ console.log(` auto-commit: ${autoCommit ? 'ON' : 'OFF'}`);
232
+ console.log(' Auto-commit when task completes');
233
+ console.log();
234
+ console.log(` batch-size: ${batchSize}`);
235
+ console.log(' Max tasks for batch queue (1-20)');
236
+ console.log();
237
+ console.log(' Toggle with: push-todo setting <name>');
238
+ console.log(' Example: push-todo setting auto-commit');
239
+ console.log();
240
+ }
241
+
242
+ /**
243
+ * Toggle a setting by name.
244
+ *
245
+ * @param {string} settingName
246
+ * @returns {boolean} True if setting was toggled
247
+ */
248
+ export function toggleSetting(settingName) {
249
+ const normalized = settingName.toLowerCase().replace(/_/g, '-');
250
+
251
+ if (normalized === 'auto-commit') {
252
+ const current = getAutoCommitEnabled();
253
+ const newValue = !current;
254
+ if (setAutoCommitEnabled(newValue)) {
255
+ console.log(`Auto-commit is now ${newValue ? 'ON' : 'OFF'}`);
256
+ if (newValue) {
257
+ console.log('Tasks will be auto-committed (without push) when completed.');
258
+ } else {
259
+ console.log('Tasks will NOT be auto-committed when completed.');
260
+ }
261
+ return true;
262
+ }
263
+ console.error('Failed to update setting');
264
+ return false;
265
+ }
266
+
267
+ if (normalized === 'batch-size') {
268
+ const batchSize = getMaxBatchSize();
269
+ console.log(`Current batch size: ${batchSize}`);
270
+ console.log('Change with: push-todo --set-batch-size N');
271
+ return true;
272
+ }
273
+
274
+ console.error(`Unknown setting: ${settingName}`);
275
+ console.error('Available settings: auto-commit, batch-size');
276
+ return false;
277
+ }
278
+
279
+ export { CONFIG_DIR, CONFIG_FILE };
package/lib/connect.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Connect and authentication module for Push CLI.
3
+ *
4
+ * Handles the "doctor" flow for setting up and validating
5
+ * the CLI connection to Push backend.
6
+ */
7
+
8
+ import { execSync } from 'child_process';
9
+ import { setTimeout } from 'timers/promises';
10
+ import * as api from './api.js';
11
+ import { getApiKey, saveCredentials, getConfigValue } from './config.js';
12
+ import { getMachineId, getMachineName } from './machine-id.js';
13
+ import { getRegistry } from './project-registry.js';
14
+ import { getGitRemote, isGitRepo, getGitRoot } from './utils/git.js';
15
+ import { isE2EEAvailable } from './encryption.js';
16
+ import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
17
+
18
+ // Supabase anonymous key for auth flow
19
+ const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imp4dXpxY2JxaGlheG1maXR6eGxvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzI0ODA5MjIsImV4cCI6MjA0ODA1NjkyMn0.Qxov5qJTVLWmseyFNhBQBJN7-t5sXlHZyzFKhSN_e5g';
20
+
21
+ const VERSION = '3.0.0';
22
+
23
+ /**
24
+ * Check if a newer version is available.
25
+ *
26
+ * @returns {Promise<Object>} Version check result
27
+ */
28
+ async function checkVersion() {
29
+ const latest = await api.getLatestVersion();
30
+
31
+ if (!latest) {
32
+ return { current: VERSION, latest: null, updateAvailable: false };
33
+ }
34
+
35
+ const currentParts = VERSION.split('.').map(Number);
36
+ const latestParts = latest.split('.').map(Number);
37
+
38
+ let updateAvailable = false;
39
+ for (let i = 0; i < 3; i++) {
40
+ if (latestParts[i] > currentParts[i]) {
41
+ updateAvailable = true;
42
+ break;
43
+ } else if (latestParts[i] < currentParts[i]) {
44
+ break;
45
+ }
46
+ }
47
+
48
+ return { current: VERSION, latest, updateAvailable };
49
+ }
50
+
51
+ /**
52
+ * Validate the current API key.
53
+ *
54
+ * @returns {Promise<Object>} Validation result
55
+ */
56
+ async function validateApiKeyStatus() {
57
+ const apiKey = getApiKey();
58
+
59
+ if (!apiKey) {
60
+ return { status: 'missing', message: 'No API key configured' };
61
+ }
62
+
63
+ const result = await api.validateApiKey();
64
+
65
+ if (result.valid) {
66
+ return { status: 'valid', email: result.email, userId: result.userId };
67
+ }
68
+
69
+ return { status: 'invalid', message: result.reason };
70
+ }
71
+
72
+ /**
73
+ * Validate machine registration.
74
+ *
75
+ * @returns {Promise<Object>} Validation result
76
+ */
77
+ async function validateMachineStatus() {
78
+ const machineId = getMachineId();
79
+ const machineName = getMachineName();
80
+
81
+ try {
82
+ const result = await api.validateMachine(machineId);
83
+ return {
84
+ status: 'valid',
85
+ machineId,
86
+ machineName,
87
+ ...result
88
+ };
89
+ } catch (error) {
90
+ return {
91
+ status: 'error',
92
+ machineId,
93
+ machineName,
94
+ message: error.message
95
+ };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Validate project registration.
101
+ *
102
+ * @returns {Object} Validation result
103
+ */
104
+ function validateProjectStatus() {
105
+ if (!isGitRepo()) {
106
+ return { status: 'not_git', message: 'Not in a git repository' };
107
+ }
108
+
109
+ const gitRemote = getGitRemote();
110
+ if (!gitRemote) {
111
+ return { status: 'no_remote', message: 'No git remote configured' };
112
+ }
113
+
114
+ const registry = getRegistry();
115
+ const isRegistered = registry.isRegistered(gitRemote);
116
+ const localPath = registry.getPathWithoutUpdate(gitRemote);
117
+
118
+ return {
119
+ status: isRegistered ? 'registered' : 'unregistered',
120
+ gitRemote,
121
+ localPath,
122
+ gitRoot: getGitRoot()
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Generate a random auth code for the authentication flow.
128
+ *
129
+ * @returns {string} 6-character alphanumeric code
130
+ */
131
+ function generateAuthCode() {
132
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Omit confusing chars
133
+ let code = '';
134
+ for (let i = 0; i < 6; i++) {
135
+ code += chars[Math.floor(Math.random() * chars.length)];
136
+ }
137
+ return code;
138
+ }
139
+
140
+ /**
141
+ * Open a URL in the default browser.
142
+ *
143
+ * @param {string} url - URL to open
144
+ */
145
+ function openBrowser(url) {
146
+ try {
147
+ if (process.platform === 'darwin') {
148
+ execSync(`open "${url}"`, { stdio: 'ignore' });
149
+ } else if (process.platform === 'linux') {
150
+ execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
151
+ } else if (process.platform === 'win32') {
152
+ execSync(`start "" "${url}"`, { stdio: 'ignore' });
153
+ }
154
+ } catch {
155
+ console.log(`Please open this URL manually: ${url}`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Poll for authentication completion.
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
165
+ */
166
+ async function pollForAuth(authCode, timeout = 300) {
167
+ const startTime = Date.now();
168
+ const pollInterval = 2000; // 2 seconds
169
+
170
+ while ((Date.now() - startTime) < timeout * 1000) {
171
+ try {
172
+ const response = await fetch(
173
+ `https://jxuzqcbqhiaxmfitzxlo.supabase.co/functions/v1/poll-auth?code=${authCode}`,
174
+ {
175
+ headers: {
176
+ 'Authorization': `Bearer ${ANON_KEY}`,
177
+ 'Content-Type': 'application/json'
178
+ }
179
+ }
180
+ );
181
+
182
+ if (response.ok) {
183
+ const data = await response.json();
184
+ if (data.api_key) {
185
+ return data;
186
+ }
187
+ }
188
+ } catch {
189
+ // Ignore errors during polling
190
+ }
191
+
192
+ await setTimeout(pollInterval);
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Run the authentication flow.
200
+ *
201
+ * @returns {Promise<boolean>} True if successful
202
+ */
203
+ async function runAuthFlow() {
204
+ const authCode = generateAuthCode();
205
+ const authUrl = `https://pushto.do/connect?code=${authCode}`;
206
+
207
+ console.log('');
208
+ console.log(bold('Authentication Required'));
209
+ console.log('');
210
+ console.log(`Opening browser to: ${cyan(authUrl)}`);
211
+ console.log('');
212
+ console.log(`Or enter this code manually: ${bold(authCode)}`);
213
+ console.log('');
214
+ console.log(dim('Waiting for authentication...'));
215
+
216
+ openBrowser(authUrl);
217
+
218
+ const credentials = await pollForAuth(authCode);
219
+
220
+ if (!credentials) {
221
+ console.log(red('Authentication timed out. Please try again.'));
222
+ return false;
223
+ }
224
+
225
+ // Save credentials
226
+ saveCredentials(credentials.api_key, credentials.user_id, credentials.email);
227
+
228
+ console.log('');
229
+ console.log(green('✓ Authentication successful!'));
230
+ console.log(` Logged in as: ${credentials.email}`);
231
+
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * Register the current project.
237
+ *
238
+ * @param {string[]} keywords - Project keywords
239
+ * @param {string} description - Project description
240
+ * @returns {Promise<boolean>} True if successful
241
+ */
242
+ async function registerCurrentProject(keywords = [], description = '') {
243
+ const gitRemote = getGitRemote();
244
+ const gitRoot = getGitRoot();
245
+
246
+ if (!gitRemote || !gitRoot) {
247
+ console.log(yellow('Cannot register: not in a git repository with a remote.'));
248
+ return false;
249
+ }
250
+
251
+ // Register locally
252
+ const registry = getRegistry();
253
+ const isNew = registry.register(gitRemote, gitRoot);
254
+
255
+ // Register with backend
256
+ try {
257
+ await api.registerProject(gitRemote, keywords, description);
258
+ console.log(green(`✓ Project registered: ${gitRemote}`));
259
+ return true;
260
+ } catch (error) {
261
+ console.log(yellow(`Local registration OK, but backend registration failed: ${error.message}`));
262
+ return false;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Print a status line with icon.
268
+ *
269
+ * @param {string} icon - Status icon
270
+ * @param {string} label - Status label
271
+ * @param {string} value - Status value
272
+ */
273
+ function printStatus(icon, label, value) {
274
+ console.log(` ${icon} ${label}: ${value}`);
275
+ }
276
+
277
+ /**
278
+ * Run the connect/doctor flow.
279
+ *
280
+ * @param {Object} options - Options from CLI
281
+ * @returns {Promise<void>}
282
+ */
283
+ export async function runConnect(options = {}) {
284
+ console.log('');
285
+ console.log(bold('Push Connect - Diagnostic Check'));
286
+ console.log(dim('=' .repeat(40)));
287
+ console.log('');
288
+
289
+ let allPassed = true;
290
+
291
+ // Step 1: Version check
292
+ console.log(bold('1. Version Check'));
293
+ const versionStatus = await checkVersion();
294
+ if (versionStatus.updateAvailable) {
295
+ printStatus(yellow('⚠'), 'Version', `${versionStatus.current} → ${versionStatus.latest} available`);
296
+ console.log(dim(` Update: npm update -g @masslessai/push-todo`));
297
+ allPassed = false;
298
+ } else {
299
+ printStatus(green('✓'), 'Version', `${versionStatus.current} (latest)`);
300
+ }
301
+ console.log('');
302
+
303
+ // Step 2: API Key validation
304
+ console.log(bold('2. API Key'));
305
+ let keyStatus = await validateApiKeyStatus();
306
+
307
+ if (keyStatus.status === 'missing' || keyStatus.status === 'invalid') {
308
+ printStatus(red('✗'), 'API Key', keyStatus.message || 'Invalid');
309
+ console.log('');
310
+
311
+ // Run auth flow
312
+ const authSuccess = await runAuthFlow();
313
+ if (authSuccess) {
314
+ keyStatus = await validateApiKeyStatus();
315
+ } else {
316
+ allPassed = false;
317
+ }
318
+ }
319
+
320
+ if (keyStatus.status === 'valid') {
321
+ printStatus(green('✓'), 'API Key', `Valid (${keyStatus.email})`);
322
+ }
323
+ console.log('');
324
+
325
+ // Step 3: Machine validation
326
+ console.log(bold('3. Machine'));
327
+ const machineStatus = await validateMachineStatus();
328
+ if (machineStatus.status === 'valid') {
329
+ printStatus(green('✓'), 'Machine', machineStatus.machineName);
330
+ printStatus(dim('·'), 'ID', machineStatus.machineId);
331
+ } else {
332
+ printStatus(yellow('⚠'), 'Machine', machineStatus.message || 'Not validated');
333
+ allPassed = false;
334
+ }
335
+ console.log('');
336
+
337
+ // Step 4: Project validation
338
+ console.log(bold('4. Project'));
339
+ const projectStatus = validateProjectStatus();
340
+
341
+ if (projectStatus.status === 'registered') {
342
+ printStatus(green('✓'), 'Project', projectStatus.gitRemote);
343
+ printStatus(dim('·'), 'Path', projectStatus.localPath);
344
+ } else if (projectStatus.status === 'unregistered') {
345
+ printStatus(yellow('⚠'), 'Project', `${projectStatus.gitRemote} (not registered)`);
346
+ console.log('');
347
+
348
+ // Offer to register
349
+ if (keyStatus.status === 'valid') {
350
+ const keywords = options.keywords ? options.keywords.split(',') : [];
351
+ const description = options.description || '';
352
+ await registerCurrentProject(keywords, description);
353
+ }
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
+ }
360
+ console.log('');
361
+
362
+ // Step 5: E2EE check
363
+ console.log(bold('5. E2EE (End-to-End Encryption)'));
364
+ const [e2eeAvailable, e2eeMessage] = isE2EEAvailable();
365
+ if (e2eeAvailable) {
366
+ printStatus(green('✓'), 'E2EE', 'Available');
367
+ } else {
368
+ printStatus(yellow('⚠'), 'E2EE', e2eeMessage);
369
+ }
370
+ console.log('');
371
+
372
+ // Summary
373
+ console.log(dim('=' .repeat(40)));
374
+ if (allPassed) {
375
+ console.log(green(bold('All checks passed!')));
376
+ } else {
377
+ console.log(yellow('Some checks need attention. See above for details.'));
378
+ }
379
+ console.log('');
380
+ }