@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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * E2EE Encryption support for Push CLI.
3
+ *
4
+ * Decrypts end-to-end encrypted todo fields using
5
+ * the Swift keychain helper binary.
6
+ */
7
+
8
+ import { execFileSync } from 'child_process';
9
+ import { createDecipheriv } from 'crypto';
10
+ import { existsSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ const HELPER_PATH = join(__dirname, '../bin/push-keychain-helper');
18
+
19
+ // Cached encryption key
20
+ let cachedKey = null;
21
+ let keyCheckDone = false;
22
+ let keyAvailable = false;
23
+
24
+ /**
25
+ * Check if the keychain helper binary exists.
26
+ *
27
+ * @returns {boolean}
28
+ */
29
+ function helperExists() {
30
+ return existsSync(HELPER_PATH);
31
+ }
32
+
33
+ /**
34
+ * Get the encryption key from the macOS Keychain.
35
+ *
36
+ * @returns {Buffer|null} The 32-byte encryption key or null
37
+ */
38
+ export function getEncryptionKey() {
39
+ if (cachedKey !== null) {
40
+ return cachedKey;
41
+ }
42
+
43
+ if (!helperExists()) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ const result = execFileSync(HELPER_PATH, [], {
49
+ encoding: 'utf8',
50
+ timeout: 5000,
51
+ stdio: ['pipe', 'pipe', 'pipe']
52
+ });
53
+
54
+ const keyBase64 = result.trim();
55
+ if (!keyBase64) {
56
+ return null;
57
+ }
58
+
59
+ cachedKey = Buffer.from(keyBase64, 'base64');
60
+ return cachedKey;
61
+ } catch (error) {
62
+ // Exit codes:
63
+ // 1 = Key not found in Keychain
64
+ // 2 = iCloud Keychain not available
65
+ // Other = Unexpected error
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Decrypt an AES-256-GCM encrypted value.
72
+ *
73
+ * Format: version (1 byte) + nonce (12 bytes) + ciphertext + tag (16 bytes)
74
+ *
75
+ * @param {Buffer} ciphertext - The encrypted data
76
+ * @returns {string} Decrypted plaintext
77
+ */
78
+ export function decrypt(ciphertext) {
79
+ const key = getEncryptionKey();
80
+ if (!key) {
81
+ throw new Error('Encryption key not available');
82
+ }
83
+
84
+ // Check version byte
85
+ const version = ciphertext[0];
86
+ if (version !== 0) {
87
+ throw new Error(`Unsupported encryption version: ${version}`);
88
+ }
89
+
90
+ // Extract components
91
+ const nonce = ciphertext.slice(1, 13); // 12 bytes
92
+ const encrypted = ciphertext.slice(13, -16); // Ciphertext without tag
93
+ const authTag = ciphertext.slice(-16); // Last 16 bytes
94
+
95
+ // Decrypt
96
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
97
+ decipher.setAuthTag(authTag);
98
+
99
+ const decrypted = Buffer.concat([
100
+ decipher.update(encrypted),
101
+ decipher.final()
102
+ ]);
103
+
104
+ return decrypted.toString('utf8');
105
+ }
106
+
107
+ /**
108
+ * Decrypt a potentially encrypted todo field.
109
+ *
110
+ * Returns the original value if:
111
+ * - The value is null/undefined
112
+ * - The value is too short to be encrypted
113
+ * - Decryption fails (returns original)
114
+ *
115
+ * @param {string} value - The field value (possibly base64 encoded ciphertext)
116
+ * @returns {string} Decrypted value or original
117
+ */
118
+ export function decryptTodoField(value) {
119
+ if (!value || typeof value !== 'string') {
120
+ return value;
121
+ }
122
+
123
+ // Encrypted values are base64 and at least 40+ chars
124
+ // (version + nonce + min ciphertext + tag)
125
+ if (value.length < 40) {
126
+ return value;
127
+ }
128
+
129
+ // Quick check: if it doesn't look like base64, skip
130
+ if (!/^[A-Za-z0-9+/]+=*$/.test(value)) {
131
+ return value;
132
+ }
133
+
134
+ try {
135
+ const decoded = Buffer.from(value, 'base64');
136
+
137
+ // Check if it starts with version byte 0
138
+ if (decoded.length < 30 || decoded[0] !== 0) {
139
+ return value;
140
+ }
141
+
142
+ return decrypt(decoded);
143
+ } catch {
144
+ // Decryption failed - return original value
145
+ return value;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Check if E2EE is available on this machine.
151
+ *
152
+ * @returns {[boolean, string]} Tuple of [available, message]
153
+ */
154
+ export function isE2EEAvailable() {
155
+ if (keyCheckDone) {
156
+ return [keyAvailable, keyAvailable ? 'E2EE available' : 'E2EE not available'];
157
+ }
158
+
159
+ // Check platform
160
+ if (process.platform !== 'darwin') {
161
+ keyCheckDone = true;
162
+ keyAvailable = false;
163
+ return [false, 'E2EE requires macOS'];
164
+ }
165
+
166
+ // Check helper binary
167
+ if (!helperExists()) {
168
+ keyCheckDone = true;
169
+ keyAvailable = false;
170
+ return [false, 'Keychain helper not installed'];
171
+ }
172
+
173
+ // Try to get the key
174
+ try {
175
+ const key = getEncryptionKey();
176
+ keyCheckDone = true;
177
+ keyAvailable = key !== null;
178
+
179
+ if (keyAvailable) {
180
+ return [true, 'E2EE available'];
181
+ } else {
182
+ return [false, 'Encryption key not in Keychain'];
183
+ }
184
+ } catch (error) {
185
+ keyCheckDone = true;
186
+ keyAvailable = false;
187
+ return [false, `E2EE check failed: ${error.message}`];
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Clear the cached encryption key.
193
+ * Useful for testing or when the key might have changed.
194
+ */
195
+ export function clearKeyCache() {
196
+ cachedKey = null;
197
+ keyCheckDone = false;
198
+ keyAvailable = false;
199
+ }
200
+
201
+ export { HELPER_PATH };
package/lib/fetch.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Task fetching and display for Push CLI.
3
+ *
4
+ * Main module for listing, viewing, and managing tasks.
5
+ */
6
+
7
+ import * as api from './api.js';
8
+ import { getMachineId, getMachineName } from './machine-id.js';
9
+ import { getRegistry } from './project-registry.js';
10
+ import { getGitRemote, isGitRepo } from './utils/git.js';
11
+ import { formatTaskForDisplay, formatTaskTable, formatSearchResult, formatBatchOffer } from './utils/format.js';
12
+ import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
13
+ import { decryptTodoField, isE2EEAvailable } from './encryption.js';
14
+ import { getAutoCommitEnabled, getMaxBatchSize } from './config.js';
15
+
16
+ /**
17
+ * Decrypt encrypted fields in a task object.
18
+ *
19
+ * @param {Object} task - Task object from API
20
+ * @returns {Object} Task with decrypted fields
21
+ */
22
+ function decryptTaskFields(task) {
23
+ const decrypted = { ...task };
24
+
25
+ // Fields that may be encrypted
26
+ const encryptedFields = [
27
+ 'summary',
28
+ 'content',
29
+ 'normalizedContent',
30
+ 'normalized_content',
31
+ 'originalTranscript',
32
+ 'original_transcript',
33
+ 'transcript'
34
+ ];
35
+
36
+ for (const field of encryptedFields) {
37
+ if (decrypted[field]) {
38
+ decrypted[field] = decryptTodoField(decrypted[field]);
39
+ }
40
+ }
41
+
42
+ return decrypted;
43
+ }
44
+
45
+ /**
46
+ * List tasks for the current project or all projects.
47
+ *
48
+ * @param {Object} options - List options
49
+ * @param {boolean} options.allProjects - List tasks from all projects
50
+ * @param {boolean} options.backlog - Only show backlog items
51
+ * @param {boolean} options.includeBacklog - Include backlog items
52
+ * @param {boolean} options.completed - Only show completed items
53
+ * @param {boolean} options.includeCompleted - Include completed items
54
+ * @param {boolean} options.json - Output as JSON
55
+ * @returns {Promise<void>}
56
+ */
57
+ export async function listTasks(options = {}) {
58
+ // Determine git remote
59
+ let gitRemote = null;
60
+ if (!options.allProjects) {
61
+ gitRemote = getGitRemote();
62
+ if (!gitRemote && isGitRepo()) {
63
+ console.error(yellow('Warning: In a git repo but no remote configured.'));
64
+ }
65
+ }
66
+
67
+ // Fetch tasks
68
+ const tasks = await api.fetchTasks(gitRemote, {
69
+ backlogOnly: options.backlog,
70
+ includeBacklog: options.includeBacklog,
71
+ completedOnly: options.completed,
72
+ includeCompleted: options.includeCompleted
73
+ });
74
+
75
+ // Decrypt if E2EE is available
76
+ const decryptedTasks = tasks.map(decryptTaskFields);
77
+
78
+ // Output
79
+ if (options.json) {
80
+ console.log(JSON.stringify(decryptedTasks, null, 2));
81
+ return;
82
+ }
83
+
84
+ if (decryptedTasks.length === 0) {
85
+ const scope = gitRemote ? `for ${cyan(gitRemote)}` : 'across all projects';
86
+ console.log(`No active tasks ${scope}.`);
87
+ return;
88
+ }
89
+
90
+ // Group by status for display
91
+ const active = decryptedTasks.filter(t => !t.isCompleted && !t.is_completed && !t.isBacklog && !t.is_backlog);
92
+ const backlog = decryptedTasks.filter(t => !t.isCompleted && !t.is_completed && (t.isBacklog || t.is_backlog));
93
+ const completed = decryptedTasks.filter(t => t.isCompleted || t.is_completed);
94
+
95
+ // Header
96
+ const scope = gitRemote ? gitRemote : 'All Projects';
97
+ console.log(bold(`\nPush Tasks - ${scope}\n`));
98
+
99
+ // Active tasks
100
+ if (active.length > 0) {
101
+ console.log(green(`Active (${active.length}):`));
102
+ console.log(formatTaskTable(active));
103
+ console.log('');
104
+ }
105
+
106
+ // Backlog tasks (if requested)
107
+ if (backlog.length > 0 && (options.backlog || options.includeBacklog)) {
108
+ console.log(yellow(`Backlog (${backlog.length}):`));
109
+ console.log(formatTaskTable(backlog));
110
+ console.log('');
111
+ }
112
+
113
+ // Completed tasks (if requested)
114
+ if (completed.length > 0 && (options.completed || options.includeCompleted)) {
115
+ console.log(dim(`Completed (${completed.length}):`));
116
+ console.log(formatTaskTable(completed));
117
+ console.log('');
118
+ }
119
+
120
+ // Summary
121
+ const total = active.length + (options.includeBacklog ? backlog.length : 0);
122
+ console.log(muted(`Showing ${total} task(s). Use --include-backlog or --completed for more.`));
123
+ }
124
+
125
+ /**
126
+ * Show a specific task by display number.
127
+ *
128
+ * @param {number} displayNumber - The task's display number
129
+ * @param {Object} options - Display options
130
+ * @param {boolean} options.json - Output as JSON
131
+ * @returns {Promise<void>}
132
+ */
133
+ export async function showTask(displayNumber, options = {}) {
134
+ const task = await api.fetchTaskByNumber(displayNumber);
135
+
136
+ if (!task) {
137
+ console.error(red(`Task #${displayNumber} not found.`));
138
+ process.exit(1);
139
+ }
140
+
141
+ const decrypted = decryptTaskFields(task);
142
+
143
+ if (options.json) {
144
+ console.log(JSON.stringify(decrypted, null, 2));
145
+ return;
146
+ }
147
+
148
+ console.log(formatTaskForDisplay(decrypted));
149
+ }
150
+
151
+ /**
152
+ * Mark a task as completed.
153
+ *
154
+ * @param {string} taskId - UUID of the task
155
+ * @param {string} comment - Completion comment
156
+ * @returns {Promise<void>}
157
+ */
158
+ export async function markComplete(taskId, comment = '') {
159
+ await api.markTaskCompleted(taskId, comment);
160
+ console.log(green(`Task marked as completed.`));
161
+ }
162
+
163
+ /**
164
+ * Queue tasks for daemon execution.
165
+ *
166
+ * @param {string} numbersStr - Comma-separated display numbers
167
+ * @returns {Promise<void>}
168
+ */
169
+ export async function queueForExecution(numbersStr) {
170
+ const numbers = numbersStr.split(',').map(n => parseInt(n.trim(), 10)).filter(n => !isNaN(n));
171
+
172
+ if (numbers.length === 0) {
173
+ console.error(red('No valid task numbers provided.'));
174
+ process.exit(1);
175
+ }
176
+
177
+ const results = await api.queueTasks(numbers);
178
+
179
+ if (results.success.length > 0) {
180
+ console.log(green(`Queued: ${results.success.join(', ')}`));
181
+ }
182
+
183
+ if (results.failed.length > 0) {
184
+ for (const { num, error } of results.failed) {
185
+ console.error(red(`Failed to queue #${num}: ${error}`));
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Search tasks by query.
192
+ *
193
+ * @param {string} query - Search query
194
+ * @param {Object} options - Search options
195
+ * @param {boolean} options.allProjects - Search all projects
196
+ * @param {boolean} options.json - Output as JSON
197
+ * @returns {Promise<void>}
198
+ */
199
+ export async function searchTasks(query, options = {}) {
200
+ let gitRemote = null;
201
+ if (!options.allProjects) {
202
+ gitRemote = getGitRemote();
203
+ }
204
+
205
+ const results = await api.searchTasks(query, gitRemote);
206
+ const decrypted = results.map(decryptTaskFields);
207
+
208
+ if (options.json) {
209
+ console.log(JSON.stringify(decrypted, null, 2));
210
+ return;
211
+ }
212
+
213
+ if (decrypted.length === 0) {
214
+ console.log(`No tasks found matching "${query}".`);
215
+ return;
216
+ }
217
+
218
+ console.log(bold(`\nSearch Results for "${query}":\n`));
219
+ for (const result of decrypted) {
220
+ console.log(formatSearchResult(result));
221
+ }
222
+ console.log('');
223
+ }
224
+
225
+ /**
226
+ * Show status information.
227
+ *
228
+ * @param {Object} options - Status options
229
+ * @param {boolean} options.json - Output as JSON
230
+ * @returns {Promise<void>}
231
+ */
232
+ export async function showStatus(options = {}) {
233
+ const machineId = getMachineId();
234
+ const machineName = getMachineName();
235
+ const gitRemote = getGitRemote();
236
+ const registry = getRegistry();
237
+ const [e2eeAvailable, e2eeMessage] = isE2EEAvailable();
238
+ const autoCommit = getAutoCommitEnabled();
239
+ const maxBatch = getMaxBatchSize();
240
+
241
+ // Validate API key
242
+ const keyStatus = await api.validateApiKey();
243
+
244
+ const status = {
245
+ machine: {
246
+ id: machineId,
247
+ name: machineName
248
+ },
249
+ project: {
250
+ gitRemote,
251
+ isGitRepo: isGitRepo(),
252
+ isRegistered: gitRemote ? registry.isRegistered(gitRemote) : false
253
+ },
254
+ api: {
255
+ valid: keyStatus.valid,
256
+ email: keyStatus.email || null
257
+ },
258
+ e2ee: {
259
+ available: e2eeAvailable,
260
+ message: e2eeMessage
261
+ },
262
+ settings: {
263
+ autoCommit,
264
+ maxBatchSize: maxBatch
265
+ },
266
+ registeredProjects: registry.projectCount()
267
+ };
268
+
269
+ if (options.json) {
270
+ console.log(JSON.stringify(status, null, 2));
271
+ return;
272
+ }
273
+
274
+ console.log(bold('\nPush Status\n'));
275
+
276
+ // Machine
277
+ console.log(`${bold('Machine:')} ${machineName}`);
278
+ console.log(`${bold('Machine ID:')} ${machineId}`);
279
+ console.log('');
280
+
281
+ // API
282
+ if (status.api.valid) {
283
+ console.log(`${bold('API:')} ${green('Connected')} (${status.api.email})`);
284
+ } else {
285
+ console.log(`${bold('API:')} ${red('Not connected')} - run "push-todo connect"`);
286
+ }
287
+ console.log('');
288
+
289
+ // Project
290
+ if (gitRemote) {
291
+ console.log(`${bold('Project:')} ${gitRemote}`);
292
+ console.log(`${bold('Registered:')} ${status.project.isRegistered ? green('Yes') : yellow('No')}`);
293
+ } else if (status.project.isGitRepo) {
294
+ console.log(`${bold('Project:')} ${yellow('No remote configured')}`);
295
+ } else {
296
+ console.log(`${bold('Project:')} ${dim('Not in a git repository')}`);
297
+ }
298
+ console.log('');
299
+
300
+ // E2EE
301
+ console.log(`${bold('E2EE:')} ${e2eeAvailable ? green('Available') : yellow(e2eeMessage)}`);
302
+ console.log('');
303
+
304
+ // Settings
305
+ console.log(`${bold('Auto-commit:')} ${autoCommit ? 'Enabled' : 'Disabled'}`);
306
+ console.log(`${bold('Max batch size:')} ${maxBatch}`);
307
+ console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
308
+ }
309
+
310
+ /**
311
+ * Offer a batch of tasks for processing.
312
+ *
313
+ * @param {Object} options - Batch options
314
+ * @returns {Promise<void>}
315
+ */
316
+ export async function offerBatch(options = {}) {
317
+ const gitRemote = options.allProjects ? null : getGitRemote();
318
+ const maxBatch = getMaxBatchSize();
319
+
320
+ const tasks = await api.fetchTasks(gitRemote, {
321
+ includeBacklog: false,
322
+ includeCompleted: false
323
+ });
324
+
325
+ const decrypted = tasks.map(decryptTaskFields);
326
+ const active = decrypted.filter(t => !t.isCompleted && !t.is_completed && !t.isBacklog && !t.is_backlog);
327
+
328
+ if (active.length === 0) {
329
+ console.log('No active tasks to offer.');
330
+ return;
331
+ }
332
+
333
+ // Take up to maxBatch tasks
334
+ const batch = active.slice(0, maxBatch);
335
+
336
+ if (options.json) {
337
+ console.log(JSON.stringify(batch, null, 2));
338
+ return;
339
+ }
340
+
341
+ console.log(formatBatchOffer(batch));
342
+ }
343
+
344
+ /**
345
+ * Run the review flow for completed tasks.
346
+ *
347
+ * @param {Object} options - Review options
348
+ * @returns {Promise<void>}
349
+ */
350
+ export async function runReview(options = {}) {
351
+ const gitRemote = options.allProjects ? null : getGitRemote();
352
+
353
+ const tasks = await api.fetchTasks(gitRemote, {
354
+ completedOnly: true
355
+ });
356
+
357
+ const decrypted = tasks.map(decryptTaskFields);
358
+
359
+ if (decrypted.length === 0) {
360
+ console.log('No completed tasks to review.');
361
+ return;
362
+ }
363
+
364
+ if (options.json) {
365
+ console.log(JSON.stringify(decrypted, null, 2));
366
+ return;
367
+ }
368
+
369
+ console.log(bold(`\nCompleted Tasks for Review (${decrypted.length}):\n`));
370
+ console.log(formatTaskTable(decrypted));
371
+ }
package/lib/index.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Push CLI - Voice tasks from Push iOS app for Claude Code
3
+ *
4
+ * Main module exports for programmatic usage.
5
+ *
6
+ * @module @masslessai/push-todo
7
+ */
8
+
9
+ // CLI entry point
10
+ export { run } from './cli.js';
11
+
12
+ // Task operations
13
+ export {
14
+ listTasks,
15
+ showTask,
16
+ markComplete,
17
+ queueForExecution,
18
+ searchTasks,
19
+ showStatus,
20
+ offerBatch,
21
+ runReview
22
+ } from './fetch.js';
23
+
24
+ // API client
25
+ export {
26
+ fetchTasks,
27
+ fetchTaskByNumber,
28
+ markTaskCompleted,
29
+ queueTask,
30
+ queueTasks,
31
+ searchTasks as apiSearchTasks,
32
+ updateTaskExecution,
33
+ validateApiKey,
34
+ registerProject,
35
+ validateMachine,
36
+ getLatestVersion
37
+ } from './api.js';
38
+
39
+ // Connect and authentication
40
+ export { runConnect } from './connect.js';
41
+
42
+ // Watch/monitor
43
+ export { startWatch } from './watch.js';
44
+
45
+ // Configuration
46
+ export {
47
+ getConfigValue,
48
+ setConfigValue,
49
+ getApiKey,
50
+ saveCredentials,
51
+ getAutoCommitEnabled,
52
+ getMaxBatchSize,
53
+ showSettings,
54
+ toggleSetting
55
+ } from './config.js';
56
+
57
+ // Machine identification
58
+ export {
59
+ getMachineId,
60
+ getMachineName,
61
+ getMachineInfo
62
+ } from './machine-id.js';
63
+
64
+ // Project registry
65
+ export {
66
+ getRegistry,
67
+ resetRegistry,
68
+ ProjectRegistry,
69
+ REGISTRY_FILE
70
+ } from './project-registry.js';
71
+
72
+ // Encryption
73
+ export {
74
+ getEncryptionKey,
75
+ decrypt,
76
+ decryptTodoField,
77
+ isE2EEAvailable
78
+ } from './encryption.js';
79
+
80
+ // Utilities
81
+ export {
82
+ getGitRemote,
83
+ isGitRepo,
84
+ getCurrentBranch,
85
+ getGitRoot,
86
+ getRecentCommits,
87
+ hasUncommittedChanges
88
+ } from './utils/git.js';
89
+
90
+ export {
91
+ formatDuration,
92
+ formatDate,
93
+ formatTaskForDisplay,
94
+ formatSearchResult,
95
+ formatTaskTable,
96
+ formatBatchOffer,
97
+ truncate
98
+ } from './utils/format.js';
99
+
100
+ export {
101
+ bold,
102
+ dim,
103
+ red,
104
+ green,
105
+ yellow,
106
+ cyan,
107
+ muted,
108
+ codes,
109
+ symbols,
110
+ colorsEnabled
111
+ } from './utils/colors.js';
112
+
113
+ // Version
114
+ export const VERSION = '3.0.0';