agent-orchestrator/packages/cli/src/commands/update.ts

841 lines
30 KiB
TypeScript

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import type { Command } from "commander";
import chalk from "chalk";
import {
getGlobalConfigPath,
isCanonicalGlobalConfigPath,
isWindows,
loadConfig,
loadGlobalConfig,
recordActivityEvent,
type Session,
} from "@aoagents/ao-core";
import { runRepoScript } from "../lib/script-runner.js";
import {
checkForUpdate,
detectInstallMethod,
getCurrentVersion,
getUpdateCommand,
invalidateCache,
readCachedUpdateInfo,
resolveUpdateChannel,
type InstallMethod,
} from "../lib/update-check.js";
import { promptConfirm } from "../lib/prompts.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { getRunning } from "../lib/running-state.js";
/** Inline check instead of module-level constant so tests can control TTY state. */
function isTTY(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
/**
* The dashboard's POST /api/update spawns `ao update` with `stdio: "ignore"`,
* which makes `isTTY()` return false. That used to be fatal: handleNpmUpdate
* fell into the "non-interactive — print and return" branch and never invoked
* the install. The route would respond 202 "started" and absolutely nothing
* would happen.
*
* /api/update sets `AO_NON_INTERACTIVE_INSTALL=1` on the spawn env so we can
* distinguish "API kicked this off, please install without prompting" from
* "user piped output and we shouldn't surprise-install."
*/
export const NON_INTERACTIVE_INSTALL_ENV = "AO_NON_INTERACTIVE_INSTALL";
function isApiInvoked(): boolean {
return process.env[NON_INTERACTIVE_INSTALL_ENV] === "1";
}
/**
* Statuses that mean "the agent is doing real work right now and updating
* `ao` would yank the rug out from under it."
*
* Mirrors the design doc (release-process.html §07): refuse, never auto-stop.
*/
const ACTIVE_SESSION_STATUSES = new Set<Session["status"]>([
"working",
"idle",
"needs_input",
"stuck",
]);
export function registerUpdate(program: Command): void {
program
.command("update")
.description("Check for updates and upgrade AO to the latest version")
.option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)")
.option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)")
.option("--check", "Print version info as JSON without upgrading")
.option("--no-restore", "Restart AO after updating but do not restore stopped sessions")
.action(
async (opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
check?: boolean;
restore?: boolean;
}) => {
if (opts.skipSmoke && opts.smokeOnly) {
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
process.exit(1);
}
if (opts.check) {
await handleCheck();
return;
}
const method = detectInstallMethod();
recordActivityEvent({
source: "cli",
kind: "cli.update_invoked",
level: "info",
summary: `ao update invoked (method: ${method})`,
data: { method, options: opts },
});
// Reject git-only flags up front when the install isn't a git source.
// Without this, users copy/pasting `ao update --skip-smoke` from older
// docs would silently no-op on npm/pnpm/bun installs (the flag would be
// accepted, ignored, and the user would never know why smoke tests
// didn't run — because they never ran on these install methods anyway).
if ((opts.skipSmoke || opts.smokeOnly) && method !== "git") {
const flag = opts.skipSmoke ? "--skip-smoke" : "--smoke-only";
console.error(`${flag} only applies to git installs (current install: ${method}).`);
process.exit(1);
}
switch (method) {
case "git":
await handleGitUpdate(opts);
break;
case "homebrew":
await handleHomebrewUpdate();
break;
case "npm-global":
case "pnpm-global":
case "bun-global":
await handleNpmUpdate(method, { restore: opts.restore !== false });
break;
case "unknown":
await handleUnknownUpdate();
break;
}
},
);
}
// ---------------------------------------------------------------------------
// --check
// ---------------------------------------------------------------------------
async function handleCheck(): Promise<void> {
const info = await checkForUpdate({ force: true });
console.log(JSON.stringify(info, null, 2));
}
// ---------------------------------------------------------------------------
// Update lifecycle planning
// ---------------------------------------------------------------------------
/**
* Best-effort snapshot used by `ao update` to pause and resume AO around the
* package-manager install. Missing/broken config should not block an update;
* in that case we proceed without attempting a stop/start round trip.
*/
interface UpdateLifecyclePlan {
runningBeforeUpdate: boolean;
configPath?: string;
primaryProjectId?: string;
activeSessions: Session[];
}
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
let sessions: Session[];
let configPath: string | undefined;
let primaryProjectId: string | undefined;
let runningBeforeUpdate = false;
try {
// Live signal first: running.json lists whichever projects the active
// `ao start` daemon is currently polling. That can include local-only
// projects whose `agent-orchestrator.yaml` is NOT in the global registry
// (Dhruv edge case: user runs `ao start` from a repo with a local config
// and no global registration — sessions live on disk, would be clobbered
// if `ao update` proceeded).
//
// If a daemon is running, trust its configPath — it's the source of
// truth for "which sessions does the running ao instance own?"
const running = await getRunning();
if (running && running.projects.length > 0) {
runningBeforeUpdate = true;
configPath = running.configPath;
primaryProjectId = running.projects[0];
// running.configPath could be local-wrapped (a project's
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
// dispatches based on the path shape — both cases produce a full
// OrchestratorConfig the SessionManager can enumerate.
const config = loadConfig(running.configPath);
const sm = await getSessionManager(config);
sessions = await sm.list();
} else {
// No live daemon. Fall back to the global registry — covers the case
// where the user ran `ao stop` (running.json gone) but stale sessions
// sit on disk under ~/.agent-orchestrator/{hash}-{projectId}/. The
// SessionManager's enrichment will reconcile any stale-runtime
// sessions to `killed`, so terminal statuses don't block the update.
const globalPath = getGlobalConfigPath();
if (!existsSync(globalPath)) {
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
const globalConfig = loadGlobalConfig(globalPath);
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
if (!isCanonicalGlobalConfigPath(globalPath)) {
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
configPath = globalPath;
const config = loadConfig(globalPath);
primaryProjectId = Object.keys(config.projects)[0];
const sm = await getSessionManager(config);
sessions = await sm.list();
}
} catch {
// If we can't enumerate sessions, don't pretend there are zero — but
// also don't block the upgrade indefinitely. Surface a soft warning.
console.error(
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
);
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active };
}
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0;
if (!shouldStop) return false;
if (plan.activeSessions.length > 0) {
const noun = plan.activeSessions.length === 1 ? "session" : "sessions";
console.log(
chalk.yellow(
`\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`,
),
);
for (const s of plan.activeSessions.slice(0, 5)) {
console.log(chalk.dim(`${s.id} (${s.status})`));
}
if (plan.activeSessions.length > 5) {
console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`));
}
} else {
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
}
const stopExit = await runAoLifecycleCommand(["stop", "--yes"], {
configPath: plan.configPath,
});
if (stopExit !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update failed: internal ao stop exited non-zero`,
data: { exitCode: stopExit },
});
console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`));
process.exit(stopExit);
}
const afterStop = await getUpdateLifecyclePlan();
if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update failed: AO still appears active after internal ao stop`,
data: {
runningAfterStop: afterStop.runningBeforeUpdate,
activeSessionCount: afterStop.activeSessions.length,
activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20),
},
});
console.error(
chalk.red(
"\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.",
),
);
if (afterStop.activeSessions.length > 0) {
console.error(chalk.dim("Still-active sessions:"));
for (const s of afterStop.activeSessions.slice(0, 5)) {
console.error(chalk.dim(`${s.id} (${s.status})`));
}
if (afterStop.activeSessions.length > 5) {
console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`));
}
}
console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped."));
process.exit(1);
}
return plan.runningBeforeUpdate;
}
async function restartAoAfterUpdate(
plan: UpdateLifecyclePlan,
opts: { restore: boolean },
): Promise<void> {
const args = ["start"];
if (plan.primaryProjectId) args.push(plan.primaryProjectId);
args.push(opts.restore ? "--restore" : "--no-restore");
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath });
if (exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_restart_failed",
level: "error",
summary: `ao update could not restart AO after install`,
data: { exitCode, args },
});
console.error(
chalk.yellow(
`\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` +
`Run it manually to restore your sessions.`,
),
);
process.exit(exitCode);
}
}
function runAoLifecycleCommand(
args: string[],
opts: { configPath?: string } = {},
): Promise<number> {
return new Promise<number>((resolveExit) => {
const child = spawn("ao", args, {
stdio: "inherit",
shell: isWindows(),
windowsHide: true,
env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env,
});
child.on("error", (error) => {
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
resolveExit(1);
});
child.on("exit", (code, signal) => {
resolveExit(signal ? 1 : (code ?? 1));
});
});
}
// ---------------------------------------------------------------------------
// git install
// ---------------------------------------------------------------------------
async function handleGitUpdate(opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
restore?: boolean;
}): Promise<void> {
const lifecyclePlan = await getUpdateLifecyclePlan();
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const args: string[] = [];
if (opts.skipSmoke) args.push("--skip-smoke");
if (opts.smokeOnly) args.push("--smoke-only");
try {
const exitCode = await runRepoScript("ao-update.sh", args);
if (exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
data: { method: "git", exitCode },
});
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(exitCode);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
} catch (error) {
if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (git) failed: ao-update.sh missing from bundled assets`,
data: { method: "git", reason: "script_missing" },
});
console.error(
chalk.red(
"ao-update.sh is missing from the bundled assets. " +
"If you're running from a source checkout, rebuild with `pnpm --filter @aoagents/ao-cli build`. " +
"If you're on a package install, reinstall the package.",
),
);
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (git) failed`,
data: {
method: "git",
errorMessage: error instanceof Error ? error.message : String(error),
},
});
console.error(error instanceof Error ? error.message : String(error));
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// npm / pnpm / bun global install
// ---------------------------------------------------------------------------
async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise<void> {
const channel = resolveUpdateChannel();
// Snapshot the previously cached channel BEFORE we force a refresh, so we
// can detect a channel switch (stable→nightly or vice versa). force:true
// would overwrite cache.channel before we can read it.
const previousChannel = readCachedUpdateInfo(method)?.channel;
let info: Awaited<ReturnType<typeof checkForUpdate>>;
try {
info = await checkForUpdate({ force: true, channel });
} catch (error) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: npm registry lookup threw`,
data: {
method,
channel,
reason: "registry_lookup_threw",
errorMessage: error instanceof Error ? error.message : String(error),
},
});
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
process.exit(1);
}
if (!info.latestVersion) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: npm registry lookup returned no version`,
data: { method, channel, reason: "registry_unreachable" },
});
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
process.exit(1);
}
// Detect a channel switch. When stable=0.5.0 and nightly=0.5.0-nightly-abc,
// isVersionOutdated returns false (per semver, prerelease < stable on equal
// base), so a stable→nightly user would see "Already on latest nightly"
// until the next numeric bump. Force the prompt instead — explicit consent
// is the right UX for a channel transition, and the install command we'd
// run is genuinely different even if the version-compare says "no".
const isChannelSwitch =
!info.isOutdated && previousChannel !== undefined && previousChannel !== channel;
// First-channel opt-in. previousChannel === undefined means we've never
// installed via the auto-updater. A user who just ran `ao config set
// updateChannel nightly` (after a manual install) would otherwise see
// "Already on latest nightly" because semver says prerelease < stable.
// Treat any version mismatch as install-worthy in that case.
const isFirstChannelOptIn =
!info.isOutdated &&
!isChannelSwitch &&
previousChannel === undefined &&
info.currentVersion !== info.latestVersion;
const needsInstall = info.isOutdated || isChannelSwitch || isFirstChannelOptIn;
if (!needsInstall) {
console.log(
chalk.green(
`Already on latest ${channel === "nightly" ? "nightly" : "version"} (${info.currentVersion}).`,
),
);
return;
}
console.log(`Current version: ${chalk.dim(info.currentVersion)}`);
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
console.log(`Channel: ${chalk.cyan(channel)}`);
if (isChannelSwitch) {
console.log(
chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`),
);
console.log(
chalk.dim(
" The version compare says you're current, but the install command picks a different dist-tag.",
),
);
} else if (isFirstChannelOptIn) {
console.log(
chalk.yellow(
`\nFirst install via the ${channel} channel — installing the channel's current build.`,
),
);
}
console.log();
const command = getUpdateCommand(method, channel);
const apiInvoked = isApiInvoked();
const interactive = isTTY() && !apiInvoked;
const lifecyclePlan = await getUpdateLifecyclePlan();
// Non-interactive path: API-invoked OR piped output. We still plan the
// stop/start lifecycle, but we never bail out just because there's no
// terminal — the dashboard's "Update" click must actually install. The
// only thing we skip is the confirm prompt.
if (interactive) {
// Soft auto-install: when the user has opted into stable or nightly we
// skip the confirm prompt — they've already said "keep me on this channel."
// Manual users (and explicit channel switches / first opt-ins) still see
// the confirm so an unintended `ao update` doesn't wipe the version they
// pinned to.
if (channel === "manual" || isChannelSwitch || isFirstChannelOptIn) {
const promptText =
isChannelSwitch || isFirstChannelOptIn
? `Switch to ${channel} via ${chalk.cyan(command)}?`
: `Run ${chalk.cyan(command)}?`;
const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn));
if (!confirmed) return;
} else {
console.log(chalk.dim(`Updating: ${command}`));
}
} else if (apiInvoked) {
console.log(chalk.dim(`Updating (api-invoked): ${command}`));
} else {
// Non-TTY but also not API-invoked (piped output). Keep the old
// "print the command and let the user run it" behavior so a script
// running `ao update | tee` doesn't get a surprise install.
console.log(`Run: ${chalk.cyan(command)}`);
return;
}
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const installResult = await runNpmInstall(command);
if (installResult.exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: install command exited non-zero`,
data: {
method,
command,
exitCode: installResult.exitCode,
classification: classifyInstallFailure(installResult.output).kind,
},
});
printInstallFailure({
method,
command,
channel,
currentVersion: info.currentVersion,
exitCode: installResult.exitCode,
output: installResult.output,
});
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO with the existing installation..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
const verification = await verifyInstalledVersion(info.latestVersion, info.currentVersion);
if (!verification.ok) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: installed version verification failed`,
data: {
method,
command,
expectedVersion: info.latestVersion,
actualVersion: verification.actualVersion,
output: verification.output,
},
});
console.error(chalk.red(`\nAO was not verified after install.`));
console.error(chalk.yellow(verification.message));
console.error(chalk.dim(`Expected: ${info.latestVersion}`));
console.error(chalk.dim(`Current before update: ${info.currentVersion}`));
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO before exiting..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, opts);
}
console.log(
chalk.green(
`\nUpdate complete: ${info.currentVersion}${verification.actualVersion}.` +
(shouldRestart ? " AO restarted." : ""),
),
);
}
interface CommandResult {
exitCode: number;
output: string;
}
function runNpmInstall(command: string): Promise<CommandResult> {
const [cmd, ...args] = command.split(" ");
return runCommandCapture(cmd!, args, { echo: true }).then((result) => {
if (result.exitCode !== 0) {
console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`));
}
return result;
});
}
function runCommandCapture(
cmd: string,
args: string[],
opts: { echo?: boolean } = {},
): Promise<CommandResult> {
return new Promise<CommandResult>((resolveExit) => {
// `shell: isWindows()` is required so PATHEXT gets consulted on Windows —
// npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at
// PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup
// would silently ENOENT on every Windows install. `windowsHide: true`
// keeps the shell window from flashing. Same fix that landed for the
// dashboard's /api/update spawn in commit 9f29131d.
const child = spawn(cmd!, args, {
stdio: ["inherit", "pipe", "pipe"],
shell: isWindows(),
windowsHide: true,
});
let output = "";
const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => {
const text = chunk.toString();
output += text;
if (opts.echo) stream.write(chunk);
};
child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout));
child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr));
child.on("error", (error) => {
output += `${error.name}: ${error.message}`;
resolveExit({ exitCode: 1, output });
});
child.on("exit", (code, signal) => {
if (signal) {
resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` });
return;
}
resolveExit({ exitCode: code ?? 1, output });
});
});
}
interface VerificationResult {
ok: boolean;
actualVersion?: string;
output: string;
message: string;
}
async function verifyInstalledVersion(
expectedVersion: string,
previousVersion: string,
): Promise<VerificationResult> {
const result = await runCommandCapture("ao", ["--version"]);
const output = result.output.trim();
const actualVersion = parseAoVersion(output);
if (result.exitCode !== 0) {
return {
ok: false,
output,
message: `\`ao --version\` failed with exit ${result.exitCode}.`,
};
}
if (!actualVersion) {
return {
ok: false,
output,
message: `Could not parse \`ao --version\` output: ${output || "<empty>"}`,
};
}
if (actualVersion !== expectedVersion) {
return {
ok: false,
actualVersion,
output,
message:
actualVersion === previousVersion
? `The install command exited successfully, but AO is still on ${previousVersion}.`
: `The install command exited successfully, but AO reports ${actualVersion}.`,
};
}
return { ok: true, actualVersion, output, message: "verified" };
}
function parseAoVersion(output: string): string | undefined {
const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
return match?.[1];
}
function classifyInstallFailure(output: string): { kind: string; guidance: string } {
if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) {
return {
kind: "pnpm_virtual_store",
guidance:
"pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " +
"If pnpm remains stuck, use the npm fallback below.",
};
}
if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) {
return {
kind: "permission",
guidance:
"The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.",
};
}
if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) {
return {
kind: "network",
guidance:
"The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.",
};
}
if (
/(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test(
output,
)
) {
return {
kind: "lockfile",
guidance:
"pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.",
};
}
if (
/(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test(
output,
)
) {
return {
kind: "registry",
guidance:
"The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.",
};
}
return {
kind: "unknown",
guidance:
"The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.",
};
}
function printInstallFailure(opts: {
method: InstallMethod;
command: string;
channel: ReturnType<typeof resolveUpdateChannel>;
currentVersion: string;
exitCode: number;
output: string;
}): void {
const classification = classifyInstallFailure(opts.output);
const fallbackCommand = getUpdateCommand("npm-global", opts.channel);
console.error(
chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`),
);
console.error(
chalk.yellow(
`The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`,
),
);
console.error(chalk.yellow(classification.guidance));
console.error(chalk.dim(`\nTo retry: ao update`));
if (opts.command !== fallbackCommand) {
console.error(chalk.dim(`You can also try: ${fallbackCommand}`));
}
console.error(chalk.dim("\nPackage manager output:"));
console.error(opts.output.trim() || "<no output>");
}
// ---------------------------------------------------------------------------
// homebrew install (notice only)
// ---------------------------------------------------------------------------
async function handleHomebrewUpdate(): Promise<void> {
const channel = resolveUpdateChannel();
const info = await checkForUpdate({ force: true, channel });
console.log(`Installed via: ${chalk.yellow("Homebrew")}`);
console.log(`Current version: ${chalk.dim(info.currentVersion)}`);
if (info.latestVersion) {
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
}
console.log();
console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`);
console.log(
chalk.dim(
" (AO does not auto-install for brew installs because it would clobber brew's symlinks.)",
),
);
}
// ---------------------------------------------------------------------------
// unknown install
// ---------------------------------------------------------------------------
async function handleUnknownUpdate(): Promise<void> {
const version = getCurrentVersion();
const channel = resolveUpdateChannel();
const info = await checkForUpdate({ force: true, channel });
console.log(`Installed version: ${chalk.dim(version)}`);
if (info.latestVersion) {
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
}
console.log(`Install method: ${chalk.yellow("unknown")}`);
console.log(`Channel: ${chalk.cyan(channel)}`);
console.log();
console.log(
`Could not detect install method. If you installed via npm, run:\n ${chalk.cyan(getUpdateCommand("npm-global", channel))}`,
);
console.log(
chalk.dim(
` Override detection in ~/.agent-orchestrator/config.yaml:\n installMethod: pnpm-global # or bun-global, npm-global, homebrew, git`,
),
);
}