From f28ebd0ddf0472b6fdaa3a5c49d5ed86f24d0dcc Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 12 Oct 2026 15:19:21 -0400 Subject: [PATCH] feat(project): block add/remove command usage for v0 project on v1 CLI --- src/core/project/fsUtils.ts | 14 ++++ src/core/project/manager.tsx | 12 ++-- src/handlers/project/add/index.ts | 14 ++-- src/handlers/project/index.ts | 4 +- src/middleware/index.tsx | 1 + src/middleware/withV0ProjectGuard.test.ts | 81 +++++++++++++++++++++++ src/middleware/withV0ProjectGuard.ts | 27 ++++++++ 7 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 src/middleware/withV0ProjectGuard.test.ts create mode 100644 src/middleware/withV0ProjectGuard.ts diff --git a/src/core/project/fsUtils.ts b/src/core/project/fsUtils.ts index 5f17f9f5d5..46dc6ff95b 100644 --- a/src/core/project/fsUtils.ts +++ b/src/core/project/fsUtils.ts @@ -1,5 +1,7 @@ import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { z } from "zod"; /** The project spec, relative to the project root. */ export const PROJECT_SPEC_RELATIVE_PATH = join("agentcore", "agentcore.json"); @@ -21,6 +23,18 @@ export function enclosingProjectRoot(directory: string): string | undefined { } } +/** Checks the project header without requiring the v1 project schema to parse. */ +export async function isV0Project(rootPath: string): Promise { + try { + const contents = await readFile(projectSpecPath(rootPath), "utf8"); + const header = z.object({ version: z.unknown().optional() }).safeParse(JSON.parse(contents)); + return header.success && header.data.version === 1; + } catch { + // Leave unreadable or malformed specs to normal project validation. + return false; + } +} + export function toPythonPackageName(name: string): string { return name .replace(/[^a-zA-Z0-9._-]/g, "-") diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 68b2db702c..600a1fab37 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -72,7 +72,12 @@ import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config" import { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; import { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; import { RuntimeEndpointSchema } from "../../projectSchemas/runtime"; -import { defaultExportProjectName, enclosingProjectRoot, projectSpecPath } from "./fsUtils"; +import { + defaultExportProjectName, + enclosingProjectRoot, + isV0Project, + projectSpecPath, +} from "./fsUtils"; import { parseArn } from "../arn"; import { AgentCoreCLIError, @@ -417,10 +422,7 @@ export class FsProjectManager implements ProjectManager { } private async v0DependencyWarning(rootPath: string): Promise { - const header = await this.json - .read(projectSpecPath(rootPath), z.object({ version: z.unknown().optional() })) - .catch(() => undefined); - if (header?.version !== 1) return undefined; + if (!(await isV0Project(rootPath))) return undefined; const manifest = await this.json .read( diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 15eb2a758a..45ff781110 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,4 +1,4 @@ -import { withProject, withTuiWhenInteractive } from "../../../middleware/"; +import { withProject, withTuiWhenInteractive, withV0ProjectGuard } from "../../../middleware/"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; @@ -44,12 +44,14 @@ export function createAddProjectResourceHandler( "credentials", ); projectAdd.default(renderTui(core, config.io)); - // withProject first, so it is the outermost wrapper: a resource added outside - // a project gets the CLI's own not-found guidance, and the resolved project - // seeds the wizard through ProjectKey. withTuiWhenInteractive then opens that - // wizard for a bare `add ` on a TTY; it is inert for a resource - // declared command-line only above, and for flags, --json and non-TTY runs. + // withV0ProjectGuard first to validate project version, then withProject. + // A resource added outside a project gets the CLI's own not-found guidance, + // and the resolved project seeds the wizard through ProjectKey. + // withTuiWhenInteractive then opens that wizard for a bare `add ` + // on a TTY; it is inert for a resource declared command-line only above, + // and for flags, --json and non-TTY runs. projectAdd.use( + withV0ProjectGuard(), withProject({ projectManager: config.projectManager, cwd: process.cwd() }), withTuiWhenInteractive(core, config.io), ); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 524b66df83..aa5abc71aa 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -11,7 +11,7 @@ import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; import { InspectorAssets } from "../../core/dev/inspectorAssets"; import { startOtelCollector } from "../../core/dev/otel/collector"; -import { withProject, withTuiWhenInteractive } from "../../middleware"; +import { withProject, withTuiWhenInteractive, withV0ProjectGuard } from "../../middleware"; import type { Core } from "../types"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; @@ -79,7 +79,7 @@ export function createProjectHandlers(core: Core, io: AppIO): Handler[] { createRemoveProjectHandler({ projectManager, io, - middlewares: [withProjectMiddleware, withTuiWhenInteractive(core, io)], + middlewares: [withV0ProjectGuard(), withProjectMiddleware, withTuiWhenInteractive(core, io)], }), createExportProjectResourceHandler({ projectManager, core, io }), createBuildProjectHandler({ diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index 90d47c98c6..b6ea294aa3 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -5,3 +5,4 @@ export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; export { withPlatform } from "./withPlatform"; export { withProject } from "./withProject"; +export { withV0ProjectGuard } from "./withV0ProjectGuard"; diff --git a/src/middleware/withV0ProjectGuard.test.ts b/src/middleware/withV0ProjectGuard.test.ts new file mode 100644 index 0000000000..cabfab7240 --- /dev/null +++ b/src/middleware/withV0ProjectGuard.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DeserializationError, InputValidationError, ProjectStateError } from "../errors"; +import { createRootHandler } from "../handlers"; +import { + createSilentLogger, + inTempDirectory, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../testing"; +import { V0_PROJECT_ADD_REMOVE_MESSAGE } from "./withV0ProjectGuard"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function projectWithSpec(spec: string): Promise<{ root: string; specPath: string }> { + const { path, cleanup } = await inTempDirectory("agentcore-v0-guard-"); + cleanups.push(cleanup); + const specPath = join(path, "agentcore", "agentcore.json"); + await mkdir(join(path, "agentcore")); + await writeFile(specPath, spec); + return { root: path, specPath }; +} + +function run(args: string[]): Promise { + const root = createRootHandler(new TestCoreClient(), { + io: testIO().io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + return root.route(["node", "agentcore", ...args]); +} + +describe("v0 project add/remove guard", () => { + test.each([ + { label: "add runtime", args: ["add", "runtime", "--name", "agent"] }, + { label: "bare add", args: ["add"] }, + { label: "remove runtime", args: ["remove", "runtime", "--name", "agent"] }, + { label: "remove all", args: ["remove", "all", "--yes"] }, + ])("blocks $label before changing a v0 project", async ({ args }) => { + const { root, specPath } = await projectWithSpec('{"name":"Legacy","version":1}\n'); + await mkdir(join(root, "app", "nested"), { recursive: true }); + process.chdir(join(root, "app", "nested")); + const before = await readFile(specPath, "utf8"); + + await expect(run([...args])).rejects.toThrow( + new ProjectStateError(V0_PROJECT_ADD_REMOVE_MESSAGE), + ); + expect(await readFile(specPath, "utf8")).toBe(before); + }); + + test("uses the add/remove error even when the old CDK dependency warning applies", async () => { + const { root } = await projectWithSpec('{"name":"Legacy","version":1}\n'); + const cdkPath = join(root, "agentcore", "cdk"); + await mkdir(cdkPath); + await writeFile( + join(cdkPath, "package.json"), + '{"dependencies":{"@aws/agentcore-cdk":"^0.1.0-alpha.19"}}', + ); + + await expect(run(["remove", "runtime", "--name", "agent"])).rejects.toThrow( + new ProjectStateError(V0_PROJECT_ADD_REMOVE_MESSAGE), + ); + }); + + test("allows v1 projects through to normal command validation", async () => { + await projectWithSpec('{"name":"Current","version":2}\n'); + + await expect(run(["remove"])).rejects.toBeInstanceOf(InputValidationError); + }); + + test("preserves normal validation for a malformed project file", async () => { + await projectWithSpec("{ not valid json"); + + await expect(run(["remove", "runtime", "--name", "agent"])).rejects.toBeInstanceOf( + DeserializationError, + ); + }); +}); diff --git a/src/middleware/withV0ProjectGuard.ts b/src/middleware/withV0ProjectGuard.ts new file mode 100644 index 0000000000..bf40035b9c --- /dev/null +++ b/src/middleware/withV0ProjectGuard.ts @@ -0,0 +1,27 @@ +import { enclosingProjectRoot, isV0Project } from "../core/project/fsUtils"; +import { ProjectStateError } from "../errors"; +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."; + +/** Blocks project add/remove before withProject attempts to parse the v1 schema. */ +export function withV0ProjectGuard(): 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))) { + throw new ProjectStateError(V0_PROJECT_ADD_REMOVE_MESSAGE); + } + await h.handle(ctx, flags, args); + }, + }); +}