From e86a2183dfc1715325545f57f3ea3e01fd1881a8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 29 Mar 2026 23:10:47 -0400 Subject: [PATCH] Tests: type package contract npm pack helper --- .../package-contract-guardrails.test.ts | 98 +++++++++++++++++-- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/src/plugin-sdk/package-contract-guardrails.test.ts b/src/plugin-sdk/package-contract-guardrails.test.ts index 215a770f521..167e61c7115 100644 --- a/src/plugin-sdk/package-contract-guardrails.test.ts +++ b/src/plugin-sdk/package-contract-guardrails.test.ts @@ -1,11 +1,10 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; -import { resolveNpmRunner } from "../../scripts/stage-bundled-plugin-runtime-deps.mjs"; import { pluginSdkEntrypoints } from "./entrypoints.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -16,6 +15,7 @@ const PUBLIC_CONTRACT_REFERENCE_FILES = [ ] as const; const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g; const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; function collectPluginSdkPackageExports(): string[] { const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as { @@ -95,23 +95,101 @@ function createRootPackageRequire() { return createRequire(pathToFileURL(resolve(REPO_ROOT, "package.json")).href); } +function isNpmExecPath(value: string): boolean { + return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test( + value.split(/[\\/]/).at(-1)?.toLowerCase() ?? "", + ); +} + +function escapeForCmdExe(arg: string): string { + if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { + throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`); + } + if (!arg.includes(" ") && !arg.includes('"')) { + return arg; + } + return `"${arg.replace(/"/g, '""')}"`; +} + +function buildCmdExeCommandLine(command: string, args: string[]): string { + return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" "); +} + +type NpmCommandInvocation = { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + windowsVerbatimArguments?: boolean; +}; + +function resolveNpmCommandInvocation(npmArgs: string[]): NpmCommandInvocation { + const npmExecPath = process.env.npm_execpath; + if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) { + return { command: process.execPath, args: [npmExecPath, ...npmArgs] }; + } + + if (process.platform !== "win32") { + return { command: "npm", args: npmArgs }; + } + + const nodeDir = dirname(process.execPath); + const npmCliCandidates = [ + resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), + resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), + ]; + const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate)); + if (npmCliPath) { + return { command: process.execPath, args: [npmCliPath, ...npmArgs] }; + } + + const npmExePath = resolve(nodeDir, "npm.exe"); + if (existsSync(npmExePath)) { + return { command: npmExePath, args: npmArgs }; + } + + const npmCmdPath = resolve(nodeDir, "npm.cmd"); + if (existsSync(npmCmdPath)) { + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmCmdPath, npmArgs)], + windowsVerbatimArguments: true, + }; + } + + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", buildCmdExeCommandLine("npm.cmd", npmArgs)], + windowsVerbatimArguments: true, + }; +} + function packOpenClawToTempDir(packDir: string): string { - const npmRunner = resolveNpmRunner({ - npmArgs: ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], - }); - const raw = execFileSync(npmRunner.command, npmRunner.args, { + const invocation = resolveNpmCommandInvocation([ + "pack", + "--ignore-scripts", + "--json", + "--pack-destination", + packDir, + ]); + const result = spawnSync(invocation.command, invocation.args, { cwd: REPO_ROOT, encoding: "utf8", env: { ...process.env, - ...npmRunner.env, + ...invocation.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", }, maxBuffer: NPM_PACK_MAX_BUFFER_BYTES, - shell: npmRunner.shell, stdio: ["ignore", "pipe", "pipe"], - windowsVerbatimArguments: npmRunner.windowsVerbatimArguments, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "npm pack failed").trim()); + } + const raw = result.stdout; const parsed = JSON.parse(raw) as Array<{ filename?: string }>; const filename = parsed[0]?.filename?.trim(); if (!filename) {