@masslessai/push-todo 4.1.3 → 4.1.4

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Agent version detection and tracking for Push daemon.
3
+ *
4
+ * Detects installed versions of Claude Code, OpenAI Codex, and OpenClaw CLIs.
5
+ * Reports version parity with the push-todo CLI and flags outdated agents.
6
+ *
7
+ * Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
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 VERSIONS_CACHE_FILE = join(PUSH_DIR, 'agent_versions.json');
17
+ const CHECK_INTERVAL = 3600000; // 1 hour
18
+
19
+ // ==================== Agent Definitions ====================
20
+
21
+ /**
22
+ * Agent CLI definitions: command name, version flag, and how to parse output.
23
+ *
24
+ * Each agent has:
25
+ * - cmd: the CLI binary name
26
+ * - versionArgs: args to get version string
27
+ * - parseVersion: extracts semver from command output
28
+ */
29
+ const AGENTS = {
30
+ 'claude-code': {
31
+ cmd: 'claude',
32
+ versionArgs: ['--version'],
33
+ parseVersion(output) {
34
+ // "claude v2.1.41" or just "2.1.41"
35
+ const match = output.match(/(\d+\.\d+\.\d+)/);
36
+ return match ? match[1] : null;
37
+ },
38
+ },
39
+ 'openai-codex': {
40
+ cmd: 'codex',
41
+ versionArgs: ['--version'],
42
+ parseVersion(output) {
43
+ const match = output.match(/(\d+\.\d+\.\d+)/);
44
+ return match ? match[1] : null;
45
+ },
46
+ },
47
+ 'openclaw': {
48
+ cmd: 'openclaw',
49
+ versionArgs: ['--version'],
50
+ parseVersion(output) {
51
+ const match = output.match(/(\d+\.\d+\.\d+)/);
52
+ return match ? match[1] : null;
53
+ },
54
+ },
55
+ };
56
+
57
+ // ==================== Version Detection ====================
58
+
59
+ /**
60
+ * Detect the installed version of a single agent CLI.
61
+ *
62
+ * @param {string} agentType - One of 'claude-code', 'openai-codex', 'openclaw'
63
+ * @returns {{ installed: boolean, version: string|null, error: string|null }}
64
+ */
65
+ export function detectAgentVersion(agentType) {
66
+ const agent = AGENTS[agentType];
67
+ if (!agent) {
68
+ return { installed: false, version: null, error: `Unknown agent type: ${agentType}` };
69
+ }
70
+
71
+ try {
72
+ const output = execFileSync(agent.cmd, agent.versionArgs, {
73
+ timeout: 10000,
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ }).trim();
77
+
78
+ const version = agent.parseVersion(output);
79
+ if (version) {
80
+ return { installed: true, version, error: null };
81
+ }
82
+ return { installed: true, version: null, error: `Could not parse version from: ${output}` };
83
+ } catch (err) {
84
+ // ENOENT = not installed; other errors = installed but broken
85
+ if (err.code === 'ENOENT') {
86
+ return { installed: false, version: null, error: null };
87
+ }
88
+ return { installed: false, version: null, error: err.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Detect versions of all known agent CLIs.
94
+ *
95
+ * @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
96
+ */
97
+ export function detectAllAgentVersions() {
98
+ const results = {};
99
+ for (const agentType of Object.keys(AGENTS)) {
100
+ results[agentType] = detectAgentVersion(agentType);
101
+ }
102
+ return results;
103
+ }
104
+
105
+ // ==================== Cache ====================
106
+
107
+ /**
108
+ * Load cached agent version data.
109
+ *
110
+ * @returns {{ versions: Object, checkedAt: string|null }|null}
111
+ */
112
+ function loadCache() {
113
+ try {
114
+ if (existsSync(VERSIONS_CACHE_FILE)) {
115
+ return JSON.parse(readFileSync(VERSIONS_CACHE_FILE, 'utf8'));
116
+ }
117
+ } catch {}
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Save agent version data to cache.
123
+ *
124
+ * @param {Object} versions - Agent version results
125
+ */
126
+ function saveCache(versions) {
127
+ try {
128
+ mkdirSync(PUSH_DIR, { recursive: true });
129
+ writeFileSync(VERSIONS_CACHE_FILE, JSON.stringify({
130
+ versions,
131
+ checkedAt: new Date().toISOString(),
132
+ }, null, 2));
133
+ } catch {}
134
+ }
135
+
136
+ // ==================== Throttled Check ====================
137
+
138
+ let lastCheckTime = 0;
139
+ let cachedResults = null;
140
+
141
+ /**
142
+ * Get agent versions (throttled to once per hour).
143
+ * Returns cached results if within the check interval.
144
+ *
145
+ * @param {{ force?: boolean }} options
146
+ * @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
147
+ */
148
+ export function getAgentVersions({ force = false } = {}) {
149
+ const now = Date.now();
150
+
151
+ // Return in-memory cache if fresh
152
+ if (!force && cachedResults && (now - lastCheckTime < CHECK_INTERVAL)) {
153
+ return cachedResults;
154
+ }
155
+
156
+ // Try disk cache if no in-memory cache
157
+ if (!force && !cachedResults) {
158
+ const diskCache = loadCache();
159
+ if (diskCache?.checkedAt) {
160
+ const cacheAge = now - new Date(diskCache.checkedAt).getTime();
161
+ if (cacheAge < CHECK_INTERVAL) {
162
+ cachedResults = diskCache.versions;
163
+ lastCheckTime = now - (CHECK_INTERVAL - cacheAge); // preserve remaining TTL
164
+ return cachedResults;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Fresh detection
170
+ cachedResults = detectAllAgentVersions();
171
+ lastCheckTime = now;
172
+ saveCache(cachedResults);
173
+ return cachedResults;
174
+ }
175
+
176
+ /**
177
+ * Get a human-readable summary of agent versions for logging.
178
+ *
179
+ * @param {Object} versions - From getAgentVersions()
180
+ * @returns {string}
181
+ */
182
+ export function formatAgentVersionSummary(versions) {
183
+ const parts = [];
184
+ for (const [type, info] of Object.entries(versions)) {
185
+ const label = type.replace(/-/g, ' ');
186
+ if (info.installed && info.version) {
187
+ parts.push(`${label}=v${info.version}`);
188
+ } else if (info.installed) {
189
+ parts.push(`${label}=installed (unknown version)`);
190
+ } else {
191
+ parts.push(`${label}=not found`);
192
+ }
193
+ }
194
+ return parts.join(', ');
195
+ }
196
+
197
+ /**
198
+ * Get the list of known agent types.
199
+ *
200
+ * @returns {string[]}
201
+ */
202
+ export function getKnownAgentTypes() {
203
+ return Object.keys(AGENTS);
204
+ }
package/lib/cli.js CHANGED
@@ -47,6 +47,7 @@ ${bold('USAGE:')}
47
47
  push-todo connect Run connection doctor
48
48
  push-todo search <query> Search tasks
49
49
  push-todo review Review completed tasks
50
+ push-todo update Update CLI, check agents, refresh projects
50
51
 
51
52
  ${bold('OPTIONS:')}
52
53
  --all-projects, -a List tasks from all projects
@@ -655,6 +656,12 @@ export async function run(argv) {
655
656
  return runConnect(values);
656
657
  }
657
658
 
659
+ // Update command - manual update of CLI, agents, and project freshness
660
+ if (command === 'update') {
661
+ const { runManualUpdate } = await import('./update.js');
662
+ return runManualUpdate(values);
663
+ }
664
+
658
665
  // Review command
659
666
  if (command === 'review') {
660
667
  return fetch.runReview(values);
package/lib/daemon.js CHANGED
@@ -25,6 +25,8 @@ import { getProjectContext, buildSmartPrompt, invalidateCache } from './context-
25
25
  import { sendMacNotification } from './utils/notify.js';
26
26
  import { checkAndRunDueJobs } from './cron.js';
27
27
  import { runHeartbeatChecks } from './heartbeat.js';
28
+ import { getAgentVersions, formatAgentVersionSummary } from './agent-versions.js';
29
+ import { checkAllProjectsFreshness } from './project-freshness.js';
28
30
 
29
31
  const __filename = fileURLToPath(import.meta.url);
30
32
  const __dirname = dirname(__filename);
@@ -2243,6 +2245,8 @@ async function mainLoop() {
2243
2245
  log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
2244
2246
  const caps = getCapabilities();
2245
2247
  log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
2248
+ const agentVersions = getAgentVersions({ force: true });
2249
+ log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
2246
2250
  log(`Log file: ${LOG_FILE}`);
2247
2251
 
2248
2252
  // Show registered projects
@@ -2313,6 +2317,30 @@ async function mainLoop() {
2313
2317
  logError(`Heartbeat error: ${error.message}`);
2314
2318
  }
2315
2319
 
2320
+ // Project freshness check (internally throttled to once per hour)
2321
+ try {
2322
+ const projects = getListedProjects();
2323
+ const busyPaths = new Set();
2324
+ for (const [, info] of runningTasks) {
2325
+ if (info.projectPath) busyPaths.add(info.projectPath);
2326
+ }
2327
+ checkAllProjectsFreshness({
2328
+ projects,
2329
+ autoRebase: getAutoUpdateEnabled(),
2330
+ busyPaths,
2331
+ log,
2332
+ });
2333
+ } catch (error) {
2334
+ logError(`Freshness check error: ${error.message}`);
2335
+ }
2336
+
2337
+ // Agent version check (internally throttled to once per hour)
2338
+ try {
2339
+ getAgentVersions();
2340
+ } catch (error) {
2341
+ logError(`Agent version check error: ${error.message}`);
2342
+ }
2343
+
2316
2344
  // Self-update check (throttled to once per hour, only applies when idle)
2317
2345
  if (getAutoUpdateEnabled()) {
2318
2346
  checkAndApplyUpdate();
package/lib/fetch.js CHANGED
@@ -12,6 +12,7 @@ import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
12
12
  import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
13
13
  import { decryptTodoField, isE2EEAvailable } from './encryption.js';
14
14
  import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
15
+ import { getAgentVersions } from './agent-versions.js';
15
16
  import { writeFileSync, mkdirSync } from 'fs';
16
17
  import { homedir } from 'os';
17
18
  import { join } from 'path';
@@ -445,6 +446,26 @@ export async function showStatus(options = {}) {
445
446
  console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
446
447
  console.log(`${bold('Max batch size:')} ${maxBatch}`);
447
448
  console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
449
+ console.log('');
450
+
451
+ // Agent versions
452
+ const agentVersions = getAgentVersions();
453
+ console.log(bold('Agent Versions:'));
454
+ const agentLabels = {
455
+ 'claude-code': 'Claude Code',
456
+ 'openai-codex': 'Codex',
457
+ 'openclaw': 'OpenClaw',
458
+ };
459
+ for (const [type, info] of Object.entries(agentVersions)) {
460
+ const label = agentLabels[type] || type;
461
+ if (info.installed && info.version) {
462
+ console.log(` ${bold(label + ':')} ${green('v' + info.version)}`);
463
+ } else if (info.installed) {
464
+ console.log(` ${bold(label + ':')} ${yellow('installed (unknown version)')}`);
465
+ } else {
466
+ console.log(` ${bold(label + ':')} ${dim('not installed')}`);
467
+ }
468
+ }
448
469
  }
449
470
 
450
471
  /**
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Project freshness checker for Push daemon.
3
+ *
4
+ * Checks if registered projects are behind their remote and optionally
5
+ * pulls updates via rebase. Only updates when the working tree is clean
6
+ * and no daemon tasks are running for that project.
7
+ *
8
+ * Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
9
+ */
10
+
11
+ import { execFileSync } from 'child_process';
12
+ import { existsSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ // ==================== Configuration ====================
16
+
17
+ const FRESHNESS_CHECK_INTERVAL = 3600000; // 1 hour
18
+
19
+ // ==================== Internal State ====================
20
+
21
+ let lastFreshnessCheck = 0;
22
+
23
+ // ==================== Git Helpers ====================
24
+
25
+ /**
26
+ * Get the default branch for a repository (main or master).
27
+ *
28
+ * @param {string} projectPath
29
+ * @returns {string|null}
30
+ */
31
+ function getDefaultBranch(projectPath) {
32
+ try {
33
+ // Try symbolic-ref to origin/HEAD first
34
+ const ref = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
35
+ cwd: projectPath,
36
+ timeout: 5000,
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ }).trim();
40
+ // "refs/remotes/origin/main" → "main"
41
+ return ref.split('/').pop();
42
+ } catch {
43
+ // Fallback: check if main or master exists
44
+ for (const branch of ['main', 'master']) {
45
+ try {
46
+ execFileSync('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
47
+ cwd: projectPath,
48
+ timeout: 5000,
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ });
52
+ return branch;
53
+ } catch {}
54
+ }
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Get the currently checked-out branch.
61
+ *
62
+ * @param {string} projectPath
63
+ * @returns {string|null}
64
+ */
65
+ function getCurrentBranch(projectPath) {
66
+ try {
67
+ return execFileSync('git', ['branch', '--show-current'], {
68
+ cwd: projectPath,
69
+ timeout: 5000,
70
+ encoding: 'utf8',
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ }).trim() || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check if the working tree is clean (no uncommitted changes).
80
+ *
81
+ * @param {string} projectPath
82
+ * @returns {boolean}
83
+ */
84
+ function isWorkingTreeClean(projectPath) {
85
+ try {
86
+ const status = execFileSync('git', ['status', '--porcelain'], {
87
+ cwd: projectPath,
88
+ timeout: 10000,
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ }).trim();
92
+ return status === '';
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Fetch from remote (quiet, non-interactive).
100
+ *
101
+ * @param {string} projectPath
102
+ * @returns {boolean} true if fetch succeeded
103
+ */
104
+ function gitFetch(projectPath) {
105
+ try {
106
+ execFileSync('git', ['fetch', '--quiet'], {
107
+ cwd: projectPath,
108
+ timeout: 30000,
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ });
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Count how many commits the local branch is behind the remote.
120
+ *
121
+ * @param {string} projectPath
122
+ * @param {string} branch
123
+ * @returns {number} commits behind, or -1 on error
124
+ */
125
+ function commitsBehind(projectPath, branch) {
126
+ try {
127
+ const output = execFileSync('git', [
128
+ 'rev-list', '--count', `${branch}..origin/${branch}`
129
+ ], {
130
+ cwd: projectPath,
131
+ timeout: 5000,
132
+ encoding: 'utf8',
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ }).trim();
135
+ return parseInt(output, 10) || 0;
136
+ } catch {
137
+ return -1;
138
+ }
139
+ }
140
+
141
+ // ==================== Freshness Check ====================
142
+
143
+ /**
144
+ * Check and optionally update a single project.
145
+ *
146
+ * @param {string} projectPath - Absolute path to the project
147
+ * @param {Object} options
148
+ * @param {boolean} options.autoRebase - If true, pull --rebase when behind
149
+ * @param {Set<string>} options.busyPaths - Paths with running tasks (skip these)
150
+ * @param {Function} options.log - Logging function
151
+ * @returns {{ status: string, behind?: number, updated?: boolean, error?: string }}
152
+ */
153
+ export function checkProjectFreshness(projectPath, { autoRebase = false, busyPaths = new Set(), log = () => {} } = {}) {
154
+ if (!existsSync(join(projectPath, '.git'))) {
155
+ return { status: 'not_a_repo' };
156
+ }
157
+
158
+ const defaultBranch = getDefaultBranch(projectPath);
159
+ if (!defaultBranch) {
160
+ return { status: 'no_default_branch' };
161
+ }
162
+
163
+ const currentBranch = getCurrentBranch(projectPath);
164
+
165
+ // Fetch latest from remote
166
+ if (!gitFetch(projectPath)) {
167
+ return { status: 'fetch_failed' };
168
+ }
169
+
170
+ // Check how far behind the default branch is
171
+ const behind = commitsBehind(projectPath, defaultBranch);
172
+ if (behind < 0) {
173
+ return { status: 'compare_failed' };
174
+ }
175
+
176
+ if (behind === 0) {
177
+ return { status: 'up_to_date', behind: 0 };
178
+ }
179
+
180
+ // Project is behind — decide whether to auto-update
181
+ if (!autoRebase) {
182
+ return { status: 'behind', behind };
183
+ }
184
+
185
+ // Skip if tasks are running in this project
186
+ if (busyPaths.has(projectPath)) {
187
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but has running tasks — skipping rebase`);
188
+ return { status: 'behind_busy', behind };
189
+ }
190
+
191
+ // Skip if not on default branch (user is on a feature branch)
192
+ if (currentBranch !== defaultBranch) {
193
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but on branch '${currentBranch}' — skipping rebase`);
194
+ return { status: 'behind_wrong_branch', behind, currentBranch };
195
+ }
196
+
197
+ // Skip if working tree is dirty
198
+ if (!isWorkingTreeClean(projectPath)) {
199
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but working tree is dirty — skipping rebase`);
200
+ return { status: 'behind_dirty', behind };
201
+ }
202
+
203
+ // Safe to rebase
204
+ try {
205
+ execFileSync('git', ['pull', '--rebase', '--quiet'], {
206
+ cwd: projectPath,
207
+ timeout: 60000,
208
+ encoding: 'utf8',
209
+ stdio: ['ignore', 'pipe', 'pipe'],
210
+ });
211
+ log(`Freshness: ${projectPath} updated (${behind} commit(s) rebased)`);
212
+ return { status: 'updated', behind, updated: true };
213
+ } catch (err) {
214
+ // Abort failed rebase to leave tree clean
215
+ try {
216
+ execFileSync('git', ['rebase', '--abort'], {
217
+ cwd: projectPath,
218
+ timeout: 5000,
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ });
221
+ } catch {}
222
+ log(`Freshness: ${projectPath} rebase failed — aborted. Error: ${err.message}`);
223
+ return { status: 'rebase_failed', behind, error: err.message };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Check freshness of all registered projects (internally throttled).
229
+ *
230
+ * @param {Object} config
231
+ * @param {Object.<string, string>} config.projects - gitRemote → localPath mapping
232
+ * @param {boolean} config.autoRebase - Whether to auto-pull when behind
233
+ * @param {Set<string>} config.busyPaths - Paths with running tasks
234
+ * @param {Function} config.log - Logging function
235
+ * @param {boolean} [config.force] - Skip throttle
236
+ * @returns {Object.<string, Object>|null} Results per project path, or null if throttled
237
+ */
238
+ export function checkAllProjectsFreshness(config) {
239
+ const { projects, autoRebase = false, busyPaths = new Set(), log = () => {}, force = false } = config;
240
+ const now = Date.now();
241
+
242
+ if (!force && (now - lastFreshnessCheck < FRESHNESS_CHECK_INTERVAL)) {
243
+ return null; // throttled
244
+ }
245
+ lastFreshnessCheck = now;
246
+
247
+ const results = {};
248
+ const paths = Object.values(projects);
249
+
250
+ for (const projectPath of paths) {
251
+ results[projectPath] = checkProjectFreshness(projectPath, {
252
+ autoRebase,
253
+ busyPaths,
254
+ log,
255
+ });
256
+ }
257
+
258
+ // Log summary
259
+ const behindCount = Object.values(results).filter(r =>
260
+ r.status === 'behind' || r.status === 'behind_busy' ||
261
+ r.status === 'behind_dirty' || r.status === 'behind_wrong_branch'
262
+ ).length;
263
+ const updatedCount = Object.values(results).filter(r => r.status === 'updated').length;
264
+
265
+ if (behindCount > 0 || updatedCount > 0) {
266
+ log(`Freshness: ${updatedCount} project(s) updated, ${behindCount} behind`);
267
+ }
268
+
269
+ return results;
270
+ }
package/lib/update.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Manual update orchestrator for Push CLI.
3
+ *
4
+ * `push-todo update` performs three actions:
5
+ * 1. Self-update: check and install latest push-todo from npm
6
+ * 2. Agent versions: detect and display installed agent CLI versions
7
+ * 3. Project freshness: fetch and rebase registered projects that are behind
8
+ *
9
+ * Separation of concerns:
10
+ * - Daemon: runs all three checks periodically (hourly, throttled, non-interactive)
11
+ * - This module: runs on explicit user request (immediate, verbose, interactive)
12
+ */
13
+
14
+ import { readFileSync } from 'fs';
15
+ import { join, dirname } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ import { checkForUpdate, performUpdate, compareSemver } from './self-update.js';
19
+ import { getAgentVersions, getKnownAgentTypes } from './agent-versions.js';
20
+ import { checkProjectFreshness } from './project-freshness.js';
21
+ import { getRegistry } from './project-registry.js';
22
+ import { bold, green, yellow, red, cyan, dim } from './utils/colors.js';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+
27
+ function getVersion() {
28
+ try {
29
+ return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
30
+ } catch {
31
+ return 'unknown';
32
+ }
33
+ }
34
+
35
+ const AGENT_LABELS = {
36
+ 'claude-code': 'Claude Code',
37
+ 'openai-codex': 'Codex',
38
+ 'openclaw': 'OpenClaw',
39
+ };
40
+
41
+ /**
42
+ * Run the manual update flow.
43
+ *
44
+ * @param {Object} values - Parsed CLI flags
45
+ */
46
+ export async function runManualUpdate(values) {
47
+ const currentVersion = getVersion();
48
+
49
+ console.log();
50
+ console.log(bold(' Push Update'));
51
+ console.log(' ' + '='.repeat(40));
52
+ console.log();
53
+
54
+ // ── 1. Self-update ──────────────────────────────────────
55
+ console.log(bold(' Push CLI'));
56
+ console.log(` Current version: v${currentVersion}`);
57
+
58
+ // Force bypass throttle for manual check
59
+ const updateResult = checkForUpdate(currentVersion);
60
+
61
+ if (updateResult.available) {
62
+ console.log(` Latest version: ${green('v' + updateResult.version)}`);
63
+ console.log();
64
+ console.log(` Updating to v${updateResult.version}...`);
65
+ const success = performUpdate(updateResult.version);
66
+ if (success) {
67
+ console.log(` ${green('Updated successfully')}`);
68
+ } else {
69
+ console.log(` ${red('Update failed')} — try: npm install -g @masslessai/push-todo`);
70
+ }
71
+ } else if (updateResult.reason === 'too_recent') {
72
+ console.log(` Latest version: v${updateResult.version} ${dim('(published <1hr ago, waiting)')}`);
73
+ console.log(` ${green('Up to date')}`);
74
+ } else {
75
+ console.log(` ${green('Up to date')}`);
76
+ }
77
+ console.log();
78
+
79
+ // ── 2. Agent versions ──────────────────────────────────
80
+ console.log(bold(' Agent CLIs'));
81
+
82
+ const agentVersions = getAgentVersions({ force: true });
83
+ for (const type of getKnownAgentTypes()) {
84
+ const info = agentVersions[type];
85
+ const label = AGENT_LABELS[type] || type;
86
+
87
+ if (info.installed && info.version) {
88
+ console.log(` ${label}: ${green('v' + info.version)}`);
89
+ } else if (info.installed) {
90
+ console.log(` ${label}: ${yellow('installed')} ${dim('(version unknown)')}`);
91
+ } else {
92
+ console.log(` ${label}: ${dim('not installed')}`);
93
+ }
94
+ }
95
+ console.log();
96
+
97
+ // ── 3. Project freshness ───────────────────────────────
98
+ const registry = getRegistry();
99
+ const projects = registry.listProjects();
100
+ const projectPaths = Object.entries(projects);
101
+
102
+ if (projectPaths.length === 0) {
103
+ console.log(bold(' Projects'));
104
+ console.log(` ${dim('No projects registered')}`);
105
+ console.log();
106
+ return;
107
+ }
108
+
109
+ console.log(bold(' Project Freshness'));
110
+
111
+ for (const [remote, localPath] of projectPaths) {
112
+ const result = checkProjectFreshness(localPath, {
113
+ autoRebase: true,
114
+ busyPaths: new Set(), // Manual update has no running tasks
115
+ log: (msg) => console.log(` ${dim(msg)}`),
116
+ });
117
+
118
+ const shortRemote = remote.length > 40
119
+ ? '...' + remote.slice(-37)
120
+ : remote;
121
+
122
+ switch (result.status) {
123
+ case 'up_to_date':
124
+ console.log(` ${shortRemote}: ${green('up to date')}`);
125
+ break;
126
+ case 'updated':
127
+ console.log(` ${shortRemote}: ${green('updated')} ${dim(`(${result.behind} commit(s) rebased)`)}`);
128
+ break;
129
+ case 'behind_wrong_branch':
130
+ console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim(`(on branch '${result.currentBranch}')`)}`);
131
+ break;
132
+ case 'behind_dirty':
133
+ console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim('(dirty working tree)')}`);
134
+ break;
135
+ case 'rebase_failed':
136
+ console.log(` ${shortRemote}: ${red('rebase failed')} ${dim(`(${result.behind} behind)`)}`);
137
+ break;
138
+ case 'fetch_failed':
139
+ console.log(` ${shortRemote}: ${dim('fetch failed (offline?)')}`);
140
+ break;
141
+ case 'not_a_repo':
142
+ console.log(` ${shortRemote}: ${dim('path is not a git repo')}`);
143
+ break;
144
+ default:
145
+ console.log(` ${shortRemote}: ${dim(result.status)}`);
146
+ }
147
+ }
148
+
149
+ console.log();
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.3",
3
+ "version": "4.1.4",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {