diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 0000000..96c0232 --- /dev/null +++ b/src/cli/commands/update.ts @@ -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 { + 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. + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 7c1f614..8ed86f4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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') diff --git a/src/index.ts b/src/index.ts index 2ec1c3c..dd3f6fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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()); diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..d7c9c42 --- /dev/null +++ b/tests/update.test.ts @@ -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(); + }); +});