From f33e30f91fc3443f38a9d44771a1402134efa1d1 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 3 Sep 2026 09:04:27 -0400 Subject: [PATCH 1/3] fix: fail fast on missing project create dependencies --- src/core/project/manager.test.ts | 75 +++++++++++++++++++++++++++++--- src/core/project/manager.tsx | 24 ++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 674b84be7..dbc1ad12b 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, }; } @@ -279,9 +288,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 +351,7 @@ describe("FsProjectManager.create", () => { }); expect(commands).toEqual([{ command: ["git", "init"], cwd: join(directory, "example") }]); + expect(checkedTools).toEqual(["git"]); }); test.each([ @@ -308,7 +371,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 +382,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 +396,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 () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c1d9cd07a..633d1a245 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); } @@ -1075,6 +1081,18 @@ 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); + } + } + /** * Installs dependencies for a scaffolded runtime directory (e.g. `uv sync` * for Python). No-ops if the runtime has no recognized dependency manifest. From 4537720a39aa40125db492d43e675dca63fdf18a Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 3 Sep 2026 10:05:09 -0400 Subject: [PATCH 2/3] chore: validate depts before scaffolding in add runtime flow --- src/core/project/manager.test.ts | 76 +++++++++++++++++++++++++++----- src/core/project/manager.tsx | 21 ++++++--- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index dbc1ad12b..67c963952 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -96,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()) @@ -453,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. @@ -898,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 633d1a245..dae461a7c 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -320,6 +320,10 @@ export class FsProjectManager implements ProjectManager { ); } + if (input.resourceType === "runtime") { + await this.checkRuntimeDependency(input.resourceConfig.scaffoldRuntimeInput); + } + const scaffoldedPaths: string[] = []; let envFile: EnvLocalFile | undefined; @@ -1093,20 +1097,27 @@ export class FsProjectManager implements ProjectManager { } } + 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); } From d86d07a4424504a5ecba1eb40fc9e76990af908c Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 3 Sep 2026 12:42:21 -0400 Subject: [PATCH 3/3] fix: move dependency check inside runtime switch case --- src/core/project/manager.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index dae461a7c..33c2d5064 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -320,10 +320,6 @@ export class FsProjectManager implements ProjectManager { ); } - if (input.resourceType === "runtime") { - await this.checkRuntimeDependency(input.resourceConfig.scaffoldRuntimeInput); - } - const scaffoldedPaths: string[] = []; let envFile: EnvLocalFile | undefined; @@ -340,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);