@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/lib/fetch.js CHANGED
@@ -148,6 +148,20 @@ export async function showTask(displayNumber, options = {}) {
148
148
  console.log(formatTaskForDisplay(decrypted));
149
149
  }
150
150
 
151
+ /**
152
+ * Get a task by display number (for programmatic use).
153
+ *
154
+ * @param {number} displayNumber - The task's display number
155
+ * @returns {Promise<Object|null>} The task object or null if not found
156
+ */
157
+ export async function getTaskByNumber(displayNumber) {
158
+ const task = await api.fetchTaskByNumber(displayNumber);
159
+ if (!task) {
160
+ return null;
161
+ }
162
+ return decryptTaskFields(task);
163
+ }
164
+
151
165
  /**
152
166
  * Mark a task as completed.
153
167
  *
@@ -280,7 +294,8 @@ export async function showStatus(options = {}) {
280
294
 
281
295
  // API
282
296
  if (status.api.valid) {
283
- console.log(`${bold('API:')} ${green('Connected')} (${status.api.email})`);
297
+ const emailPart = status.api.email ? ` (${status.api.email})` : '';
298
+ console.log(`${bold('API:')} ${green('Connected')}${emailPart}`);
284
299
  } else {
285
300
  console.log(`${bold('API:')} ${red('Not connected')} - run "push-todo connect"`);
286
301
  }
package/lib/utils/git.js CHANGED
@@ -147,3 +147,46 @@ export function hasUncommittedChanges() {
147
147
  return false;
148
148
  }
149
149
  }
150
+
151
+ /**
152
+ * Normalize a git remote URL to a consistent format.
153
+ *
154
+ * Converts various URL formats to: host/owner/repo
155
+ * - git@github.com:user/repo.git → github.com/user/repo
156
+ * - https://github.com/user/repo.git → github.com/user/repo
157
+ * - ssh://git@github.com/user/repo → github.com/user/repo
158
+ *
159
+ * @param {string} url - The git remote URL to normalize
160
+ * @returns {string} Normalized URL
161
+ */
162
+ export function normalizeGitRemote(url) {
163
+ if (!url) return url;
164
+
165
+ let normalized = url.trim();
166
+
167
+ // Remove protocol prefixes
168
+ const prefixes = ['https://', 'http://', 'git@', 'ssh://git@', 'ssh://'];
169
+ for (const prefix of prefixes) {
170
+ if (normalized.startsWith(prefix)) {
171
+ normalized = normalized.slice(prefix.length);
172
+ break;
173
+ }
174
+ }
175
+
176
+ // Convert : to / (for git@ style URLs like git@github.com:user/repo)
177
+ if (normalized.includes(':') && !normalized.includes('://')) {
178
+ normalized = normalized.replace(':', '/');
179
+ }
180
+
181
+ // Remove .git suffix
182
+ if (normalized.endsWith('.git')) {
183
+ normalized = normalized.slice(0, -4);
184
+ }
185
+
186
+ // Remove trailing slash
187
+ if (normalized.endsWith('/')) {
188
+ normalized = normalized.slice(0, -1);
189
+ }
190
+
191
+ return normalized;
192
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Screenshot utilities for Push CLI.
3
+ *
4
+ * Screenshots are stored in iCloud Drive and can be opened via the default image viewer.
5
+ */
6
+
7
+ import { existsSync } from 'fs';
8
+ import { homedir } from 'os';
9
+ import { join, basename } from 'path';
10
+ import { spawn } from 'child_process';
11
+
12
+ const SCREENSHOTS_DIR = join(
13
+ homedir(),
14
+ 'Library/Mobile Documents/iCloud~ai~massless~push/Documents/Screenshots'
15
+ );
16
+
17
+ /**
18
+ * Get the full path to a screenshot file in iCloud Drive.
19
+ *
20
+ * @param {string} filename - Screenshot filename (e.g., "ABC-123.heic")
21
+ * @returns {string} Full path to screenshot file
22
+ */
23
+ export function getScreenshotPath(filename) {
24
+ return join(SCREENSHOTS_DIR, filename);
25
+ }
26
+
27
+ /**
28
+ * Check if screenshot file exists in iCloud Drive.
29
+ *
30
+ * @param {string} filename - Screenshot filename
31
+ * @returns {boolean} True if file exists
32
+ */
33
+ export function screenshotExists(filename) {
34
+ return existsSync(getScreenshotPath(filename));
35
+ }
36
+
37
+ /**
38
+ * Open screenshot file in default image viewer.
39
+ *
40
+ * @param {string} filepath - Full path to screenshot file
41
+ * @returns {Promise<void>}
42
+ */
43
+ export function openScreenshot(filepath) {
44
+ return new Promise((resolve, reject) => {
45
+ if (!existsSync(filepath)) {
46
+ reject(new Error(`Screenshot not found: ${filepath}`));
47
+ return;
48
+ }
49
+
50
+ // Use macOS 'open' command
51
+ const child = spawn('open', [filepath], {
52
+ stdio: 'inherit'
53
+ });
54
+
55
+ child.on('error', reject);
56
+ child.on('close', (code) => {
57
+ if (code === 0) {
58
+ console.log(`Opened screenshot: ${basename(filepath)}`);
59
+ resolve();
60
+ } else {
61
+ reject(new Error(`Failed to open screenshot (exit code: ${code})`));
62
+ }
63
+ });
64
+ });
65
+ }
package/lib/watch.js CHANGED
@@ -265,6 +265,7 @@ function outputPlainText() {
265
265
  *
266
266
  * @param {Object} options - Watch options
267
267
  * @param {boolean} options.json - Output JSON instead of TUI
268
+ * @param {boolean} options.follow - Exit when all tasks complete
268
269
  */
269
270
  export function startWatch(options = {}) {
270
271
  // JSON mode
@@ -281,6 +282,7 @@ export function startWatch(options = {}) {
281
282
 
282
283
  // Live TUI mode
283
284
  let running = true;
285
+ const followMode = options.follow || options.f;
284
286
 
285
287
  // Hide cursor
286
288
  process.stdout.write(codes.hideCursor);
@@ -290,6 +292,15 @@ export function startWatch(options = {}) {
290
292
  const status = readStatus();
291
293
  const output = formatUI(status);
292
294
  process.stdout.write(codes.clearScreen + codes.cursorHome + output);
295
+
296
+ // In follow mode, exit when no running or queued tasks
297
+ if (followMode && status) {
298
+ const runningTasks = status.runningTasks || [];
299
+ const queuedTasks = status.queuedTasks || [];
300
+ if (runningTasks.length === 0 && queuedTasks.length === 0) {
301
+ cleanup('All tasks completed.');
302
+ }
303
+ }
293
304
  }
294
305
 
295
306
  // Initial render
@@ -310,7 +321,7 @@ export function startWatch(options = {}) {
310
321
  }
311
322
 
312
323
  // Cleanup function
313
- function cleanup() {
324
+ function cleanup(message = 'Watch mode ended.') {
314
325
  running = false;
315
326
  clearInterval(interval);
316
327
 
@@ -319,7 +330,7 @@ export function startWatch(options = {}) {
319
330
 
320
331
  // Clear screen and show exit message
321
332
  process.stdout.write(codes.clearScreen + codes.cursorHome);
322
- console.log('Watch mode ended.');
333
+ console.log(message);
323
334
 
324
335
  if (process.stdin.isTTY) {
325
336
  process.stdin.setRawMode(false);
@@ -1,134 +1,351 @@
1
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
- */
2
+ //
3
+ // KeychainHelper.swift
4
+ // push-todo CLI
5
+ //
6
+ // Created on 2026-01-29.
7
+ //
8
+ // Reads the Push encryption key from iCloud Keychain.
9
+ // This helper is called by the Python CLI to decrypt encrypted todos.
10
+ //
11
+ // COMPILE:
12
+ // swiftc -O KeychainHelper.swift -o push-keychain-helper
13
+ //
14
+ // USAGE:
15
+ // ./push-keychain-helper # Outputs base64-encoded key
16
+ // ./push-keychain-helper --check # Exits 0 if key exists, 1 if not
17
+ // ./push-keychain-helper --version # Print version
18
+ //
19
+ // EXIT CODES:
20
+ // 0 = Success
21
+ // 1 = Key not found
22
+ // 2 = iCloud Keychain not available
23
+ // 3 = Other error
24
+ //
25
+ // CRITICAL: These values MUST match the iOS EncryptionService exactly:
26
+ // - Service: "ai.massless.push.encryption"
27
+ // - Account: "data-encryption-key"
28
+ // - Synchronizable: true (iCloud Keychain)
29
+ // - NO access group (allows CLI to read)
30
+ //
31
+ // See: /docs/20260126_e2ee_cli_implementation_analysis.md
32
+ //
17
33
 
18
34
  import Foundation
19
35
  import Security
20
36
 
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"
37
+ // MARK: - Constants
25
38
 
26
- /// Query the keychain for the E2EE encryption key
27
- func getEncryptionKey() -> (Data?, OSStatus) {
28
- let query: [String: Any] = [
39
+ /// Version of this helper (for update checks)
40
+ let VERSION = "1.0.1"
41
+
42
+ /// Keychain service - MUST match iOS EncryptionService
43
+ let KEYCHAIN_SERVICE = "ai.massless.push.encryption"
44
+
45
+ /// Keychain account - MUST match iOS EncryptionService
46
+ let KEYCHAIN_ACCOUNT = "data-encryption-key"
47
+
48
+ // MARK: - Debug
49
+
50
+ var debugMode = false
51
+
52
+ func debug(_ message: String) {
53
+ // Always write to file for debugging hangs
54
+ let logPath = "/tmp/push-keychain-helper.log"
55
+ let entry = "[DEBUG] \(message)\n"
56
+ if let data = entry.data(using: .utf8) {
57
+ if FileManager.default.fileExists(atPath: logPath) {
58
+ if let handle = FileHandle(forWritingAtPath: logPath) {
59
+ handle.seekToEndOfFile()
60
+ handle.write(data)
61
+ handle.closeFile()
62
+ }
63
+ } else {
64
+ FileManager.default.createFile(atPath: logPath, contents: data)
65
+ }
66
+ }
67
+ if debugMode {
68
+ fputs("[DEBUG] \(message)\n", stderr)
69
+ }
70
+ }
71
+
72
+ // MARK: - Exit Codes
73
+
74
+ enum ExitCode: Int32 {
75
+ case success = 0
76
+ case keyNotFound = 1
77
+ case iCloudNotAvailable = 2
78
+ case otherError = 3
79
+ }
80
+
81
+ // MARK: - Keychain Functions
82
+
83
+ /// Read the encryption key from iCloud Keychain.
84
+ ///
85
+ /// - Returns: The key data, or nil if not found.
86
+ func readEncryptionKey() -> Data? {
87
+ debug("Building keychain query...")
88
+ debug(" Service: \(KEYCHAIN_SERVICE)")
89
+ debug(" Account: \(KEYCHAIN_ACCOUNT)")
90
+ debug(" Synchronizable: any (prefer synced)")
91
+
92
+ // First try iCloud-synced key
93
+ let syncQuery: [String: Any] = [
29
94
  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!,
95
+ kSecAttrService as String: KEYCHAIN_SERVICE,
96
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
97
+ kSecAttrSynchronizable as String: true,
98
+ kSecReturnData as String: true,
34
99
  kSecMatchLimit as String: kSecMatchLimitOne
35
100
  ]
36
101
 
102
+ debug("Trying iCloud-synced key first...")
37
103
  var result: AnyObject?
38
- let status = SecItemCopyMatching(query as CFDictionary, &result)
104
+ var status = SecItemCopyMatching(syncQuery as CFDictionary, &result)
105
+ debug("Synced query returned: \(status)")
39
106
 
40
- if status == errSecSuccess, let data = result as? Data {
41
- return (data, status)
107
+ // If not found, try local key (fallback for testing or manual import)
108
+ if status == errSecItemNotFound {
109
+ debug("Synced key not found, trying local key...")
110
+ let localQuery: [String: Any] = [
111
+ kSecClass as String: kSecClassGenericPassword,
112
+ kSecAttrService as String: KEYCHAIN_SERVICE,
113
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
114
+ kSecAttrSynchronizable as String: false,
115
+ kSecReturnData as String: true,
116
+ kSecMatchLimit as String: kSecMatchLimitOne
117
+ ]
118
+ result = nil
119
+ status = SecItemCopyMatching(localQuery as CFDictionary, &result)
120
+ debug("Local query returned: \(status)")
42
121
  }
43
122
 
44
- return (nil, status)
123
+ switch status {
124
+ case errSecSuccess:
125
+ debug("Success! Got key data.")
126
+ return result as? Data
127
+
128
+ case errSecItemNotFound:
129
+ debug("Key not found in keychain.")
130
+ return nil
131
+
132
+ case errSecNotAvailable:
133
+ // iCloud Keychain not available
134
+ debug("iCloud Keychain not available (errSecNotAvailable)")
135
+ fputs("Error: iCloud Keychain is not available on this Mac.\n", stderr)
136
+ fputs("Make sure you're signed into iCloud and have Keychain enabled.\n", stderr)
137
+ exit(ExitCode.iCloudNotAvailable.rawValue)
138
+
139
+ case -34018: // errSecMissingEntitlement
140
+ debug("Missing entitlement (errSecMissingEntitlement)")
141
+ fputs("Error: Missing keychain entitlement. The helper may need to be properly signed.\n", stderr)
142
+ exit(ExitCode.otherError.rawValue)
143
+
144
+ default:
145
+ debug("Query failed with status: \(status)")
146
+ fputs("Error: Keychain query failed with status \(status)\n", stderr)
147
+ exit(ExitCode.otherError.rawValue)
148
+ }
45
149
  }
46
150
 
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] = [
151
+ /// Check if the encryption key exists (either synced or local).
152
+ ///
153
+ /// - Returns: true if the key exists, false otherwise.
154
+ func keyExists() -> Bool {
155
+ debug("Checking if key exists...")
156
+
157
+ // First check iCloud-synced key
158
+ let syncQuery: [String: Any] = [
51
159
  kSecClass as String: kSecClassGenericPassword,
52
- kSecAttrSynchronizable as String: kCFBooleanTrue!,
53
- kSecMatchLimit as String: kSecMatchLimitOne,
54
- kSecReturnAttributes as String: kCFBooleanTrue!
160
+ kSecAttrService as String: KEYCHAIN_SERVICE,
161
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
162
+ kSecAttrSynchronizable as String: true,
163
+ kSecReturnData as String: false
55
164
  ]
56
165
 
166
+ debug("Checking synced key...")
57
167
  var result: AnyObject?
58
- let status = SecItemCopyMatching(query as CFDictionary, &result)
168
+ var status = SecItemCopyMatching(syncQuery as CFDictionary, &result)
169
+ debug("Synced check returned: \(status)")
170
+
171
+ if status == errSecSuccess {
172
+ return true
173
+ }
174
+
175
+ // Fallback to local key
176
+ let localQuery: [String: Any] = [
177
+ kSecClass as String: kSecClassGenericPassword,
178
+ kSecAttrService as String: KEYCHAIN_SERVICE,
179
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
180
+ kSecAttrSynchronizable as String: false,
181
+ kSecReturnData as String: false
182
+ ]
183
+
184
+ debug("Checking local key...")
185
+ result = nil
186
+ status = SecItemCopyMatching(localQuery as CFDictionary, &result)
187
+ debug("Local check returned: \(status)")
59
188
 
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
189
+ return status == errSecSuccess
64
190
  }
65
191
 
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
- }
192
+ /// Store an encryption key in the LOCAL (file-based) keychain.
193
+ ///
194
+ /// This is used for manual key import when iCloud Keychain sync doesn't work for CLI.
195
+ /// The key is stored with kSecAttrSynchronizable: false so it stays in login.keychain.
196
+ ///
197
+ /// - Parameter keyData: The 32-byte AES-256 encryption key
198
+ /// - Returns: true if stored successfully, false otherwise.
199
+ func storeLocalKey(_ keyData: Data) -> Bool {
200
+ debug("Storing key in local keychain...")
201
+ debug(" Key size: \(keyData.count) bytes")
202
+
203
+ // Validate key size (AES-256 = 32 bytes)
204
+ guard keyData.count == 32 else {
205
+ debug("Invalid key size: expected 32 bytes, got \(keyData.count)")
206
+ return false
77
207
  }
78
208
 
79
- // Handle --help flag
80
- if CommandLine.arguments.contains("--help") || CommandLine.arguments.contains("-h") {
209
+ // Delete any existing local key first
210
+ let deleteQuery: [String: Any] = [
211
+ kSecClass as String: kSecClassGenericPassword,
212
+ kSecAttrService as String: KEYCHAIN_SERVICE,
213
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
214
+ kSecAttrSynchronizable as String: false
215
+ ]
216
+
217
+ let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
218
+ debug("Delete existing returned: \(deleteStatus)")
219
+
220
+ // Store new key
221
+ let addQuery: [String: Any] = [
222
+ kSecClass as String: kSecClassGenericPassword,
223
+ kSecAttrService as String: KEYCHAIN_SERVICE,
224
+ kSecAttrAccount as String: KEYCHAIN_ACCOUNT,
225
+ kSecAttrSynchronizable as String: false, // CRITICAL: file-based keychain
226
+ kSecValueData as String: keyData,
227
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
228
+ ]
229
+
230
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
231
+ debug("Add key returned: \(status)")
232
+
233
+ return status == errSecSuccess
234
+ }
235
+
236
+ // MARK: - Main
237
+
238
+ func main() {
239
+ // First thing - write to log to prove we started
240
+ debug("=== Starting push-keychain-helper ===")
241
+
242
+ let args = CommandLine.arguments
243
+
244
+ // Check for debug mode
245
+ if args.contains("--debug") || args.contains("-d") {
246
+ debugMode = true
247
+ debug("Debug mode enabled (stderr output)")
248
+ }
249
+
250
+ debug("push-keychain-helper v\(VERSION)")
251
+ debug("Arguments: \(args)")
252
+
253
+ // Handle --version
254
+ if args.contains("--version") || args.contains("-v") {
255
+ print(VERSION)
256
+ exit(ExitCode.success.rawValue)
257
+ }
258
+
259
+ // Handle --help
260
+ if args.contains("--help") || args.contains("-h") {
81
261
  print("""
82
- Usage: push-keychain-helper [options]
262
+ push-keychain-helper - Read/write Push encryption key
83
263
 
84
- Options:
85
- --check Check if iCloud Keychain is available
86
- --help Show this help
264
+ USAGE:
265
+ push-keychain-helper Output base64-encoded encryption key
266
+ push-keychain-helper --check Check if key exists (exit 0) or not (exit 1)
267
+ push-keychain-helper --store Import key from stdin (base64)
268
+ push-keychain-helper --debug Enable debug output
269
+ push-keychain-helper --version Print version
270
+ push-keychain-helper --help Show this help
87
271
 
88
- Without options, retrieves and prints the E2EE key as base64.
272
+ EXIT CODES:
273
+ 0 Success
274
+ 1 Key not found / invalid key
275
+ 2 iCloud Keychain not available
276
+ 3 Other error
89
277
 
90
- Exit codes:
91
- 0 - Success
92
- 1 - Key not found
93
- 2 - iCloud Keychain not available
94
- 3 - Other error
278
+ NOTES:
279
+ - Key is stored by the Push iOS app in iCloud Keychain
280
+ - For CLI, use --store to import key to local (file-based) keychain
281
+ - First run may prompt for Keychain access permission
95
282
  """)
96
- return 0
283
+ exit(ExitCode.success.rawValue)
97
284
  }
98
285
 
99
- // Check iCloud Keychain availability first
100
- if !isICloudKeychainAvailable() {
101
- fputs("Error: iCloud Keychain not available\n", stderr)
102
- return 2
103
- }
286
+ // Handle --store (import key from stdin)
287
+ if args.contains("--store") {
288
+ debug("Store mode: reading key from stdin...")
104
289
 
105
- // Get the encryption key
106
- let (keyData, status) = getEncryptionKey()
290
+ // Read base64 key from stdin
291
+ guard let inputLine = readLine() else {
292
+ fputs("Error: No input provided. Paste the base64 key from your iOS app.\n", stderr)
293
+ exit(ExitCode.keyNotFound.rawValue)
294
+ }
107
295
 
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
296
+ let base64Key = inputLine.trimmingCharacters(in: .whitespacesAndNewlines)
297
+ debug("Read base64 key: \(base64Key.prefix(20))...")
298
+
299
+ // Decode base64
300
+ guard let keyData = Data(base64Encoded: base64Key) else {
301
+ fputs("Error: Invalid base64 encoding.\n", stderr)
302
+ exit(ExitCode.keyNotFound.rawValue)
117
303
  }
118
304
 
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
305
+ debug("Decoded key: \(keyData.count) bytes")
123
306
 
124
- case errSecAuthFailed:
125
- fputs("Error: Keychain authentication failed\n", stderr)
126
- return 3
307
+ // Validate key size
308
+ guard keyData.count == 32 else {
309
+ fputs("Error: Invalid key size. Expected 32 bytes, got \(keyData.count).\n", stderr)
310
+ exit(ExitCode.keyNotFound.rawValue)
311
+ }
127
312
 
128
- default:
129
- fputs("Error: Keychain error (status: \(status))\n", stderr)
130
- return 3
313
+ // Store in local keychain
314
+ if storeLocalKey(keyData) {
315
+ print("Key stored successfully in macOS Keychain")
316
+ exit(ExitCode.success.rawValue)
317
+ } else {
318
+ fputs("Error: Failed to store key in Keychain.\n", stderr)
319
+ exit(ExitCode.otherError.rawValue)
320
+ }
321
+ }
322
+
323
+ // Handle --check (just check if key exists)
324
+ if args.contains("--check") {
325
+ if keyExists() {
326
+ print("Key exists")
327
+ exit(ExitCode.success.rawValue)
328
+ } else {
329
+ print("Key not found")
330
+ exit(ExitCode.keyNotFound.rawValue)
331
+ }
332
+ }
333
+
334
+ // Default: read and output the key
335
+ debug("Reading encryption key...")
336
+ guard let keyData = readEncryptionKey() else {
337
+ fputs("Error: Encryption key not found in iCloud Keychain.\n", stderr)
338
+ fputs("Make sure:\n", stderr)
339
+ fputs(" 1. You have enabled E2EE in the Push iOS app\n", stderr)
340
+ fputs(" 2. iCloud Keychain is syncing to this Mac\n", stderr)
341
+ fputs(" 3. You're signed into the same Apple ID\n", stderr)
342
+ exit(ExitCode.keyNotFound.rawValue)
131
343
  }
344
+
345
+ debug("Key found, outputting base64...")
346
+ // Output base64-encoded key to stdout
347
+ print(keyData.base64EncodedString())
348
+ exit(ExitCode.success.rawValue)
132
349
  }
133
350
 
134
- exit(main())
351
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "postinstall": "node scripts/postinstall.js",
32
+ "preuninstall": "node scripts/preuninstall.js",
32
33
  "test": "node --test test/",
33
34
  "start": "node bin/push-todo.js"
34
35
  },