Skip to content

Commit 36d6ba5

Browse files
Release: fix npm release preflight under pnpm (#52985)
Co-authored-by: Vincent Koc <[email protected]>
1 parent f9a7427 commit 36d6ba5

2 files changed

Lines changed: 126 additions & 20 deletions

File tree

scripts/openclaw-npm-release-check.ts

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { execFileSync } from "node:child_process";
44
import { readFileSync } from "node:fs";
5+
import { basename } from "node:path";
56
import { pathToFileURL } from "node:url";
67

78
type PackageJson = {
@@ -40,7 +41,6 @@ const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>
4041
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
4142
const MAX_CALVER_DISTANCE_DAYS = 2;
4243
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
43-
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
4444

4545
function normalizeRepoUrl(value: unknown): string {
4646
if (typeof value !== "string") {
@@ -288,15 +288,31 @@ function loadPackageJson(): PackageJson {
288288
return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson;
289289
}
290290

291+
function isNpmExecPath(value: string): boolean {
292+
return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test(basename(value).toLowerCase());
293+
}
294+
295+
export function resolveNpmCommandInvocation(
296+
params: {
297+
npmExecPath?: string;
298+
nodeExecPath?: string;
299+
platform?: NodeJS.Platform;
300+
} = {},
301+
): { command: string; args: string[] } {
302+
const npmExecPath = params.npmExecPath ?? process.env.npm_execpath;
303+
const nodeExecPath = params.nodeExecPath ?? process.execPath;
304+
const npmCommand = (params.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm";
305+
306+
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) {
307+
return { command: nodeExecPath, args: [npmExecPath] };
308+
}
309+
310+
return { command: npmCommand, args: [] };
311+
}
312+
291313
function runNpmCommand(args: string[]): string {
292-
const npmExecPath = process.env.npm_execpath;
293-
if (typeof npmExecPath === "string" && npmExecPath.length > 0) {
294-
return execFileSync(process.execPath, [npmExecPath, ...args], {
295-
encoding: "utf8",
296-
stdio: ["ignore", "pipe", "pipe"],
297-
});
298-
}
299-
return execFileSync(NPM_COMMAND, args, {
314+
const invocation = resolveNpmCommandInvocation();
315+
return execFileSync(invocation.command, [...invocation.args, ...args], {
300316
encoding: "utf8",
301317
stdio: ["ignore", "pipe", "pipe"],
302318
});
@@ -312,16 +328,16 @@ type NpmPackResult = {
312328
};
313329

314330
type ExecFailure = Error & {
315-
stderr?: string | Buffer;
316-
stdout?: string | Buffer;
331+
stderr?: string | Uint8Array;
332+
stdout?: string | Uint8Array;
317333
};
318334

319-
function toTrimmedUtf8(value: string | Buffer | undefined): string {
335+
function toTrimmedUtf8(value: string | Uint8Array | undefined): string {
320336
if (typeof value === "string") {
321337
return value.trim();
322338
}
323-
if (Buffer.isBuffer(value)) {
324-
return value.toString("utf8").trim();
339+
if (value instanceof Uint8Array) {
340+
return new TextDecoder().decode(value).trim();
325341
}
326342
return "";
327343
}
@@ -343,6 +359,32 @@ function describeExecFailure(error: unknown): string {
343359
return details.join(" | ");
344360
}
345361

362+
export function parseNpmPackJsonOutput(stdout: string): NpmPackResult[] | null {
363+
const trimmed = stdout.trim();
364+
if (!trimmed) {
365+
return null;
366+
}
367+
368+
const candidates = [trimmed];
369+
const trailingArrayStart = trimmed.lastIndexOf("\n[");
370+
if (trailingArrayStart !== -1) {
371+
candidates.push(trimmed.slice(trailingArrayStart + 1).trim());
372+
}
373+
374+
for (const candidate of candidates) {
375+
try {
376+
const parsed = JSON.parse(candidate) as unknown;
377+
if (Array.isArray(parsed)) {
378+
return parsed as NpmPackResult[];
379+
}
380+
} catch {
381+
// Try the next candidate. npm lifecycle output can prepend non-JSON logs.
382+
}
383+
}
384+
385+
return null;
386+
}
387+
346388
function collectPackedTarballErrors(): string[] {
347389
const errors: string[] = [];
348390
let stdout = "";
@@ -356,15 +398,11 @@ function collectPackedTarballErrors(): string[] {
356398
return errors;
357399
}
358400

359-
let parsed: unknown;
360-
try {
361-
parsed = JSON.parse(stdout);
362-
} catch {
401+
const packResults = parseNpmPackJsonOutput(stdout);
402+
if (!packResults) {
363403
errors.push("Failed to parse JSON output from `npm pack --json --dry-run`.");
364404
return errors;
365405
}
366-
367-
const packResults = Array.isArray(parsed) ? (parsed as NpmPackResult[]) : [];
368406
const firstResult = packResults[0];
369407
if (!firstResult || !Array.isArray(firstResult.files)) {
370408
errors.push("`npm pack --json --dry-run` did not return a files list to validate.");

test/openclaw-npm-release-check.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
22
import {
33
collectReleasePackageMetadataErrors,
44
collectReleaseTagErrors,
5+
parseNpmPackJsonOutput,
56
parseReleaseTagVersion,
67
parseReleaseVersion,
8+
resolveNpmCommandInvocation,
79
utcCalendarDayDistance,
810
} from "../scripts/openclaw-npm-release-check.ts";
911

@@ -62,6 +64,72 @@ describe("utcCalendarDayDistance", () => {
6264
});
6365
});
6466

67+
describe("resolveNpmCommandInvocation", () => {
68+
it("uses npm_execpath when it points to npm", () => {
69+
expect(
70+
resolveNpmCommandInvocation({
71+
npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js",
72+
nodeExecPath: "/usr/local/bin/node",
73+
platform: "linux",
74+
}),
75+
).toEqual({
76+
command: "/usr/local/bin/node",
77+
args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"],
78+
});
79+
});
80+
81+
it("falls back to the npm command when npm_execpath points to pnpm", () => {
82+
expect(
83+
resolveNpmCommandInvocation({
84+
npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs",
85+
nodeExecPath: "/usr/local/bin/node",
86+
platform: "linux",
87+
}),
88+
).toEqual({
89+
command: "npm",
90+
args: [],
91+
});
92+
});
93+
94+
it("uses the platform npm command when npm_execpath is missing", () => {
95+
expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({
96+
command: "npm.cmd",
97+
args: [],
98+
});
99+
});
100+
});
101+
102+
describe("parseNpmPackJsonOutput", () => {
103+
it("parses a plain npm pack JSON array", () => {
104+
expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([
105+
{ filename: "openclaw.tgz", files: [] },
106+
]);
107+
});
108+
109+
it("parses the trailing JSON payload after npm lifecycle logs", () => {
110+
const stdout = [
111+
'npm warn Unknown project config "node-linker".',
112+
"",
113+
"> [email protected] prepack",
114+
"> pnpm build && pnpm ui:build",
115+
"",
116+
"[copy-hook-metadata] Copied 4 hook metadata files.",
117+
'[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]',
118+
].join("\n");
119+
120+
expect(parseNpmPackJsonOutput(stdout)).toEqual([
121+
{
122+
filename: "openclaw.tgz",
123+
files: [{ path: "dist/control-ui/index.html" }],
124+
},
125+
]);
126+
});
127+
128+
it("returns null when no JSON payload is present", () => {
129+
expect(parseNpmPackJsonOutput("> [email protected] prepack")).toBeNull();
130+
});
131+
});
132+
65133
describe("collectReleaseTagErrors", () => {
66134
it("accepts versions within the two-day CalVer window", () => {
67135
expect(

0 commit comments

Comments
 (0)