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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
84 changes: 84 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { CdkBackend } from "./cdk";

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

async function project(withDependencies = true): Promise<Project> {
const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-"));
tempDirectories.push(rootPath);
const cdkDir = join(rootPath, "agentcore", "cdk");
await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true });
return {
name: "example",
rootPath,
spec: ProjectSpecSchema.parse({ name: "example", version: 1 }),
};
}

async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
const events: ProjectEvent[] = [];
for await (const event of generator) events.push(event);
return events;
}

describe("CdkBackend.build", () => {
test("compiles and synthesizes through the generated CDK script", async () => {
const commands: { command: string[]; cwd: string }[] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
checkTool: async () => {},
});
const input = await project();

expect(await drain(subject.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
]);
expect(commands).toEqual([
{
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
cwd: join(input.rootPath, "agentcore", "cdk"),
},
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const commands: string[][] = [];
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async (command) => {
commands.push(command);
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/);
expect(commands).toEqual([]);
});

test("propagates synthesis failures", async () => {
const subject = new CdkBackend({
logger: createSilentLogger(),
runner: async () => {
throw new Error("cdk synth exploded");
},
checkTool: async () => {},
});

await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded");
});
});
44 changes: 44 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
runner?: ProcessRunner;
checkTool?: typeof requireTool;
};

/** Builds projects through the CDK app scaffolded by `agentcore project create`. */

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.

this would also be deploying eventually too right? (no need to change anything, just want to make sure I understand the vision)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yes!

export class CdkBackend implements ProjectBackend {
private readonly logger: Logger;
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

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.

Nice!


yield { message: "Synthesizing CloudFormation templates" };
await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], {
cwd: cdkDir,
onOutput: (chunk) => this.logger.debug(chunk),
});
}
}
6 changes: 6 additions & 0 deletions src/core/project/backends/types.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import type { Project, ProjectEvent } from "../../../handlers/project/types";

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
}
2 changes: 2 additions & 0 deletions src/core/project/index.tsx
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
export { FsProjectManager } from "./manager";
export { CdkBackend, type CdkBackendConfig } from "./backends/cdk";
export type { ProjectBackend } from "./backends/types";
55 changes: 18 additions & 37 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ import {
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
AgentCoreCLIError,
Expand All@@ -30,13 +30,16 @@ import {
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";

type ProjectManagerConfig = {
logger: Logger;
source?: AssetSource; // Bun executable or dist/assets depending on runtime
runner?: ProcessRunner; // injectable so tests never spawn real processes
checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH
json?: ReadWriteJson; // injectable so tests read fixtures instead of disk
backends?: Partial<Record<ManagedBy, ProjectBackend>>;
};

/**
Expand All@@ -48,13 +51,21 @@ export class FsProjectManager implements ProjectManager {
private readonly runner: ProcessRunner;
private readonly checkTool: typeof requireTool;
private readonly json: ReadWriteJson;
private readonly backends: Partial<Record<ManagedBy, ProjectBackend>>;

constructor(config: ProjectManagerConfig) {
this.logger = config.logger;
this.source = config.source ?? defaultSource();
this.runner = config.runner ?? runProcess;
this.checkTool = config.checkTool ?? requireTool;
this.json = config.json ?? new FsReadWriteJson({ logger: config.logger });
this.backends = config.backends ?? {
CDK: new CdkBackend({
logger: config.logger,
runner: config.runner,
checkTool: config.checkTool,
}),
};
}

public async resolve(input: ResolveProjectInput): Promise<Project | undefined> {
Expand DownExpand Up@@ -229,47 +240,17 @@ export class FsProjectManager implements ProjectManager {
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {

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.

nice, I really like how clean this is now!

// agentcore.json records which backend owns the project's artifacts. CDK is the
// only one today; a terraform or no-IaC backend adds an arm here rather than
// editing the CDK path.
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
}
}
yield* this.backendFor(project).build(project);
}

// Compiles the generated CDK app and synthesizes its CloudFormation templates.
private async *buildWithCdk(project: Project): AsyncGenerator<ProjectEvent, void> {
const cdkDir = join(project.rootPath, "agentcore", "cdk");

// The generated CDK app is built from its own node_modules; without them the
// failure would otherwise surface as an opaque "cdk: not found".
if (!existsSync(join(cdkDir, "node_modules"))) {
private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
`project '${project.name}' declares an unsupported backend: ${project.spec.managedBy}`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");

// The generated package.json defines `cdk` as "npm run build && cdk", so this
// single command compiles the app and then synthesizes it. Synthesis needs no
// AWS credentials: each stack's environment comes from aws-targets.json.
//
// Build deliberately does not require a deployment target. A freshly created
// project has none, and building is how the user first typechecks their agent, so
// the generated app synthesizes one environment-agnostic stack when the list is
// empty. Only deploying somewhere needs a real target.
yield { message: "Synthesizing CloudFormation templates" };
await this.run(["npm", "run", "cdk", "--", "synth", "--quiet"], cdkDir);
return backend;
}

// Runs a command with its output streamed to the file logger.
Expand Down
Loading