From 65deac9c6a77c1c5e90a5991a5ba1220c1807a0b Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 11 Aug 2026 07:16:28 -0400 Subject: [PATCH 1/3] feat(project): implement project build `agentcore project build` compiles the project's CDK app and synthesizes its CloudFormation templates, so the deployable artifacts exist before deploy. Synthesis runs offline: each stack's environment comes from aws-targets.json, so no credentials are needed. An empty targets file makes the CDK app fail with its own actionable message rather than the CLI guessing an account. The generated package.json defines `cdk` as "npm run build && cdk", so one `npm run cdk -- synth --quiet` covers both compile and synthesis. Also puts withProject to work for the first time: it resolves the enclosing project and hands it to the handler through ProjectKey, and it wraps only build so that `create` (which refuses to nest inside a project) stays unaffected. Its cwd is now resolved per invocation instead of at wiring time, so the directory the user actually ran in is the one searched. --- src/core/project/manager.test.ts | 71 ++++++++++++++++++++++++++++ src/core/project/manager.tsx | 20 ++++++++ src/handlers/project/build/index.ts | 25 ++++++++-- src/handlers/project/index.ts | 9 +++- src/handlers/project/project.test.ts | 55 ++++++++++++++++++++- src/handlers/project/types.ts | 3 ++ src/middleware/index.tsx | 1 + src/middleware/withProject.test.ts | 13 ++++- src/middleware/withProject.tsx | 22 ++++++--- 9 files changed, 203 insertions(+), 16 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 8da0f28e5..27a6c0dcd 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -217,6 +217,77 @@ describe("FsProjectManager.create", () => { }); }); +describe("FsProjectManager.build", () => { + // build() requires the CDK app's node_modules; create() with skipInstall + // never produces them, so tests stub the directory in. + async function scaffolded( + subject: FsProjectManager, + directory: string, + withDependencies = true, + ): Promise { + const { project } = await runCreate(subject, { + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + if (withDependencies) { + await mkdir(join(directory, "example", "agentcore", "cdk", "node_modules"), { + recursive: true, + }); + } + return project; + } + + async function drain(generator: AsyncGenerator): Promise { + const events: ProjectEvent[] = []; + for await (const event of generator) events.push(event); + return events; + } + + test("compiles and synthesizes via the generated cdk script", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + const project = await scaffolded(subject, directory); + commands.length = 0; // discard create()'s commands + + const events = await drain(subject.build(project)); + + expect(commands).toEqual([ + { + command: ["npm", "run", "cdk", "--", "synth", "--quiet"], + cwd: join(directory, "example", "agentcore", "cdk"), + }, + ]); + expect(events).toEqual([{ message: "Synthesizing CloudFormation templates" }]); + }); + + test("fails actionably when the CDK dependencies are missing", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + const project = await scaffolded(subject, directory, false); + commands.length = 0; + + await expect(drain(subject.build(project))).rejects.toThrow(/npm install/); + expect(commands).toEqual([]); + }); + + test("propagates a synthesis failure", async () => { + const directory = await inTempDirectory(); + const { manager: subject } = manager(); + const project = await scaffolded(subject, directory); + const failing = new FsProjectManager({ + logger: createSilentLogger(), + runner: async () => { + throw new Error("cdk synth exploded"); + }, + checkTool: async () => {}, + }); + + await expect(drain(failing.build(project))).rejects.toThrow("cdk synth exploded"); + }); +}); + describe("FsProjectManager.resolve", () => { test("round-trips a project it just created", async () => { const root = await inTempDirectory(); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index eeaea3ca0..c385fce6a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -118,6 +118,26 @@ export class FsProjectManager implements ProjectManager { return project; } + public async *build(project: Project): AsyncGenerator { + const cdkDir = join(project.rootPath, "agentcore", "cdk"); + + // The generated CDK app is built from its own node_modules; without them the + // failure would otherwise surface as an opaque "cdk: not found". + if (!existsSync(join(cdkDir, "node_modules"))) { + throw new ProjectStateError( + `CDK dependencies are missing for project '${project.name}'. ` + + `Run 'cd ${cdkDir} && npm install'.`, + ); + } + await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + + // The generated package.json defines `cdk` as "npm run build && cdk", so this + // single command compiles the app and then synthesizes it. Synthesis needs no + // AWS credentials: each stack's environment comes from aws-targets.json. + yield { message: "Synthesizing CloudFormation templates" }; + await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir); + } + // Runs a command with its output streamed to the file logger. private run(command: string[], cwd: string): Promise { return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts index 8e92791e5..8c41c21ec 100644 --- a/src/handlers/project/build/index.ts +++ b/src/handlers/project/build/index.ts @@ -1,11 +1,26 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import { createHandler, ProjectKey } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { ProjectManager } from "../types"; -export const createBuildProjectHandler = () => +type BuildProjectHandlerConfig = { + projectManager: ProjectManager; + io: AppIO; +}; + +export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) => createHandler({ name: "build", description: "build the project's deployable artifacts", - handle: async () => { - throw new NotImplementedError("agentcore project build is not implemented yet"); + handle: async (ctx) => { + // withProject has already resolved the enclosing project. + const project = ctx.require(ProjectKey); + + // Progress goes to stderr, keeping stdout for machine output. Subprocess + // output goes to the debug log; on failure ProcessFailedError carries it. + for await (const event of config.projectManager.build(project)) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`Built project '${project.name}'\n`); }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 6c681a32d..5b3e1a99b 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,5 @@ import { Router } from "../../router"; +import { withProject } from "../../middleware"; import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; @@ -25,7 +26,13 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { project.handler(createDevProjectHandler()); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); - project.handler(createBuildProjectHandler()); + // withProject wraps only the commands that require an existing project, so + // `create` (which refuses to nest inside one) stays unaffected. + project.handler( + withProject({ projectManager: config.projectManager })( + createBuildProjectHandler({ projectManager: config.projectManager, io: config.io }), + ), + ); return project; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8ca6f537a..922d8ca2f 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,5 +1,5 @@ import { afterEach, test, expect, describe } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; @@ -22,7 +22,7 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { +describe.each(["add", "remove", "dev", "deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); @@ -97,3 +97,54 @@ describe("project create", () => { await expect(run(["create", "--name", "MyAgent", "--template", "nonsense"])).rejects.toThrow(); }); }); + +describe("project build", () => { + // Scaffolds a project, then runs from inside it so withProject resolves it. + async function inProject(): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]); + + const projectRoot = join(directory, "MyAgent"); + // create --skip-install leaves no node_modules, which build requires. + await mkdir(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); + process.chdir(projectRoot); + return projectRoot; + } + + test("synthesizes the CDK app of the enclosing project", async () => { + const projectRoot = await inProject(); + const { io, core } = await run(["build"]); + + expect(core.projectCommands).toEqual([ + { + command: ["npm", "run", "cdk", "--", "synth", "--quiet"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, + ]); + expect(io.stderr()).toContain("Synthesizing CloudFormation templates"); + expect(io.stderr()).toContain("Built project 'MyAgent'"); + }); + + test("resolves the project from a nested directory", async () => { + const projectRoot = await inProject(); + process.chdir(join(projectRoot, "app", "hello-world")); + + const { core } = await run(["build"]); + + expect(core.projectCommands.map(({ cwd }) => cwd)).toEqual([ + join(projectRoot, "agentcore", "cdk"), + ]); + }); + + test("fails with actionable guidance outside a project", async () => { + await inTempDirectory(); + await expect(run(["build"])).rejects.toThrow(/No AgentCore project found/); + }); + + test("fails when the CDK dependencies have not been installed", async () => { + const projectRoot = await inProject(); + await rm(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); + + await expect(run(["build"])).rejects.toThrow(/npm install/); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 4b0478998..0b4cd4914 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -44,6 +44,9 @@ export interface ProjectManager { /** Scaffold a new AgentCore project from the given template. */ create(input: CreateProjectInput): AsyncGenerator; + /** Compile the project's CDK app and synthesize its CloudFormation templates. */ + build(project: Project): AsyncGenerator; + /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; } diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e77355898..4838ad322 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,3 +3,4 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withProject } from "./withProject"; diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index 3d904ebf3..c167e2cbc 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -18,6 +18,17 @@ describe("withProject", () => { }), ); - await expect(app.route(["node", "app", "check"])).rejects.toThrow(/no AgentCore project found/); + await expect(app.route(["node", "app", "check"])).rejects.toThrow(/No AgentCore project found/); + }); + + test("defaults to the cwd at invocation time when none is configured", async () => { + const projectManager = new FsProjectManager({ logger: createSilentLogger() }); + + const app = new Router("app", "test"); + app.use(withProject({ projectManager })); + app.handler(createHandler({ name: "check", description: "noop", handle: async () => {} })); + + // tmpdir encloses no project, so the message must name the cwd it searched. + await expect(app.route(["node", "app", "check"])).rejects.toThrow(process.cwd()); }); }); diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index 3a59d1e36..95d1e1b6b 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -1,18 +1,19 @@ import type { Project, ProjectManager } from "../handlers/project/types"; -import { InputValidationError } from "../errors/errors"; +import { ProjectStateError } from "../errors/errors"; import { ProjectKey, type Middleware } from "../router"; interface WithProjectConfig { projectManager: ProjectManager; - cwd: string; + /** Directory to search upwards from. Defaults to the cwd at invocation time. */ + cwd?: string; } /** - * Middleware that locates the AgentCore project from the configured working - * directory and pins it on the context under {@link ProjectKey}. + * Middleware that locates the AgentCore project enclosing the working directory + * and pins it on the context under {@link ProjectKey}. * Throws if no project can be found. * - * @param config - Contains the {@link ProjectManager} and the `cwd` to search from. + * @param config - Contains the {@link ProjectManager} and an optional `cwd` to search from. */ export function withProject(config: WithProjectConfig): Middleware { return (h) => ({ @@ -23,9 +24,16 @@ export function withProject(config: WithProjectConfig): Middleware { doesSupportTui: () => h.doesSupportTui(), children: () => h.children(), handle: async (ctx, flags, args) => { - const project = await config.projectManager.resolve({ filePath: config.cwd }); + // Resolved per invocation rather than at wiring time so the cwd the user + // actually ran in is the one searched. + const from = config.cwd ?? process.cwd(); + const project = await config.projectManager.resolve({ filePath: from }); if (!project) { - throw new InputValidationError(`no AgentCore project found at ${config.cwd}`); + throw new ProjectStateError( + `No AgentCore project found at ${from} or any parent directory ` + + `(looked for agentcore/agentcore.json). ` + + `Run 'agentcore project create' to scaffold one.`, + ); } await h.handle(ctx.withValue(ProjectKey, project), flags, args); }, From f2b5390389f9aee3496a721638e79325d14c6bca Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 11 Aug 2026 07:19:01 -0400 Subject: [PATCH 2/3] fix(project): set runtimeVersion on the CodeZip template The CDK construct library rejects a CodeZip runtime that declares no runtimeVersion ("runtimeVersion is required for CodeZip builds"), and it is the field that selects the packager. Without it, synthesizing a freshly created python project fails on its own scaffolded config. Container builds take their version from the image, so the container template is unaffected. --- src/core/project/manager.test.ts | 1 + src/core/project/templates.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 27a6c0dcd..f875d0614 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -97,6 +97,7 @@ describe("FsProjectManager.create", () => { build: "CodeZip", entrypoint: "main.py", codeLocation: "app/hello-world", + runtimeVersion: "PYTHON_3_14", }, ]); expect(await Bun.file(join(configDir, "aws-targets.json")).json()).toEqual([]); diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index dcd3adb2f..4d7aa5150 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -32,6 +32,10 @@ export const TEMPLATES: Record = { build: "CodeZip", entrypoint: "main.py", codeLocation: "app/hello-world", + // Required for CodeZip builds: the CDK construct library rejects a + // CodeZip runtime with no runtimeVersion, and it is what selects the + // packager. Container builds take their version from the image. + runtimeVersion: "PYTHON_3_14", }, ], }, From 05c244eed719d0833c8377648bf7f745a1ef527e Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 12 Aug 2026 11:07:44 -0400 Subject: [PATCH 3/3] feat(project): dispatch build on the project's managedBy backend agentcore.json already records `managedBy` (CDK is the only value today), but nothing read it: build() hardcoded the CDK path, so adding a terraform or no-IaC backend later would have meant editing that path instead of adding alongside it. Carry managedBy on Project and switch on it in build(), delegating the CDK work to a private buildWithCdk(). The default arm assigns to `never`, so a new ManagedBySchema member fails to compile until it has an arm here. --- src/core/project/manager.test.ts | 13 +++++++++++++ src/core/project/manager.tsx | 26 +++++++++++++++++++++++++- src/handlers/project/types.ts | 3 +++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index f875d0614..a355dc4b4 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -273,6 +273,18 @@ describe("FsProjectManager.build", () => { expect(commands).toEqual([]); }); + test("refuses a project managed by a backend it cannot build", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + const project = await scaffolded(subject, directory); + commands.length = 0; + + // CDK is the only backend today; the cast stands in for a future one. + const foreign = { ...project, managedBy: "Terraform" as Project["managedBy"] }; + await expect(drain(subject.build(foreign))).rejects.toThrow(/unsupported backend: Terraform/); + expect(commands).toEqual([]); + }); + test("propagates a synthesis failure", async () => { const directory = await inTempDirectory(); const { manager: subject } = manager(); @@ -300,6 +312,7 @@ describe("FsProjectManager.resolve", () => { expect(resolved?.name).toBe("example"); expect(resolved?.rootPath).toBe(join(root, "example")); + expect(resolved?.managedBy).toBe("CDK"); expect(resolved?.runtimes).toHaveLength(1); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c385fce6a..c23f4d960 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -54,7 +54,12 @@ export class FsProjectManager implements ProjectManager { const configPath = join(rootPath, "agentcore", "agentcore.json"); try { const spec = await this.json.read(configPath, ProjectSpecSchema); - return { name: spec.name, rootPath, runtimes: spec.runtimes }; + return { + name: spec.name, + rootPath, + managedBy: spec.managedBy, + runtimes: spec.runtimes, + }; } catch (error) { // A malformed agentcore.json is a user-correctable problem, not a crash. if (error instanceof DeserializationError) { @@ -119,6 +124,25 @@ export class FsProjectManager implements ProjectManager { } public async *build(project: Project): AsyncGenerator { + // agentcore.json records which backend owns the project's artifacts. CDK is the + // only one today; a terraform or no-IaC backend adds an arm here rather than + // editing the CDK path. + switch (project.managedBy) { + case "CDK": + yield* this.buildWithCdk(project); + break; + default: { + // Exhaustiveness: a new ManagedBy member fails to compile until it is handled. + const unsupported: never = project.managedBy; + throw new ProjectStateError( + `project '${project.name}' declares an unsupported backend: ${String(unsupported)}`, + ); + } + } + } + + // Compiles the generated CDK app and synthesizes its CloudFormation templates. + private async *buildWithCdk(project: Project): AsyncGenerator { const cdkDir = join(project.rootPath, "agentcore", "cdk"); // The generated CDK app is built from its own node_modules; without them the diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 0b4cd4914..cc9066e25 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,3 +1,4 @@ +import type { ManagedBy } from "../../projectSchemas/project"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; /** Available project templates for scaffolding new AgentCore projects. */ @@ -33,6 +34,8 @@ export type Project = { name: string; /** Absolute path to the project root (the parent of agentcore/). */ rootPath: string; + /** The infrastructure backend that owns the project's deployable artifacts. */ + managedBy: ManagedBy; /** The runtimes registered in agentcore.json. */ runtimes: ProjectRuntime[]; };