diff --git a/README.md b/README.md index b2cb2cd..2e5ccc7 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Windows (PowerShell): irm https://atomicagent.io/install.ps1 | iex ``` -The installer downloads the release archive, verifies the checksum, and installs the CLI plus support assets (`grammars/`, native prebuilds, and bundled `ripgrep`). Atomic Agent updates itself in place; after an update the TUI prompts you to restart. +The installer downloads the release archive, verifies the checksum, and installs the CLI plus support assets (`grammars/`, native prebuilds, and bundled `ripgrep`). Atomic Agent updates itself in place; after an update the TUI prompts you to restart. Outside the TUI, run `atomic-agent update` (or `atag update`) to check for a newer release and re-run the installer in place — `atomic-agent update --check` probes without installing, and `--version ` pins a specific release. Only the installed binary can self-update; a dev checkout updates via git. > [!NOTE] > Developer preview. APIs, commands, config, and behavior are still moving, so pin a release if you need a stable integration point. Current builds: macOS (Apple Silicon), Linux x64 / arm64, and Windows x64. diff --git a/src/cli/index.ts b/src/cli/index.ts index 5f00c1d..eec23a6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import { traceCommand } from "./trace-command.js"; import { taskCommand } from "./task-command.js"; import { modelsCommand } from "./models-command.js"; import { importCommand } from "./import-command.js"; +import { updateCommand } from "./update-command.js"; import { tuiCommand } from "../tui/index.js"; import { getAppVersion } from "../version.js"; @@ -105,6 +106,11 @@ const COMMANDS: CommandDescriptor[] = [ summary: "Import conversation history + cron jobs from another agent (hermes)", run: importCommand, }, + { + name: "update", + summary: "Self-update the installed binary from GitHub Releases (--check to probe only)", + run: updateCommand, + }, ]; function printHelp(): void { diff --git a/src/cli/update-command.test.ts b/src/cli/update-command.test.ts new file mode 100644 index 0000000..4aa1b5b --- /dev/null +++ b/src/cli/update-command.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + AppUpdateCheckError, + AppUpdateError, + type AppUpdateCheckResult, +} from "../update/index.js"; +import { + updateCommand, + type UpdateCommandDeps, +} from "./update-command.js"; + +function makeResult( + overrides: Partial = {}, +): AppUpdateCheckResult { + return { + updateAvailable: true, + currentVersion: "0.3.1", + latestTag: "v0.3.2", + latestVersion: "0.3.2", + ...overrides, + }; +} + +describe("atomic-agent update", () => { + let stdoutChunks: string[]; + let stderrChunks: string[]; + let deps: Required; + + const runInstaller = vi.fn(); + const check = vi.fn(); + const canSelfUpdate = vi.fn(); + + beforeEach(() => { + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + runInstaller.mockReset(); + check.mockReset(); + canSelfUpdate.mockReset(); + deps = { + checkForAppUpdate: check, + runAppUpdate: runInstaller, + canSelfUpdate, + getRepo: () => "AtomicBot-ai/atomic-agent", + isTTY: () => false, + confirm: async () => true, + }; + check.mockResolvedValue(makeResult()); + canSelfUpdate.mockReturnValue(true); + runInstaller.mockResolvedValue({ ok: true, installDir: "/tmp/install" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function stdout(): string { + return stdoutChunks.join(""); + } + + function stderr(): string { + return stderrChunks.join(""); + } + + it("prints help and exits 0 for -h and --help", async () => { + expect(await updateCommand(["-h"], deps)).toBe(0); + expect(await updateCommand(["--help"], deps)).toBe(0); + expect(stdout()).toMatch(/atomic-agent update/); + expect(stdout()).toMatch(/--check/); + expect(check).not.toHaveBeenCalled(); + }); + + it("exits 2 for an unknown flag", async () => { + expect(await updateCommand(["--bogus"], deps)).toBe(2); + expect(stderr()).toMatch(/unknown option: --bogus/); + expect(check).not.toHaveBeenCalled(); + }); + + it("exits 2 for --version without a value", async () => { + expect(await updateCommand(["--version"], deps)).toBe(2); + expect(stderr()).toMatch(/--version requires a tag/); + }); + + it("exits 2 when --check is combined with --version", async () => { + expect(await updateCommand(["--check", "--version", "v0.3.2"], deps)).toBe( + 2, + ); + expect(stderr()).toMatch(/--check and --version are mutually exclusive/); + }); + + it("--check reports up to date and exits 0 without installing", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand(["--check"], deps)).toBe(0); + expect(stdout()).toMatch(/up to date \(0\.3\.1\)/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("--check reports the newer version and exits 0 without installing", async () => { + expect(await updateCommand(["--check"], deps)).toBe(0); + expect(stdout()).toMatch(/update available: 0\.3\.1 → 0\.3\.2/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("--check exits 1 when the check itself fails", async () => { + check.mockRejectedValue(new AppUpdateCheckError("HTTP 403", 403)); + expect(await updateCommand(["--check"], deps)).toBe(1); + expect(stderr()).toMatch(/HTTP 403/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("refuses to self-update in a dev build, exiting 1", async () => { + canSelfUpdate.mockReturnValue(false); + expect(await updateCommand([], deps)).toBe(1); + expect(stderr()).toMatch(/installed binary/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("reports up to date and exits 0 without installing when current", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/up to date \(0\.3\.1\)/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("updates in place when a newer version exists (non-interactive)", async () => { + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/current: 0\.3\.1 → latest: 0\.3\.2/); + expect(runInstaller).toHaveBeenCalledTimes(1); + expect(runInstaller).toHaveBeenCalledWith( + expect.objectContaining({ + repo: "AtomicBot-ai/atomic-agent", + version: undefined, + }), + ); + expect(stdout()).toMatch(/updated to 0\.3\.2/); + }); + + it("streams installer lines prefixed with [update]", async () => { + runInstaller.mockImplementation( + async (opts?: { onLine?: (line: string) => void }) => { + opts?.onLine?.("downloading atomic-agent"); + opts?.onLine?.("installed atomic-agent to /tmp/install"); + return { ok: true, installDir: "/tmp/install" }; + }, + ); + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/\[update\] downloading atomic-agent/); + expect(stdout()).toMatch(/\[update\] installed atomic-agent/); + }); + + it("prompts in an interactive terminal and cancels on 'no'", async () => { + const confirm = vi.fn().mockResolvedValue(false); + expect(await updateCommand([], { ...deps, isTTY: () => true, confirm })).toBe( + 0, + ); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith("update to 0.3.2? [y/N] "); + expect(stdout()).toMatch(/update cancelled/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("proceeds when the interactive prompt is accepted", async () => { + const confirm = vi.fn().mockResolvedValue(true); + expect(await updateCommand([], { ...deps, isTTY: () => true, confirm })).toBe( + 0, + ); + expect(confirm).toHaveBeenCalledTimes(1); + expect(runInstaller).toHaveBeenCalledTimes(1); + }); + + it("exits 1 and reports the installer failure", async () => { + runInstaller.mockRejectedValue( + new AppUpdateError("install script exited with code 7"), + ); + expect(await updateCommand([], deps)).toBe(1); + expect(stderr()).toMatch(/install script exited with code 7/); + expect(stdout()).not.toMatch(/updated to/); + }); + + it("--version pins a specific tag even when the running version is newer", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand(["--version", "v0.3.2"], deps)).toBe(0); + expect(stdout()).toMatch(/installing v0\.3\.2/); + expect(runInstaller).toHaveBeenCalledWith( + expect.objectContaining({ version: "v0.3.2" }), + ); + }); +}); diff --git a/src/cli/update-command.ts b/src/cli/update-command.ts new file mode 100644 index 0000000..afce893 --- /dev/null +++ b/src/cli/update-command.ts @@ -0,0 +1,202 @@ +import { createInterface } from "node:readline/promises"; + +import { getConfig } from "../config/index.js"; +import { + checkForAppUpdate, + runAppUpdate, + canSelfUpdate, +} from "../update/index.js"; + +/** + * Dependency seam for `updateCommand`. Defaults to the real + * `src/update/` functions; tests inject stubs so nothing spawns a + * process or hits the network. All deps are optional. + */ +export interface UpdateCommandDeps { + checkForAppUpdate?: typeof checkForAppUpdate; + runAppUpdate?: typeof runAppUpdate; + canSelfUpdate?: typeof canSelfUpdate; + /** Resolves the `update.repo` config value. Defaults to `getConfig().update.repo`. */ + getRepo?: () => string; + /** Whether the command is attached to a TTY. Defaults to stdout. */ + isTTY?: () => boolean; + /** Interactive y/n confirmation. Defaults to a readline prompt. */ + confirm?: (prompt: string) => Promise; +} + +const HELP = [ + "atomic-agent update — self-update the installed binary from GitHub Releases", + "", + "Checks GitHub Releases for a newer published version and re-runs the", + "canonical installer (install.sh / install.ps1) in place, exactly like the", + "TUI's in-app update. Only meaningful for the installed SEA binary — a dev", + "checkout is updated via git. The running process is not restarted; the", + "next launch picks up the new binary.", + "", + "Flags:", + " --check Check only: report current vs latest, install nothing", + " --version Install a specific release tag (e.g. v0.3.2) instead of latest", + " -h, --help Show this help", + "", + "Exit codes:", + " 0 success (up to date, updated, or --check ran fine)", + " 1 operational failure (check failed, not self-updatable, installer failed)", + " 2 usage error (unknown flag, missing --version value, conflicting flags)", + "", + "Examples:", + " atomic-agent update", + " atomic-agent update --check", + " atomic-agent update --version v0.3.2", +].join("\n") + "\n"; + +/** Parse flags into a discriminated plan; returns a usage error string on bad input. */ +function parseArgs( + args: string[], +): { ok: true; checkOnly: boolean; version?: string } | { ok: false; error: string } { + let checkOnly = false; + let version: string | undefined; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "-h" || arg === "--help") { + return { ok: true, checkOnly: false }; + } + if (arg === "--check") { + checkOnly = true; + continue; + } + if (arg === "--version") { + const value = args[i + 1]; + if (!value || value.startsWith("-")) { + return { ok: false, error: "--version requires a tag (e.g. v0.3.2)" }; + } + version = value; + i += 1; + continue; + } + return { ok: false, error: `unknown option: ${arg}` }; + } + if (checkOnly && version) { + return { + ok: false, + error: "--check and --version are mutually exclusive", + }; + } + return { ok: true, checkOnly, version }; +} + +async function defaultConfirm(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(prompt); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** + * `atomic-agent update` — check GitHub Releases and re-run the canonical + * installer in place when a newer version exists. Mirrors the TUI's + * in-app update for headless / `run` / sidecar users who never see it. + * + * Exit codes follow the documented CLI contract: 0 success, 1 operational + * failure, 2 usage error. + */ +export async function updateCommand( + args: string[], + deps: UpdateCommandDeps = {}, +): Promise { + const check = deps.checkForAppUpdate ?? checkForAppUpdate; + const run = deps.runAppUpdate ?? runAppUpdate; + const canSelf = deps.canSelfUpdate ?? canSelfUpdate; + const getRepo = deps.getRepo ?? (() => getConfig().update.repo); + const isTTY = deps.isTTY ?? (() => process.stdout.isTTY === true); + const confirm = deps.confirm ?? defaultConfirm; + const repo = getRepo(); + + const parsed = parseArgs(args); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n`); + return 2; + } + if (args.includes("-h") || args.includes("--help")) { + process.stdout.write(HELP); + return 0; + } + + try { + // --version installs a pinned tag regardless of what "latest" says; + // it still refuses dev builds and still confirms interactively. + if (parsed.version) { + if (!canSelf()) { + process.stderr.write( + "self-update is only supported for the installed binary; " + + "update via git in development\n", + ); + return 1; + } + process.stdout.write(`installing ${parsed.version}…\n`); + if (isTTY()) { + const ok = await confirm(`update to ${parsed.version}? [y/N] `); + if (!ok) { + process.stdout.write("update cancelled\n"); + return 0; + } + } + await run({ repo, version: parsed.version, onLine: streamUpdateLine }); + process.stdout.write(`updated to ${parsed.version}\n`); + return 0; + } + + const result = await check({ repo }); + if (parsed.checkOnly) { + if (!result.updateAvailable) { + process.stdout.write(`up to date (${result.currentVersion})\n`); + } else { + process.stdout.write( + `update available: ${result.currentVersion} → ${result.latestVersion}\n`, + ); + } + return 0; + } + + if (!result.updateAvailable) { + process.stdout.write(`up to date (${result.currentVersion})\n`); + return 0; + } + if (!canSelf()) { + process.stderr.write( + "self-update is only supported for the installed binary; " + + "update via git in development\n", + ); + return 1; + } + + process.stdout.write( + `current: ${result.currentVersion} → latest: ${result.latestVersion}\n`, + ); + if (isTTY()) { + const ok = await confirm(`update to ${result.latestVersion}? [y/N] `); + if (!ok) { + process.stdout.write("update cancelled\n"); + return 0; + } + } + await run({ + repo, + version: undefined, + onLine: streamUpdateLine, + }); + process.stdout.write(`updated to ${result.latestVersion}\n`); + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`update failed: ${message}\n`); + return 1; + } +} + +/** Stream installer stdout/stderr lines to the terminal, one per line. */ +function streamUpdateLine(line: string): void { + process.stdout.write(`[update] ${line}\n`); +}