@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/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
|
-
|
|
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(
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
31
|
-
kSecAttrAccount as String:
|
|
32
|
-
kSecAttrSynchronizable as String:
|
|
33
|
-
kSecReturnData as String:
|
|
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
|
-
|
|
104
|
+
var status = SecItemCopyMatching(syncQuery as CFDictionary, &result)
|
|
105
|
+
debug("Synced query returned: \(status)")
|
|
39
106
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
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
|
-
|
|
262
|
+
push-keychain-helper - Read/write Push encryption key
|
|
83
263
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
283
|
+
exit(ExitCode.success.rawValue)
|
|
97
284
|
}
|
|
98
285
|
|
|
99
|
-
//
|
|
100
|
-
if
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
351
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masslessai/push-todo",
|
|
3
|
-
"version": "3.
|
|
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
|
},
|