diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index a10246354..620819493 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -6,8 +6,8 @@ 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 } from "../../handlers/project/shortcuts"; import { - RUNTIME_TEMPLATE_SHORTCUTS, type CreateProjectInput, type DeployResult, type Project, diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 275d63580..f1db624f5 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -73,6 +73,9 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa if (input.protocol !== undefined && input.protocol !== "HTTP") throw new InputValidationError("the strands-python template only supports HTTP"); + if (input.scaffoldRuntimeInput.build !== "CodeZip") + throw new InputValidationError("the strands template only supports CodeZip builds"); + const filesystemConfigurations = input.filesystemConfigurations ?? []; const sessionStorageMountPath = filesystemConfigurations.flatMap((configuration) => "sessionStorage" in configuration ? [configuration.sessionStorage.mountPath] : [], diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 8f91a2039..6b6a29f5b 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -94,6 +94,13 @@ describe("project add runtime", () => { ]; const expectedSpecByLabel: Record> = { + "template overrides to Container": { + build: "Container", + dockerfile: "Dockerfile", + }, + "container template build override to CodeZip": { + build: "CodeZip", + }, "all infrastructure flags": { description: "Configured runtime", executionRoleArn: "arn:aws:iam::123456789012:role/MyRole", @@ -129,6 +136,25 @@ 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"], + ], ["custom — all scaffolding flags", ["--name", "my_agent", ...allScaffoldingFlags]], [ "custom — framework strands", @@ -286,9 +312,12 @@ 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"); }); @@ -309,30 +338,14 @@ 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"], ], + [ + "strands-python only supports CodeZip builds", + ["--name", "my_agent", "--template", "strands-python", "--build", "Container"], + ], [ "invalid JSON in --network-config", ["--name", "my_agent", ...template, "--network-config", "{bad}"], @@ -345,4 +358,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..50ae1d09b 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,41 @@ 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 from explicit custom flags", async () => { @@ -189,7 +223,7 @@ describe("project create", () => { expect(existsSync(join(directory, "MyProject"))).toBe(false); }); - test("rejects an API key with the Bedrock model provider before scaffolding", async () => { + test("rejects an incompatible API-key template override before scaffolding", async () => { const directory = await inTempDirectory(); await expect( run( @@ -197,18 +231,10 @@ describe("project create", () => { "create", "--name", "MyProject", - "--build", - "CodeZip", - "--language", - "Python", - "--framework", - "none", - "--model-provider", - "Bedrock", + "--template", + "hello-world-python", "--api-key", "-", - "--memory", - "none", "--skip-install", "--skip-git", ], 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 064030279..4b12cb84e 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;