Skip to content

Commit 15bbf4f

Browse files
authored
fix(channels): clarify remote install hints
Clarify remote channel install hints and align onboarding install source labels with progress-bar coverage.
1 parent 9a899a2 commit 15bbf4f

8 files changed

Lines changed: 332 additions & 34 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates.
1818
- Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc.
1919
- Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop.
20+
- Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw.
2021
- CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries.
2122
- Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`.
2223
- Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits.

src/commands/channel-setup/plugin-install.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
518518
options: [
519519
expect.objectContaining({
520520
value: "npm",
521-
label: `Download from npm (${bundledChatForkNpmSpec})`,
521+
label: `Remote install from npm (${bundledChatForkNpmSpec})`,
522522
}),
523523
expect.objectContaining({
524524
value: "skip",
@@ -562,7 +562,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
562562
options: [
563563
expect.objectContaining({
564564
value: "clawhub",
565-
label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
565+
label: "Remote install from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
566566
}),
567567
expect.objectContaining({
568568
value: "skip",

src/commands/onboarding-plugin-install.test.ts

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ vi.mock("../utils/with-timeout.js", () => ({
7272

7373
import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js";
7474

75+
function createDeferred<T>() {
76+
let resolve!: (value: T) => void;
77+
const promise = new Promise<T>((next) => {
78+
resolve = next;
79+
});
80+
return { promise, resolve };
81+
}
82+
83+
async function waitForMockCall(mock: { mock: { calls: unknown[][] } }) {
84+
for (let i = 0; i < 20; i += 1) {
85+
if (mock.mock.calls.length > 0) {
86+
return;
87+
}
88+
await new Promise((resolve) => setTimeout(resolve, 0));
89+
}
90+
}
91+
7592
describe("ensureOnboardingPluginInstalled", () => {
7693
beforeEach(() => {
7794
vi.clearAllMocks();
@@ -241,6 +258,114 @@ describe("ensureOnboardingPluginInstalled", () => {
241258
expect(refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled();
242259
});
243260

261+
it("animates ClawHub install progress while the remote install is running", async () => {
262+
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromClawHub>>>();
263+
installPluginFromClawHub.mockImplementation(async (params) => {
264+
params.logger?.info?.("Downloading demo-plugin from ClawHub…");
265+
return await deferred.promise;
266+
});
267+
const stop = vi.fn();
268+
const update = vi.fn();
269+
270+
const install = ensureOnboardingPluginInstalled({
271+
cfg: {},
272+
entry: {
273+
pluginId: "demo-plugin",
274+
label: "Demo Provider",
275+
install: {
276+
clawhubSpec: "clawhub:demo-plugin@2026.5.2",
277+
defaultChoice: "clawhub",
278+
},
279+
},
280+
prompter: {
281+
select: vi.fn(async () => "clawhub"),
282+
progress: vi.fn(() => ({ update, stop })),
283+
} as never,
284+
runtime: {} as never,
285+
});
286+
287+
await waitForMockCall(installPluginFromClawHub);
288+
expect(installPluginFromClawHub).toHaveBeenCalled();
289+
290+
await new Promise((resolve) => setTimeout(resolve, 250));
291+
expect(update).toHaveBeenCalledWith("Downloading");
292+
expect(
293+
update.mock.calls.some(
294+
([message]) =>
295+
typeof message === "string" && /^Downloading {2}\[[]{16}\] \d+%$/u.test(message),
296+
),
297+
).toBe(true);
298+
299+
deferred.resolve({
300+
ok: true,
301+
pluginId: "demo-plugin",
302+
targetDir: "/tmp/demo-plugin",
303+
version: "2026.5.2",
304+
packageName: "demo-plugin",
305+
clawhub: {
306+
source: "clawhub",
307+
clawhubUrl: "https://clawhub.ai",
308+
clawhubPackage: "demo-plugin",
309+
clawhubFamily: "code-plugin",
310+
clawhubChannel: "official",
311+
version: "2026.5.2",
312+
integrity: "sha256-clawpack",
313+
resolvedAt: "2026-05-02T00:00:00.000Z",
314+
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
315+
clawpackSpecVersion: 1,
316+
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
317+
clawpackSize: 4096,
318+
},
319+
});
320+
await install;
321+
});
322+
323+
it("animates npm install progress while the remote install is running", async () => {
324+
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromNpmSpec>>>();
325+
installPluginFromNpmSpec.mockImplementation(async (params) => {
326+
params.logger?.info?.("Resolving npm package…");
327+
return await deferred.promise;
328+
});
329+
const stop = vi.fn();
330+
const update = vi.fn();
331+
332+
const install = ensureOnboardingPluginInstalled({
333+
cfg: {},
334+
entry: {
335+
pluginId: "demo-plugin",
336+
label: "Demo Plugin",
337+
install: {
338+
npmSpec: "@demo/plugin@1.2.3",
339+
},
340+
},
341+
prompter: {
342+
select: vi.fn(async () => "npm"),
343+
progress: vi.fn(() => ({ update, stop })),
344+
} as never,
345+
runtime: {} as never,
346+
});
347+
348+
await waitForMockCall(installPluginFromNpmSpec);
349+
expect(installPluginFromNpmSpec).toHaveBeenCalled();
350+
351+
await new Promise((resolve) => setTimeout(resolve, 250));
352+
expect(update).toHaveBeenCalledWith("Resolving");
353+
expect(
354+
update.mock.calls.some(
355+
([message]) =>
356+
typeof message === "string" && /^Resolving {2}\[[]{16}\] \d+%$/u.test(message),
357+
),
358+
).toBe(true);
359+
360+
deferred.resolve({
361+
ok: true,
362+
pluginId: "demo-plugin",
363+
targetDir: "/tmp/demo-plugin",
364+
version: "1.2.3",
365+
});
366+
await install;
367+
});
368+
244369
it("returns a timed out status and notes the retry path when npm install hangs", async () => {
245370
const note = vi.fn(async () => {});
246371
const stop = vi.fn();
@@ -310,7 +435,7 @@ describe("ensureOnboardingPluginInstalled", () => {
310435
});
311436

312437
expect(captured?.options).toEqual([
313-
{ value: "npm", label: "Download from npm (@demo/plugin)" },
438+
{ value: "npm", label: "Remote install from npm (@demo/plugin)" },
314439
{ value: "skip", label: "Skip for now" },
315440
]);
316441
expect(captured?.initialValue).toBe("npm");
@@ -349,8 +474,11 @@ describe("ensureOnboardingPluginInstalled", () => {
349474
});
350475

351476
expect(captured?.options).toEqual([
352-
{ value: "clawhub", label: "Download from ClawHub (clawhub:demo-plugin@2026.5.2)" },
353-
{ value: "npm", label: "Download from npm (@openclaw/demo-plugin@2026.5.2)" },
477+
{
478+
value: "clawhub",
479+
label: "Remote install from ClawHub (clawhub:demo-plugin@2026.5.2)",
480+
},
481+
{ value: "npm", label: "Remote install from npm (@openclaw/demo-plugin@2026.5.2)" },
354482
{ value: "skip", label: "Skip for now" },
355483
]);
356484
expect(captured?.initialValue).toBe("clawhub");
@@ -460,7 +588,7 @@ describe("ensureOnboardingPluginInstalled", () => {
460588
expect(captured).toBeDefined();
461589
expect(captured?.message).toBe("Install Demo Plugin\\n plugin?");
462590
expect(captured?.options).toEqual([
463-
{ value: "npm", label: "Download from npm (@demo/plugin@1.2.3)" },
591+
{ value: "npm", label: "Remote install from npm (@demo/plugin@1.2.3)" },
464592
{
465593
value: "local",
466594
label: "Use local plugin path",
@@ -674,7 +802,7 @@ describe("ensureOnboardingPluginInstalled", () => {
674802
});
675803

676804
expect(captured).toBeDefined();
677-
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
805+
// "Remote install from npm (@openclaw/tlon)" must NOT appear: the bundled
678806
// copy is what gets enabled, so the npm hint would only confuse
679807
// users into thinking the plugin is missing.
680808
expect(captured?.options).toEqual([

src/commands/onboarding-plugin-install.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ async function promptInstallChoice(params: {
320320
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
321321
// the bundled copy is the source of truth: it is version-locked to the
322322
// current host build and is what `defaultChoice` will pick anyway (see
323-
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
323+
// `resolveInstallDefaultChoice`). Surfacing remote install options in that
324324
// case is misleading; those catalog specs only exist as fallback metadata for
325325
// non-bundled builds. Hide them so bundled channels like Tlon look identical
326326
// to Twitch / Slack in the menu.
@@ -334,13 +334,13 @@ async function promptInstallChoice(params: {
334334
if (safeClawHubSpec) {
335335
options.push({
336336
value: "clawhub",
337-
label: `Download from ClawHub (${safeClawHubSpec})`,
337+
label: formatRemoteInstallChoiceLabel("clawhub", safeClawHubSpec),
338338
});
339339
}
340340
if (safeNpmSpec) {
341341
options.push({
342342
value: "npm",
343-
label: `Download from npm (${safeNpmSpec})`,
343+
label: formatRemoteInstallChoiceLabel("npm", safeNpmSpec),
344344
});
345345
}
346346
if (params.localPath) {
@@ -420,6 +420,11 @@ function isTimeoutError(error: unknown): boolean {
420420
return error instanceof Error && error.message === "timeout";
421421
}
422422

423+
function formatRemoteInstallChoiceLabel(source: "clawhub" | "npm", spec: string): string {
424+
const sourceLabel = source === "clawhub" ? "ClawHub" : "npm";
425+
return `Remote install from ${sourceLabel} (${spec})`;
426+
}
427+
423428
async function applyPluginEnablement(params: {
424429
cfg: OpenClawConfig;
425430
pluginId: string;

src/flows/channel-setup.status.test.ts

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh
1212
type FormatChannelSelectionLine =
1313
typeof import("../channels/registry.js").formatChannelSelectionLine;
1414
type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured;
15+
type ResolveBundledPluginSources =
16+
typeof import("../plugins/bundled-sources.js").resolveBundledPluginSources;
17+
type FindBundledPluginSourceInMap =
18+
typeof import("../plugins/bundled-sources.js").findBundledPluginSourceInMap;
1519
type NoteChannelPrimerChannels = Parameters<
1620
typeof import("./channel-setup.status.js").noteChannelPrimer
1721
>[1];
@@ -33,6 +37,21 @@ const formatChannelSelectionLine = vi.hoisted(() =>
3337
vi.fn<FormatChannelSelectionLine>((meta) => `${meta.label}${meta.blurb}`),
3438
);
3539
const isChannelConfigured = vi.hoisted(() => vi.fn<IsChannelConfigured>(() => false));
40+
const resolveBundledPluginSources = vi.hoisted(() =>
41+
vi.fn<ResolveBundledPluginSources>(() => new Map()),
42+
);
43+
const findBundledPluginSourceInMap = vi.hoisted(() =>
44+
vi.fn<FindBundledPluginSourceInMap>(({ bundled, lookup }) => {
45+
const value = lookup.value.trim();
46+
if (!value) {
47+
return undefined;
48+
}
49+
if (lookup.kind === "pluginId") {
50+
return bundled.get(value);
51+
}
52+
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
53+
}),
54+
);
3655

3756
vi.mock("../channels/chat-meta.js", () => ({
3857
listChatChannels: () => listChatChannels(),
@@ -62,20 +81,20 @@ vi.mock("../config/channel-configured.js", () => ({
6281
) => isChannelConfigured(cfg, channelId),
6382
}));
6483

65-
// Avoid touching the real `extensions/<id>` tree from unit tests. Status
66-
// rendering for installable catalog entries asks `bundled-sources` whether
67-
// a plugin already lives in-tree to decide between
68-
// "install plugin to enable" vs "bundled · enable to use". For these tests
69-
// we want the installable-catalog branch unconditionally, so we stub the
70-
// bundled lookup to "nothing is bundled".
84+
// Avoid touching the real `extensions/<id>` tree from unit tests. Tests opt
85+
// into bundled-source entries explicitly when they cover bundled catalog
86+
// rendering; the default fixture behaves as if nothing is bundled.
7187
vi.mock("../plugins/bundled-sources.js", () => ({
72-
resolveBundledPluginSources: () => new Map(),
73-
findBundledPluginSourceInMap: () => undefined,
88+
resolveBundledPluginSources: (params: Parameters<ResolveBundledPluginSources>[0]) =>
89+
resolveBundledPluginSources(params),
90+
findBundledPluginSourceInMap: (params: Parameters<FindBundledPluginSourceInMap>[0]) =>
91+
findBundledPluginSourceInMap(params),
7492
}));
7593

7694
import {
7795
collectChannelStatus,
7896
noteChannelPrimer,
97+
resolveCatalogChannelSelectionHint,
7998
resolveChannelSelectionNoteLines,
8099
resolveChannelSetupSelectionContributions,
81100
} from "./channel-setup.status.js";
@@ -93,6 +112,17 @@ describe("resolveChannelSetupSelectionContributions", () => {
93112
);
94113
formatChannelSelectionLine.mockImplementation((meta) => `${meta.label}${meta.blurb}`);
95114
isChannelConfigured.mockReturnValue(false);
115+
resolveBundledPluginSources.mockReturnValue(new Map());
116+
findBundledPluginSourceInMap.mockImplementation(({ bundled, lookup }) => {
117+
const value = lookup.value.trim();
118+
if (!value) {
119+
return undefined;
120+
}
121+
if (lookup.kind === "pluginId") {
122+
return bundled.get(value);
123+
}
124+
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
125+
});
96126
});
97127

98128
it("sorts channels alphabetically by picker label", () => {
@@ -158,6 +188,67 @@ describe("resolveChannelSetupSelectionContributions", () => {
158188
]);
159189
});
160190

191+
it("describes installable catalog choices as remote npm installs", () => {
192+
expect(
193+
resolveCatalogChannelSelectionHint({
194+
install: { npmSpec: "@openclaw/googlechat" },
195+
}),
196+
).toBe("remote install from npm: @openclaw/googlechat");
197+
});
198+
199+
it("sanitizes remote npm install hints", () => {
200+
expect(
201+
resolveCatalogChannelSelectionHint({
202+
install: { npmSpec: "@openclaw/googlechat\u001B[31m\nbeta" },
203+
}),
204+
).toBe("remote install from npm: @openclaw/googlechat\\nbeta");
205+
});
206+
207+
it("suppresses remote install hints for bundled channels", () => {
208+
expect(
209+
resolveCatalogChannelSelectionHint(
210+
{
211+
install: { npmSpec: "@openclaw/googlechat" },
212+
},
213+
{ bundledLocalPath: "extensions/googlechat" },
214+
),
215+
).toBe("");
216+
});
217+
218+
it("renders bundled catalog statuses without remote install hints", async () => {
219+
const entry = makeCatalogEntry("slack", "Slack", {
220+
pluginId: "@openclaw/slack",
221+
install: { npmSpec: "@openclaw/slack" },
222+
});
223+
listChatChannels.mockReturnValue([]);
224+
resolveBundledPluginSources.mockReturnValue(
225+
new Map([
226+
[
227+
"@openclaw/slack",
228+
{
229+
pluginId: "@openclaw/slack",
230+
localPath: "extensions/slack",
231+
npmSpec: "@openclaw/slack",
232+
},
233+
],
234+
]),
235+
);
236+
resolveChannelSetupEntries.mockReturnValue(
237+
makeChannelSetupEntries({
238+
installableCatalogEntries: [entry],
239+
}),
240+
);
241+
242+
const summary = await collectChannelStatus({
243+
cfg: {} as never,
244+
accountOverrides: {},
245+
installedPlugins: [],
246+
});
247+
248+
expect(summary.statusLines).toEqual(["Slack: bundled · enable to use"]);
249+
expect(summary.statusByChannel.get("slack")?.selectionHint).toBe("");
250+
});
251+
161252
it("combines real status and disabled hints when available", () => {
162253
const contributions = resolveChannelSetupSelectionContributions({
163254
entries: [

0 commit comments

Comments
 (0)