518 lines
18 KiB
TypeScript
518 lines
18 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")
|
|
.action(
|
|
async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: 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);
|
|
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));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Active-session guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Refuse to update when the user has live sessions.
|
|
*
|
|
* Auto-stopping would lose the agent's in-flight context (and potentially
|
|
* uncommitted work). The release doc is explicit: refuse, surface the
|
|
* `ao stop` command, let the user decide.
|
|
*
|
|
* Best-effort — when no config is reachable (fresh install, broken yaml)
|
|
* we skip the guard rather than blocking the update on a missing dependency.
|
|
*/
|
|
async function ensureNoActiveSessions(): Promise<boolean> {
|
|
let sessions: Session[];
|
|
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) {
|
|
// 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 true;
|
|
const globalConfig = loadGlobalConfig(globalPath);
|
|
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
|
|
return true;
|
|
}
|
|
if (!isCanonicalGlobalConfigPath(globalPath)) {
|
|
return true;
|
|
}
|
|
const config = loadConfig(globalPath);
|
|
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 true;
|
|
}
|
|
|
|
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
|
|
if (active.length === 0) return true;
|
|
|
|
const noun = active.length === 1 ? "session" : "sessions";
|
|
console.error(
|
|
chalk.red(
|
|
`\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`,
|
|
),
|
|
);
|
|
for (const s of active.slice(0, 5)) {
|
|
console.error(chalk.dim(` • ${s.id} (${s.status})`));
|
|
}
|
|
if (active.length > 5) {
|
|
console.error(chalk.dim(` … and ${active.length - 5} more`));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handleGitUpdate(opts: {
|
|
skipSmoke?: boolean;
|
|
smokeOnly?: boolean;
|
|
}): Promise<void> {
|
|
if (!(await ensureNoActiveSessions())) {
|
|
process.exit(1);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
process.exit(exitCode);
|
|
}
|
|
invalidateCache();
|
|
} 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.",
|
|
),
|
|
);
|
|
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));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// npm / pnpm / bun global install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handleNpmUpdate(method: InstallMethod): 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;
|
|
|
|
// Non-interactive path: API-invoked OR piped output. We still gate on the
|
|
// active-session guard (refusing returns true/false), 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 (!(await ensureNoActiveSessions())) {
|
|
process.exit(1);
|
|
}
|
|
|
|
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 exitCode = await runNpmInstall(command);
|
|
if (exitCode === 0) {
|
|
invalidateCache();
|
|
console.log(chalk.green("\nUpdate complete."));
|
|
} else {
|
|
recordActivityEvent({
|
|
source: "cli",
|
|
kind: "cli.update_failed",
|
|
level: "error",
|
|
summary: `ao update (${method}) failed: install command exited non-zero`,
|
|
data: { method, command, exitCode },
|
|
});
|
|
process.exit(exitCode);
|
|
}
|
|
}
|
|
|
|
function runNpmInstall(command: string): Promise<number> {
|
|
const [cmd, ...args] = command.split(" ");
|
|
return new Promise<number>((resolveExit, reject) => {
|
|
// `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",
|
|
shell: isWindows(),
|
|
windowsHide: true,
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code, signal) => {
|
|
if (signal) {
|
|
resolveExit(1);
|
|
return;
|
|
}
|
|
|
|
if (code !== 0) {
|
|
console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`));
|
|
}
|
|
resolveExit(code ?? 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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`,
|
|
),
|
|
);
|
|
}
|