@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.
- package/lib/agent-versions.js +204 -0
- package/lib/cli.js +7 -0
- package/lib/daemon.js +28 -0
- package/lib/fetch.js +21 -0
- package/lib/project-freshness.js +270 -0
- package/lib/update.js +150 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|