Skip to content

Commit cf31197

Browse files
authored
fix(plugins): fallback bundled channel specs when npm install returns 404 (#12849)
* plugins: add bundled source resolver * plugins: add bundled source resolver tests * cli: fallback npm 404 plugin installs to bundled sources * plugins: use bundled source resolver during updates * protocol: regenerate macos gateway swift models * protocol: regenerate shared swift models * Revert "protocol: regenerate shared swift models" This reverts commit 6a2b08c. * Revert "protocol: regenerate macos gateway swift models" This reverts commit 27c0301.
1 parent 7b5153f commit cf31197

File tree

4 files changed

+214
-44
lines changed

4 files changed

+214
-44
lines changed

src/cli/plugins-cli.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js";
66
import { loadConfig, writeConfigFile } from "../config/config.js";
77
import { resolveStateDir } from "../config/paths.js";
88
import { resolveArchiveKind } from "../infra/archive.js";
9+
import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js";
910
import { enablePluginInConfig } from "../plugins/enable.js";
1011
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
1112
import { recordPluginInstall } from "../plugins/installs.js";
@@ -147,6 +148,16 @@ function logSlotWarnings(warnings: string[]) {
147148
}
148149
}
149150

151+
function isPackageNotFoundInstallError(message: string): boolean {
152+
const lower = message.toLowerCase();
153+
return (
154+
lower.includes("npm pack failed:") &&
155+
(lower.includes("e404") ||
156+
lower.includes("404 not found") ||
157+
lower.includes("could not be found"))
158+
);
159+
}
160+
150161
export function registerPluginsCli(program: Command) {
151162
const plugins = program
152163
.command("plugins")
@@ -614,8 +625,52 @@ export function registerPluginsCli(program: Command) {
614625
logger: createPluginInstallLogger(),
615626
});
616627
if (!result.ok) {
617-
defaultRuntime.error(result.error);
618-
process.exit(1);
628+
const bundledFallback = isPackageNotFoundInstallError(result.error)
629+
? findBundledPluginByNpmSpec({ spec: raw })
630+
: undefined;
631+
if (!bundledFallback) {
632+
defaultRuntime.error(result.error);
633+
process.exit(1);
634+
}
635+
636+
const existing = cfg.plugins?.load?.paths ?? [];
637+
const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath]));
638+
let next: OpenClawConfig = {
639+
...cfg,
640+
plugins: {
641+
...cfg.plugins,
642+
load: {
643+
...cfg.plugins?.load,
644+
paths: mergedPaths,
645+
},
646+
entries: {
647+
...cfg.plugins?.entries,
648+
[bundledFallback.pluginId]: {
649+
...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined),
650+
enabled: true,
651+
},
652+
},
653+
},
654+
};
655+
next = recordPluginInstall(next, {
656+
pluginId: bundledFallback.pluginId,
657+
source: "path",
658+
spec: raw,
659+
sourcePath: bundledFallback.localPath,
660+
installPath: bundledFallback.localPath,
661+
});
662+
const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId);
663+
next = slotResult.config;
664+
await writeConfigFile(next);
665+
logSlotWarnings(slotResult.warnings);
666+
defaultRuntime.log(
667+
theme.warn(
668+
`npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
669+
),
670+
);
671+
defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`);
672+
defaultRuntime.log(`Restart the gateway to load plugins.`);
673+
return;
619674
}
620675
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
621676
clearPluginManifestRegistryCache();
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { findBundledPluginByNpmSpec, resolveBundledPluginSources } from "./bundled-sources.js";
3+
4+
const discoverOpenClawPluginsMock = vi.fn();
5+
const loadPluginManifestMock = vi.fn();
6+
7+
vi.mock("./discovery.js", () => ({
8+
discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args),
9+
}));
10+
11+
vi.mock("./manifest.js", () => ({
12+
loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args),
13+
}));
14+
15+
describe("bundled plugin sources", () => {
16+
beforeEach(() => {
17+
discoverOpenClawPluginsMock.mockReset();
18+
loadPluginManifestMock.mockReset();
19+
});
20+
21+
it("resolves bundled sources keyed by plugin id", () => {
22+
discoverOpenClawPluginsMock.mockReturnValue({
23+
candidates: [
24+
{
25+
origin: "global",
26+
rootDir: "/global/feishu",
27+
packageName: "@openclaw/feishu",
28+
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
29+
},
30+
{
31+
origin: "bundled",
32+
rootDir: "/app/extensions/feishu",
33+
packageName: "@openclaw/feishu",
34+
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
35+
},
36+
{
37+
origin: "bundled",
38+
rootDir: "/app/extensions/feishu-dup",
39+
packageName: "@openclaw/feishu",
40+
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
41+
},
42+
{
43+
origin: "bundled",
44+
rootDir: "/app/extensions/msteams",
45+
packageName: "@openclaw/msteams",
46+
packageManifest: { install: { npmSpec: "@openclaw/msteams" } },
47+
},
48+
],
49+
diagnostics: [],
50+
});
51+
52+
loadPluginManifestMock.mockImplementation((rootDir: string) => {
53+
if (rootDir === "/app/extensions/feishu") {
54+
return { ok: true, manifest: { id: "feishu" } };
55+
}
56+
if (rootDir === "/app/extensions/msteams") {
57+
return { ok: true, manifest: { id: "msteams" } };
58+
}
59+
return {
60+
ok: false,
61+
error: "invalid manifest",
62+
manifestPath: `${rootDir}/openclaw.plugin.json`,
63+
};
64+
});
65+
66+
const map = resolveBundledPluginSources({});
67+
68+
expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]);
69+
expect(map.get("feishu")).toEqual({
70+
pluginId: "feishu",
71+
localPath: "/app/extensions/feishu",
72+
npmSpec: "@openclaw/feishu",
73+
});
74+
});
75+
76+
it("finds bundled source by npm spec", () => {
77+
discoverOpenClawPluginsMock.mockReturnValue({
78+
candidates: [
79+
{
80+
origin: "bundled",
81+
rootDir: "/app/extensions/feishu",
82+
packageName: "@openclaw/feishu",
83+
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
84+
},
85+
],
86+
diagnostics: [],
87+
});
88+
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
89+
90+
const resolved = findBundledPluginByNpmSpec({ spec: "@openclaw/feishu" });
91+
const missing = findBundledPluginByNpmSpec({ spec: "@openclaw/not-found" });
92+
93+
expect(resolved?.pluginId).toBe("feishu");
94+
expect(resolved?.localPath).toBe("/app/extensions/feishu");
95+
expect(missing).toBeUndefined();
96+
});
97+
});

src/plugins/bundled-sources.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { discoverOpenClawPlugins } from "./discovery.js";
2+
import { loadPluginManifest } from "./manifest.js";
3+
4+
export type BundledPluginSource = {
5+
pluginId: string;
6+
localPath: string;
7+
npmSpec?: string;
8+
};
9+
10+
export function resolveBundledPluginSources(params: {
11+
workspaceDir?: string;
12+
}): Map<string, BundledPluginSource> {
13+
const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir });
14+
const bundled = new Map<string, BundledPluginSource>();
15+
16+
for (const candidate of discovery.candidates) {
17+
if (candidate.origin !== "bundled") {
18+
continue;
19+
}
20+
const manifest = loadPluginManifest(candidate.rootDir);
21+
if (!manifest.ok) {
22+
continue;
23+
}
24+
const pluginId = manifest.manifest.id;
25+
if (bundled.has(pluginId)) {
26+
continue;
27+
}
28+
29+
const npmSpec =
30+
candidate.packageManifest?.install?.npmSpec?.trim() ||
31+
candidate.packageName?.trim() ||
32+
undefined;
33+
34+
bundled.set(pluginId, {
35+
pluginId,
36+
localPath: candidate.rootDir,
37+
npmSpec,
38+
});
39+
}
40+
41+
return bundled;
42+
}
43+
44+
export function findBundledPluginByNpmSpec(params: {
45+
spec: string;
46+
workspaceDir?: string;
47+
}): BundledPluginSource | undefined {
48+
const targetSpec = params.spec.trim();
49+
if (!targetSpec) {
50+
return undefined;
51+
}
52+
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
53+
for (const source of bundled.values()) {
54+
if (source.npmSpec === targetSpec) {
55+
return source;
56+
}
57+
}
58+
return undefined;
59+
}

src/plugins/update.ts

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import type { OpenClawConfig } from "../config/config.js";
44
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
55
import type { UpdateChannel } from "../infra/update-channels.js";
66
import { resolveUserPath } from "../utils.js";
7-
import { discoverOpenClawPlugins } from "./discovery.js";
7+
import { resolveBundledPluginSources } from "./bundled-sources.js";
88
import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js";
99
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
10-
import { loadPluginManifest } from "./manifest.js";
1110

1211
export type PluginUpdateLogger = {
1312
info?: (message: string) => void;
@@ -54,12 +53,6 @@ export type PluginChannelSyncResult = {
5453
summary: PluginChannelSyncSummary;
5554
};
5655

57-
type BundledPluginSource = {
58-
pluginId: string;
59-
localPath: string;
60-
npmSpec?: string;
61-
};
62-
6356
type InstallIntegrityDrift = {
6457
spec: string;
6558
expectedIntegrity: string;
@@ -91,40 +84,6 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
9184
}
9285
}
9386

94-
function resolveBundledPluginSources(params: {
95-
workspaceDir?: string;
96-
}): Map<string, BundledPluginSource> {
97-
const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir });
98-
const bundled = new Map<string, BundledPluginSource>();
99-
100-
for (const candidate of discovery.candidates) {
101-
if (candidate.origin !== "bundled") {
102-
continue;
103-
}
104-
const manifest = loadPluginManifest(candidate.rootDir);
105-
if (!manifest.ok) {
106-
continue;
107-
}
108-
const pluginId = manifest.manifest.id;
109-
if (bundled.has(pluginId)) {
110-
continue;
111-
}
112-
113-
const npmSpec =
114-
candidate.packageManifest?.install?.npmSpec?.trim() ||
115-
candidate.packageName?.trim() ||
116-
undefined;
117-
118-
bundled.set(pluginId, {
119-
pluginId,
120-
localPath: candidate.rootDir,
121-
npmSpec,
122-
});
123-
}
124-
125-
return bundled;
126-
}
127-
12887
function pathsEqual(left?: string, right?: string): boolean {
12988
if (!left || !right) {
13089
return false;

0 commit comments

Comments
 (0)