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 { delegate } from './commands/delegate.js';
|
||||
import { serverStart } from './commands/server.js';
|
||||
import { update } from './commands/update.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { findProjectRoot } from '../core/paths.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');
|
||||
serverCmd
|
||||
.command('start')
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
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());
|
||||
|
||||
|
||||
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