Skip to content

Commit 78348f6

Browse files
committed
refactor(project): load the CDK toolkit lazily and test the adapter
The toolkit is the heaviest dependency in the CLI and src/io/index.ts re-exports runCdk, so a static import made every command load it: agentcore --help ran in 2.7s from source, 0.7s once the import moved inside the function that builds the toolkit. The compiled binary is unchanged either way, since --compile embeds the module regardless. src/io/cdk.ts splits into the three things a run does -- load the toolkit, perform one operation with it, bridge its reporting to a generator -- so each is reachable from a test. src/io/cdk.test.ts covers them against the real toolkit package: constructing a Toolkit and its BootstrapEnvironments, BootstrapStackParameters, and StackSelectionStrategy helpers resolves no credentials and calls no API, so the arguments a deploy passes are asserted against the values the toolkit itself defines rather than stand-ins. What the tests assert includes the ones a caller cannot see and a fake cannot check: that messages are yielded while the operation is still running, that a failure surfaces only after the output explaining it, that a request is answered with its suggested default rather than prompting, and that createCustomerMasterKey and PATTERN_MUST_MATCH reach the toolkit. The fake in TestCoreClient still buffers rather than streams; it now says so, and names the test that covers the real behaviour. ProjectEvent becomes a discriminated union. It documented that exactly one of step and output is set while typing both optional, which allowed {} and both-at-once and spread `if (event.output)` checks through three handlers. Both variants carry `message`, so a consumer that only writes text needs no switch, and those checks are gone.
1 parent 5ae34c8 commit 78348f6

9 files changed

Lines changed: 350 additions & 80 deletions

File tree

src/core/project/manager.test.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ describe("FsProjectManager.build", () => {
273273
cwd: cdkDir,
274274
},
275275
]);
276-
expect(events).toEqual([{ message: "Synthesizing CloudFormation templates" }]);
276+
expect(events).toEqual([{ kind: "step", message: "Synthesizing CloudFormation templates" }]);
277277
});
278278

279279
test("fails actionably when the CDK dependencies are missing", async () => {
@@ -444,6 +444,11 @@ describe("FsProjectManager.deploy", () => {
444444
return events;
445445
}
446446

447+
// The toolkit's own messages, as opposed to the steps this CLI words itself.
448+
function forwarded(events: ProjectEvent[]): string[] {
449+
return events.filter((event) => event.kind === "output").map((event) => event.message);
450+
}
451+
447452
test("synthesizes, bootstraps the target environment, then deploys its stack", async () => {
448453
const directory = await inTempDirectory();
449454
const { manager: subject, commands, runs } = deployManager();
@@ -463,9 +468,9 @@ describe("FsProjectManager.deploy", () => {
463468
{ operation: { kind: "deploy", stackName: stackName("default") }, options },
464469
]);
465470
expect(events).toEqual([
466-
{ message: "Synthesizing CloudFormation templates" },
467-
{ message: "Bootstrapping aws://111122223333/us-east-1" },
468-
{ message: `Deploying ${stackName("default")}` },
471+
{ kind: "step", message: "Synthesizing CloudFormation templates" },
472+
{ kind: "step", message: "Bootstrapping aws://111122223333/us-east-1" },
473+
{ kind: "step", message: `Deploying ${stackName("default")}` },
469474
]);
470475
});
471476

@@ -554,6 +559,20 @@ describe("FsProjectManager.deploy", () => {
554559
expect(runs).toEqual([]);
555560
});
556561

562+
test("names the path it looked in when synthesis wrote no assembly", async () => {
563+
const directory = await inTempDirectory();
564+
const { manager: subject, runs } = deployManager();
565+
const project = await scaffolded(subject, directory);
566+
// Synthesis is stubbed in these tests, so removing the stand-in manifest is what
567+
// a real synth writing somewhere else entirely would leave behind.
568+
await rm(join(assemblyDirectory(directory), "manifest.json"));
569+
570+
await expect(
571+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
572+
).rejects.toThrow(/No synthesized cloud assembly was found at .*manifest\.json/);
573+
expect(runs).toEqual([]);
574+
});
575+
557576
test("skips bootstrapping when asked, and still deploys", async () => {
558577
const directory = await inTempDirectory();
559578
const { manager: subject, commands, runs } = deployManager();
@@ -569,7 +588,10 @@ describe("FsProjectManager.deploy", () => {
569588
expect(runs.map(({ operation }) => operation)).toEqual([
570589
{ kind: "deploy", stackName: stackName("default") },
571590
]);
572-
expect(events).not.toContainEqual({ message: "Bootstrapping aws://111122223333/us-east-1" });
591+
expect(events).not.toContainEqual({
592+
kind: "step",
593+
message: "Bootstrapping aws://111122223333/us-east-1",
594+
});
573595
});
574596

575597
test("names the file to fix when no deployment targets are configured", async () => {
@@ -622,7 +644,7 @@ describe("FsProjectManager.deploy", () => {
622644
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
623645
);
624646

625-
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
647+
expect(forwarded(events)).toEqual([
626648
"example-stack: creating CloudFormation changeset...",
627649
"example-stack: deployed",
628650
]);
@@ -643,9 +665,7 @@ describe("FsProjectManager.deploy", () => {
643665
);
644666

645667
// The suppressed ones are still in the debug log; only the warning is surfaced.
646-
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
647-
"example-stack: no changes",
648-
]);
668+
expect(forwarded(events)).toEqual(["example-stack: no changes"]);
649669
});
650670

651671
test("yields the output that explains a failure before propagating it", async () => {
@@ -668,7 +688,7 @@ describe("FsProjectManager.deploy", () => {
668688
for await (const event of generator) events.push(event);
669689
})(),
670690
).rejects.toThrow("cdk deploy exploded");
671-
expect(events).toContainEqual({ output: "example-stack: CREATE_FAILED" });
691+
expect(events).toContainEqual({ kind: "output", message: "example-stack: CREATE_FAILED" });
672692
});
673693

674694
test("refuses a project managed by a backend it cannot deploy", async () => {

src/core/project/manager.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,15 @@ export class FsProjectManager implements ProjectManager {
103103
const destination = join(process.cwd(), input.name);
104104
this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`);
105105

106-
yield { message: "Creating project tree" };
106+
yield { kind: "step", message: "Creating project tree" };
107107
const tree = await createProjectTreeFromTemplate(input.name, input.template, this.source);
108108
await tree.write(destination);
109109

110110
// A failed step leaves the scaffolded files in place; the error tells the
111111
// user how to rerun the step by hand.
112112
if (!input.skipInstall) {
113113
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
114-
yield { message: "Installing CDK dependencies with npm" };
114+
yield { kind: "step", message: "Installing CDK dependencies with npm" };
115115
await this.run(["npm", "install"], join(destination, "agentcore", "cdk"));
116116

117117
const appDir = join(destination, "app", TEMPLATES[input.template].appDir);
@@ -120,14 +120,14 @@ export class FsProjectManager implements ProjectManager {
120120
"uv",
121121
"Install uv: https://docs.astral.sh/uv/getting-started/installation/",
122122
);
123-
yield { message: "Syncing Python dependencies with uv" };
123+
yield { kind: "step", message: "Syncing Python dependencies with uv" };
124124
await this.run(["uv", "sync"], appDir);
125125
}
126126
}
127127

128128
if (!input.skipGit) {
129129
await this.checkTool("git", "Install git: https://git-scm.com/downloads");
130-
yield { message: "Initializing git repository" };
130+
yield { kind: "step", message: "Initializing git repository" };
131131
await this.run(["git", "init"], destination);
132132
}
133133

@@ -192,7 +192,7 @@ export class FsProjectManager implements ProjectManager {
192192
// reads this directory back. A project that sets cdk.json's `output` would
193193
// otherwise send synth somewhere deploy never looks, and deploy would ship
194194
// whatever stale assembly it found there while reporting success.
195-
yield { message: "Synthesizing CloudFormation templates" };
195+
yield { kind: "step", message: "Synthesizing CloudFormation templates" };
196196
await this.run(
197197
["npm", "run", "cdk", "--", "synth", "--quiet", "--output", this.assemblyPath(project)],
198198
cdkDir,
@@ -279,11 +279,11 @@ export class FsProjectManager implements ProjectManager {
279279
// environment, so it runs every deploy rather than probing CloudFormation
280280
// first.
281281
const environment = `aws://${target.account}/${target.region}`;
282-
yield { message: `Bootstrapping ${environment}` };
282+
yield { kind: "step", message: `Bootstrapping ${environment}` };
283283
yield* this.streamCdk({ kind: "bootstrap", environments: [environment] }, run);
284284
}
285285

286-
yield { message: `Deploying ${stackName}` };
286+
yield { kind: "step", message: `Deploying ${stackName}` };
287287
yield* this.streamCdk({ kind: "deploy", stackName }, run);
288288
}
289289

@@ -296,7 +296,7 @@ export class FsProjectManager implements ProjectManager {
296296
for await (const event of this.cdk(operation, options)) {
297297
this.logger.debug(event.message);
298298
if (event.level === "debug" || event.level === "trace") continue;
299-
yield { output: event.message };
299+
yield { kind: "output", message: event.message };
300300
}
301301
}
302302

src/handlers/project/build/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) =>
1818
// Progress goes to stderr, keeping stdout for machine output. Subprocess
1919
// output goes to the debug log; on failure ProcessFailedError carries it.
2020
for await (const event of config.projectManager.build(project)) {
21-
if (event.message) {
22-
config.io.stderr.write(`${event.message}\n`);
23-
}
21+
config.io.stderr.write(`${event.message}\n`);
2422
}
2523

2624
config.io.stderr.write(`Built project '${project.name}'\n`);

src/handlers/project/create/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =
3535
skipInstall: flags["skip-install"],
3636
skipGit: flags["skip-git"],
3737
})) {
38-
if (event.message) {
39-
config.io.stderr.write(`${event.message}\n`);
40-
}
38+
config.io.stderr.write(`${event.message}\n`);
4139
}
4240

4341
config.io.stderr.write(`Created project '${flags["name"]}' in ./${flags["name"]}\n`);

src/handlers/project/deploy/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =
3636
skipBootstrap: flags["skip-bootstrap"],
3737
target: flags.target,
3838
})) {
39-
if (event.message) {
40-
config.io.stderr.write(`${event.message}\n`);
41-
}
42-
if (event.output) {
43-
config.io.stderr.write(`${event.output}\n`);
44-
}
39+
config.io.stderr.write(`${event.message}\n`);
4540
}
4641

4742
config.io.stderr.write(`Deployed project '${project.name}'\n`);

src/handlers/project/types.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@ export type CreateProjectInput = {
3333
};
3434

3535
/**
36-
* A progress step reported while a long-running project operation runs, or a line
37-
* of output from the tool it drives. One of the two is set per event: a step is
38-
* this CLI's own wording, while output is forwarded as the tool phrased it.
36+
* Something worth showing the user while a long-running project operation runs.
37+
*
38+
* A union rather than two optional fields, so an event is always exactly one of the
39+
* two and a consumer that only writes text can read `message` without checking
40+
* which it got.
3941
*/
40-
export type ProjectEvent = {
41-
message?: string;
42-
output?: string;
43-
};
42+
export type ProjectEvent =
43+
/** A progress step, in this CLI's own wording. */
44+
| { kind: "step"; message: string }
45+
/** A line of output, forwarded as the tool that produced it phrased it. */
46+
| { kind: "output"; message: string };
4447

4548
export type DeployProjectOptions = {
4649
/** The resolved AWS region, forwarded to the CDK subprocesses. */

0 commit comments

Comments
 (0)