Skip to content
Merged
5 changes: 4 additions & 1 deletion src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]);
Expand Down
59 changes: 51 additions & 8 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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",
Expand All @@ -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<void> {
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<ProjectEvent, void, unknown> {
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOS here since I think its existing behavior, but this is dumping raw output (with ANSI codes) into the logs which are not human readable.

Ex.

{
  "level": "debug",
  "msg": "\u001b[32m+\u001b[39m \u001b[1ms3transfer\u001b[0m\u001b[2m==0.19.2\u001b[0m\n \u001b[32m+\u001b[39m \u001b[1msix\u001b[0m\u001b[2m==1.17.0\u001b[0m\n \u001b[32m+",
  "time": 1788392133886,
  "cliSessionId": "cebc9a0c-9985-400f-b5e7-28c9221ce9cd",
  "version": "1.0.0",
  "module": "projectManager"
}
{
  "level": "debug",
  "msg": "\u001b[39m \u001b[1msse-starlette\u001b[0m\u001b[2m==3.4.8\u001b[0m",
  "time": 1788392133887,
  "cliSessionId": "cebc9a0c-9985-400f-b5e7-28c9221ce9cd",
  "version": "1.0.0",
  "module": "projectManager"
}
{
  "level": "debug",
  "msg": "\n ",
  "time": 1788392133887,
  "cliSessionId": "cebc9a0c-9985-400f-b5e7-28c9221ce9cd",
  "version": "1.0.0",
  "module": "projectManager"
}
{
  "level": "debug",
  "msg": "\u001b[32m",
  "time": 1788392133887,
  "cliSessionId": "cebc9a0c-9985-400f-b5e7-28c9221ce9cd",
  "version": "1.0.0",
  "module": "projectManager"
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhhh this is a good call out. I can fix this in a follow up. I don't think that this was pre existing.

lines.push(chunk);
},
}).finally(() => lines.flush());
});
}
}

Expand Down
12 changes: 8 additions & 4 deletions src/handlers/project/create/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i don't feel like this comment is relevant.

// 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`);
Expand Down
10 changes: 8 additions & 2 deletions src/handlers/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]);
});
Expand Down Expand Up @@ -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 },
]);
Expand Down
Loading