Skip to content

Commit 7aa2337

Browse files
graysurfvincentkoc
andauthored
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 <[email protected]>
1 parent 9d52dcf commit 7aa2337

File tree

3 files changed

+133
-13
lines changed

3 files changed

+133
-13
lines changed

src/infra/install-source-utils.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ describe("resolveArchiveSourcePath", () => {
123123
describe("packNpmSpecToArchive", () => {
124124
it("packs spec and returns archive path using JSON output metadata", async () => {
125125
const cwd = await createFixtureDir();
126+
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
127+
await fs.writeFile(archivePath, "", "utf-8");
126128
mockPackCommandResult({
127129
stdout: JSON.stringify([
128130
{
@@ -140,7 +142,7 @@ describe("packNpmSpecToArchive", () => {
140142

141143
expect(result).toEqual({
142144
ok: true,
143-
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
145+
archivePath,
144146
metadata: {
145147
name: "openclaw-plugin",
146148
version: "1.2.3",
@@ -160,6 +162,8 @@ describe("packNpmSpecToArchive", () => {
160162

161163
it("falls back to parsing final stdout line when npm json output is unavailable", async () => {
162164
const cwd = await createFixtureDir();
165+
const expectedArchivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
166+
await fs.writeFile(expectedArchivePath, "", "utf-8");
163167
mockPackCommandResult({
164168
stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n",
165169
});
@@ -168,7 +172,7 @@ describe("packNpmSpecToArchive", () => {
168172

169173
expect(result).toEqual({
170174
ok: true,
171-
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
175+
archivePath: expectedArchivePath,
172176
metadata: {},
173177
});
174178
});
@@ -190,6 +194,56 @@ describe("packNpmSpecToArchive", () => {
190194
}
191195
});
192196

197+
it("falls back to archive detected in cwd when npm pack stdout is empty", async () => {
198+
const cwd = await createTempDir("openclaw-install-source-utils-");
199+
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
200+
await fs.writeFile(archivePath, "", "utf-8");
201+
runCommandWithTimeoutMock.mockResolvedValue({
202+
stdout: " \n\n",
203+
stderr: "",
204+
code: 0,
205+
signal: null,
206+
killed: false,
207+
});
208+
209+
const result = await packNpmSpecToArchive({
210+
211+
timeoutMs: 5000,
212+
cwd,
213+
});
214+
215+
expect(result).toEqual({
216+
ok: true,
217+
archivePath,
218+
metadata: {},
219+
});
220+
});
221+
222+
it("falls back to archive detected in cwd when stdout does not contain a tgz", async () => {
223+
const cwd = await createTempDir("openclaw-install-source-utils-");
224+
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
225+
await fs.writeFile(archivePath, "", "utf-8");
226+
runCommandWithTimeoutMock.mockResolvedValue({
227+
stdout: "npm pack completed successfully\n",
228+
stderr: "",
229+
code: 0,
230+
signal: null,
231+
killed: false,
232+
});
233+
234+
const result = await packNpmSpecToArchive({
235+
236+
timeoutMs: 5000,
237+
cwd,
238+
});
239+
240+
expect(result).toEqual({
241+
ok: true,
242+
archivePath,
243+
metadata: {},
244+
});
245+
});
246+
193247
it("returns explicit error when npm pack produces no archive name", async () => {
194248
const cwd = await createFixtureDir();
195249
mockPackCommandResult({
@@ -206,6 +260,7 @@ describe("packNpmSpecToArchive", () => {
206260

207261
it("parses scoped metadata from id-only json output even with npm notice prefix", async () => {
208262
const cwd = await createFixtureDir();
263+
await fs.writeFile(path.join(cwd, "openclaw-plugin-demo-2.0.0.tgz"), "", "utf-8");
209264
mockPackCommandResult({
210265
stdout:
211266
"npm notice creating package\n" +

src/infra/install-source-utils.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,42 @@ function parseNpmPackJsonOutput(
144144
return null;
145145
}
146146

147+
function parsePackedArchiveFromStdout(stdout: string): string | undefined {
148+
const lines = stdout
149+
.split(/\r?\n/)
150+
.map((line) => line.trim())
151+
.filter(Boolean);
152+
153+
for (let index = lines.length - 1; index >= 0; index -= 1) {
154+
const line = lines[index];
155+
const match = line?.match(/([^\s"']+\.tgz)/);
156+
if (match?.[1]) {
157+
return match[1];
158+
}
159+
}
160+
return undefined;
161+
}
162+
163+
async function findPackedArchiveInDir(cwd: string): Promise<string | undefined> {
164+
const entries = await fs.readdir(cwd, { withFileTypes: true }).catch(() => []);
165+
const archives = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"));
166+
if (archives.length === 0) {
167+
return undefined;
168+
}
169+
if (archives.length === 1) {
170+
return archives[0]?.name;
171+
}
172+
173+
const sortedByMtime = await Promise.all(
174+
archives.map(async (entry) => ({
175+
name: entry.name,
176+
mtimeMs: (await fs.stat(path.join(cwd, entry.name))).mtimeMs,
177+
})),
178+
);
179+
sortedByMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
180+
return sortedByMtime[0]?.name;
181+
}
182+
147183
export async function packNpmSpecToArchive(params: {
148184
spec: string;
149185
timeoutMs: number;
@@ -176,20 +212,26 @@ export async function packNpmSpecToArchive(params: {
176212

177213
const parsedJson = parseNpmPackJsonOutput(res.stdout || "");
178214

179-
const packed =
180-
parsedJson?.filename ??
181-
(res.stdout || "")
182-
.split("\n")
183-
.map((line) => line.trim())
184-
.filter(Boolean)
185-
.pop();
215+
let packed = parsedJson?.filename ?? parsePackedArchiveFromStdout(res.stdout || "");
216+
if (!packed) {
217+
packed = await findPackedArchiveInDir(params.cwd);
218+
}
186219
if (!packed) {
187220
return { ok: false, error: "npm pack produced no archive" };
188221
}
189222

223+
let archivePath = path.isAbsolute(packed) ? packed : path.join(params.cwd, packed);
224+
if (!(await fileExists(archivePath))) {
225+
const fallbackPacked = await findPackedArchiveInDir(params.cwd);
226+
if (!fallbackPacked) {
227+
return { ok: false, error: "npm pack produced no archive" };
228+
}
229+
archivePath = path.join(params.cwd, fallbackPacked);
230+
}
231+
190232
return {
191233
ok: true,
192-
archivePath: path.join(params.cwd, packed),
234+
archivePath,
193235
metadata: parsedJson?.metadata ?? {},
194236
};
195237
}

src/test-utils/npm-spec-install-test-helpers.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
13
import { expect } from "vitest";
2-
import type { SpawnResult } from "../process/exec.js";
4+
import type { CommandOptions, SpawnResult } from "../process/exec.js";
35
import { expectSingleNpmInstallIgnoreScriptsCall } from "./exec-assertions.js";
46

57
export type InstallResultLike = {
@@ -40,10 +42,31 @@ export async function expectUnsupportedNpmSpec(
4042
}
4143

4244
export function mockNpmPackMetadataResult(
43-
run: { mockResolvedValue: (value: SpawnResult) => unknown },
45+
run: {
46+
mockImplementation: (
47+
implementation: (
48+
argv: string[],
49+
optionsOrTimeout: number | CommandOptions,
50+
) => Promise<SpawnResult>,
51+
) => unknown;
52+
},
4453
metadata: NpmPackMetadata,
4554
) {
46-
run.mockResolvedValue(createSuccessfulSpawnResult(JSON.stringify([metadata])));
55+
run.mockImplementation(async (argv, optionsOrTimeout) => {
56+
if (argv[0] !== "npm" || argv[1] !== "pack") {
57+
throw new Error(`unexpected command: ${argv.join(" ")}`);
58+
}
59+
60+
const cwd =
61+
typeof optionsOrTimeout === "object" && optionsOrTimeout !== null
62+
? optionsOrTimeout.cwd
63+
: undefined;
64+
if (cwd) {
65+
fs.writeFileSync(path.join(cwd, metadata.filename), "");
66+
}
67+
68+
return createSuccessfulSpawnResult(JSON.stringify([metadata]));
69+
});
4770
}
4871

4972
export function expectIntegrityDriftRejected(params: {

0 commit comments

Comments
 (0)