From f9fb992dcb326ed5aca3187b47e8c9db69ce353f Mon Sep 17 00:00:00 2001 From: Aditya Borakati <41518783+ABorakati@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:51:23 +0100 Subject: [PATCH] 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 --- .../src/server/plugins/preparation.test.ts | 128 ++++++++++++++++++ .../server/src/server/plugins/preparation.ts | 8 +- 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/server/plugins/preparation.test.ts diff --git a/packages/server/src/server/plugins/preparation.test.ts b/packages/server/src/server/plugins/preparation.test.ts new file mode 100644 index 0000000000..0fdb0e34fc --- /dev/null +++ b/packages/server/src/server/plugins/preparation.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts index 1e9b4aabef..267f9697e9 100644 --- a/packages/server/src/server/plugins/preparation.ts +++ b/packages/server/src/server/plugins/preparation.ts @@ -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 { 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) => {