@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/.claude-plugin/plugin.json +5 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/SKILL.md +180 -0
- package/bin/push-todo.js +23 -0
- package/commands/push-todo.md +78 -0
- package/hooks/hooks.json +26 -0
- package/hooks/session-end.js +99 -0
- package/hooks/session-start.js +134 -0
- package/lib/api.js +325 -0
- package/lib/cli.js +191 -0
- package/lib/config.js +279 -0
- package/lib/connect.js +380 -0
- package/lib/encryption.js +201 -0
- package/lib/fetch.js +371 -0
- package/lib/index.js +114 -0
- package/lib/machine-id.js +101 -0
- package/lib/project-registry.js +279 -0
- package/lib/utils/colors.js +126 -0
- package/lib/utils/format.js +253 -0
- package/lib/utils/git.js +149 -0
- package/lib/watch.js +343 -0
- package/natives/KeychainHelper.swift +134 -0
- package/package.json +54 -0
- package/scripts/postinstall.js +136 -0
package/lib/watch.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live terminal UI for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Displays real-time daemon status and task execution progress.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'fs';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import readline from 'readline';
|
|
11
|
+
import { codes, colorsEnabled } from './utils/colors.js';
|
|
12
|
+
import { formatDuration, truncate } from './utils/format.js';
|
|
13
|
+
|
|
14
|
+
const STATUS_FILE = join(homedir(), '.push', 'daemon_status.json');
|
|
15
|
+
const REFRESH_INTERVAL = 500; // ms
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read the current daemon status from file.
|
|
19
|
+
*
|
|
20
|
+
* @returns {Object|null} Status object or null if unavailable
|
|
21
|
+
*/
|
|
22
|
+
function readStatus() {
|
|
23
|
+
if (!existsSync(STATUS_FILE)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const content = readFileSync(STATUS_FILE, 'utf8');
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a task status line for display.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} task - Task status object
|
|
39
|
+
* @returns {string} Formatted status line
|
|
40
|
+
*/
|
|
41
|
+
function formatTaskLine(task) {
|
|
42
|
+
const num = `#${task.displayNumber || '?'}`.padEnd(5);
|
|
43
|
+
const summary = truncate(task.summary || 'Unknown', 40);
|
|
44
|
+
|
|
45
|
+
let statusIcon = '○';
|
|
46
|
+
let statusColor = codes.yellow;
|
|
47
|
+
|
|
48
|
+
switch (task.status) {
|
|
49
|
+
case 'running':
|
|
50
|
+
statusIcon = '●';
|
|
51
|
+
statusColor = codes.green;
|
|
52
|
+
break;
|
|
53
|
+
case 'completed':
|
|
54
|
+
statusIcon = '✓';
|
|
55
|
+
statusColor = codes.green;
|
|
56
|
+
break;
|
|
57
|
+
case 'failed':
|
|
58
|
+
statusIcon = '✗';
|
|
59
|
+
statusColor = codes.red;
|
|
60
|
+
break;
|
|
61
|
+
case 'queued':
|
|
62
|
+
statusIcon = '○';
|
|
63
|
+
statusColor = codes.yellow;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (colorsEnabled()) {
|
|
68
|
+
return ` ${statusColor}${statusIcon}${codes.reset} ${num} ${summary}`;
|
|
69
|
+
}
|
|
70
|
+
return ` ${statusIcon} ${num} ${summary}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Format the full UI screen.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object|null} status - Daemon status object
|
|
77
|
+
* @returns {string} Formatted screen content
|
|
78
|
+
*/
|
|
79
|
+
function formatUI(status) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
|
|
82
|
+
// Header
|
|
83
|
+
lines.push('');
|
|
84
|
+
if (colorsEnabled()) {
|
|
85
|
+
lines.push(`${codes.bold}Push Daemon Monitor${codes.reset}`);
|
|
86
|
+
} else {
|
|
87
|
+
lines.push('Push Daemon Monitor');
|
|
88
|
+
}
|
|
89
|
+
lines.push('─'.repeat(50));
|
|
90
|
+
|
|
91
|
+
if (!status) {
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(' Daemon not running or status unavailable.');
|
|
94
|
+
lines.push(' Run "push-todo" to start the daemon.');
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('─'.repeat(50));
|
|
97
|
+
lines.push('Press q to quit');
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Daemon info
|
|
102
|
+
lines.push('');
|
|
103
|
+
const daemonStatus = status.running ? 'Running' : 'Stopped';
|
|
104
|
+
const daemonColor = status.running ? codes.green : codes.red;
|
|
105
|
+
|
|
106
|
+
if (colorsEnabled()) {
|
|
107
|
+
lines.push(` Status: ${daemonColor}${daemonStatus}${codes.reset}`);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(` Status: ${daemonStatus}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (status.pid) {
|
|
113
|
+
lines.push(` PID: ${status.pid}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (status.uptime) {
|
|
117
|
+
lines.push(` Uptime: ${formatDuration(status.uptime)}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Running tasks
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('─'.repeat(50));
|
|
123
|
+
|
|
124
|
+
const runningTasks = status.runningTasks || [];
|
|
125
|
+
if (runningTasks.length > 0) {
|
|
126
|
+
if (colorsEnabled()) {
|
|
127
|
+
lines.push(`${codes.bold}Running Tasks (${runningTasks.length})${codes.reset}`);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push(`Running Tasks (${runningTasks.length})`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
|
|
133
|
+
for (const task of runningTasks) {
|
|
134
|
+
lines.push(formatTaskLine(task));
|
|
135
|
+
|
|
136
|
+
// Show progress if available
|
|
137
|
+
if (task.progress) {
|
|
138
|
+
const progressBar = renderProgressBar(task.progress, 30);
|
|
139
|
+
lines.push(` ${progressBar}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Show current step if available
|
|
143
|
+
if (task.currentStep) {
|
|
144
|
+
const step = truncate(task.currentStep, 40);
|
|
145
|
+
if (colorsEnabled()) {
|
|
146
|
+
lines.push(` ${codes.dim}${step}${codes.reset}`);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(` ${step}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if (colorsEnabled()) {
|
|
154
|
+
lines.push(`${codes.dim}No tasks currently running${codes.reset}`);
|
|
155
|
+
} else {
|
|
156
|
+
lines.push('No tasks currently running');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Queued tasks
|
|
161
|
+
const queuedTasks = status.queuedTasks || [];
|
|
162
|
+
if (queuedTasks.length > 0) {
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push('─'.repeat(50));
|
|
165
|
+
if (colorsEnabled()) {
|
|
166
|
+
lines.push(`${codes.bold}Queued (${queuedTasks.length})${codes.reset}`);
|
|
167
|
+
} else {
|
|
168
|
+
lines.push(`Queued (${queuedTasks.length})`);
|
|
169
|
+
}
|
|
170
|
+
lines.push('');
|
|
171
|
+
|
|
172
|
+
for (const task of queuedTasks.slice(0, 5)) {
|
|
173
|
+
lines.push(formatTaskLine(task));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (queuedTasks.length > 5) {
|
|
177
|
+
lines.push(` ... and ${queuedTasks.length - 5} more`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Completed today
|
|
182
|
+
const completedToday = status.completedToday || [];
|
|
183
|
+
if (completedToday.length > 0) {
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('─'.repeat(50));
|
|
186
|
+
if (colorsEnabled()) {
|
|
187
|
+
lines.push(`${codes.bold}Completed Today (${completedToday.length})${codes.reset}`);
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(`Completed Today (${completedToday.length})`);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
|
|
193
|
+
for (const task of completedToday.slice(-3)) {
|
|
194
|
+
lines.push(formatTaskLine({ ...task, status: 'completed' }));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (completedToday.length > 3) {
|
|
198
|
+
lines.push(` ... and ${completedToday.length - 3} more`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Footer
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('─'.repeat(50));
|
|
205
|
+
lines.push('Press q to quit, r to refresh');
|
|
206
|
+
|
|
207
|
+
return lines.join('\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Render a progress bar.
|
|
212
|
+
*
|
|
213
|
+
* @param {number} progress - Progress 0-100
|
|
214
|
+
* @param {number} width - Bar width in characters
|
|
215
|
+
* @returns {string} Rendered progress bar
|
|
216
|
+
*/
|
|
217
|
+
function renderProgressBar(progress, width) {
|
|
218
|
+
const filled = Math.round((progress / 100) * width);
|
|
219
|
+
const empty = width - filled;
|
|
220
|
+
|
|
221
|
+
if (colorsEnabled()) {
|
|
222
|
+
return `${codes.green}${'█'.repeat(filled)}${codes.dim}${'░'.repeat(empty)}${codes.reset} ${progress}%`;
|
|
223
|
+
}
|
|
224
|
+
return `[${'#'.repeat(filled)}${'-'.repeat(empty)}] ${progress}%`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Output status as JSON (for non-TTY).
|
|
229
|
+
*/
|
|
230
|
+
function outputJSON() {
|
|
231
|
+
const status = readStatus();
|
|
232
|
+
console.log(JSON.stringify(status, null, 2));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Output status as plain text (for non-TTY).
|
|
237
|
+
*/
|
|
238
|
+
function outputPlainText() {
|
|
239
|
+
const status = readStatus();
|
|
240
|
+
|
|
241
|
+
if (!status) {
|
|
242
|
+
console.log('Daemon not running');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`Status: ${status.running ? 'Running' : 'Stopped'}`);
|
|
247
|
+
|
|
248
|
+
if (status.pid) {
|
|
249
|
+
console.log(`PID: ${status.pid}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const running = status.runningTasks || [];
|
|
253
|
+
console.log(`Running tasks: ${running.length}`);
|
|
254
|
+
|
|
255
|
+
for (const task of running) {
|
|
256
|
+
console.log(` #${task.displayNumber}: ${task.summary || 'Unknown'}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const queued = status.queuedTasks || [];
|
|
260
|
+
console.log(`Queued tasks: ${queued.length}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Start the live watch UI.
|
|
265
|
+
*
|
|
266
|
+
* @param {Object} options - Watch options
|
|
267
|
+
* @param {boolean} options.json - Output JSON instead of TUI
|
|
268
|
+
*/
|
|
269
|
+
export function startWatch(options = {}) {
|
|
270
|
+
// JSON mode
|
|
271
|
+
if (options.json) {
|
|
272
|
+
outputJSON();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Non-TTY mode
|
|
277
|
+
if (!process.stdout.isTTY) {
|
|
278
|
+
outputPlainText();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Live TUI mode
|
|
283
|
+
let running = true;
|
|
284
|
+
|
|
285
|
+
// Hide cursor
|
|
286
|
+
process.stdout.write(codes.hideCursor);
|
|
287
|
+
|
|
288
|
+
// Render function
|
|
289
|
+
function render() {
|
|
290
|
+
const status = readStatus();
|
|
291
|
+
const output = formatUI(status);
|
|
292
|
+
process.stdout.write(codes.clearScreen + codes.cursorHome + output);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Initial render
|
|
296
|
+
render();
|
|
297
|
+
|
|
298
|
+
// Set up refresh interval
|
|
299
|
+
const interval = setInterval(() => {
|
|
300
|
+
if (running) {
|
|
301
|
+
render();
|
|
302
|
+
}
|
|
303
|
+
}, REFRESH_INTERVAL);
|
|
304
|
+
|
|
305
|
+
// Set up keyboard handling
|
|
306
|
+
readline.emitKeypressEvents(process.stdin);
|
|
307
|
+
|
|
308
|
+
if (process.stdin.isTTY) {
|
|
309
|
+
process.stdin.setRawMode(true);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Cleanup function
|
|
313
|
+
function cleanup() {
|
|
314
|
+
running = false;
|
|
315
|
+
clearInterval(interval);
|
|
316
|
+
|
|
317
|
+
// Show cursor
|
|
318
|
+
process.stdout.write(codes.showCursor);
|
|
319
|
+
|
|
320
|
+
// Clear screen and show exit message
|
|
321
|
+
process.stdout.write(codes.clearScreen + codes.cursorHome);
|
|
322
|
+
console.log('Watch mode ended.');
|
|
323
|
+
|
|
324
|
+
if (process.stdin.isTTY) {
|
|
325
|
+
process.stdin.setRawMode(false);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Handle keyboard input
|
|
332
|
+
process.stdin.on('keypress', (str, key) => {
|
|
333
|
+
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
334
|
+
cleanup();
|
|
335
|
+
} else if (key.name === 'r') {
|
|
336
|
+
render();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Handle process signals
|
|
341
|
+
process.on('SIGINT', cleanup);
|
|
342
|
+
process.on('SIGTERM', cleanup);
|
|
343
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
/**
|
|
3
|
+
* Push Keychain Helper
|
|
4
|
+
*
|
|
5
|
+
* Retrieves the E2EE encryption key from iCloud Keychain.
|
|
6
|
+
* Used by the push-todo CLI for decrypting end-to-end encrypted tasks.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes:
|
|
9
|
+
* 0 - Success (key printed to stdout as base64)
|
|
10
|
+
* 1 - Key not found in Keychain
|
|
11
|
+
* 2 - iCloud Keychain not available
|
|
12
|
+
* 3 - Other error
|
|
13
|
+
*
|
|
14
|
+
* Build:
|
|
15
|
+
* swiftc -O KeychainHelper.swift -o push-keychain-helper
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import Foundation
|
|
19
|
+
import Security
|
|
20
|
+
|
|
21
|
+
/// Keychain service and account identifiers
|
|
22
|
+
/// Must match the iOS app's keychain storage
|
|
23
|
+
let keychainService = "ai.massless.push.e2ee"
|
|
24
|
+
let keychainAccount = "encryption-key"
|
|
25
|
+
|
|
26
|
+
/// Query the keychain for the E2EE encryption key
|
|
27
|
+
func getEncryptionKey() -> (Data?, OSStatus) {
|
|
28
|
+
let query: [String: Any] = [
|
|
29
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
30
|
+
kSecAttrService as String: keychainService,
|
|
31
|
+
kSecAttrAccount as String: keychainAccount,
|
|
32
|
+
kSecAttrSynchronizable as String: kCFBooleanTrue!, // iCloud Keychain
|
|
33
|
+
kSecReturnData as String: kCFBooleanTrue!,
|
|
34
|
+
kSecMatchLimit as String: kSecMatchLimitOne
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
var result: AnyObject?
|
|
38
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
39
|
+
|
|
40
|
+
if status == errSecSuccess, let data = result as? Data {
|
|
41
|
+
return (data, status)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (nil, status)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Check if iCloud Keychain is available
|
|
48
|
+
func isICloudKeychainAvailable() -> Bool {
|
|
49
|
+
// Try to query for any iCloud synced item
|
|
50
|
+
let query: [String: Any] = [
|
|
51
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
52
|
+
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
|
53
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
54
|
+
kSecReturnAttributes as String: kCFBooleanTrue!
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
var result: AnyObject?
|
|
58
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
59
|
+
|
|
60
|
+
// If we get errSecItemNotFound, iCloud Keychain is available but empty
|
|
61
|
+
// If we get errSecSuccess, iCloud Keychain is available with items
|
|
62
|
+
// Other errors may indicate iCloud Keychain is not available
|
|
63
|
+
return status == errSecSuccess || status == errSecItemNotFound
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Main entry point
|
|
67
|
+
func main() -> Int32 {
|
|
68
|
+
// Handle --check flag
|
|
69
|
+
if CommandLine.arguments.contains("--check") {
|
|
70
|
+
if isICloudKeychainAvailable() {
|
|
71
|
+
print("iCloud Keychain available")
|
|
72
|
+
return 0
|
|
73
|
+
} else {
|
|
74
|
+
fputs("iCloud Keychain not available\n", stderr)
|
|
75
|
+
return 2
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle --help flag
|
|
80
|
+
if CommandLine.arguments.contains("--help") || CommandLine.arguments.contains("-h") {
|
|
81
|
+
print("""
|
|
82
|
+
Usage: push-keychain-helper [options]
|
|
83
|
+
|
|
84
|
+
Options:
|
|
85
|
+
--check Check if iCloud Keychain is available
|
|
86
|
+
--help Show this help
|
|
87
|
+
|
|
88
|
+
Without options, retrieves and prints the E2EE key as base64.
|
|
89
|
+
|
|
90
|
+
Exit codes:
|
|
91
|
+
0 - Success
|
|
92
|
+
1 - Key not found
|
|
93
|
+
2 - iCloud Keychain not available
|
|
94
|
+
3 - Other error
|
|
95
|
+
""")
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check iCloud Keychain availability first
|
|
100
|
+
if !isICloudKeychainAvailable() {
|
|
101
|
+
fputs("Error: iCloud Keychain not available\n", stderr)
|
|
102
|
+
return 2
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Get the encryption key
|
|
106
|
+
let (keyData, status) = getEncryptionKey()
|
|
107
|
+
|
|
108
|
+
switch status {
|
|
109
|
+
case errSecSuccess:
|
|
110
|
+
if let data = keyData {
|
|
111
|
+
// Output as base64
|
|
112
|
+
print(data.base64EncodedString())
|
|
113
|
+
return 0
|
|
114
|
+
} else {
|
|
115
|
+
fputs("Error: Key data is nil\n", stderr)
|
|
116
|
+
return 3
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case errSecItemNotFound:
|
|
120
|
+
fputs("Error: Encryption key not found in Keychain\n", stderr)
|
|
121
|
+
fputs("Make sure E2EE is enabled in the Push iOS app\n", stderr)
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
case errSecAuthFailed:
|
|
125
|
+
fputs("Error: Keychain authentication failed\n", stderr)
|
|
126
|
+
return 3
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
fputs("Error: Keychain error (status: \(status))\n", stderr)
|
|
130
|
+
return 3
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
exit(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@masslessai/push-todo",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Voice tasks from Push iOS app for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"push-todo": "./bin/push-todo.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./lib/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/index.js",
|
|
12
|
+
"./fetch": "./lib/fetch.js",
|
|
13
|
+
"./connect": "./lib/connect.js",
|
|
14
|
+
"./config": "./lib/config.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"lib/",
|
|
19
|
+
"hooks/",
|
|
20
|
+
"commands/",
|
|
21
|
+
"natives/",
|
|
22
|
+
"scripts/",
|
|
23
|
+
".claude-plugin/",
|
|
24
|
+
"SKILL.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "node scripts/postinstall.js",
|
|
32
|
+
"test": "node --test test/",
|
|
33
|
+
"start": "node bin/push-todo.js"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"claude-code",
|
|
37
|
+
"claude-code-plugin",
|
|
38
|
+
"push",
|
|
39
|
+
"voice",
|
|
40
|
+
"todo",
|
|
41
|
+
"ios",
|
|
42
|
+
"cli"
|
|
43
|
+
],
|
|
44
|
+
"author": "MasslessAI",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/MasslessAI/push-todo-cli"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://pushto.do",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/MasslessAI/push-todo-cli/issues"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Post-install script for Push CLI.
|
|
4
|
+
*
|
|
5
|
+
* Downloads the native keychain helper binary for macOS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readFileSync } from 'fs';
|
|
9
|
+
import { chmod, stat } from 'fs/promises';
|
|
10
|
+
import { pipeline } from 'stream/promises';
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { homedir, platform, arch } from 'os';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
const BINARY_NAME = 'push-keychain-helper';
|
|
19
|
+
const BINARY_DIR = join(__dirname, '../bin');
|
|
20
|
+
const BINARY_PATH = join(BINARY_DIR, BINARY_NAME);
|
|
21
|
+
|
|
22
|
+
// GitHub release URL pattern
|
|
23
|
+
const RELEASE_VERSION = '3.0.0';
|
|
24
|
+
const BINARY_URL = `https://github.com/MasslessAI/push-todo-cli/releases/download/v${RELEASE_VERSION}/${BINARY_NAME}-darwin-arm64`;
|
|
25
|
+
const BINARY_URL_X64 = `https://github.com/MasslessAI/push-todo-cli/releases/download/v${RELEASE_VERSION}/${BINARY_NAME}-darwin-x64`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Migrate from old Python installation if present.
|
|
29
|
+
*/
|
|
30
|
+
function migrateFromPython() {
|
|
31
|
+
const oldPath = join(homedir(), '.claude', 'skills', 'push-todo');
|
|
32
|
+
|
|
33
|
+
if (existsSync(oldPath)) {
|
|
34
|
+
console.log('[push-todo] Detected previous Python installation.');
|
|
35
|
+
console.log(`[push-todo] Config preserved at ~/.config/push/`);
|
|
36
|
+
console.log(`[push-todo] Old files can be removed: rm -rf ${oldPath}`);
|
|
37
|
+
console.log('');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Download a file from URL to destination.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url - Source URL
|
|
45
|
+
* @param {string} dest - Destination path
|
|
46
|
+
* @returns {Promise<boolean>} True if successful
|
|
47
|
+
*/
|
|
48
|
+
async function downloadFile(url, dest) {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
signal: AbortSignal.timeout(60000) // 60 second timeout
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ensure directory exists
|
|
59
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
60
|
+
|
|
61
|
+
// Remove existing file if present
|
|
62
|
+
if (existsSync(dest)) {
|
|
63
|
+
unlinkSync(dest);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Write to file
|
|
67
|
+
const fileStream = createWriteStream(dest);
|
|
68
|
+
await pipeline(response.body, fileStream);
|
|
69
|
+
|
|
70
|
+
// Make executable
|
|
71
|
+
await chmod(dest, 0o755);
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(`[push-todo] Download failed: ${error.message}`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Main post-install routine.
|
|
82
|
+
*/
|
|
83
|
+
async function main() {
|
|
84
|
+
console.log('[push-todo] Running post-install...');
|
|
85
|
+
|
|
86
|
+
// Check for migration
|
|
87
|
+
migrateFromPython();
|
|
88
|
+
|
|
89
|
+
// Check platform
|
|
90
|
+
if (platform() !== 'darwin') {
|
|
91
|
+
console.log('[push-todo] Skipping native binary (macOS only)');
|
|
92
|
+
console.log('[push-todo] E2EE features will not be available.');
|
|
93
|
+
console.log('[push-todo] Installation complete.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if binary already exists and is valid
|
|
98
|
+
if (existsSync(BINARY_PATH)) {
|
|
99
|
+
try {
|
|
100
|
+
const stats = await stat(BINARY_PATH);
|
|
101
|
+
if (stats.size > 0) {
|
|
102
|
+
console.log('[push-todo] Native binary already installed.');
|
|
103
|
+
console.log('[push-todo] Installation complete.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Continue to download
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Determine architecture
|
|
112
|
+
const archType = arch();
|
|
113
|
+
const url = archType === 'arm64' ? BINARY_URL : BINARY_URL_X64;
|
|
114
|
+
|
|
115
|
+
console.log(`[push-todo] Downloading native binary for ${archType}...`);
|
|
116
|
+
|
|
117
|
+
const success = await downloadFile(url, BINARY_PATH);
|
|
118
|
+
|
|
119
|
+
if (success) {
|
|
120
|
+
console.log('[push-todo] Native binary installed successfully.');
|
|
121
|
+
console.log('[push-todo] E2EE decryption is now available.');
|
|
122
|
+
} else {
|
|
123
|
+
console.log('[push-todo] Native binary download failed.');
|
|
124
|
+
console.log('[push-todo] E2EE features will not be available.');
|
|
125
|
+
console.log('[push-todo] You can manually download from:');
|
|
126
|
+
console.log(`[push-todo] ${url}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('[push-todo] Installation complete.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().catch(error => {
|
|
133
|
+
console.error(`[push-todo] Post-install error: ${error.message}`);
|
|
134
|
+
// Don't fail the install - E2EE is optional
|
|
135
|
+
process.exit(0);
|
|
136
|
+
});
|