@masslessai/push-todo 3.7.5 → 3.7.7

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 CHANGED
@@ -192,6 +192,27 @@ export function setAutoCompleteEnabled(enabled) {
192
192
  return setConfigValue('AUTO_COMPLETE', enabled ? 'true' : 'false');
193
193
  }
194
194
 
195
+ /**
196
+ * Check if auto-update is enabled for daemon self-updates.
197
+ * Default: true (daemon checks npm hourly and updates when idle)
198
+ *
199
+ * @returns {boolean}
200
+ */
201
+ export function getAutoUpdateEnabled() {
202
+ const value = getConfigValue('AUTO_UPDATE', 'true');
203
+ return value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === 'yes';
204
+ }
205
+
206
+ /**
207
+ * Set auto-update setting.
208
+ *
209
+ * @param {boolean} enabled
210
+ * @returns {boolean} True if successful
211
+ */
212
+ export function setAutoUpdateEnabled(enabled) {
213
+ return setConfigValue('AUTO_UPDATE', enabled ? 'true' : 'false');
214
+ }
215
+
195
216
  /**
196
217
  * Get the maximum batch size for queuing tasks.
197
218
  * Default: 5
@@ -270,6 +291,7 @@ export function showSettings() {
270
291
  const autoCommit = getAutoCommitEnabled();
271
292
  const autoMerge = getAutoMergeEnabled();
272
293
  const autoComplete = getAutoCompleteEnabled();
294
+ const autoUpdate = getAutoUpdateEnabled();
273
295
  const batchSize = getMaxBatchSize();
274
296
 
275
297
  console.log(` auto-commit: ${autoCommit ? 'ON' : 'OFF'}`);
@@ -281,6 +303,9 @@ export function showSettings() {
281
303
  console.log(` auto-complete: ${autoComplete ? 'ON' : 'OFF'}`);
282
304
  console.log(' Mark task completed after successful merge');
283
305
  console.log();
306
+ console.log(` auto-update: ${autoUpdate ? 'ON' : 'OFF'}`);
307
+ console.log(' Daemon auto-updates from npm when idle');
308
+ console.log();
284
309
  console.log(` batch-size: ${batchSize}`);
285
310
  console.log(' Max tasks for batch queue (1-20)');
286
311
  console.log();
@@ -346,6 +371,22 @@ export function toggleSetting(settingName) {
346
371
  return false;
347
372
  }
348
373
 
374
+ if (normalized === 'auto-update') {
375
+ const current = getAutoUpdateEnabled();
376
+ const newValue = !current;
377
+ if (setAutoUpdateEnabled(newValue)) {
378
+ console.log(`Auto-update is now ${newValue ? 'ON' : 'OFF'}`);
379
+ if (newValue) {
380
+ console.log('Daemon will auto-update from npm when idle (hourly check).');
381
+ } else {
382
+ console.log('Daemon will NOT auto-update. Manual updates required.');
383
+ }
384
+ return true;
385
+ }
386
+ console.error('Failed to update setting');
387
+ return false;
388
+ }
389
+
349
390
  if (normalized === 'batch-size') {
350
391
  const batchSize = getMaxBatchSize();
351
392
  console.log(`Current batch size: ${batchSize}`);
@@ -354,7 +395,7 @@ export function toggleSetting(settingName) {
354
395
  }
355
396
 
356
397
  console.error(`Unknown setting: ${settingName}`);
357
- console.error('Available settings: auto-commit, auto-merge, auto-complete, batch-size');
398
+ console.error('Available settings: auto-commit, auto-merge, auto-complete, auto-update, batch-size');
358
399
  return false;
359
400
  }
360
401
 
package/lib/daemon.js CHANGED
@@ -19,6 +19,8 @@ import { homedir, hostname, platform } from 'os';
19
19
  import { join, dirname } from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
 
22
+ import { checkForUpdate, performUpdate } from './self-update.js';
23
+
22
24
  const __filename = fileURLToPath(import.meta.url);
23
25
  const __dirname = dirname(__filename);
24
26
 
@@ -230,6 +232,48 @@ function getAutoCompleteEnabled() {
230
232
  return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
231
233
  }
232
234
 
235
+ function getAutoUpdateEnabled() {
236
+ const v = getConfigValueFromFile('AUTO_UPDATE', 'true');
237
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
238
+ }
239
+
240
+ // ==================== Capabilities Detection ====================
241
+
242
+ let cachedCapabilities = null;
243
+ let lastCapabilityCheck = 0;
244
+ const CAPABILITY_CHECK_INTERVAL = 3600000; // 1 hour
245
+
246
+ function detectCapabilities() {
247
+ const caps = {
248
+ auto_merge: getAutoMergeEnabled(),
249
+ auto_complete: getAutoCompleteEnabled(),
250
+ auto_update: getAutoUpdateEnabled(),
251
+ };
252
+
253
+ try {
254
+ execFileSync('gh', ['--version'], { timeout: 5000, stdio: 'pipe' });
255
+ try {
256
+ execFileSync('gh', ['auth', 'status'], { timeout: 5000, stdio: 'pipe' });
257
+ caps.gh_cli = 'authenticated';
258
+ } catch {
259
+ caps.gh_cli = 'installed_not_authenticated';
260
+ }
261
+ } catch {
262
+ caps.gh_cli = 'not_installed';
263
+ }
264
+
265
+ return caps;
266
+ }
267
+
268
+ function getCapabilities() {
269
+ const now = Date.now();
270
+ if (!cachedCapabilities || now - lastCapabilityCheck > CAPABILITY_CHECK_INTERVAL) {
271
+ cachedCapabilities = detectCapabilities();
272
+ lastCapabilityCheck = now;
273
+ }
274
+ return cachedCapabilities;
275
+ }
276
+
233
277
  // ==================== E2EE Decryption ====================
234
278
 
235
279
  let decryptTodoField = null;
@@ -335,6 +379,8 @@ async function fetchQueuedTasks() {
335
379
  heartbeatHeaders['X-Machine-Id'] = machineId;
336
380
  heartbeatHeaders['X-Machine-Name'] = machineName || 'Unknown Mac';
337
381
  heartbeatHeaders['X-Git-Remotes'] = gitRemotes.join(',');
382
+ heartbeatHeaders['X-Daemon-Version'] = getVersion();
383
+ heartbeatHeaders['X-Capabilities'] = JSON.stringify(getCapabilities());
338
384
  }
339
385
 
340
386
  const response = await apiRequest(`synced-todos?${params}`, {
@@ -409,11 +455,15 @@ async function claimTask(displayNumber) {
409
455
  return true;
410
456
  }
411
457
 
458
+ const suffix = getWorktreeSuffix();
459
+ const branch = `push-${displayNumber}-${suffix}`;
460
+
412
461
  const payload = {
413
462
  displayNumber,
414
463
  status: 'running',
415
464
  machineId,
416
465
  machineName,
466
+ branch,
417
467
  atomic: true
418
468
  };
419
469
 
@@ -1313,6 +1363,54 @@ async function checkTimeouts() {
1313
1363
  }
1314
1364
  }
1315
1365
 
1366
+ // ==================== Self-Update ====================
1367
+
1368
+ let pendingUpdateVersion = null;
1369
+
1370
+ function checkAndApplyUpdate() {
1371
+ const currentVersion = getVersion();
1372
+
1373
+ // Check for update (throttled internally to once per hour)
1374
+ if (!pendingUpdateVersion) {
1375
+ const result = checkForUpdate(currentVersion);
1376
+ if (result.available) {
1377
+ log(`Update available: v${currentVersion} -> v${result.version}`);
1378
+ pendingUpdateVersion = result.version;
1379
+ }
1380
+ }
1381
+
1382
+ // Only apply when no tasks are running
1383
+ if (pendingUpdateVersion && runningTasks.size === 0) {
1384
+ log(`Applying update to v${pendingUpdateVersion}...`);
1385
+ sendMacNotification(
1386
+ 'Push Daemon Updating',
1387
+ `v${currentVersion} → v${pendingUpdateVersion}`,
1388
+ 'Glass'
1389
+ );
1390
+
1391
+ const success = performUpdate(pendingUpdateVersion);
1392
+ if (success) {
1393
+ log(`Update to v${pendingUpdateVersion} successful. Restarting daemon...`);
1394
+
1395
+ // Spawn new daemon from updated code, then exit
1396
+ const daemonScript = join(__dirname, 'daemon.js');
1397
+ const child = spawn(process.execPath, [daemonScript], {
1398
+ detached: true,
1399
+ stdio: ['ignore', 'ignore', 'ignore'],
1400
+ env: { ...process.env, PUSH_DAEMON: '1' }
1401
+ });
1402
+ writeFileSync(PID_FILE, String(child.pid));
1403
+ child.unref();
1404
+
1405
+ log(`New daemon spawned (PID: ${child.pid}). Old daemon exiting.`);
1406
+ process.exit(0);
1407
+ } else {
1408
+ logError(`Update to v${pendingUpdateVersion} failed, will retry next hour`);
1409
+ pendingUpdateVersion = null;
1410
+ }
1411
+ }
1412
+ }
1413
+
1316
1414
  // ==================== Main Loop ====================
1317
1415
 
1318
1416
  async function pollAndExecute() {
@@ -1360,6 +1458,9 @@ async function mainLoop() {
1360
1458
  log(`Polling interval: ${POLL_INTERVAL / 1000}s`);
1361
1459
  log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
1362
1460
  log(`E2EE: ${e2eeAvailable ? 'Available' : 'Not available'}`);
1461
+ log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
1462
+ const caps = getCapabilities();
1463
+ log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
1363
1464
  log(`Log file: ${LOG_FILE}`);
1364
1465
 
1365
1466
  // Show registered projects
@@ -1395,6 +1496,11 @@ async function mainLoop() {
1395
1496
  try {
1396
1497
  await checkTimeouts();
1397
1498
  await pollAndExecute();
1499
+
1500
+ // Self-update check (throttled to once per hour, only applies when idle)
1501
+ if (getAutoUpdateEnabled()) {
1502
+ checkAndApplyUpdate();
1503
+ }
1398
1504
  } catch (error) {
1399
1505
  logError(`Poll error: ${error.message}`);
1400
1506
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Self-update module for Push daemon.
3
+ *
4
+ * Checks npm registry for newer versions and auto-updates.
5
+ * Safety: Only updates to versions published >1 hour ago.
6
+ * Throttle: Checks at most once per hour.
7
+ * Config: PUSH_AUTO_UPDATE (default true)
8
+ */
9
+
10
+ import { execFileSync } from 'child_process';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { homedir } from 'os';
13
+ import { join } from 'path';
14
+
15
+ const PUSH_DIR = join(homedir(), '.push');
16
+ const LAST_UPDATE_CHECK_FILE = join(PUSH_DIR, 'last_update_check');
17
+ const UPDATE_CHECK_INTERVAL = 3600000; // 1 hour
18
+
19
+ /**
20
+ * Compare semver strings.
21
+ * @returns -1 if a < b, 0 if equal, 1 if a > b
22
+ */
23
+ export function compareSemver(a, b) {
24
+ const pa = a.split('.').map(Number);
25
+ const pb = b.split('.').map(Number);
26
+ for (let i = 0; i < 3; i++) {
27
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
28
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ /**
34
+ * Fetch latest version info from npm registry.
35
+ * @returns {{ version: string, publishedAt: string | null }} or null on failure
36
+ */
37
+ function fetchLatestVersionInfo() {
38
+ try {
39
+ const result = execFileSync('npm', ['view', '@masslessai/push-todo', '--json'], {
40
+ timeout: 15000,
41
+ encoding: 'utf8',
42
+ stdio: ['ignore', 'pipe', 'pipe']
43
+ });
44
+ const data = JSON.parse(result);
45
+ const latest = data['dist-tags']?.latest || data.version;
46
+ return {
47
+ version: latest,
48
+ publishedAt: data.time?.[latest] || null
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check if an update is available and safe to install.
57
+ * Throttled to once per hour. Enforces 1-hour age gate on new versions.
58
+ *
59
+ * @param {string} currentVersion
60
+ * @returns {{ available: boolean, version?: string, reason?: string }}
61
+ */
62
+ export function checkForUpdate(currentVersion) {
63
+ // Throttle: check at most once per hour
64
+ if (existsSync(LAST_UPDATE_CHECK_FILE)) {
65
+ try {
66
+ const lastCheck = parseInt(readFileSync(LAST_UPDATE_CHECK_FILE, 'utf8').trim(), 10);
67
+ if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL) {
68
+ return { available: false, reason: 'throttled' };
69
+ }
70
+ } catch {}
71
+ }
72
+
73
+ // Record check time
74
+ try {
75
+ mkdirSync(PUSH_DIR, { recursive: true });
76
+ writeFileSync(LAST_UPDATE_CHECK_FILE, String(Date.now()));
77
+ } catch {}
78
+
79
+ const info = fetchLatestVersionInfo();
80
+ if (!info) {
81
+ return { available: false, reason: 'registry_unreachable' };
82
+ }
83
+
84
+ // Already up to date
85
+ if (compareSemver(currentVersion, info.version) >= 0) {
86
+ return { available: false, reason: 'up_to_date' };
87
+ }
88
+
89
+ // Safety: only update to versions published >1 hour ago
90
+ if (info.publishedAt) {
91
+ const publishedAge = Date.now() - new Date(info.publishedAt).getTime();
92
+ if (publishedAge < UPDATE_CHECK_INTERVAL) {
93
+ return { available: false, reason: 'too_recent', version: info.version };
94
+ }
95
+ }
96
+
97
+ return { available: true, version: info.version };
98
+ }
99
+
100
+ /**
101
+ * Install a specific version globally.
102
+ * @param {string} targetVersion
103
+ * @returns {boolean} true if update succeeded
104
+ */
105
+ export function performUpdate(targetVersion) {
106
+ try {
107
+ execFileSync('npm', ['install', '-g', `@masslessai/push-todo@${targetVersion}`], {
108
+ timeout: 120000,
109
+ stdio: 'pipe'
110
+ });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.5",
3
+ "version": "3.7.7",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {