From 4e41c28d849f9375d4fd6b101437204d81bcb748 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 22 Jul 2026 16:26:26 -0400 Subject: [PATCH 1/8] feat(assets): add AssetManager and CDK scaffold templates Introduce the asset embedding + rendering subsystem: AssetManager renders asset trees from either Bun.embeddedFiles (compiled binary) or the source filesystem (dev/node), applying Handlebars templating with HTML escaping disabled and atomic writes. - src/fs/atomicWrite: central temp-file + rename write util - src/assetManager: AssetManager, types, barrel, tests, snapshot baseline - src/assets/cdk: CDK project scaffold templates (payload, not source) - tsconfig/bunfig/oxlint/prettier: exclude src/assets from all tooling - add handlebars dependency --- .oxlintrc.json | 2 +- .prettierignore | 3 + bun.lock | 13 + bunfig.toml | 5 +- package.json | 1 + src/assetManager/AssetManager.test.ts | 129 +++++++++ src/assetManager/AssetManager.ts | 109 ++++++++ .../__snapshots__/AssetManager.test.ts.snap | 17 ++ src/assetManager/index.ts | 2 + src/assetManager/types.ts | 13 + src/assets/cdk/.prettierrc | 8 + src/assets/cdk/README.md | 26 ++ src/assets/cdk/bin/cdk.ts | 192 ++++++++++++++ src/assets/cdk/cdk.json | 88 +++++++ src/assets/cdk/gitignore.template | 9 + src/assets/cdk/jest.config.js | 9 + src/assets/cdk/lib/cdk-stack.ts | 249 ++++++++++++++++++ src/assets/cdk/npmignore.template | 6 + src/assets/cdk/package.json | 30 +++ src/assets/cdk/test/cdk.test.ts | 31 +++ src/assets/cdk/tsconfig.json | 28 ++ src/fs/atomicWrite.test.ts | 47 ++++ src/fs/atomicWrite.ts | 14 + src/fs/index.ts | 1 + tsconfig.json | 3 +- 25 files changed, 1030 insertions(+), 5 deletions(-) create mode 100644 src/assetManager/AssetManager.test.ts create mode 100644 src/assetManager/AssetManager.ts create mode 100644 src/assetManager/__snapshots__/AssetManager.test.ts.snap create mode 100644 src/assetManager/index.ts create mode 100644 src/assetManager/types.ts create mode 100644 src/assets/cdk/.prettierrc create mode 100644 src/assets/cdk/README.md create mode 100644 src/assets/cdk/bin/cdk.ts create mode 100644 src/assets/cdk/cdk.json create mode 100644 src/assets/cdk/gitignore.template create mode 100644 src/assets/cdk/jest.config.js create mode 100644 src/assets/cdk/lib/cdk-stack.ts create mode 100644 src/assets/cdk/npmignore.template create mode 100644 src/assets/cdk/package.json create mode 100644 src/assets/cdk/test/cdk.test.ts create mode 100644 src/assets/cdk/tsconfig.json create mode 100644 src/fs/atomicWrite.test.ts create mode 100644 src/fs/atomicWrite.ts create mode 100644 src/fs/index.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index b99c28331..0be6af4aa 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,6 +4,6 @@ "categories": { "correctness": "error" }, - "ignorePatterns": ["dist/", "node_modules/"], + "ignorePatterns": ["dist/", "node_modules/", "src/assets/"], "overrides": [] } diff --git a/.prettierignore b/.prettierignore index f66abac46..d32842846 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,6 @@ bun.lock # rewrites the recorded content (e.g. collapsing arrays) and breaks the exact # comparison the golden tests rely on. Refresh them with RECORD=1 instead. __fixtures__ + +*.snap +src/assets diff --git a/bun.lock b/bun.lock index 4be453a15..06a5cff65 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", "commander": "^15.0.0", + "handlebars": "^4.7.9", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", @@ -205,6 +206,8 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -239,10 +242,14 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], @@ -283,6 +290,8 @@ "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -311,6 +320,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -323,6 +334,8 @@ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], diff --git a/bunfig.toml b/bunfig.toml index 62427b31e..1b46fe0f7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,6 +3,5 @@ # frames are plain text (no ANSI color codes) regardless of whether stdout is a # TTY, keeping frame assertions deterministic across `bun test` and piped runs. preload = ["./src/testing/setup.ts"] -coveragePathIgnorePatterns = [ - "src/testing/**" -] +pathIgnorePatterns = ["src/assets/**"] +coveragePathIgnorePatterns = ["src/testing/**", "src/assets/**"] diff --git a/package.json b/package.json index 63625847d..527b42925 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-router": "^8.3.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "handlebars": "^4.7.9", "zod": "^4.4.3" } } diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/AssetManager.test.ts new file mode 100644 index 000000000..e969a4428 --- /dev/null +++ b/src/assetManager/AssetManager.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { AssetManager, resolveSourceRoot } from "./AssetManager"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-assets-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("AssetManager", () => { + test("renders a filesystem asset tree", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(join(source, "lib"), { recursive: true }); + await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); + await Bun.write(join(source, ".prettierrc"), "{}"); + await Bun.write(join(source, "gitignore.template"), "dist/"); + await Bun.write(join(source, "lib", "stack.ts"), "export const name = '{{projectName}}';"); + + await new AssetManager([], join(root, "assets")).render("cdk", destination, { + projectName: "example", + }); + + expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello example"); + expect(await Bun.file(join(destination, ".prettierrc")).text()).toBe("{}"); + expect(await Bun.file(join(destination, ".gitignore")).text()).toBe("dist/"); + expect(await Bun.file(join(destination, "lib", "stack.ts")).text()).toBe( + "export const name = 'example';", + ); + }); + + test("renders embedded asset files", async () => { + const root = await makeTempDirectory(); + const destination = join(root, "output"); + const embedded = new File( + ["Hello {{projectName}}"], + "agentcore-assets/src/assets/cdk/README.md", + ); + + await new AssetManager([embedded]).render("cdk", destination, { + projectName: "embedded", + }); + + expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello embedded"); + }); + + test("does not HTML-escape code template values", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(source, { recursive: true }); + await Bun.write(join(source, "config.ts"), "export const expr = {{expr}};"); + + await new AssetManager([], join(root, "assets")).render("cdk", destination, { + expr: "a && b < c", + }); + + expect(await Bun.file(join(destination, "config.ts")).text()).toBe( + "export const expr = a && b < c;", + ); + }); + + test("rejects an embedded path that escapes the destination", async () => { + const root = await makeTempDirectory(); + const destination = join(root, "output"); + const embedded = new File( + ["pwned"], + "agentcore-assets/src/assets/cdk/../../../../../../etc/evil", + ); + + await expect(new AssetManager([embedded]).render("cdk", destination)).rejects.toThrow( + "Unsafe asset path", + ); + }); + + describe("resolveSourceRoot", () => { + test("returns the bundled root when assets/ sits beside the module", async () => { + const moduleDir = await makeTempDirectory(); + await mkdir(join(moduleDir, "assets"), { recursive: true }); + + expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "assets")); + }); + + test("falls back to ../assets when no sibling assets/ exists", async () => { + const moduleDir = await makeTempDirectory(); + + expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "../assets")); + }); + }); + + test("cdk renders the expected output tree", async () => { + // Real cdk asset, source-tree mode (no injected root, no embedded files). + // Snapshots the rendered manifest so adding/removing/renaming an asset file + // is a reviewable diff. Content is covered by the byte-for-byte tests above. + const destination = await makeTempDirectory(); + await new AssetManager().render("cdk", destination); + + const rendered = (await readdir(destination, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(destination, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); + + expect(rendered).toMatchSnapshot(); + }); + + test("rejects a missing asset", async () => { + const root = await makeTempDirectory(); + + await expect( + new AssetManager([], join(root, "assets")).render("cdk", join(root, "output")), + ).rejects.toThrow("Asset 'cdk' does not exist"); + }); +}); diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts new file mode 100644 index 000000000..893a47973 --- /dev/null +++ b/src/assetManager/AssetManager.ts @@ -0,0 +1,109 @@ +import Handlebars from "handlebars"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { atomicWrite } from "../fs"; +import type { AssetFile, AssetName, AssetVariables, EmbeddedFile } from "./types"; + +// Bundled builds place `assets/` beside the module; source layout has it one up. +// Directory-injected so it's testable without a real install layout. +export function resolveSourceRoot( + moduleDirectory = dirname(fileURLToPath(import.meta.url)), +): string { + const bundledRoot = resolve(moduleDirectory, "assets"); + if (existsSync(bundledRoot)) { + return bundledRoot; + } + + return resolve(moduleDirectory, "../assets"); +} + +export class AssetManager { + private handlebars = Handlebars.create(); + constructor( + private readonly embeddedFiles: readonly EmbeddedFile[] = [], + private readonly sourceRoot?: string, + ) {} + + async render( + asset: AssetName, + destination: string, + variables: AssetVariables = {}, + ): Promise { + const assetFiles = + this.embeddedFiles.length > 0 + ? this.listEmbeddedFiles(asset) + : await this.listFileSystemFiles(asset); + + if (assetFiles.length === 0) { + throw new Error(`Asset '${asset}' does not exist`); + } + + const resolvedDestination = resolve(destination); + for (const file of assetFiles) { + const outputPath = this.resolveOutputPath(resolvedDestination, file.relativePath); + const rendered = this.handlebars.compile(await file.text(), { noEscape: true })(variables); + + await mkdir(dirname(outputPath), { recursive: true }); + await atomicWrite(outputPath, rendered); + } + } + + private async listFileSystemFiles(asset: AssetName): Promise { + const assetRoot = join(this.sourceRoot ?? resolveSourceRoot(), asset); + + let entries; + try { + entries = await readdir(assetRoot, { recursive: true, withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + return entries + .filter((entry) => entry.isFile()) + .map((entry) => relative(assetRoot, join(entry.parentPath, entry.name))) + .sort() + .map((relativePath) => ({ + relativePath, + text: () => readFile(join(assetRoot, relativePath), "utf8"), + })); + } + + private listEmbeddedFiles(asset: AssetName): AssetFile[] { + const prefix = `agentcore-assets/src/assets/${asset}/`; + + return this.embeddedFiles + .filter((file) => file.name.startsWith(prefix)) + .map((file) => ({ + relativePath: file.name.slice(prefix.length), + text: () => file.text(), + })) + .sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + } + + private resolveOutputPath(resolvedDestination: string, relativePath: string): string { + const mapped = this.resolveTemplateName(relativePath).replaceAll("\\", "/"); + const segments = mapped.split("/"); + + if (mapped.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) { + throw new Error(`Unsafe asset path '${relativePath}'`); + } + + const outputPath = resolve(resolvedDestination, mapped); + if (outputPath !== resolvedDestination && !outputPath.startsWith(resolvedDestination + sep)) { + throw new Error(`Asset path '${relativePath}' escapes destination`); + } + + return outputPath; + } + + private resolveTemplateName(relativePath: string): string { + const filename = basename(relativePath); + const ignore = filename.match(/^(docker|git|npm)ignore\.template$/); + return join(dirname(relativePath), ignore ? `.${ignore[1]}ignore` : filename); + } +} diff --git a/src/assetManager/__snapshots__/AssetManager.test.ts.snap b/src/assetManager/__snapshots__/AssetManager.test.ts.snap new file mode 100644 index 000000000..0738c7ac1 --- /dev/null +++ b/src/assetManager/__snapshots__/AssetManager.test.ts.snap @@ -0,0 +1,17 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`AssetManager cdk renders the expected output tree 1`] = ` +[ + ".gitignore", + ".npmignore", + ".prettierrc", + "README.md", + "bin/cdk.ts", + "cdk.json", + "jest.config.js", + "lib/cdk-stack.ts", + "package.json", + "test/cdk.test.ts", + "tsconfig.json", +] +`; diff --git a/src/assetManager/index.ts b/src/assetManager/index.ts new file mode 100644 index 000000000..a2679a04b --- /dev/null +++ b/src/assetManager/index.ts @@ -0,0 +1,2 @@ +export { AssetManager } from "./AssetManager"; +export { ASSET_NAMES, type AssetName, type AssetVariables } from "./types"; diff --git a/src/assetManager/types.ts b/src/assetManager/types.ts new file mode 100644 index 000000000..a6310ae35 --- /dev/null +++ b/src/assetManager/types.ts @@ -0,0 +1,13 @@ +export const ASSET_NAMES = ["cdk"] as const; + +export type AssetName = (typeof ASSET_NAMES)[number]; + +export type AssetVariables = Record; +export interface AssetFile { + relativePath: string; + text(): Promise; +} +export interface EmbeddedFile { + readonly name: string; + text(): Promise; +} diff --git a/src/assets/cdk/.prettierrc b/src/assets/cdk/.prettierrc new file mode 100644 index 000000000..5563802ee --- /dev/null +++ b/src/assets/cdk/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/src/assets/cdk/README.md b/src/assets/cdk/README.md new file mode 100644 index 000000000..5fa522fc6 --- /dev/null +++ b/src/assets/cdk/README.md @@ -0,0 +1,26 @@ +# AgentCore CDK Project + +This CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs. + +## Structure + +- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target. +- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct. +- `test/cdk.test.ts` — Unit tests for stack synthesis. + +## Useful commands + +- `npm run build` compile TypeScript to JavaScript +- `npm run test` run unit tests +- `npx cdk synth` emit the synthesized CloudFormation template +- `npx cdk deploy` deploy this stack to your default AWS account/region +- `npx cdk diff` compare deployed stack with current state + +## Usage + +You typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment: + +```bash +agentcore deploy # synthesizes and deploys via CDK +agentcore status # checks deployment status +``` diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts new file mode 100644 index 000000000..b15b3281d --- /dev/null +++ b/src/assets/cdk/bin/cdk.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; +import * as fs from 'fs'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function sanitize(name: string): string { + return name.replace(/_/g, '-'); +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + // 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). Cast once so those fields are reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + + // Extract MCP configuration from project spec. + // Gateway fields are stored in agentcore.json but may not yet be on the + const mcpSpec = specAny.agentCoreGateways?.length + ? { + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, + } + : undefined; + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + // Read harness configs: the full validated spec drives the CFN resource; the + // role-scoped fields drive the IAM role + container build. + const projectRoot = path.resolve(configRoot, '..'); + + // Read non-S3 KB connector-config files and pass their parsed contents to the + // L3 verbatim. The L3 does not read files; it expects the parsed + // connectorParameters keyed by the data source's connectorConfigFile path. + const connectorParametersByFile: Record> = {}; + for (const kb of specAny.knowledgeBases ?? []) { + for (const ds of kb.dataSources ?? []) { + if (ds.type !== 'S3' && ds.connectorConfigFile) { + const abs = path.resolve(projectRoot, ds.connectorConfigFile); + try { + connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8')); + } catch (err) { + throw new Error( + `Could not read connector config '${ds.connectorConfigFile}' for knowledge base '${kb.name}' at ${abs}: ${err instanceof Error ? err.message : err}` + ); + } + } + } + } + + // Synthesize an AWS::BedrockAgentCore::Harness resource for each harness entry in the spec. + const harnessConfigs: HarnessConfig[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8'))); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + // Only an `existing` memory ref carries a name to wire IAM against; managed memory is + // owned by the harness (no sibling) and disabled has none — both resolve to undefined. + memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + skills: harnessSpec.skills, + apiKeyArn: harnessSpec.model?.apiKeyArn, + efsAccessPoints: harnessSpec.efsAccessPoints, + s3AccessPoints: harnessSpec.s3AccessPoints, + apiFormat: harnessSpec.model?.apiFormat, + // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource. + spec: harnessSpec, + harnessDir, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | undefined; + + // Payment credential provider ARNs live in the same credentials map as identity credentials + const paymentCredentials = credentials; + + const paymentSpec = specAny.payments?.length + ? specAny.payments.map( + (p: { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: unknown; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; provider?: string; credentialName: string }[]; + }) => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => { + const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; + if (!credentialProviderArn) { + // Fail fast with an actionable message rather than passing an empty + // ARN that fails opaquely server-side at CreatePaymentConnector. + throw new Error( + `Payment connector "${c.name}" on manager "${p.name}" references credential ` + + `"${c.credentialName}", but no deployed credential provider was found for it. ` + + `Run \`agentcore deploy\` so the credential provider is created first.` + ); + } + return { name: c.name, provider: c.provider, credentialProviderArn }; + }), + }) + ) + : undefined; + + new AgentCoreStack(app, stackName, { + spec, + mcpSpec, + credentials, + connectorParametersByFile, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main().catch((error: unknown) => { + console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/assets/cdk/cdk.json b/src/assets/cdk/cdk.json new file mode 100644 index 000000000..19e6983ab --- /dev/null +++ b/src/assets/cdk/cdk.json @@ -0,0 +1,88 @@ +{ + "app": "node dist/bin/cdk.js", + "watch": { + "include": ["**"], + "exclude": ["README.md", "cdk*.json", "tsconfig.json", "package*.json", "yarn.lock", "node_modules", "dist", "test"] + }, + "context": { + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn", "aws-us-gov"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true, + "@aws-cdk/core:aspectPrioritiesMutating": true, + "@aws-cdk/aws-dynamodb:retainTableReplica": true, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true + } +} diff --git a/src/assets/cdk/gitignore.template b/src/assets/cdk/gitignore.template new file mode 100644 index 000000000..964b4d89c --- /dev/null +++ b/src/assets/cdk/gitignore.template @@ -0,0 +1,9 @@ +# Build output +dist/ + +# Dependencies +node_modules/ + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/src/assets/cdk/jest.config.js b/src/assets/cdk/jest.config.js new file mode 100644 index 000000000..0077a6547 --- /dev/null +++ b/src/assets/cdk/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'], +}; diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts new file mode 100644 index 000000000..3dac0669d --- /dev/null +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -0,0 +1,249 @@ +import { + AgentCoreApplication, + AgentCoreMcp, + AgentCorePaymentManager, + AgentCorePaymentConnector, + type AgentCoreProjectSpec, + type AgentCoreMcpSpec, + type CustomJWTAuthorizerConfig, + type HarnessDeploymentConfig, +} from '@aws/agentcore-cdk'; +import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +/** + * Harness deployment config: role-scoped fields (for IAM role + container build) + * plus the full validated spec + its config directory so the L3 construct can + * synthesize the AWS::BedrockAgentCore::Harness resource. + */ +export type HarnessConfig = HarnessDeploymentConfig; + +export interface PaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP' | 'StripePrivy'; + credentialProviderArn: string; +} + +export interface PaymentSpec { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: PaymentConnectorSpec[]; +} + +export interface AgentCoreStackProps extends StackProps { + /** + * The AgentCore project specification containing agents, memories, and credentials. + */ + spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; + /** + * Harness role configurations. + */ + harnesses?: HarnessConfig[]; + /** + * Parsed connectorParameters for non-S3 KB data sources, keyed by + * connectorConfigFile path. Forwarded to AgentCoreApplication. + */ + connectorParametersByFile?: Record>; + /** + * Payment specifications with resolved credential provider ARNs. + */ + paymentSpec?: PaymentSpec[]; +} + +function toCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Decide whether a deployed runtime should receive payment env vars + IAM grants. + * Payments today only ships a runtime shim for Python HTTP runtimes; injecting + * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes + * would surface env vars they cannot consume and would dilute least-privilege + * IAM grants for runtimes that never call ProcessPayment. + */ +function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { + if (agent.protocol && agent.protocol !== 'HTTP') { + return false; + } + const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + return entrypointFile.endsWith('.py'); +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; + + // Create AgentCoreApplication with all agents and harness roles + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appProps: Record = { spec }; + if (harnesses?.length) { + appProps.harnesses = harnesses; + } + if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) { + appProps.connectorParametersByFile = connectorParametersByFile; + } + if (credentials) { + appProps.credentials = credentials; + } + this.application = new AgentCoreApplication(this, 'Application', appProps as any); + + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } + + // Create payment infrastructure via CFN constructs + if (paymentSpec && paymentSpec.length > 0) { + for (const payment of paymentSpec) { + const mgrId = toCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { + projectName: spec.name, + name: payment.name, + authorizerType: payment.authorizerType, + description: payment.description, + authorizerConfiguration: payment.authorizerConfiguration, + tags: spec.tags, + }); + + const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; + + // Wire env vars from construct output tokens into eligible agent environments only. + // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that + // can consume these env vars, and giving them sts:AssumeRole on the + // ProcessPaymentRole would broaden the privilege surface unnecessarily. + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) { + continue; + } + env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); + env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); + + // Grant runtime execution role permission to assume the ProcessPaymentRole. + // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the + // caller still needs sts:AssumeRole on its own role to perform the assumption. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [manager.processPaymentRoleArn], + }) + ); + + // Grant payment data-plane actions directly to the runtime role. + // + // NOTE: This deviates from the canonical role model in the AgentCore Payments + // beta guide, which assigns Get/List/Create instrument+session actions to a + // separate ManagementRole and limits the agent's role to ProcessPayment only. + // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) + // calls GetPaymentInstrument internally during the 402 auto-pay path, so the + // runtime role needs read access. CreatePaymentSession is included so + // `agentcore invoke --auto-session` works without a separate ManagementRole + // call. Tighten this if the SDK is updated to accept pre-fetched instrument + // details and split create-session into a backend-only flow. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetPaymentInstrument', + 'bedrock-agentcore:ListPaymentInstruments', + 'bedrock-agentcore:GetPaymentInstrumentBalance', + 'bedrock-agentcore:GetPaymentSession', + 'bedrock-agentcore:ListPaymentSessions', + 'bedrock-agentcore:CreatePaymentSession', + 'bedrock-agentcore:ProcessPayment', + ], + resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], + }) + ); + + if (payment.autoPayment !== undefined) { + env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); + } + if (payment.paymentToolAllowlist) { + env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); + } + if (payment.networkPreferences) { + env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); + } + if (payment.authorizerType === 'CUSTOM_JWT') { + env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); + } + } + + // Create connectors for this manager + for (const connector of payment.connectors) { + const connId = toCdkId(connector.name); + const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connectorName: connector.name, + connectorType: connector.provider, + credentialProviderArn: connector.credentialProviderArn, + }); + + // Wire first connector's ID as env var (eligible agents only) + if (connector === payment.connectors[0]) { + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) continue; + env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); + } + } + + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + value: conn.paymentConnectorId, + }); + } + + // CFN Outputs for post-deploy state parsing + new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + value: manager.paymentManagerArn, + }); + new CfnOutput(this, `Payment${mgrId}ManagerId`, { + value: manager.paymentManagerId, + }); + new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + value: manager.processPaymentRoleArn, + }); + new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + value: manager.resourceRetrievalRoleArn, + }); + } + } + + // Stack-level output + new CfnOutput(this, 'StackNameOutput', { + description: 'Name of the CloudFormation Stack', + value: this.stackName, + }); + } +} diff --git a/src/assets/cdk/npmignore.template b/src/assets/cdk/npmignore.template new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/src/assets/cdk/npmignore.template @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json new file mode 100644 index 000000000..4646bfb3e --- /dev/null +++ b/src/assets/cdk/package.json @@ -0,0 +1,30 @@ +{ + "name": "agentcore-cdk-app", + "version": "0.1.0", + "bin": { + "cdk": "dist/bin/cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "npm run build && cdk", + "clean": "rm -rf dist", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^24.10.1", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "aws-cdk": "2.1126.0", + "prettier": "^3.4.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "@aws/agentcore-cdk": "^0.1.0-alpha.19", + "aws-cdk-lib": "^2.248.0", + "constructs": "^10.0.0" + } +} diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts new file mode 100644 index 000000000..8db318ada --- /dev/null +++ b/src/assets/cdk/test/cdk.test.ts @@ -0,0 +1,31 @@ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { AgentCoreStack } from '../lib/cdk-stack'; + +test('AgentCoreStack synthesizes with empty spec', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + }); + const template = Template.fromStack(stack); + template.hasOutput('StackNameOutput', { + Description: 'Name of the CloudFormation Stack', + }); +}); diff --git a/src/assets/cdk/tsconfig.json b/src/assets/cdk/tsconfig.json new file mode 100644 index 000000000..c70b0d444 --- /dev/null +++ b/src/assets/cdk/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": true, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types"], + "rootDir": ".", + "outDir": "dist" + }, + "include": ["bin/**/*", "lib/**/*", "test/**/*"], + "exclude": ["node_modules", "cdk.out", "dist"] +} diff --git a/src/fs/atomicWrite.test.ts b/src/fs/atomicWrite.test.ts new file mode 100644 index 000000000..391e4311b --- /dev/null +++ b/src/fs/atomicWrite.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { atomicWrite } from "./atomicWrite"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +async function tempDir(): Promise { + const d = await mkdtemp(join(tmpdir(), "atomic-write-")); + dirs.push(d); + return d; +} + +test("writes contents and leaves no temp file", async () => { + const dir = await tempDir(); + const target = join(dir, "out.txt"); + + await atomicWrite(target, "hello"); + + expect(await Bun.file(target).text()).toBe("hello"); + expect(await readdir(dir)).toEqual(["out.txt"]); +}); + +test("overwrites an existing file", async () => { + const dir = await tempDir(); + const target = join(dir, "out.txt"); + await writeFile(target, "old"); + + await atomicWrite(target, "new"); + + expect(await Bun.file(target).text()).toBe("new"); + expect(await readdir(dir)).toEqual(["out.txt"]); +}); + +test("cleans up the temp file when rename fails", async () => { + const dir = await tempDir(); + // Target path is a directory → rename onto it fails, exercising the catch. + const target = join(dir, "adir"); + await Bun.write(join(target, "keep"), "x"); + + await expect(atomicWrite(target, "data")).rejects.toThrow(); + expect(await readdir(dir)).toEqual(["adir"]); +}); diff --git a/src/fs/atomicWrite.ts b/src/fs/atomicWrite.ts new file mode 100644 index 000000000..68abdef90 --- /dev/null +++ b/src/fs/atomicWrite.ts @@ -0,0 +1,14 @@ +import { randomUUID } from "node:crypto"; +import { rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +export async function atomicWrite(path: string, contents: string | Uint8Array): Promise { + const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + try { + await writeFile(tempPath, contents); + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } +} diff --git a/src/fs/index.ts b/src/fs/index.ts new file mode 100644 index 000000000..5f8d9bd8d --- /dev/null +++ b/src/fs/index.ts @@ -0,0 +1 @@ +export { atomicWrite } from "./atomicWrite"; diff --git a/tsconfig.json b/tsconfig.json index b2e7497d8..bffe35ab3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["src/assets"] } From 0f84d7ff40d63ec0186cead6484be85f6c578d53 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:56:47 -0400 Subject: [PATCH 2/8] Remove comment Remove comment about directory injection for testability. --- src/assetManager/AssetManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts index 893a47973..420e905ae 100644 --- a/src/assetManager/AssetManager.ts +++ b/src/assetManager/AssetManager.ts @@ -7,7 +7,6 @@ import { atomicWrite } from "../fs"; import type { AssetFile, AssetName, AssetVariables, EmbeddedFile } from "./types"; // Bundled builds place `assets/` beside the module; source layout has it one up. -// Directory-injected so it's testable without a real install layout. export function resolveSourceRoot( moduleDirectory = dirname(fileURLToPath(import.meta.url)), ): string { From 2dff1b88dd978161ba83bbffe570e1f20355a626 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 10:38:12 -0400 Subject: [PATCH 3/8] fix(assets): strict template rendering and deterministic sort Address review feedback on the AssetManager: - Handlebars strict mode: a template referencing an undefined variable now throws instead of silently rendering an empty string into a generated project file. - drop the speculative `docker` branch from the ignore-template rename; only git/npm ignores ship as assets today. - use code-unit ordering for embedded files so both list paths (embedded and filesystem) sort identically and deterministically. --- src/assetManager/AssetManager.test.ts | 13 +++++++++++++ src/assetManager/AssetManager.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/AssetManager.test.ts index e969a4428..da74b262a 100644 --- a/src/assetManager/AssetManager.test.ts +++ b/src/assetManager/AssetManager.test.ts @@ -74,6 +74,19 @@ describe("AssetManager", () => { ); }); + test("throws on a template referencing an undefined variable", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(source, { recursive: true }); + await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); + + await expect( + new AssetManager([], join(root, "assets")).render("cdk", destination), + ).rejects.toThrow(/projectName/); + }); + test("rejects an embedded path that escapes the destination", async () => { const root = await makeTempDirectory(); const destination = join(root, "output"); diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts index 420e905ae..02c1a4402 100644 --- a/src/assetManager/AssetManager.ts +++ b/src/assetManager/AssetManager.ts @@ -42,7 +42,10 @@ export class AssetManager { const resolvedDestination = resolve(destination); for (const file of assetFiles) { const outputPath = this.resolveOutputPath(resolvedDestination, file.relativePath); - const rendered = this.handlebars.compile(await file.text(), { noEscape: true })(variables); + const rendered = this.handlebars.compile(await file.text(), { + noEscape: true, + strict: true, + })(variables); await mkdir(dirname(outputPath), { recursive: true }); await atomicWrite(outputPath, rendered); @@ -81,7 +84,7 @@ export class AssetManager { relativePath: file.name.slice(prefix.length), text: () => file.text(), })) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + .sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1)); } private resolveOutputPath(resolvedDestination: string, relativePath: string): string { @@ -102,7 +105,7 @@ export class AssetManager { private resolveTemplateName(relativePath: string): string { const filename = basename(relativePath); - const ignore = filename.match(/^(docker|git|npm)ignore\.template$/); + const ignore = filename.match(/^(git|npm)ignore\.template$/); return join(dirname(relativePath), ignore ? `.${ignore[1]}ignore` : filename); } } From 969193cb88d5d463280479bc8f74459ce098fa07 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 11:18:27 -0400 Subject: [PATCH 4/8] refactor(assets): rename to kebab-case, doc types, sync cdk pins Address review feedback from @Hweinstock: - rename AssetManager.ts -> manager.ts (only non-React .ts in the repo with an uppercase name; feature dirs use role-named lowercase files like core/project/manager.tsx). - document AssetFile vs EmbeddedFile so the distinction between a resolved tree entry and a raw Bun.embeddedFiles blob is explicit. - sync src/assets/cdk/package.json to the minor-version pins landed in #1777 (aws-cdk-lib ~2.261.0, @aws/agentcore-cdk 0.1.0-alpha.45, etc.). --- ...nager.test.ts.snap => manager.test.ts.snap} | 0 src/assetManager/index.ts | 2 +- .../{AssetManager.test.ts => manager.test.ts} | 2 +- .../{AssetManager.ts => manager.ts} | 0 src/assetManager/types.ts | 13 +++++++++++++ src/assets/cdk/package.json | 18 +++++++++--------- 6 files changed, 24 insertions(+), 11 deletions(-) rename src/assetManager/__snapshots__/{AssetManager.test.ts.snap => manager.test.ts.snap} (100%) rename src/assetManager/{AssetManager.test.ts => manager.test.ts} (98%) rename src/assetManager/{AssetManager.ts => manager.ts} (100%) diff --git a/src/assetManager/__snapshots__/AssetManager.test.ts.snap b/src/assetManager/__snapshots__/manager.test.ts.snap similarity index 100% rename from src/assetManager/__snapshots__/AssetManager.test.ts.snap rename to src/assetManager/__snapshots__/manager.test.ts.snap diff --git a/src/assetManager/index.ts b/src/assetManager/index.ts index a2679a04b..fa83cb979 100644 --- a/src/assetManager/index.ts +++ b/src/assetManager/index.ts @@ -1,2 +1,2 @@ -export { AssetManager } from "./AssetManager"; +export { AssetManager } from "./manager"; export { ASSET_NAMES, type AssetName, type AssetVariables } from "./types"; diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/manager.test.ts similarity index 98% rename from src/assetManager/AssetManager.test.ts rename to src/assetManager/manager.test.ts index da74b262a..19fa97009 100644 --- a/src/assetManager/AssetManager.test.ts +++ b/src/assetManager/manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { tmpdir } from "node:os"; -import { AssetManager, resolveSourceRoot } from "./AssetManager"; +import { AssetManager, resolveSourceRoot } from "./manager"; const tempDirectories: string[] = []; diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/manager.ts similarity index 100% rename from src/assetManager/AssetManager.ts rename to src/assetManager/manager.ts diff --git a/src/assetManager/types.ts b/src/assetManager/types.ts index a6310ae35..2351beb7f 100644 --- a/src/assetManager/types.ts +++ b/src/assetManager/types.ts @@ -3,10 +3,23 @@ export const ASSET_NAMES = ["cdk"] as const; export type AssetName = (typeof ASSET_NAMES)[number]; export type AssetVariables = Record; + +/** + * A single asset resolved to its position within an asset tree, ready to + * render. `relativePath` is relative to the asset root (e.g. `bin/cdk.ts`), + * regardless of whether the bytes came from disk or an embedded blob. + */ export interface AssetFile { relativePath: string; text(): Promise; } + +/** + * A raw file embedded in the compiled binary, as exposed by `Bun.embeddedFiles`. + * `name` is the full build-time virtual path (e.g. + * `agentcore-assets/src/assets/cdk/bin/cdk.ts`), which the manager strips down + * to an `AssetFile.relativePath`. + */ export interface EmbeddedFile { readonly name: string; text(): Promise; diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 4646bfb3e..550a52797 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -14,17 +14,17 @@ "format:check": "prettier --check ." }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^24.10.1", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", - "aws-cdk": "2.1126.0", - "prettier": "^3.4.2", + "@types/jest": "~29.5.14", + "@types/node": "~24.13.3", + "jest": "~29.7.0", + "ts-jest": "~29.4.11", + "aws-cdk": "~2.1126.0", + "prettier": "~3.9.5", "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", - "constructs": "^10.0.0" + "@aws/agentcore-cdk": "0.1.0-alpha.45", + "aws-cdk-lib": "~2.261.0", + "constructs": "~10.7.0" } } From 0c04a17359ea2bd20fa26bfe26ea68ea8d7f4876 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 11:23:17 -0400 Subject: [PATCH 5/8] docs(assets): note cdk README commands are provisional Add a source-only TODO (HTML comment, invisible in the rendered README shipped to generated projects) flagging that the agentcore deploy/status commands may need a project prefix once the project CLI surface is final. --- src/assets/cdk/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/assets/cdk/README.md b/src/assets/cdk/README.md index 5fa522fc6..be548731f 100644 --- a/src/assets/cdk/README.md +++ b/src/assets/cdk/README.md @@ -20,6 +20,9 @@ This CDK project is managed by the AgentCore CLI. It deploys your agent infrastr You typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment: + + ```bash agentcore deploy # synthesizes and deploys via CDK agentcore status # checks deployment status From e0ea1cc3c1b1867e520d370859bfdf67f08a5ab6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 28 Jul 2026 15:40:56 -0400 Subject: [PATCH 6/8] refactor(project): tree-based scaffolding with runtime-blind asset source Replace AssetManager/render with a project tree: a runtime-blind writer (writeTree over dir/file nodes) fed by a single asset-access seam (Source) that reads from disk under Node and from Bun.embeddedFiles in the compiled executable. scripts/build.ts embeds assets by naming so no per-file import attributes are needed. - project create takes a required --project-name and scaffolds into a fresh .// directory (writeTree refuses to clobber existing files) - Template carries a spec fragment spread over the fixed base, so a new template (runtimes/memories/harnesses) is a pure-data entry --- .gitignore | 2 + package.json | 14 +- scripts/build.ts | 99 ++++++++++++ .../__snapshots__/manager.test.ts.snap | 17 --- src/assetManager/index.ts | 2 - src/assetManager/manager.test.ts | 142 ------------------ src/assetManager/manager.ts | 111 -------------- src/assetManager/types.ts | 26 ---- .../templates/hello-world-python/main.py | 17 +++ .../hello-world-python/pyproject.toml | 19 +++ src/core/project/manager.tsx | 25 ++- src/handlers/project/create/index.ts | 8 +- src/handlers/project/project.test.ts | 41 ++++- src/handlers/project/types.ts | 15 +- .../__snapshots__/compose.test.ts.snap | 21 +++ src/project/compose.test.ts | 59 ++++++++ src/project/compose.ts | 75 +++++++++ src/project/source.test.ts | 46 ++++++ src/project/source.ts | 80 ++++++++++ src/project/templates.ts | 38 +++++ src/project/tree.test.ts | 44 ++++++ src/project/tree.ts | 53 +++++++ 22 files changed, 636 insertions(+), 318 deletions(-) create mode 100644 scripts/build.ts delete mode 100644 src/assetManager/__snapshots__/manager.test.ts.snap delete mode 100644 src/assetManager/index.ts delete mode 100644 src/assetManager/manager.test.ts delete mode 100644 src/assetManager/manager.ts delete mode 100644 src/assetManager/types.ts create mode 100644 src/assets/templates/hello-world-python/main.py create mode 100644 src/assets/templates/hello-world-python/pyproject.toml create mode 100644 src/project/__snapshots__/compose.test.ts.snap create mode 100644 src/project/compose.test.ts create mode 100644 src/project/compose.ts create mode 100644 src/project/source.test.ts create mode 100644 src/project/source.ts create mode 100644 src/project/templates.ts create mode 100644 src/project/tree.test.ts create mode 100644 src/project/tree.ts diff --git a/.gitignore b/.gitignore index 3da67f960..89cc7556b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +.agentreview diff --git a/package.json b/package.json index 527b42925..caca70296 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "dist" ], "scripts": { - "build": "bun build ./src/index.ts --target node --outdir ./dist --minify", + "build": "bun scripts/build.ts bundle", "compile": "bun run compile:darwin-x64 && bun run compile:darwin-arm64 && bun run compile:linux-x64 && bun run compile:linux-arm64 && bun run compile:windows-x64 && bun run compile:windows-arm64", - "compile:darwin-x64": "bun build --compile --minify --target=bun-darwin-x64 ./src/index.ts --outfile dist/bin/agentcore-darwin-x64", - "compile:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 ./src/index.ts --outfile dist/bin/agentcore-darwin-arm64", - "compile:linux-x64": "bun build --compile --minify --target=bun-linux-x64 ./src/index.ts --outfile dist/bin/agentcore-linux-x64", - "compile:linux-arm64": "bun build --compile --minify --target=bun-linux-arm64 ./src/index.ts --outfile dist/bin/agentcore-linux-arm64", - "compile:windows-x64": "bun build --compile --minify --target=bun-windows-x64 ./src/index.ts --outfile dist/bin/agentcore-windows-x64", - "compile:windows-arm64": "bun build --compile --minify --target=bun-windows-arm64 ./src/index.ts --outfile dist/bin/agentcore-windows-arm64", + "compile:darwin-x64": "bun scripts/build.ts compile bun-darwin-x64", + "compile:darwin-arm64": "bun scripts/build.ts compile bun-darwin-arm64", + "compile:linux-x64": "bun scripts/build.ts compile bun-linux-x64", + "compile:linux-arm64": "bun scripts/build.ts compile bun-linux-arm64", + "compile:windows-x64": "bun scripts/build.ts compile bun-windows-x64", + "compile:windows-arm64": "bun scripts/build.ts compile bun-windows-arm64", "start": "bun run src/index.ts", "test": "bun test", "typecheck": "tsc --noEmit", diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 000000000..74104274a --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import { join, resolve } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const ASSETS_DIR = join(REPO_ROOT, "src", "assets"); +const ENTRYPOINT = join(REPO_ROOT, "src", "index.ts"); +const DIST = join(REPO_ROOT, "dist"); + +const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; + +/** Absolute paths of every asset file. dot:true so hidden files (.prettierrc) are included. */ +function discoverAssets(): string[] { + const files = [...new Bun.Glob("**/*").scanSync({ cwd: ASSETS_DIR, onlyFiles: true, dot: true })]; + return files.sort().map((relativePath) => join(ASSETS_DIR, relativePath)); +} + +/** Force asset files through the file loader so template .ts/.js are embedded as bytes, not compiled. */ +function assetLoaderPlugin(): Bun.BunPlugin { + return { + name: "asset-file-loader", + setup(build) { + build.onLoad({ filter: /src[/\\]assets[/\\]/ }, async ({ path }) => ({ + contents: await Bun.file(path).bytes(), + loader: "file", + })); + }, + }; +} + +/** Fail loudly on a non-UTF-8 asset — the source reads every asset as text. */ +async function assertAssetsAreText(assets: string[]): Promise { + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (const path of assets) { + try { + decoder.decode(await Bun.file(path).bytes()); + } catch { + throw new Error(`Asset is not valid UTF-8: ${path}`); + } + } +} + +function reportAndExit(result: Bun.BuildOutput): void { + if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); + } +} + +async function bundle(): Promise { + const result = await Bun.build({ + entrypoints: [ENTRYPOINT], + outdir: DIST, + target: "node", + minify: true, + }); + reportAndExit(result); + + // Mirror assets beside the emitted module for resolveAssetsRoot(). + const distAssets = join(DIST, "assets"); + await $`rm -rf ${distAssets}`; + await $`cp -R ${ASSETS_DIR} ${distAssets}`; + console.log(`Bundled to ${join(DIST, "index.js")} with assets/`); +} + +async function compile(target: string): Promise { + const assets = discoverAssets(); + await assertAssetsAreText(assets); + + const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`); + await $`mkdir -p ${join(DIST, "bin")}`; + + const result = await Bun.build({ + entrypoints: [ENTRYPOINT, ...assets], + compile: { target: target as Bun.Build.CompileTarget, outfile }, + minify: true, + root: REPO_ROOT, + naming: { asset: ASSET_NAMING }, + plugins: [assetLoaderPlugin()], + }); + reportAndExit(result); + console.log(`Compiled ${target} → ${outfile} (${assets.length} assets embedded)`); +} + +const [command, target] = process.argv.slice(2); + +if (command === "bundle") { + await bundle(); +} else if (command === "compile") { + if (!target) { + console.error("Usage: bun scripts/build.ts compile "); + process.exit(1); + } + await compile(target); +} else { + console.error("Usage: bun scripts/build.ts >"); + process.exit(1); +} diff --git a/src/assetManager/__snapshots__/manager.test.ts.snap b/src/assetManager/__snapshots__/manager.test.ts.snap deleted file mode 100644 index 0738c7ac1..000000000 --- a/src/assetManager/__snapshots__/manager.test.ts.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots - -exports[`AssetManager cdk renders the expected output tree 1`] = ` -[ - ".gitignore", - ".npmignore", - ".prettierrc", - "README.md", - "bin/cdk.ts", - "cdk.json", - "jest.config.js", - "lib/cdk-stack.ts", - "package.json", - "test/cdk.test.ts", - "tsconfig.json", -] -`; diff --git a/src/assetManager/index.ts b/src/assetManager/index.ts deleted file mode 100644 index fa83cb979..000000000 --- a/src/assetManager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AssetManager } from "./manager"; -export { ASSET_NAMES, type AssetName, type AssetVariables } from "./types"; diff --git a/src/assetManager/manager.test.ts b/src/assetManager/manager.test.ts deleted file mode 100644 index 19fa97009..000000000 --- a/src/assetManager/manager.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"; -import { join, relative, resolve } from "node:path"; -import { tmpdir } from "node:os"; -import { AssetManager, resolveSourceRoot } from "./manager"; - -const tempDirectories: string[] = []; - -async function makeTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-assets-")); - tempDirectories.push(directory); - return directory; -} - -afterEach(async () => { - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -describe("AssetManager", () => { - test("renders a filesystem asset tree", async () => { - const root = await makeTempDirectory(); - const source = join(root, "assets", "cdk"); - const destination = join(root, "output"); - - await mkdir(join(source, "lib"), { recursive: true }); - await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); - await Bun.write(join(source, ".prettierrc"), "{}"); - await Bun.write(join(source, "gitignore.template"), "dist/"); - await Bun.write(join(source, "lib", "stack.ts"), "export const name = '{{projectName}}';"); - - await new AssetManager([], join(root, "assets")).render("cdk", destination, { - projectName: "example", - }); - - expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello example"); - expect(await Bun.file(join(destination, ".prettierrc")).text()).toBe("{}"); - expect(await Bun.file(join(destination, ".gitignore")).text()).toBe("dist/"); - expect(await Bun.file(join(destination, "lib", "stack.ts")).text()).toBe( - "export const name = 'example';", - ); - }); - - test("renders embedded asset files", async () => { - const root = await makeTempDirectory(); - const destination = join(root, "output"); - const embedded = new File( - ["Hello {{projectName}}"], - "agentcore-assets/src/assets/cdk/README.md", - ); - - await new AssetManager([embedded]).render("cdk", destination, { - projectName: "embedded", - }); - - expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello embedded"); - }); - - test("does not HTML-escape code template values", async () => { - const root = await makeTempDirectory(); - const source = join(root, "assets", "cdk"); - const destination = join(root, "output"); - - await mkdir(source, { recursive: true }); - await Bun.write(join(source, "config.ts"), "export const expr = {{expr}};"); - - await new AssetManager([], join(root, "assets")).render("cdk", destination, { - expr: "a && b < c", - }); - - expect(await Bun.file(join(destination, "config.ts")).text()).toBe( - "export const expr = a && b < c;", - ); - }); - - test("throws on a template referencing an undefined variable", async () => { - const root = await makeTempDirectory(); - const source = join(root, "assets", "cdk"); - const destination = join(root, "output"); - - await mkdir(source, { recursive: true }); - await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); - - await expect( - new AssetManager([], join(root, "assets")).render("cdk", destination), - ).rejects.toThrow(/projectName/); - }); - - test("rejects an embedded path that escapes the destination", async () => { - const root = await makeTempDirectory(); - const destination = join(root, "output"); - const embedded = new File( - ["pwned"], - "agentcore-assets/src/assets/cdk/../../../../../../etc/evil", - ); - - await expect(new AssetManager([embedded]).render("cdk", destination)).rejects.toThrow( - "Unsafe asset path", - ); - }); - - describe("resolveSourceRoot", () => { - test("returns the bundled root when assets/ sits beside the module", async () => { - const moduleDir = await makeTempDirectory(); - await mkdir(join(moduleDir, "assets"), { recursive: true }); - - expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "assets")); - }); - - test("falls back to ../assets when no sibling assets/ exists", async () => { - const moduleDir = await makeTempDirectory(); - - expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "../assets")); - }); - }); - - test("cdk renders the expected output tree", async () => { - // Real cdk asset, source-tree mode (no injected root, no embedded files). - // Snapshots the rendered manifest so adding/removing/renaming an asset file - // is a reviewable diff. Content is covered by the byte-for-byte tests above. - const destination = await makeTempDirectory(); - await new AssetManager().render("cdk", destination); - - const rendered = (await readdir(destination, { recursive: true, withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => - relative(destination, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), - ) - .sort(); - - expect(rendered).toMatchSnapshot(); - }); - - test("rejects a missing asset", async () => { - const root = await makeTempDirectory(); - - await expect( - new AssetManager([], join(root, "assets")).render("cdk", join(root, "output")), - ).rejects.toThrow("Asset 'cdk' does not exist"); - }); -}); diff --git a/src/assetManager/manager.ts b/src/assetManager/manager.ts deleted file mode 100644 index 02c1a4402..000000000 --- a/src/assetManager/manager.ts +++ /dev/null @@ -1,111 +0,0 @@ -import Handlebars from "handlebars"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, readdir } from "node:fs/promises"; -import { basename, dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { atomicWrite } from "../fs"; -import type { AssetFile, AssetName, AssetVariables, EmbeddedFile } from "./types"; - -// Bundled builds place `assets/` beside the module; source layout has it one up. -export function resolveSourceRoot( - moduleDirectory = dirname(fileURLToPath(import.meta.url)), -): string { - const bundledRoot = resolve(moduleDirectory, "assets"); - if (existsSync(bundledRoot)) { - return bundledRoot; - } - - return resolve(moduleDirectory, "../assets"); -} - -export class AssetManager { - private handlebars = Handlebars.create(); - constructor( - private readonly embeddedFiles: readonly EmbeddedFile[] = [], - private readonly sourceRoot?: string, - ) {} - - async render( - asset: AssetName, - destination: string, - variables: AssetVariables = {}, - ): Promise { - const assetFiles = - this.embeddedFiles.length > 0 - ? this.listEmbeddedFiles(asset) - : await this.listFileSystemFiles(asset); - - if (assetFiles.length === 0) { - throw new Error(`Asset '${asset}' does not exist`); - } - - const resolvedDestination = resolve(destination); - for (const file of assetFiles) { - const outputPath = this.resolveOutputPath(resolvedDestination, file.relativePath); - const rendered = this.handlebars.compile(await file.text(), { - noEscape: true, - strict: true, - })(variables); - - await mkdir(dirname(outputPath), { recursive: true }); - await atomicWrite(outputPath, rendered); - } - } - - private async listFileSystemFiles(asset: AssetName): Promise { - const assetRoot = join(this.sourceRoot ?? resolveSourceRoot(), asset); - - let entries; - try { - entries = await readdir(assetRoot, { recursive: true, withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return []; - } - throw error; - } - - return entries - .filter((entry) => entry.isFile()) - .map((entry) => relative(assetRoot, join(entry.parentPath, entry.name))) - .sort() - .map((relativePath) => ({ - relativePath, - text: () => readFile(join(assetRoot, relativePath), "utf8"), - })); - } - - private listEmbeddedFiles(asset: AssetName): AssetFile[] { - const prefix = `agentcore-assets/src/assets/${asset}/`; - - return this.embeddedFiles - .filter((file) => file.name.startsWith(prefix)) - .map((file) => ({ - relativePath: file.name.slice(prefix.length), - text: () => file.text(), - })) - .sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1)); - } - - private resolveOutputPath(resolvedDestination: string, relativePath: string): string { - const mapped = this.resolveTemplateName(relativePath).replaceAll("\\", "/"); - const segments = mapped.split("/"); - - if (mapped.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) { - throw new Error(`Unsafe asset path '${relativePath}'`); - } - - const outputPath = resolve(resolvedDestination, mapped); - if (outputPath !== resolvedDestination && !outputPath.startsWith(resolvedDestination + sep)) { - throw new Error(`Asset path '${relativePath}' escapes destination`); - } - - return outputPath; - } - - private resolveTemplateName(relativePath: string): string { - const filename = basename(relativePath); - const ignore = filename.match(/^(git|npm)ignore\.template$/); - return join(dirname(relativePath), ignore ? `.${ignore[1]}ignore` : filename); - } -} diff --git a/src/assetManager/types.ts b/src/assetManager/types.ts deleted file mode 100644 index 2351beb7f..000000000 --- a/src/assetManager/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const ASSET_NAMES = ["cdk"] as const; - -export type AssetName = (typeof ASSET_NAMES)[number]; - -export type AssetVariables = Record; - -/** - * A single asset resolved to its position within an asset tree, ready to - * render. `relativePath` is relative to the asset root (e.g. `bin/cdk.ts`), - * regardless of whether the bytes came from disk or an embedded blob. - */ -export interface AssetFile { - relativePath: string; - text(): Promise; -} - -/** - * A raw file embedded in the compiled binary, as exposed by `Bun.embeddedFiles`. - * `name` is the full build-time virtual path (e.g. - * `agentcore-assets/src/assets/cdk/bin/cdk.ts`), which the manager strips down - * to an `AssetFile.relativePath`. - */ -export interface EmbeddedFile { - readonly name: string; - text(): Promise; -} diff --git a/src/assets/templates/hello-world-python/main.py b/src/assets/templates/hello-world-python/main.py new file mode 100644 index 000000000..182bfd57e --- /dev/null +++ b/src/assets/templates/hello-world-python/main.py @@ -0,0 +1,17 @@ +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from strands import Agent + +app = BedrockAgentCoreApp() +agent = Agent(system_prompt="You are a helpful assistant.") + + +@app.entrypoint +async def invoke(payload, context): + """Stream the agent's response to the caller's prompt.""" + prompt = payload.get("prompt", "Hello!") + async for event in agent.stream_async(prompt): + yield event + + +if __name__ == "__main__": + app.run() diff --git a/src/assets/templates/hello-world-python/pyproject.toml b/src/assets/templates/hello-world-python/pyproject.toml new file mode 100644 index 000000000..09070bf8e --- /dev/null +++ b/src/assets/templates/hello-world-python/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hello-world" +version = "0.1.0" +description = "AgentCore Runtime application using the Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "aws-opentelemetry-distro", + "bedrock-agentcore >= 1.9.1", + "botocore[crt] >= 1.35.0", + "strands-agents >= 1.15.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 5e91ff693..e58ecb0aa 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,3 +1,4 @@ +import { join } from "node:path"; import type { CreateProjectInput, ResolveProjectInput, @@ -5,22 +6,40 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; +import { projectTree } from "../../project/compose"; +import { defaultSource, type Source } from "../../project/source"; +import { writeTree } from "../../project/tree"; type ProjectManagerConfig = { logger: Logger; + /** Asset source; defaults to the current runtime's (disk or embedded). */ + source?: Source; }; /** * An implementation of {@link ProjectManager} that relies on the local file system to manage projects. */ export class FsProjectManager implements ProjectManager { - constructor(_config: ProjectManagerConfig) {} + private readonly logger: Logger; + private readonly source: Source; + + constructor(config: ProjectManagerConfig) { + this.logger = config.logger; + this.source = config.source ?? defaultSource(); + } public resolve(_input: ResolveProjectInput): Promise { throw new Error(`ProjectManager.resolve is not implemented yet`); } - public create(_input: CreateProjectInput): Promise { - throw new Error(`ProjectManager.create is not implemented yet`); + public async create(input: CreateProjectInput): Promise { + // Scaffold into a fresh directory. + const destination = join(process.cwd(), input.name); + this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); + + const tree = await projectTree(input.name, input.template, this.source); + await writeTree(tree, destination); + + return { name: input.name }; } } diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index aef7c9788..9967cdb7e 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,6 +1,6 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; -import { PROJECT_TEMPLATES, type ProjectManager } from "../types"; +import { PROJECT_TEMPLATES, ProjectNameSchema, type ProjectManager } from "../types"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; @@ -11,15 +11,17 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = name: "create", description: "create a new AgentCore project", flags: [ + flag("project-name", "name of the project to create", ProjectNameSchema), flag( "template", "project template to scaffold from", - z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.BAREBONES), + z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.HELLO_WORLD_PYTHON), ), ], handle: async (_ctx, flags) => { await config.projectManager.create({ - template: flags.template, + name: flags["project-name"], + template: flags["template"], }); }, }); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d92defe5e..39007d668 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,4 +1,7 @@ -import { test, expect, describe } from "bun:test"; +import { afterEach, test, expect, describe } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, @@ -23,16 +26,42 @@ describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s }); }); +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-project-")); + tempDirectories.push(directory); + process.chdir(directory); + return directory; +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + describe("project create", () => { - test("throws because it is not implemented yet", async () => { - await expect(run(["create"])).rejects.toThrow(/not implemented/); + test("scaffolds the project into a fresh directory named for the project", async () => { + const directory = await inTempDirectory(); + await run(["create", "--project-name", "MyAgent"]); + + const projectRoot = join(directory, "MyAgent"); + expect(await Bun.file(join(projectRoot, "agentcore.json")).exists()).toBe(true); + expect(await Bun.file(join(projectRoot, "app", "hello-world", "main.py")).exists()).toBe(true); }); - test("accepts a known --template value", async () => { - await expect(run(["create", "--template", "barebones"])).rejects.toThrow(/not implemented/); + test("rejects an invalid --project-name", async () => { + await inTempDirectory(); + await expect(run(["create", "--project-name", "1-bad"])).rejects.toThrow(); }); test("rejects an unknown --template value", async () => { - await expect(run(["create", "--template", "nonsense"])).rejects.toThrow(); + await inTempDirectory(); + await expect( + run(["create", "--project-name", "MyAgent", "--template", "nonsense"]), + ).rejects.toThrow(); }); }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 1215cfd51..c05564111 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,11 +1,24 @@ +import z from "zod"; + /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { - BAREBONES: "barebones", + HELLO_WORLD_PYTHON: "hello-world-python", } as const; export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; +export const ProjectNameSchema = z + .string() + .min(1, "project name is required") + .max(23, "project name must be 23 characters or less") + .regex( + /^[A-Za-z][A-Za-z0-9]{0,22}$/, + "project name must start with a letter and contain only letters and digits", + ); + export type CreateProjectInput = { + /** The name of the project; also the directory it is scaffolded into. */ + name: string; /** The project template to scaffold from. */ template: ProjectTemplate; }; diff --git a/src/project/__snapshots__/compose.test.ts.snap b/src/project/__snapshots__/compose.test.ts.snap new file mode 100644 index 000000000..4bee05b76 --- /dev/null +++ b/src/project/__snapshots__/compose.test.ts.snap @@ -0,0 +1,21 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`projectTree scaffolds the expected file tree 1`] = ` +[ + "agentcore.json", + "agentcore/aws-targets.json", + "agentcore/cdk/.gitignore", + "agentcore/cdk/.npmignore", + "agentcore/cdk/.prettierrc", + "agentcore/cdk/README.md", + "agentcore/cdk/bin/cdk.ts", + "agentcore/cdk/cdk.json", + "agentcore/cdk/jest.config.js", + "agentcore/cdk/lib/cdk-stack.ts", + "agentcore/cdk/package.json", + "agentcore/cdk/test/cdk.test.ts", + "agentcore/cdk/tsconfig.json", + "app/hello-world/main.py", + "app/hello-world/pyproject.toml", +] +`; diff --git a/src/project/compose.test.ts b/src/project/compose.test.ts new file mode 100644 index 000000000..b8e930ed8 --- /dev/null +++ b/src/project/compose.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; +import { projectTree } from "./compose"; +import { fileSource } from "./source"; +import { writeTree } from "./tree"; +import { PROJECT_TEMPLATES } from "../handlers/project/types"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-compose-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("projectTree", () => { + // Renders the real hello-world-python tree from disk and snapshots the file + // manifest, so adding/removing/renaming a scaffolded file is a reviewable diff. + test("scaffolds the expected file tree", async () => { + const destination = await makeTempDirectory(); + const tree = await projectTree("example", PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, fileSource()); + await writeTree(tree, destination); + + const manifest = (await readdir(destination, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(destination, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); + + expect(manifest).toMatchSnapshot(); + }); + + test("writes a deploy-ready agentcore.json registering the template agent", async () => { + const destination = await makeTempDirectory(); + const tree = await projectTree("example", PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, fileSource()); + await writeTree(tree, destination); + + const spec = await Bun.file(join(destination, "agentcore.json")).json(); + expect(spec.name).toBe("example"); + expect(spec.runtimes).toEqual([ + { + name: "hello-world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ]); + expect(await Bun.file(join(destination, "agentcore", "aws-targets.json")).json()).toEqual([]); + }); +}); diff --git a/src/project/compose.ts b/src/project/compose.ts new file mode 100644 index 000000000..14e58429a --- /dev/null +++ b/src/project/compose.ts @@ -0,0 +1,75 @@ +import type { DirNode, ProjectNode } from "./tree"; +import { dir, file } from "./tree"; +import type { Source } from "./source"; +import { TEMPLATES } from "./templates"; +import type { ProjectTemplate } from "../handlers/project/types"; + +/** Serializes a value as pretty-printed JSON with a trailing newline. */ +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +/** + * Builds the child nodes for agentcore/cdk/ by expanding the listing under cdk/ + * into a nested tree. `gitignore.template` + */ +async function expandDir(src: Source, assetDir: string): Promise { + const paths = await src.list(assetDir); + const root: ProjectNode[] = []; + + for (const assetPath of paths) { + const relative = assetPath.slice(assetDir.length + 1); + const segments = relative.split("/"); + if (segments.some((s) => s === "" || s === "." || s === "..")) { + throw new Error(`Unsafe asset path: ${assetPath}`); + } + + let cursor = root; + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor.push(file(renderName(segment), src.read(assetPath))); + return; + } + let child = cursor.find((n): n is DirNode => n.kind === "dir" && n.name === segment); + if (!child) { + child = dir(segment, []); + cursor.push(child); + } + cursor = child.children; + }); + } + + return root; +} + +function renderName(filename: string): string { + const ignore = filename.match(/^(git|npm)ignore\.template$/); + return ignore ? `.${ignore[1]}ignore` : filename; +} + +// The fixed base every project shares (name/version/managedBy), with the +// template's resource sections spread on top. Sections are template-specific +// and never collide with the base, so this is a spread, not a merge. +function agentcoreSpec(name: string, template: ProjectTemplate): unknown { + return { + name, + version: 1, + managedBy: "CDK", + ...TEMPLATES[template].spec, + }; +} + +/** Composes the full project tree for a template rooted at the destination */ +export async function projectTree( + name: string, + template: ProjectTemplate, + src: Source, +): Promise { + const { appDir, assetDir } = TEMPLATES[template]; + return dir(".", [ + dir("agentcore", [ + dir("cdk", await expandDir(src, "cdk")), + file("aws-targets.json", async () => json([])), + ]), + file("agentcore.json", async () => json(agentcoreSpec(name, template))), + dir("app", [dir(appDir, await expandDir(src, assetDir))]), + ]); +} diff --git a/src/project/source.test.ts b/src/project/source.test.ts new file mode 100644 index 000000000..6466fb770 --- /dev/null +++ b/src/project/source.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { embeddedSource, fileSource } from "./source"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-source-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("fileSource", () => { + test("reads an asset relative to the assets root", async () => { + const root = await makeTempDirectory(); + await mkdir(join(root, "cdk"), { recursive: true }); + await Bun.write(join(root, "cdk", "package.json"), "{}"); + + expect(await fileSource(root).read("cdk/package.json")()).toBe("{}"); + }); + + test("lists files under a directory as sorted, forward-slash asset paths", async () => { + const root = await makeTempDirectory(); + await mkdir(join(root, "cdk", "bin"), { recursive: true }); + await Bun.write(join(root, "cdk", "package.json"), "{}"); + await Bun.write(join(root, "cdk", "bin", "cdk.ts"), ""); + + expect(await fileSource(root).list("cdk")).toEqual(["cdk/bin/cdk.ts", "cdk/package.json"]); + }); +}); + +describe("embeddedSource", () => { + // Bun.embeddedFiles is empty outside a compiled executable, so any lookup + // misses — this exercises the miss path deterministically in `bun test`. + test("throws when the asset is not embedded", () => { + expect(() => embeddedSource.read("cdk/package.json")()).toThrow(/Embedded asset not found/); + }); +}); diff --git a/src/project/source.ts b/src/project/source.ts new file mode 100644 index 000000000..a326634e1 --- /dev/null +++ b/src/project/source.ts @@ -0,0 +1,80 @@ +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"; + +/** + * Provides an interface for abstracting bun executable asset reading + * and Node asset reading + */ +export interface Source { + /** Returns a thunk that 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; +} + +// Embedded assets are keyed by the build-time virtual path (see scripts/build.ts ASSET_NAMING) +const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; + +// A standalone executable exposes its assets through Bun.embeddedFiles as +// File blobs carrying their build-time name +type NamedBlob = Blob & { readonly name: string }; + +const embeddedBlobs = () => Bun.embeddedFiles as readonly NamedBlob[]; + +/** Reads assets from Bun.embeddedFiles — for the compiled standalone executable. */ +export const embeddedSource: Source = { + read(assetPath) { + const name = `${EMBEDDED_PREFIX}${assetPath}`; + return () => { + const blob = embeddedBlobs().find((f) => f.name === name); + if (!blob) { + throw new Error(`Embedded asset not found: ${assetPath}`); + } + return blob.text(); + }; + }, + async list(assetDir) { + const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; + return embeddedBlobs() + .filter((f) => f.name.startsWith(prefix)) + .map((f) => f.name.slice(EMBEDDED_PREFIX.length)) + .sort(); + }, +}; + +/** Reads assets from src/assets/ on disk — for the Node/Bun runtime from source. */ +export function fileSource(assetsRoot = resolveAssetsRoot()): Source { + return { + read: (assetPath) => () => readFile(join(assetsRoot, assetPath), "utf8"), + async list(assetDir) { + const root = join(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 but source layout has it +// one level up from this module +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(): Source { + const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; + return embedded ? embeddedSource : fileSource(); +} diff --git a/src/project/templates.ts b/src/project/templates.ts new file mode 100644 index 000000000..2d4bcd042 --- /dev/null +++ b/src/project/templates.ts @@ -0,0 +1,38 @@ +import { PROJECT_TEMPLATES, type ProjectTemplate } from "../handlers/project/types"; + +interface TemplateSpec { + runtimes?: unknown[]; + memories?: unknown[]; + harnesses?: unknown[]; +} + +/** + * A project template: the agent code it scaffolds under `app/` and the resource + * sections it registers in `agentcore.json`. Adding a template is one entry here + * plus its assets under `src/assets/` + */ +interface Template { + /** Directory under `app/` the template's code is written to. */ + appDir: string; + /** Asset directory (relative to the asset root) expanded into `app/`. */ + assetDir: string; + /** Resource sections this template contributes to `agentcore.json`. */ + spec: TemplateSpec; +} + +export const TEMPLATES: Record = { + [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { + appDir: "hello-world", + assetDir: "templates/hello-world-python", + spec: { + runtimes: [ + { + name: "hello-world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ], + }, + }, +}; diff --git a/src/project/tree.test.ts b/src/project/tree.test.ts new file mode 100644 index 000000000..9cff3063a --- /dev/null +++ b/src/project/tree.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { dir, file, writeTree } from "./tree"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-tree-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("writeTree", () => { + test("writes a nested tree to disk", async () => { + const destination = await makeTempDirectory(); + const tree = dir("project", [ + file("agentcore.json", async () => "{}"), + dir("app", [file("main.py", async () => "print('hi')")]), + ]); + + await writeTree(tree, destination); + + const root = join(destination, "project"); + expect(await Bun.file(join(root, "agentcore.json")).text()).toBe("{}"); + expect(await Bun.file(join(root, "app", "main.py")).text()).toBe("print('hi')"); + }); + + test("refuses to overwrite an existing file", async () => { + const destination = await makeTempDirectory(); + const tree = dir("project", [file("keep.txt", async () => "new")]); + + await writeTree(tree, destination); + await expect(writeTree(tree, destination)).rejects.toThrow(/Refusing to overwrite/); + expect(await Bun.file(join(destination, "project", "keep.txt")).text()).toBe("new"); + }); +}); diff --git a/src/project/tree.ts b/src/project/tree.ts new file mode 100644 index 000000000..ebcd854e2 --- /dev/null +++ b/src/project/tree.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { atomicWrite } from "../fs"; + +/** + * A node in a project's file tree. + */ +export type ProjectNode = DirNode | FileNode; + +export interface DirNode { + kind: "dir"; + name: string; + children: ProjectNode[]; +} + +export interface FileNode { + kind: "file"; + name: string; + bytes: () => Promise; +} + +export const dir = (name: string, children: ProjectNode[]): DirNode => ({ + kind: "dir", + name, + children, +}); + +export const file = (name: string, bytes: () => Promise): FileNode => ({ + kind: "file", + name, + bytes, +}); + +/** + * Write a project tree to `destination`. Directories are created recursively; + * files are written atomically. + */ +export async function writeTree(node: ProjectNode, destination: string): Promise { + const path = join(destination, node.name); + if (node.kind === "dir") { + await mkdir(path, { recursive: true }); + for (const child of node.children) { + await writeTree(child, path); + } + return; + } + + if (existsSync(path)) { + throw new Error(`Refusing to overwrite existing file: ${path}`); + } + await atomicWrite(path, await node.bytes()); +} From 675cc66f4f0e90e17ace5ff38c21049cb29e4108 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 29 Jul 2026 11:05:27 -0400 Subject: [PATCH 7/8] fix(project): address review feedback on tree-based scaffolding - write agentcore.json inside agentcore/ so ConfigIO project discovery finds it - mirror the CDK schema's reserved-name validation on --project-name - rename the hello world runtime to hello_world (AgentNameSchema forbids hyphens) - keep identifiers un-minified in builds so stack traces stay readable - rebuild scripts/build.ts on runWithExitCode; Bun.build already throws on failure so reportAndExit was dead code - rename Source to AssetSource; read() returns data, laziness moved to compose - add modeled ProjectFileExistsError - move src/project under src/core/project; test through FsProjectManager.create - extract config resolvers in the vended bin/cdk.ts and fail early on zero targets (typechecked against the built @aws/agentcore-cdk) --- scripts/build.ts | 52 ++++----- src/assets/cdk/bin/cdk.ts | 100 ++++++++++-------- .../__snapshots__/manager.test.ts.snap} | 4 +- src/{ => core}/project/compose.ts | 28 ++--- src/core/project/manager.test.ts | 72 +++++++++++++ src/core/project/manager.tsx | 11 +- src/core/project/source.test.ts | 8 ++ src/{ => core}/project/source.ts | 46 ++++---- src/core/project/templates.ts | 37 +++++++ src/{ => core}/project/tree.ts | 19 +++- src/handlers/project/project.test.ts | 10 +- src/handlers/project/types.ts | 69 +++++++++++- src/project/compose.test.ts | 59 ----------- src/project/source.test.ts | 46 -------- src/project/templates.ts | 38 ------- src/project/tree.test.ts | 44 -------- 16 files changed, 335 insertions(+), 308 deletions(-) rename src/{project/__snapshots__/compose.test.ts.snap => core/project/__snapshots__/manager.test.ts.snap} (80%) rename src/{ => core}/project/compose.ts (65%) create mode 100644 src/core/project/manager.test.ts create mode 100644 src/core/project/source.test.ts rename src/{ => core}/project/source.ts (57%) create mode 100644 src/core/project/templates.ts rename src/{ => core}/project/tree.ts (58%) delete mode 100644 src/project/compose.test.ts delete mode 100644 src/project/source.test.ts delete mode 100644 src/project/templates.ts delete mode 100644 src/project/tree.test.ts diff --git a/scripts/build.ts b/scripts/build.ts index 74104274a..ea2b316ec 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -2,6 +2,7 @@ import { $ } from "bun"; import { join, resolve } from "node:path"; +import { runWithExitCode } from "../src/runnable"; const REPO_ROOT = resolve(import.meta.dir, ".."); const ASSETS_DIR = join(REPO_ROOT, "src", "assets"); @@ -10,6 +11,10 @@ const DIST = join(REPO_ROOT, "dist"); const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; +// Shrink whitespace/syntax but keep identifiers: minified names make stack +// traces unreadable and erase error names telemetry keys on. +const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const; + /** Absolute paths of every asset file. dot:true so hidden files (.prettierrc) are included. */ function discoverAssets(): string[] { const files = [...new Bun.Glob("**/*").scanSync({ cwd: ASSETS_DIR, onlyFiles: true, dot: true })]; @@ -41,21 +46,15 @@ async function assertAssetsAreText(assets: string[]): Promise { } } -function reportAndExit(result: Bun.BuildOutput): void { - if (!result.success) { - for (const log of result.logs) console.error(log); - process.exit(1); - } -} - +// Bun.build rejects with an AggregateError on failure (throw defaults to true), +// so build errors propagate to runWithExitCode like any other. async function bundle(): Promise { - const result = await Bun.build({ + await Bun.build({ entrypoints: [ENTRYPOINT], outdir: DIST, target: "node", - minify: true, + minify: MINIFY, }); - reportAndExit(result); // Mirror assets beside the emitted module for resolveAssetsRoot(). const distAssets = join(DIST, "assets"); @@ -71,29 +70,30 @@ async function compile(target: string): Promise { const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`); await $`mkdir -p ${join(DIST, "bin")}`; - const result = await Bun.build({ + await Bun.build({ entrypoints: [ENTRYPOINT, ...assets], compile: { target: target as Bun.Build.CompileTarget, outfile }, - minify: true, + minify: MINIFY, root: REPO_ROOT, naming: { asset: ASSET_NAMING }, plugins: [assetLoaderPlugin()], }); - reportAndExit(result); console.log(`Compiled ${target} → ${outfile} (${assets.length} assets embedded)`); } -const [command, target] = process.argv.slice(2); +process.exit( + await runWithExitCode(async () => { + const [command, target] = process.argv.slice(2); -if (command === "bundle") { - await bundle(); -} else if (command === "compile") { - if (!target) { - console.error("Usage: bun scripts/build.ts compile "); - process.exit(1); - } - await compile(target); -} else { - console.error("Usage: bun scripts/build.ts >"); - process.exit(1); -} + if (command === "bundle") { + await bundle(); + } else if (command === "compile") { + if (!target) { + throw new Error("Usage: bun scripts/build.ts compile "); + } + await compile(target); + } else { + throw new Error("Usage: bun scripts/build.ts >"); + } + }), +); diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index b15b3281d..c8450e3a9 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -20,51 +20,34 @@ function toStackName(projectName: string, targetName: string): string { return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; } -async function main() { - // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. - const configRoot = path.resolve(process.cwd(), '..'); - const configIO = new ConfigIO({ baseDir: configRoot }); - - const spec = await configIO.readProjectSpec(); - const targets = await configIO.readAWSDeploymentTargets(); - - // 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). Cast once so those fields are reachable. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const specAny = spec as any; - - // Extract MCP configuration from project spec. - // Gateway fields are stored in agentcore.json but may not yet be on the - const mcpSpec = specAny.agentCoreGateways?.length +// 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. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SpecWithLatestFields = any; + +// Extract MCP configuration from the project spec. Gateway fields are stored in +// agentcore.json but may not yet be on the published spec type, so they are read +// off the loosened alias. +function resolveMcpSpec(spec: SpecWithLatestFields) { + return spec.agentCoreGateways?.length ? { - agentCoreGateways: specAny.agentCoreGateways, - mcpRuntimeTools: specAny.mcpRuntimeTools, - unassignedTargets: specAny.unassignedTargets, + agentCoreGateways: spec.agentCoreGateways, + mcpRuntimeTools: spec.mcpRuntimeTools, + unassignedTargets: spec.unassignedTargets, } : undefined; +} - // Read deployed state for credential ARNs (populated by pre-deploy identity setup) - let deployedState: Record | undefined; - try { - deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - } catch { - // Deployed state may not exist on first deploy - } - - if (targets.length === 0) { - throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); - } - - // Read harness configs: the full validated spec drives the CFN resource; the - // role-scoped fields drive the IAM role + container build. - const projectRoot = path.resolve(configRoot, '..'); - - // Read non-S3 KB connector-config files and pass their parsed contents to the - // L3 verbatim. The L3 does not read files; it expects the parsed - // connectorParameters keyed by the data source's connectorConfigFile path. +// Read non-S3 KB connector-config files and return their parsed contents keyed by +// the data source's connectorConfigFile path. The L3 does not read files; it +// expects these parsed connectorParameters verbatim. +function resolveConnectorParametersByFile( + spec: SpecWithLatestFields, + projectRoot: string +): Record> { const connectorParametersByFile: Record> = {}; - for (const kb of specAny.knowledgeBases ?? []) { + for (const kb of spec.knowledgeBases ?? []) { for (const ds of kb.dataSources ?? []) { if (ds.type !== 'S3' && ds.connectorConfigFile) { const abs = path.resolve(projectRoot, ds.connectorConfigFile); @@ -78,10 +61,15 @@ async function main() { } } } + return connectorParametersByFile; +} - // Synthesize an AWS::BedrockAgentCore::Harness resource for each harness entry in the spec. +// Synthesize a HarnessConfig for each harness entry in the spec. The full validated +// spec drives the AWS::BedrockAgentCore::Harness CFN resource; the role-scoped +// fields drive the IAM role + container build. +function resolveHarnessConfigs(spec: SpecWithLatestFields, projectRoot: string): HarnessConfig[] { const harnessConfigs: HarnessConfig[] = []; - for (const entry of specAny.harnesses ?? []) { + for (const entry of spec.harnesses ?? []) { const harnessDir = path.resolve(projectRoot, entry.path); const harnessPath = path.resolve(harnessDir, 'harness.json'); try { @@ -112,6 +100,34 @@ async function main() { ); } } + return harnessConfigs; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + const specAny: SpecWithLatestFields = spec; + const projectRoot = path.resolve(configRoot, '..'); + + const mcpSpec = resolveMcpSpec(specAny); + const connectorParametersByFile = resolveConnectorParametersByFile(specAny, projectRoot); + const harnessConfigs = resolveHarnessConfigs(specAny, projectRoot); + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } const app = new App(); diff --git a/src/project/__snapshots__/compose.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap similarity index 80% rename from src/project/__snapshots__/compose.test.ts.snap rename to src/core/project/__snapshots__/manager.test.ts.snap index 4bee05b76..87d602318 100644 --- a/src/project/__snapshots__/compose.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -1,8 +1,8 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`projectTree scaffolds the expected file tree 1`] = ` +exports[`FsProjectManager.create scaffolds the expected file tree into a fresh directory 1`] = ` [ - "agentcore.json", + "agentcore/agentcore.json", "agentcore/aws-targets.json", "agentcore/cdk/.gitignore", "agentcore/cdk/.npmignore", diff --git a/src/project/compose.ts b/src/core/project/compose.ts similarity index 65% rename from src/project/compose.ts rename to src/core/project/compose.ts index 14e58429a..06b3239c6 100644 --- a/src/project/compose.ts +++ b/src/core/project/compose.ts @@ -1,17 +1,17 @@ import type { DirNode, ProjectNode } from "./tree"; import { dir, file } from "./tree"; -import type { Source } from "./source"; +import type { AssetSource } from "./source"; import { TEMPLATES } from "./templates"; -import type { ProjectTemplate } from "../handlers/project/types"; +import type { ProjectTemplate } from "../../handlers/project/types"; /** Serializes a value as pretty-printed JSON with a trailing newline. */ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; /** - * Builds the child nodes for agentcore/cdk/ by expanding the listing under cdk/ - * into a nested tree. `gitignore.template` + * Expands the flat asset listing under assetDir into a nested tree of nodes. + * Ignore templates are renamed to dotfiles because npm strips real dotfiles when publishing. */ -async function expandDir(src: Source, assetDir: string): Promise { +async function expandDir(src: AssetSource, assetDir: string): Promise { const paths = await src.list(assetDir); const root: ProjectNode[] = []; @@ -25,7 +25,7 @@ async function expandDir(src: Source, assetDir: string): Promise let cursor = root; segments.forEach((segment, index) => { if (index === segments.length - 1) { - cursor.push(file(renderName(segment), src.read(assetPath))); + cursor.push(file(renderName(segment), () => src.read(assetPath))); return; } let child = cursor.find((n): n is DirNode => n.kind === "dir" && n.name === segment); @@ -45,9 +45,10 @@ function renderName(filename: string): string { return ignore ? `.${ignore[1]}ignore` : filename; } -// The fixed base every project shares (name/version/managedBy), with the -// template's resource sections spread on top. Sections are template-specific -// and never collide with the base, so this is a spread, not a merge. +/** + * Builds the agentcore.json spec by adding the template's resource sections to the shared base. + * The base fields and template sections never overlap so this is a plain spread. + */ function agentcoreSpec(name: string, template: ProjectTemplate): unknown { return { name, @@ -57,19 +58,22 @@ function agentcoreSpec(name: string, template: ProjectTemplate): unknown { }; } -/** Composes the full project tree for a template rooted at the destination */ +/** + * Composes the full project tree for a template rooted at the destination. + * Config files live under agentcore/ because that is where the deploy tooling discovers a project. + */ export async function projectTree( name: string, template: ProjectTemplate, - src: Source, + src: AssetSource, ): Promise { const { appDir, assetDir } = TEMPLATES[template]; return dir(".", [ dir("agentcore", [ dir("cdk", await expandDir(src, "cdk")), + file("agentcore.json", async () => json(agentcoreSpec(name, template))), file("aws-targets.json", async () => json([])), ]), - file("agentcore.json", async () => json(agentcoreSpec(name, template))), dir("app", [dir(appDir, await expandDir(src, assetDir))]), ]); } diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts new file mode 100644 index 000000000..6ca54d238 --- /dev/null +++ b/src/core/project/manager.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; +import { FsProjectManager } from "./manager"; +import { ProjectFileExistsError } from "./tree"; +import { PROJECT_TEMPLATES } from "../../handlers/project/types"; +import { createSilentLogger } from "../../testing"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-")); + tempDirectories.push(directory); + process.chdir(directory); + return directory; +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function manager(): FsProjectManager { + return new FsProjectManager({ logger: createSilentLogger() }); +} + +describe("FsProjectManager.create", () => { + test("scaffolds the expected file tree into a fresh directory", async () => { + const directory = await inTempDirectory(); + await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + const projectRoot = join(directory, "example"); + const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(projectRoot, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); + + expect(manifest).toMatchSnapshot(); + }); + + test("writes a deploy-ready agentcore.json registering the template agent", async () => { + const directory = await inTempDirectory(); + await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + const configDir = join(directory, "example", "agentcore"); + const spec = await Bun.file(join(configDir, "agentcore.json")).json(); + expect(spec.name).toBe("example"); + expect(spec.runtimes).toEqual([ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ]); + expect(await Bun.file(join(configDir, "aws-targets.json")).json()).toEqual([]); + }); + + test("refuses to overwrite an existing project", async () => { + await inTempDirectory(); + const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }; + + await manager().create(input); + await expect(manager().create(input)).rejects.toBeInstanceOf(ProjectFileExistsError); + }); +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index e58ecb0aa..a87c8689f 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -6,14 +6,13 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; -import { projectTree } from "../../project/compose"; -import { defaultSource, type Source } from "../../project/source"; -import { writeTree } from "../../project/tree"; +import { projectTree } from "./compose"; +import { defaultSource, type AssetSource } from "./source"; +import { writeTree } from "./tree"; type ProjectManagerConfig = { logger: Logger; - /** Asset source; defaults to the current runtime's (disk or embedded). */ - source?: Source; + source?: AssetSource; // Bun executable or dist/assets depending on runtime }; /** @@ -21,7 +20,7 @@ type ProjectManagerConfig = { */ export class FsProjectManager implements ProjectManager { private readonly logger: Logger; - private readonly source: Source; + private readonly source: AssetSource; constructor(config: ProjectManagerConfig) { this.logger = config.logger; diff --git a/src/core/project/source.test.ts b/src/core/project/source.test.ts new file mode 100644 index 000000000..e20baf9a7 --- /dev/null +++ b/src/core/project/source.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test"; +import { embeddedSource } from "./source"; + +describe("embeddedSource", () => { + test("throws when the asset is not embedded", () => { + expect(embeddedSource.read("cdk/package.json")).rejects.toThrow(/Embedded asset not found/); + }); +}); diff --git a/src/project/source.ts b/src/core/project/source.ts similarity index 57% rename from src/project/source.ts rename to src/core/project/source.ts index a326634e1..36676764a 100644 --- a/src/project/source.ts +++ b/src/core/project/source.ts @@ -4,36 +4,33 @@ import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; /** - * Provides an interface for abstracting bun executable asset reading - * and Node asset reading + * 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 Source { - /** Returns a thunk that reads the text of the asset at `assetPath`. */ - read(assetPath: string): () => Promise; +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; } -// Embedded assets are keyed by the build-time virtual path (see scripts/build.ts ASSET_NAMING) +// Prefix the build script gives every embedded asset name at compile time. const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; -// A standalone executable exposes its assets through Bun.embeddedFiles as -// File blobs carrying their build-time name +// Embedded files carry a name property that Bun's types widen to Blob. type NamedBlob = Blob & { readonly name: string }; const embeddedBlobs = () => Bun.embeddedFiles as readonly NamedBlob[]; -/** Reads assets from Bun.embeddedFiles — for the compiled standalone executable. */ -export const embeddedSource: Source = { - read(assetPath) { +/** Reads assets embedded in the compiled standalone executable. */ +export const embeddedSource: AssetSource = { + async read(assetPath) { const name = `${EMBEDDED_PREFIX}${assetPath}`; - return () => { - const blob = embeddedBlobs().find((f) => f.name === name); - if (!blob) { - throw new Error(`Embedded asset not found: ${assetPath}`); - } - return blob.text(); - }; + const blob = embeddedBlobs().find((f) => f.name === name); + if (!blob) { + throw new Error(`Embedded asset not found: ${assetPath}`); + } + return blob.text(); }, async list(assetDir) { const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; @@ -44,10 +41,10 @@ export const embeddedSource: Source = { }, }; -/** Reads assets from src/assets/ on disk — for the Node/Bun runtime from source. */ -export function fileSource(assetsRoot = resolveAssetsRoot()): Source { +/** Reads assets from the assets directory on disk. */ +export function fileSource(assetsRoot = resolveAssetsRoot()): AssetSource { return { - read: (assetPath) => () => readFile(join(assetsRoot, assetPath), "utf8"), + read: (assetPath) => readFile(join(assetsRoot, assetPath), "utf8"), async list(assetDir) { const root = join(assetsRoot, assetDir); const entries = await readdir(root, { recursive: true, withFileTypes: true }); @@ -60,21 +57,20 @@ export function fileSource(assetsRoot = resolveAssetsRoot()): Source { }; } -// Bundled builds place assets/ beside the emitted module but source layout has it -// one level up from this module +// 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"); + 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(): Source { +export function defaultSource(): AssetSource { const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; return embedded ? embeddedSource : fileSource(); } diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts new file mode 100644 index 000000000..a63f5904c --- /dev/null +++ b/src/core/project/templates.ts @@ -0,0 +1,37 @@ +import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; + +interface TemplateSpec { + runtimes?: unknown[]; + memories?: unknown[]; + harnesses?: unknown[]; +} + +/** + * A project template pairs the agent code scaffolded under app/ with the resource + * sections it registers in agentcore.json. Adding a template is one entry here plus its assets. + */ +interface Template { + /** Directory under app/ the template code is written to. */ + appDir: string; + /** Asset directory relative to the asset root, expanded into the app directory. */ + assetDir: string; + /** Resource sections this template contributes to agentcore.json. */ + spec: TemplateSpec; +} + +export const TEMPLATES: Record = { + [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { + appDir: "hello-world", + assetDir: "templates/hello-world-python", + spec: { + runtimes: [ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ], + }, + }, +}; diff --git a/src/project/tree.ts b/src/core/project/tree.ts similarity index 58% rename from src/project/tree.ts rename to src/core/project/tree.ts index ebcd854e2..1d7f7262d 100644 --- a/src/project/tree.ts +++ b/src/core/project/tree.ts @@ -1,10 +1,11 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { atomicWrite } from "../fs"; +import { atomicWrite } from "../../fs"; /** - * A node in a project's file tree. + * A node in a project's file tree where directories nest and files are leaves. + * File bytes come from a thunk so the tree never knows where the bytes originate. */ export type ProjectNode = DirNode | FileNode; @@ -32,9 +33,17 @@ export const file = (name: string, bytes: () => Promise): FileNode => ({ bytes, }); +/** Thrown when scaffolding would overwrite a file that already exists. */ +export class ProjectFileExistsError extends Error { + constructor(public readonly path: string) { + super(`Refusing to overwrite existing file: ${path}`); + this.name = "ProjectFileExistsError"; + } +} + /** - * Write a project tree to `destination`. Directories are created recursively; - * files are written atomically. + * 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 { const path = join(destination, node.name); @@ -47,7 +56,7 @@ export async function writeTree(node: ProjectNode, destination: string): Promise } if (existsSync(path)) { - throw new Error(`Refusing to overwrite existing file: ${path}`); + throw new ProjectFileExistsError(path); } await atomicWrite(path, await node.bytes()); } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 39007d668..7e1d13c0f 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -48,9 +48,10 @@ describe("project create", () => { const directory = await inTempDirectory(); await run(["create", "--project-name", "MyAgent"]); + // One existence check proves the handler→manager pipe; the full manifest + // is covered by the FsProjectManager snapshot test. const projectRoot = join(directory, "MyAgent"); - expect(await Bun.file(join(projectRoot, "agentcore.json")).exists()).toBe(true); - expect(await Bun.file(join(projectRoot, "app", "hello-world", "main.py")).exists()).toBe(true); + expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).exists()).toBe(true); }); test("rejects an invalid --project-name", async () => { @@ -58,6 +59,11 @@ describe("project create", () => { await expect(run(["create", "--project-name", "1-bad"])).rejects.toThrow(); }); + test("rejects a reserved --project-name", async () => { + await inTempDirectory(); + await expect(run(["create", "--project-name", "test"])).rejects.toThrow(/conflicts with/); + }); + test("rejects an unknown --template value", async () => { await inTempDirectory(); await expect( diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c05564111..458d562c4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -7,6 +7,70 @@ export const PROJECT_TEMPLATES = { export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; +// A generated project's Python package is named after the +// project, so a name that collides with a dependency breaks imports. +const RESERVED_PROJECT_NAMES: readonly string[] = [ + // Core SDK packages + "anthropic", + "autogen", + "autogenagentchat", + "autogenext", + "bedrock", + "bedrockagentcore", + "crewai", + "crewaitools", + "googleadk", + "googlegenerativeai", + "langchain", + "langchainanthropic", + "langchainaws", + "langchaingooglegenai", + "langchainmcpadapters", + "langchainopenai", + "langgraph", + "mcp", + "openai", + "openaiagents", + "strands", + "strandsagents", + "strandsagentstools", + // AG-UI adapter packages + "agui", + "aguistrands", + "aguilanggraph", + "aguiadk", + "aguiprotocol", + "vercelai", + "aisdk", + // Common utilities + "httpx", + "pytest", + "pytestasyncio", + "pythondotenv", + "tiktoken", + // Build tools + "hatchling", + "setuptools", + "wheel", + // AWS packages + "awsopentelemetrydistro", + "boto3", + "botocore", + // Common Python stdlib/package names that could cause issues + "test", + "tests", + "src", + "lib", + "dist", + "build", + "env", + "venv", + "site", + "pip", + "uv", +]; + +// Mirrors ProjectNameSchema in @aws/agentcore-cdk export const ProjectNameSchema = z .string() .min(1, "project name is required") @@ -14,7 +78,10 @@ export const ProjectNameSchema = z .regex( /^[A-Za-z][A-Za-z0-9]{0,22}$/, "project name must start with a letter and contain only letters and digits", - ); + ) + .refine((name) => !RESERVED_PROJECT_NAMES.includes(name.toLowerCase()), { + message: "this name conflicts with a Python package dependency; choose a different name", + }); export type CreateProjectInput = { /** The name of the project; also the directory it is scaffolded into. */ diff --git a/src/project/compose.test.ts b/src/project/compose.test.ts deleted file mode 100644 index b8e930ed8..000000000 --- a/src/project/compose.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; -import { join, relative } from "node:path"; -import { tmpdir } from "node:os"; -import { projectTree } from "./compose"; -import { fileSource } from "./source"; -import { writeTree } from "./tree"; -import { PROJECT_TEMPLATES } from "../handlers/project/types"; - -const tempDirectories: string[] = []; - -async function makeTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-compose-")); - tempDirectories.push(directory); - return directory; -} - -afterEach(async () => { - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -describe("projectTree", () => { - // Renders the real hello-world-python tree from disk and snapshots the file - // manifest, so adding/removing/renaming a scaffolded file is a reviewable diff. - test("scaffolds the expected file tree", async () => { - const destination = await makeTempDirectory(); - const tree = await projectTree("example", PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, fileSource()); - await writeTree(tree, destination); - - const manifest = (await readdir(destination, { recursive: true, withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => - relative(destination, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), - ) - .sort(); - - expect(manifest).toMatchSnapshot(); - }); - - test("writes a deploy-ready agentcore.json registering the template agent", async () => { - const destination = await makeTempDirectory(); - const tree = await projectTree("example", PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, fileSource()); - await writeTree(tree, destination); - - const spec = await Bun.file(join(destination, "agentcore.json")).json(); - expect(spec.name).toBe("example"); - expect(spec.runtimes).toEqual([ - { - name: "hello-world", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/hello-world", - }, - ]); - expect(await Bun.file(join(destination, "agentcore", "aws-targets.json")).json()).toEqual([]); - }); -}); diff --git a/src/project/source.test.ts b/src/project/source.test.ts deleted file mode 100644 index 6466fb770..000000000 --- a/src/project/source.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { embeddedSource, fileSource } from "./source"; - -const tempDirectories: string[] = []; - -async function makeTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-source-")); - tempDirectories.push(directory); - return directory; -} - -afterEach(async () => { - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -describe("fileSource", () => { - test("reads an asset relative to the assets root", async () => { - const root = await makeTempDirectory(); - await mkdir(join(root, "cdk"), { recursive: true }); - await Bun.write(join(root, "cdk", "package.json"), "{}"); - - expect(await fileSource(root).read("cdk/package.json")()).toBe("{}"); - }); - - test("lists files under a directory as sorted, forward-slash asset paths", async () => { - const root = await makeTempDirectory(); - await mkdir(join(root, "cdk", "bin"), { recursive: true }); - await Bun.write(join(root, "cdk", "package.json"), "{}"); - await Bun.write(join(root, "cdk", "bin", "cdk.ts"), ""); - - expect(await fileSource(root).list("cdk")).toEqual(["cdk/bin/cdk.ts", "cdk/package.json"]); - }); -}); - -describe("embeddedSource", () => { - // Bun.embeddedFiles is empty outside a compiled executable, so any lookup - // misses — this exercises the miss path deterministically in `bun test`. - test("throws when the asset is not embedded", () => { - expect(() => embeddedSource.read("cdk/package.json")()).toThrow(/Embedded asset not found/); - }); -}); diff --git a/src/project/templates.ts b/src/project/templates.ts deleted file mode 100644 index 2d4bcd042..000000000 --- a/src/project/templates.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PROJECT_TEMPLATES, type ProjectTemplate } from "../handlers/project/types"; - -interface TemplateSpec { - runtimes?: unknown[]; - memories?: unknown[]; - harnesses?: unknown[]; -} - -/** - * A project template: the agent code it scaffolds under `app/` and the resource - * sections it registers in `agentcore.json`. Adding a template is one entry here - * plus its assets under `src/assets/` - */ -interface Template { - /** Directory under `app/` the template's code is written to. */ - appDir: string; - /** Asset directory (relative to the asset root) expanded into `app/`. */ - assetDir: string; - /** Resource sections this template contributes to `agentcore.json`. */ - spec: TemplateSpec; -} - -export const TEMPLATES: Record = { - [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { - appDir: "hello-world", - assetDir: "templates/hello-world-python", - spec: { - runtimes: [ - { - name: "hello-world", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/hello-world", - }, - ], - }, - }, -}; diff --git a/src/project/tree.test.ts b/src/project/tree.test.ts deleted file mode 100644 index 9cff3063a..000000000 --- a/src/project/tree.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { dir, file, writeTree } from "./tree"; - -const tempDirectories: string[] = []; - -async function makeTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-tree-")); - tempDirectories.push(directory); - return directory; -} - -afterEach(async () => { - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -describe("writeTree", () => { - test("writes a nested tree to disk", async () => { - const destination = await makeTempDirectory(); - const tree = dir("project", [ - file("agentcore.json", async () => "{}"), - dir("app", [file("main.py", async () => "print('hi')")]), - ]); - - await writeTree(tree, destination); - - const root = join(destination, "project"); - expect(await Bun.file(join(root, "agentcore.json")).text()).toBe("{}"); - expect(await Bun.file(join(root, "app", "main.py")).text()).toBe("print('hi')"); - }); - - test("refuses to overwrite an existing file", async () => { - const destination = await makeTempDirectory(); - const tree = dir("project", [file("keep.txt", async () => "new")]); - - await writeTree(tree, destination); - await expect(writeTree(tree, destination)).rejects.toThrow(/Refusing to overwrite/); - expect(await Bun.file(join(destination, "project", "keep.txt")).text()).toBe("new"); - }); -}); From d1a704f7722eeb20843d1fd8a62ca2fa9f20e87b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 09:36:05 -0400 Subject: [PATCH 8/8] refactor(project): align scaffolding with codebase conventions Address review feedback: - AssetSource implementations are now classes (EmbeddedAssetSource, FsAssetSource) matching how interfaces are implemented elsewhere - TemplateSpec, Template, DirNode, and FileNode are types since they model concrete data, not extendable behavior - ProjectFileExistsError extends AgentCoreCLIError with a user error source so it participates in error classification and exit codes - atomicWrite moved from src/fs into the shared src/io module --- src/core/project/source.test.ts | 8 +++-- src/core/project/source.ts | 58 ++++++++++++++++-------------- src/core/project/templates.ts | 8 ++--- src/core/project/tree.ts | 19 +++++----- src/fs/index.ts | 1 - src/{fs => io}/atomicWrite.test.ts | 0 src/{fs => io}/atomicWrite.ts | 0 src/io/index.ts | 1 + 8 files changed, 53 insertions(+), 42 deletions(-) delete mode 100644 src/fs/index.ts rename src/{fs => io}/atomicWrite.test.ts (100%) rename src/{fs => io}/atomicWrite.ts (100%) diff --git a/src/core/project/source.test.ts b/src/core/project/source.test.ts index e20baf9a7..e933e15c4 100644 --- a/src/core/project/source.test.ts +++ b/src/core/project/source.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { embeddedSource } from "./source"; +import { EmbeddedAssetSource } from "./source"; -describe("embeddedSource", () => { +describe("EmbeddedAssetSource", () => { test("throws when the asset is not embedded", () => { - expect(embeddedSource.read("cdk/package.json")).rejects.toThrow(/Embedded asset not found/); + expect(new EmbeddedAssetSource().read("cdk/package.json")).rejects.toThrow( + /Embedded asset not found/, + ); }); }); diff --git a/src/core/project/source.ts b/src/core/project/source.ts index 36676764a..823b118e1 100644 --- a/src/core/project/source.ts +++ b/src/core/project/source.ts @@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url"; * 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`. */ + /** Reads the text of the asset at assetPath. */ read(assetPath: string): Promise; - /** Lists asset paths of every file under `assetDir`, sorted, recursively. */ + /** Lists asset paths of every file under assetDir, sorted, recursively. */ list(assetDir: string): Promise; } @@ -20,41 +20,47 @@ 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 }; -const embeddedBlobs = () => Bun.embeddedFiles as readonly NamedBlob[]; - /** Reads assets embedded in the compiled standalone executable. */ -export const embeddedSource: AssetSource = { - async read(assetPath) { +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 = embeddedBlobs().find((f) => f.name === name); + const blob = this.blobs().find((f) => f.name === name); if (!blob) { throw new Error(`Embedded asset not found: ${assetPath}`); } return blob.text(); - }, - async list(assetDir) { + } + + public async list(assetDir: string): Promise { const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; - return embeddedBlobs() + 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 function fileSource(assetsRoot = resolveAssetsRoot()): AssetSource { - return { - read: (assetPath) => readFile(join(assetsRoot, assetPath), "utf8"), - async list(assetDir) { - const root = join(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(); - }, - }; +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. @@ -72,5 +78,5 @@ function resolveAssetsRoot(moduleDirectory = dirname(fileURLToPath(import.meta.u */ export function defaultSource(): AssetSource { const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; - return embedded ? embeddedSource : fileSource(); + return embedded ? new EmbeddedAssetSource() : new FsAssetSource(); } diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index a63f5904c..558b0d531 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,23 +1,23 @@ import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; -interface TemplateSpec { +type TemplateSpec = { runtimes?: unknown[]; memories?: unknown[]; harnesses?: unknown[]; -} +}; /** * A project template pairs the agent code scaffolded under app/ with the resource * sections it registers in agentcore.json. Adding a template is one entry here plus its assets. */ -interface Template { +type Template = { /** Directory under app/ the template code is written to. */ appDir: string; /** Asset directory relative to the asset root, expanded into the app directory. */ assetDir: string; /** Resource sections this template contributes to agentcore.json. */ spec: TemplateSpec; -} +}; export const TEMPLATES: Record = { [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { diff --git a/src/core/project/tree.ts b/src/core/project/tree.ts index 1d7f7262d..52c0dd109 100644 --- a/src/core/project/tree.ts +++ b/src/core/project/tree.ts @@ -1,7 +1,8 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { atomicWrite } from "../../fs"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; +import { atomicWrite } from "../../io"; /** * A node in a project's file tree where directories nest and files are leaves. @@ -9,17 +10,17 @@ import { atomicWrite } from "../../fs"; */ export type ProjectNode = DirNode | FileNode; -export interface DirNode { +export type DirNode = { kind: "dir"; name: string; children: ProjectNode[]; -} +}; -export interface FileNode { +export type FileNode = { kind: "file"; name: string; bytes: () => Promise; -} +}; export const dir = (name: string, children: ProjectNode[]): DirNode => ({ kind: "dir", @@ -34,10 +35,12 @@ export const file = (name: string, bytes: () => Promise): FileNode => ({ }); /** Thrown when scaffolding would overwrite a file that already exists. */ -export class ProjectFileExistsError extends Error { +export class ProjectFileExistsError extends AgentCoreCLIError { constructor(public readonly path: string) { - super(`Refusing to overwrite existing file: ${path}`); - this.name = "ProjectFileExistsError"; + super(`Refusing to overwrite existing file: ${path}`, { + source: ERROR_SOURCE.USER, + meta: { path }, + }); } } diff --git a/src/fs/index.ts b/src/fs/index.ts deleted file mode 100644 index 5f8d9bd8d..000000000 --- a/src/fs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { atomicWrite } from "./atomicWrite"; diff --git a/src/fs/atomicWrite.test.ts b/src/io/atomicWrite.test.ts similarity index 100% rename from src/fs/atomicWrite.test.ts rename to src/io/atomicWrite.test.ts diff --git a/src/fs/atomicWrite.ts b/src/io/atomicWrite.ts similarity index 100% rename from src/fs/atomicWrite.ts rename to src/io/atomicWrite.ts diff --git a/src/io/index.ts b/src/io/index.ts index 07095428c..92b32d0c0 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,3 +1,4 @@ +export { atomicWrite } from "./atomicWrite"; export { FsReadWriteJson } from "./json"; export { SourceResolutionError, SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types";