Skip to content
Merged
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
151 changes: 134 additions & 17 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,27 @@ afterEach(async () => {
});

// A manager whose runner records commands instead of spawning them.
function manager(): { manager: FsProjectManager; commands: { command: string[]; cwd: string }[] } {
function manager(): {
manager: FsProjectManager;
commands: { command: string[]; cwd: string }[];
checkedTools: string[];
} {
const commands: { command: string[]; cwd: string }[] = [];
const checkedTools: string[] = [];
return {
manager: new FsProjectManager({
logger: createSilentLogger(),
identity: new TestIdentityClient(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {}, // CI hosts don't have uv installed
// CI hosts don't have uv installed.
checkTool: async (tool) => {
checkedTools.push(tool);
},
}),
commands,
checkedTools,
};
}

Expand All @@ -87,6 +96,18 @@ async function runCreate(
}
}

async function runAdd(
subject: FsProjectManager,
project: Project,
input: AddResourceInput,
): Promise<Project> {
const iterator = subject.addResource(project, input);
while (true) {
const next = await iterator.next();
if (next.done) return next.value;
}
}

async function projectManifest(projectRoot: string): Promise<string[]> {
return (await readdir(projectRoot, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
Expand Down Expand Up @@ -279,16 +300,70 @@ describe("FsProjectManager.create", () => {
]);
});

test("fails before writing files or running npm when a later dependency is missing", async () => {
const directory = await inTempDirectory();
const checkedTools: string[] = [];
const commands: string[][] = [];
const subject = new FsProjectManager({
logger: createSilentLogger(),
identity: new TestIdentityClient(),
runner: async (command) => {
commands.push(command);
},
checkTool: async (tool) => {
checkedTools.push(tool);
if (tool === "uv") throw new Error("uv is missing");
},
});

await expect(
runCreate(subject, { name: "example", scaffoldRuntimeInput: AGENT_PYTHON }),
).rejects.toThrow("uv is missing");

expect(checkedTools).toEqual(["npm", "uv"]);
expect(commands).toEqual([]);
expect(existsSync(join(directory, "example"))).toBe(false);
});

test("checks the tools required by the selected create path", async () => {
await inTempDirectory();

const python = manager();
await runCreate(python.manager, {
name: "python",
scaffoldRuntimeInput: AGENT_PYTHON,
});
expect(python.checkedTools).toEqual(["npm", "uv", "git", "uv"]);

const typescript = manager();
await runCreate(typescript.manager, {
name: "typescript",
scaffoldRuntimeInput: AGENT_TYPESCRIPT_STRANDS,
});
expect(typescript.checkedTools).toEqual(["npm", "git", "npm"]);

const harness = manager();
await runCreate(harness.manager, {
name: "harness",
scaffoldHarnessInput: {
name: "harness",
model: { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" },
},
});
expect(harness.checkedTools).toEqual(["npm", "git"]);
});

test("skipInstall skips npm install and uv sync", async () => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
const { manager: subject, commands, checkedTools } = manager();
await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: AGENT_PYTHON,
skipInstall: true,
});

expect(commands).toEqual([{ command: ["git", "init"], cwd: join(directory, "example") }]);
expect(checkedTools).toEqual(["git"]);
});

test.each([
Expand All @@ -308,7 +383,7 @@ describe("FsProjectManager.create", () => {
"skipInstall still generates the container lockfile for %s",
async (_label, scaffoldRuntimeInput, lockCommand, runtimeName) => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
const { manager: subject, commands, checkedTools } = manager();
await runCreate(subject, {
name: "example",
scaffoldRuntimeInput,
Expand All @@ -319,19 +394,21 @@ describe("FsProjectManager.create", () => {
expect(commands).toEqual([
{ command: lockCommand, cwd: join(directory, "example", "app", runtimeName) },
]);
expect(checkedTools).toEqual([]);
},
);

test("skipGit skips git init", async () => {
await inTempDirectory();
const { manager: subject, commands } = manager();
const { manager: subject, commands, checkedTools } = manager();
await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: AGENT_PYTHON,
skipGit: true,
});

expect(commands.map(({ command }) => command[0])).toEqual(["npm", "uv"]);
expect(checkedTools).toEqual(["npm", "uv", "uv"]);
});

test("yields each step as a project event", async () => {
Expand Down Expand Up @@ -388,6 +465,58 @@ describe("FsProjectManager.create", () => {
});
});

describe("FsProjectManager.addResource", () => {
test.each([
["Python", AGENT_PYTHON, "uv"],
["TypeScript", AGENT_TYPESCRIPT_STRANDS, "npm"],
] as const)(
"fails before scaffolding a %s runtime when its installer is missing",
async (_language, template, tool) => {
await inTempDirectory();
const checkedTools: string[] = [];
const commands: string[][] = [];
let missingTool: string | undefined;
const subject = new FsProjectManager({
logger: createSilentLogger(),
identity: new TestIdentityClient(),
runner: async (command) => {
commands.push(command);
},
checkTool: async (candidate) => {
checkedTools.push(candidate);
if (candidate === missingTool) throw new Error(`${candidate} is missing`);
},
});
const { project } = await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: AGENT_PYTHON,
skipInstall: true,
skipGit: true,
});
const runtimeName = `added_${tool}`;
const runtimePath = join(project.rootPath, "app", runtimeName);
const specPath = join(project.rootPath, "agentcore", "agentcore.json");
const specBefore = await Bun.file(specPath).text();
missingTool = tool;

await expect(
runAdd(subject, project, {
resourceType: "runtime",
resourceConfig: {
name: runtimeName,
scaffoldRuntimeInput: { ...template, runtimeName },
},
}),
).rejects.toThrow(`${tool} is missing`);

expect(checkedTools).toEqual([tool]);
expect(commands).toEqual([]);
expect(existsSync(runtimePath)).toBe(false);
expect(await Bun.file(specPath).text()).toBe(specBefore);
},
);
});

describe("FsProjectManager.build", () => {
// build() requires the CDK app's node_modules; create() with skipInstall
// never produces them, so tests stub the directory in.
Expand Down Expand Up @@ -833,18 +962,6 @@ describe("FsProjectManager.resolve", () => {
});

describe("FsProjectManager removal", () => {
async function runAdd(
subject: FsProjectManager,
project: Project,
input: AddResourceInput,
): Promise<Project> {
const iterator = subject.addResource(project, input);
while (true) {
const next = await iterator.next();
if (next.done) return next.value;
}
}

async function createdProject(): Promise<{ subject: FsProjectManager; project: Project }> {
await inTempDirectory();
const subject = manager().manager;
Expand Down
42 changes: 34 additions & 8 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ import type { CoreIdentityClient } from "../../handlers/identity/types";

const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]';

const NODE_INSTALL_HINT = "Install Node.js: https://nodejs.org/";
const UV_INSTALL_HINT = "Install uv: https://docs.astral.sh/uv/getting-started/installation/";
const GIT_INSTALL_HINT = "Install git: https://git-scm.com/downloads";

// npm prints nothing until it exits when stderr is piped, and its HTTP log is the only per-package
// progress it will emit, so the log is asked for and then rewritten into package names.
const NPM_INSTALL = ["npm", "install", "--loglevel=http"];
Expand Down Expand Up @@ -191,12 +195,16 @@ export class FsProjectManager implements ProjectManager {
const scaffoldRuntimeInput = input.scaffoldRuntimeInput;
const destination = join(process.cwd(), input.name);

yield { type: "step", message: "Creating project tree" };
const { tree: projectTree, envEntries } = await createProjectTree(
{ templateRenderer: this.templateRenderer, assetSource: this.assetSource },
{ projectName: input.name },
{ runtime: scaffoldRuntimeInput, importBedrockAgent: input.importBedrockAgent },
);

// Validate required tools exist before starting creation flow
await this.checkCreateDependencies(input);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need a similar check in the add runtime flow? I believe right now we'll fail after writing files if uv/npm is missing based on the code:

const spec = await this.scaffoldRuntimeResources(outputPath, input.resourceConfig);
if (spec.runtimes) projectSpec.runtimes.push(...spec.runtimes);
if (spec.memories) projectSpec.memories.push(...spec.memories);
if (spec.credentials) projectSpec.credentials.push(...spec.credentials);
yield* this.installRuntimeDependencies(outputPath);

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.

Good call out. Just updated the PR with a check for this flow as well


yield { type: "step", message: "Creating project tree" };
await projectTree.write(destination);

if (envEntries.length > 0) {
Expand All @@ -223,7 +231,6 @@ export class FsProjectManager implements ProjectManager {
// A failed step leaves the scaffolded files in place; the error tells the
// user how to rerun the step by hand.
if (!input.skipInstall) {
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
yield { type: "step", message: "Installing CDK dependencies with npm" };
yield* this.run(NPM_INSTALL, join(destination, "agentcore", "cdk"), npmProgressLine);

Expand All @@ -238,7 +245,6 @@ export class FsProjectManager implements ProjectManager {
}

if (!input.skipGit) {
await this.checkTool("git", "Install git: https://git-scm.com/downloads");
yield { type: "step", message: "Initializing git repository" };
yield* this.run(["git", "init"], destination);
}
Expand Down Expand Up @@ -330,6 +336,7 @@ export class FsProjectManager implements ProjectManager {
break;
}
case "runtime": {
await this.checkRuntimeDependency(input.resourceConfig.scaffoldRuntimeInput);
yield { type: "step", message: "Scaffolding runtime in project" };
const outputPath = join(project.rootPath, "app", input.resourceConfig.name);
scaffoldedPaths.push(outputPath);
Expand Down Expand Up @@ -1075,20 +1082,39 @@ export class FsProjectManager implements ProjectManager {
return backend;
}

private async checkCreateDependencies(input: CreateProjectInput): Promise<void> {
if (!input.skipInstall) {
await this.checkTool("npm", NODE_INSTALL_HINT);
if (input.scaffoldRuntimeInput?.language === "Python") {
await this.checkTool("uv", UV_INSTALL_HINT);
}
}
if (!input.skipGit) {
await this.checkTool("git", GIT_INSTALL_HINT);
}
}

private async checkRuntimeDependency(
input: RuntimeResourceConfig["scaffoldRuntimeInput"],
): Promise<void> {
if (input.language === "Python") {
await this.checkTool("uv", UV_INSTALL_HINT);
} else {
await this.checkTool("npm", NODE_INSTALL_HINT);
}
}

/**
* Installs dependencies for a scaffolded runtime directory (e.g. `uv sync`
* for Python). No-ops if the runtime has no recognized dependency manifest.
*/
private async *installRuntimeDependencies(appDir: string): AsyncGenerator<ProjectEvent, void> {
if (existsSync(join(appDir, "pyproject.toml"))) {
await this.checkTool(
"uv",
"Install uv: https://docs.astral.sh/uv/getting-started/installation/",
);
await this.checkTool("uv", UV_INSTALL_HINT);
yield { type: "step", message: "Syncing Python dependencies with uv" };
yield* this.run(["uv", "sync"], appDir);
} else if (existsSync(join(appDir, "package.json"))) {
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
await this.checkTool("npm", NODE_INSTALL_HINT);
yield { type: "step", message: "Installing Node dependencies with npm" };
yield* this.run(NPM_INSTALL, appDir, npmProgressLine);
}
Expand Down
Loading