feat(cli): add agenthub update self-updater + non-blocking update hint
agenthub update: fetch + reset install to origin/<branch> + reinstall + rebuild, so users never touch git or pnpm. Robust to a dirty working tree (the 'commit before pull' wall) by resetting to remote; guards unpushed local commits and refuses to discard them. Adds a daily, non-blocking update hint on startup (detached background fetch + instant local comparison, like gh/npm/brew) that never auto-applies. Tests for the non-git-install path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
79f5c8afde
commit
074b75f909
167
src/cli/commands/update.ts
Normal file
167
src/cli/commands/update.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, parse } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Locate the agenthub package install dir by walking up from this file. */
|
||||||
|
function findPackageRoot(): string | undefined {
|
||||||
|
let dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const { root } = parse(dir);
|
||||||
|
while (true) {
|
||||||
|
const pkgPath = join(dir, 'package.json');
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string };
|
||||||
|
if (pkg.name === 'agenthub') return dir;
|
||||||
|
} catch {
|
||||||
|
// malformed package.json — keep walking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dir === root) return undefined;
|
||||||
|
dir = dirname(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitCapture(root: string, args: string[]): string {
|
||||||
|
return execFileSync('git', args, { cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBranch(root: string): string {
|
||||||
|
try {
|
||||||
|
return gitCapture(root, ['rev-parse', '--abbrev-ref', 'HEAD']) || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageVersion(root: string): string {
|
||||||
|
try {
|
||||||
|
return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')) as { version?: string }).version ?? '?';
|
||||||
|
} catch {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring this AgentHub install to the latest version. For a git checkout this
|
||||||
|
* means fast-forward pull + reinstall + rebuild — so the user never has to
|
||||||
|
* touch git, pnpm or the build themselves. For other installs it points at the
|
||||||
|
* package manager.
|
||||||
|
*/
|
||||||
|
export async function update(rootOverride?: string): Promise<void> {
|
||||||
|
const root = rootOverride ?? findPackageRoot();
|
||||||
|
if (!root) {
|
||||||
|
console.error('Could not locate the AgentHub install directory.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(join(root, '.git'))) {
|
||||||
|
console.log('AgentHub was not installed from git. Update via your package manager (e.g. `npm update -g agenthub`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = currentBranch(root);
|
||||||
|
console.log(`Checking for updates on ${branch}…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
gitCapture(root, ['fetch', '--quiet', 'origin', branch]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Could not reach the AgentHub remote: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect local commits that were never pushed (the authoring machine).
|
||||||
|
let ahead = '0';
|
||||||
|
try {
|
||||||
|
ahead = gitCapture(root, ['rev-list', '--count', `origin/${branch}..HEAD`]);
|
||||||
|
} catch {
|
||||||
|
ahead = '0';
|
||||||
|
}
|
||||||
|
if (ahead !== '0') {
|
||||||
|
console.error(
|
||||||
|
`This install has ${ahead} local commit(s) not on the server. ` +
|
||||||
|
`Push or resolve them first — refusing to discard local work.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let behind = '0';
|
||||||
|
try {
|
||||||
|
behind = gitCapture(root, ['rev-list', '--count', `HEAD..origin/${branch}`]);
|
||||||
|
} catch {
|
||||||
|
behind = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (behind === '0') {
|
||||||
|
console.log(`Already on the latest version (${packageVersion(root)}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = packageVersion(root);
|
||||||
|
console.log(`Updating — ${behind} new commit(s) (resetting install to match the server)…`);
|
||||||
|
// Reset instead of pull: an install directory is a consumable, not a
|
||||||
|
// workspace. This makes update robust to local working-tree cruft (the
|
||||||
|
// "commit before pull" wall) without ever touching pushed history — local
|
||||||
|
// commits are already guarded above.
|
||||||
|
execFileSync('git', ['reset', '--hard', `origin/${branch}`], { cwd: root, stdio: 'inherit' });
|
||||||
|
|
||||||
|
const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
|
||||||
|
const useShell = process.platform === 'win32';
|
||||||
|
console.log('Installing dependencies…');
|
||||||
|
execFileSync(pm, ['install'], { cwd: root, stdio: 'inherit', shell: useShell });
|
||||||
|
console.log('Building…');
|
||||||
|
execFileSync(pm, ['run', 'build'], { cwd: root, stdio: 'inherit', shell: useShell });
|
||||||
|
|
||||||
|
console.log(`AgentHub updated: ${before} → ${packageVersion(root)} (now at ${gitCapture(root, ['rev-parse', '--short', 'HEAD'])}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStampPath(): string {
|
||||||
|
return join(homedir(), '.agenthub', 'last-update-check');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking, throttled update hint. Prints a one-line notice to stderr when
|
||||||
|
* the install is behind its remote, then kicks off a detached background fetch
|
||||||
|
* so the next run is accurate. Never throws — a failed check must never break
|
||||||
|
* a command.
|
||||||
|
*/
|
||||||
|
export function maybeNotifyUpdate(rootOverride?: string): void {
|
||||||
|
try {
|
||||||
|
const root = rootOverride ?? findPackageRoot();
|
||||||
|
if (!root || !existsSync(join(root, '.git'))) return;
|
||||||
|
|
||||||
|
const branch = currentBranch(root);
|
||||||
|
|
||||||
|
const stamp = checkStampPath();
|
||||||
|
let last = 0;
|
||||||
|
try {
|
||||||
|
last = Number(readFileSync(stamp, 'utf-8')) || 0;
|
||||||
|
} catch {
|
||||||
|
// no stamp yet
|
||||||
|
}
|
||||||
|
if (Date.now() - last > CHECK_INTERVAL_MS) {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(stamp), { recursive: true });
|
||||||
|
writeFileSync(stamp, String(Date.now()), 'utf-8');
|
||||||
|
spawn('git', ['fetch', '--quiet', 'origin', branch], { cwd: root, detached: true, stdio: 'ignore' }).unref();
|
||||||
|
} catch {
|
||||||
|
// best-effort background refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let behind = '0';
|
||||||
|
try {
|
||||||
|
behind = gitCapture(root, ['rev-list', '--count', `HEAD..origin/${branch}`]);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (behind !== '0') {
|
||||||
|
process.stderr.write(`\n🔄 A newer AgentHub is available (${behind} commit(s) behind). Run \`agenthub update\`.\n\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Never let the update check break a command.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
|||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
import { serverStart } from './commands/server.js';
|
import { serverStart } from './commands/server.js';
|
||||||
|
import { update } from './commands/update.js';
|
||||||
import { loadConfig } from '../core/config.js';
|
import { loadConfig } from '../core/config.js';
|
||||||
import { findProjectRoot } from '../core/paths.js';
|
import { findProjectRoot } from '../core/paths.js';
|
||||||
import { discoverServer } from '../discovery.js';
|
import { discoverServer } from '../discovery.js';
|
||||||
@ -343,6 +344,13 @@ export function createProgram(cwd: string): Command {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('update')
|
||||||
|
.description('Update AgentHub to the latest version')
|
||||||
|
.action(async () => {
|
||||||
|
await update();
|
||||||
|
});
|
||||||
|
|
||||||
const serverCmd = new Command('server').description('Optional local API server');
|
const serverCmd = new Command('server').description('Optional local API server');
|
||||||
serverCmd
|
serverCmd
|
||||||
.command('start')
|
.command('start')
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { createProgram } from './cli/index.js';
|
import { createProgram } from './cli/index.js';
|
||||||
|
import { maybeNotifyUpdate } from './cli/commands/update.js';
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
if (!argv.includes('update') && !argv.includes('server')) {
|
||||||
|
maybeNotifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
const program = createProgram(process.cwd());
|
const program = createProgram(process.cwd());
|
||||||
|
|
||||||
|
|||||||
28
tests/update.test.ts
Normal file
28
tests/update.test.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { update, maybeNotifyUpdate } from '../src/cli/commands/update.js';
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'ah-upd-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points at the package manager when the install is not a git checkout', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const original = console.log;
|
||||||
|
console.log = (msg: string) => logs.push(msg);
|
||||||
|
await update(dir);
|
||||||
|
console.log = original;
|
||||||
|
expect(logs.some((m) => m.includes('package manager'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maybeNotifyUpdate is a silent no-op outside a git checkout', () => {
|
||||||
|
expect(() => maybeNotifyUpdate(dir)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user