Skip to content
Closed
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
9 changes: 1 addition & 8 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack';
import { toStackName } from '../lib/names';
import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk';
import { App, type Environment } from 'aws-cdk-lib';
import * as path from 'path';
Expand All @@ -12,14 +13,6 @@ function toEnvironment(target: AwsDeploymentTarget): Environment {
};
}

function sanitize(name: string): string {
return name.replace(/_/g, '-');
}

function toStackName(projectName: string, targetName: string): string {
return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`;
}

// The vended CDK project compiles against the published @aws/agentcore-cdk schema
// type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, harnesses,
// gateway fields). This alias documents each read of those not-yet-published fields.
Expand Down
3 changes: 3 additions & 0 deletions src/assets/cdk/lib/names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function toStackName(projectName: string, targetName: string): string {
return `AgentCore-${projectName.replaceAll("_", "-")}-${targetName.replaceAll("_", "-")}`;
}
7 changes: 7 additions & 0 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
import type { Logger } from "../logging";
import type { ProjectManager } from "../handlers/project/types";
import { FsProjectManager } from "./project";
import { defaultAssetSource, localFileSystem, requireTool, runProcess } from "../io";

export type {
AwsClients,
Expand Down Expand Up @@ -73,6 +74,12 @@ export class CoreClient implements AwsClients {

this.projectManager = new FsProjectManager({
logger: this.logger.child({ module: "projectManager" }),
source: defaultAssetSource(localFileSystem),
runner: runProcess,
checkTool: requireTool,
fileSystem: localFileSystem,
workingDirectory: () => process.cwd(),
now: () => new Date(),
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/project/__snapshots__/manager.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

exports[`FsProjectManager.create scaffolds the expected file tree into a fresh directory 1`] = `
[
"agentcore/.gitignore",
"agentcore/agentcore.json",
"agentcore/aws-targets.json",
"agentcore/cdk/.gitignore",
Expand All @@ -12,6 +13,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d
"agentcore/cdk/cdk.json",
"agentcore/cdk/jest.config.js",
"agentcore/cdk/lib/cdk-stack.ts",
"agentcore/cdk/lib/names.ts",
"agentcore/cdk/package.json",
"agentcore/cdk/test/cdk.test.ts",
"agentcore/cdk/tsconfig.json",
Expand Down
19 changes: 19 additions & 0 deletions src/core/project/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {
BuildArtifact,
DeploymentTarget,
Project,
ProjectProgressEvent,
} from "../../handlers/project/types";

export type BackendBuildResult = {
artifact: BuildArtifact;
};

export type ProjectBuildBackend = {
readonly name: string;
build(
project: Project,
targets: DeploymentTarget[],
onProgress?: (event: ProjectProgressEvent) => void,
): Promise<BackendBuildResult>;
};
111 changes: 111 additions & 0 deletions src/core/project/cdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { join, relative } from "node:path";
import { AgentCoreCLIError, InputValidationError } from "../../errors";
import type { LocalFileSystem, ProcessRunner, ToolChecker } from "../../io";
import type { Logger } from "../../logging";
import type { DeploymentTarget, Project, ProjectProgressEvent } from "../../handlers/project/types";
import type { BackendBuildResult, ProjectBuildBackend } from "./backend";
import { CloudAssemblyManifestSchema } from "./schemas";
import { toStackName } from "../../assets/cdk/lib/names";

type CdkBackendConfig = {
logger: Logger;
runner: ProcessRunner;
checkTool: ToolChecker;
fileSystem: LocalFileSystem;
};

export class CdkProjectBackend implements ProjectBuildBackend {
readonly name = "CDK";

constructor(private readonly config: CdkBackendConfig) {}

async build(
project: Project,
targets: DeploymentTarget[],
onProgress?: (event: ProjectProgressEvent) => void,
): Promise<BackendBuildResult> {
const cdkDirectory = join(project.configDir, "cdk");
const packageJson = join(cdkDirectory, "package.json");
if (!(await this.config.fileSystem.exists(packageJson))) {
throw new InputValidationError(
`CDK project not found at ${cdkDirectory}. Create or restore agentcore/cdk before building.`,
);
}

await this.config.checkTool("npm", "Install Node.js: https://nodejs.org/");
await this.config.checkTool("node", "Install Node.js: https://nodejs.org/");

onProgress?.({ message: "Compiling CDK application..." });
await this.run(["npm", "run", "build"], cdkDirectory);

const assemblyDirectory = join(cdkDirectory, "cdk.out");
await this.config.fileSystem.remove(assemblyDirectory);
await this.config.fileSystem.createDirectory(assemblyDirectory);

onProgress?.({ message: "Validating project and synthesizing deployment artifacts..." });
await this.run(
[
"node",
join("node_modules", "aws-cdk", "bin", "cdk"),
"synth",
"--output",
"cdk.out",
"--quiet",
],
cdkDirectory,
);

const manifestPath = join(assemblyDirectory, "manifest.json");
let manifest: unknown;
try {
manifest = JSON.parse(await this.config.fileSystem.readText(manifestPath));
} catch (error) {
throw new AgentCoreCLIError(
`CDK synthesis did not produce a readable cloud assembly at ${manifestPath}`,
{ cause: error },
);
}

const parsed = CloudAssemblyManifestSchema.safeParse(manifest);
if (!parsed.success) {
throw new AgentCoreCLIError(`Invalid CDK cloud assembly manifest at ${manifestPath}`, {
cause: parsed.error,
});
}

const synthesizedStacks = new Set(
Object.entries(parsed.data.artifacts)
.filter(([, artifact]) => artifact.type === "aws:cloudformation:stack")
.map(([artifactId, artifact]) => artifact.properties?.stackName ?? artifactId),
);
const stacks = Object.fromEntries(
targets.map((target) => [target.name, toStackName(project.name, target.name)]),
);

const missing = targets.filter((target) => !synthesizedStacks.has(stacks[target.name]!));
if (missing.length > 0) {
throw new AgentCoreCLIError(
`CDK synthesis did not produce stacks for target(s): ${missing.map((target) => target.name).join(", ")}`,
);
}

return {
artifact: {
type: "cdk-cloud-assembly",
path: this.relativeToProject(project.root, assemblyDirectory),
stacks,
},
};
}

private relativeToProject(projectRoot: string, path: string): string {
return relative(projectRoot, path).replaceAll("\\", "/");
}

private run(command: string[], cwd: string): Promise<void> {
return this.config.runner(command, {
cwd,
onOutput: (chunk) => this.config.logger.debug(chunk),
});
}
}
3 changes: 2 additions & 1 deletion src/core/project/compose.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DirNode, ProjectNode } from "./tree";
import { dir, file } from "./tree";
import type { AssetSource } from "./source";
import type { AssetSource } from "../../io";
import { TEMPLATES } from "./templates";
import type { ProjectTemplate } from "../../handlers/project/types";

Expand Down Expand Up @@ -71,6 +71,7 @@ export async function projectTree(
return dir(".", [
dir("agentcore", [
dir("cdk", await expandDir(src, "cdk")),
file(".gitignore", async () => ".build/\n.cache/\n.cli/\n"),
file("agentcore.json", async () => json(agentcoreSpec(name, template))),
file("aws-targets.json", async () => json([])),
]),
Expand Down
96 changes: 96 additions & 0 deletions src/core/project/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import { join, relative, sep } from "node:path";
import { PACKAGE_VERSION } from "../../constants";
import type { LocalFileSystem } from "../../io";

const EXCLUDED_DIRECTORY_NAMES = new Set([
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"node_modules",
"venv",
]);

function normalize(path: string): string {
return path.split(sep).join("/");
}

function excluded(relativePath: string, isDirectory: boolean): boolean {
const normalized = normalize(relativePath);
const segments = normalized.split("/");

if (isDirectory && segments.some((segment) => EXCLUDED_DIRECTORY_NAMES.has(segment))) {
return true;
}

return (
normalized === "agentcore/.build" ||
normalized.startsWith("agentcore/.build/") ||
normalized === "agentcore/.cache" ||
normalized.startsWith("agentcore/.cache/") ||
normalized === "agentcore/.cli" ||
normalized.startsWith("agentcore/.cli/") ||
normalized === "agentcore/cdk/cdk.out" ||
normalized.startsWith("agentcore/cdk/cdk.out/") ||
normalized === "agentcore/cdk/.cdk.staging" ||
normalized.startsWith("agentcore/cdk/.cdk.staging/") ||
normalized === "agentcore/cdk/dist" ||
normalized.startsWith("agentcore/cdk/dist/") ||
segments.some((segment) => segment === ".env" || segment.startsWith(".env."))
);
}

async function projectFiles(
fileSystem: LocalFileSystem,
root: string,
directory = root,
): Promise<string[]> {
const entries = await fileSystem.readDirectory(directory);
const paths: string[] = [];

for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const absolutePath = join(directory, entry.name);
const relativePath = relative(root, absolutePath);
if (excluded(relativePath, entry.kind === "directory")) continue;

if (entry.kind === "directory") {
paths.push(...(await projectFiles(fileSystem, root, absolutePath)));
} else if (entry.kind === "file" || entry.kind === "symbolic-link") {
paths.push(absolutePath);
}
}

return paths;
}

/** Hashes build inputs by path and content, excluding generated output and dependency directories. */
export async function computeProjectFingerprint(
fileSystem: LocalFileSystem,
root: string,
): Promise<string> {
const hash = createHash("sha256");
hash.update(`agentcore-cli:${PACKAGE_VERSION}\0`);

for (const absolutePath of await projectFiles(fileSystem, root)) {
const relativePath = normalize(relative(root, absolutePath));
const stats = await fileSystem.lstat(absolutePath);
hash.update(relativePath);
hash.update("\0");
hash.update(String(stats.mode & 0o777));
hash.update("\0");

if (stats.kind === "symbolic-link") {
hash.update("link\0");
hash.update(await fileSystem.readLink(absolutePath));
} else {
hash.update("file\0");
hash.update(await fileSystem.readBytes(absolutePath));
}
hash.update("\0");
}

return hash.digest("hex");
}
Loading
Loading