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
12 changes: 11 additions & 1 deletion src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
32 changes: 30 additions & 2 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -32,7 +56,11 @@ export class FsProjectManager implements ProjectManager {
}

public async create(input: CreateProjectInput): Promise<Project> {
// 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}"`);

Expand Down
8 changes: 4 additions & 4 deletions src/core/project/source.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
10 changes: 9 additions & 1 deletion src/core/project/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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[] {
Expand All @@ -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();
}
Expand Down
Loading