diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 33e2dda8624..e690f552c72 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { basename } from "node:path"; import { pathToFileURL } from "node:url"; type PackageJson = { @@ -40,7 +41,6 @@ const CORRECTION_TAG_REGEX = /^(?\d{4}\.[1-9]\d?\.[1-9]\d?)-(? const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; const MAX_CALVER_DISTANCE_DAYS = 2; const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"]; -const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm"; function normalizeRepoUrl(value: unknown): string { if (typeof value !== "string") { @@ -288,15 +288,31 @@ function loadPackageJson(): PackageJson { return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson; } -function runNpmCommand(args: string[]): string { - const npmExecPath = process.env.npm_execpath; - if (typeof npmExecPath === "string" && npmExecPath.length > 0) { - return execFileSync(process.execPath, [npmExecPath, ...args], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); +function isNpmExecPath(value: string): boolean { + return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test(basename(value).toLowerCase()); +} + +export function resolveNpmCommandInvocation( + params: { + npmExecPath?: string; + nodeExecPath?: string; + platform?: NodeJS.Platform; + } = {}, +): { command: string; args: string[] } { + const npmExecPath = params.npmExecPath ?? process.env.npm_execpath; + const nodeExecPath = params.nodeExecPath ?? process.execPath; + const npmCommand = (params.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm"; + + if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) { + return { command: nodeExecPath, args: [npmExecPath] }; } - return execFileSync(NPM_COMMAND, args, { + + return { command: npmCommand, args: [] }; +} + +function runNpmCommand(args: string[]): string { + const invocation = resolveNpmCommandInvocation(); + return execFileSync(invocation.command, [...invocation.args, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); @@ -312,16 +328,16 @@ type NpmPackResult = { }; type ExecFailure = Error & { - stderr?: string | Buffer; - stdout?: string | Buffer; + stderr?: string | Uint8Array; + stdout?: string | Uint8Array; }; -function toTrimmedUtf8(value: string | Buffer | undefined): string { +function toTrimmedUtf8(value: string | Uint8Array | undefined): string { if (typeof value === "string") { return value.trim(); } - if (Buffer.isBuffer(value)) { - return value.toString("utf8").trim(); + if (value instanceof Uint8Array) { + return new TextDecoder().decode(value).trim(); } return ""; } @@ -343,6 +359,32 @@ function describeExecFailure(error: unknown): string { return details.join(" | "); } +export function parseNpmPackJsonOutput(stdout: string): NpmPackResult[] | null { + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + + const candidates = [trimmed]; + const trailingArrayStart = trimmed.lastIndexOf("\n["); + if (trailingArrayStart !== -1) { + candidates.push(trimmed.slice(trailingArrayStart + 1).trim()); + } + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) as unknown; + if (Array.isArray(parsed)) { + return parsed as NpmPackResult[]; + } + } catch { + // Try the next candidate. npm lifecycle output can prepend non-JSON logs. + } + } + + return null; +} + function collectPackedTarballErrors(): string[] { const errors: string[] = []; let stdout = ""; @@ -356,15 +398,11 @@ function collectPackedTarballErrors(): string[] { return errors; } - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { + const packResults = parseNpmPackJsonOutput(stdout); + if (!packResults) { errors.push("Failed to parse JSON output from `npm pack --json --dry-run`."); return errors; } - - const packResults = Array.isArray(parsed) ? (parsed as NpmPackResult[]) : []; const firstResult = packResults[0]; if (!firstResult || !Array.isArray(firstResult.files)) { errors.push("`npm pack --json --dry-run` did not return a files list to validate."); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 6ce0d35cfdb..d863ca74259 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { collectReleasePackageMetadataErrors, collectReleaseTagErrors, + parseNpmPackJsonOutput, parseReleaseTagVersion, parseReleaseVersion, + resolveNpmCommandInvocation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; @@ -62,6 +64,72 @@ describe("utcCalendarDayDistance", () => { }); }); +describe("resolveNpmCommandInvocation", () => { + it("uses npm_execpath when it points to npm", () => { + expect( + resolveNpmCommandInvocation({ + npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js", + nodeExecPath: "/usr/local/bin/node", + platform: "linux", + }), + ).toEqual({ + command: "/usr/local/bin/node", + args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"], + }); + }); + + it("falls back to the npm command when npm_execpath points to pnpm", () => { + expect( + resolveNpmCommandInvocation({ + npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs", + nodeExecPath: "/usr/local/bin/node", + platform: "linux", + }), + ).toEqual({ + command: "npm", + args: [], + }); + }); + + it("uses the platform npm command when npm_execpath is missing", () => { + expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({ + command: "npm.cmd", + args: [], + }); + }); +}); + +describe("parseNpmPackJsonOutput", () => { + it("parses a plain npm pack JSON array", () => { + expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([ + { filename: "openclaw.tgz", files: [] }, + ]); + }); + + it("parses the trailing JSON payload after npm lifecycle logs", () => { + const stdout = [ + 'npm warn Unknown project config "node-linker".', + "", + "> openclaw@2026.3.23 prepack", + "> pnpm build && pnpm ui:build", + "", + "[copy-hook-metadata] Copied 4 hook metadata files.", + '[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]', + ].join("\n"); + + expect(parseNpmPackJsonOutput(stdout)).toEqual([ + { + filename: "openclaw.tgz", + files: [{ path: "dist/control-ui/index.html" }], + }, + ]); + }); + + it("returns null when no JSON payload is present", () => { + expect(parseNpmPackJsonOutput("> openclaw@2026.3.23 prepack")).toBeNull(); + }); +}); + describe("collectReleaseTagErrors", () => { it("accepts versions within the two-day CalVer window", () => { expect(