mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
fix(eve): init - avoid package-manager policy prompts (#3492)
Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
@@ -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() !== "") {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user