diff --git a/src/assets/templates/hello-world-python-container/Dockerfile b/src/assets/templates/hello-world-python-container/Dockerfile.template similarity index 100% rename from src/assets/templates/hello-world-python-container/Dockerfile rename to src/assets/templates/hello-world-python-container/Dockerfile.template diff --git a/src/assets/templates/strands-http-python/Dockerfile.template b/src/assets/templates/strands-http-python/Dockerfile.template new file mode 100644 index 000000000..cb3569eff --- /dev/null +++ b/src/assets/templates/strands-http-python/Dockerfile.template @@ -0,0 +1,40 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim-trixie + +RUN pip install --no-cache-dir uv + +ARG UV_DEFAULT_INDEX +ARG UV_INDEX + +WORKDIR /app + +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX} \ + UV_INDEX=${UV_INDEX} \ + PATH="/app/.venv/bin:$PATH" + +RUN useradd -m -u 1000 bedrock_agentcore + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY --chown=bedrock_agentcore:bedrock_agentcore . . +RUN uv sync --frozen --no-dev + +USER bedrock_agentcore + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +{{#if enableOtel}} +CMD ["opentelemetry-instrument", "python", "-m", "{{entrypoint}}"] +{{else}} +CMD ["python", "-m", "{{entrypoint}}"] +{{/if}} diff --git a/src/assets/templates/strands-http-python/dockerignore.template b/src/assets/templates/strands-http-python/dockerignore.template new file mode 100644 index 000000000..a0c4eb658 --- /dev/null +++ b/src/assets/templates/strands-http-python/dockerignore.template @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ + +# AgentCore build artifacts +.agentcore/artifacts/ +*.zip diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 45997ff8e..a1e8219c7 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -6,12 +6,12 @@ import { DeserializationError, ProjectStateError } from "../../errors/errors"; import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { FsProjectManager } from "./manager"; -import { - RUNTIME_TEMPLATE_SHORTCUTS, - type CreateProjectInput, - type DeployResult, - type Project, - type ProjectEvent, +import { RUNTIME_TEMPLATE_SHORTCUTS } from "../../handlers/project/shortcuts"; +import type { + CreateProjectInput, + DeployResult, + Project, + ProjectEvent, } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; import type { DeployBackendInput, ProjectBackend } from "./backends/types"; diff --git a/src/core/project/templates/fsTree.test.ts b/src/core/project/templates/fsTree.test.ts index bdf4e0503..682f67e37 100644 --- a/src/core/project/templates/fsTree.test.ts +++ b/src/core/project/templates/fsTree.test.ts @@ -72,7 +72,11 @@ describe("FsTreeNode.fromAssetSource", () => { }, }; - const tree = await FsTreeNode.fromAssetSource(source, "template", "root"); + const tree = await FsTreeNode.fromAssetSource( + { assetSource: source }, + { assetDir: "template" }, + { rootDirName: "root" }, + ); expect(tree.name).toBe("root"); expect(tree.children.map((node) => node.name)).toEqual(["README.md", "src", ".gitignore"]); @@ -83,4 +87,61 @@ describe("FsTreeNode.fromAssetSource", () => { ]); expect(await tree.children[2]?.bytes?.()).toBe("contents:template/gitignore.template"); }); + + test("strips .template suffix from non-ignore files", async () => { + const source: AssetSource = { + async list() { + return ["template/Dockerfile.template", "template/dockerignore.template"]; + }, + async read(assetPath) { + return `contents:${assetPath}`; + }, + }; + + const tree = await FsTreeNode.fromAssetSource( + { assetSource: source }, + { assetDir: "template" }, + { rootDirName: "root" }, + ); + + expect(tree.children.map((node) => node.name)).toEqual(["Dockerfile", ".dockerignore"]); + }); + + test("filters on the rendered name and omits rejected subtrees", async () => { + const source: AssetSource = { + async list() { + return ["template/main.ts", "template/Dockerfile.template", "template/skip/ignored.ts"]; + }, + async read(assetPath) { + return `contents:${assetPath}`; + }, + }; + + const tree = await FsTreeNode.fromAssetSource( + { assetSource: source }, + { assetDir: "template" }, + { filter: (name) => name !== "Dockerfile" && name !== "skip" }, + ); + + expect(tree.children.map((node) => node.name)).toEqual(["main.ts"]); + }); + + test("applies transformContent lazily to file contents", async () => { + const source: AssetSource = { + async list() { + return ["template/main.ts"]; + }, + async read(assetPath) { + return `contents:${assetPath}`; + }, + }; + + const tree = await FsTreeNode.fromAssetSource( + { assetSource: source }, + { assetDir: "template" }, + { transformContent: (raw) => raw.toUpperCase() }, + ); + + expect(await tree.children[0]?.bytes?.()).toBe("CONTENTS:TEMPLATE/MAIN.TS"); + }); }); diff --git a/src/core/project/templates/fsTree.ts b/src/core/project/templates/fsTree.ts index d388cbe96..c1281ebc3 100644 --- a/src/core/project/templates/fsTree.ts +++ b/src/core/project/templates/fsTree.ts @@ -71,18 +71,30 @@ export class FsTreeNode { } /** - * Expands the flat asset listing under assetDir into a nested tree of nodes. + * Builds a file tree from assets under `input.assetDir`. + * + * @param config - Asset source configuration. + * @param input - Asset directory to load. + * @param options - Optional root name, lazy content transform, and descendant filter. Rejecting a directory omits its subtree. */ static async fromAssetSource( - src: AssetSource, - assetDir: string, - rootDirName?: string, - transform?: (content: string) => string, + config: { assetSource: AssetSource }, + input: { assetDir: string }, + options?: { + rootDirName?: string; + transformContent?: (content: string) => string; + filter?: (name: string, isDir: boolean) => boolean; + }, ): Promise { - const paths = await src.list(assetDir); + const { assetSource } = config; + const { assetDir } = input; + const rootDirName = options?.rootDirName; + const transformContent = options?.transformContent; + const filter = options?.filter; + const paths = await assetSource.list(assetDir); const root = FsTreeNode.createDirectory(rootDirName ?? assetDir, []); - for (const assetPath of paths) { + assetPaths: for (const assetPath of paths) { const relative = assetPath.slice(assetDir.length + 1); const segments = relative.split("/"); if (segments.some((s) => s === "" || s === "." || s === "..")) { @@ -92,25 +104,31 @@ export class FsTreeNode { } let parent = root; - segments.forEach((segment, index) => { - if (index === segments.length - 1) { + for (const [index, segment] of segments.entries()) { + const isDir = index < segments.length - 1; + const name = isDir ? segment : renderName(segment); + // if the segment of a path rejects, reject the rest of the path so we jump to top-loop via assetPaths label. + if (filter && !filter(name, isDir)) continue assetPaths; + + if (!isDir) { parent.children.push( - FsTreeNode.createFile(renderName(segment), async () => { - const raw = await src.read(assetPath); - return transform ? transform(raw) : raw; + FsTreeNode.createFile(name, async () => { + const raw = await assetSource.read(assetPath); + return transformContent ? transformContent(raw) : raw; }), ); - return; + continue; } - let child = parent.children.find((n): n is FsTreeNode => n.isDir && n.name === segment); + let child = parent.children.find( + (node): node is FsTreeNode => node.isDir && node.name === name, + ); if (!child) { - child = FsTreeNode.createDirectory(segment, []); + child = FsTreeNode.createDirectory(name, []); parent.children.push(child); } - parent = child; - }); + } } return root; @@ -118,9 +136,13 @@ export class FsTreeNode { } /** - * Ignore templates are renamed to dotfiles because npm strips real dotfiles when publishing. + * Render filenames according to the following rules: + * - *ignore.template -> .*ignore to support `.` files. + * - *.template -> * to support extensionless files. */ function renderName(filename: string): string { - const ignore = filename.match(/^(git|npm|docker)ignore\.template$/); - return ignore ? `.${ignore[1]}ignore` : filename; + const ignoreMatchResult = filename.match(/^(git|npm|docker)ignore\.template$/); + if (ignoreMatchResult) return `.${ignoreMatchResult[1]}ignore`; + if (filename.endsWith(".template")) return filename.slice(0, -".template".length); + return filename; } diff --git a/src/core/project/templates/project.ts b/src/core/project/templates/project.ts index ff0a1ecaa..373fcd43b 100644 --- a/src/core/project/templates/project.ts +++ b/src/core/project/templates/project.ts @@ -35,7 +35,7 @@ export async function createProjectTree( config.assetSource.read("templates/shared/gitignore.template"), ), FsTreeNode.createDirectory("agentcore", [ - await FsTreeNode.fromAssetSource(config.assetSource, "cdk"), + await FsTreeNode.fromAssetSource({ assetSource: config.assetSource }, { assetDir: "cdk" }), FsTreeNode.createFile("agentcore.json", async () => json({ name: input.projectName, diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 275d63580..6dd7cfd2e 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -61,11 +61,14 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa if (input.protocol !== undefined && input.protocol !== "HTTP") throw new InputValidationError(`hello-world-python only supports HTTP protocol`); const tree = await FsTreeNode.fromAssetSource( - assetSource, - input.scaffoldRuntimeInput.build === "Container" - ? "templates/hello-world-python-container" - : "templates/hello-world-python", - input.name, + { assetSource }, + { + assetDir: + input.scaffoldRuntimeInput.build === "Container" + ? "templates/hello-world-python-container" + : "templates/hello-world-python", + }, + { rootDirName: input.name }, ); return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; }, @@ -103,12 +106,18 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa s3Mounts, needsOs: filesystemConfigurations.length > 0, hasConfigBundle: false, + enableOtel: true, + entrypoint: input.scaffoldRuntimeInput.entrypoint.replace(/\.py$/, ""), }; + const isContainer = input.scaffoldRuntimeInput.build === "Container"; const tree = await FsTreeNode.fromAssetSource( - assetSource, - "templates/strands-http-python", - input.name, - (raw) => templateRenderer.render(raw, context), + { assetSource }, + { assetDir: "templates/strands-http-python" }, + { + rootDirName: input.name, + transformContent: (raw) => templateRenderer.render(raw, context), + filter: (name) => isContainer || (name !== "Dockerfile" && name !== ".dockerignore"), + }, ); return { tree, diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 8f91a2039..b6e0177d1 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -94,6 +94,17 @@ describe("project add runtime", () => { ]; const expectedSpecByLabel: Record> = { + "template overrides to Container": { + build: "Container", + dockerfile: "Dockerfile", + }, + "container template build override to CodeZip": { + build: "CodeZip", + }, + "strands template overrides to Container": { + build: "Container", + dockerfile: "Dockerfile", + }, "all infrastructure flags": { description: "Configured runtime", executionRoleArn: "arn:aws:iam::123456789012:role/MyRole", @@ -129,6 +140,29 @@ describe("project add runtime", () => { ["--name", "my_agent", "--template", "hello-world-python-container"], ], ["strands-python template preset", ["--name", "my_agent", "--template", "strands-python"]], + [ + "template overrides to Container", + [ + "--name", + "my_agent", + "--template", + "hello-world-python", + "--build", + "Container", + "--model-provider", + "Bedrock", + "--memory", + "none", + ], + ], + [ + "container template build override to CodeZip", + ["--name", "my_agent", "--template", "hello-world-python-container", "--build", "CodeZip"], + ], + [ + "strands template overrides to Container", + ["--name", "my_agent", "--template", "strands-python", "--build", "Container"], + ], ["custom — all scaffolding flags", ["--name", "my_agent", ...allScaffoldingFlags]], [ "custom — framework strands", @@ -286,10 +320,17 @@ describe("project add runtime", () => { const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const runtime = spec.runtimes.find((candidate: { name: string }) => candidate.name === name); expect(runtime).toMatchObject({ entrypoint: "main.py", ...expectedSpecByLabel[label] }); - const isContainer = flags.some( - (value) => value === "Container" || value === "hello-world-python-container", - ); + expect(await Bun.file(join(projectRoot, "app", name, "main.py")).exists()).toBe(true); + const buildFlagIndex = flags.indexOf("--build"); + const isContainer = + buildFlagIndex >= 0 + ? flags[buildFlagIndex + 1] === "Container" + : flags.includes("hello-world-python-container"); expect(runtime.runtimeVersion).toBe(isContainer ? undefined : "PYTHON_3_14"); + expect(await Bun.file(join(projectRoot, "app", name, "Dockerfile")).exists()).toBe(isContainer); + expect(await Bun.file(join(projectRoot, "app", name, ".dockerignore")).exists()).toBe( + isContainer, + ); }); test.each<[string, string[]]>([ @@ -309,26 +350,6 @@ describe("project add runtime", () => { "none", ], ], - [ - "--template and --build are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--build", "Container"], - ], - [ - "--template and --language are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--language", "Python"], - ], - [ - "--template and --framework are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--framework", "none"], - ], - [ - "--template and --model-provider are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--model-provider", "Bedrock"], - ], - [ - "--template and --memory are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--memory", "none"], - ], [ "strands-python only supports HTTP", ["--name", "my_agent", "--template", "strands-python", "--protocol", "MCP"], @@ -345,4 +366,42 @@ describe("project add runtime", () => { await inProject(); await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); + + test.each([ + ["language", "Python"], + ["framework", "none"], + ])("rejects --%s as a template override", async (flagName, value) => { + await inProject(); + await expect( + run([ + "add", + "runtime", + "--name", + "my_agent", + "--template", + "hello-world-python", + `--${flagName}`, + value, + ]), + ).rejects.toThrow(`--${flagName} cannot override a template`); + }); + + test("rejects an incompatible API-key template override", async () => { + const projectRoot = await inProject(); + const apiKeyPath = join(projectRoot, "api-key.txt"); + await Bun.write(apiKeyPath, "secret-key"); + + await expect( + run([ + "add", + "runtime", + "--name", + "my_agent", + "--template", + "hello-world-python", + "--api-key", + `file://${apiKeyPath}`, + ]), + ).rejects.toThrow(/API keys are not compatible with Bedrock model providers/); + }); }); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index e386fc9c5..c4ece055b 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -10,8 +10,9 @@ import { SourceResolver } from "../../../../io"; import { RUNTIME_TEMPLATE_SHORTCUT_NAMES, RUNTIME_TEMPLATE_SHORTCUTS, - ScaffoldRuntimeInputSchema, -} from "../../types"; + resolveRuntimeTemplateShortcut, +} from "../../shortcuts"; +import { ScaffoldRuntimeInputSchema } from "../../types"; import { RuntimeResourceConfigSchema } from "./types"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => @@ -23,7 +24,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag("description", "an optional description of the runtime", z.string().optional()), flag( "template", - "a preset of flags to be leveraged in scaffolding the runtime. mutually exclusive with all runtime scaffolding flags", + "a preset of flags for scaffolding the runtime; compatible flags override preset values", z.enum(RUNTIME_TEMPLATE_SHORTCUT_NAMES).optional(), ), flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), @@ -108,11 +109,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ] as const; const presentScaffoldingFlags = scaffoldingFlags.filter((f) => flags[f] !== undefined); const isTemplate = flags["template"] !== undefined; - - if (isTemplate && presentScaffoldingFlags.length > 0) - throw new InputValidationError( - `--template and --${presentScaffoldingFlags[0]} are mutually exclusive`, - ); + const lockedFlag = (["language", "framework"] as const).find( + (flagName) => flags[flagName] !== undefined, + ); + if (isTemplate && lockedFlag) { + throw new InputValidationError(`--${lockedFlag} cannot override a template`); + } const isCustom = presentScaffoldingFlags.length > 0; @@ -120,7 +122,18 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => const apiKey = await source.resolveSecret("api-key", flags["api-key"]); const scaffoldRuntimeInput = isTemplate - ? RUNTIME_TEMPLATE_SHORTCUTS[flags.template!] + ? resolveRuntimeTemplateShortcut(flags.template!, { + runtimeName: flags.name, + ...(flags.build !== undefined && { + build: flags.build, + runtimeVersion: flags.build === "CodeZip" ? "PYTHON_3_14" : undefined, + }), + ...(flags["model-provider"] !== undefined && { + modelProvider: flags["model-provider"], + }), + ...(apiKey !== undefined && { apiKey }), + ...(flags.memory !== undefined && { memory: flags.memory }), + }) : isCustom ? parseScaffoldRuntimeInput({ runtimeName: flags.name, diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 7252f2048..29d537f72 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -4,10 +4,9 @@ import { SourceResolver, type AppIO } from "../../../io"; import { RUNTIME_TEMPLATE_SHORTCUT_NAMES, RUNTIME_TEMPLATE_SHORTCUTS, - ScaffoldRuntimeInputSchema, - type CreateProjectInput, - type ProjectManager, -} from "../types"; + resolveRuntimeTemplateShortcut, +} from "../shortcuts"; +import { ScaffoldRuntimeInputSchema, type CreateProjectInput, type ProjectManager } from "../types"; import { ProjectNameSchema } from "../../../projectSchemas/project"; import { InputValidationError } from "../../../errors"; @@ -24,7 +23,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag("name", "name of the project to create", ProjectNameSchema), flag( "template", - "a preset of flags to be leveraged in scaffolding the runtime. mutually exclusive with all runtime scaffolding flags", + "a preset of flags for scaffolding the runtime; compatible flags override preset values", z.enum(RUNTIME_TEMPLATE_SHORTCUT_NAMES).optional(), ), flag( @@ -75,10 +74,12 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = const presentScaffoldingFlags = scaffoldingFlags.filter((f) => flags[f] !== undefined); const isTemplate = flags["template"] !== undefined; - if (presentScaffoldingFlags.length > 0 && isTemplate) - throw new InputValidationError( - `--template and --${presentScaffoldingFlags[0]} are mutually exclusive`, - ); + const lockedFlag = (["language", "framework"] as const).find( + (flagName) => flags[flagName] !== undefined, + ); + if (isTemplate && lockedFlag) { + throw new InputValidationError(`--${lockedFlag} cannot override a template`); + } const isCustom = presentScaffoldingFlags.length > 0; @@ -86,7 +87,20 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = const apiKey = await source.resolveSecret("api-key", flags["api-key"]); const scaffoldRuntimeInput = isTemplate - ? RUNTIME_TEMPLATE_SHORTCUTS[flags["template"]!] + ? resolveRuntimeTemplateShortcut(flags["template"]!, { + ...(flags["runtime-name"] !== undefined && { + runtimeName: flags["runtime-name"], + }), + ...(flags["build"] !== undefined && { + build: flags["build"], + runtimeVersion: flags["build"] === "CodeZip" ? "PYTHON_3_14" : undefined, + }), + ...(flags["model-provider"] !== undefined && { + modelProvider: flags["model-provider"], + }), + ...(apiKey !== undefined && { apiKey }), + ...(flags["memory"] !== undefined && { memory: flags["memory"] }), + }) : isCustom ? parseScaffoldRuntimeInput({ runtimeName: flags["runtime-name"] ?? flags["name"], diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index f3e7fc530..36c4f8a28 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -114,7 +114,10 @@ describe("project create", () => { expect(core.projectCommands).toEqual([]); }); - test("rejects --template combined with scaffolding flags", async () => { + test.each([ + ["language", "Python"], + ["framework", "none"], + ])("rejects --%s as a template override", async (flagName, value) => { await inTempDirectory(); await expect( run([ @@ -123,10 +126,88 @@ describe("project create", () => { "MyAgent", "--template", "hello-world-python", - "--build", - "Container", + `--${flagName}`, + value, ]), - ).rejects.toThrow(/--template and --build are mutually exclusive/); + ).rejects.toThrow(`--${flagName} cannot override a template`); + }); + + test("applies compatible overrides to a template", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyProject", + "--template", + "strands-python", + "--runtime-name", + "custom_agent", + "--build", + "CodeZip", + "--model-provider", + "Bedrock", + "--memory", + "none", + "--skip-install", + "--skip-git", + ]); + + const projectRoot = join(directory, "MyProject"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes[0]).toMatchObject({ + name: "custom_agent", + build: "CodeZip", + codeLocation: "app/custom_agent", + runtimeVersion: "PYTHON_3_14", + }); + expect(await Bun.file(join(projectRoot, "app", "custom_agent", "main.py")).exists()).toBe(true); + }); + + test("scaffolds a Container agent from the strands template", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyProject", + "--template", + "strands-python", + "--build", + "Container", + "--skip-install", + "--skip-git", + ]); + + const projectRoot = join(directory, "MyProject"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes[0]).toMatchObject({ + name: "strands_agent", + build: "Container", + codeLocation: "app/strands_agent", + dockerfile: "Dockerfile", + }); + expect(spec.runtimes[0].runtimeVersion).toBeUndefined(); + const runtimeRoot = join(projectRoot, "app", "strands_agent"); + expect(await Bun.file(join(runtimeRoot, "main.py")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, "Dockerfile")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(true); + }); + + test("omits the Dockerfile from a CodeZip strands template", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyProject", + "--template", + "strands-python", + "--skip-install", + "--skip-git", + ]); + + const runtimeRoot = join(directory, "MyProject", "app", "strands_agent"); + expect(await Bun.file(join(runtimeRoot, "main.py")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, "Dockerfile")).exists()).toBe(false); + expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(false); }); test("scaffolds from explicit custom flags", async () => { diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts new file mode 100644 index 000000000..86d62df13 --- /dev/null +++ b/src/handlers/project/shortcuts.ts @@ -0,0 +1,59 @@ +import z from "zod"; +import { InputValidationError } from "../../errors"; +import { ScaffoldRuntimeInputSchema, type ScaffoldRuntimeInput } from "./types"; + +export const RUNTIME_TEMPLATE_SHORTCUTS = { + "hello-world-python": { + runtimeName: "hello_world", + build: "CodeZip", + language: "Python", + framework: "none", + modelProvider: "Bedrock", + memory: "none", + entrypoint: "main.py", + runtimeVersion: "PYTHON_3_14", + }, + "hello-world-python-container": { + runtimeName: "hello_world", + build: "Container", + language: "Python", + framework: "none", + modelProvider: "Bedrock", + memory: "none", + entrypoint: "main.py", + }, + "strands-python": { + runtimeName: "strands_agent", + build: "CodeZip", + language: "Python", + framework: "strands", + modelProvider: "Bedrock", + memory: "none", + entrypoint: "main.py", + runtimeVersion: "PYTHON_3_14", + }, +} as const satisfies Record; + +export type RuntimeTemplateShortcutName = keyof typeof RUNTIME_TEMPLATE_SHORTCUTS; + +export const RUNTIME_TEMPLATE_SHORTCUT_NAMES = Object.keys( + RUNTIME_TEMPLATE_SHORTCUTS, +) as unknown as readonly [RuntimeTemplateShortcutName, ...RuntimeTemplateShortcutName[]]; + +type RuntimeTemplateOverrides = Partial< + Pick< + ScaffoldRuntimeInput, + "runtimeName" | "build" | "modelProvider" | "apiKey" | "memory" | "runtimeVersion" + > +>; + +export function resolveRuntimeTemplateShortcut( + name: RuntimeTemplateShortcutName, + overrides: RuntimeTemplateOverrides, +): ScaffoldRuntimeInput { + const input = { ...RUNTIME_TEMPLATE_SHORTCUTS[name], ...overrides }; + + const result = ScaffoldRuntimeInputSchema.safeParse(input); + if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); + return result.data; +} diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c96b2e7af..1bdc1016a 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -12,44 +12,6 @@ import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; -export const RUNTIME_TEMPLATE_SHORTCUTS = { - "hello-world-python": { - runtimeName: "hello_world", - build: "CodeZip", - language: "Python", - framework: "none", - modelProvider: "Bedrock", - memory: "none", - entrypoint: "main.py", - runtimeVersion: "PYTHON_3_14", - }, - "hello-world-python-container": { - runtimeName: "hello_world", - build: "Container", - language: "Python", - framework: "none", - modelProvider: "Bedrock", - memory: "none", - entrypoint: "main.py", - }, - "strands-python": { - runtimeName: "strands_agent", - build: "CodeZip", - language: "Python", - framework: "strands", - modelProvider: "Bedrock", - memory: "none", - entrypoint: "main.py", - runtimeVersion: "PYTHON_3_14", - }, -} as const satisfies Record; - -export type RuntimeTemplateShortcutName = keyof typeof RUNTIME_TEMPLATE_SHORTCUTS; - -export const RUNTIME_TEMPLATE_SHORTCUT_NAMES = Object.keys( - RUNTIME_TEMPLATE_SHORTCUTS, -) as unknown as readonly [RuntimeTemplateShortcutName, ...RuntimeTemplateShortcutName[]]; - type CreateProjectInputBase = { /** The name of the project; also the directory it is scaffolded into. */ name: string;