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:
chahinebrini 2026-06-25 22:24:53 +02:00
parent 79f5c8afde
commit 074b75f909
4 changed files with 209 additions and 0 deletions

167
src/cli/commands/update.ts Normal file
View 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.
}
}

View File

@ -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')

View File

@ -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
View 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();
});
});