diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 674b84be7..67c963952 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -56,8 +56,13 @@ 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(), @@ -65,9 +70,13 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; 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, }; } @@ -87,6 +96,18 @@ async function runCreate( } } +async function runAdd( + subject: FsProjectManager, + project: Project, + input: AddResourceInput, +): Promise { + 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 { return (await readdir(projectRoot, { recursive: true, withFileTypes: true })) .filter((entry) => entry.isFile()) @@ -279,9 +300,62 @@ 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, @@ -289,6 +363,7 @@ describe("FsProjectManager.create", () => { }); expect(commands).toEqual([{ command: ["git", "init"], cwd: join(directory, "example") }]); + expect(checkedTools).toEqual(["git"]); }); test.each([ @@ -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, @@ -319,12 +394,13 @@ 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, @@ -332,6 +408,7 @@ describe("FsProjectManager.create", () => { }); expect(commands.map(({ command }) => command[0])).toEqual(["npm", "uv"]); + expect(checkedTools).toEqual(["npm", "uv", "uv"]); }); test("yields each step as a project event", async () => { @@ -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. @@ -833,18 +962,6 @@ describe("FsProjectManager.resolve", () => { }); describe("FsProjectManager removal", () => { - async function runAdd( - subject: FsProjectManager, - project: Project, - input: AddResourceInput, - ): Promise { - 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; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c1d9cd07a..33c2d5064 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -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"]; @@ -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); + + yield { type: "step", message: "Creating project tree" }; await projectTree.write(destination); if (envEntries.length > 0) { @@ -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); @@ -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); } @@ -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); @@ -1075,20 +1082,39 @@ export class FsProjectManager implements ProjectManager { return backend; } + private async checkCreateDependencies(input: CreateProjectInput): Promise { + 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 { + 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 { 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); }