diff --git a/bun.lock b/bun.lock index a27d2b9f5..750bb041c 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.3.0", + "semver": "^7.8.5", "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", @@ -39,6 +40,7 @@ "@secretlint/secretlint-rule-preset-recommend": "^12.2.0", "@types/bun": "latest", "@types/react": "^19.2.17", + "@types/semver": "^7.8.0", "husky": "^9.1.7", "ink-testing-library": "^4.0.0", "lint-staged": "^17.0.8", @@ -430,6 +432,8 @@ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/package.json b/package.json index 1232e5277..ec7259ada 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@secretlint/secretlint-rule-preset-recommend": "^12.2.0", "@types/bun": "latest", "@types/react": "^19.2.17", + "@types/semver": "^7.8.0", "husky": "^9.1.7", "ink-testing-library": "^4.0.0", "lint-staged": "^17.0.8", @@ -80,6 +81,7 @@ "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.3.0", + "semver": "^7.8.5", "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a2ff49e03..dde648a9d 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -8,6 +8,7 @@ import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; +import { createUpdateHandler } from "./update/index.tsx"; import { renderTui } from "../tui"; import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO } from "../io"; @@ -55,6 +56,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); root.handler(createProjectHandler({ core, io })); + root.handler(createUpdateHandler(io)); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b3f4e3386..8fcf36279 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -19,6 +19,7 @@ describe("createRootHandler", () => { "eval", "config", "project", + "update", ]); }); }); diff --git a/src/handlers/update/index.tsx b/src/handlers/update/index.tsx new file mode 100644 index 000000000..d4b296003 --- /dev/null +++ b/src/handlers/update/index.tsx @@ -0,0 +1,83 @@ +import z from "zod"; +import semver from "semver"; +import { createHandler, flag } from "../../router"; +import { NetworkingError } from "../../errors"; +import { runProcess, type ProcessRunner, type AppIO } from "../../io"; +import { JsonRendererKey } from "../../tui"; +import { PACKAGE_VERSION } from "../../constants"; + +const PACKAGE_NAME = "@aws/agentcore"; +const REGISTRY_URL = "https://registry.npmjs.org"; + +function distTag(): string { + return PACKAGE_VERSION.includes("-") ? "preview" : "latest"; +} + +export function installArgv(): string[] { + return ["npm", "install", "-g", `${PACKAGE_NAME}@${distTag()}`]; +} + +export async function fetchLatestVersion(): Promise { + let response: Response; + try { + response = await fetch(`${REGISTRY_URL}/${PACKAGE_NAME}/latest`); + } catch (cause) { + throw new NetworkingError( + `Could not reach the npm registry: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } + if (!response.ok) { + throw new NetworkingError(`Failed to fetch latest version: ${response.statusText}`); + } + const data = (await response.json()) as { version: string }; + return data.version; +} + +export type UpdateStatus = "up-to-date" | "newer-local" | "update-available" | "updated"; + +export interface UpdateResult { + status: UpdateStatus; + currentVersion: string; + latestVersion: string; +} + +export interface HandleUpdateOptions { + runner?: ProcessRunner; + onOutput?: (chunk: string) => void; +} + +export async function handleUpdate( + checkOnly: boolean, + { runner = runProcess, onOutput }: HandleUpdateOptions = {}, +): Promise { + const latestVersion = await fetchLatestVersion(); + const comparison = semver.compare(latestVersion, PACKAGE_VERSION); + + if (comparison === 0) { + return { status: "up-to-date", currentVersion: PACKAGE_VERSION, latestVersion }; + } + if (comparison < 0) { + return { status: "newer-local", currentVersion: PACKAGE_VERSION, latestVersion }; + } + if (checkOnly) { + return { status: "update-available", currentVersion: PACKAGE_VERSION, latestVersion }; + } + + await runner(installArgv(), { cwd: process.cwd(), onOutput }); + return { status: "updated", currentVersion: PACKAGE_VERSION, latestVersion }; +} + +export const createUpdateHandler = (io: AppIO) => + createHandler({ + name: "update", + description: "Check for and install CLI updates", + flags: [flag("check", "check for updates without installing", z.boolean().default(false))], + handle: async (ctx, flags) => { + const result = await handleUpdate(flags.check, { + onOutput: (chunk) => io.stderr.write(chunk), + }); + + ctx.require(JsonRendererKey).renderJson(result); + }, + }); diff --git a/src/handlers/update/update.test.ts b/src/handlers/update/update.test.ts new file mode 100644 index 000000000..f5126e75a --- /dev/null +++ b/src/handlers/update/update.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { fetchLatestVersion, handleUpdate } from "./index"; +import { NetworkingError } from "../../errors"; +import type { ProcessRunner } from "../../io"; + +// No golden/fixture tests here: the repo's *.fixture.test.tsx harness records and +// replays AWS SDK responses through CoreClient, but `update` makes no AWS calls — +// it queries the npm registry (fetch) and shells out to `npm install -g` +// (runProcess). There is nothing for that harness to record, so a fetch spy plus +// an injected fake runner is the right, hermetic way to cover this command. + +describe("fetchLatestVersion", () => { + afterEach(() => { + spyOn(globalThis, "fetch").mockRestore(); + }); + + test("returns the version from the npm registry", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ version: "9.9.9" }), { status: 200 }), + ); + expect(await fetchLatestVersion()).toBe("9.9.9"); + expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@aws/agentcore/latest"); + }); + + test("throws a NetworkingError when the registry responds non-OK", async () => { + spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { status: 404, statusText: "Not Found" }), + ); + await expect(fetchLatestVersion()).rejects.toBeInstanceOf(NetworkingError); + }); + + test("wraps a fetch failure (offline) as a NetworkingError", async () => { + spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + await expect(fetchLatestVersion()).rejects.toBeInstanceOf(NetworkingError); + }); +}); + +describe("handleUpdate", () => { + afterEach(() => { + spyOn(globalThis, "fetch").mockRestore(); + }); + + const mockLatest = (version: string) => + spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ version }), { status: 200 }), + ); + const okRunner: ProcessRunner = mock(async () => {}); + const failRunner: ProcessRunner = mock(async () => { + throw new Error("npm exploded"); + }); + + test("up-to-date when versions match, without invoking the runner", async () => { + mockLatest("1.0.0"); + const runner: ProcessRunner = mock(async () => {}); + expect(await handleUpdate(false, { runner })).toEqual({ + status: "up-to-date", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + }); + expect(runner).not.toHaveBeenCalled(); + }); + + test("newer-local when local is ahead of the registry", async () => { + mockLatest("0.9.0"); + expect((await handleUpdate(false)).status).toBe("newer-local"); + }); + + test("update-available when newer exists and checkOnly is set (no install)", async () => { + mockLatest("2.0.0"); + const runner: ProcessRunner = mock(async () => {}); + expect(await handleUpdate(true, { runner })).toEqual({ + status: "update-available", + currentVersion: "1.0.0", + latestVersion: "2.0.0", + }); + expect(runner).not.toHaveBeenCalled(); + }); + + test("updated when the install runner succeeds", async () => { + mockLatest("2.0.0"); + const result = await handleUpdate(false, { runner: okRunner }); + expect(result.status).toBe("updated"); + expect(okRunner).toHaveBeenCalledWith( + ["npm", "install", "-g", "@aws/agentcore@latest"], + expect.objectContaining({ cwd: expect.any(String) }), + ); + }); + + test("propagates the install failure instead of swallowing it", async () => { + mockLatest("2.0.0"); + await expect(handleUpdate(false, { runner: failRunner })).rejects.toThrow("npm exploded"); + }); +});