mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 22:14:34 +00:00
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:
@@ -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" +
|
||||||
|
|||||||
@@ -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 ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user