@masslessai/push-todo 3.7.6 → 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}`, {
@@ -1317,6 +1363,54 @@ async function checkTimeouts() {
1317
1363
  }
1318
1364
  }
1319
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
+
1320
1414
  // ==================== Main Loop ====================
1321
1415
 
1322
1416
  async function pollAndExecute() {
@@ -1364,6 +1458,9 @@ async function mainLoop() {
1364
1458
  log(`Polling interval: ${POLL_INTERVAL / 1000}s`);
1365
1459
  log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
1366
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}`);
1367
1464
  log(`Log file: ${LOG_FILE}`);
1368
1465
 
1369
1466
  // Show registered projects
@@ -1399,6 +1496,11 @@ async function mainLoop() {
1399
1496
  try {
1400
1497
  await checkTimeouts();
1401
1498
  await pollAndExecute();
1499
+
1500
+ // Self-update check (throttled to once per hour, only applies when idle)
1501
+ if (getAutoUpdateEnabled()) {
1502
+ checkAndApplyUpdate();
1503
+ }
1402
1504
  } catch (error) {
1403
1505
  logError(`Poll error: ${error.message}`);
1404
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.6",
3
+ "version": "3.7.7",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {