From 0212015eb9a710999f361184c93670364783cd84 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 5 Aug 2026 14:01:41 -0400 Subject: [PATCH 1/2] feat(project): add build command --- .../__snapshots__/manager.test.ts.snap | 1 + src/core/project/backend.ts | 20 ++ src/core/project/cdk.ts | 113 ++++++++++ src/core/project/compose.ts | 1 + src/core/project/fingerprint.ts | 89 ++++++++ src/core/project/manager.test.ts | 201 +++++++++++++++++- src/core/project/manager.tsx | 131 +++++++++++- src/core/project/schemas.ts | 78 +++++++ src/core/project/templates.ts | 1 + src/handlers/project/build/index.ts | 19 +- src/handlers/project/index.ts | 4 +- src/handlers/project/project.test.ts | 50 ++++- src/handlers/project/types.ts | 44 ++++ src/middleware/withProject.test.ts | 6 +- src/testing/TestCoreClient.tsx | 29 +++ 15 files changed, 772 insertions(+), 15 deletions(-) create mode 100644 src/core/project/backend.ts create mode 100644 src/core/project/cdk.ts create mode 100644 src/core/project/fingerprint.ts create mode 100644 src/core/project/schemas.ts diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 2b1f68db1..506948616 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -2,6 +2,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh directory 1`] = ` [ + "agentcore/.gitignore", "agentcore/agentcore.json", "agentcore/aws-targets.json", "agentcore/cdk/.gitignore", diff --git a/src/core/project/backend.ts b/src/core/project/backend.ts new file mode 100644 index 000000000..1ff3b686f --- /dev/null +++ b/src/core/project/backend.ts @@ -0,0 +1,20 @@ +import type { + BuildTarget, + DeploymentTarget, + Project, + ProjectProgressEvent, +} from "../../handlers/project/types"; + +export type BackendBuildResult = { + cloudAssemblyPath: string; + targets: BuildTarget[]; +}; + +export type ProjectBuildBackend = { + readonly name: string; + build( + project: Project, + targets: DeploymentTarget[], + onProgress?: (event: ProjectProgressEvent) => void, + ): Promise; +}; diff --git a/src/core/project/cdk.ts b/src/core/project/cdk.ts new file mode 100644 index 000000000..3dfb8380c --- /dev/null +++ b/src/core/project/cdk.ts @@ -0,0 +1,113 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { isAbsolute, join, relative } from "node:path"; +import { InputValidationError } from "../../errors"; +import type { ProcessRunner } from "../../io"; +import type { Logger } from "../../logging"; +import type { DeploymentTarget, Project, ProjectProgressEvent } from "../../handlers/project/types"; +import type { BackendBuildResult, ProjectBuildBackend } from "./backend"; +import { CloudAssemblyManifestSchema } from "./schemas"; + +type CdkBackendConfig = { + logger: Logger; + runner: ProcessRunner; + checkTool: (tool: string, installHint: string, probeArgs?: string[]) => Promise; +}; + +function stackName(projectName: string, targetName: string): string { + return `AgentCore-${projectName.replaceAll("_", "-")}-${targetName.replaceAll("_", "-")}`; +} + +export class CdkProjectBackend implements ProjectBuildBackend { + readonly name = "CDK"; + + constructor(private readonly config: CdkBackendConfig) {} + + async build( + project: Project, + targets: DeploymentTarget[], + onProgress?: (event: ProjectProgressEvent) => void, + ): Promise { + const cdkDirectory = join(project.configDir, "cdk"); + const packageJson = join(cdkDirectory, "package.json"); + if (!existsSync(packageJson)) { + throw new InputValidationError( + `CDK project not found at ${cdkDirectory}. Create or restore agentcore/cdk before building.`, + ); + } + + await this.config.checkTool("npm", "Install Node.js: https://nodejs.org/"); + await this.config.checkTool("node", "Install Node.js: https://nodejs.org/"); + + onProgress?.({ message: "Compiling CDK application..." }); + await this.run(["npm", "run", "build"], cdkDirectory); + + const assemblyDirectory = join(cdkDirectory, "cdk.out"); + await rm(assemblyDirectory, { recursive: true, force: true }); + await mkdir(assemblyDirectory, { recursive: true }); + + onProgress?.({ message: "Validating project and synthesizing deployment artifacts..." }); + await this.run( + [ + "node", + join("node_modules", "aws-cdk", "bin", "cdk"), + "synth", + "--output", + "cdk.out", + "--quiet", + ], + cdkDirectory, + ); + + const manifestPath = join(assemblyDirectory, "manifest.json"); + let manifest: unknown; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (error) { + throw new InputValidationError( + `CDK synthesis did not produce a readable cloud assembly at ${manifestPath}`, + { cause: error }, + ); + } + + const parsed = CloudAssemblyManifestSchema.safeParse(manifest); + if (!parsed.success) { + throw new InputValidationError(`Invalid CDK cloud assembly manifest at ${manifestPath}`, { + cause: parsed.error, + }); + } + + const synthesizedStacks = new Set( + Object.entries(parsed.data.artifacts) + .filter(([, artifact]) => artifact.type === "aws:cloudformation:stack") + .map(([artifactId, artifact]) => artifact.properties?.stackName ?? artifactId), + ); + const buildTargets = targets.map((target) => ({ + ...target, + stackName: stackName(project.name, target.name), + })); + + const missing = buildTargets.filter((target) => !synthesizedStacks.has(target.stackName)); + if (missing.length > 0) { + throw new InputValidationError( + `CDK synthesis did not produce stacks for target(s): ${missing.map((target) => target.name).join(", ")}`, + ); + } + + return { + cloudAssemblyPath: this.relativeToProject(project.root, assemblyDirectory), + targets: buildTargets, + }; + } + + private relativeToProject(projectRoot: string, path: string): string { + return isAbsolute(path) ? relative(projectRoot, path).replaceAll("\\", "/") : path; + } + + private run(command: string[], cwd: string): Promise { + return this.config.runner(command, { + cwd, + onOutput: (chunk) => this.config.logger.debug(chunk), + }); + } +} diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts index 06b3239c6..076680376 100644 --- a/src/core/project/compose.ts +++ b/src/core/project/compose.ts @@ -71,6 +71,7 @@ export async function projectTree( return dir(".", [ dir("agentcore", [ dir("cdk", await expandDir(src, "cdk")), + file(".gitignore", async () => ".build/\n.cache/\n.cli/\n"), file("agentcore.json", async () => json(agentcoreSpec(name, template))), file("aws-targets.json", async () => json([])), ]), diff --git a/src/core/project/fingerprint.ts b/src/core/project/fingerprint.ts new file mode 100644 index 000000000..85f416702 --- /dev/null +++ b/src/core/project/fingerprint.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, readFile, readlink } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { PACKAGE_VERSION } from "../../constants"; + +const EXCLUDED_DIRECTORY_NAMES = new Set([ + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", + "node_modules", + "venv", +]); + +function normalize(path: string): string { + return path.split(sep).join("/"); +} + +function excluded(relativePath: string, isDirectory: boolean): boolean { + const normalized = normalize(relativePath); + const segments = normalized.split("/"); + + if (isDirectory && segments.some((segment) => EXCLUDED_DIRECTORY_NAMES.has(segment))) { + return true; + } + + return ( + normalized === "agentcore/.build" || + normalized.startsWith("agentcore/.build/") || + normalized === "agentcore/.cache" || + normalized.startsWith("agentcore/.cache/") || + normalized === "agentcore/.cli" || + normalized.startsWith("agentcore/.cli/") || + normalized === "agentcore/cdk/cdk.out" || + normalized.startsWith("agentcore/cdk/cdk.out/") || + normalized === "agentcore/cdk/.cdk.staging" || + normalized.startsWith("agentcore/cdk/.cdk.staging/") || + normalized === "agentcore/cdk/dist" || + normalized.startsWith("agentcore/cdk/dist/") || + segments.some((segment) => segment === ".env" || segment.startsWith(".env.")) + ); +} + +async function projectFiles(root: string, directory = root): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const paths: string[] = []; + + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolutePath = join(directory, entry.name); + const relativePath = relative(root, absolutePath); + if (excluded(relativePath, entry.isDirectory())) continue; + + if (entry.isDirectory()) { + paths.push(...(await projectFiles(root, absolutePath))); + } else if (entry.isFile() || entry.isSymbolicLink()) { + paths.push(absolutePath); + } + } + + return paths; +} + +/** Hashes build inputs by path and content, excluding generated output and dependency directories. */ +export async function computeProjectFingerprint(root: string): Promise { + const hash = createHash("sha256"); + hash.update(`agentcore-cli:${PACKAGE_VERSION}\0`); + + for (const absolutePath of await projectFiles(root)) { + const relativePath = normalize(relative(root, absolutePath)); + const stats = await lstat(absolutePath); + hash.update(relativePath); + hash.update("\0"); + hash.update(String(stats.mode & 0o777)); + hash.update("\0"); + + if (stats.isSymbolicLink()) { + hash.update("link\0"); + hash.update(await readlink(absolutePath)); + } else { + hash.update("file\0"); + hash.update(await readFile(absolutePath)); + } + hash.update("\0"); + } + + return hash.digest("hex"); +} diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index eec07ca77..9654fb389 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { NestedProjectError, ProjectFileExistsError } from "../../errors"; @@ -34,13 +34,52 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; logger: createSilentLogger(), runner: async (command, { cwd }) => { commands.push({ command, cwd }); + if (command.includes("synth")) { + const configDir = join(cwd, ".."); + const project = await Bun.file(join(configDir, "agentcore.json")).json(); + const targets = (await Bun.file(join(configDir, "aws-targets.json")).json()) as { + name: string; + }[]; + const artifacts = Object.fromEntries( + targets.map((target) => { + const stackName = `AgentCore-${project.name}-${target.name}`; + return [ + stackName, + { + type: "aws:cloudformation:stack", + properties: { stackName }, + }, + ]; + }), + ); + const assembly = join(cwd, "cdk.out"); + await mkdir(assembly, { recursive: true }); + await writeFile( + join(assembly, "manifest.json"), + JSON.stringify({ version: "48.0.0", artifacts }), + ); + } }, checkTool: async () => {}, // CI hosts don't have uv installed + now: () => new Date("2026-08-05T12:00:00.000Z"), }), commands, }; } +async function configureTarget(projectRoot: string, name = "default"): Promise { + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([ + { + name, + account: "123456789012", + region: "us-east-1", + }, + ]), + ); +} + describe("FsProjectManager.create", () => { test("scaffolds the expected file tree into a fresh directory", async () => { const directory = await inTempDirectory(); @@ -76,6 +115,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([]); @@ -174,3 +214,162 @@ describe("FsProjectManager.create", () => { ).rejects.toBeInstanceOf(NestedProjectError); }); }); + +describe("FsProjectManager.resolve", () => { + test("finds the enclosing project from a nested path", async () => { + const directory = await inTempDirectory(); + const subject = manager().manager; + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + const projectRoot = join(directory, "example"); + await configureTarget(projectRoot); + const nested = join(projectRoot, "app", "hello-world"); + + const project = await subject.resolve({ filePath: nested }); + + expect(project).toEqual({ + name: "example", + root: projectRoot, + configDir: join(projectRoot, "agentcore"), + managedBy: "CDK", + targets: [ + { + name: "default", + account: "123456789012", + region: "us-east-1", + }, + ], + }); + }); + + test("returns undefined outside a project", async () => { + const directory = await inTempDirectory(); + expect(await manager().manager.resolve({ filePath: directory })).toBeUndefined(); + }); +}); + +describe("FsProjectManager.build", () => { + test("compiles, synthesizes every target, and writes a build manifest", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + const projectRoot = join(directory, "example"); + await configureTarget(projectRoot); + commands.length = 0; + + const messages: string[] = []; + const result = await subject.build({ + filePath: join(projectRoot, "app", "hello-world"), + onProgress: ({ message }) => messages.push(message), + }); + + const cdkDirectory = join(projectRoot, "agentcore", "cdk"); + expect(commands).toEqual([ + { command: ["npm", "run", "build"], cwd: cdkDirectory }, + { + command: [ + "node", + join("node_modules", "aws-cdk", "bin", "cdk"), + "synth", + "--output", + "cdk.out", + "--quiet", + ], + cwd: cdkDirectory, + }, + ]); + expect(result).toMatchObject({ + version: 1, + projectName: "example", + backend: "CDK", + builtAt: "2026-08-05T12:00:00.000Z", + cloudAssemblyPath: "agentcore/cdk/cdk.out", + manifestPath: "agentcore/.build/manifest.json", + targets: [ + { + name: "default", + account: "123456789012", + region: "us-east-1", + stackName: "AgentCore-example-default", + }, + ], + }); + expect(result.inputFingerprint).toMatch(/^[a-f0-9]{64}$/); + const { manifestPath: _manifestPath, ...manifest } = result; + expect( + await Bun.file(join(projectRoot, "agentcore", ".build", "manifest.json")).json(), + ).toEqual(manifest); + expect(messages).toEqual([ + "Compiling CDK application...", + "Validating project and synthesizing deployment artifacts...", + "Recording build manifest...", + ]); + }); + + test("changes the fingerprint when project source changes", async () => { + const directory = await inTempDirectory(); + const subject = manager().manager; + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + const projectRoot = join(directory, "example"); + await configureTarget(projectRoot); + + const first = await subject.build({ filePath: projectRoot }); + await writeFile(join(projectRoot, "app", "hello-world", "new.py"), "print('changed')\n"); + const second = await subject.build({ filePath: projectRoot }); + + expect(second.inputFingerprint).not.toBe(first.inputFingerprint); + }); + + test("rejects a project without deployment targets before spawning a build", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + commands.length = 0; + + await expect(subject.build({ filePath: join(directory, "example") })).rejects.toThrow( + /No deployment targets configured/, + ); + expect(commands).toEqual([]); + }); + + test("rejects an unsupported backend before spawning a build", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + const projectRoot = join(directory, "example"); + await configureTarget(projectRoot); + const specPath = join(projectRoot, "agentcore", "agentcore.json"); + const spec = await Bun.file(specPath).json(); + await writeFile(specPath, JSON.stringify({ ...spec, managedBy: "Terraform" })); + commands.length = 0; + + await expect(subject.build({ filePath: projectRoot })).rejects.toThrow( + /backend "Terraform" is not supported/, + ); + expect(commands).toEqual([]); + }); +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index a834ea106..b10eb336e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,18 +1,27 @@ import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { NestedProjectError } from "../../errors"; +import { mkdir, readFile, stat } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { InputValidationError, NestedProjectError } from "../../errors"; import type { + BuildManifest, + BuildProjectInput, + BuildProjectResult, CreateProjectInput, ResolveProjectInput, Project, ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; -import { requireTool, runProcess, type ProcessRunner } from "../../io"; +import { atomicWrite, requireTool, runProcess, type ProcessRunner } from "../../io"; +import { PACKAGE_VERSION } from "../../constants"; import { projectTree } from "./compose"; import { defaultSource, type AssetSource } from "./source"; import { TEMPLATES } from "./templates"; import { writeTree } from "./tree"; +import { CdkProjectBackend } from "./cdk"; +import type { ProjectBuildBackend } from "./backend"; +import { computeProjectFingerprint } from "./fingerprint"; +import { DeploymentTargetsSchema, ProjectSpecEnvelopeSchema } from "./schemas"; /** Walks up from directory looking for the agentcore/agentcore.json project marker. */ function enclosingProjectRoot(directory: string): string | undefined { @@ -31,6 +40,7 @@ type ProjectManagerConfig = { source?: AssetSource; // Bun executable or dist/assets depending on runtime runner?: ProcessRunner; // injectable so tests never spawn real processes checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH + now?: () => Date; }; /** @@ -41,16 +51,29 @@ export class FsProjectManager implements ProjectManager { private readonly source: AssetSource; private readonly runner: ProcessRunner; private readonly checkTool: typeof requireTool; + private readonly now: () => Date; constructor(config: ProjectManagerConfig) { this.logger = config.logger; this.source = config.source ?? defaultSource(); this.runner = config.runner ?? runProcess; this.checkTool = config.checkTool ?? requireTool; + this.now = config.now ?? (() => new Date()); } - public resolve(_input: ResolveProjectInput): Promise { - throw new Error(`ProjectManager.resolve is not implemented yet`); + public async resolve(input: ResolveProjectInput): Promise { + const candidate = resolve(input.filePath); + let directory = candidate; + try { + if ((await stat(candidate)).isFile()) directory = dirname(candidate); + } catch { + // A non-existent path can still identify a directory beneath an enclosing project. + } + + const root = enclosingProjectRoot(directory); + if (!root) return undefined; + + return this.loadProject(root); } public async create(input: CreateProjectInput): Promise { @@ -90,11 +113,107 @@ export class FsProjectManager implements ProjectManager { await this.run(["git", "init"], destination); } - return { name: input.name }; + return this.loadProject(destination); + } + + public async build(input: BuildProjectInput): Promise { + const project = await this.resolve({ filePath: input.filePath }); + if (!project) { + throw new InputValidationError( + `No AgentCore project found from ${resolve(input.filePath)}. Run this command inside a project containing agentcore/agentcore.json.`, + ); + } + if (project.targets.length === 0) { + throw new InputValidationError( + `No deployment targets configured in ${join(project.configDir, "aws-targets.json")}. Add at least one target before building.`, + ); + } + + const backend = this.backend(project.managedBy); + this.logger.debug(`building project "${project.name}" with ${backend.name}`); + const backendResult = await backend.build(project, project.targets, input.onProgress); + + input.onProgress?.({ message: "Recording build manifest..." }); + const manifest: BuildManifest = { + version: 1, + projectName: project.name, + backend: backend.name, + cliVersion: PACKAGE_VERSION, + inputFingerprint: await computeProjectFingerprint(project.root), + builtAt: this.now().toISOString(), + cloudAssemblyPath: backendResult.cloudAssemblyPath, + targets: backendResult.targets, + }; + + const manifestPath = join(project.configDir, ".build", "manifest.json"); + await mkdir(dirname(manifestPath), { recursive: true }); + await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + return { + ...manifest, + manifestPath: relative(project.root, manifestPath).replaceAll("\\", "/"), + }; } // 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) }); } + + private backend(managedBy: string): ProjectBuildBackend { + if (managedBy !== "CDK") { + throw new InputValidationError( + `Project backend "${managedBy}" is not supported. Supported backends: CDK`, + ); + } + return new CdkProjectBackend({ + logger: this.logger, + runner: this.runner, + checkTool: this.checkTool, + }); + } + + private async loadProject(root: string): Promise { + const configDir = join(root, "agentcore"); + const spec = await this.readConfig( + join(configDir, "agentcore.json"), + ProjectSpecEnvelopeSchema, + ); + const targets = await this.readConfig( + join(configDir, "aws-targets.json"), + DeploymentTargetsSchema, + ); + + return { + name: spec.name, + root, + configDir, + managedBy: spec.managedBy, + targets, + }; + } + + private async readConfig( + path: string, + schema: { + safeParse(value: unknown): { success: true; data: T } | { success: false; error: Error }; + }, + ): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + throw new InputValidationError(`Unable to read project configuration at ${path}`, { + cause: error, + }); + } + + const result = schema.safeParse(value); + if (!result.success) { + throw new InputValidationError(`Invalid project configuration at ${path}: ${result.error}`, { + cause: result.error, + }); + } + return result.data; + } } diff --git a/src/core/project/schemas.ts b/src/core/project/schemas.ts new file mode 100644 index 000000000..cb79824a2 --- /dev/null +++ b/src/core/project/schemas.ts @@ -0,0 +1,78 @@ +import z from "zod"; +import { ProjectNameSchema } from "../../handlers/project/types"; + +export const ProjectSpecEnvelopeSchema = z + .object({ + name: ProjectNameSchema, + version: z.number().int().min(1), + managedBy: z.string().min(1).default("CDK"), + }) + .passthrough(); + +export const DeploymentTargetSchema = z + .object({ + name: z + .string() + .min(1) + .max(64) + .regex( + /^[a-zA-Z][a-zA-Z0-9-]*$/, + "must start with a letter and contain only letters, numbers, and hyphens", + ), + description: z.string().max(256).optional(), + account: z.string().regex(/^[0-9]{12}$/, "must be a 12-digit AWS account ID"), + region: z.string().min(1), + }) + .strict(); + +export const DeploymentTargetsSchema = z + .array(DeploymentTargetSchema) + .superRefine((targets, ctx) => { + const seen = new Set(); + targets.forEach((target, index) => { + if (seen.has(target.name)) { + ctx.addIssue({ + code: "custom", + message: `duplicate deployment target name: ${target.name}`, + path: [index, "name"], + }); + } + seen.add(target.name); + }); + }); + +export const BuildManifestSchema = z + .object({ + version: z.literal(1), + projectName: z.string().min(1), + backend: z.string().min(1), + cliVersion: z.string().min(1), + inputFingerprint: z.string().regex(/^[a-f0-9]{64}$/), + builtAt: z.iso.datetime(), + cloudAssemblyPath: z.string().min(1), + targets: z.array( + DeploymentTargetSchema.extend({ + stackName: z.string().min(1), + }), + ), + }) + .strict(); + +export const CloudAssemblyManifestSchema = z + .object({ + artifacts: z.record( + z.string(), + z + .object({ + type: z.string(), + properties: z + .object({ + stackName: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(), + ), + }) + .passthrough(); diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 558b0d531..2223e00f8 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -30,6 +30,7 @@ export const TEMPLATES: Record = { build: "CodeZip", entrypoint: "main.py", codeLocation: "app/hello-world", + runtimeVersion: "PYTHON_3_14", }, ], }, diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts index 8e92791e5..10690cee9 100644 --- a/src/handlers/project/build/index.ts +++ b/src/handlers/project/build/index.ts @@ -1,11 +1,22 @@ import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import type { AppIO } from "../../../io"; +import { JsonRendererKey } from "../../../tui"; +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) => { + const result = await config.projectManager.build({ + filePath: process.cwd(), + onProgress: (event) => config.io.stderr.write(`${event.message}\n`), + }); + ctx.require(JsonRendererKey).renderJson(result); }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 6c681a32d..77778bba5 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -25,7 +25,9 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { project.handler(createDevProjectHandler()); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); - project.handler(createBuildProjectHandler()); + project.handler( + 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 94b669535..cb19b1f87 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -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/); }); @@ -102,3 +102,51 @@ describe("project create", () => { ).rejects.toThrow(); }); }); + +describe("project build", () => { + test("builds from a nested directory and emits JSON plus progress", async () => { + const directory = await inTempDirectory(); + await run(["create", "--project-name", "MyAgent", "--skip-install", "--skip-git"]); + const projectRoot = join(directory, "MyAgent"); + await Bun.write( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([ + { + name: "default", + account: "123456789012", + region: "us-east-1", + }, + ]), + ); + process.chdir(join(projectRoot, "app", "hello-world")); + + const { io, core } = await run(["build"]); + const result = JSON.parse(io.stdout()); + + expect(result).toMatchObject({ + projectName: "MyAgent", + backend: "CDK", + cloudAssemblyPath: "agentcore/cdk/cdk.out", + manifestPath: "agentcore/.build/manifest.json", + }); + expect(core.projectCommands).toEqual([ + { + command: ["npm", "run", "build"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, + { + command: [ + "node", + join("node_modules", "aws-cdk", "bin", "cdk"), + "synth", + "--output", + "cdk.out", + "--quiet", + ], + cwd: join(projectRoot, "agentcore", "cdk"), + }, + ]); + expect(io.stderr()).toContain("Compiling CDK application..."); + expect(io.stderr()).toContain("Recording build manifest..."); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index f1d767303..4ed7390b3 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -107,8 +107,49 @@ export type ResolveProjectInput = { filePath: string; }; +export type DeploymentTarget = { + name: string; + description?: string; + account: string; + region: string; +}; + export type Project = { name: string; + root: string; + configDir: string; + managedBy: string; + targets: DeploymentTarget[]; +}; + +export type ProjectProgressEvent = { + message: string; +}; + +export type BuildProjectInput = { + /** A path to search from when locating the project root. */ + filePath: string; + /** Called as each build step begins. */ + onProgress?: (event: ProjectProgressEvent) => void; +}; + +export type BuildTarget = DeploymentTarget & { + stackName: string; +}; + +export type BuildManifest = { + version: 1; + projectName: string; + backend: string; + cliVersion: string; + inputFingerprint: string; + builtAt: string; + cloudAssemblyPath: string; + targets: BuildTarget[]; +}; + +export type BuildProjectResult = BuildManifest & { + manifestPath: string; }; /** @@ -120,4 +161,7 @@ export interface ProjectManager { /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; + + /** Validate and build all deployable artifacts for a project. */ + build(input: BuildProjectInput): Promise; } diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index 852e75bf3..1a68c1ecb 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -5,7 +5,7 @@ import { withProject } from "./withProject"; import { FsProjectManager } from "../core/project"; describe("withProject", () => { - test("throws not implemented", async () => { + test("throws when no project can be resolved", async () => { const projectManager = new FsProjectManager({ logger: createSilentLogger() }); const app = new Router("app", "test"); @@ -18,6 +18,8 @@ describe("withProject", () => { }), ); - await expect(app.route(["node", "app", "check"])).rejects.toThrow(/not implemented/); + await expect(app.route(["node", "app", "check"])).rejects.toThrow( + "Unable to find project at path /some/path", + ); }); }); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 7426ea9e1..a20f4ff50 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -93,6 +93,8 @@ import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; import { createSilentLogger } from "./logging"; import { FsProjectManager } from "../core/project"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; // TestCoreClient is a hand-controllable `Core` for tests. It implements the same // interface the real CoreClient satisfies, so it drops straight into @@ -1188,6 +1190,33 @@ export class TestCoreClient implements Core { logger: options?.logger ?? createSilentLogger(), runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); + if (command.includes("synth")) { + const configDir = dirname(cwd); + const project = JSON.parse(await readFile(join(configDir, "agentcore.json"), "utf8")) as { + name: string; + }; + const targets = JSON.parse( + await readFile(join(configDir, "aws-targets.json"), "utf8"), + ) as { name: string }[]; + const artifacts = Object.fromEntries( + targets.map((target) => { + const stackName = `AgentCore-${project.name.replaceAll("_", "-")}-${target.name.replaceAll("_", "-")}`; + return [ + stackName, + { + type: "aws:cloudformation:stack", + properties: { stackName }, + }, + ]; + }), + ); + const output = join(cwd, "cdk.out"); + await mkdir(output, { recursive: true }); + await writeFile( + join(output, "manifest.json"), + JSON.stringify({ version: "48.0.0", artifacts }), + ); + } }, checkTool: async () => {}, // CI hosts don't have uv installed }); From ae9a15fdcfacafed8d6b166af17e4666769415e9 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 5 Aug 2026 17:22:36 -0400 Subject: [PATCH 2/2] refactor(project): isolate build IO and artifacts --- src/assets/cdk/bin/cdk.ts | 9 +- src/assets/cdk/lib/names.ts | 3 + src/core/index.tsx | 7 ++ .../__snapshots__/manager.test.ts.snap | 1 + src/core/project/backend.ts | 5 +- src/core/project/cdk.ts | 48 ++++----- src/core/project/compose.ts | 2 +- src/core/project/fingerprint.ts | 33 +++--- src/core/project/manager.test.ts | 60 ++++++++++- src/core/project/manager.tsx | 68 ++++++------ src/core/project/names.test.ts | 8 ++ src/core/project/schemas.ts | 14 +-- src/core/project/source.ts | 83 -------------- src/core/project/tree.ts | 18 ++-- src/handlers/project/project.test.ts | 6 +- src/handlers/project/types.ts | 10 +- .../source.test.ts => io/assets.test.ts} | 4 +- src/io/assets.ts | 94 ++++++++++++++++ src/io/exec.ts | 2 + src/io/fileSystem.test.ts | 34 ++++++ src/io/fileSystem.ts | 101 ++++++++++++++++++ src/io/index.ts | 10 ++ src/middleware/withProject.test.ts | 11 +- src/testing/TestCoreClient.tsx | 8 +- 24 files changed, 449 insertions(+), 190 deletions(-) create mode 100644 src/assets/cdk/lib/names.ts create mode 100644 src/core/project/names.test.ts delete mode 100644 src/core/project/source.ts rename src/{core/project/source.test.ts => io/assets.test.ts} (73%) create mode 100644 src/io/assets.ts create mode 100644 src/io/fileSystem.test.ts create mode 100644 src/io/fileSystem.ts diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index c8450e3a9..b5ab386d6 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { toStackName } from '../lib/names'; import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; @@ -12,14 +13,6 @@ function toEnvironment(target: AwsDeploymentTarget): Environment { }; } -function sanitize(name: string): string { - return name.replace(/_/g, '-'); -} - -function toStackName(projectName: string, targetName: string): string { - return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; -} - // The vended CDK project compiles against the published @aws/agentcore-cdk schema // type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, harnesses, // gateway fields). This alias documents each read of those not-yet-published fields. diff --git a/src/assets/cdk/lib/names.ts b/src/assets/cdk/lib/names.ts new file mode 100644 index 000000000..8662b144a --- /dev/null +++ b/src/assets/cdk/lib/names.ts @@ -0,0 +1,3 @@ +export function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${projectName.replaceAll("_", "-")}-${targetName.replaceAll("_", "-")}`; +} diff --git a/src/core/index.tsx b/src/core/index.tsx index d3175502a..103e6ebbf 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -18,6 +18,7 @@ import type { import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; import { FsProjectManager } from "./project"; +import { defaultAssetSource, localFileSystem, requireTool, runProcess } from "../io"; export type { AwsClients, @@ -73,6 +74,12 @@ export class CoreClient implements AwsClients { this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), + source: defaultAssetSource(localFileSystem), + runner: runProcess, + checkTool: requireTool, + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), }); } diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 506948616..a4501db25 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -13,6 +13,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d "agentcore/cdk/cdk.json", "agentcore/cdk/jest.config.js", "agentcore/cdk/lib/cdk-stack.ts", + "agentcore/cdk/lib/names.ts", "agentcore/cdk/package.json", "agentcore/cdk/test/cdk.test.ts", "agentcore/cdk/tsconfig.json", diff --git a/src/core/project/backend.ts b/src/core/project/backend.ts index 1ff3b686f..fe3265b92 100644 --- a/src/core/project/backend.ts +++ b/src/core/project/backend.ts @@ -1,13 +1,12 @@ import type { - BuildTarget, + BuildArtifact, DeploymentTarget, Project, ProjectProgressEvent, } from "../../handlers/project/types"; export type BackendBuildResult = { - cloudAssemblyPath: string; - targets: BuildTarget[]; + artifact: BuildArtifact; }; export type ProjectBuildBackend = { diff --git a/src/core/project/cdk.ts b/src/core/project/cdk.ts index 3dfb8380c..a358cfe18 100644 --- a/src/core/project/cdk.ts +++ b/src/core/project/cdk.ts @@ -1,23 +1,19 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; -import { isAbsolute, join, relative } from "node:path"; -import { InputValidationError } from "../../errors"; -import type { ProcessRunner } from "../../io"; +import { join, relative } from "node:path"; +import { AgentCoreCLIError, InputValidationError } from "../../errors"; +import type { LocalFileSystem, ProcessRunner, ToolChecker } from "../../io"; import type { Logger } from "../../logging"; import type { DeploymentTarget, Project, ProjectProgressEvent } from "../../handlers/project/types"; import type { BackendBuildResult, ProjectBuildBackend } from "./backend"; import { CloudAssemblyManifestSchema } from "./schemas"; +import { toStackName } from "../../assets/cdk/lib/names"; type CdkBackendConfig = { logger: Logger; runner: ProcessRunner; - checkTool: (tool: string, installHint: string, probeArgs?: string[]) => Promise; + checkTool: ToolChecker; + fileSystem: LocalFileSystem; }; -function stackName(projectName: string, targetName: string): string { - return `AgentCore-${projectName.replaceAll("_", "-")}-${targetName.replaceAll("_", "-")}`; -} - export class CdkProjectBackend implements ProjectBuildBackend { readonly name = "CDK"; @@ -30,7 +26,7 @@ export class CdkProjectBackend implements ProjectBuildBackend { ): Promise { const cdkDirectory = join(project.configDir, "cdk"); const packageJson = join(cdkDirectory, "package.json"); - if (!existsSync(packageJson)) { + if (!(await this.config.fileSystem.exists(packageJson))) { throw new InputValidationError( `CDK project not found at ${cdkDirectory}. Create or restore agentcore/cdk before building.`, ); @@ -43,8 +39,8 @@ export class CdkProjectBackend implements ProjectBuildBackend { await this.run(["npm", "run", "build"], cdkDirectory); const assemblyDirectory = join(cdkDirectory, "cdk.out"); - await rm(assemblyDirectory, { recursive: true, force: true }); - await mkdir(assemblyDirectory, { recursive: true }); + await this.config.fileSystem.remove(assemblyDirectory); + await this.config.fileSystem.createDirectory(assemblyDirectory); onProgress?.({ message: "Validating project and synthesizing deployment artifacts..." }); await this.run( @@ -62,9 +58,9 @@ export class CdkProjectBackend implements ProjectBuildBackend { const manifestPath = join(assemblyDirectory, "manifest.json"); let manifest: unknown; try { - manifest = JSON.parse(await readFile(manifestPath, "utf8")); + manifest = JSON.parse(await this.config.fileSystem.readText(manifestPath)); } catch (error) { - throw new InputValidationError( + throw new AgentCoreCLIError( `CDK synthesis did not produce a readable cloud assembly at ${manifestPath}`, { cause: error }, ); @@ -72,7 +68,7 @@ export class CdkProjectBackend implements ProjectBuildBackend { const parsed = CloudAssemblyManifestSchema.safeParse(manifest); if (!parsed.success) { - throw new InputValidationError(`Invalid CDK cloud assembly manifest at ${manifestPath}`, { + throw new AgentCoreCLIError(`Invalid CDK cloud assembly manifest at ${manifestPath}`, { cause: parsed.error, }); } @@ -82,26 +78,28 @@ export class CdkProjectBackend implements ProjectBuildBackend { .filter(([, artifact]) => artifact.type === "aws:cloudformation:stack") .map(([artifactId, artifact]) => artifact.properties?.stackName ?? artifactId), ); - const buildTargets = targets.map((target) => ({ - ...target, - stackName: stackName(project.name, target.name), - })); + const stacks = Object.fromEntries( + targets.map((target) => [target.name, toStackName(project.name, target.name)]), + ); - const missing = buildTargets.filter((target) => !synthesizedStacks.has(target.stackName)); + const missing = targets.filter((target) => !synthesizedStacks.has(stacks[target.name]!)); if (missing.length > 0) { - throw new InputValidationError( + throw new AgentCoreCLIError( `CDK synthesis did not produce stacks for target(s): ${missing.map((target) => target.name).join(", ")}`, ); } return { - cloudAssemblyPath: this.relativeToProject(project.root, assemblyDirectory), - targets: buildTargets, + artifact: { + type: "cdk-cloud-assembly", + path: this.relativeToProject(project.root, assemblyDirectory), + stacks, + }, }; } private relativeToProject(projectRoot: string, path: string): string { - return isAbsolute(path) ? relative(projectRoot, path).replaceAll("\\", "/") : path; + return relative(projectRoot, path).replaceAll("\\", "/"); } private run(command: string[], cwd: string): Promise { diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts index 076680376..306dcfc46 100644 --- a/src/core/project/compose.ts +++ b/src/core/project/compose.ts @@ -1,6 +1,6 @@ import type { DirNode, ProjectNode } from "./tree"; import { dir, file } from "./tree"; -import type { AssetSource } from "./source"; +import type { AssetSource } from "../../io"; import { TEMPLATES } from "./templates"; import type { ProjectTemplate } from "../../handlers/project/types"; diff --git a/src/core/project/fingerprint.ts b/src/core/project/fingerprint.ts index 85f416702..6acbf48c4 100644 --- a/src/core/project/fingerprint.ts +++ b/src/core/project/fingerprint.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; -import { lstat, readdir, readFile, readlink } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { PACKAGE_VERSION } from "../../constants"; +import type { LocalFileSystem } from "../../io"; const EXCLUDED_DIRECTORY_NAMES = new Set([ ".git", @@ -43,18 +43,22 @@ function excluded(relativePath: string, isDirectory: boolean): boolean { ); } -async function projectFiles(root: string, directory = root): Promise { - const entries = await readdir(directory, { withFileTypes: true }); +async function projectFiles( + fileSystem: LocalFileSystem, + root: string, + directory = root, +): Promise { + const entries = await fileSystem.readDirectory(directory); const paths: string[] = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { const absolutePath = join(directory, entry.name); const relativePath = relative(root, absolutePath); - if (excluded(relativePath, entry.isDirectory())) continue; + if (excluded(relativePath, entry.kind === "directory")) continue; - if (entry.isDirectory()) { - paths.push(...(await projectFiles(root, absolutePath))); - } else if (entry.isFile() || entry.isSymbolicLink()) { + if (entry.kind === "directory") { + paths.push(...(await projectFiles(fileSystem, root, absolutePath))); + } else if (entry.kind === "file" || entry.kind === "symbolic-link") { paths.push(absolutePath); } } @@ -63,24 +67,27 @@ async function projectFiles(root: string, directory = root): Promise { } /** Hashes build inputs by path and content, excluding generated output and dependency directories. */ -export async function computeProjectFingerprint(root: string): Promise { +export async function computeProjectFingerprint( + fileSystem: LocalFileSystem, + root: string, +): Promise { const hash = createHash("sha256"); hash.update(`agentcore-cli:${PACKAGE_VERSION}\0`); - for (const absolutePath of await projectFiles(root)) { + for (const absolutePath of await projectFiles(fileSystem, root)) { const relativePath = normalize(relative(root, absolutePath)); - const stats = await lstat(absolutePath); + const stats = await fileSystem.lstat(absolutePath); hash.update(relativePath); hash.update("\0"); hash.update(String(stats.mode & 0o777)); hash.update("\0"); - if (stats.isSymbolicLink()) { + if (stats.kind === "symbolic-link") { hash.update("link\0"); - hash.update(await readlink(absolutePath)); + hash.update(await fileSystem.readLink(absolutePath)); } else { hash.update("file\0"); - hash.update(await readFile(absolutePath)); + hash.update(await fileSystem.readBytes(absolutePath)); } hash.update("\0"); } diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 9654fb389..a9cd55547 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -2,10 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { NestedProjectError, ProjectFileExistsError } from "../../errors"; +import { + AgentCoreCLIError, + InputValidationError, + NestedProjectError, + ProjectFileExistsError, +} from "../../errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; +import { defaultAssetSource, localFileSystem } from "../../io"; +import { toStackName } from "../../assets/cdk/lib/names"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -32,6 +39,7 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; return { manager: new FsProjectManager({ logger: createSilentLogger(), + source: defaultAssetSource(localFileSystem), runner: async (command, { cwd }) => { commands.push({ command, cwd }); if (command.includes("synth")) { @@ -42,7 +50,7 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; }[]; const artifacts = Object.fromEntries( targets.map((target) => { - const stackName = `AgentCore-${project.name}-${target.name}`; + const stackName = toStackName(project.name, target.name); return [ stackName, { @@ -61,6 +69,8 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; } }, checkTool: async () => {}, // CI hosts don't have uv installed + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), now: () => new Date("2026-08-05T12:00:00.000Z"), }), commands, @@ -187,10 +197,14 @@ describe("FsProjectManager.create", () => { const directory = await inTempDirectory(); const failing = new FsProjectManager({ logger: createSilentLogger(), + source: defaultAssetSource(localFileSystem), runner: async () => { throw new Error("npm exploded"); }, checkTool: async () => {}, + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), }); await expect( @@ -292,14 +306,19 @@ describe("FsProjectManager.build", () => { projectName: "example", backend: "CDK", builtAt: "2026-08-05T12:00:00.000Z", - cloudAssemblyPath: "agentcore/cdk/cdk.out", + artifact: { + type: "cdk-cloud-assembly", + path: "agentcore/cdk/cdk.out", + stacks: { + default: "AgentCore-example-default", + }, + }, manifestPath: "agentcore/.build/manifest.json", targets: [ { name: "default", account: "123456789012", region: "us-east-1", - stackName: "AgentCore-example-default", }, ], }); @@ -334,6 +353,39 @@ describe("FsProjectManager.build", () => { expect(second.inputFingerprint).not.toBe(first.inputFingerprint); }); + test("classifies malformed CDK output as an internal build failure", async () => { + const directory = await inTempDirectory(); + const subject = new FsProjectManager({ + logger: createSilentLogger(), + source: defaultAssetSource(localFileSystem), + runner: async (command, { cwd }) => { + if (command.includes("synth")) { + const assembly = join(cwd, "cdk.out"); + await mkdir(assembly, { recursive: true }); + await writeFile(join(assembly, "manifest.json"), "{}"); + } + }, + checkTool: async () => {}, + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), + }); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + const projectRoot = join(directory, "example"); + await configureTarget(projectRoot); + + const error = await subject.build({ filePath: projectRoot }).catch((cause) => cause); + + expect(error).toBeInstanceOf(AgentCoreCLIError); + expect(error).not.toBeInstanceOf(InputValidationError); + expect(error).toMatchObject({ source: "internal" }); + }); + test("rejects a project without deployment targets before spawning a build", async () => { const directory = await inTempDirectory(); const { manager: subject, commands } = manager(); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index b10eb336e..4571f7310 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,5 +1,3 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, stat } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { InputValidationError, NestedProjectError } from "../../errors"; import type { @@ -12,21 +10,23 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; -import { atomicWrite, requireTool, runProcess, type ProcessRunner } from "../../io"; +import type { AssetSource, LocalFileSystem, ProcessRunner, ToolChecker } from "../../io"; import { PACKAGE_VERSION } from "../../constants"; import { projectTree } from "./compose"; -import { defaultSource, type AssetSource } from "./source"; import { TEMPLATES } from "./templates"; import { writeTree } from "./tree"; import { CdkProjectBackend } from "./cdk"; import type { ProjectBuildBackend } from "./backend"; import { computeProjectFingerprint } from "./fingerprint"; -import { DeploymentTargetsSchema, ProjectSpecEnvelopeSchema } from "./schemas"; +import { BuildManifestSchema, DeploymentTargetsSchema, ProjectSpecEnvelopeSchema } from "./schemas"; /** Walks up from directory looking for the agentcore/agentcore.json project marker. */ -function enclosingProjectRoot(directory: string): string | undefined { +async function enclosingProjectRoot( + fileSystem: LocalFileSystem, + directory: string, +): Promise { for (let current = directory; ; current = dirname(current)) { - if (existsSync(join(current, "agentcore", "agentcore.json"))) { + if (await fileSystem.exists(join(current, "agentcore", "agentcore.json"))) { return current; } if (dirname(current) === current) { @@ -37,10 +37,12 @@ function enclosingProjectRoot(directory: string): string | undefined { type ProjectManagerConfig = { logger: Logger; - source?: AssetSource; // Bun executable or dist/assets depending on runtime - runner?: ProcessRunner; // injectable so tests never spawn real processes - checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH - now?: () => Date; + source: AssetSource; + runner: ProcessRunner; + checkTool: ToolChecker; + fileSystem: LocalFileSystem; + workingDirectory: () => string; + now: () => Date; }; /** @@ -50,27 +52,31 @@ export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; private readonly runner: ProcessRunner; - private readonly checkTool: typeof requireTool; + private readonly checkTool: ToolChecker; + private readonly fileSystem: LocalFileSystem; + private readonly workingDirectory: () => string; private readonly now: () => Date; constructor(config: ProjectManagerConfig) { this.logger = config.logger; - this.source = config.source ?? defaultSource(); - this.runner = config.runner ?? runProcess; - this.checkTool = config.checkTool ?? requireTool; - this.now = config.now ?? (() => new Date()); + this.source = config.source; + this.runner = config.runner; + this.checkTool = config.checkTool; + this.fileSystem = config.fileSystem; + this.workingDirectory = config.workingDirectory; + this.now = config.now; } public async resolve(input: ResolveProjectInput): Promise { const candidate = resolve(input.filePath); let directory = candidate; try { - if ((await stat(candidate)).isFile()) directory = dirname(candidate); + if ((await this.fileSystem.stat(candidate)).kind === "file") directory = dirname(candidate); } catch { // A non-existent path can still identify a directory beneath an enclosing project. } - const root = enclosingProjectRoot(directory); + const root = await enclosingProjectRoot(this.fileSystem, directory); if (!root) return undefined; return this.loadProject(root); @@ -78,16 +84,17 @@ export class FsProjectManager implements ProjectManager { public async create(input: CreateProjectInput): Promise { // Scaffold into a fresh directory, refusing to nest inside an existing project. - const enclosing = enclosingProjectRoot(process.cwd()); + const workingDirectory = this.workingDirectory(); + const enclosing = await enclosingProjectRoot(this.fileSystem, workingDirectory); if (enclosing) { throw new NestedProjectError(enclosing); } - const destination = join(process.cwd(), input.name); + const destination = join(workingDirectory, input.name); this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); input.onProgress?.({ message: "Scaffolding project files..." }); const tree = await projectTree(input.name, input.template, this.source); - await writeTree(tree, destination); + await writeTree(this.fileSystem, tree, destination); // A failed step leaves the scaffolded files in place; the error tells the // user how to rerun the step by hand. @@ -97,7 +104,7 @@ export class FsProjectManager implements ProjectManager { await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); const appDir = join(destination, "app", TEMPLATES[input.template].appDir); - if (existsSync(join(appDir, "pyproject.toml"))) { + if (await this.fileSystem.exists(join(appDir, "pyproject.toml"))) { await this.checkTool( "uv", "Install uv: https://docs.astral.sh/uv/getting-started/installation/", @@ -134,20 +141,20 @@ export class FsProjectManager implements ProjectManager { const backendResult = await backend.build(project, project.targets, input.onProgress); input.onProgress?.({ message: "Recording build manifest..." }); - const manifest: BuildManifest = { + const manifest = BuildManifestSchema.parse({ version: 1, projectName: project.name, backend: backend.name, cliVersion: PACKAGE_VERSION, - inputFingerprint: await computeProjectFingerprint(project.root), + inputFingerprint: await computeProjectFingerprint(this.fileSystem, project.root), builtAt: this.now().toISOString(), - cloudAssemblyPath: backendResult.cloudAssemblyPath, - targets: backendResult.targets, - }; + artifact: backendResult.artifact, + targets: project.targets, + } satisfies BuildManifest); const manifestPath = join(project.configDir, ".build", "manifest.json"); - await mkdir(dirname(manifestPath), { recursive: true }); - await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + await this.fileSystem.createDirectory(dirname(manifestPath)); + await this.fileSystem.writeAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); return { ...manifest, @@ -170,6 +177,7 @@ export class FsProjectManager implements ProjectManager { logger: this.logger, runner: this.runner, checkTool: this.checkTool, + fileSystem: this.fileSystem, }); } @@ -201,7 +209,7 @@ export class FsProjectManager implements ProjectManager { ): Promise { let value: unknown; try { - value = JSON.parse(await readFile(path, "utf8")); + value = JSON.parse(await this.fileSystem.readText(path)); } catch (error) { throw new InputValidationError(`Unable to read project configuration at ${path}`, { cause: error, diff --git a/src/core/project/names.test.ts b/src/core/project/names.test.ts new file mode 100644 index 000000000..5c27c93b8 --- /dev/null +++ b/src/core/project/names.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test"; +import { toStackName } from "../../assets/cdk/lib/names"; + +describe("toStackName", () => { + test("uses the shared CDK stack naming contract", () => { + expect(toStackName("my_project", "us_west_2")).toBe("AgentCore-my-project-us-west-2"); + }); +}); diff --git a/src/core/project/schemas.ts b/src/core/project/schemas.ts index cb79824a2..845698879 100644 --- a/src/core/project/schemas.ts +++ b/src/core/project/schemas.ts @@ -49,12 +49,14 @@ export const BuildManifestSchema = z cliVersion: z.string().min(1), inputFingerprint: z.string().regex(/^[a-f0-9]{64}$/), builtAt: z.iso.datetime(), - cloudAssemblyPath: z.string().min(1), - targets: z.array( - DeploymentTargetSchema.extend({ - stackName: z.string().min(1), - }), - ), + artifact: z + .object({ + type: z.literal("cdk-cloud-assembly"), + path: z.string().min(1), + stacks: z.record(z.string(), z.string().min(1)), + }) + .strict(), + targets: z.array(DeploymentTargetSchema), }) .strict(); diff --git a/src/core/project/source.ts b/src/core/project/source.ts deleted file mode 100644 index 8294dfd3e..000000000 --- a/src/core/project/source.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { EmbeddedAssetNotFoundError } from "../../errors"; - -/** - * Reads and lists asset files by path relative to the asset root. - * This is the one place that knows whether assets come from disk or from the compiled executable. - */ -export interface AssetSource { - /** Reads the text of the asset at assetPath. */ - read(assetPath: string): Promise; - /** Lists asset paths of every file under assetDir, sorted, recursively. */ - list(assetDir: string): Promise; -} - -// Prefix the build script gives every embedded asset name at compile time. -const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; - -// Embedded files carry a name property that Bun's types widen to Blob. -type NamedBlob = Blob & { readonly name: string }; - -/** Reads assets embedded in the compiled standalone executable. */ -export class EmbeddedAssetSource implements AssetSource { - private blobs(): readonly NamedBlob[] { - return Bun.embeddedFiles as readonly NamedBlob[]; - } - - public async read(assetPath: string): Promise { - const name = `${EMBEDDED_PREFIX}${assetPath}`; - const blob = this.blobs().find((f) => f.name === name); - if (!blob) { - throw new EmbeddedAssetNotFoundError(assetPath); - } - return blob.text(); - } - - public async list(assetDir: string): Promise { - const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; - return this.blobs() - .filter((f) => f.name.startsWith(prefix)) - .map((f) => f.name.slice(EMBEDDED_PREFIX.length)) - .sort(); - } -} - -/** Reads assets from the assets directory on disk. */ -export class FsAssetSource implements AssetSource { - constructor(private readonly assetsRoot: string = resolveAssetsRoot()) {} - - public read(assetPath: string): Promise { - return readFile(join(this.assetsRoot, assetPath), "utf8"); - } - - public async list(assetDir: string): Promise { - const root = join(this.assetsRoot, assetDir); - const entries = await readdir(root, { recursive: true, withFileTypes: true }); - return entries - .filter((entry) => entry.isFile()) - .map((entry) => join(assetDir, relative(root, join(entry.parentPath, entry.name)))) - .map((p) => p.replaceAll("\\", "/")) - .sort(); - } -} - -// Bundled builds place assets beside the emitted module and the source layout keeps them two levels up. -function resolveAssetsRoot(moduleDirectory = dirname(fileURLToPath(import.meta.url))): string { - const bundledRoot = resolve(moduleDirectory, "assets"); - if (existsSync(bundledRoot)) { - return bundledRoot; - } - return resolve(moduleDirectory, "../../assets"); -} - -/** - * Selects the asset source for the current runtime: embedded assets when running - * as a compiled Bun executable, disk otherwise. - */ -export function defaultSource(): AssetSource { - const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; - return embedded ? new EmbeddedAssetSource() : new FsAssetSource(); -} diff --git a/src/core/project/tree.ts b/src/core/project/tree.ts index 3b14e7c2d..a5c8049dd 100644 --- a/src/core/project/tree.ts +++ b/src/core/project/tree.ts @@ -1,8 +1,6 @@ -import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { ProjectFileExistsError } from "../../errors"; -import { atomicWrite } from "../../io"; +import type { LocalFileSystem } from "../../io"; /** * A node in a project's file tree where directories nest and files are leaves. @@ -38,18 +36,22 @@ export const file = (name: string, bytes: () => Promise): FileNode => ({ * Writes a project tree to the destination with atomic file writes. * Refuses to overwrite an existing file so a re-run fails loudly instead of clobbering user work. */ -export async function writeTree(node: ProjectNode, destination: string): Promise { +export async function writeTree( + fileSystem: LocalFileSystem, + node: ProjectNode, + destination: string, +): Promise { const path = join(destination, node.name); if (node.kind === "dir") { - await mkdir(path, { recursive: true }); + await fileSystem.createDirectory(path); for (const child of node.children) { - await writeTree(child, path); + await writeTree(fileSystem, child, path); } return; } - if (existsSync(path)) { + if (await fileSystem.exists(path)) { throw new ProjectFileExistsError(path); } - await atomicWrite(path, await node.bytes()); + await fileSystem.writeAtomic(path, await node.bytes()); } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index cb19b1f87..4be776642 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -126,7 +126,11 @@ describe("project build", () => { expect(result).toMatchObject({ projectName: "MyAgent", backend: "CDK", - cloudAssemblyPath: "agentcore/cdk/cdk.out", + artifact: { + type: "cdk-cloud-assembly", + path: "agentcore/cdk/cdk.out", + stacks: { default: "AgentCore-MyAgent-default" }, + }, manifestPath: "agentcore/.build/manifest.json", }); expect(core.projectCommands).toEqual([ diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 4ed7390b3..9dedb03e3 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -133,8 +133,10 @@ export type BuildProjectInput = { onProgress?: (event: ProjectProgressEvent) => void; }; -export type BuildTarget = DeploymentTarget & { - stackName: string; +export type BuildArtifact = { + type: "cdk-cloud-assembly"; + path: string; + stacks: Record; }; export type BuildManifest = { @@ -144,8 +146,8 @@ export type BuildManifest = { cliVersion: string; inputFingerprint: string; builtAt: string; - cloudAssemblyPath: string; - targets: BuildTarget[]; + artifact: BuildArtifact; + targets: DeploymentTarget[]; }; export type BuildProjectResult = BuildManifest & { diff --git a/src/core/project/source.test.ts b/src/io/assets.test.ts similarity index 73% rename from src/core/project/source.test.ts rename to src/io/assets.test.ts index ee2a6284d..a70fc3d5e 100644 --- a/src/core/project/source.test.ts +++ b/src/io/assets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { EmbeddedAssetNotFoundError } from "../../errors"; -import { EmbeddedAssetSource } from "./source"; +import { EmbeddedAssetNotFoundError } from "../errors"; +import { EmbeddedAssetSource } from "./assets"; describe("EmbeddedAssetSource", () => { test("throws a modeled error when the asset is not embedded", () => { diff --git a/src/io/assets.ts b/src/io/assets.ts new file mode 100644 index 000000000..48bd62e5b --- /dev/null +++ b/src/io/assets.ts @@ -0,0 +1,94 @@ +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { EmbeddedAssetNotFoundError } from "../errors"; +import type { LocalFileSystem } from "./fileSystem"; +import { localFileSystem } from "./fileSystem"; + +const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; + +type NamedBlob = Blob & { readonly name: string }; + +export interface AssetSource { + read(assetPath: string): Promise; + list(assetDir: string): Promise; +} + +/** Reads project assets embedded in the compiled standalone executable. */ +export class EmbeddedAssetSource implements AssetSource { + private blobs(): readonly NamedBlob[] { + return Bun.embeddedFiles as readonly NamedBlob[]; + } + + async read(assetPath: string): Promise { + const name = `${EMBEDDED_PREFIX}${assetPath}`; + const blob = this.blobs().find((file) => file.name === name); + if (!blob) { + throw new EmbeddedAssetNotFoundError(assetPath); + } + return blob.text(); + } + + async list(assetDir: string): Promise { + const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; + return this.blobs() + .filter((file) => file.name.startsWith(prefix)) + .map((file) => file.name.slice(EMBEDDED_PREFIX.length)) + .sort(); + } +} + +/** Reads project assets from the source or bundled assets directory. */ +export class FsAssetSource implements AssetSource { + private assetsRoot?: Promise; + + constructor( + private readonly fileSystem: LocalFileSystem, + private readonly configuredRoot?: string, + private readonly moduleDirectory = dirname(fileURLToPath(import.meta.url)), + ) {} + + async read(assetPath: string): Promise { + return this.fileSystem.readText(join(await this.root(), assetPath)); + } + + async list(assetDir: string): Promise { + const assetsRoot = await this.root(); + const root = join(assetsRoot, assetDir); + const paths = await this.listFiles(root); + return paths.map((path) => join(assetDir, relative(root, path)).replaceAll("\\", "/")).sort(); + } + + private async root(): Promise { + this.assetsRoot ??= this.resolveRoot(); + return this.assetsRoot; + } + + private async resolveRoot(): Promise { + if (this.configuredRoot) { + return this.configuredRoot; + } + const bundledRoot = resolve(this.moduleDirectory, "assets"); + return (await this.fileSystem.exists(bundledRoot)) + ? bundledRoot + : resolve(this.moduleDirectory, "../assets"); + } + + private async listFiles(directory: string): Promise { + const paths: string[] = []; + const entries = await this.fileSystem.readDirectory(directory); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.kind === "directory") { + paths.push(...(await this.listFiles(path))); + } else if (entry.kind === "file") { + paths.push(path); + } + } + return paths; + } +} + +export function defaultAssetSource(fileSystem: LocalFileSystem = localFileSystem): AssetSource { + const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; + return embedded ? new EmbeddedAssetSource() : new FsAssetSource(fileSystem); +} diff --git a/src/io/exec.ts b/src/io/exec.ts index ce7d8f549..c2417cd1e 100644 --- a/src/io/exec.ts +++ b/src/io/exec.ts @@ -47,6 +47,8 @@ export async function requireTool( if (!(await toolAvailable(tool, probeArgs))) throw new MissingToolError(tool, installHint); } +export type ToolChecker = typeof requireTool; + export type RunProcessOptions = { /** Working directory the process runs in. */ cwd: string; diff --git a/src/io/fileSystem.test.ts b/src/io/fileSystem.test.ts new file mode 100644 index 000000000..ba4fb8c1b --- /dev/null +++ b/src/io/fileSystem.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodeLocalFileSystem } from "./fileSystem"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("NodeLocalFileSystem", () => { + test("implements project filesystem operations behind one IO boundary", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-file-system-")); + directories.push(root); + const subject = new NodeLocalFileSystem(); + const directory = join(root, "nested"); + const file = join(directory, "value.txt"); + + await subject.createDirectory(directory); + await subject.writeAtomic(file, "value"); + + expect(await subject.exists(file)).toBe(true); + expect(await subject.readText(file)).toBe("value"); + expect(await subject.stat(file)).toMatchObject({ kind: "file" }); + expect(await subject.readDirectory(directory)).toEqual([{ name: "value.txt", kind: "file" }]); + + await subject.remove(directory); + expect(await subject.exists(file)).toBe(false); + }); +}); diff --git a/src/io/fileSystem.ts b/src/io/fileSystem.ts new file mode 100644 index 000000000..ff3de9900 --- /dev/null +++ b/src/io/fileSystem.ts @@ -0,0 +1,101 @@ +import { access, lstat, mkdir, readFile, readdir, readlink, rm, stat } from "node:fs/promises"; +import { atomicWrite } from "./atomicWrite"; + +export type FileKind = "directory" | "file" | "symbolic-link" | "other"; + +export type FileInfo = { + kind: FileKind; + mode: number; +}; + +export type DirectoryEntry = { + name: string; + kind: FileKind; +}; + +export interface LocalFileSystem { + exists(path: string): Promise; + stat(path: string): Promise; + lstat(path: string): Promise; + readDirectory(path: string): Promise; + readText(path: string): Promise; + readBytes(path: string): Promise; + readLink(path: string): Promise; + createDirectory(path: string): Promise; + remove(path: string): Promise; + writeAtomic(path: string, contents: string | Uint8Array): Promise; +} + +function fileInfo(value: { + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; + mode: number; +}): FileInfo { + const kind = value.isDirectory() + ? "directory" + : value.isFile() + ? "file" + : value.isSymbolicLink() + ? "symbolic-link" + : "other"; + return { kind, mode: value.mode }; +} + +export class NodeLocalFileSystem implements LocalFileSystem { + async exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } + } + + async stat(path: string): Promise { + return fileInfo(await stat(path)); + } + + async lstat(path: string): Promise { + return fileInfo(await lstat(path)); + } + + async readDirectory(path: string): Promise { + return (await readdir(path, { withFileTypes: true })).map((entry) => ({ + name: entry.name, + kind: entry.isDirectory() + ? "directory" + : entry.isFile() + ? "file" + : entry.isSymbolicLink() + ? "symbolic-link" + : "other", + })); + } + + readText(path: string): Promise { + return readFile(path, "utf8"); + } + + async readBytes(path: string): Promise { + return readFile(path); + } + + readLink(path: string): Promise { + return readlink(path); + } + + async createDirectory(path: string): Promise { + await mkdir(path, { recursive: true }); + } + + async remove(path: string): Promise { + await rm(path, { recursive: true, force: true }); + } + + writeAtomic(path: string, contents: string | Uint8Array): Promise { + return atomicWrite(path, contents); + } +} + +export const localFileSystem: LocalFileSystem = new NodeLocalFileSystem(); diff --git a/src/io/index.ts b/src/io/index.ts index f146280e6..1b2f0e551 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,4 +1,5 @@ export { atomicWrite } from "./atomicWrite"; +export { EmbeddedAssetSource, FsAssetSource, defaultAssetSource, type AssetSource } from "./assets"; export { MissingToolError, ProcessFailedError, @@ -7,7 +8,16 @@ export { toolAvailable, type ProcessRunner, type RunProcessOptions, + type ToolChecker, } from "./exec"; +export { + NodeLocalFileSystem, + localFileSystem, + type DirectoryEntry, + type FileInfo, + type FileKind, + type LocalFileSystem, +} from "./fileSystem"; export { FsReadWriteJson } from "./json"; export { SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types"; diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index 1a68c1ecb..bcb9fc2a9 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -3,10 +3,19 @@ import { Router, createHandler } from "../router"; import { createSilentLogger } from "../testing"; import { withProject } from "./withProject"; import { FsProjectManager } from "../core/project"; +import { defaultAssetSource, localFileSystem, requireTool, runProcess } from "../io"; describe("withProject", () => { test("throws when no project can be resolved", async () => { - const projectManager = new FsProjectManager({ logger: createSilentLogger() }); + const projectManager = new FsProjectManager({ + logger: createSilentLogger(), + source: defaultAssetSource(localFileSystem), + runner: runProcess, + checkTool: requireTool, + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), + }); const app = new Router("app", "test"); app.use(withProject({ projectManager, cwd: "/some/path" })); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index a20f4ff50..406d25eed 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -95,6 +95,8 @@ import { createSilentLogger } from "./logging"; import { FsProjectManager } from "../core/project"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { defaultAssetSource, localFileSystem } from "../io"; +import { toStackName } from "../assets/cdk/lib/names"; // TestCoreClient is a hand-controllable `Core` for tests. It implements the same // interface the real CoreClient satisfies, so it drops straight into @@ -1188,6 +1190,7 @@ export class TestCoreClient implements Core { constructor(options?: TestCoreClientOptions) { this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger(), + source: defaultAssetSource(localFileSystem), runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); if (command.includes("synth")) { @@ -1200,7 +1203,7 @@ export class TestCoreClient implements Core { ) as { name: string }[]; const artifacts = Object.fromEntries( targets.map((target) => { - const stackName = `AgentCore-${project.name.replaceAll("_", "-")}-${target.name.replaceAll("_", "-")}`; + const stackName = toStackName(project.name, target.name); return [ stackName, { @@ -1219,6 +1222,9 @@ export class TestCoreClient implements Core { } }, checkTool: async () => {}, // CI hosts don't have uv installed + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), }); } }