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
12 changes: 8 additions & 4 deletions src/assets/templates/runtime-docs/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ prints anything it changed:

- **Build type:** adding a file named exactly `Dockerfile` to this folder
switches the runtime to a container build, and the CLI says so. It never
switches back on its own: to return to a direct code deploy, delete the
Dockerfile, then in `agentcore.json` set `"build": "CodeZip"`, remove
`"dockerfile"`, and add back a `"runtimeVersion"` (the CLI removed it when
it switched; the schema requires it for CodeZip).
switches back on its own. To return to a direct code deploy, delete the
`Dockerfile`, then open `agentcore/agentcore.json`. In the `runtimes` array,
find the object whose `"name"` is `"{{name}}"`, set `"build": "CodeZip"`,
remove `"dockerfile"` (and `"buildContextPath"` or
`"customDockerBuildArgs"` if present), and add
`"runtimeVersion": "{{versions.latestPythonRuntime}}"` to that same object.
The CLI removed `runtimeVersion` when it switched to a container build, and
CodeZip requires it.
- **Python version** (CodeZip only): kept as long as `requires-python` in
`pyproject.toml` allows it (see below).
- **Entrypoint:** from `[tool.agentcore]` in `pyproject.toml`, if you want a
Expand Down
12 changes: 8 additions & 4 deletions src/assets/templates/runtime-docs/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ the runtime in `agentcore/agentcore.json`, and prints anything it changed:

- **Build type:** adding a file named exactly `Dockerfile` to this folder
switches the runtime to a container build, and the CLI says so. It never
switches back on its own: to return to a direct code deploy, delete the
Dockerfile, then in `agentcore.json` set `"build": "CodeZip"`, remove
`"dockerfile"`, and add back a `"runtimeVersion"` (the CLI removed it when
it switched; the schema requires it for CodeZip).
switches back on its own. To return to a direct code deploy, delete the
`Dockerfile`, then open `agentcore/agentcore.json`. In the `runtimes` array,
find the object whose `"name"` is `"{{name}}"`, set `"build": "CodeZip"`,
remove `"dockerfile"` (and `"buildContextPath"` or
`"customDockerBuildArgs"` if present), and add
`"runtimeVersion": "{{versions.latestNodeRuntime}}"` to that same object. The
CLI removed `runtimeVersion` when it switched to a container build, and
CodeZip requires it.
- **Node.js version** (CodeZip only): kept as long as `engines.node` in
`package.json` allows it (see below).
- **Entrypoint** (CodeZip only): AgentCore currently bundles `main.ts` at the
Expand Down
21 changes: 21 additions & 0 deletions src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import { glyphs } from "./ui/_core";
import { createProjectHandlers } from "../handlers/project";
import type { Project } from "../handlers/project/types";
import { ProjectDetectedKey, V0ProjectDetectedKey } from "../handlers/project/context";
import { V0_PROJECT_TUI_BANNER } from "../handlers/project/v0Notice";

afterEach(cleanupScreens);

Expand Down Expand Up @@ -52,6 +54,25 @@ function menuGroups(frame: string): { title: string | undefined; names: string[]
// rendered frames — behavior a user would see, not internal state.

describe("menu rendering", () => {
test("places the v0 compatibility warning below the command input and above dev", async () => {
const screen = renderScreen("/agentcore", {
withContext: (ctx) =>
ctx.withValue(ProjectDetectedKey, true).withValue(V0ProjectDetectedKey, true),
});
await waitForText(screen.lastFrame, V0_PROJECT_TUI_BANNER);

const lines = (screen.lastFrame() ?? "").split("\n");
const inputRow = lines.findIndex((line) => line.includes("type to choose a command"));
const warningRow = lines.findIndex((line) =>
line.includes(`${glyphs.warning} ${V0_PROJECT_TUI_BANNER}`),
);
const devRow = lines.findIndex((line) => line.includes("❯ dev"));

expect(inputRow).toBeGreaterThanOrEqual(0);
expect(warningRow).toBeGreaterThan(inputRow);
expect(devRow).toBeGreaterThan(warningRow);
});

test.each([
{
path: "/agentcore/gateway",
Expand Down
9 changes: 7 additions & 2 deletions src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
shouldHideBrandBanner,
} from "./BrandBanner";
import { Layout } from "./Layout";
import { Alert } from "./ui/alert";
import { Alert, type AlertProps } from "./ui/alert";
import { Divider } from "./ui/divider";
import { TextInput } from "./ui/text-input";
import { darkTheme, glyphs } from "./ui/_core.js";
Expand Down Expand Up @@ -84,6 +84,7 @@ export interface RouterScreenProps extends ScreenProps {
cliSectionCommands?: readonly string[];
// alert is optional guidance rendered between the filter and menu options.
alert?: string;
alertVariant?: AlertProps["variant"];
hiddenOptions?: readonly string[];
}

Expand All @@ -108,6 +109,7 @@ function CommandMenu({
optionNotices,
cliSectionCommands = [],
alert,
alertVariant,
hiddenOptions,
command,
}: RouterScreenProps & { command: Command }) {
Expand Down Expand Up @@ -226,6 +228,7 @@ function CommandMenu({
{({ columns, contentRows }) => (
<CommandMenuBody
alert={alert}
alertVariant={alertVariant}
columns={columns}
contentRows={contentRows}
filtered={filtered}
Expand All @@ -245,6 +248,7 @@ function CommandMenu({

interface CommandMenuBodyProps {
alert?: string;
alertVariant?: AlertProps["variant"];
columns: number;
contentRows: number;
filtered: Option[];
Expand All @@ -264,6 +268,7 @@ function alertRows(alert: string | undefined, columns: number): number {

function CommandMenuBody({
alert,
alertVariant,
columns,
contentRows,
filtered,
Expand Down Expand Up @@ -301,7 +306,7 @@ function CommandMenuBody({

<Divider />

{alert && <Alert>{alert}</Alert>}
{alert && <Alert variant={alertVariant}>{alert}</Alert>}

<Box flexDirection="column" height={menuHeight} overflow="hidden">
{filtered.length === 0 ? (
Expand Down
17 changes: 8 additions & 9 deletions src/components/ui/alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ import { darkTheme, glyphs, type InkUITheme } from "../_core.js";
export interface AlertProps {
children: React.ReactNode;
theme?: InkUITheme;
variant?: "info" | "warning";
}

/**
* Compact informational callout for inline TUI guidance.
* Compact callout for inline TUI guidance.
*/
export function Alert({ children, theme = darkTheme }: AlertProps) {
export function Alert({ children, theme = darkTheme, variant = "info" }: AlertProps) {
const color = variant === "warning" ? theme.colors.warning : theme.colors.secondary;
const glyph = variant === "warning" ? glyphs.warning : glyphs.info;

return (
<Box
alignSelf="flex-start"
borderStyle="round"
borderColor={theme.colors.secondary}
paddingX={1}
>
<Text color={theme.colors.secondary}>{glyphs.info} </Text>
<Box alignSelf="flex-start" borderStyle="round" borderColor={color} paddingX={1}>
<Text color={color}>{glyph} </Text>
<Text color={theme.colors.text}>{children}</Text>
</Box>
);
Expand Down
2 changes: 2 additions & 0 deletions src/core/project/templates/runtimeDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function runtimeDocsTree(
versions: {
latestPython: versionNumber(LATEST_PYTHON),
latestNode: versionNumber(LATEST_NODE),
latestPythonRuntime: LATEST_PYTHON,
latestNodeRuntime: LATEST_NODE,
python: listVersions(PythonRuntimeSchema.options.map(versionNumber)),
node: listVersions(NodeRuntimeSchema.options.map(versionNumber)),
},
Expand Down
5 changes: 5 additions & 0 deletions src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
withLogging,
withGlobalConfigAccessor,
withPlatform,
withV0ProjectNotice,
} from "../middleware";
import type { AppIO } from "../io";
import type { V0DeployRunner } from "../core/project/legacy/deploy";
Expand Down Expand Up @@ -86,6 +87,10 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router
// Pin the host platform so Windows-specific behavior is decided from the context.
root.use(withPlatform(config.platform ?? process.platform));

// Make v0 compatibility mode visible for every command and TUI session
// launched from a legacy project.
root.use(withV0ProjectNotice(io));

// Install sub handlers. Registration order is menu/help order; project is
// the primary workflow, so it goes first.
createProjectHandlers(core, io, { runV0Deploy: config.runV0Deploy }).forEach((handler) => {
Expand Down
4 changes: 4 additions & 0 deletions src/handlers/project/add/runtime/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ describe("project add runtime", () => {
: "https://github.com/aws/bedrock-agentcore-sdk-typescript",
);
expect(readme).toContain("## How `agentcore.json` follows your code");
expect(readme).toContain('find the object whose `"name"` is `"search"`');
expect(readme).toContain(`"runtimeVersion": "${expected.runtimeVersion as string}"`);
expect(readme).toContain('"buildContextPath"');
expect(readme).toContain('"customDockerBuildArgs"');
if (expected.entrypoint === "main.js") {
expect(readme).toContain("--format=esm");
expect(readme).toContain("--packages=external");
Expand Down
12 changes: 12 additions & 0 deletions src/handlers/project/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ import { contextKey } from "../../router";
// ProjectDetectedKey records whether the TUI was launched from inside a project
// without pinning a Project object that could become stale while the TUI is open.
export const ProjectDetectedKey = contextKey<boolean>("project.detected");

// V0ProjectDetectedKey lets every TUI screen show the compatibility notice
// without loading or retaining a mutable Project object for the whole session.
export const V0ProjectDetectedKey = contextKey<boolean>("project.v0.detected");

export interface V0ProjectNoticeState {
enteredTui: boolean;
}

// The root command middleware uses this mutable invocation-local marker to
// avoid printing both a direct-command reminder and the TUI exit reminder.
export const V0ProjectNoticeStateKey = contextKey<V0ProjectNoticeState>("project.v0.noticeState");
13 changes: 8 additions & 5 deletions src/handlers/project/deploy/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { writeMigrationState } from "../../../core/project/migrate/journal";
import { ProjectStateError } from "../../../errors";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
import type { DeployResult, Project, ProjectEvent, TeardownConfirmationRequest } from "../types";
import { V0_PROJECT_MIGRATION_MESSAGE } from "../v0Notice";

const DEFAULT_TARGET: AwsDeploymentTarget = {
name: "default",
Expand Down Expand Up @@ -277,10 +278,11 @@ describe("project deploy handler", () => {
"--region is ignored for v0 projects; v0 uses the target's region from " +
"aws-targets.json, or AWS_REGION / your AWS profile.";
expect(subject.io.stderr()).toBe(
flag === "--json"
? regionWarning
: `${regionWarning}\n` +
"This is a v0 project; running the latest v0 AgentCore CLI deploy in compatibility mode.",
`${regionWarning}\n` +
(flag === "--json"
? V0_PROJECT_MIGRATION_MESSAGE
: "This is a v0 project; running the latest v0 AgentCore CLI deploy in compatibility mode." +
`\n${V0_PROJECT_MIGRATION_MESSAGE}`),
);
expect(
await Promise.all([readFile(specPath, "utf8"), readFile(manifestPath, "utf8")]),
Expand Down Expand Up @@ -309,7 +311,8 @@ describe("project deploy handler", () => {

expect(legacyDeploys).toHaveLength(1);
expect(subject.io.stderr()).toBe(
"This is a v0 project; running the latest v0 AgentCore CLI deploy in compatibility mode.",
"This is a v0 project; running the latest v0 AgentCore CLI deploy in compatibility mode." +
`\n${V0_PROJECT_MIGRATION_MESSAGE}`,
);
});

Expand Down
6 changes: 6 additions & 0 deletions src/handlers/project/v0Notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const V0_PROJECT_MIGRATION_MESSAGE =
"This is an AgentCore CLI v0 project running in v1 compatibility mode. " +
"Use 'agentcore migrate' to migrate the project fully.";

export const V0_PROJECT_TUI_BANNER =
"v0 project · CLI v1 compatibility mode · use agentcore migrate to migrate fully";
8 changes: 7 additions & 1 deletion src/handlers/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { useTuiUpdate } from "../components/TuiUpdateContext";
import { glyphs } from "../components/ui/_core.js";
import type { ScreenProps } from "./types";
import { LoadingFrame, useProjectDetected } from "./project/ProjectGate";
import { V0ProjectDetectedKey } from "./project/context";
import { V0_PROJECT_TUI_BANNER } from "./project/v0Notice";

const NO_PROJECT_HINTS = { create: `${glyphs.leftArrow} start here` };
const ROOT_CLI_SECTION_COMMANDS = ["update"];
Expand Down Expand Up @@ -36,6 +38,7 @@ export interface RootScreenProps extends ScreenProps {
export function RootScreen({ inProject, ...props }: RootScreenProps) {
const projectDetected = useProjectDetected(props.core, inProject);
const updateState = useTuiUpdate();
const v0Project = props.ctx.value(V0ProjectDetectedKey) === true;
if (projectDetected.data === undefined) {
const command = resolveCommand(props.ctx.require(CommandKey), ["agentcore"]);
return (
Expand All @@ -60,7 +63,10 @@ export function RootScreen({ inProject, ...props }: RootScreenProps) {
? { update: `· update to install ${updateState.latestVersion}` }
: undefined
}
alert={projectDetected.data ? undefined : NO_PROJECT_ALERT}
alert={
v0Project ? V0_PROJECT_TUI_BANNER : projectDetected.data ? undefined : NO_PROJECT_ALERT
}
alertVariant={v0Project ? "warning" : "info"}
hiddenOptions={projectDetected.data ? PROJECT_HIDDEN_OPTIONS : NO_PROJECT_HIDDEN_OPTIONS}
/>
);
Expand Down
1 change: 1 addition & 0 deletions src/middleware/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor";
export { withPlatform } from "./withPlatform";
export { withProject } from "./withProject";
export { withV0ProjectGuard } from "./withV0ProjectGuard";
export { withV0ProjectNotice } from "./withV0ProjectNotice";
2 changes: 1 addition & 1 deletion src/middleware/withV0ProjectGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Middleware } from "../router";
export const V0_PROJECT_ADD_REMOVE_MESSAGE =
"This project uses the v0 format (agentcore.json version 1). " +
"The v1 CLI cannot add or remove resources in it. " +
"Migrate the project to v1 in order to add/remove its resources.";
"Use 'agentcore migrate' to migrate the project fully before adding or removing resources.";

/** Blocks project add/remove before withProject attempts to parse the v1 schema. */
export function withV0ProjectGuard(): Middleware {
Expand Down
61 changes: 61 additions & 0 deletions src/middleware/withV0ProjectNotice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createHandler, Router, type Context } from "../router";
import { inTempDirectory, testIO } from "../testing";
import { V0ProjectNoticeStateKey } from "../handlers/project/context";
import { V0_PROJECT_MIGRATION_MESSAGE } from "../handlers/project/v0Notice";
import { withV0ProjectNotice } from "./withV0ProjectNotice";

const cleanups: Array<() => Promise<void>> = [];
afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup())));

async function writeProject(version: number): Promise<void> {
const { path, cleanup } = await inTempDirectory("agentcore-v0-notice-");
cleanups.push(cleanup);
await mkdir(join(path, "agentcore"));
await writeFile(
join(path, "agentcore", "agentcore.json"),
JSON.stringify({ name: "Project", version }),
);
}

function app(
io: ReturnType<typeof testIO>,
handle: (ctx: Context) => Promise<void> = async () => {},
) {
return new Router("app", "test")
.use(withV0ProjectNotice(io.io))
.handler(createHandler({ name: "check", description: "noop", handle }));
}

describe("v0 project notice", () => {
test("prints migration guidance after a direct command", async () => {
await writeProject(1);
const io = testIO();

await app(io).route(["node", "app", "check"]);

expect(io.stderr()).toBe(V0_PROJECT_MIGRATION_MESSAGE);
});

test("does not print for a v1 project", async () => {
await writeProject(2);
const io = testIO();

await app(io).route(["node", "app", "check"]);

expect(io.stderr()).toBe("");
});

test("lets the TUI own the reminder when one was entered", async () => {
await writeProject(1);
const io = testIO();

await app(io, async (ctx) => {
ctx.require(V0ProjectNoticeStateKey).enteredTui = true;
}).route(["node", "app", "check"]);

expect(io.stderr()).toBe("");
});
});
Loading
Loading