diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 6ca54d238..934f3ca58 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { FsProjectManager } from "./manager"; +import { FsProjectManager, NestedProjectError } from "./manager"; import { ProjectFileExistsError } from "./tree"; import { PROJECT_TEMPLATES } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; @@ -69,4 +69,14 @@ describe("FsProjectManager.create", () => { await manager().create(input); await expect(manager().create(input)).rejects.toBeInstanceOf(ProjectFileExistsError); }); + + test("refuses to create a project inside an existing project", async () => { + const directory = await inTempDirectory(); + await manager().create({ name: "root", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + process.chdir(join(directory, "root")); + await expect( + manager().create({ name: "child", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }), + ).rejects.toBeInstanceOf(NestedProjectError); + }); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index a87c8689f..49d74d066 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,4 +1,6 @@ -import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; import type { CreateProjectInput, ResolveProjectInput, @@ -10,6 +12,28 @@ import { projectTree } from "./compose"; import { defaultSource, type AssetSource } from "./source"; import { writeTree } from "./tree"; +/** Thrown when scaffolding would nest a new project inside an existing AgentCore project. */ +export class NestedProjectError extends AgentCoreCLIError { + constructor(public readonly projectRoot: string) { + super( + `cannot create a project inside an existing AgentCore project (found ${join(projectRoot, "agentcore", "agentcore.json")})`, + { source: ERROR_SOURCE.USER, meta: { projectRoot } }, + ); + } +} + +/** Walks up from directory looking for the agentcore/agentcore.json project marker. */ +function enclosingProjectRoot(directory: string): string | undefined { + for (let current = directory; ; current = dirname(current)) { + if (existsSync(join(current, "agentcore", "agentcore.json"))) { + return current; + } + if (dirname(current) === current) { + return undefined; + } + } +} + type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime @@ -32,7 +56,11 @@ export class FsProjectManager implements ProjectManager { } public async create(input: CreateProjectInput): Promise { - // Scaffold into a fresh directory. + // Scaffold into a fresh directory, refusing to nest inside an existing project. + const enclosing = enclosingProjectRoot(process.cwd()); + if (enclosing) { + throw new NestedProjectError(enclosing); + } const destination = join(process.cwd(), input.name); this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); diff --git a/src/core/project/source.test.ts b/src/core/project/source.test.ts index e933e15c4..bea700f48 100644 --- a/src/core/project/source.test.ts +++ b/src/core/project/source.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { EmbeddedAssetSource } from "./source"; +import { EmbeddedAssetNotFoundError, EmbeddedAssetSource } from "./source"; describe("EmbeddedAssetSource", () => { - test("throws when the asset is not embedded", () => { - expect(new EmbeddedAssetSource().read("cdk/package.json")).rejects.toThrow( - /Embedded asset not found/, + test("throws a modeled error when the asset is not embedded", () => { + expect(new EmbeddedAssetSource().read("cdk/package.json")).rejects.toBeInstanceOf( + EmbeddedAssetNotFoundError, ); }); }); diff --git a/src/core/project/source.ts b/src/core/project/source.ts index 823b118e1..d515dc196 100644 --- a/src/core/project/source.ts +++ b/src/core/project/source.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { AgentCoreCLIError } from "../../errors"; /** * Reads and lists asset files by path relative to the asset root. @@ -20,6 +21,13 @@ const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; // Embedded files carry a name property that Bun's types widen to Blob. type NamedBlob = Blob & { readonly name: string }; +/** Thrown when an asset is missing from the compiled executable, indicating a packaging bug. */ +export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { + constructor(public readonly assetPath: string) { + super(`Embedded asset not found: ${assetPath}`, { meta: { assetPath } }); + } +} + /** Reads assets embedded in the compiled standalone executable. */ export class EmbeddedAssetSource implements AssetSource { private blobs(): readonly NamedBlob[] { @@ -30,7 +38,7 @@ export class EmbeddedAssetSource implements AssetSource { const name = `${EMBEDDED_PREFIX}${assetPath}`; const blob = this.blobs().find((f) => f.name === name); if (!blob) { - throw new Error(`Embedded asset not found: ${assetPath}`); + throw new EmbeddedAssetNotFoundError(assetPath); } return blob.text(); }