Skip to content

Commit 5696e24

Browse files
committed
refactor(cli): use typed clawhub fallback decisions
1 parent 145e514 commit 5696e24

7 files changed

Lines changed: 251 additions & 71 deletions

File tree

src/cli/plugins-cli.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ vi.mock("../hooks/installs.js", () => ({
104104
}));
105105

106106
vi.mock("../plugins/clawhub.js", () => ({
107+
CLAWHUB_INSTALL_ERROR_CODE: {
108+
PACKAGE_NOT_FOUND: "package_not_found",
109+
VERSION_NOT_FOUND: "version_not_found",
110+
},
107111
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args),
108112
formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) =>
109113
`clawhub:${name}${version ? `@${version}` : ""}`,
@@ -454,6 +458,7 @@ describe("plugins cli", () => {
454458
installPluginFromClawHub.mockResolvedValue({
455459
ok: false,
456460
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
461+
code: "package_not_found",
457462
});
458463
installPluginFromNpmSpec.mockResolvedValue({
459464
ok: true,
@@ -491,6 +496,7 @@ describe("plugins cli", () => {
491496
installPluginFromClawHub.mockResolvedValue({
492497
ok: false,
493498
error: 'Use "openclaw skills install demo" instead.',
499+
code: "skill_package",
494500
});
495501

496502
await expect(runCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1");
@@ -517,6 +523,7 @@ describe("plugins cli", () => {
517523
installPluginFromClawHub.mockResolvedValue({
518524
ok: false,
519525
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
526+
code: "package_not_found",
520527
});
521528
installPluginFromNpmSpec.mockResolvedValue({
522529
ok: false,

src/cli/plugins-command-helpers.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
44
import type { HookInstallRecord } from "../config/types.hooks.js";
55
import type { PluginInstallRecord } from "../config/types.plugins.js";
66
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
7+
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
78
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
89
import { buildPluginStatusReport } from "../plugins/status.js";
910
import { defaultRuntime } from "../runtime.js";
@@ -164,11 +165,22 @@ export function buildPreferredClawHubSpec(raw: string): string | null {
164165
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
165166
}
166167

167-
export function shouldFallbackFromClawHubToNpm(error: string): boolean {
168-
const normalized = error.trim();
169-
return (
170-
/Package not found on ClawHub/i.test(normalized) ||
171-
/ClawHub .* failed \(404\)/i.test(normalized) ||
172-
/Version not found/i.test(normalized)
173-
);
168+
export const PREFERRED_CLAWHUB_FALLBACK_DECISION = {
169+
FALLBACK_TO_NPM: "fallback_to_npm",
170+
STOP: "stop",
171+
} as const;
172+
173+
export type PreferredClawHubFallbackDecision =
174+
(typeof PREFERRED_CLAWHUB_FALLBACK_DECISION)[keyof typeof PREFERRED_CLAWHUB_FALLBACK_DECISION];
175+
176+
export function decidePreferredClawHubFallback(params: {
177+
code?: string;
178+
}): PreferredClawHubFallbackDecision {
179+
if (
180+
params.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
181+
params.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
182+
) {
183+
return PREFERRED_CLAWHUB_FALLBACK_DECISION.FALLBACK_TO_NPM;
184+
}
185+
return PREFERRED_CLAWHUB_FALLBACK_DECISION.STOP;
174186
}

src/cli/plugins-install-command.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ import {
2525
buildPreferredClawHubSpec,
2626
createHookPackInstallLogger,
2727
createPluginInstallLogger,
28+
decidePreferredClawHubFallback,
2829
formatPluginInstallWithHookFallbackError,
2930
resolveFileNpmSpecToLocalPath,
30-
shouldFallbackFromClawHubToNpm,
3131
} from "./plugins-command-helpers.js";
3232
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
3333

@@ -406,7 +406,7 @@ export async function runPluginInstallCommand(params: {
406406
});
407407
return;
408408
}
409-
if (!shouldFallbackFromClawHubToNpm(clawhubResult.error)) {
409+
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
410410
defaultRuntime.error(clawhubResult.error);
411411
return defaultRuntime.exit(1);
412412
}

src/infra/clawhub.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ type ClawHubRequestParams = {
179179
fetchImpl?: FetchLike;
180180
};
181181

182+
export class ClawHubRequestError extends Error {
183+
readonly status: number;
184+
readonly requestPath: string;
185+
readonly responseBody: string;
186+
187+
constructor(params: { path: string; status: number; body: string }) {
188+
super(`ClawHub ${params.path} failed (${params.status}): ${params.body}`);
189+
this.name = "ClawHubRequestError";
190+
this.status = params.status;
191+
this.requestPath = params.path;
192+
this.responseBody = params.body;
193+
}
194+
}
195+
182196
function normalizeBaseUrl(baseUrl?: string): string {
183197
const envValue =
184198
process.env.OPENCLAW_CLAWHUB_URL?.trim() ||
@@ -389,9 +403,11 @@ async function readErrorBody(response: Response): Promise<string> {
389403
async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
390404
const { response, url } = await clawhubRequest(params);
391405
if (!response.ok) {
392-
throw new Error(
393-
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
394-
);
406+
throw new ClawHubRequestError({
407+
path: url.pathname,
408+
status: response.status,
409+
body: await readErrorBody(response),
410+
});
395411
}
396412
return (await response.json()) as T;
397413
}
@@ -567,9 +583,11 @@ export async function downloadClawHubPackageArchive(params: {
567583
fetchImpl: params.fetchImpl,
568584
});
569585
if (!response.ok) {
570-
throw new Error(
571-
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
572-
);
586+
throw new ClawHubRequestError({
587+
path: url.pathname,
588+
status: response.status,
589+
body: await readErrorBody(response),
590+
});
573591
}
574592
const bytes = new Uint8Array(await response.arrayBuffer());
575593
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-package-"));
@@ -603,9 +621,11 @@ export async function downloadClawHubSkillArchive(params: {
603621
},
604622
});
605623
if (!response.ok) {
606-
throw new Error(
607-
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
608-
);
624+
throw new ClawHubRequestError({
625+
path: url.pathname,
626+
status: response.status,
627+
body: await readErrorBody(response),
628+
});
609629
}
610630
const bytes = new Uint8Array(await response.arrayBuffer());
611631
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-skill-"));

src/plugins/clawhub.test.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@ const satisfiesGatewayMinimumMock = vi.fn();
1010
const resolveRuntimeServiceVersionMock = vi.fn();
1111
const installPluginFromArchiveMock = vi.fn();
1212

13-
vi.mock("../infra/clawhub.js", () => ({
14-
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args),
15-
fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args),
16-
fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args),
17-
downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args),
18-
resolveLatestVersionFromPackage: (...args: unknown[]) =>
19-
resolveLatestVersionFromPackageMock(...args),
20-
satisfiesPluginApiRange: (...args: unknown[]) => satisfiesPluginApiRangeMock(...args),
21-
satisfiesGatewayMinimum: (...args: unknown[]) => satisfiesGatewayMinimumMock(...args),
22-
}));
13+
vi.mock("../infra/clawhub.js", async () => {
14+
const actual = await vi.importActual<typeof import("../infra/clawhub.js")>("../infra/clawhub.js");
15+
return {
16+
...actual,
17+
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args),
18+
fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args),
19+
fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args),
20+
downloadClawHubPackageArchive: (...args: unknown[]) =>
21+
downloadClawHubPackageArchiveMock(...args),
22+
resolveLatestVersionFromPackage: (...args: unknown[]) =>
23+
resolveLatestVersionFromPackageMock(...args),
24+
satisfiesPluginApiRange: (...args: unknown[]) => satisfiesPluginApiRangeMock(...args),
25+
satisfiesGatewayMinimum: (...args: unknown[]) => satisfiesGatewayMinimumMock(...args),
26+
};
27+
});
2328

2429
vi.mock("../version.js", () => ({
2530
resolveRuntimeServiceVersion: (...args: unknown[]) => resolveRuntimeServiceVersionMock(...args),
@@ -29,7 +34,9 @@ vi.mock("./install.js", () => ({
2934
installPluginFromArchive: (...args: unknown[]) => installPluginFromArchiveMock(...args),
3035
}));
3136

32-
const { formatClawHubSpecifier, installPluginFromClawHub } = await import("./clawhub.js");
37+
const { ClawHubRequestError } = await import("../infra/clawhub.js");
38+
const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } =
39+
await import("./clawhub.js");
3340

3441
describe("installPluginFromClawHub", () => {
3542
beforeEach(() => {
@@ -147,8 +154,43 @@ describe("installPluginFromClawHub", () => {
147154
},
148155
});
149156

150-
await expect(installPluginFromClawHub({ spec: "clawhub:calendar" })).rejects.toThrow(
151-
'Use "openclaw skills install calendar" instead.',
157+
await expect(installPluginFromClawHub({ spec: "clawhub:calendar" })).resolves.toMatchObject({
158+
ok: false,
159+
code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
160+
error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.',
161+
});
162+
});
163+
164+
it("returns typed package-not-found failures", async () => {
165+
fetchClawHubPackageDetailMock.mockRejectedValueOnce(
166+
new ClawHubRequestError({
167+
path: "/api/v1/packages/demo",
168+
status: 404,
169+
body: "Package not found",
170+
}),
171+
);
172+
173+
await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({
174+
ok: false,
175+
code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
176+
error: "Package not found on ClawHub.",
177+
});
178+
});
179+
180+
it("returns typed version-not-found failures", async () => {
181+
parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" });
182+
fetchClawHubPackageVersionMock.mockRejectedValueOnce(
183+
new ClawHubRequestError({
184+
path: "/api/v1/packages/demo/versions/9.9.9",
185+
status: 404,
186+
body: "Version not found",
187+
}),
152188
);
189+
190+
await expect(installPluginFromClawHub({ spec: "clawhub:[email protected]" })).resolves.toMatchObject({
191+
ok: false,
192+
code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND,
193+
error: "Version not found on ClawHub: [email protected].",
194+
});
153195
});
154196
});

0 commit comments

Comments
 (0)