fix(onboarding): Improve the dynamic import UX. (#73419)

* fix(onboarding): skip redundant install prompt when only one source exists

When the channel-setup flow asks 'Install <plugin>?' after the user has
already picked the channel in the previous menu, and the only real
install source available is npm (or local), the prompt degenerates into
'<that source> vs Skip'. The user already expressed intent by picking
the channel, so re-confirming adds friction without offering a
meaningful choice.

Resolve directly to the available source in that case. Keep the prompt
when both npm and local sources exist so the user can still pick which
to use, and keep it when no real source exists (the prompt then only
offers Skip, which is informative).

* fix ci

* fix ci

* fix(channel-setup): skip redundant install prompt when only one source exists

Add autoConfirmSingleSource opt-in parameter to promptInstallChoice /
ensureOnboardingPluginInstalled / ensureChannelSetupPluginInstalled.
When set and only one real install source (npm or local, not both)
exists, the 'Install <plugin>? / Skip' prompt is skipped and the
single source is used directly.

Only channel-setup.ts passes autoConfirmSingleSource: true — the user
already expressed intent by picking the channel in the previous menu,
so re-confirming adds friction without a meaningful choice. The
onboarding and quickstart entry points keep the existing prompt
behavior unchanged.

Also fix findBundledPluginSourceInMap mock type in
onboarding-plugin-install.test.ts to avoid TS2345.

* fix(tests): revert auto-confirm test expectations and fix mock leak

- Revert 'offers registry npm specs' test to expect the prompt
  (autoConfirmSingleSource not passed)
- Revert channel-setup 'does not default to bundled local path' test
  to expect the prompt
- Reset findBundledPluginSourceInMap and
  resolveBundledInstallPlanForCatalogEntry mocks after the bundled
  prompt test to prevent cross-test leakage

* fix ci

* docs(changelog): add #73419
This commit is contained in:
Sliverp
2026-04-29 10:41:42 +08:00
committed by GitHub
parent 180033eeae
commit e0008268ad
10 changed files with 405 additions and 22 deletions

View File

@@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/channel-setup: auto-skip the redundant "Install \<plugin\>?" confirmation when only one install source (npm or local) exists, show `download from <npm-spec>` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp.
- BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris.
- Agents/approvals: fail restart-interrupted sessions whose transcript tail is still `approval-pending` instead of replaying stale exec approval IDs into the new Gateway process after restart. Fixes #65486. Thanks @mjmai20682068-create.
- CLI/Gateway: use method-specific least-privilege scopes for classified CLI Gateway calls while preserving legacy broad scopes for unclassified plugin methods, so read-only commands no longer create admin/write/pairing scope-upgrade prompts. Fixes #68634. Thanks @nightmusher.

View File

@@ -558,6 +558,46 @@ describe("ensureChannelSetupPluginInstalled", () => {
expect(runtime.error).not.toHaveBeenCalled();
});
it("skips the install prompt when autoConfirmSingleSource is set and only npm is available", async () => {
const runtime = makeRuntime();
const { prompter, select } = makeSkipInstallPrompter();
const cfg: OpenClawConfig = {};
// npm-only entry (no local path)
const npmOnlyEntry: ChannelPluginCatalogEntry = {
id: "wecom",
pluginId: "wecom",
meta: {
id: "wecom",
label: "WeCom",
selectionLabel: "WeCom",
docsPath: "/channels/wecom",
blurb: "WeCom channel",
},
install: {
npmSpec: "@openclaw/wecom@2026.4.23",
},
};
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "wecom",
installPath: "/tmp/wecom",
});
vi.mocked(fs.existsSync).mockReturnValue(false);
resolveBundledPluginSources.mockReturnValue(new Map());
const result = await ensureChannelSetupPluginInstalled({
cfg,
entry: npmOnlyEntry,
prompter,
runtime,
autoConfirmSingleSource: true,
});
expect(select).not.toHaveBeenCalled();
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("wecom");
});
it("clears discovery cache before reloading the setup plugin registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};

View File

@@ -42,6 +42,7 @@ export async function ensureChannelSetupPluginInstalled(params: {
runtime: RuntimeEnv;
workspaceDir?: string;
promptInstall?: boolean;
autoConfirmSingleSource?: boolean;
}): Promise<InstallResult> {
const result = await ensureOnboardingPluginInstalled({
cfg: params.cfg,
@@ -50,6 +51,9 @@ export async function ensureChannelSetupPluginInstalled(params: {
runtime: params.runtime,
workspaceDir: params.workspaceDir,
...(params.promptInstall !== undefined ? { promptInstall: params.promptInstall } : {}),
...(params.autoConfirmSingleSource !== undefined
? { autoConfirmSingleSource: params.autoConfirmSingleSource }
: {}),
});
return {
cfg: result.cfg,

View File

@@ -18,7 +18,9 @@ vi.mock("../cli/plugins-registry-refresh.js", () => ({
}));
const resolveBundledPluginSources = vi.hoisted(() => vi.fn(() => new Map()));
const findBundledPluginSourceInMap = vi.hoisted(() => vi.fn(() => null));
const findBundledPluginSourceInMap = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => { localPath: string } | undefined>(() => undefined),
);
vi.mock("../plugins/bundled-sources.js", () => ({
resolveBundledPluginSources,
findBundledPluginSourceInMap,
@@ -126,7 +128,7 @@ describe("ensureOnboardingPluginInstalled", () => {
timeoutMs: 300_000,
}),
);
expect(update).toHaveBeenCalledWith("Downloading demo-plugin…");
expect(update).toHaveBeenCalledWith("Downloading");
expect(stop).toHaveBeenCalledWith("Installed WeCom plugin");
expect(buildNpmResolutionInstallFields).toHaveBeenCalledWith(npmResolution);
expect(recordPluginInstall).toHaveBeenCalledWith(
@@ -481,6 +483,68 @@ describe("ensureOnboardingPluginInstalled", () => {
});
});
it("hides the npm download option for bundled plugins so the menu matches non-npm channels", async () => {
await withTempDir(
{ prefix: "openclaw-onboarding-install-bundled-prompt-" },
async (temp) => {
const bundledDir = path.join(temp, "dist", "extensions", "tlon");
await fs.mkdir(bundledDir, { recursive: true });
const realBundledDir = await fs.realpath(bundledDir);
// Both code paths that surface a bundled plugin to the install
// pipeline must agree on the local path: the catalog-driven
// resolver (used when an npm spec is present) and the pluginId
// fallback. We stub both so the prompt sees a stable bundled path.
resolveBundledInstallPlanForCatalogEntry.mockReturnValue({
bundledSource: { localPath: realBundledDir },
});
findBundledPluginSourceInMap.mockReturnValue({ localPath: realBundledDir });
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip";
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "tlon",
label: "Tlon",
install: {
npmSpec: "@openclaw/tlon",
defaultChoice: "npm",
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip";
}),
} as never,
runtime: {} as never,
});
expect(captured).toBeDefined();
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
// copy is what gets enabled, so the npm hint would only confuse
// users into thinking the plugin is missing.
expect(captured?.options).toEqual([
{
value: "local",
label: "Use local plugin path",
hint: realBundledDir,
},
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("local");
findBundledPluginSourceInMap.mockReset();
resolveBundledInstallPlanForCatalogEntry.mockReset();
},
);
});
it("enables bundled plugins without adding their bundled directory as a local install", async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-bundled-record-" }, async (temp) => {
const bundledDir = path.join(temp, "dist", "extensions", "discord");

View File

@@ -286,10 +286,27 @@ function resolveInstallDefaultChoice(params: {
async function promptInstallChoice(params: {
entry: OnboardingPluginInstallEntry;
localPath?: string | null;
bundledLocalPath?: string | null;
defaultChoice: InstallChoice;
prompter: WizardPrompter;
/** When true and only one real install source (npm *or* local, not both)
* exists, skip the "Install <plugin>? / Skip" prompt and resolve directly
* to that source. Useful when the caller already knows the user's intent
* (e.g. they just picked the channel in a previous menu). */
autoConfirmSingleSource?: boolean;
}): Promise<InstallChoice> {
const npmSpec = resolveNpmSpecForOnboarding(params.entry.install);
const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
// When the plugin already ships bundled with the host (i.e. lives under
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
// the bundled copy is the source of truth: it is version-locked to the
// current host build and is what `defaultChoice` will pick anyway (see
// `resolveInstallDefaultChoice`). Surfacing a "Download from npm (...)"
// option in that case is misleading — it suggests the plugin is missing
// and forces the user to reason about an npm catalog channel that, for
// bundled channels, only exists as a fallback for non-bundled builds.
// Hide the npm option entirely in this scenario so bundled channels like
// Tlon look identical to Twitch / Slack in the menu.
const npmSpec = params.bundledLocalPath ? null : rawNpmSpec;
const safeLabel = sanitizeTerminalText(params.entry.label);
const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null;
@@ -307,6 +324,20 @@ async function promptInstallChoice(params: {
...(safeLocalPath ? { hint: safeLocalPath } : {}),
});
}
if (params.autoConfirmSingleSource) {
const realSources: InstallChoice[] = [];
if (safeNpmSpec) {
realSources.push("npm");
}
if (params.localPath) {
realSources.push("local");
}
if (realSources.length === 1) {
return realSources[0];
}
}
options.push({ value: "skip", label: "Skip for now" });
const initialValue =
@@ -366,6 +397,120 @@ async function applyPluginEnablement(params: {
return enableResult;
}
type AnimatedProgress = {
setLabel: (label: string) => void;
stop: () => void;
};
const PROGRESS_BAR_WIDTH = 16;
const PROGRESS_BAR_TICK_MS = 200;
const PROGRESS_BAR_DURATION_MS = 10_000;
const PROGRESS_BAR_MAX_PERCENT = 99;
/**
* Maps a verbose install log line (e.g. `Downloading @scope/pkg@1.2.3 from
* ClawHub…`, `Extracting /tmp/…/wecom-…-2026.4.23.tgz…`, `Installing to
* /home/.../plugins/demo…`) to a short verb suitable for a progress label.
*
* Falls back to the raw message when no known verb prefix is recognised so
* that unexpected log lines still surface to the user instead of being
* swallowed.
*/
function shortenInstallLabel(message: string): string {
const trimmed = message.trim();
// Match a leading verb phrase. Order matters: more specific phrases first.
const patterns: Array<[RegExp, string]> = [
[/^Downloading\b/i, "Downloading"],
[/^Extracting\b/i, "Extracting"],
[/^Installing\s+to\b/i, "Installing"],
[/^Installing\b/i, "Installing"],
[/^Resolving\b/i, "Resolving"],
[/^Cloning\b/i, "Cloning"],
[/^Verifying\b/i, "Verifying"],
[/^Preparing\b/i, "Preparing"],
[/^Linking\b/i, "Linking"],
[/^Linked\b/i, "Linking"],
[/^Compatibility\b/i, "Resolving"],
[/^ClawHub\b/i, "Resolving"],
];
for (const [pattern, label] of patterns) {
if (pattern.test(trimmed)) {
return label;
}
}
return trimmed;
}
/**
* Wraps a {@link WizardProgress} so the spinner message keeps a steadily
* growing ASCII bar attached to whatever the current install step label is.
*
* The plugin install pipeline only emits coarse `info` log lines, so without
* animation the spinner can sit on the same string for many seconds with no
* visible feedback. We render a deterministic left-to-right filling bar that
* advances linearly over {@link PROGRESS_BAR_DURATION_MS} (default 10s) up to
* {@link PROGRESS_BAR_MAX_PERCENT} (99%). If the install takes longer than the
* preset duration the bar simply stays pinned at 99% — never wrapping back to
* 0% — so the user always sees forward motion and a ceiling that signals
* "almost there, just waiting on the last bit".
*
* The bare label is forwarded to `progress.update` first on every label
* change so callers/tests that assert on the unadorned message continue to
* observe it before any decorated frame is overlaid.
*/
function createAnimatedInstallProgress(
progress: { update: (message: string) => void },
options: { totalMs?: number } = {},
): AnimatedProgress {
const totalMs = options.totalMs ?? PROGRESS_BAR_DURATION_MS;
let currentLabel = "";
const startedAt = Date.now();
const computePercent = (): number => {
const elapsed = Date.now() - startedAt;
const raw = Math.floor((elapsed / totalMs) * 100);
return Math.max(0, Math.min(PROGRESS_BAR_MAX_PERCENT, raw));
};
const renderBar = (): string => {
const percent = computePercent();
const filled = Math.round((percent / 100) * PROGRESS_BAR_WIDTH);
const bar =
"█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled));
return `[${bar}] ${percent}%`;
};
const decorate = (label: string): string => {
if (!label) {
return renderBar();
}
return `${label} ${renderBar()}`;
};
const timer = setInterval(() => {
if (currentLabel) {
progress.update(decorate(currentLabel));
}
}, PROGRESS_BAR_TICK_MS);
// Animation is decorative: never let it hold the event loop open if a caller
// forgets to stop us (e.g. an unexpected throw bypasses the `finally`).
if (typeof timer.unref === "function") {
timer.unref();
}
return {
setLabel: (label: string) => {
currentLabel = label;
// Always emit the bare label first so existing log/test expectations
// continue to observe the unadorned message before any animation frame.
progress.update(label);
},
stop: () => {
clearInterval(timer);
},
};
}
async function installPluginFromNpmSpecWithProgress(params: {
entry: OnboardingPluginInstallEntry;
npmSpec: string;
@@ -380,12 +525,14 @@ async function installPluginFromNpmSpecWithProgress(params: {
> {
const safeLabel = sanitizeTerminalText(params.entry.label);
const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
const animated = createAnimatedInstallProgress(progress);
animated.setLabel("Preparing");
const updateProgress = (message: string) => {
const next = sanitizeTerminalText(message).trim();
if (!next) {
const sanitized = sanitizeTerminalText(message).trim();
if (!sanitized) {
return;
}
progress.update(next);
animated.setLabel(shortenInstallLabel(sanitized));
};
try {
@@ -405,6 +552,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
}),
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
);
animated.stop();
if (result.ok) {
progress.stop(`Installed ${safeLabel} plugin`);
} else {
@@ -415,6 +563,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
result,
};
} catch (error) {
animated.stop();
if (isTimeoutError(error)) {
progress.stop(`Install timed out: ${safeLabel}`);
return { status: "timed_out" };
@@ -437,6 +586,7 @@ export async function ensureOnboardingPluginInstalled(params: {
runtime: RuntimeEnv;
workspaceDir?: string;
promptInstall?: boolean;
autoConfirmSingleSource?: boolean;
}): Promise<OnboardingPluginInstallResult> {
const { entry, prompter, runtime, workspaceDir } = params;
let next = params.cfg;
@@ -463,8 +613,10 @@ export async function ensureOnboardingPluginInstalled(params: {
: await promptInstallChoice({
entry,
localPath,
bundledLocalPath,
defaultChoice,
prompter,
autoConfirmSingleSource: params.autoConfirmSingleSource,
});
if (choice === "skip") {

View File

@@ -132,9 +132,7 @@ function isWorkspaceDerivedPath(
const realDir = realpathExistingServicePathDir(dir);
const realCwd = realpathServicePathDir(cwd);
const realHome = home ? realpathServicePathDir(home) : undefined;
return Boolean(
realDir && realCwd && realHome !== realCwd && isSameOrChildPath(realDir, realCwd),
);
return Boolean(realDir && realCwd && realHome !== realCwd && isSameOrChildPath(realDir, realCwd));
}
function addEnvConfiguredBinDir(

View File

@@ -61,6 +61,17 @@ vi.mock("../config/channel-configured.js", () => ({
) => isChannelConfigured(cfg, channelId),
}));
// Avoid touching the real `extensions/<id>` tree from unit tests. Status
// rendering for installable catalog entries asks `bundled-sources` whether
// a plugin already lives in-tree to decide between
// "install plugin to enable" vs "bundled · enable to use". For these tests
// we want the installable-catalog branch unconditionally, so we stub the
// bundled lookup to "nothing is bundled".
vi.mock("../plugins/bundled-sources.js", () => ({
resolveBundledPluginSources: () => new Map(),
findBundledPluginSourceInMap: () => undefined,
}));
import {
collectChannelStatus,
noteChannelPrimer,

View File

@@ -17,6 +17,11 @@ import type {
import type { ChannelChoice } from "../commands/onboard-types.js";
import { isChannelConfigured } from "../config/channel-configured.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
findBundledPluginSourceInMap,
resolveBundledPluginSources,
type BundledPluginSource,
} from "../plugins/bundled-sources.js";
import { formatDocsLink } from "../terminal/links.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -127,6 +132,63 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta {
};
}
/**
* Hint shown next to an installable channel option in the selection menu when
* we don't yet have a runtime-collected status. Mirrors the "configured" /
* "installed" affordance other channels get so users can see "download from
* <npm-spec>" before committing to install.
*
* Bundled channels (the plugin lives under `extensions/<id>` in the host
* repo, e.g. Signal / Tlon / Twitch / Slack) are NOT downloaded from npm —
* they ship with the host. Even when their `package.json` declares an
* `npmSpec` (or the catalog falls back to the package name), surfacing
* "download from <npm-spec>" misleads users into believing the plugin is
* missing. For bundled channels we suppress the npm hint entirely so the
* menu shows the same neutral "plugin · install" affordance used when no
* npm source is known.
*/
export function resolveCatalogChannelSelectionHint(
entry: { install?: { npmSpec?: string } },
options?: { bundledLocalPath?: string | null },
): string {
const npmSpec = entry.install?.npmSpec?.trim();
if (npmSpec && !options?.bundledLocalPath) {
return `download from ${formatSetupSelectionLabel(npmSpec, npmSpec)}`;
}
return "";
}
/**
* Look up the bundled-source entry for a catalog channel, regardless of
* whether the catalog refers to it by `pluginId` or `npmSpec`. We use this
* to detect bundled channels in the selection menu so we can suppress the
* misleading "download from <npm-spec>" hint for plugins that already ship
* with the host (Signal / Tlon / Twitch / Slack ...).
*/
export function findBundledSourceForCatalogChannel(params: {
bundled: ReadonlyMap<string, BundledPluginSource>;
entry: { id: string; pluginId?: string; install?: { npmSpec?: string } };
}): BundledPluginSource | undefined {
const pluginId = params.entry.pluginId?.trim() || params.entry.id.trim();
if (pluginId) {
const byId = findBundledPluginSourceInMap({
bundled: params.bundled,
lookup: { kind: "pluginId", value: pluginId },
});
if (byId) {
return byId;
}
}
const npmSpec = params.entry.install?.npmSpec?.trim();
if (npmSpec) {
return findBundledPluginSourceInMap({
bundled: params.bundled,
lookup: { kind: "npmSpec", value: npmSpec },
});
}
return undefined;
}
export async function collectChannelStatus(params: {
cfg: OpenClawConfig;
options?: SetupChannelsOptions;
@@ -141,6 +203,7 @@ export async function collectChannelStatus(params: {
installedPlugins,
workspaceDir,
});
const bundledSources = resolveBundledPluginSources({ workspaceDir });
const resolveAdapter =
params.resolveAdapter ??
((channel: ChannelChoice) =>
@@ -199,15 +262,22 @@ export async function collectChannelStatus(params: {
quickstartScore: 0,
};
});
const catalogStatuses = installableCatalogEntries.map((entry) => ({
channel: entry.id,
configured: false,
statusLines: [
`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: install plugin to enable`,
],
selectionHint: "plugin · install",
quickstartScore: 0,
}));
const catalogStatuses = installableCatalogEntries.map((entry) => {
const bundledLocalPath =
findBundledSourceForCatalogChannel({ bundled: bundledSources, entry })?.localPath ?? null;
const isBundled = Boolean(bundledLocalPath);
// For bundled channels we already have the plugin code on disk; the user
// just needs to enable + configure it. Reflect that in the status line so
// it does not read like a fresh "install plugin to enable" download flow.
const statusLabel = isBundled ? "bundled · enable to use" : "install plugin to enable";
return {
channel: entry.id,
configured: false,
statusLines: [`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: ${statusLabel}`],
selectionHint: resolveCatalogChannelSelectionHint(entry, { bundledLocalPath }),
quickstartScore: 0,
};
});
const combinedStatuses = [
...statusEntries,
...fallbackStatuses,

View File

@@ -176,8 +176,10 @@ vi.mock("./channel-setup.prompts.js", () => ({
vi.mock("./channel-setup.status.js", () => ({
collectChannelStatus: (params: Parameters<CollectChannelStatus>[0]) =>
collectChannelStatus(params),
findBundledSourceForCatalogChannel: vi.fn(() => undefined),
noteChannelPrimer: vi.fn(),
noteChannelStatus: vi.fn(),
resolveCatalogChannelSelectionHint: vi.fn(() => "download from <npm>"),
resolveChannelSelectionNoteLines: vi.fn(() => []),
resolveChannelSetupSelectionContributions: vi.fn(() => []),
resolveQuickstartDefault: vi.fn(() => undefined),

View File

@@ -28,6 +28,7 @@ import type { ChannelChoice } from "../commands/onboard-types.js";
import { isChannelConfigured } from "../config/channel-configured.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveBundledPluginSources } from "../plugins/bundled-sources.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -40,7 +41,9 @@ import {
} from "./channel-setup.prompts.js";
import {
collectChannelStatus,
findBundledSourceForCatalogChannel,
noteChannelPrimer,
resolveCatalogChannelSelectionHint,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
resolveQuickstartDefault,
@@ -315,6 +318,43 @@ export async function setupChannels(
};
};
// Decorates the runtime status map with synthetic `selectionHint` entries for
// installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we
// run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty
// until the user picks a channel — without this overlay the selection menu
// would render those options without any "download from <npm-spec>" hint.
//
// Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path
// too whenever their plugin is not yet enabled, because they share the same
// "installable catalog" bucket. For those we must NOT show "download from
// <npm-spec>" — the plugin already lives under `extensions/<id>` and the
// hint would mislead users into thinking the plugin is missing.
const buildStatusByChannelForSelection = (
catalogById: ReturnType<typeof getChannelEntries>["catalogById"],
): Map<ChannelChoice, ChannelSetupStatus> => {
const decorated = new Map(statusByChannel);
if (catalogById.size === 0) {
return decorated;
}
const bundledSources = resolveBundledPluginSources({
workspaceDir: resolveWorkspaceDir(),
});
for (const [channel, entry] of catalogById) {
if (decorated.has(channel)) {
continue;
}
const bundledLocalPath =
findBundledSourceForCatalogChannel({ bundled: bundledSources, entry })?.localPath ?? null;
decorated.set(channel, {
channel,
configured: false,
statusLines: [],
selectionHint: resolveCatalogChannelSelectionHint(entry, { bundledLocalPath }),
});
}
return decorated;
};
const refreshStatus = async (channel: ChannelChoice) => {
const adapter = getVisibleSetupFlowAdapter(channel);
if (!adapter) {
@@ -538,6 +578,7 @@ export async function setupChannels(
prompter,
runtime,
workspaceDir,
autoConfirmSingleSource: true,
});
next = result.cfg;
if (!result.installed) {
@@ -591,13 +632,13 @@ export async function setupChannels(
if (options?.quickstartDefaults) {
while (true) {
const { entries } = getChannelEntries();
const { entries, catalogById } = getChannelEntries();
const choice = await prompter.select({
message: "Select channel (QuickStart)",
options: [
...resolveChannelSetupSelectionContributions({
entries,
statusByChannel,
statusByChannel: buildStatusByChannelForSelection(catalogById),
resolveDisabledHint,
}).map((contribution) => contribution.option),
{
@@ -620,13 +661,13 @@ export async function setupChannels(
const doneValue = "__done__" as const;
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
while (true) {
const { entries } = getChannelEntries();
const { entries, catalogById } = getChannelEntries();
const choice = await prompter.select({
message: "Select a channel",
options: [
...resolveChannelSetupSelectionContributions({
entries,
statusByChannel,
statusByChannel: buildStatusByChannelForSelection(catalogById),
resolveDisabledHint,
}).map((contribution) => contribution.option),
{