-
Notifications
You must be signed in to change notification settings - Fork 87
feat(update): port CLI self-updater command to refactor architecture #2151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: refactor
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ describe("createRootHandler", () => { | |
| "eval", | ||
| "config", | ||
| "project", | ||
| "update", | ||
| ]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { runProcess, type ProcessRunner } from "../../io"; | ||
| 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<string> { | ||
| const response = await fetch(`${REGISTRY_URL}/${PACKAGE_NAME}/latest`); | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch latest version: ${response.statusText}`); | ||
| } | ||
| const data = (await response.json()) as { version: string }; | ||
| return data.version; | ||
| } | ||
|
|
||
| export function compareVersions(current: string, latest: string): number { | ||
| const parse = (v: string) => { | ||
| const [core = "", ...prereleaseParts] = v.split("-"); | ||
| const nums = core.split(".").map(Number); | ||
| const prerelease = prereleaseParts.join("-"); | ||
| return { nums, prerelease }; | ||
| }; | ||
|
|
||
| const curr = parse(current); | ||
| const lat = parse(latest); | ||
|
|
||
| for (let i = 0; i < 3; i++) { | ||
| const c = curr.nums[i] ?? 0; | ||
| const l = lat.nums[i] ?? 0; | ||
| if (l > c) return 1; | ||
| if (l < c) return -1; | ||
| } | ||
|
|
||
| if (!curr.prerelease && !lat.prerelease) return 0; | ||
| if (!curr.prerelease) return -1; | ||
| if (!lat.prerelease) return 1; | ||
|
|
||
| const currSegments = curr.prerelease.split("."); | ||
| const latSegments = lat.prerelease.split("."); | ||
| const len = Math.max(currSegments.length, latSegments.length); | ||
|
|
||
| for (let i = 0; i < len; i++) { | ||
| const cs = currSegments[i]; | ||
| const ls = latSegments[i]; | ||
| if (cs === undefined) return 1; | ||
| if (ls === undefined) return -1; | ||
| const cn = Number(cs); | ||
| const ln = Number(ls); | ||
| if (!isNaN(cn) && !isNaN(ln)) { | ||
| if (ln > cn) return 1; | ||
| if (ln < cn) return -1; | ||
| } else { | ||
| if (ls > cs) return 1; | ||
| if (ls < cs) return -1; | ||
| } | ||
| } | ||
|
|
||
| return 0; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is looking a little gnarly! Can we not use |
||
|
|
||
| export type UpdateStatus = | ||
| "up-to-date" | "newer-local" | "update-available" | "updated" | "update-failed"; | ||
|
|
||
| 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<UpdateResult> { | ||
| const latestVersion = await fetchLatestVersion(); | ||
| const comparison = compareVersions(PACKAGE_VERSION, latestVersion); | ||
|
|
||
| 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 }; | ||
| } | ||
|
|
||
| try { | ||
| await runner(installArgv(), { cwd: process.cwd(), onOutput }); | ||
| return { status: "updated", currentVersion: PACKAGE_VERSION, latestVersion }; | ||
| } catch { | ||
| return { status: "update-failed", currentVersion: PACKAGE_VERSION, latestVersion }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import z from "zod"; | ||
| import { createHandler, flag } from "../../router"; | ||
| import { SilentCLIError } from "../../errors"; | ||
| import { JsonRendererKey } from "../../tui"; | ||
| import type { AppIO } from "../../io"; | ||
| import { handleUpdate } from "./action"; | ||
|
|
||
| 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, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd probably just put all of the functionality here. I don't see an upshot of pulling this out. I think code could still be tested easily enough if it were inlined here. |
||
| onOutput: (chunk) => io.stderr.write(chunk), | ||
| }); | ||
|
|
||
| ctx.require(JsonRendererKey).renderJson(result); | ||
|
|
||
| if (result.status === "update-failed") { | ||
| throw new SilentCLIError("failed to install update", { exitCode: 1 }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why should this error be silent? |
||
| } | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; | ||
| import { compareVersions, fetchLatestVersion, handleUpdate } from "./action"; | ||
| 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("compareVersions", () => { | ||
| const cases: Array<[string, string, number]> = [ | ||
| ["1.2.3", "1.2.3", 0], | ||
| ["1.0.0", "2.0.0", 1], | ||
| ["2.0.0", "1.0.0", -1], | ||
| ["1.1.0", "1.2.0", 1], | ||
| ["1.2.0", "1.1.0", -1], | ||
| ["1.2.3", "1.2.4", 1], | ||
| ["1.2.4", "1.2.3", -1], | ||
| ["1.0", "1.0.0", 0], | ||
| ["1.0.0-preview", "1.0.0", 1], | ||
| ["1.0.0", "1.0.0-preview", -1], | ||
| ["1.0.0-preview.1", "1.0.0-preview.2", 1], | ||
| ["1.0.0-preview.2", "1.0.0-preview.1", -1], | ||
| ["1.0.0-alpha", "1.0.0-beta", 1], | ||
| ["1.0.0-beta", "1.0.0-alpha", -1], | ||
| ]; | ||
| test.each(cases)("compareVersions(%p, %p) === %p", (current, latest, expected) => { | ||
| expect(compareVersions(current, latest)).toBe(expected); | ||
| }); | ||
| }); | ||
|
|
||
| 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 when the registry responds non-OK", async () => { | ||
| spyOn(globalThis, "fetch").mockResolvedValue( | ||
| new Response("", { status: 404, statusText: "Not Found" }), | ||
| ); | ||
| await expect(fetchLatestVersion()).rejects.toThrow("Failed to fetch latest version: Not Found"); | ||
| }); | ||
| }); | ||
|
|
||
| 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("update-failed when the install runner throws", async () => { | ||
| mockLatest("2.0.0"); | ||
| expect((await handleUpdate(false, { runner: failRunner })).status).toBe("update-failed"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be an AgentCoreCLIError. I think we should create a NetworkingError to cover this and other cases.