diff --git a/src/assets/templates/runtime-docs/python/README.md b/src/assets/templates/runtime-docs/python/README.md index b224f373cb..262bbc0bee 100644 --- a/src/assets/templates/runtime-docs/python/README.md +++ b/src/assets/templates/runtime-docs/python/README.md @@ -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 diff --git a/src/assets/templates/runtime-docs/typescript/README.md b/src/assets/templates/runtime-docs/typescript/README.md index a040b6f15a..0809014cc9 100644 --- a/src/assets/templates/runtime-docs/typescript/README.md +++ b/src/assets/templates/runtime-docs/typescript/README.md @@ -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 diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index a941820abb..4378cce87c 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -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); @@ -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", diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 9740093f69..4611e2aef4 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -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"; @@ -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[]; } @@ -108,6 +109,7 @@ function CommandMenu({ optionNotices, cliSectionCommands = [], alert, + alertVariant, hiddenOptions, command, }: RouterScreenProps & { command: Command }) { @@ -226,6 +228,7 @@ function CommandMenu({ {({ columns, contentRows }) => ( - {alert && {alert}} + {alert && {alert}} {filtered.length === 0 ? ( diff --git a/src/components/ui/alert/Alert.tsx b/src/components/ui/alert/Alert.tsx index f5f918c440..654254644b 100644 --- a/src/components/ui/alert/Alert.tsx +++ b/src/components/ui/alert/Alert.tsx @@ -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 ( - - {glyphs.info} + + {glyph} {children} ); diff --git a/src/core/project/templates/runtimeDocs.ts b/src/core/project/templates/runtimeDocs.ts index 30e13e3ff0..09261c4d5c 100644 --- a/src/core/project/templates/runtimeDocs.ts +++ b/src/core/project/templates/runtimeDocs.ts @@ -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)), }, diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index e8d64afcae..b56a537bed 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -18,6 +18,7 @@ import { withLogging, withGlobalConfigAccessor, withPlatform, + withV0ProjectNotice, } from "../middleware"; import type { AppIO } from "../io"; import type { V0DeployRunner } from "../core/project/legacy/deploy"; @@ -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) => { diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index b5283eeed5..783a88f2ec 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -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"); diff --git a/src/handlers/project/context.ts b/src/handlers/project/context.ts index 8d6a5b6c05..b994af552c 100644 --- a/src/handlers/project/context.ts +++ b/src/handlers/project/context.ts @@ -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("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("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("project.v0.noticeState"); diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index c1936fb5d7..124783a662 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -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", @@ -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")]), @@ -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}`, ); }); diff --git a/src/handlers/project/v0Notice.ts b/src/handlers/project/v0Notice.ts new file mode 100644 index 0000000000..c5aaf96754 --- /dev/null +++ b/src/handlers/project/v0Notice.ts @@ -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"; diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 33848c5cd0..1d483ba1e5 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -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"]; @@ -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 ( @@ -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} /> ); diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index b6ea294aa3..fff26a14d6 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -6,3 +6,4 @@ export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; export { withPlatform } from "./withPlatform"; export { withProject } from "./withProject"; export { withV0ProjectGuard } from "./withV0ProjectGuard"; +export { withV0ProjectNotice } from "./withV0ProjectNotice"; diff --git a/src/middleware/withV0ProjectGuard.ts b/src/middleware/withV0ProjectGuard.ts index bf40035b9c..c91b7d1b1e 100644 --- a/src/middleware/withV0ProjectGuard.ts +++ b/src/middleware/withV0ProjectGuard.ts @@ -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 { diff --git a/src/middleware/withV0ProjectNotice.test.ts b/src/middleware/withV0ProjectNotice.test.ts new file mode 100644 index 0000000000..d3b1b7f163 --- /dev/null +++ b/src/middleware/withV0ProjectNotice.test.ts @@ -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> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function writeProject(version: number): Promise { + 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, + handle: (ctx: Context) => Promise = 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(""); + }); +}); diff --git a/src/middleware/withV0ProjectNotice.ts b/src/middleware/withV0ProjectNotice.ts new file mode 100644 index 0000000000..a0dc3a87b3 --- /dev/null +++ b/src/middleware/withV0ProjectNotice.ts @@ -0,0 +1,40 @@ +import { enclosingProjectRoot, isV0Project } from "../core/project/fsUtils"; +import type { AppIO } from "../io"; +import type { Middleware } from "../router"; +import { V0ProjectDetectedKey, V0ProjectNoticeStateKey } from "../handlers/project/context"; +import { V0_PROJECT_MIGRATION_MESSAGE } from "../handlers/project/v0Notice"; + +/** + * Marks commands launched from a v0 project and reminds direct-command users + * to migrate. TUI sessions render the reminder themselves after restoring the + * normal terminal buffer. + */ +export function withV0ProjectNotice(io: AppIO): Middleware { + return (h) => ({ + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + doesSupportTui: () => h.doesSupportTui(), + children: () => h.children(), + handle: async (ctx, flags, args) => { + const rootPath = enclosingProjectRoot(process.cwd()); + if (!rootPath || !(await isV0Project(rootPath))) { + await h.handle(ctx, flags, args); + return; + } + + const state = { enteredTui: false }; + const nextCtx = ctx + .withValue(V0ProjectDetectedKey, true) + .withValue(V0ProjectNoticeStateKey, state); + try { + await h.handle(nextCtx, flags, args); + } finally { + if (!state.enteredTui && h.name() !== "migrate") { + io.stderr.write(`${V0_PROJECT_MIGRATION_MESSAGE}\n`); + } + } + }, + }); +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 254a80c22b..95cd342a96 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -14,7 +14,13 @@ import { import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; -import { ProjectDetectedKey } from "../handlers/project/context"; +import { + ProjectDetectedKey, + V0ProjectDetectedKey, + V0ProjectNoticeStateKey, +} from "../handlers/project/context"; +import { V0_PROJECT_MIGRATION_MESSAGE } from "../handlers/project/v0Notice"; +import { enclosingProjectRoot, isV0Project } from "../core/project/fsUtils"; import { ExitCode, InvalidEnvironmentError } from "../errors"; import { TuiExitMessageKey } from "./exitMessage"; import { TuiExitErrorKey } from "./exitError"; @@ -69,8 +75,15 @@ export async function renderTuiAt( let exitError: Error | undefined; let handoff: string[] | undefined; let tuiContext = ctx; + let v0Project = ctx.value(V0ProjectDetectedKey); + if (v0Project === undefined) { + const rootPath = enclosingProjectRoot(process.cwd()); + v0Project = rootPath !== undefined && (await isV0Project(rootPath)); + } + tuiContext = tuiContext.withValue(V0ProjectDetectedKey, v0Project); if (isRootMenuPath(path)) { const projectDetected = + v0Project || ctx.value(ProjectKey) !== undefined || (await core.projectManager.resolve({ filePath: process.cwd(), @@ -92,6 +105,9 @@ export async function renderTuiAt( // takes over the screen and the prior scrollback is restored on exit (like Vim). const queryClient = new QueryClient(); + const noticeState = ctx.value(V0ProjectNoticeStateKey); + if (noticeState) noticeState.enteredTui = true; + const resizeGate = createResizeGate(io.stdout); const { waitUntilExit } = render( , @@ -108,6 +124,9 @@ export async function renderTuiAt( await waitUntilExit(); } finally { resizeGate.dispose(); + if (v0Project) { + io.stderr.write(`${V0_PROJECT_MIGRATION_MESSAGE}\n`); + } } // Ink handles Ctrl+C before invoking screen input handlers. if (exitError !== undefined) throw exitError; diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 7cd2e9b4ad..2ee977fac6 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,4 +1,6 @@ import { test, expect, describe } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { Command } from "commander"; import { createRootHandler } from "../handlers"; import { ExitCode, InputValidationError, InvalidEnvironmentError } from "../errors"; @@ -14,7 +16,9 @@ import { ttyTestIO, tick, waitFor, + inTempDirectory, } from "../testing"; +import { V0_PROJECT_MIGRATION_MESSAGE, V0_PROJECT_TUI_BANNER } from "../handlers/project/v0Notice"; describe("renderJson", () => { test("pretty-prints a value as indented JSON to the given writer", () => { @@ -235,6 +239,31 @@ describe("TUI launch", () => { const CREATE_ROW = "create a new project"; const BANNER = "No project detected - create a new project to get started"; + test("a v0 project shows compatibility mode and repeats migration guidance after exit", async () => { + const { path, cleanup } = await inTempDirectory("agentcore-v0-tui-"); + try { + await mkdir(join(path, "agentcore")); + await writeFile(join(path, "agentcore", "agentcore.json"), '{"name":"Legacy","version":1}\n'); + const core = new TestCoreClient(); + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + const routePromise = root.route(["node", "agentcore"]); + await waitFor(() => streams.stdout().includes(V0_PROJECT_TUI_BANNER)); + stdin.write(String.fromCharCode(3)); + await routePromise; + + expect(streams.stderr()).toContain(V0_PROJECT_MIGRATION_MESSAGE); + expect(streams.stderr().match(new RegExp(V0_PROJECT_MIGRATION_MESSAGE, "g"))).toHaveLength(1); + } finally { + await cleanup(); + } + }); + test("an unrelated TUI command does not resolve the current project", async () => { const core = new TestCoreClient(); let resolveCalls = 0;