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(); + } +}