Skip to content
Open
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
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",
]);
});
});
106 changes: 106 additions & 0 deletions src/handlers/update/action.ts
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}`);

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.

This should be an AgentCoreCLIError. I think we should create a NetworkingError to cover this and other cases.

}
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;
}

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.

This is looking a little gnarly! Can we not use semver?


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 };
}
}
24 changes: 24 additions & 0 deletions src/handlers/update/index.tsx
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, {

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.

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 });

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.

Why should this error be silent?

}
},
});
109 changes: 109 additions & 0 deletions src/handlers/update/update.test.ts
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");
});
});
Loading