Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions src/handlers/root.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("createRootHandler", () => {
"eval",
"config",
"project",
"update",
]);
});
});
83 changes: 83 additions & 0 deletions src/handlers/update/index.tsx
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 };

Copy link
Copy Markdown
Contributor

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 .json call.

Copy link
Copy Markdown
Contributor Author

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

return data.version;
}

export type UpdateStatus = "up-to-date" | "newer-local" | "update-available" | "updated";

export interface UpdateResult {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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, {
Comment thread
jariy17 marked this conversation as resolved.
onOutput: (chunk) => io.stderr.write(chunk),
});

ctx.require(JsonRendererKey).renderJson(result);
},
});
93 changes: 93 additions & 0 deletions src/handlers/update/update.test.ts
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we avoid the direct mocking by making the http fetch injectable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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");
});
});
Loading