fix(plugins): use spawnProcess for plugin build commands on Windows (#4776)

runPluginBuild called node:child_process spawn with shell: false, so bare
commands like `npm` failed on Windows with `spawn npm ENOENT` because npm
is a .cmd script. Route through the shared spawnProcess helper, which
already selects shell: true for bare commands and .cmd/.bat scripts on
Windows and quotes arguments for cmd.exe.

Side effects inherited from spawnProcess, on every platform:
- Child env is sanitized via createExternalCommandProcessEnv, stripping
  ELECTRON_RUN_AS_NODE, ESBUILD_BINARY_PATH and PASEO_* control keys that
  previously leaked from the daemon into plugin builds.
- windowsHide: true, so builds no longer flash a console window on Windows.

Adds behavioral tests for preparation.ts. Windows cases use it.runIf and
run only on Windows hosts; CI is ubuntu-only.

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
This commit is contained in:
Aditya Borakati
2026-09-15 22:51:23 +02:00
committed by GitHub
co-authored by ABorakati
parent 5ae0c5b83b
commit f9fb992dcb
2 changed files with 132 additions and 4 deletions
@@ -0,0 +1,128 @@
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { runPluginBuild } from "./preparation.js";
describe("runPluginBuild", () => {
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
it("executes build commands in the target directory", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
await runPluginBuild(
directory,
[
[process.execPath, "-e", 'require("node:fs").writeFileSync("step1.txt", process.cwd());'],
[
process.execPath,
"-e",
'require("node:fs").writeFileSync("step2.txt", process.argv[1]);',
"step2-arg",
],
],
logger,
);
const step1 = await readFile(path.join(directory, "step1.txt"), "utf8");
const step2 = await readFile(path.join(directory, "step2.txt"), "utf8");
expect(await realpath(step1)).toBe(await realpath(directory));
expect(step2).toBe("step2-arg");
});
it.runIf(process.platform === "win32")("executes Windows command scripts (.cmd)", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
const scriptPath = path.join(directory, "build.cmd");
await writeFile(scriptPath, "@echo off\r\necho %1> result.txt\r\n");
await runPluginBuild(directory, [[scriptPath, "windows-build-ok"]], logger);
const result = await readFile(path.join(directory, "result.txt"), "utf8");
expect(result.trim()).toBe("windows-build-ok");
});
it.runIf(process.platform === "win32")(
"executes bare Windows command scripts resolved through PATH",
async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
await runPluginBuild(directory, [["npm", "init", "-y"]], logger);
const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(manifest.name).toBe(path.basename(directory).toLowerCase());
},
);
it("passes arguments unchanged to a native executable", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
await runPluginBuild(
directory,
[
[
process.execPath,
"-e",
'require("node:fs").writeFileSync("args.txt", process.argv[1]);',
"a & b (c) !",
],
],
logger,
);
const args = await readFile(path.join(directory, "args.txt"), "utf8");
expect(args).toBe("a & b (c) !");
});
it.runIf(process.platform !== "win32")("executes POSIX shell scripts", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
const scriptPath = path.join(directory, "build.sh");
await writeFile(scriptPath, '#!/bin/sh\necho "$1" > result.txt\n');
await chmod(scriptPath, 0o755);
await runPluginBuild(directory, [[scriptPath, "posix-build-ok"]], logger);
const result = await readFile(path.join(directory, "result.txt"), "utf8");
expect(result.trim()).toBe("posix-build-ok");
});
it("throws when a build command exits with a non-zero code", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
await expect(
runPluginBuild(
directory,
[[process.execPath, "-e", "console.error('build failed'); process.exit(2);"]],
logger,
),
).rejects.toThrow(/Plugin build command failed \(exit 2\)/);
});
it("handles empty or undefined build commands gracefully", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const logger = createTestLogger();
await expect(runPluginBuild(directory, undefined, logger)).resolves.toBeUndefined();
await expect(runPluginBuild(directory, [], logger)).resolves.toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawnProcess } from "../../utils/spawn.js";
import type pino from "pino";
import type { PluginManifest } from "./manifest.js";
@@ -33,14 +33,14 @@ function run(command: string[], directory: string): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawn(executable, arguments_, { cwd: directory, shell: false });
const child = spawnProcess(executable, arguments_, { cwd: directory });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout.on("data", (chunk: Buffer | string) => {
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr.on("data", (chunk: Buffer | string) => {
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {