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 2b1f68db1..a4501db25 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", @@ -12,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 new file mode 100644 index 000000000..fe3265b92 --- /dev/null +++ b/src/core/project/backend.ts @@ -0,0 +1,19 @@ +import type { + BuildArtifact, + DeploymentTarget, + Project, + ProjectProgressEvent, +} from "../../handlers/project/types"; + +export type BackendBuildResult = { + artifact: BuildArtifact; +}; + +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..a358cfe18 --- /dev/null +++ b/src/core/project/cdk.ts @@ -0,0 +1,111 @@ +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: ToolChecker; + fileSystem: LocalFileSystem; +}; + +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 (!(await this.config.fileSystem.exists(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 this.config.fileSystem.remove(assemblyDirectory); + await this.config.fileSystem.createDirectory(assemblyDirectory); + + 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 this.config.fileSystem.readText(manifestPath)); + } catch (error) { + throw new AgentCoreCLIError( + `CDK synthesis did not produce a readable cloud assembly at ${manifestPath}`, + { cause: error }, + ); + } + + const parsed = CloudAssemblyManifestSchema.safeParse(manifest); + if (!parsed.success) { + throw new AgentCoreCLIError(`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 stacks = Object.fromEntries( + targets.map((target) => [target.name, toStackName(project.name, target.name)]), + ); + + const missing = targets.filter((target) => !synthesizedStacks.has(stacks[target.name]!)); + if (missing.length > 0) { + throw new AgentCoreCLIError( + `CDK synthesis did not produce stacks for target(s): ${missing.map((target) => target.name).join(", ")}`, + ); + } + + return { + artifact: { + type: "cdk-cloud-assembly", + path: this.relativeToProject(project.root, assemblyDirectory), + stacks, + }, + }; + } + + private relativeToProject(projectRoot: string, path: string): string { + return relative(projectRoot, path).replaceAll("\\", "/"); + } + + 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..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"; @@ -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..6acbf48c4 --- /dev/null +++ b/src/core/project/fingerprint.ts @@ -0,0 +1,96 @@ +import { createHash } from "node:crypto"; +import { join, relative, sep } from "node:path"; +import { PACKAGE_VERSION } from "../../constants"; +import type { LocalFileSystem } from "../../io"; + +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( + 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.kind === "directory")) continue; + + if (entry.kind === "directory") { + paths.push(...(await projectFiles(fileSystem, root, absolutePath))); + } else if (entry.kind === "file" || entry.kind === "symbolic-link") { + paths.push(absolutePath); + } + } + + return paths; +} + +/** Hashes build inputs by path and content, excluding generated output and dependency directories. */ +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(fileSystem, root)) { + const relativePath = normalize(relative(root, absolutePath)); + const stats = await fileSystem.lstat(absolutePath); + hash.update(relativePath); + hash.update("\0"); + hash.update(String(stats.mode & 0o777)); + hash.update("\0"); + + if (stats.kind === "symbolic-link") { + hash.update("link\0"); + hash.update(await fileSystem.readLink(absolutePath)); + } else { + hash.update("file\0"); + hash.update(await fileSystem.readBytes(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..a9cd55547 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -1,11 +1,18 @@ 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"; +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,15 +39,57 @@ 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")) { + 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 = toStackName(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 + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + 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 +125,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([]); @@ -147,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( @@ -174,3 +228,200 @@ 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", + 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", + }, + ], + }); + 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("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(); + 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..4571f7310 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,23 +1,32 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { NestedProjectError } from "../../errors"; +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 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 { 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) { @@ -28,9 +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 + source: AssetSource; + runner: ProcessRunner; + checkTool: ToolChecker; + fileSystem: LocalFileSystem; + workingDirectory: () => string; + now: () => Date; }; /** @@ -40,31 +52,49 @@ 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.source = config.source; + this.runner = config.runner; + this.checkTool = config.checkTool; + this.fileSystem = config.fileSystem; + this.workingDirectory = config.workingDirectory; + this.now = config.now; } - 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 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 = await enclosingProjectRoot(this.fileSystem, directory); + if (!root) return undefined; + + return this.loadProject(root); } 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. @@ -74,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/", @@ -90,11 +120,108 @@ 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 = BuildManifestSchema.parse({ + version: 1, + projectName: project.name, + backend: backend.name, + cliVersion: PACKAGE_VERSION, + inputFingerprint: await computeProjectFingerprint(this.fileSystem, project.root), + builtAt: this.now().toISOString(), + artifact: backendResult.artifact, + targets: project.targets, + } satisfies BuildManifest); + + const manifestPath = join(project.configDir, ".build", "manifest.json"); + await this.fileSystem.createDirectory(dirname(manifestPath)); + await this.fileSystem.writeAtomic(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, + fileSystem: this.fileSystem, + }); + } + + 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 this.fileSystem.readText(path)); + } 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/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 new file mode 100644 index 000000000..845698879 --- /dev/null +++ b/src/core/project/schemas.ts @@ -0,0 +1,80 @@ +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(), + 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(); + +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/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/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/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/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..4be776642 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,55 @@ 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", + artifact: { + type: "cdk-cloud-assembly", + path: "agentcore/cdk/cdk.out", + stacks: { default: "AgentCore-MyAgent-default" }, + }, + 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..9dedb03e3 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -107,8 +107,51 @@ 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 BuildArtifact = { + type: "cdk-cloud-assembly"; + path: string; + stacks: Record; +}; + +export type BuildManifest = { + version: 1; + projectName: string; + backend: string; + cliVersion: string; + inputFingerprint: string; + builtAt: string; + artifact: BuildArtifact; + targets: DeploymentTarget[]; +}; + +export type BuildProjectResult = BuildManifest & { + manifestPath: string; }; /** @@ -120,4 +163,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/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 852e75bf3..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 not implemented", async () => { - const projectManager = new FsProjectManager({ logger: createSilentLogger() }); + test("throws when no project can be resolved", async () => { + 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" })); @@ -18,6 +27,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..406d25eed 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -93,6 +93,10 @@ 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"; +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 @@ -1186,10 +1190,41 @@ 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")) { + 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 = toStackName(project.name, target.name); + 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 + fileSystem: localFileSystem, + workingDirectory: () => process.cwd(), + now: () => new Date(), }); } }