diff --git a/src/infra/install-source-utils.test.ts b/src/infra/install-source-utils.test.ts index b1bcc8ffacc..b9f245510c2 100644 --- a/src/infra/install-source-utils.test.ts +++ b/src/infra/install-source-utils.test.ts @@ -123,6 +123,8 @@ describe("resolveArchiveSourcePath", () => { describe("packNpmSpecToArchive", () => { it("packs spec and returns archive path using JSON output metadata", async () => { const cwd = await createFixtureDir(); + const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz"); + await fs.writeFile(archivePath, "", "utf-8"); mockPackCommandResult({ stdout: JSON.stringify([ { @@ -140,7 +142,7 @@ describe("packNpmSpecToArchive", () => { expect(result).toEqual({ ok: true, - archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), + archivePath, metadata: { name: "openclaw-plugin", 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 () => { const cwd = await createFixtureDir(); + const expectedArchivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz"); + await fs.writeFile(expectedArchivePath, "", "utf-8"); mockPackCommandResult({ stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n", }); @@ -168,7 +172,7 @@ describe("packNpmSpecToArchive", () => { expect(result).toEqual({ ok: true, - archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), + archivePath: expectedArchivePath, 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 () => { const cwd = await createFixtureDir(); mockPackCommandResult({ @@ -206,6 +260,7 @@ describe("packNpmSpecToArchive", () => { it("parses scoped metadata from id-only json output even with npm notice prefix", async () => { const cwd = await createFixtureDir(); + await fs.writeFile(path.join(cwd, "openclaw-plugin-demo-2.0.0.tgz"), "", "utf-8"); mockPackCommandResult({ stdout: "npm notice creating package\n" + diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index d4a2ac025d7..206711db2fc 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -144,6 +144,42 @@ function parseNpmPackJsonOutput( 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 { + 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: { spec: string; timeoutMs: number; @@ -176,20 +212,26 @@ export async function packNpmSpecToArchive(params: { const parsedJson = parseNpmPackJsonOutput(res.stdout || ""); - const packed = - parsedJson?.filename ?? - (res.stdout || "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .pop(); + let packed = parsedJson?.filename ?? parsePackedArchiveFromStdout(res.stdout || ""); + if (!packed) { + packed = await findPackedArchiveInDir(params.cwd); + } if (!packed) { 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 { ok: true, - archivePath: path.join(params.cwd, packed), + archivePath, metadata: parsedJson?.metadata ?? {}, }; } diff --git a/src/test-utils/npm-spec-install-test-helpers.ts b/src/test-utils/npm-spec-install-test-helpers.ts index 23c06afe44b..9ef8e29404e 100644 --- a/src/test-utils/npm-spec-install-test-helpers.ts +++ b/src/test-utils/npm-spec-install-test-helpers.ts @@ -1,5 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; 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"; export type InstallResultLike = { @@ -40,10 +42,31 @@ export async function expectUnsupportedNpmSpec( } export function mockNpmPackMetadataResult( - run: { mockResolvedValue: (value: SpawnResult) => unknown }, + run: { + mockImplementation: ( + implementation: ( + argv: string[], + optionsOrTimeout: number | CommandOptions, + ) => Promise, + ) => unknown; + }, 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: {