feat(runner): install stage with progress events

Install stage: package-manager detection (lockfile beats the
packageManager field, npm default — vite-kitchen's shape), install run
inside the clone with staged status heartbeats, and every failure —
including install-before-clone — mapped to the named install-failed
cause with a bounded output tail. The spawned-runner suite now runs the
full clone → install pipeline on the daemon-served vite-kitchen (real
npm registry install) and greps the child's entire stdout+stderr plus
the clone's .git/config for the PAT and workspace token. Shared test
doubles extracted (exec.mock.ts, tests/runner-session.ts) to keep the
suites duplication-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 22:44:39 +02:00
parent 750ab44379
commit ec7bf948af
10 changed files with 612 additions and 65 deletions

View File

@@ -0,0 +1,18 @@
import type { CommandResult, RunCommand } from "./exec";
/**
* Recording `RunCommand` double for stage unit tests (`.mock.ts` sibling
* per repo convention). Resolves every spawn with `result` merged over a
* clean zero-exit and records each `(command, args, options)` call.
*/
export function mockRunCommand(result: Partial<CommandResult> = {}): {
run: RunCommand;
calls: unknown[][];
} {
const calls: unknown[][] = [];
const run: RunCommand = async (...args) => {
calls.push(args);
return { exitCode: 0, stdout: "", stderr: "", ...result };
};
return { run, calls };
}

View File

@@ -11,6 +11,7 @@ import {
import { runCommand } from "./exec";
import type { Logger } from "./log";
import { cloneRepository } from "./stages/clone";
import { installDependencies } from "./stages/install";
import { runStage, type StageEmitter } from "./stages/stage-runner";
/**
@@ -142,10 +143,9 @@ export type CommandMessage = Extract<
/** Stages that later walking-skeleton stories wire up (05+). */
const NOT_IMPLEMENTED: Record<
Exclude<CommandMessage["type"], "clone">,
Exclude<CommandMessage["type"], "clone" | "install">,
{ cause: RunnerErrorCause; stage?: RunnerStage }
> = {
install: { cause: "install-failed", stage: "installing" },
scan: { cause: "scan-failed", stage: "scanning" },
"adapter-start": { cause: "adapter-start-failed", stage: "starting-preview" },
"render-frame": { cause: "render-failed" },
@@ -184,6 +184,20 @@ async function handleCommand(
if (result.ok) ctx.ready();
return;
}
case "install": {
const result = await runStage(
"installing",
"install-failed",
stageOptions,
installDependencies({
workspaceDir: options.workspaceDir,
runCommand,
log: options.log,
}),
);
if (result.ok) ctx.ready();
return;
}
default: {
const notImplemented = NOT_IMPLEMENTED[command.type];
emit.error(

View File

@@ -13,22 +13,10 @@ import {
import { StageError } from "@/stages/stage-runner";
import { PAT_ENV_VAR } from "@/git/credential-helper";
import { createLogger } from "@/log";
import type { CommandResult, RunCommand } from "@/exec";
import { mockRunCommand } from "@/exec.mock";
const PAT = "ghp_never-in-argv-or-config";
function fakeRun(result: Partial<CommandResult>): {
run: RunCommand;
calls: unknown[][];
} {
const calls: unknown[][] = [];
const run: RunCommand = async (...args) => {
calls.push(args);
return { exitCode: 0, stdout: "", stderr: "", ...result };
};
return { run, calls };
}
describe("validateGitUrl", () => {
it("accepts git, http(s), and file URLs", () => {
expect(validateGitUrl("git://127.0.0.1:9418/vite-kitchen.git")).toBeNull();
@@ -141,7 +129,7 @@ describe("mapCloneFailure", () => {
describe("cloneRepository", () => {
it("rejects an invalid URL before ever spawning git", async () => {
const { run, calls } = fakeRun({});
const { run, calls } = mockRunCommand({});
const clone = cloneRepository({
workspaceDir: "/ws",
runCommand: run,
@@ -160,7 +148,7 @@ describe("cloneRepository", () => {
const workspaceDir = await mkdtemp(
path.join(os.tmpdir(), "veect-clone-unit-"),
);
const { run, calls } = fakeRun({});
const { run, calls } = mockRunCommand({});
const clone = cloneRepository({
workspaceDir,
runCommand: run,
@@ -193,7 +181,7 @@ describe("cloneRepository", () => {
const stale = path.join(repoPath(workspaceDir), "stale.txt");
await mkdir(repoPath(workspaceDir), { recursive: true });
await writeFile(stale, "old");
const { run } = fakeRun({});
const { run } = mockRunCommand({});
const clone = cloneRepository({
workspaceDir,
runCommand: run,
@@ -209,7 +197,7 @@ describe("cloneRepository", () => {
const workspaceDir = await mkdtemp(
path.join(os.tmpdir(), "veect-clone-unit-"),
);
const { run } = fakeRun({
const { run } = mockRunCommand({
exitCode: 128,
stderr: "fatal: Authentication failed for 'https://x.test/a.git/'",
});
@@ -231,7 +219,7 @@ describe("cloneRepository", () => {
path.join(os.tmpdir(), "veect-clone-unit-"),
);
const lines: string[] = [];
const { run } = fakeRun({});
const { run } = mockRunCommand({});
const clone = cloneRepository({
workspaceDir,
runCommand: run,

View File

@@ -0,0 +1,160 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildInstallArgs,
detectPackageManager,
installDependencies,
} from "@/stages/install";
import { repoPath } from "@/stages/clone";
import { createLogger } from "@/log";
import { mockRunCommand } from "@/exec.mock";
async function makeRepo(files: Record<string, string>): Promise<string> {
const workspaceDir = await mkdtemp(
path.join(os.tmpdir(), "veect-install-unit-"),
);
const repoDir = repoPath(workspaceDir);
await mkdir(repoDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
await writeFile(path.join(repoDir, name), content);
}
return workspaceDir;
}
const PKG = JSON.stringify({ name: "fixture", private: true });
describe("detectPackageManager", () => {
it.each([
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
["bun.lockb", "bun"],
["bun.lock", "bun"],
["package-lock.json", "npm"],
["npm-shrinkwrap.json", "npm"],
] as const)("detects %s → %s", async (lockfile, expected) => {
const workspaceDir = await makeRepo({
"package.json": PKG,
[lockfile]: "",
});
await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe(
expected,
);
});
it("falls back to the packageManager field when no lockfile exists", async () => {
const workspaceDir = await makeRepo({
"package.json": JSON.stringify({
name: "x",
packageManager: "pnpm@9.15.4",
}),
});
await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe(
"pnpm",
);
});
it("prefers the lockfile over a contradicting packageManager field", async () => {
const workspaceDir = await makeRepo({
"package.json": JSON.stringify({
name: "x",
packageManager: "pnpm@9.0.0",
}),
"yarn.lock": "",
});
await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe(
"yarn",
);
});
it("defaults to npm with no lockfile and no field (vite-kitchen's shape)", async () => {
const workspaceDir = await makeRepo({ "package.json": PKG });
await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe(
"npm",
);
});
it("ignores an unknown packageManager value", async () => {
const workspaceDir = await makeRepo({
"package.json": JSON.stringify({
name: "x",
packageManager: "deno@2.0.0",
}),
});
await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe(
"npm",
);
});
});
describe("buildInstallArgs", () => {
it("runs npm without audit/fund noise; others with plain install", () => {
expect(buildInstallArgs("npm")).toEqual([
"install",
"--no-audit",
"--no-fund",
]);
expect(buildInstallArgs("pnpm")).toEqual(["install"]);
expect(buildInstallArgs("yarn")).toEqual(["install"]);
expect(buildInstallArgs("bun")).toEqual(["install"]);
});
});
describe("installDependencies", () => {
it("fails with the named cause when nothing has been cloned", async () => {
const workspaceDir = await mkdtemp(
path.join(os.tmpdir(), "veect-install-unit-"),
);
const { run, calls } = mockRunCommand({});
const install = installDependencies({
workspaceDir,
runCommand: run,
log: createLogger(() => undefined),
});
await expect(install()).rejects.toMatchObject({
namedCause: "install-failed",
message: expect.stringContaining("run clone first"),
});
expect(calls).toHaveLength(0);
});
it("runs the detected package manager inside the clone", async () => {
const workspaceDir = await makeRepo({ "package.json": PKG });
const { run, calls } = mockRunCommand({});
const install = installDependencies({
workspaceDir,
runCommand: run,
log: createLogger(() => undefined),
});
await install();
expect(calls).toEqual([
[
"npm",
["install", "--no-audit", "--no-fund"],
{ cwd: repoPath(workspaceDir) },
],
]);
});
it("maps a non-zero exit to the named install-failed cause with an output tail", async () => {
const workspaceDir = await makeRepo({ "package.json": PKG });
const { run } = mockRunCommand({
exitCode: 1,
stderr: "npm error 404 Not Found - GET https://registry.npmjs.org/nope",
});
const install = installDependencies({
workspaceDir,
runCommand: run,
log: createLogger(() => undefined),
});
await expect(install()).rejects.toMatchObject({
namedCause: "install-failed",
message: expect.stringContaining("404"),
});
});
});

View File

@@ -0,0 +1,110 @@
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import type { RunCommand } from "../exec";
import type { Logger } from "../log";
import { repoPath } from "./clone";
import { StageError } from "./stage-runner";
/**
* Package managers the runner can drive (spec §6 "Dependency install").
* Detection order: lockfile (the strongest signal — it pins the exact
* install semantics) → `packageManager` field → npm default.
*/
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
const LOCKFILES: readonly [string, PackageManager][] = [
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
["bun.lockb", "bun"],
["bun.lock", "bun"],
["package-lock.json", "npm"],
["npm-shrinkwrap.json", "npm"],
];
const PACKAGE_MANAGER_NAMES: readonly PackageManager[] = [
"npm",
"pnpm",
"yarn",
"bun",
];
/** Detect the repo's package manager. Pure over the clone's file layout. */
export async function detectPackageManager(
repoDir: string,
): Promise<PackageManager> {
for (const [lockfile, packageManager] of LOCKFILES) {
if (existsSync(path.join(repoDir, lockfile))) return packageManager;
}
const declared = await readPackageManagerField(repoDir);
return declared ?? "npm";
}
async function readPackageManagerField(
repoDir: string,
): Promise<PackageManager | null> {
try {
const raw = await readFile(path.join(repoDir, "package.json"), "utf8");
const parsed = JSON.parse(raw) as { packageManager?: unknown };
if (typeof parsed.packageManager !== "string") return null;
const name = parsed.packageManager.split("@")[0];
const match = PACKAGE_MANAGER_NAMES.find((candidate) => candidate === name);
return match ?? null;
} catch {
return null;
}
}
/** The exact argv the runner runs for each package manager. */
export function buildInstallArgs(packageManager: PackageManager): string[] {
if (packageManager === "npm") return ["install", "--no-audit", "--no-fund"];
return ["install"];
}
/** Bounded, single-line tail of the failed install's output. */
function outputTail(stdout: string, stderr: string): string {
const flat = `${stdout}\n${stderr}`
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(-8)
.join(" | ");
const bounded = flat.length > 400 ? `${flat.slice(-400)}` : flat;
return bounded || "install failed with no output";
}
export interface InstallStageDeps {
workspaceDir: string;
runCommand: RunCommand;
log: Logger;
}
/**
* The install stage: requires a clone with a `package.json`, detects the
* package manager, runs its install in the clone. Every failure throws
* `StageError("install-failed", …)` — the protocol's named cause.
*/
export function installDependencies(deps: InstallStageDeps) {
return async (): Promise<void> => {
const repoDir = repoPath(deps.workspaceDir);
if (!existsSync(path.join(repoDir, "package.json"))) {
throw new StageError(
"install-failed",
"no cloned repository with a package.json — run clone first",
);
}
const packageManager = await detectPackageManager(repoDir);
const args = buildInstallArgs(packageManager);
deps.log.info("install-spawn", { packageManager, args });
const result = await deps.runCommand(packageManager, args, {
cwd: repoDir,
});
if (result.exitCode !== 0) {
throw new StageError(
"install-failed",
`${packageManager} install failed: ${outputTail(result.stdout, result.stderr)}`,
);
}
};
}