fix(eve): init - avoid package-manager policy prompts (#3492)

Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
Andrew Barba
2026-09-17 21:15:53 -04:00
committed by GitHub
parent ed88ff4fb0
commit 190a49a0b3
9 changed files with 259 additions and 22 deletions
+2
View File
@@ -401,6 +401,8 @@ async function runInitSteps(input: {
project.packageManager,
project.projectPath,
{
autoApprove: true,
bypassMinimumReleaseAge: true,
progressDetails: process.stdout.isTTY === true && !debug,
onOutput: (line) => {
if (line.text.trim() !== "") {
+4 -1
View File
@@ -6,7 +6,10 @@ export const bunPackageManager = {
scaffoldFiles: {},
applyProjectConfiguration: applyNoProjectConfiguration,
devArguments: () => ["x", "eve", "dev"],
installArguments: () => ["install"],
installArguments: (options) => [
"install",
...(options.bypassMinimumReleaseAge === true ? ["--minimum-release-age=0"] : []),
],
prepareArguments: (_projectRoot, args) => args,
resolveInvocation: (args) => resolveStandardInvocation("bun", args),
} satisfies PackageManagerStrategy;
@@ -8,6 +8,8 @@ export const npmPackageManager = {
devArguments: () => ["exec", "--", "eve", "dev"],
installArguments: (options) => [
"install",
...(options.autoApprove === true ? ["--yes"] : []),
...(options.bypassMinimumReleaseAge === true ? ["--min-release-age=0"] : []),
...(options.progressDetails === true ? ["--loglevel=silly"] : []),
],
prepareArguments: (_projectRoot, args) => args,
@@ -242,6 +242,8 @@ export const pnpmPackageManager = {
installArguments: (options) => [
"install",
"--no-frozen-lockfile",
...(options.autoApprove === true ? ["--yes"] : []),
...(options.bypassMinimumReleaseAge === true ? ["--config.minimum-release-age=0"] : []),
...(options.ignoreWorkspace === true ? ["--ignore-workspace"] : []),
],
prepareArguments: (projectRoot, args) => ["--dir", projectRoot, ...args],
+49 -8
View File
@@ -2,7 +2,11 @@ import { spawn } from "node:child_process";
import type { PackageManagerKind } from "../../package-manager.js";
import { armProcessAbort } from "../process-abort.js";
import { createProcessOutputBuffer, type ProcessOutputHandler } from "../process-output.js";
import {
createProcessOutputBuffer,
type ProcessOutputHandler,
type ProcessOutputLine,
} from "../process-output.js";
import { getPackageManagerStrategy } from "./index.js";
import {
createPackageProcessStdoutCollector,
@@ -126,6 +130,21 @@ export type PackageManagerInstallResult =
| { kind: "workspace-probe-failed"; result: PackageManagerProcessResult }
| { kind: "workspace-probe-unrecognized"; result: PackageManagerProcessResult };
const PNPM_AUTO_APPROVE_REJECTION = /Unknown option:\s*['"]yes['"]/iu;
const PNPM_INSTALL_HELP = /^\s*For help, run:\s*pnpm help install\s*$/iu;
function forwardProcessOutput(
line: ProcessOutputLine,
onOutput: ProcessOutputHandler | undefined,
): void {
if (onOutput !== undefined) {
onOutput(line);
return;
}
const stream = line.stream === "stdout" ? process.stdout : process.stderr;
stream.write(`${line.text}\n`);
}
export function packageManagerInstallSucceeded(result: PackageManagerInstallResult): boolean {
return result.kind === "installed" && resultSucceeded(result.result);
}
@@ -169,15 +188,37 @@ export async function runPackageManagerInstall(
if (claimed === undefined) return { kind: "workspace-probe-unrecognized", result: probe };
if (!claimed) installOptions = { ...options, ignoreWorkspace: true };
}
return {
kind: "installed",
result: await spawnPackageManager(
let autoApproveRejected = false;
const canRetryWithoutAutoApprove = kind === "pnpm" && installOptions.autoApprove === true;
const firstAttemptOptions = canRetryWithoutAutoApprove
? {
...options,
onOutput: (line: ProcessOutputLine) => {
if (PNPM_AUTO_APPROVE_REJECTION.test(line.text)) {
autoApproveRejected = true;
return;
}
if (autoApproveRejected && PNPM_INSTALL_HELP.test(line.text)) return;
forwardProcessOutput(line, options.onOutput);
},
}
: options;
let result = await spawnPackageManager(
kind,
projectRoot,
strategy.installArguments(installOptions),
firstAttemptOptions,
);
// pnpm 11 accepts --yes; pnpm 9 and 10 reject it before starting the install.
if (!resultSucceeded(result) && autoApproveRejected) {
result = await spawnPackageManager(
kind,
projectRoot,
strategy.installArguments(installOptions),
strategy.installArguments({ ...installOptions, autoApprove: false }),
options,
),
};
);
}
return { kind: "installed", result };
}
/** The argv that runs the locally installed eve binary's `dev` command. */
@@ -195,7 +236,7 @@ export function spawnPnpm(
export function runPnpmInstall(
projectRoot: string,
options: RunPackageManagerOptions = {},
options: RunInstallOptions = {},
): Promise<PackageManagerInstallResult> {
return runPackageManagerInstall("pnpm", projectRoot, options);
}
@@ -24,6 +24,10 @@ export interface PackageManagerConfigurationOptions {
}
export interface PackageManagerInstallOptions {
/** Automatically accepts package-manager prompts during setup-owned installs. */
readonly autoApprove?: boolean;
/** Disables inherited minimum package release-age policies for this install. */
readonly bypassMinimumReleaseAge?: boolean;
/** Resolves the project standalone even when an ancestor workspace exists. */
readonly ignoreWorkspace?: boolean;
/** Requests verbose package-manager output for a live progress display. */
@@ -76,6 +76,96 @@ describe("runPnpmInstall", () => {
);
});
test("supports prompt-free installs with a scoped release-age override", async () => {
expect(
packageManagerInstallSucceeded(
await runPnpmInstall("/tmp/eve-agent", {
autoApprove: true,
bypassMinimumReleaseAge: true,
}),
),
).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"pnpm",
[
"--dir",
"/tmp/eve-agent",
"install",
"--no-frozen-lockfile",
"--yes",
"--config.minimum-release-age=0",
],
expect.objectContaining({ cwd: "/tmp/eve-agent", stdio: ["inherit", "pipe", "pipe"] }),
);
});
test("retries without auto-approval when pnpm rejects the option", async () => {
mockedSpawn.mockImplementationOnce(() => {
const child = createMockChildProcess();
queueMicrotask(() => {
child.stderr.emit("data", Buffer.from("ERROR Unknown option: 'yes'\n"));
child.stderr.emit("data", Buffer.from("For help, run: pnpm help install\n"));
child.emit("close", 1);
});
return child;
});
const onOutput = vi.fn();
expect(
packageManagerInstallSucceeded(
await runPnpmInstall("/tmp/eve-agent", {
autoApprove: true,
bypassMinimumReleaseAge: true,
onOutput,
}),
),
).toBe(true);
expect(mockedSpawn.mock.calls.map(([, args]) => args)).toEqual([
[
"--dir",
"/tmp/eve-agent",
"install",
"--no-frozen-lockfile",
"--yes",
"--config.minimum-release-age=0",
],
[
"--dir",
"/tmp/eve-agent",
"install",
"--no-frozen-lockfile",
"--config.minimum-release-age=0",
],
]);
expect(onOutput).not.toHaveBeenCalled();
});
test("does not retry other pnpm install failures", async () => {
mockedSpawn.mockImplementationOnce(() => {
const child = createMockChildProcess();
queueMicrotask(() => {
child.stderr.emit("data", Buffer.from("ERR_PNPM_FETCH_500 Registry unavailable\n"));
child.emit("close", 1);
});
return child;
});
const onOutput = vi.fn();
expect(
packageManagerInstallSucceeded(
await runPnpmInstall("/tmp/eve-agent", { autoApprove: true, onOutput }),
),
).toBe(false);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(onOutput).toHaveBeenCalledWith({
stream: "stderr",
text: "ERR_PNPM_FETCH_500 Registry unavailable",
});
});
test("installs a claimed workspace member with native workspace semantics", async () => {
mockedExistsSync.mockImplementation((path) => path === "/tmp/pnpm-workspace.yaml");
mockMembershipProbe(["/tmp", "/tmp/eve-agent"]);
@@ -146,6 +236,40 @@ describe("runPnpmInstall", () => {
});
describe("runPackageManagerInstall", () => {
test("automatically approves npm prompts and bypasses inherited release-age policies", async () => {
expect(
packageManagerInstallSucceeded(
await runPackageManagerInstall("npm", "/tmp/app", {
autoApprove: true,
bypassMinimumReleaseAge: true,
}),
),
).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"npm",
["install", "--yes", "--min-release-age=0"],
expect.objectContaining({ cwd: "/tmp/app" }),
);
});
test("bypasses inherited Bun release-age policies without passing an unsupported yes flag", async () => {
expect(
packageManagerInstallSucceeded(
await runPackageManagerInstall("bun", "/tmp/app", {
autoApprove: true,
bypassMinimumReleaseAge: true,
}),
),
).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"bun",
["install", "--minimum-release-age=0"],
expect.objectContaining({ cwd: "/tmp/app" }),
);
});
test("requests npm output before registry operations complete", async () => {
expect(
packageManagerInstallSucceeded(
@@ -16,6 +16,15 @@ import { useTemporaryDirectories } from "../../src/internal/testing/use-temporar
const EVE_BIN_PATH = fileURLToPath(new URL("../../bin/eve.js", import.meta.url));
const runFile = promisify(execFile);
const RELEASE_AGE_MINUTES = "2880";
const PNPM_INIT_INSTALL_ARGUMENTS = [
"install",
"--no-frozen-lockfile",
"--yes",
"--config.minimum-release-age=0",
] as const;
const PNPM_FALLBACK_INSTALL_ARGUMENTS = PNPM_INIT_INSTALL_ARGUMENTS.filter(
(argument) => argument !== "--yes",
);
const createScratchDirectory = useTemporaryDirectories();
@@ -64,7 +73,10 @@ function withoutCodingAgentMarkers(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return scrubbed;
}
async function createFakePnpmEnvironment(scratch: string): Promise<{
async function createFakePnpmEnvironment(
scratch: string,
options: { rejectAutoApprove?: boolean } = {},
): Promise<{
env: NodeJS.ProcessEnv;
readCalls(): Promise<PackageManagerCall[]>;
}> {
@@ -80,6 +92,15 @@ async function createFakePnpmEnvironment(scratch: string): Promise<{
" process.env.EVE_INIT_PNPM_LOG,",
" `${JSON.stringify({ args, cwd: process.cwd() })}\\n`,",
");",
...(options.rejectAutoApprove === true
? [
'if (args.includes("--yes")) {',
" console.error(\"ERROR Unknown option: 'yes'\");",
' console.error("For help, run: pnpm help install");',
" process.exit(1);",
"}",
]
: []),
'if (args.includes("install")) {',
' writeFileSync(join(process.cwd(), "pnpm-lock.yaml"), "lockfileVersion: 9.0\\n");',
"}",
@@ -206,7 +227,8 @@ describe("eve init smoke", () => {
"minimumReleaseAgeStrict: true",
);
// Exercise publication lag even when the checkout's version is already available on npm.
// Exercise the unpublished eve override independently of the initial
// scaffold install's release-age bypass.
const manifestPath = join(projectDir, "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
dependencies: Record<string, string>;
@@ -214,10 +236,17 @@ describe("eve init smoke", () => {
manifest.dependencies.eve = "0.0.0-eve-init-unpublished";
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
await expect(
runFile("pnpm", ["add", "--ignore-scripts", "--lockfile-only", "is-number@7.0.0"], {
cwd: projectDir,
env,
}),
runFile(
"pnpm",
[
"add",
"--ignore-scripts",
"--lockfile-only",
"--config.minimum-release-age=0",
"is-number@7.0.0",
],
{ cwd: projectDir, env },
),
).resolves.toMatchObject({ stderr: expect.any(String) });
const lockfile = (await loadYaml(join(projectDir, "pnpm-lock.yaml"))) as {
overrides?: Record<string, string>;
@@ -256,7 +285,7 @@ describe("eve init smoke", () => {
await expect(pathExists(join(projectDir, "vercel.json"))).resolves.toBe(false);
expect(await fakePnpm.readCalls()).toEqual([
{
args: ["--dir", canonicalProjectDir, "install", "--no-frozen-lockfile"],
args: ["--dir", canonicalProjectDir, ...PNPM_INIT_INSTALL_ARGUMENTS],
cwd: canonicalProjectDir,
},
]);
@@ -276,6 +305,27 @@ describe("eve init smoke", () => {
).resolves.toMatchObject({ stdout: "" });
});
it("retries without auto-approval when pnpm rejects the option", async () => {
const scratch = await createScratchDirectory("eve-init-pnpm-fallback-");
const fakePnpm = await createFakePnpmEnvironment(scratch, { rejectAutoApprove: true });
const result = await runEveBin(scratch, ["init", "fallback-agent"], fakePnpm.env);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).not.toContain("Unknown option");
const projectDir = await realpath(join(scratch, "fallback-agent"));
expect(await fakePnpm.readCalls()).toEqual([
{
args: ["--dir", projectDir, ...PNPM_INIT_INSTALL_ARGUMENTS],
cwd: projectDir,
},
{
args: ["--dir", projectDir, ...PNPM_FALLBACK_INSTALL_ARGUMENTS],
cwd: projectDir,
},
]);
});
it("adds Web Chat without Vercel configuration", async () => {
const scratch = await createScratchDirectory("eve-init-web-");
const fakePnpm = await createFakePnpmEnvironment(scratch);
@@ -294,7 +344,9 @@ describe("eve init smoke", () => {
"export default withEve(nextConfig);",
);
const [installCall, ...remainingCalls] = await fakePnpm.readCalls();
expect(installCall?.args.slice(-2)).toEqual(["install", "--no-frozen-lockfile"]);
expect(installCall?.args.slice(-PNPM_INIT_INSTALL_ARGUMENTS.length)).toEqual(
PNPM_INIT_INSTALL_ARGUMENTS,
);
expect(remainingCalls).toEqual([]);
});
@@ -316,7 +368,7 @@ describe("eve init smoke", () => {
await expect(pathExists(join(projectDir, "package-lock.json"))).resolves.toBe(true);
expect(await fakeNpm.readCalls()).toEqual([
{
args: ["install"],
args: ["install", "--yes", "--min-release-age=0"],
cwd: canonicalProjectDir,
},
]);
@@ -355,7 +407,9 @@ describe("eve init smoke", () => {
"minimumReleaseAgeStrict: true",
);
const calls = await fakePnpm.readCalls();
expect(calls[0]?.args.slice(-2)).toEqual(["install", "--no-frozen-lockfile"]);
expect(calls[0]?.args.slice(-PNPM_INIT_INSTALL_ARGUMENTS.length)).toEqual(
PNPM_INIT_INSTALL_ARGUMENTS,
);
expect(calls).toHaveLength(1);
});
@@ -374,7 +428,7 @@ describe("eve init smoke", () => {
await expect(pathExists(join(scratch, ".git"))).resolves.toBe(true);
expect(await fakePnpm.readCalls()).toEqual([
{
args: ["--dir", canonicalProjectDir, "install", "--no-frozen-lockfile"],
args: ["--dir", canonicalProjectDir, ...PNPM_INIT_INSTALL_ARGUMENTS],
cwd: canonicalProjectDir,
},
]);
@@ -413,7 +467,7 @@ describe("eve init smoke", () => {
await expect(pathExists(join(scratch, ".git"))).resolves.toBe(true);
expect(await fakePnpm.readCalls()).toEqual([
{
args: ["--dir", canonicalProjectDir, "install", "--no-frozen-lockfile"],
args: ["--dir", canonicalProjectDir, ...PNPM_INIT_INSTALL_ARGUMENTS],
cwd: canonicalProjectDir,
},
]);
@@ -441,7 +495,7 @@ describe("eve init smoke", () => {
// later in a controllable background process.
expect(await fakePnpm.readCalls()).toEqual([
{
args: ["--dir", canonicalProjectDir, "install", "--no-frozen-lockfile"],
args: ["--dir", canonicalProjectDir, ...PNPM_INIT_INSTALL_ARGUMENTS],
cwd: canonicalProjectDir,
},
]);