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(