diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 47b11e5fa..674b84be7 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -270,7 +270,10 @@ describe("FsProjectManager.create", () => { const projectRoot = join(directory, "example"); expect(commands).toEqual([ - { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, { command: ["uv", "sync"], cwd: join(projectRoot, "app", "agent_python") }, { command: ["git", "init"], cwd: projectRoot }, ]); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c502cbdae..82dbfe265 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -24,11 +24,13 @@ import type { import type { Logger } from "../../logging"; import { FsReadWriteJson, + createLineSplitter, requireTool, runProcess, type ProcessRunner, type ReadWriteJson, } from "../../io"; +import { withOutputEvents } from "./events"; import { defaultSource, type AssetSource } from "./source"; import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal"; import { getHarnessTemplateResolver, validateHarnessTemplateSource } from "./templates/harness"; @@ -83,6 +85,27 @@ import type { CoreIdentityClient } from "../../handlers/identity/types"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; +// npm prints nothing until it exits when stderr is piped, and its HTTP log is the only per-package +// progress it will emit, so the log is asked for and then rewritten into package names. +const NPM_INSTALL = ["npm", "install", "--loglevel=http"]; +const NPM_FETCH = /^npm http fetch [A-Z]+ \d{3} https?:\/\/[^/]+(\S*)/; + +function npmProgressLine(line: string): string | undefined { + const path = NPM_FETCH.exec(line)?.[1]; + // Deprecation warnings and the closing summary are already written for people. + if (path === undefined) return line; + // `/-/npm/v1/...` names no package; the only one an install makes is the audit request. + if (path.startsWith("/-/")) return "auditing dependencies"; + // A private registry may serve manifests under a prefix, so the name is the path's tail. A scope + // reaches us either as its own segment or encoded into one as %2f. + const [manifest = "", tarball] = path.split("/-/"); + const segments = manifest.replace(/%2f/gi, "/").split("/").filter(Boolean); + const scope = segments.at(-2); + const name = scope?.startsWith("@") ? `${scope}/${segments.at(-1)}` : segments.at(-1); + if (name === undefined) return undefined; + return `${tarball === undefined ? "resolving" : "downloading"} ${name}`; +} + type ProjectManagerConfig = { logger: Logger; createCloudFormationClient?: CreateCloudFormationClient; @@ -197,7 +220,7 @@ export class FsProjectManager implements ProjectManager { if (!input.skipInstall) { await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); yield { type: "step", message: "Installing CDK dependencies with npm" }; - await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); + yield* this.run(NPM_INSTALL, join(destination, "agentcore", "cdk"), npmProgressLine); if (scaffoldRuntimeInput) { const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName); @@ -212,7 +235,7 @@ export class FsProjectManager implements ProjectManager { if (!input.skipGit) { await this.checkTool("git", "Install git: https://git-scm.com/downloads"); yield { type: "step", message: "Initializing git repository" }; - await this.run(["git", "init"], destination); + yield* this.run(["git", "init"], destination); } // A created project is a resolvable one, so read it back rather than @@ -1044,11 +1067,11 @@ export class FsProjectManager implements ProjectManager { "Install uv: https://docs.astral.sh/uv/getting-started/installation/", ); yield { type: "step", message: "Syncing Python dependencies with uv" }; - await this.run(["uv", "sync"], appDir); + yield* this.run(["uv", "sync"], appDir); } else if (existsSync(join(appDir, "package.json"))) { await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); yield { type: "step", message: "Installing Node dependencies with npm" }; - await this.run(["npm", "install"], appDir); + yield* this.run(NPM_INSTALL, appDir, npmProgressLine); } } @@ -1068,7 +1091,7 @@ export class FsProjectManager implements ProjectManager { } yield { type: "step", message: `Generating ${lockfile} for container build` }; try { - await this.run(command, appDir); + yield* this.run(command, appDir); } catch { yield { type: "step", @@ -1082,9 +1105,29 @@ export class FsProjectManager implements ProjectManager { } } - // Runs a command with its output streamed to the file logger. - private run(command: string[], cwd: string): Promise { - return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); + /** + * Runs a command, yielding its output as `output` events so a progress driver can show a live tail + * under the running step. The debug log gets each chunk whole; the splitter reassembles them into + * lines for display, and `formatLine` may rewrite or drop a line before it is shown. + */ + private async *run( + command: string[], + cwd: string, + formatLine: (line: string) => string | undefined = (line) => line, + ): AsyncGenerator { + yield* withOutputEvents((emit) => { + const lines = createLineSplitter((line) => { + const formatted = formatLine(line); + if (formatted !== undefined) emit(formatted); + }); + return this.runner(command, { + cwd, + onOutput: (chunk) => { + this.logger.debug(chunk); + lines.push(chunk); + }, + }).finally(() => lines.flush()); + }); } } diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 4277f34a1..e158cbc9a 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,6 +1,7 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; import { SourceResolver, type AppIO } from "../../../io"; +import { runWithProgress } from "../../../tui/progress"; import { LANGUAGE_VERSION_DEFAULTS, MEMORY_SHORTCUT_NAMES, @@ -28,7 +29,7 @@ import { DEFAULT_HARNESS_MODEL } from "../add/harness"; import type { CoreBedrockAgentImporter } from "../../../core/project/bedrockAgentImport"; import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput } from "../importBedrockAgent"; import type { ImportBedrockAgentInput } from "../add/runtime/types"; -import { RegionKey } from "../../keys"; +import { JsonKey, RegionKey } from "../../keys"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; @@ -300,9 +301,12 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = ); } - for await (const event of config.projectManager.create(createInput)) { - if (event.type === "step") config.io.stderr.write(`${event.message}\n`); - } + // Same driver as build and deploy: a live step list in a TTY, and the previous plain + // line-per-step output when stderr is not a TTY or --json wants no ANSI on it. + await runWithProgress(config.projectManager.create(createInput), { + io: config.io, + interactive: ctx.require(JsonKey) ? false : undefined, + }); config.io.stderr.write(`Created project '${name}' in ./${name}\n`); config.io.stderr.write(`To deploy it: cd ${name} && agentcore project deploy\n`); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 200f55ce6..74dbc5f82 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -263,7 +263,10 @@ describe("project create", () => { const projectRoot = join(directory, "MyAgent"); expect(core.projectCommands).toEqual([ - { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, { command: ["git", "init"], cwd: projectRoot }, ]); }); @@ -365,7 +368,10 @@ describe("project create", () => { const projectRoot = join(directory, "MyAgent"); expect(core.projectCommands).toEqual([ - { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, { command: ["uv", "sync"], cwd: join(projectRoot, "app", "agent_python") }, { command: ["git", "init"], cwd: projectRoot }, ]);