Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { FsProjectManager } from "./manager";
import {
PROJECT_TEMPLATES,
RUNTIME_TEMPLATE_SHORTCUTS,
type CreateProjectInput,
type DeployResult,
type Project,
Expand All @@ -16,6 +16,9 @@ import {
import { createSilentLogger } from "../../testing";
import type { DeployBackendInput, ProjectBackend } from "./backends/types";

const HELLO_WORLD_PYTHON = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"];
const HELLO_WORLD_PYTHON_CONTAINER = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"];

const originalCwd = process.cwd();
const tempDirectories: string[] = [];

Expand Down Expand Up @@ -71,7 +74,7 @@ describe("FsProjectManager.create", () => {
const directory = await inTempDirectory();
await runCreate(manager().manager, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

const projectRoot = join(directory, "example");
Expand All @@ -89,7 +92,7 @@ describe("FsProjectManager.create", () => {
const directory = await inTempDirectory();
await runCreate(manager().manager, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

const configDir = join(directory, "example", "agentcore");
Expand All @@ -111,7 +114,7 @@ describe("FsProjectManager.create", () => {
const directory = await inTempDirectory();
await runCreate(manager().manager, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON_CONTAINER,
});

const appDir = join(directory, "example", "app", "hello-world");
Expand All @@ -126,7 +129,10 @@ describe("FsProjectManager.create", () => {

test("refuses to overwrite an existing project", async () => {
await inTempDirectory();
const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON };
const input: CreateProjectInput = {
name: "example",
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
};

await runCreate(manager().manager, input);
await expect(runCreate(manager().manager, input)).rejects.toBeInstanceOf(ProjectStateError);
Expand All @@ -135,7 +141,10 @@ describe("FsProjectManager.create", () => {
test("runs npm install, uv sync, and git init after scaffolding", async () => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
await runCreate(subject, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON });
await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

const projectRoot = join(directory, "example");
expect(commands).toEqual([
Expand All @@ -150,7 +159,7 @@ describe("FsProjectManager.create", () => {
const { manager: subject, commands } = manager();
await runCreate(subject, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
skipInstall: true,
});

Expand All @@ -162,7 +171,7 @@ describe("FsProjectManager.create", () => {
const { manager: subject, commands } = manager();
await runCreate(subject, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
skipGit: true,
});

Expand All @@ -173,7 +182,7 @@ describe("FsProjectManager.create", () => {
await inTempDirectory();
const { events, project } = await runCreate(manager().manager, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

expect(events.map((event) => event.message)).toEqual([
Expand All @@ -198,7 +207,7 @@ describe("FsProjectManager.create", () => {
});

await expect(
runCreate(failing, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }),
runCreate(failing, { name: "example", scaffoldRuntimeInput: HELLO_WORLD_PYTHON }),
).rejects.toThrow("npm exploded");
expect(await Bun.file(join(directory, "example", "agentcore", "agentcore.json")).exists()).toBe(
true,
Expand All @@ -209,14 +218,14 @@ describe("FsProjectManager.create", () => {
const directory = await inTempDirectory();
await runCreate(manager().manager, {
name: "root",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

process.chdir(join(directory, "root"));
await expect(
runCreate(manager().manager, {
name: "child",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
}),
).rejects.toBeInstanceOf(ProjectStateError);
});
Expand All @@ -232,7 +241,7 @@ describe("FsProjectManager.build", () => {
): Promise<Project> {
const { project } = await runCreate(subject, {
name: "example",
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
skipInstall: true,
skipGit: true,
});
Expand Down Expand Up @@ -456,7 +465,10 @@ describe("FsProjectManager.resolve", () => {
test("round-trips a project it just created", async () => {
const root = await inTempDirectory();
const subject = manager().manager;
await runCreate(subject, { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON });
await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});

// Resolve from a nested path to prove the walk-up to the project root.
const resolved = await subject.resolve({ filePath: join(root, "example", "app") });
Expand Down
8 changes: 4 additions & 4 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { createHarnessTreeFromSpec, createProjectTree } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
Expand Down Expand Up @@ -98,11 +98,11 @@ export class FsProjectManager implements ProjectManager {
);
}

const scaffoldRuntimeInput = input.scaffoldRuntimeInput;
const destination = join(process.cwd(), input.name);
this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`);

yield { message: "Creating project tree" };
const tree = await createProjectTreeFromTemplate(input.name, input.template, this.source);
const tree = await createProjectTree(input.name, scaffoldRuntimeInput, this.source);
await tree.write(destination);

// A failed step leaves the scaffolded files in place; the error tells the
Expand All @@ -112,7 +112,7 @@ export class FsProjectManager implements ProjectManager {
yield { message: "Installing CDK dependencies with npm" };
await this.run(["npm", "install"], join(destination, "agentcore", "cdk"));

const appDir = join(destination, "app", TEMPLATES[input.template].appDir);
const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName);
if (existsSync(join(appDir, "pyproject.toml"))) {
await this.checkTool(
"uv",
Expand Down
39 changes: 25 additions & 14 deletions src/core/project/templates.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { ZodError, z } from "zod";
import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types";
import { HarnessSpecSchema } from "../../projectSchemas/harness";
import { FsTreeNode } from "./fsTree";
import type { AssetSource } from "./source";
import { InputValidationError } from "../../errors/errors";
import {
RUNTIME_TEMPLATE_SHORTCUTS,
type ScaffoldRuntimeInput,
} from "../../handlers/project/types";

type TemplateSpec = {
runtimes?: unknown[];
Expand All @@ -16,17 +19,14 @@ type TemplateSpec = {
* sections it registers in agentcore.json. Adding a template is one entry here plus its assets.
*/
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<ProjectTemplate, Template> = {
[PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: {
appDir: "hello-world",
const TEMPLATES: Record<string, Template> = {
[buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"])]: {
assetDir: "templates/hello-world-python",
spec: {
runtimes: [
Expand All @@ -43,8 +43,7 @@ export const TEMPLATES: Record<ProjectTemplate, Template> = {
],
},
},
[PROJECT_TEMPLATES.HELLO_WORLD_PYTHON_CONTAINER]: {
appDir: "hello-world",
[buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"])]: {
assetDir: "templates/hello-world-python-container",
spec: {
runtimes: [
Expand All @@ -60,28 +59,38 @@ export const TEMPLATES: Record<ProjectTemplate, Template> = {
},
};

function buildRuntimeTemplateKey(input: ScaffoldRuntimeInput): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i plan to refactor this in a future PR. We're going to want some of these parameters to determine the asset templates to render from, and some of them to be rendered into it with handlebars. However, this felt like the smallest change I could make to keep it functional with the new interface.

This PR is intended to focus on aligning the handlers with what we want, then we work backwards from there to implement the functionality we need to support it.

return `runtime_${input.build}_${input.framework}_${input.language}_${input.memory}_${input.modelProvider}`;
}

function resolveTemplate(input: ScaffoldRuntimeInput): Template | undefined {
return TEMPLATES[buildRuntimeTemplateKey(input)];
}

/** Serializes a value as pretty-printed JSON with a trailing newline. */
const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;

/**
* 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 {
function agentcoreSpec(name: string, template: Template): unknown {
return {
name,
version: 1,
managedBy: "CDK",
...TEMPLATES[template].spec,
...template.spec,
};
}

export async function createProjectTreeFromTemplate(
export async function createProjectTree(
name: string,
template: ProjectTemplate,
input: ScaffoldRuntimeInput,
src: AssetSource,
): Promise<FsTreeNode> {
const { appDir, assetDir } = TEMPLATES[template];
const template = resolveTemplate(input);
if (!template)
throw new InputValidationError(`unable to find template that matches given parameters`);
return FsTreeNode.createDirectory(".", [
FsTreeNode.createFile(".gitignore", () => src.read("templates/shared/gitignore.template")),
FsTreeNode.createDirectory("agentcore", [
Expand All @@ -90,7 +99,9 @@ export async function createProjectTreeFromTemplate(
FsTreeNode.createFile("aws-targets.json", async () => json([])),
FsTreeNode.createFile(".env.local", () => src.read("templates/shared/env.local.template")),
]),
FsTreeNode.createDirectory("app", [await FsTreeNode.fromAssetSource(src, assetDir, appDir)]),
FsTreeNode.createDirectory("app", [
await FsTreeNode.fromAssetSource(src, template.assetDir, input.runtimeName),
]),
]);
}

Expand Down
Loading
Loading