-
Notifications
You must be signed in to change notification settings - Fork 92
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
Changes from all commits
3003400
1d6e821
9857cda
e5974d9
1b7f642
6c3a6d0
079ed69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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,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<string> { | ||
| 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 { | ||
|
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. should this be a type since it represents a concrete grouping of data?
Contributor
Author
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. Yes I'll fix this in a follow up |
||
| 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 = 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, { | ||
|
jariy17 marked this conversation as resolved.
|
||
| onOutput: (chunk) => io.stderr.write(chunk), | ||
| }); | ||
|
|
||
| ctx.require(JsonRendererKey).renderJson(result); | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
|
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. could we avoid the direct mocking by making the http fetch injectable?
Contributor
Author
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. Yeah, I'll look into doing that in a follow up pr. |
||
| }); | ||
|
|
||
| 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"); | ||
| }); | ||
| }); | ||
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.
should these two lines lines also be wrapped in networking error? My understanding is that fetch doesn't consume the body until the
.jsoncall.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.
Yes, ill fix this in a follow up