diff --git a/src/components/ui/task-list/TaskList.test.tsx b/src/components/ui/task-list/TaskList.test.tsx
new file mode 100644
index 000000000..91531d4f7
--- /dev/null
+++ b/src/components/ui/task-list/TaskList.test.tsx
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { cleanup, render } from "ink-testing-library";
+import { TaskList, type Task } from "./TaskList";
+
+afterEach(cleanup);
+
+function frameOf(tasks: Task[], tailLines?: number): string {
+ const instance = render();
+ const frame = instance.lastFrame() ?? "";
+ instance.unmount();
+ return frame;
+}
+
+describe("TaskList", () => {
+ test("marks done, running, and failed tasks with the shared glyphs", () => {
+ const frame = frameOf([
+ { title: "Verifying AWS account", state: "done", tail: [] },
+ { title: "Deploying stack", state: "running", tail: [] },
+ { title: "Removing stack", state: "failed", tail: [] },
+ ]);
+
+ expect(frame).toContain("✓ Verifying AWS account");
+ expect(frame).toContain("✕ Removing stack");
+ // The running task renders a spinner frame instead of a completion glyph.
+ expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Deploying stack/);
+ });
+
+ test("shows the tail only under a running task, gutter-prefixed", () => {
+ const frame = frameOf([
+ { title: "Synthesizing", state: "done", tail: ["stale synth line"] },
+ { title: "Deploying", state: "running", tail: ["3/12 | CREATE_IN_PROGRESS"] },
+ ]);
+
+ expect(frame).toContain(" │ 3/12 | CREATE_IN_PROGRESS");
+ expect(frame).not.toContain("stale synth line");
+ });
+
+ test("keeps a failed task's tail visible", () => {
+ const frame = frameOf([
+ { title: "Deploying", state: "failed", tail: ["CREATE_FAILED | AWS::IAM::Role"] },
+ ]);
+
+ expect(frame).toContain("✕ Deploying");
+ expect(frame).toContain(" │ CREATE_FAILED | AWS::IAM::Role");
+ });
+
+ test("shows only the last tailLines lines", () => {
+ const frame = frameOf(
+ [{ title: "Deploying", state: "running", tail: ["one", "two", "three"] }],
+ 2,
+ );
+
+ expect(frame).not.toContain("│ one");
+ expect(frame).toContain("│ two");
+ expect(frame).toContain("│ three");
+ });
+
+ test("truncates tail lines to the terminal width", () => {
+ const instance = render(<>>);
+ Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 24 });
+ instance.rerender(
+ ,
+ );
+
+ const tailLine = (instance.lastFrame() ?? "").split("\n").find((line) => line.includes("│"))!;
+ expect(tailLine.length).toBeLessThanOrEqual(24);
+ expect(tailLine).toContain("…");
+ instance.unmount();
+ });
+});
diff --git a/src/components/ui/task-list/TaskList.tsx b/src/components/ui/task-list/TaskList.tsx
new file mode 100644
index 000000000..db956c594
--- /dev/null
+++ b/src/components/ui/task-list/TaskList.tsx
@@ -0,0 +1,68 @@
+import React from "react";
+import { Box, Text, useStdout } from "ink";
+import cliTruncate from "cli-truncate";
+import { darkTheme } from "../_core.js";
+import type { InkUITheme } from "../_core.js";
+import { Spinner } from "../spinner/Spinner.js";
+
+export type TaskState = "running" | "done" | "failed";
+
+export interface Task {
+ title: string;
+ state: TaskState;
+ /** Recent output lines attributed to this task. Only shown while it runs (or after it fails). */
+ tail: string[];
+}
+
+export interface TaskListProps {
+ tasks: Task[];
+ /** Maximum tail lines rendered under a running or failed task. */
+ tailLines?: number;
+ theme?: InkUITheme;
+}
+
+const DEFAULT_TAIL_LINES = 5;
+
+/**
+ * A vertical list of long-running steps: a spinner marks the running task, ✓/✕
+ * mark finished ones (the Stepper glyph vocabulary), and the running task shows
+ * a live tail of its recent output behind a muted `│` gutter. Presentational
+ * only — drive it from runWithProgress (src/tui/progress.tsx) or feed it Task
+ * state directly. Designed for inline (scrollback) rendering, where a finished
+ * task's collapsed tail leaves only its ✓ line behind.
+ */
+export const TaskList: React.FC = ({
+ tasks,
+ tailLines = DEFAULT_TAIL_LINES,
+ theme = darkTheme,
+}) => {
+ const { stdout } = useStdout();
+ // `||`, not `??`: a pty can report 0 columns, which would truncate every
+ // tail line to nothing. Match Ink's own layout fallback of 80.
+ const columns = stdout?.columns || 80;
+
+ return (
+
+ {tasks.map((task, index) => (
+
+ {task.state === "running" ? (
+
+ ) : (
+
+
+ {task.state === "done" ? "✓" : "✕"}
+
+ {task.title}
+
+ )}
+ {task.state !== "done" &&
+ task.tail.slice(-tailLines).map((line, lineIndex) => (
+
+ {cliTruncate(` │ ${line}`, columns)}
+
+ ))}
+
+ ))}
+
+ );
+};
diff --git a/src/components/ui/task-list/index.ts b/src/components/ui/task-list/index.ts
new file mode 100644
index 000000000..3bff2deeb
--- /dev/null
+++ b/src/components/ui/task-list/index.ts
@@ -0,0 +1,2 @@
+export type { Task, TaskListProps, TaskState } from "./TaskList.js";
+export { TaskList } from "./TaskList.js";
diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts
index f152b528b..8bc8f954b 100644
--- a/src/core/project/backends/cdk.test.ts
+++ b/src/core/project/backends/cdk.test.ts
@@ -127,6 +127,10 @@ type HarnessOptions = {
bootstrapError?: Error;
/** Stack returned by CloudFormation. Defaults to a present stack; null means absent. */
describedStack?: Stack | null;
+ /** Chunks the fake synth process streams through onOutput. */
+ synthOutput?: string[];
+ /** Lines the fake Toolkit reports through each operation's onOutput sink. */
+ cdkOutput?: string[];
};
function harness(options: HarnessOptions = {}) {
@@ -148,8 +152,9 @@ function harness(options: HarnessOptions = {}) {
const backend = new CdkBackend({
logger: createSilentLogger(),
- runner: async (command, { cwd }) => {
+ runner: async (command, { cwd, onOutput }) => {
commands.push({ command, cwd });
+ for (const chunk of options.synthOutput ?? []) onOutput?.(chunk);
},
checkTool: async () => {},
resolveCredentials: async (region) => {
@@ -169,6 +174,7 @@ function harness(options: HarnessOptions = {}) {
},
cdk: async (operation, runOptions) => {
runs.push({ operation, options: runOptions });
+ for (const line of options.cdkOutput ?? []) runOptions.onOutput?.(line);
if (operation.kind === options.failOperation) {
throw new Error(`${operation.kind} failed`);
}
@@ -248,11 +254,25 @@ describe("CdkBackend.build", () => {
const subject = harness();
expect(await collect(subject.backend.build(input))).toEqual([
- { message: "Synthesizing CloudFormation templates" },
+ { type: "step", message: "Synthesizing CloudFormation templates" },
]);
expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]);
});
+ test("streams synth output as line-buffered output events", async () => {
+ const input = await project();
+ // The chunk boundary splits a line, so a chunk-per-event bridge would leak
+ // the fragments "line t" / "wo".
+ const subject = harness({ synthOutput: ["line one\nline t", "wo\ntrailing partial"] });
+
+ expect(await collect(subject.backend.build(input))).toEqual([
+ { type: "step", message: "Synthesizing CloudFormation templates" },
+ { type: "output", line: "line one" },
+ { type: "output", line: "line two" },
+ { type: "output", line: "trailing partial" },
+ ]);
+ });
+
test("fails actionably when CDK dependencies are missing", async () => {
const input = await project(false);
const subject = harness();
@@ -284,9 +304,9 @@ describe("CdkBackend.deploy", () => {
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));
expect(deployed.events).toEqual([
- { message: `Verifying AWS account ${TARGET.account}` },
- { message: "Synthesizing CloudFormation templates" },
- { message: "Deploying AgentCore-example-default-0" },
+ { type: "step", message: `Verifying AWS account ${TARGET.account}` },
+ { type: "step", message: "Synthesizing CloudFormation templates" },
+ { type: "step", message: "Deploying AgentCore-example-default-0" },
]);
expect(deployed.result).toEqual({ outputs: { RuntimeArn: "arn:runtime" } });
expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]);
@@ -300,6 +320,9 @@ describe("CdkBackend.deploy", () => {
assemblyDirectory: assemblyDirectory(input),
credentials: subject.credentials,
region: TARGET.region,
+ // The backend wires each operation's Toolkit output into its own
+ // event stream through this per-operation sink.
+ onOutput: expect.any(Function),
},
},
]);
@@ -311,6 +334,38 @@ describe("CdkBackend.deploy", () => {
expect(subject.templateLoads()).toBe(0);
});
+ test("streams Toolkit lines as output events under the deploy step", async () => {
+ const input = await project();
+ await writeAssembly(input, [TARGET.name]);
+ const subject = harness({
+ outputs: {},
+ cdkOutput: ["AgentCore-example-default-0 | 4/12 | CREATE_IN_PROGRESS"],
+ });
+
+ const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));
+
+ const deployStep = deployed.events.findIndex(
+ (event) => event.type === "step" && event.message.startsWith("Deploying"),
+ );
+ expect(deployed.events.slice(deployStep)).toEqual([
+ { type: "step", message: "Deploying AgentCore-example-default-0" },
+ { type: "output", line: "AgentCore-example-default-0 | 4/12 | CREATE_IN_PROGRESS" },
+ ]);
+ });
+
+ test("attaches the Toolkit's recent output to a terse operation failure", async () => {
+ const input = await project();
+ await writeAssembly(input, [TARGET.name]);
+ const subject = harness({
+ failOperation: "deploy",
+ cdkOutput: ["CREATE_FAILED | AWS::IAM::Role | RuntimeRole", "ROLLBACK_IN_PROGRESS"],
+ });
+
+ await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
+ /deploy failed[\s\S]*Recent output:[\s\S]*CREATE_FAILED \| AWS::IAM::Role \| RuntimeRole[\s\S]*ROLLBACK_IN_PROGRESS/,
+ );
+ });
+
test("persists the deployed stack ARN under the target", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
@@ -440,6 +495,7 @@ describe("CdkBackend.deploy", () => {
expect(deployed.result).toEqual({ outputs: {}, tornDown: true });
expect(deployed.events).toContainEqual({
+ type: "step",
message: "Removing stack AgentCore-example-default-0",
});
// Destroyed explicitly, rather than by deploying an empty template and
@@ -500,6 +556,7 @@ describe("CdkBackend.deploy", () => {
{ kind: "deploy", stackArtifactId: "AgentCore-example-default-0" },
]);
expect(deployed.events).toContainEqual({
+ type: "step",
message: `Bootstrapping aws://${TARGET.account}/${TARGET.region}`,
});
});
diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts
index 516ce0aa9..19264e210 100644
--- a/src/core/project/backends/cdk.ts
+++ b/src/core/project/backends/cdk.ts
@@ -9,12 +9,14 @@ import type {
ResolvedDeployedResource,
} from "../../../handlers/project/types";
import {
+ createLineSplitter,
FsReadWriteJson,
requireTool,
runProcess,
type ProcessRunner,
type ReadWriteJson,
} from "../../../io";
+import { withOutputEvents } from "../events";
import type { Logger } from "../../../logging";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
import type {
@@ -44,13 +46,21 @@ import {
loadBootstrapTemplate,
type BootstrapTemplateLoader,
type CdkCredentialResolver,
+ type CdkOperation,
type CdkRunner,
type CdkRunOptions,
+ type CdkRunResult,
} from "./cdk/toolkit";
import { describeStack } from "./cdk/stackReader";
type StackDescriber = typeof describeStack;
+/**
+ * How many trailing Toolkit lines an operation keeps for error context. Matches
+ * the cap streamProcess uses for a failed subprocess's captured output.
+ */
+const MAX_ERROR_OUTPUT_LINES = 20;
+
function findDeployedResourceId(
stack: Stack,
input: Pick,
@@ -127,14 +137,31 @@ export class CdkBackend implements ProjectBackend {
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
- yield { message: "Synthesizing CloudFormation templates" };
- await this.runner(
- ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", this.assemblyDirectory(project)],
- {
- cwd: cdkDir,
- onOutput: (chunk) => this.logger.debug(chunk),
- },
- );
+ yield { type: "step", message: "Synthesizing CloudFormation templates" };
+ yield* withOutputEvents((emit) => {
+ // Chunks still go to the debug log whole; the splitter reassembles them
+ // into lines for the live progress tail.
+ const lines = createLineSplitter(emit);
+ return this.runner(
+ [
+ "npm",
+ "run",
+ "cdk",
+ "--",
+ "synth",
+ "--quiet",
+ "--output",
+ this.assemblyDirectory(project),
+ ],
+ {
+ cwd: cdkDir,
+ onOutput: (chunk) => {
+ this.logger.debug(chunk);
+ lines.push(chunk);
+ },
+ },
+ ).finally(() => lines.flush());
+ });
}
public async *deploy(
@@ -142,7 +169,7 @@ export class CdkBackend implements ProjectBackend {
input: DeployBackendInput,
): AsyncGenerator {
const { target } = input;
- yield { message: `Verifying AWS account ${target.account}` };
+ yield { type: "step", message: `Verifying AWS account ${target.account}` };
const credentials = await this.credentialsForTarget(target);
// Validate any existing deployed state before mutating AWS. A malformed file
@@ -174,10 +201,10 @@ export class CdkBackend implements ProjectBackend {
if (bootstrap.kind !== "current") {
const environment = `aws://${target.account}/${target.region}`;
- yield { message: `Bootstrapping ${environment}` };
+ yield { type: "step", message: `Bootstrapping ${environment}` };
const template = await this.loadBootstrapTemplate();
try {
- await this.cdk(
+ yield* this.runCdk(
{
kind: "bootstrap",
environments: [environment],
@@ -190,8 +217,8 @@ export class CdkBackend implements ProjectBackend {
}
}
- yield { message: `Deploying ${artifact.id}` };
- const { outputs, stackArn } = await this.cdk(
+ yield { type: "step", message: `Deploying ${artifact.id}` };
+ const { outputs, stackArn } = yield* this.runCdk(
{ kind: "deploy", stackArtifactId: artifact.id },
options,
);
@@ -256,12 +283,43 @@ export class CdkBackend implements ProjectBackend {
);
}
- yield { message: `Removing stack ${artifact.stackName}` };
- await this.cdk({ kind: "destroy", stackArtifactId: artifact.id }, options);
+ yield { type: "step", message: `Removing stack ${artifact.stackName}` };
+ yield* this.runCdk({ kind: "destroy", stackArtifactId: artifact.id }, options);
await removeTargetState(this.json, project.rootPath, target.name);
return { outputs: {}, tornDown: true };
}
+ /**
+ * Runs one Toolkit operation with its progress streamed as `output` events.
+ * The trailing lines are also kept so a failure can carry them: the Toolkit's
+ * errors are often terse ("Access Denied"), and the resource events it
+ * reported just before failing are what make the error debuggable from the
+ * terminal alone.
+ */
+ private async *runCdk(
+ operation: CdkOperation,
+ options: CdkRunOptions,
+ ): AsyncGenerator {
+ const recent: string[] = [];
+ try {
+ return yield* withOutputEvents((emit) =>
+ this.cdk(operation, {
+ ...options,
+ onOutput: (line) => {
+ recent.push(line);
+ if (recent.length > MAX_ERROR_OUTPUT_LINES) recent.shift();
+ emit(line);
+ },
+ }),
+ );
+ } catch (error) {
+ if (error instanceof Error && recent.length > 0) {
+ error.message += `\n\nRecent output:\n${recent.join("\n")}`;
+ }
+ throw error;
+ }
+ }
+
public async resolveDeployedResources(
project: Project,
input: ResolveDeployedResourcesBackendInput,
diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts
index 6fbfc5828..747bdf833 100644
--- a/src/core/project/backends/cdk/toolkit.test.ts
+++ b/src/core/project/backends/cdk/toolkit.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { rm } from "node:fs/promises";
import { dirname } from "node:path";
-import type { IoMessage, IoRequest } from "@aws-cdk/toolkit-lib";
+import type { IIoHost, IoMessage, IoRequest } from "@aws-cdk/toolkit-lib";
import * as toolkitLib from "@aws-cdk/toolkit-lib";
import { createSilentLogger } from "../../../../testing";
import {
@@ -97,6 +97,22 @@ describe("CDK Toolkit IO", () => {
expect(debug).toHaveBeenCalledWith("stack deployment started");
});
+ test("forwards notification lines to the output sink while still debug-logging", async () => {
+ const logger = createSilentLogger();
+ const debug = mock(() => {});
+ logger.debug = debug;
+ const lines: string[] = [];
+
+ // A multi-line Toolkit message (a diff, a stack trace) must reach the sink
+ // as displayable single lines.
+ await createCdkIoHost(logger, (line) => lines.push(line)).notify(
+ message("first line\nsecond line\n"),
+ );
+
+ expect(lines).toEqual(["first line", "second line"]);
+ expect(debug).toHaveBeenCalledWith("first line\nsecond line\n");
+ });
+
test("answers noninteractive requests with their default response", async () => {
const ioHost = createCdkIoHost(createSilentLogger());
const response = await ioHost.requestResponse({
@@ -273,6 +289,24 @@ describe("Toolkit loading", () => {
expect(regions).toEqual(["eu-west-1"]);
expect(providers).toEqual([credentials]);
});
+
+ test("routes the operation's ioHost messages into its onOutput sink", async () => {
+ const { loaded } = loadedToolkit();
+ const lines: string[] = [];
+ const ioHosts: IIoHost[] = [];
+ const runner = createCdkRunner(createSilentLogger(), async (ioHost) => {
+ ioHosts.push(ioHost);
+ return loaded;
+ });
+
+ await runner(
+ { kind: "deploy", stackArtifactId: "AgentCore-orders-default" },
+ runOptions({ onOutput: (line) => lines.push(line) }),
+ );
+ await ioHosts[0]!.notify(message("CREATE_COMPLETE | AWS::IAM::Role"));
+
+ expect(lines).toEqual(["CREATE_COMPLETE | AWS::IAM::Role"]);
+ });
});
describe("bootstrap template loading", () => {
diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts
index d747117c2..470c3bce0 100644
--- a/src/core/project/backends/cdk/toolkit.ts
+++ b/src/core/project/backends/cdk/toolkit.ts
@@ -23,6 +23,12 @@ export type CdkRunOptions = {
credentials: CdkCredentialProvider;
/** Region used for the Toolkit's own AWS SDK calls. */
region: string;
+ /**
+ * Receives each line the Toolkit reports during this operation (resource
+ * progress, asset publishing). Everything still lands in the debug log
+ * whether or not a sink is given.
+ */
+ onOutput?: (line: string) => void;
};
export type CdkOutputs = Record;
@@ -113,7 +119,7 @@ export async function loadBootstrapTemplate(
};
}
-export function createCdkIoHost(logger: Logger): IIoHost {
+export function createCdkIoHost(logger: Logger, onLine?: (line: string) => void): IIoHost {
const toolkitLogger = logger.child({ component: "cdk-toolkit" });
const notify = async (message: IoMessage): Promise => {
toolkitLogger
@@ -123,6 +129,14 @@ export function createCdkIoHost(logger: Logger): IIoHost {
...(message.code && { code: message.code }),
})
.debug(message.message);
+ // Toolkit messages can span lines (diffs, stack traces); split so the sink
+ // always receives displayable single lines.
+ if (onLine) {
+ for (const line of message.message.split("\n")) {
+ const trimmed = line.trimEnd();
+ if (trimmed) onLine(trimmed);
+ }
+ }
};
return {
@@ -227,8 +241,10 @@ export function createCdkRunner(
logger: Logger,
load: CdkToolkitLoader = loadCdkToolkit,
): CdkRunner {
- const ioHost = createCdkIoHost(logger);
return async (operation, options) => {
+ // A fresh ioHost per operation, so each operation's messages can flow into
+ // its own onOutput sink rather than only the construction-time logger.
+ const ioHost = createCdkIoHost(logger, options.onOutput);
const loaded = await load(ioHost, options.region, options.credentials);
return performCdkOperation(loaded, operation, options);
};
diff --git a/src/core/project/events.test.ts b/src/core/project/events.test.ts
new file mode 100644
index 000000000..fc1452454
--- /dev/null
+++ b/src/core/project/events.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, test } from "bun:test";
+import type { ProjectEvent } from "../../handlers/project/types";
+import { withOutputEvents } from "./events";
+
+async function collect(
+ generator: AsyncGenerator,
+): Promise<{ events: ProjectEvent[]; result: T }> {
+ const events: ProjectEvent[] = [];
+ while (true) {
+ const next = await generator.next();
+ if (next.done) return { events, result: next.value };
+ events.push(next.value);
+ }
+}
+
+describe("withOutputEvents", () => {
+ test("yields emitted lines as output events and returns the operation's result", async () => {
+ const { events, result } = await collect(
+ withOutputEvents(async (emit) => {
+ emit("one");
+ emit("two");
+ return 42;
+ }),
+ );
+
+ expect(events).toEqual([
+ { type: "output", line: "one" },
+ { type: "output", line: "two" },
+ ]);
+ expect(result).toBe(42);
+ });
+
+ test("keeps yielding while the operation is still running", async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const generator = withOutputEvents(async (emit) => {
+ emit("early");
+ await gate;
+ emit("late");
+ return "done";
+ });
+
+ expect((await generator.next()).value).toEqual({ type: "output", line: "early" });
+ release();
+ expect((await generator.next()).value).toEqual({ type: "output", line: "late" });
+ const next = await generator.next();
+ expect(next.done).toBe(true);
+ expect(next.value).toBe("done");
+ });
+
+ test("drains pending lines before rethrowing the operation's failure", async () => {
+ const failure = new Error("operation exploded");
+ const generator = withOutputEvents(async (emit) => {
+ emit("last words");
+ throw failure;
+ });
+
+ const events: ProjectEvent[] = [];
+ await expect(
+ (async () => {
+ for await (const event of generator) events.push(event);
+ })(),
+ ).rejects.toBe(failure);
+ expect(events).toEqual([{ type: "output", line: "last words" }]);
+ });
+});
diff --git a/src/core/project/events.ts b/src/core/project/events.ts
new file mode 100644
index 000000000..e951c15b9
--- /dev/null
+++ b/src/core/project/events.ts
@@ -0,0 +1,23 @@
+import type { ProjectEvent } from "../../handlers/project/types";
+import { AsyncChannel } from "../../io";
+
+/**
+ * Runs `operation`, yielding every line handed to its `emit` callback as an
+ * `output` event while the operation is in flight, then returns (or rethrows)
+ * its result. This is the seam between push-style output sources (process
+ * chunk callbacks, the CDK Toolkit's ioHost) and the pull-based ProjectEvent
+ * generators long-running commands expose: the operation pushes, the enclosing
+ * generator's consumer pulls.
+ */
+export async function* withOutputEvents(
+ operation: (emit: (line: string) => void) => Promise,
+): AsyncGenerator {
+ const channel = new AsyncChannel();
+ const running = operation((line) => channel.push(line));
+ // The rejection is consumed by the await below; this handler only keeps the
+ // window between the failure and the channel draining from being reported as
+ // an unhandled rejection.
+ running.catch(() => {}).finally(() => channel.close());
+ for await (const line of channel) yield { type: "output", line };
+ return await running;
+}
diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts
index 06a289366..fc770885d 100644
--- a/src/core/project/manager.test.ts
+++ b/src/core/project/manager.test.ts
@@ -277,7 +277,7 @@ describe("FsProjectManager.create", () => {
scaffoldRuntimeInput: HELLO_WORLD_PYTHON,
});
- expect(events.map((event) => event.message)).toEqual([
+ expect(events.flatMap((event) => (event.type === "step" ? [event.message] : []))).toEqual([
"Creating project tree",
"Installing CDK dependencies with npm",
"Syncing Python dependencies with uv",
@@ -374,7 +374,7 @@ describe("FsProjectManager.build", () => {
cwd: join(directory, "example", "agentcore", "cdk"),
},
]);
- expect(events).toEqual([{ message: "Synthesizing CloudFormation templates" }]);
+ expect(events).toEqual([{ type: "step", message: "Synthesizing CloudFormation templates" }]);
});
test("fails actionably when the CDK dependencies are missing", async () => {
@@ -430,7 +430,7 @@ describe("FsProjectManager.deploy", () => {
async *build() {},
async *deploy(project, input) {
calls.push({ project, input });
- yield { message: "Backend deployment started" };
+ yield { type: "step" as const, message: "Backend deployment started" };
return { outputs: { RuntimeArn: "arn:runtime" } };
},
async resolveDeployedResources() {
@@ -512,7 +512,7 @@ describe("FsProjectManager.deploy", () => {
expect(subject.calls).toHaveLength(1);
expect(subject.calls[0]?.project).toBe(project);
expect(subject.calls[0]?.input.target).toEqual(targets[1]);
- expect(deployed.events).toEqual([{ message: "Backend deployment started" }]);
+ expect(deployed.events).toEqual([{ type: "step", message: "Backend deployment started" }]);
expect(deployed.result).toEqual({
outputs: { RuntimeArn: "arn:runtime" },
});
@@ -600,8 +600,8 @@ describe("FsProjectManager.deploy", () => {
expect(subject.calls).toHaveLength(1);
expect(subject.calls[0]?.input.target).toEqual(SYNTHESIZED);
expect(deployed.events).toEqual([
- { message: CREATED_MESSAGE },
- { message: "Backend deployment started" },
+ { type: "step", message: CREATED_MESSAGE },
+ { type: "step", message: "Backend deployment started" },
]);
expect(await Bun.file(targetsFile(root)).json()).toEqual([SYNTHESIZED]);
});
@@ -701,7 +701,7 @@ describe("FsProjectManager.deploy", () => {
expect(subject.accountCalls).toEqual([]);
expect(subject.calls[0]?.input.target).toEqual(configured[0]!);
- expect(deployed.events).toEqual([{ message: "Backend deployment started" }]);
+ expect(deployed.events).toEqual([{ type: "step", message: "Backend deployment started" }]);
expect(await Bun.file(targetsFile(root)).text()).toBe(contents);
});
});
diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx
index bc2b5da91..2901d7afd 100644
--- a/src/core/project/manager.tsx
+++ b/src/core/project/manager.tsx
@@ -13,6 +13,7 @@ import type {
ResolvedDeployedResource,
ResolvedDeployedResources,
ResolveProjectInput,
+ ResolveTargetInput,
Project,
ProjectManager,
ProjectEvent,
@@ -160,7 +161,7 @@ export class FsProjectManager implements ProjectManager {
const scaffoldRuntimeInput = input.scaffoldRuntimeInput;
const destination = join(process.cwd(), input.name);
- yield { message: "Creating project tree" };
+ yield { type: "step", message: "Creating project tree" };
const projectTree = await createProjectTree(
{ templateRenderer: this.templateRenderer, assetSource: this.assetSource },
{ projectName: input.name },
@@ -188,7 +189,7 @@ export class FsProjectManager implements ProjectManager {
// user how to rerun the step by hand.
if (!input.skipInstall) {
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
- yield { message: "Installing CDK dependencies with npm" };
+ yield { type: "step", message: "Installing CDK dependencies with npm" };
await this.run(["npm", "install"], join(destination, "agentcore", "cdk"));
if (scaffoldRuntimeInput) {
@@ -203,7 +204,7 @@ export class FsProjectManager implements ProjectManager {
if (!input.skipGit) {
await this.checkTool("git", "Install git: https://git-scm.com/downloads");
- yield { message: "Initializing git repository" };
+ yield { type: "step", message: "Initializing git repository" };
await this.run(["git", "init"], destination);
}
@@ -226,7 +227,7 @@ export class FsProjectManager implements ProjectManager {
const agentCoreSpecPath = this.getProjectSpecPath(project);
const projectSpecKey = toProjectSpecKey(input.resourceType);
- yield { message: `Reading project spec file at '${agentCoreSpecPath}'` };
+ yield { type: "step", message: `Reading project spec file at '${agentCoreSpecPath}'` };
const projectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema);
const existingResources = projectSpec[projectSpecKey] ?? [];
@@ -283,7 +284,7 @@ export class FsProjectManager implements ProjectManager {
switch (input.resourceType) {
case "harness": {
- yield { message: `Scaffolding harness in project` };
+ yield { type: "step", message: `Scaffolding harness in project` };
const outputPath = join(project.rootPath, "app", input.resourceConfig.name);
scaffoldedPaths.push(outputPath);
@@ -294,7 +295,7 @@ export class FsProjectManager implements ProjectManager {
break;
}
case "runtime": {
- yield { message: "Scaffolding runtime in project" };
+ yield { type: "step", message: "Scaffolding runtime in project" };
const outputPath = join(project.rootPath, "app", input.resourceConfig.name);
scaffoldedPaths.push(outputPath);
@@ -311,10 +312,11 @@ export class FsProjectManager implements ProjectManager {
projectSpec.credentials.push(credential);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
- yield { message: `Updating secrets file at '${envFile.path}'` };
+ yield { type: "step", message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
+ type: "step",
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
@@ -338,7 +340,7 @@ export class FsProjectManager implements ProjectManager {
}
case "evaluator": {
if (input.scaffold) {
- yield { message: "Scaffolding evaluator in project" };
+ yield { type: "step", message: "Scaffolding evaluator in project" };
const outputPath = join(project.rootPath, "app", input.scaffold.name);
if (existsSync(outputPath))
throw new InputValidationError(
@@ -419,7 +421,7 @@ export class FsProjectManager implements ProjectManager {
}
}
- yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };
+ yield { type: "step", message: `Updating project spec file at '${agentCoreSpecPath}'` };
let newProjectSpec: z.infer;
try {
@@ -645,7 +647,7 @@ export class FsProjectManager implements ProjectManager {
const agentCoreSpecPath = this.getProjectSpecPath(project);
const { targetAgentName } = input;
- yield { message: `Reading project spec file at '${agentCoreSpecPath}'` };
+ yield { type: "step", message: `Reading project spec file at '${agentCoreSpecPath}'` };
const projectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema);
// Resolve the harness spec + system prompt: from the prefetched service
@@ -671,7 +673,10 @@ export class FsProjectManager implements ProjectManager {
);
}
harnessDir = join(project.rootPath, entry.path);
- yield { message: `Reading harness configuration from '${join(entry.path, "harness.json")}'` };
+ yield {
+ type: "step",
+ message: `Reading harness configuration from '${join(entry.path, "harness.json")}'`,
+ };
spec = await this.json.read(join(harnessDir, "harness.json"), HarnessSpecSchema);
const promptPath = join(harnessDir, "system-prompt.md");
const filePrompt = existsSync(promptPath)
@@ -703,7 +708,10 @@ export class FsProjectManager implements ProjectManager {
);
}
- yield { message: `Mapping harness '${harnessName}' to the Strands runtime template` };
+ yield {
+ type: "step",
+ message: `Mapping harness '${harnessName}' to the Strands runtime template`,
+ };
const plan = mapHarnessToExportPlan({
harnessName,
targetAgentName,
@@ -718,7 +726,7 @@ export class FsProjectManager implements ProjectManager {
});
const isContainer = plan.buildType === "Container";
- yield { message: `Rendering agent code at 'app/${targetAgentName}'` };
+ yield { type: "step", message: `Rendering agent code at 'app/${targetAgentName}'` };
const tree = await FsTreeNode.fromAssetSource(
{ assetSource: this.assetSource },
{ assetDir: "templates/strands-http-python" },
@@ -765,7 +773,7 @@ export class FsProjectManager implements ProjectManager {
await writeFile(join(agentDir, fileName), `${JSON.stringify(policyDoc, null, 2)}\n`);
}
- yield { message: `Writing ${EXPORT_NOTES_FILENAME}` };
+ yield { type: "step", message: `Writing ${EXPORT_NOTES_FILENAME}` };
const notesPath = join(agentDir, EXPORT_NOTES_FILENAME);
await writeFile(
notesPath,
@@ -779,16 +787,17 @@ export class FsProjectManager implements ProjectManager {
if (plan.envEntries.length > 0) {
envFile = new EnvLocalFile(project.rootPath);
- yield { message: `Updating secrets file at '${envFile.path}'` };
+ yield { type: "step", message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(plan.envEntries);
for (const key of skipped) {
yield {
+ type: "step",
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
- yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };
+ yield { type: "step", message: `Updating project spec file at '${agentCoreSpecPath}'` };
projectSpec.runtimes.push(plan.runtime);
for (const credential of plan.credentials) {
if (!projectSpec.credentials.some((candidate) => candidate.name === credential.name)) {
@@ -867,6 +876,7 @@ export class FsProjectManager implements ProjectManager {
if (!target && input.target === DEFAULT_TARGET_NAME) {
target = await this.provisionDefaultTarget(project, targetsPath, input.region);
yield {
+ type: "step",
message:
`Created default deployment target: account ${target.account}, ` +
`region ${target.region} (${join("agentcore", "aws-targets.json")})`,
@@ -898,6 +908,19 @@ export class FsProjectManager implements ProjectManager {
});
}
+ // A read-only lookup, so callers (e.g. the deploy handler's up-front teardown
+ // confirmation) can name the target's account and region without triggering
+ // the default-target provisioning deploy performs.
+ public async resolveTarget(
+ project: Project,
+ input: ResolveTargetInput,
+ ): Promise {
+ const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json");
+ if (!existsSync(targetsPath)) return undefined;
+ const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema);
+ return targets.find((candidate) => candidate.name === input.target);
+ }
+
public async resolveDeployedResource(
project: Project,
input: ResolveDeployedResourceInput,
@@ -1035,11 +1058,11 @@ export class FsProjectManager implements ProjectManager {
"uv",
"Install uv: https://docs.astral.sh/uv/getting-started/installation/",
);
- yield { message: "Syncing Python dependencies with uv" };
+ yield { type: "step", message: "Syncing Python dependencies with uv" };
await this.run(["uv", "sync"], appDir);
} else if (existsSync(join(appDir, "package.json"))) {
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
- yield { message: "Installing Node dependencies with npm" };
+ yield { type: "step", message: "Installing Node dependencies with npm" };
await this.run(["npm", "install"], appDir);
}
}
@@ -1058,11 +1081,12 @@ export class FsProjectManager implements ProjectManager {
if (!existsSync(join(appDir, manifest)) || existsSync(join(appDir, lockfile))) {
continue;
}
- yield { message: `Generating ${lockfile} for container build` };
+ yield { type: "step", message: `Generating ${lockfile} for container build` };
try {
await this.run(command, appDir);
} catch {
yield {
+ type: "step",
message:
`Warning: could not generate ${lockfile} in ${appDir}. ` +
`Run \`${command.join(" ")}\` there before \`agentcore project dev\` or \`deploy\` — ` +
diff --git a/src/handlers/project/add/config-bundle/index.ts b/src/handlers/project/add/config-bundle/index.ts
index 988d12c92..a502154ad 100644
--- a/src/handlers/project/add/config-bundle/index.ts
+++ b/src/handlers/project/add/config-bundle/index.ts
@@ -79,7 +79,7 @@ export const createAddConfigBundleHandler = (config: AddProjectResourceConfig) =
kmsKeyArn: flags["kms-key-arn"],
},
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added configuration bundle '${flags.name}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/credentials/shared.ts b/src/handlers/project/add/credentials/shared.ts
index a36abd506..14f370558 100644
--- a/src/handlers/project/add/credentials/shared.ts
+++ b/src/handlers/project/add/credentials/shared.ts
@@ -54,7 +54,7 @@ export async function addCredentialToProject(
resourceType: "credential",
...input,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added credential '${input.resourceConfig.name}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/evaluator/code-based/index.ts b/src/handlers/project/add/evaluator/code-based/index.ts
index 4732c6c34..ad9db7986 100644
--- a/src/handlers/project/add/evaluator/code-based/index.ts
+++ b/src/handlers/project/add/evaluator/code-based/index.ts
@@ -87,7 +87,7 @@ export const createAddCodeBasedEvaluatorHandler = (config: AddProjectResourceCon
resourceType: "evaluator",
resourceConfig: parsed.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added evaluator '${flags["name"]}' to '${project.name}'\n`);
return;
@@ -107,7 +107,7 @@ export const createAddCodeBasedEvaluatorHandler = (config: AddProjectResourceCon
resourceConfig: { name: scaffold.name },
scaffold,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added evaluator '${flags["name"]}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/evaluator/llm-as-a-judge/index.ts b/src/handlers/project/add/evaluator/llm-as-a-judge/index.ts
index 027cc2680..6da1f2ed7 100644
--- a/src/handlers/project/add/evaluator/llm-as-a-judge/index.ts
+++ b/src/handlers/project/add/evaluator/llm-as-a-judge/index.ts
@@ -97,7 +97,7 @@ export const createAddLlmAsAJudgeEvaluatorHandler = (config: AddProjectResourceC
resourceType: "evaluator",
resourceConfig: parsed.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added evaluator '${flags["name"]}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/gateway-connector/index.ts b/src/handlers/project/add/gateway-connector/index.ts
index d7ffa0225..93f50145b 100644
--- a/src/handlers/project/add/gateway-connector/index.ts
+++ b/src/handlers/project/add/gateway-connector/index.ts
@@ -90,7 +90,7 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi
gatewayName: flags.gateway,
resourceConfig: target,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(
`added Connector Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`,
diff --git a/src/handlers/project/add/gateway-target/index.ts b/src/handlers/project/add/gateway-target/index.ts
index 0b4fbf23f..ef593afe1 100644
--- a/src/handlers/project/add/gateway-target/index.ts
+++ b/src/handlers/project/add/gateway-target/index.ts
@@ -125,7 +125,7 @@ Use project add gateway-connector for curated Connector shortcuts.`,
gatewayName: flags.gateway,
resourceConfig: target,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(
`added Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`,
diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts
index 41febd2c6..1844cad5c 100644
--- a/src/handlers/project/add/gateway/index.ts
+++ b/src/handlers/project/add/gateway/index.ts
@@ -129,7 +129,7 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) =>
resourceType: "gateway",
resourceConfig: gateway,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added Gateway '${flags.name}' to '${project.name}'\n`);
},
diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts
index be40dc5a4..6e0595c4f 100644
--- a/src/handlers/project/add/harness/index.ts
+++ b/src/handlers/project/add/harness/index.ts
@@ -113,7 +113,7 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
resourceType: "harness",
resourceConfig: result.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts
index 3e504c2f8..dac5592c1 100644
--- a/src/handlers/project/add/memory/index.ts
+++ b/src/handlers/project/add/memory/index.ts
@@ -176,7 +176,7 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) =>
resourceType: "memory",
resourceConfig: memoryConfig,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added memory '${flags["name"]}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/online-eval/index.ts b/src/handlers/project/add/online-eval/index.ts
index 6fb31cf3a..85fd3cc29 100644
--- a/src/handlers/project/add/online-eval/index.ts
+++ b/src/handlers/project/add/online-eval/index.ts
@@ -85,7 +85,7 @@ export const createAddOnlineEvalHandler = (config: AddProjectResourceConfig) =>
resourceType: "online-eval",
resourceConfig: parsed.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added online-eval config '${flags["name"]}' to '${project.name}'\n`);
diff --git a/src/handlers/project/add/online-insight/index.ts b/src/handlers/project/add/online-insight/index.ts
index 04ae64637..4e499f524 100644
--- a/src/handlers/project/add/online-insight/index.ts
+++ b/src/handlers/project/add/online-insight/index.ts
@@ -102,7 +102,7 @@ export const createAddOnlineInsightHandler = (config: AddProjectResourceConfig)
resourceType: "online-insight",
resourceConfig: parsed.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(
diff --git a/src/handlers/project/add/payment-connector/index.ts b/src/handlers/project/add/payment-connector/index.ts
index 9c90f7baf..701e4c33b 100644
--- a/src/handlers/project/add/payment-connector/index.ts
+++ b/src/handlers/project/add/payment-connector/index.ts
@@ -65,7 +65,7 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi
credentialName: credentialName!,
},
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(
diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts
index 79c03bc24..33fd4b46c 100644
--- a/src/handlers/project/add/payment-manager/index.ts
+++ b/src/handlers/project/add/payment-manager/index.ts
@@ -92,7 +92,7 @@ export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig)
networkPreferences: flags["network-preferences"],
},
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added payment manager '${flags.name}' to '${project.name}'\n`);
if (flags["auto-payment"]) {
diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts
index e7d3e8eda..496193aaf 100644
--- a/src/handlers/project/add/policy-engine/index.ts
+++ b/src/handlers/project/add/policy-engine/index.ts
@@ -64,7 +64,7 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) =
}
: undefined,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added Policy Engine '${flags.name}' to '${project.name}'\n`);
if (flags["attach-to-gateways"]) {
diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts
index 7d245a0a8..b6af93f5f 100644
--- a/src/handlers/project/add/policy/index.ts
+++ b/src/handlers/project/add/policy/index.ts
@@ -85,7 +85,7 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) =>
engineName: flags.engine,
resourceConfig: policy,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(
`added Policy '${flags.name}' to Policy Engine '${flags.engine}' in '${project.name}'\n`,
diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts
index b3eabfba0..5e796ce98 100644
--- a/src/handlers/project/add/runtime/index.ts
+++ b/src/handlers/project/add/runtime/index.ts
@@ -245,7 +245,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) =>
resourceType: "runtime",
resourceConfig: result.data,
})) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`added runtime '${flags.name}' to '${project.name}'\n`);
diff --git a/src/handlers/project/build/index.test.ts b/src/handlers/project/build/index.test.ts
new file mode 100644
index 000000000..845ef8036
--- /dev/null
+++ b/src/handlers/project/build/index.test.ts
@@ -0,0 +1,118 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createRootHandler } from "../../index";
+import {
+ createSilentLogger,
+ TestCoreClient,
+ TestGlobalConfigAccessor,
+ testIO,
+} from "../../../testing";
+import type { DeployResult, ProjectEvent } from "../types";
+import type { ProjectBackend } from "../../../core/project";
+
+type TestBuildOptions = {
+ events?: ProjectEvent[];
+ /** Thrown by the fake backend after its events, to exercise failure paths. */
+ failure?: Error;
+};
+
+/** Stubs the backend so the real FsProjectManager and withProject stay in the path. */
+function testBuildCommand(options: TestBuildOptions = {}) {
+ const io = testIO();
+ const backend: ProjectBackend = {
+ async *build() {
+ yield* options.events ?? [];
+ if (options.failure) throw options.failure;
+ },
+ deploy(): AsyncGenerator {
+ throw new Error("deploy is not under test");
+ },
+ async resolveDeployedResources() {
+ return [];
+ },
+ };
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ const root = createRootHandler(core, {
+ io: io.io,
+ globalConfigAccessor: new TestGlobalConfigAccessor(),
+ logger: createSilentLogger(),
+ });
+
+ return {
+ io,
+ run: (args: string[] = []) => root.route(["node", "agentcore", "project", "build", ...args]),
+ create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]),
+ };
+}
+
+const originalCwd = process.cwd();
+const tempDirectories: string[] = [];
+
+afterEach(async () => {
+ process.chdir(originalCwd);
+ await Promise.all(
+ tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+/** Scaffolds a project named 'orders' and cds into it. */
+async function inProject(subject: ReturnType): Promise {
+ const directory = await mkdtemp(join(tmpdir(), "agentcore-build-"));
+ tempDirectories.push(directory);
+ process.chdir(directory);
+ // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var
+ // symlink), matching the paths the manager derives from process.cwd().
+ await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]);
+ process.chdir(join(process.cwd(), "orders"));
+}
+
+describe("project build handler", () => {
+ test("writes step lines and the success line to stderr, nothing to stdout", async () => {
+ const subject = testBuildCommand({
+ events: [
+ { type: "step", message: "Synthesizing CloudFormation templates" },
+ { type: "output", line: "synth chatter" },
+ ],
+ });
+ await inProject(subject);
+
+ await subject.run();
+
+ expect(subject.io.stderr()).toContain("Synthesizing CloudFormation templates");
+ expect(subject.io.stderr()).toContain("Built project 'orders'");
+ // Output lines belong to the debug log outside a TTY, not the plain stream.
+ expect(subject.io.stderr()).not.toContain("synth chatter");
+ expect(subject.io.stdout()).toBe("");
+ });
+
+ test("renders the success message as JSON with --json", async () => {
+ const subject = testBuildCommand();
+ await inProject(subject);
+
+ await subject.run(["--json"]);
+
+ expect(JSON.parse(subject.io.stdout())).toEqual({ message: "Built project 'orders'" });
+ });
+
+ test("renders a build failure as JSON without changing the thrown error", async () => {
+ const failure = new Error("cdk synth exploded");
+ const subject = testBuildCommand({ failure });
+ await inProject(subject);
+
+ await expect(subject.run(["--json"])).rejects.toThrow("cdk synth exploded");
+
+ expect(JSON.parse(subject.io.stdout())).toEqual({ error: "cdk synth exploded" });
+ expect(subject.io.stderr()).not.toContain("Built project");
+ });
+
+ test("keeps stdout empty on failure without --json", async () => {
+ const subject = testBuildCommand({ failure: new Error("cdk synth exploded") });
+ await inProject(subject);
+
+ await expect(subject.run()).rejects.toThrow("cdk synth exploded");
+
+ expect(subject.io.stdout()).toBe("");
+ });
+});
diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts
index 8c41c21ec..8f2f0883c 100644
--- a/src/handlers/project/build/index.ts
+++ b/src/handlers/project/build/index.ts
@@ -1,5 +1,9 @@
import { createHandler, ProjectKey } from "../../../router";
import type { AppIO } from "../../../io";
+import { JsonRendererKey } from "../../../tui";
+import { runWithProgress } from "../../../tui/progress";
+import { JsonKey } from "../../keys";
+import { renderJsonError } from "../../utils";
import type { ProjectManager } from "../types";
type BuildProjectHandlerConfig = {
@@ -14,13 +18,25 @@ export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) =>
handle: async (ctx) => {
// withProject has already resolved the enclosing project.
const project = ctx.require(ProjectKey);
+ const jsonOutput = ctx.require(JsonKey);
- // Progress goes to stderr, keeping stdout for machine output. Subprocess
- // output goes to the debug log; on failure ProcessFailedError carries it.
- for await (const event of config.projectManager.build(project)) {
- config.io.stderr.write(`${event.message}\n`);
+ // Progress goes to stderr, keeping stdout for machine output. In a TTY
+ // the driver renders the live step list; otherwise it writes plain lines
+ // and the subprocess output stays in the debug log (ProcessFailedError
+ // carries it on failure). --json forces the plain path so no ANSI
+ // reaches a scripted caller's stderr.
+ try {
+ await runWithProgress(config.projectManager.build(project), {
+ io: config.io,
+ interactive: jsonOutput ? false : undefined,
+ });
+ } catch (error) {
+ if (jsonOutput) renderJsonError(ctx, error);
+ throw error;
}
- config.io.stderr.write(`Built project '${project.name}'\n`);
+ const message = `Built project '${project.name}'`;
+ config.io.stderr.write(`${message}\n`);
+ if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message });
},
});
diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx
index c5f8232f9..3736547d4 100644
--- a/src/handlers/project/create/create.screen.test.tsx
+++ b/src/handlers/project/create/create.screen.test.tsx
@@ -345,7 +345,7 @@ describe("project create wizard", () => {
const core = new TestCoreClient();
core.projectManager.create = () => {
return (async function* () {
- yield { message: "creating project directory" };
+ yield { type: "step" as const, message: "creating project directory" };
throw new Error("disk full");
})();
};
@@ -377,7 +377,7 @@ describe("project create wizard", () => {
core.projectManager.create = (input) => {
created.push(input);
return (async function* () {
- yield { message: "creating project directory" };
+ yield { type: "step" as const, message: "creating project directory" };
throw new Error("disk full");
})();
};
diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts
index 58902cb2a..379589e67 100644
--- a/src/handlers/project/create/index.ts
+++ b/src/handlers/project/create/index.ts
@@ -286,7 +286,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =
}
for await (const event of config.projectManager.create(createInput)) {
- config.io.stderr.write(`${event.message}\n`);
+ if (event.type === "step") config.io.stderr.write(`${event.message}\n`);
}
config.io.stderr.write(`Created project '${name}' in ./${name}\n`);
diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx
index b51b3ec1c..3db835cf2 100644
--- a/src/handlers/project/create/screen.tsx
+++ b/src/handlers/project/create/screen.tsx
@@ -204,7 +204,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) {
setPhase({ kind: "running" });
try {
for await (const event of core.projectManager.create(input)) {
- setEvents((current) => [...current, event.message]);
+ if (event.type === "step") setEvents((current) => [...current, event.message]);
}
setPhase({ kind: "success" });
} catch (error) {
diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts
index f85a18711..69b163cd3 100644
--- a/src/handlers/project/deploy/index.test.ts
+++ b/src/handlers/project/deploy/index.test.ts
@@ -32,6 +32,9 @@ const TEARDOWN: TeardownConfirmationRequest = {
account: DEFAULT_TARGET.account,
region: DEFAULT_TARGET.region,
};
+const TEARDOWN_PROMPT =
+ "Deploying will delete everything deployed to target 'default' (111122223333/us-east-1). " +
+ "Continue? (y/N)";
/**
* A ProjectBackend that deploys successfully, which CdkBackend cannot do until
@@ -43,6 +46,7 @@ function fakeBackend(
result: DeployResult,
events: ProjectEvent[] = [],
teardown?: TeardownConfirmationRequest,
+ failure?: Error,
) {
const calls: { project: Project; input: DeployBackendInput }[] = [];
const confirmations: boolean[] = [];
@@ -58,6 +62,7 @@ function fakeBackend(
}
}
yield* events;
+ if (failure) throw failure;
return result;
},
async resolveDeployedResources() {
@@ -71,6 +76,8 @@ type TestDeployOptions = {
isTTY?: boolean;
stdin?: string;
teardown?: TeardownConfirmationRequest;
+ /** Thrown by the fake backend after its events, to exercise failure paths. */
+ failure?: Error;
resolveAccount?: (region: string) => Promise;
};
@@ -80,7 +87,7 @@ function testDeployCommand(
options: TestDeployOptions = {},
) {
const io = testIO({ isTTY: options.isTTY, stdin: options.stdin });
- const fake = fakeBackend(result, events, options.teardown);
+ const fake = fakeBackend(result, events, options.teardown, options.failure);
const core = new TestCoreClient({
backends: { CDK: fake.backend },
resolveAccount: options.resolveAccount,
@@ -131,11 +138,26 @@ async function inProjectWithTargets(
return projectRoot;
}
+/**
+ * Rewrites the spec so it declares no resources — what remove --all leaves —
+ * which is the up-front signal the deploy handler prompts for a teardown on.
+ */
+async function emptyProjectSpec(projectRoot: string): Promise {
+ await writeFile(
+ join(projectRoot, "agentcore", "agentcore.json"),
+ JSON.stringify({ name: "orders", version: 1 }),
+ );
+}
+
describe("project deploy handler", () => {
test("defaults to the default target and keeps progress off stdout", async () => {
const subject = testDeployCommand(
{ outputs: { ZetaUrl: "https://zeta.example", AlphaArn: "arn:alpha" } },
- [{ message: "Preparing deployment" }, { message: "Deploying stack" }],
+ [
+ { type: "step", message: "Preparing deployment" },
+ { type: "output", line: "CREATE_IN_PROGRESS | AWS::IAM::Role" },
+ { type: "step", message: "Deploying stack" },
+ ],
);
await inProjectWithTargets(subject);
@@ -144,8 +166,11 @@ describe("project deploy handler", () => {
expect(subject.calls).toHaveLength(1);
expect(subject.calls[0]?.input.target).toEqual(DEFAULT_TARGET);
expect(subject.io.stderr()).toContain("Preparing deployment\nDeploying stack");
+ // Output lines belong to the debug log outside a TTY, not the plain stream.
+ expect(subject.io.stderr()).not.toContain("CREATE_IN_PROGRESS");
expect(subject.io.stderr()).toContain("Deployed project 'orders' to target 'default'");
- expect(subject.io.stdout()).toBe("AlphaArn: arn:alpha\nZetaUrl: https://zeta.example");
+ // Stack outputs are rendered only with --json; without it stdout stays empty.
+ expect(subject.io.stdout()).toBe("");
});
test("passes an explicit target and renders the result as JSON", async () => {
@@ -157,7 +182,36 @@ describe("project deploy handler", () => {
expect(subject.calls).toHaveLength(1);
expect(subject.calls[0]?.input.target).toEqual(STAGING_TARGET);
- expect(JSON.parse(subject.io.stdout())).toEqual(result);
+ expect(JSON.parse(subject.io.stdout())).toEqual({
+ message: "Deployed project 'orders' to target 'staging'",
+ ...result,
+ });
+ });
+
+ test("renders a teardown result as JSON with the removal message", async () => {
+ const subject = testDeployCommand({ outputs: {}, tornDown: true });
+ await inProjectWithTargets(subject);
+
+ await subject.run(["--yes", "--json"]);
+
+ expect(JSON.parse(subject.io.stdout())).toEqual({
+ message: "Removed project 'orders' from target 'default'",
+ outputs: {},
+ tornDown: true,
+ });
+ });
+
+ test("renders a deploy failure as JSON without changing the thrown error", async () => {
+ const subject = testDeployCommand({ outputs: {} }, [], {
+ failure: new Error("The stack failed creation: ROLLBACK_COMPLETE"),
+ });
+ await inProjectWithTargets(subject);
+
+ await expect(subject.run(["--json"])).rejects.toThrow("ROLLBACK_COMPLETE");
+
+ expect(JSON.parse(subject.io.stdout())).toEqual({
+ error: "The stack failed creation: ROLLBACK_COMPLETE",
+ });
});
// --yes is the only way to authorize the teardown the backend refuses without
@@ -176,21 +230,23 @@ describe("project deploy handler", () => {
expect(subject.io.stderr()).not.toContain("(y/N)");
});
+ // The prompt is settled before the deploy generator starts (and before any
+ // progress UI could own the terminal), so it fires on the spec declaring
+ // nothing deployable rather than on the backend's post-synth discovery.
test("prompts before tearing down and proceeds on yes", async () => {
const subject = testDeployCommand({ outputs: {}, tornDown: true }, [], {
isTTY: true,
stdin: "yes\n",
teardown: TEARDOWN,
});
- await inProjectWithTargets(subject);
+ const projectRoot = await inProjectWithTargets(subject);
+ await emptyProjectSpec(projectRoot);
await subject.run();
expect(subject.io.stderr()).toContain("Project 'orders' declares no resources to deploy.");
- expect(subject.io.stderr()).toContain(
- "Delete stack 'AgentCore-orders-default-0' and every resource in it from target " +
- "'default' (111122223333/us-east-1)? (y/N)",
- );
+ expect(subject.io.stderr()).toContain(TEARDOWN_PROMPT);
+ expect(subject.confirmations).toEqual([true]);
expect(subject.io.stderr()).toContain("Removed project 'orders' from target 'default'");
});
@@ -203,11 +259,14 @@ describe("project deploy handler", () => {
stdin,
teardown: TEARDOWN,
});
- await inProjectWithTargets(subject);
+ const projectRoot = await inProjectWithTargets(subject);
+ await emptyProjectSpec(projectRoot);
await expect(subject.run()).rejects.toBeInstanceOf(UserCancellationError);
expect(subject.io.stderr()).toContain("(y/N)");
+ // Declined before the generator started: the backend never ran.
+ expect(subject.calls).toEqual([]);
expect(subject.io.stderr()).not.toContain("Removed project");
});
@@ -217,13 +276,32 @@ describe("project deploy handler", () => {
stdin: "",
teardown: TEARDOWN,
});
- await inProjectWithTargets(subject);
+ const projectRoot = await inProjectWithTargets(subject);
+ await emptyProjectSpec(projectRoot);
await expect(subject.run()).rejects.toBeInstanceOf(UserCancellationError);
+ expect(subject.calls).toEqual([]);
expect(subject.io.stderr()).not.toContain("Removed project");
});
+ // The spec-level check can miss (a hand-edited CDK app can synthesize an
+ // empty template from a non-empty spec); the backend's post-synth count is
+ // the backstop, and by then the answer must already be no.
+ test("falls back to requiring --yes when only synthesis reveals the teardown", async () => {
+ const subject = testDeployCommand({ outputs: {}, tornDown: true }, [], {
+ isTTY: true,
+ stdin: "yes\n",
+ teardown: TEARDOWN,
+ });
+ await inProjectWithTargets(subject);
+
+ await expect(subject.run()).rejects.toThrow(/--yes/);
+
+ expect(subject.io.stderr()).not.toContain("(y/N)");
+ expect(subject.confirmations).toEqual([false]);
+ });
+
test("requires --yes instead of prompting in a non-interactive shell", async () => {
const subject = testDeployCommand({ outputs: {}, tornDown: true }, [], {
stdin: "yes\n",
@@ -249,6 +327,10 @@ describe("project deploy handler", () => {
expect(subject.io.stderr()).not.toContain("(y/N)");
expect(subject.confirmations).toEqual([false]);
+ // JSON mode reports the refusal on stdout too, so scripts need not parse stderr.
+ expect(JSON.parse(subject.io.stdout())).toEqual({
+ error: expect.stringContaining("--yes"),
+ });
});
test("does not prompt for a normal deployment", async () => {
@@ -265,7 +347,7 @@ describe("project deploy handler", () => {
test("says the project was removed when the deploy tore the stack down", async () => {
const subject = testDeployCommand({ outputs: {}, tornDown: true }, [
- { message: "Removing stack AgentCore-orders-default" },
+ { type: "step", message: "Removing stack AgentCore-orders-default" },
]);
await inProjectWithTargets(subject);
diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts
index 6c74a2da1..2e63fc08a 100644
--- a/src/handlers/project/deploy/index.ts
+++ b/src/handlers/project/deploy/index.ts
@@ -5,12 +5,10 @@ import type { AppIO } from "../../../io";
import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets";
import { createHandler, flag, ProjectKey } from "../../../router";
import { JsonRendererKey } from "../../../tui";
+import { runWithProgress } from "../../../tui/progress";
import { JsonKey, RegionKey } from "../../keys";
-import type {
- ProjectManager,
- TeardownConfirmationRequest,
- TeardownConfirmationHandler,
-} from "../types";
+import { renderJsonError } from "../../utils";
+import type { DeployResult, Project, ProjectManager, TeardownConfirmationHandler } from "../types";
type DeployProjectHandlerConfig = {
projectManager: ProjectManager;
@@ -45,57 +43,113 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =
config.io.stdout.isTTY &&
config.io.stderr.isTTY;
- // Progress goes to stderr, keeping stdout for machine output. Driven by
- // hand rather than `for await` because the outputs we render below are the
- // generator's return value, which `for await` discards.
+ // The teardown question is settled here, before the generator starts:
+ // once the progress UI is mounted, nothing downstream may block on
+ // interactive input.
+ const confirmTeardown = await resolveTeardownDecision(
+ config,
+ project,
+ flags.target,
+ flags.yes,
+ canPrompt === true,
+ );
+
const deployment = config.projectManager.deploy(project, {
target: flags.target,
region: ctx.require(RegionKey),
- confirmTeardown: createTeardownConfirmationHandler(config.io, flags.yes, canPrompt),
+ confirmTeardown,
});
- let next = await deployment.next();
- while (!next.done) {
- config.io.stderr.write(`${next.value.message}\n`);
- next = await deployment.next();
+ // Progress goes to stderr, keeping stdout for machine output. --json
+ // forces the plain path so no ANSI reaches a scripted caller's stderr.
+ let result: DeployResult;
+ try {
+ result = await runWithProgress(deployment, {
+ io: config.io,
+ interactive: jsonOutput ? false : undefined,
+ });
+ } catch (error) {
+ if (jsonOutput) renderJsonError(ctx, error);
+ throw error;
}
- const result = next.value;
- config.io.stderr.write(
- result.tornDown
- ? `Removed project '${project.name}' from target '${flags.target}'\n`
- : `Deployed project '${project.name}' to target '${flags.target}'\n`,
- );
+ const message = result.tornDown
+ ? `Removed project '${project.name}' from target '${flags.target}'`
+ : `Deployed project '${project.name}' to target '${flags.target}'`;
+ config.io.stderr.write(`${message}\n`);
if (jsonOutput) {
- ctx.require(JsonRendererKey).renderJson(result);
+ ctx.require(JsonRendererKey).renderJson({ message, ...result });
return;
}
- for (const [key, value] of Object.entries(result.outputs).sort(([a], [b]) =>
- a.localeCompare(b),
- )) {
- config.io.stdout.write(`${key}: ${value}\n`);
- }
},
});
-function createTeardownConfirmationHandler(
- io: AppIO,
+/**
+ * True when the spec declares none of the resources `removeAllResources`
+ * clears, i.e. what an emptied project looks like. This is the up-front proxy
+ * for the backend's post-synth zero-resource count; the two can disagree when a
+ * declared resource synthesizes no CloudFormation resource (or a hand-edited
+ * CDK app adds one), so the backend's count stays authoritative and this only
+ * decides whether to ask the user before starting.
+ */
+function declaresNothingDeployable(project: Project): boolean {
+ const { spec } = project;
+ const collections = [
+ spec.runtimes,
+ spec.memories,
+ spec.knowledgeBases,
+ spec.credentials,
+ spec.evaluators,
+ spec.onlineEvalConfigs,
+ spec.agentCoreGateways,
+ spec.policyEngines,
+ spec.configBundles,
+ spec.abTests,
+ spec.harnesses,
+ spec.mcpRuntimeTools ?? [],
+ spec.unassignedTargets ?? [],
+ spec.datasets ?? [],
+ spec.payments ?? [],
+ ];
+ return collections.every((collection) => collection.length === 0);
+}
+
+/**
+ * Resolves the teardown question before the deploy generator starts. The
+ * returned handler never blocks on input: it is a pre-answered decision the
+ * backend consults if synthesis confirms the deploy would remove the stack.
+ */
+async function resolveTeardownDecision(
+ config: DeployProjectHandlerConfig,
+ project: Project,
+ targetName: string,
confirmed: boolean,
canPrompt: boolean,
-): TeardownConfirmationHandler {
+): Promise {
if (confirmed) return async () => true;
- if (!canPrompt) return async () => false;
- return async (request) => {
- if (!(await promptForTeardown(io, request))) {
- throw new UserCancellationError();
+ if (canPrompt && declaresNothingDeployable(project)) {
+ // An undefined target cannot have a stack to tear down: deploy either
+ // rejects the name or provisions a fresh default, and the backend then
+ // fails with "no stack ... to remove" before consulting the decision.
+ const target = await config.projectManager.resolveTarget(project, { target: targetName });
+ if (target) {
+ if (!(await promptForTeardown(config.io, project.name, target))) {
+ throw new UserCancellationError();
+ }
+ return async () => true;
}
- return true;
- };
+ }
+
+ // Non-interactive, --json, or the spec-level check missed (see
+ // declaresNothingDeployable): the backend's own zero-resource check throws
+ // the "re-run with --yes" ProjectStateError when this declines.
+ return async () => false;
}
async function promptForTeardown(
io: AppIO,
- request: TeardownConfirmationRequest,
+ projectName: string,
+ target: { name: string; account: string; region: string },
): Promise {
const readline = createInterface({ input: io.stdin, output: io.stderr });
try {
@@ -104,11 +158,13 @@ async function promptForTeardown(
readline.once("SIGINT", cancel);
readline.once("close", cancel);
});
+ // Asked before synthesis, so the exact stack name is not known yet; the
+ // target coordinates identify what would be deleted.
const answer = await Promise.race([
readline.question(
- `Project '${request.projectName}' declares no resources to deploy.\n` +
- `Delete ${request.resourceDescription} from target ` +
- `'${request.targetName}' (${request.account}/${request.region})? (y/N) `,
+ `Project '${projectName}' declares no resources to deploy.\n` +
+ `Deploying will delete everything deployed to target ` +
+ `'${target.name}' (${target.account}/${target.region}). Continue? (y/N) `,
),
cancelled,
]);
diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts
index 388053239..9a2403b30 100644
--- a/src/handlers/project/export/harness.ts
+++ b/src/handlers/project/export/harness.ts
@@ -75,7 +75,7 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig)
const exportRun = config.projectManager.exportHarness(project, input);
let next = await exportRun.next();
while (!next.done) {
- config.io.stderr.write(`${next.value.message}\n`);
+ if (next.value.type === "step") config.io.stderr.write(`${next.value.message}\n`);
next = await exportRun.next();
}
const result = next.value;
diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts
index 53accf7d5..59f97a578 100644
--- a/src/handlers/project/types.ts
+++ b/src/handlers/project/types.ts
@@ -15,6 +15,7 @@ import { ProtocolModeSchema, RuntimeVersionSchema } from "../../projectSchemas/c
import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway";
import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy";
import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets";
+import type { ProgressEvent } from "../../tui/progress";
type CreateProjectInputBase = {
/** The name of the project; also the directory it is scaffolded into. */
@@ -96,10 +97,14 @@ export type CreateProjectInput = CreateProjectInputBase &
}
);
-/** A progress step reported while a long-running project operation runs. */
-export type ProjectEvent = {
- message: string;
-};
+/**
+ * A progress event reported while a long-running project operation runs. The
+ * same shape as the generic {@link ProgressEvent} the TUI progress driver
+ * consumes: a `step` begins a new unit of work (completing the previous one),
+ * an `output` line belongs to the current step, and the final step completes
+ * when the generator returns (or fails when it throws).
+ */
+export type ProjectEvent = ProgressEvent;
/** The destructive deployment discovered after a project has been synthesized. */
export type TeardownConfirmationRequest = {
@@ -150,6 +155,11 @@ export type ResolveProjectInput = {
filePath: string;
};
+export type ResolveTargetInput = {
+ /** Name of the aws-targets.json entry to look up. */
+ target: string;
+};
+
export type ResolveDeployedResourceInput = {
target: string;
resourceType: ProjectInvokableResource;
@@ -341,6 +351,16 @@ export interface ProjectManager {
/** Deploy the project to one of its configured AWS targets. */
deploy(project: Project, input: DeployProjectInput): AsyncGenerator;
+ /**
+ * Look up a target in aws-targets.json without provisioning or requiring it.
+ * Returns undefined when the file or the named entry is absent — unlike
+ * deploy, which synthesizes the default target on demand.
+ */
+ resolveTarget(
+ project: Project,
+ input: ResolveTargetInput,
+ ): Promise;
+
/** Locate an existing AgentCore project. Returns undefined if no project can be found. */
resolve(input: ResolveProjectInput): Promise;
diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx
index 17fc4cec4..a87ec614e 100644
--- a/src/handlers/utils.tsx
+++ b/src/handlers/utils.tsx
@@ -1,9 +1,10 @@
import type { Context } from "../router";
import type z from "zod";
import type { CoreOptions } from "../core/types";
-import { InputValidationError } from "../errors";
+import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors";
import { formatZodError } from "../router/schema";
import { EndpointKey, RegionKey } from "./keys";
+import { JsonRendererKey } from "../tui";
// coreOptsFromCtx builds the standard CoreOptions handed to Core operations from
// the values pinned on the context: the resolved region (always present, see the
@@ -123,3 +124,14 @@ export function parseTags(values: string[] | undefined): Record
}
return result;
}
+
+// renderJsonError reports a command failure as a JSON document in --json mode,
+// so scripted callers can read the outcome from stdout instead of parsing the
+// human-oriented `Error: ...` line the exit-code handler prints to stderr.
+// Callers rethrow afterwards; exit codes are untouched. Errors marked silent
+// (e.g. a user cancellation) stay silent here too.
+export function renderJsonError(ctx: Context, error: unknown): void {
+ const cliError = AgentCoreCLIError.fromError(error);
+ if (cliError instanceof SilentCLIError) return;
+ ctx.require(JsonRendererKey).renderJson({ error: cliError.message });
+}
diff --git a/src/io/channel.test.ts b/src/io/channel.test.ts
new file mode 100644
index 000000000..ae55d1f76
--- /dev/null
+++ b/src/io/channel.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "bun:test";
+import { AsyncChannel, createLineSplitter } from "./channel";
+
+async function drain(channel: AsyncChannel): Promise {
+ const values: T[] = [];
+ for await (const value of channel) values.push(value);
+ return values;
+}
+
+describe("AsyncChannel", () => {
+ test("delivers values pushed before the consumer starts, then completes", async () => {
+ const channel = new AsyncChannel();
+ channel.push(1);
+ channel.push(2);
+ channel.close();
+
+ expect(await drain(channel)).toEqual([1, 2]);
+ });
+
+ test("wakes a consumer waiting for the next value", async () => {
+ const channel = new AsyncChannel();
+ const draining = drain(channel);
+ channel.push("a");
+ channel.push("b");
+ channel.close();
+
+ expect(await draining).toEqual(["a", "b"]);
+ });
+
+ test("drops values pushed after close instead of wedging the consumer", async () => {
+ const channel = new AsyncChannel();
+ channel.push("kept");
+ channel.close();
+ channel.push("late");
+
+ expect(await drain(channel)).toEqual(["kept"]);
+ });
+});
+
+describe("createLineSplitter", () => {
+ test("reassembles lines split across chunks", () => {
+ const lines: string[] = [];
+ const splitter = createLineSplitter((line) => lines.push(line));
+
+ splitter.push("first li");
+ splitter.push("ne\nsecond line\npart");
+ splitter.push("ial");
+ splitter.flush();
+
+ expect(lines).toEqual(["first line", "second line", "partial"]);
+ });
+
+ test("drops blank lines and trailing whitespace", () => {
+ const lines: string[] = [];
+ const splitter = createLineSplitter((line) => lines.push(line));
+
+ splitter.push("one \n\n indented\r\n");
+ splitter.flush();
+
+ expect(lines).toEqual(["one", " indented"]);
+ });
+
+ test("flush emits nothing when the last chunk ended on a newline", () => {
+ const lines: string[] = [];
+ const splitter = createLineSplitter((line) => lines.push(line));
+
+ splitter.push("done\n");
+ splitter.flush();
+
+ expect(lines).toEqual(["done"]);
+ });
+});
diff --git a/src/io/channel.ts b/src/io/channel.ts
new file mode 100644
index 000000000..9da64d162
--- /dev/null
+++ b/src/io/channel.ts
@@ -0,0 +1,76 @@
+// Push→pull bridges for turning callback-style output into async iteration.
+// Producers (process chunk handlers, Toolkit ioHost notifications) push from
+// callbacks; consumers pull through `for await`. The queue+wake shape mirrors
+// streamProcess in exec.ts, promoted here so generators outside exec can reuse
+// it.
+
+/**
+ * An unbounded in-memory channel: `push` delivers a value to a waiting consumer
+ * or queues it, `close` completes the iteration once the queue drains. Values
+ * pushed after `close` are dropped, so a late callback from an already-settled
+ * operation cannot wedge a consumer.
+ */
+export class AsyncChannel implements AsyncIterable {
+ private queue: T[] = [];
+ private closed = false;
+ private waiters: ((result: IteratorResult) => void)[] = [];
+
+ push(value: T): void {
+ if (this.closed) return;
+ const waiter = this.waiters.shift();
+ if (waiter) waiter({ value, done: false });
+ else this.queue.push(value);
+ }
+
+ close(): void {
+ this.closed = true;
+ for (const waiter of this.waiters.splice(0)) {
+ waiter({ value: undefined, done: true });
+ }
+ }
+
+ [Symbol.asyncIterator](): AsyncIterator {
+ return {
+ next: (): Promise> => {
+ if (this.queue.length > 0) {
+ return Promise.resolve({ value: this.queue.shift()!, done: false });
+ }
+ if (this.closed) {
+ return Promise.resolve({ value: undefined, done: true });
+ }
+ return new Promise((resolve) => this.waiters.push(resolve));
+ },
+ };
+ }
+}
+
+export type LineSplitter = {
+ push(chunk: string): void;
+ /** Emits any buffered partial line. Call once, after the source is done. */
+ flush(): void;
+};
+
+/**
+ * Reassembles a stream of arbitrary chunks into complete lines. A chunk can end
+ * mid-line (process stdout arrives in fixed-size buffers), so the tail of each
+ * chunk is held back until its newline arrives. Blank lines are dropped: they
+ * carry nothing a progress tail or an error excerpt could show.
+ */
+export function createLineSplitter(onLine: (line: string) => void): LineSplitter {
+ let pending = "";
+ const emit = (line: string) => {
+ const trimmed = line.trimEnd();
+ if (trimmed) onLine(trimmed);
+ };
+ return {
+ push(chunk: string): void {
+ const lines = (pending + chunk).split("\n");
+ pending = lines.pop() ?? "";
+ for (const line of lines) emit(line);
+ },
+ flush(): void {
+ if (pending) emit(pending);
+ pending = "";
+ },
+ };
+}
diff --git a/src/io/index.ts b/src/io/index.ts
index f30dd7206..843c4da82 100644
--- a/src/io/index.ts
+++ b/src/io/index.ts
@@ -4,6 +4,7 @@ export {
type AtomicWriteStreamOptions,
type AtomicWriteStreamSource,
} from "./atomicWrite";
+export { AsyncChannel, createLineSplitter, type LineSplitter } from "./channel";
export {
MissingToolError,
ProcessFailedError,
diff --git a/src/tui/progress.test.tsx b/src/tui/progress.test.tsx
new file mode 100644
index 000000000..0365b33a0
--- /dev/null
+++ b/src/tui/progress.test.tsx
@@ -0,0 +1,137 @@
+import { describe, expect, test } from "bun:test";
+import { testIO } from "../testing";
+import { runWithProgress, type ProgressEvent } from "./progress";
+
+// Ink writes cursor/erase sequences around each frame; the assertions here
+// care about frame text, not terminal control. Built without a control-char
+// literal so lint stays quiet.
+const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[A-Za-z]`, "g");
+
+function stripAnsi(text: string): string {
+ return text.replace(ANSI_SEQUENCE, "");
+}
+
+async function* scripted(
+ events: ProgressEvent[],
+ outcome: { result: T } | { failure: Error },
+): AsyncGenerator {
+ yield* events;
+ if ("failure" in outcome) throw outcome.failure;
+ return outcome.result;
+}
+
+describe("runWithProgress plain path (no TTY)", () => {
+ test("writes step lines, drops output lines, and resolves the return value", async () => {
+ const io = testIO();
+
+ const result = await runWithProgress(
+ scripted(
+ [
+ { type: "step", message: "Step one" },
+ { type: "output", line: "noisy detail" },
+ { type: "step", message: "Step two" },
+ ],
+ { result: 7 },
+ ),
+ { io: io.io },
+ );
+
+ expect(result).toBe(7);
+ expect(io.stderr()).toBe("Step one\nStep two");
+ expect(io.stdout()).toBe("");
+ });
+
+ test("rethrows a failure after writing the steps that ran", async () => {
+ const io = testIO();
+ const failure = new Error("synth exploded");
+
+ await expect(
+ runWithProgress(scripted([{ type: "step", message: "Synthesizing" }], { failure }), {
+ io: io.io,
+ }),
+ ).rejects.toBe(failure);
+ expect(io.stderr()).toBe("Synthesizing");
+ });
+
+ test("interactive: false forces the plain path even on a TTY", async () => {
+ const io = testIO({ isTTY: true });
+
+ await runWithProgress(scripted([{ type: "step", message: "Step one" }], { result: null }), {
+ io: io.io,
+ interactive: false,
+ });
+
+ expect(io.stderr()).toBe("Step one");
+ });
+});
+
+describe("runWithProgress interactive path", () => {
+ test("renders every step completed and resolves the return value", async () => {
+ const io = testIO({ isTTY: true });
+
+ const result = await runWithProgress(
+ scripted(
+ [
+ { type: "step", message: "Verifying account" },
+ { type: "output", line: "identity checked" },
+ { type: "step", message: "Deploying stack" },
+ ],
+ { result: "outputs" },
+ ),
+ { io: io.io },
+ );
+
+ const frames = stripAnsi(io.stderr());
+ expect(result).toBe("outputs");
+ expect(frames).toContain("✓ Verifying account");
+ expect(frames).toContain("✓ Deploying stack");
+ // Progress renders on stderr only; stdout stays machine-readable.
+ expect(io.stdout()).toBe("");
+ });
+
+ test("marks the failing step ✕, keeps its recent tail, and rethrows", async () => {
+ const io = testIO({ isTTY: true });
+ const failure = new Error("Access Denied");
+
+ await expect(
+ runWithProgress(
+ scripted(
+ [
+ { type: "step", message: "Deploying stack" },
+ { type: "output", line: "dropped early line" },
+ { type: "output", line: "CREATE_FAILED | RuntimeRole" },
+ { type: "output", line: "ROLLBACK_IN_PROGRESS" },
+ ],
+ { failure },
+ ),
+ { io: io.io, tailLines: 2 },
+ ),
+ ).rejects.toBe(failure);
+
+ const frames = stripAnsi(io.stderr());
+ expect(frames).toContain("✕ Deploying stack");
+ expect(frames).toContain("│ CREATE_FAILED | RuntimeRole");
+ expect(frames).toContain("│ ROLLBACK_IN_PROGRESS");
+ // The final frame honors tailLines; the oldest line has scrolled away.
+ const finalFrame = frames.slice(frames.lastIndexOf("✕ Deploying stack"));
+ expect(finalFrame).not.toContain("dropped early line");
+ });
+
+ test("tolerates output lines that arrive before the first step", async () => {
+ const io = testIO({ isTTY: true });
+
+ const result = await runWithProgress(
+ scripted(
+ [
+ { type: "output", line: "orphan line" },
+ { type: "step", message: "Only step" },
+ ],
+ { result: 1 },
+ ),
+ { io: io.io },
+ );
+
+ expect(result).toBe(1);
+ expect(stripAnsi(io.stderr())).toContain("✓ Only step");
+ });
+});
diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx
new file mode 100644
index 000000000..af1c327b1
--- /dev/null
+++ b/src/tui/progress.tsx
@@ -0,0 +1,114 @@
+import { render } from "ink";
+import { TaskList, type Task } from "../components/ui/task-list";
+import type { AppIO } from "../io";
+
+/**
+ * The event vocabulary long-running operations report progress through. A
+ * `step` starts a new unit of work and implicitly completes the one before it;
+ * an `output` line belongs to the most recent step. The final step completes
+ * when the generator returns, and fails when it throws.
+ */
+export type ProgressEvent = { type: "step"; message: string } | { type: "output"; line: string };
+
+export type RunWithProgressOptions = {
+ io: AppIO;
+ /** Lines of live output kept under the running step (default 5). */
+ tailLines?: number;
+ /**
+ * Overrides TTY detection: pass false to force the plain line-per-step path
+ * (e.g. in --json mode, where stderr may be a TTY but the caller wants no
+ * ANSI). Defaults to whether io.stderr is a TTY.
+ */
+ interactive?: boolean;
+};
+
+const DEFAULT_TAIL_LINES = 5;
+
+/**
+ * Drains a progress generator into a live step list and resolves with the
+ * generator's return value.
+ *
+ * Interactive path (stderr is a TTY): mounts an inline Ink TaskList on
+ * io.stderr — normal scrollback, not the alternate screen — with a spinner on
+ * the running step and a scrolling tail of its recent output. stdout is never
+ * touched, so machine output stays clean. On failure the current step is
+ * marked ✕ with its tail left visible in scrollback, and the error is rethrown
+ * unchanged for the caller's exit-code handling to print in full.
+ *
+ * Fallback path (non-TTY or interactive: false): writes each step message as a
+ * plain line to stderr and drops output lines (they are in the debug log),
+ * matching the pre-TUI behavior byte for byte.
+ *
+ * Generic over the operation: nothing here knows about deploys. Any command
+ * whose work is an AsyncGenerator can run under it.
+ */
+export async function runWithProgress(
+ generator: AsyncGenerator,
+ options: RunWithProgressOptions,
+): Promise {
+ const interactive = options.interactive ?? options.io.stderr.isTTY === true;
+ if (!interactive) {
+ let next = await generator.next();
+ while (!next.done) {
+ if (next.value.type === "step") options.io.stderr.write(`${next.value.message}\n`);
+ next = await generator.next();
+ }
+ return next.value;
+ }
+
+ const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES;
+ const tasks: Task[] = [];
+ // Ink renders onto its `stdout` option; handing it io.stderr keeps progress
+ // off the machine-readable stream, same as the plain path.
+ const instance = render(, {
+ stdout: options.io.stderr,
+ stderr: options.io.stderr,
+ stdin: options.io.stdin,
+ // Nothing here reads input, so stdin stays out of raw mode and Ctrl+C
+ // reaches the process as a normal SIGINT; Ink's exit hook restores the
+ // cursor on the way down.
+ exitOnCtrlC: false,
+ patchConsole: false,
+ });
+ const draw = () => instance.rerender();
+ const current = () => tasks[tasks.length - 1];
+
+ try {
+ let next = await generator.next();
+ while (!next.done) {
+ const event = next.value;
+ if (event.type === "step") {
+ const previous = current();
+ if (previous) {
+ previous.state = "done";
+ previous.tail = [];
+ }
+ tasks.push({ title: event.message, state: "running", tail: [] });
+ } else {
+ // An output line before the first step has nowhere to render; the
+ // debug log still has it.
+ const task = current();
+ if (task) task.tail = [...task.tail, event.line].slice(-tailLines);
+ }
+ draw();
+ next = await generator.next();
+ }
+ const last = current();
+ if (last) {
+ last.state = "done";
+ last.tail = [];
+ }
+ draw();
+ return next.value;
+ } catch (error) {
+ // The failed step keeps its tail: the last frame stays in scrollback above
+ // the error message runWithExitCode prints after the rethrow.
+ const task = current();
+ if (task) task.state = "failed";
+ draw();
+ throw error;
+ } finally {
+ instance.unmount();
+ await instance.waitUntilExit();
+ }
+}