Fix npm-spec plugin installs when npm pack output is empty (#21039)

* fix(plugins): recover npm pack archive when stdout is empty

* test(plugins): create npm pack archive in metadata mock

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
graysurf
2026-02-27 11:00:24 +08:00
committed by GitHub
parent 9d52dcf1f4
commit 7aa233790b
3 changed files with 133 additions and 13 deletions

View File

@@ -123,6 +123,8 @@ describe("resolveArchiveSourcePath", () => {
describe("packNpmSpecToArchive", () => { describe("packNpmSpecToArchive", () => {
it("packs spec and returns archive path using JSON output metadata", async () => { it("packs spec and returns archive path using JSON output metadata", async () => {
const cwd = await createFixtureDir(); const cwd = await createFixtureDir();
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
mockPackCommandResult({ mockPackCommandResult({
stdout: JSON.stringify([ stdout: JSON.stringify([
{ {
@@ -140,7 +142,7 @@ describe("packNpmSpecToArchive", () => {
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), archivePath,
metadata: { metadata: {
name: "openclaw-plugin", name: "openclaw-plugin",
version: "1.2.3", version: "1.2.3",
@@ -160,6 +162,8 @@ describe("packNpmSpecToArchive", () => {
it("falls back to parsing final stdout line when npm json output is unavailable", async () => { it("falls back to parsing final stdout line when npm json output is unavailable", async () => {
const cwd = await createFixtureDir(); const cwd = await createFixtureDir();
const expectedArchivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(expectedArchivePath, "", "utf-8");
mockPackCommandResult({ mockPackCommandResult({
stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n", stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n",
}); });
@@ -168,7 +172,7 @@ describe("packNpmSpecToArchive", () => {
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), archivePath: expectedArchivePath,
metadata: {}, metadata: {},
}); });
}); });
@@ -190,6 +194,56 @@ describe("packNpmSpecToArchive", () => {
} }
}); });
it("falls back to archive detected in cwd when npm pack stdout is empty", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-");
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: " \n\n",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 5000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath,
metadata: {},
});
});
it("falls back to archive detected in cwd when stdout does not contain a tgz", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-");
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: "npm pack completed successfully\n",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 5000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath,
metadata: {},
});
});
it("returns explicit error when npm pack produces no archive name", async () => { it("returns explicit error when npm pack produces no archive name", async () => {
const cwd = await createFixtureDir(); const cwd = await createFixtureDir();
mockPackCommandResult({ mockPackCommandResult({
@@ -206,6 +260,7 @@ describe("packNpmSpecToArchive", () => {
it("parses scoped metadata from id-only json output even with npm notice prefix", async () => { it("parses scoped metadata from id-only json output even with npm notice prefix", async () => {
const cwd = await createFixtureDir(); const cwd = await createFixtureDir();
await fs.writeFile(path.join(cwd, "openclaw-plugin-demo-2.0.0.tgz"), "", "utf-8");
mockPackCommandResult({ mockPackCommandResult({
stdout: stdout:
"npm notice creating package\n" + "npm notice creating package\n" +

View File

@@ -144,6 +144,42 @@ function parseNpmPackJsonOutput(
return null; return null;
} }
function parsePackedArchiveFromStdout(stdout: string): string | undefined {
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
const match = line?.match(/([^\s"']+\.tgz)/);
if (match?.[1]) {
return match[1];
}
}
return undefined;
}
async function findPackedArchiveInDir(cwd: string): Promise<string | undefined> {
const entries = await fs.readdir(cwd, { withFileTypes: true }).catch(() => []);
const archives = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"));
if (archives.length === 0) {
return undefined;
}
if (archives.length === 1) {
return archives[0]?.name;
}
const sortedByMtime = await Promise.all(
archives.map(async (entry) => ({
name: entry.name,
mtimeMs: (await fs.stat(path.join(cwd, entry.name))).mtimeMs,
})),
);
sortedByMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
return sortedByMtime[0]?.name;
}
export async function packNpmSpecToArchive(params: { export async function packNpmSpecToArchive(params: {
spec: string; spec: string;
timeoutMs: number; timeoutMs: number;
@@ -176,20 +212,26 @@ export async function packNpmSpecToArchive(params: {
const parsedJson = parseNpmPackJsonOutput(res.stdout || ""); const parsedJson = parseNpmPackJsonOutput(res.stdout || "");
const packed = let packed = parsedJson?.filename ?? parsePackedArchiveFromStdout(res.stdout || "");
parsedJson?.filename ?? if (!packed) {
(res.stdout || "") packed = await findPackedArchiveInDir(params.cwd);
.split("\n") }
.map((line) => line.trim())
.filter(Boolean)
.pop();
if (!packed) { if (!packed) {
return { ok: false, error: "npm pack produced no archive" }; return { ok: false, error: "npm pack produced no archive" };
} }
let archivePath = path.isAbsolute(packed) ? packed : path.join(params.cwd, packed);
if (!(await fileExists(archivePath))) {
const fallbackPacked = await findPackedArchiveInDir(params.cwd);
if (!fallbackPacked) {
return { ok: false, error: "npm pack produced no archive" };
}
archivePath = path.join(params.cwd, fallbackPacked);
}
return { return {
ok: true, ok: true,
archivePath: path.join(params.cwd, packed), archivePath,
metadata: parsedJson?.metadata ?? {}, metadata: parsedJson?.metadata ?? {},
}; };
} }

View File

@@ -1,5 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { expect } from "vitest"; import { expect } from "vitest";
import type { SpawnResult } from "../process/exec.js"; import type { CommandOptions, SpawnResult } from "../process/exec.js";
import { expectSingleNpmInstallIgnoreScriptsCall } from "./exec-assertions.js"; import { expectSingleNpmInstallIgnoreScriptsCall } from "./exec-assertions.js";
export type InstallResultLike = { export type InstallResultLike = {
@@ -40,10 +42,31 @@ export async function expectUnsupportedNpmSpec(
} }
export function mockNpmPackMetadataResult( export function mockNpmPackMetadataResult(
run: { mockResolvedValue: (value: SpawnResult) => unknown }, run: {
mockImplementation: (
implementation: (
argv: string[],
optionsOrTimeout: number | CommandOptions,
) => Promise<SpawnResult>,
) => unknown;
},
metadata: NpmPackMetadata, metadata: NpmPackMetadata,
) { ) {
run.mockResolvedValue(createSuccessfulSpawnResult(JSON.stringify([metadata]))); run.mockImplementation(async (argv, optionsOrTimeout) => {
if (argv[0] !== "npm" || argv[1] !== "pack") {
throw new Error(`unexpected command: ${argv.join(" ")}`);
}
const cwd =
typeof optionsOrTimeout === "object" && optionsOrTimeout !== null
? optionsOrTimeout.cwd
: undefined;
if (cwd) {
fs.writeFileSync(path.join(cwd, metadata.filename), "");
}
return createSuccessfulSpawnResult(JSON.stringify([metadata]));
});
} }
export function expectIntegrityDriftRejected(params: { export function expectIntegrityDriftRejected(params: {