From d4ba0d51b9c14e631f7fd73b80b4912c081b1a17 Mon Sep 17 00:00:00 2001 From: GeekCmore Date: Fri, 4 Sep 2026 22:52:45 +0800 Subject: [PATCH 1/8] feat(interaction): /plugin marketplace command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin-market/types|catalog|installer: index parse guard with a schemaVersion check, cache-first loader with raw → jsDelivr fallback and stale serving, installer over the dsh CLI seam (npm and github: specs incl. monorepo subdirs, allowBuilds preflight, profile-patch row insert/remove, installed-state derivation) - plugin-commands: flat grouped-by-badge browse panel (Enter detail, i/u/r hotkeys), installed view with updates and removed-from-market rows, install/uninstall/info/list/refresh argument paths, restart reminders, web-only warnings - settings: mayfly.marketIndexUrl (official chain when empty) - locale: zh catalog for the family; tips row; command reference rows - specs: catalog loader and command worlds over the updater seams, including fiber-unload gates and locale re-render Co-Authored-By: Claude Code --- .../mayfly/src/interaction/commands-plugin.ts | 8 +- packages/mayfly/src/interaction/locale.ts | 34 + .../mayfly/src/interaction/plugin-commands.ts | 487 +++++++++ .../src/interaction/plugin-market/catalog.ts | 115 +++ .../interaction/plugin-market/installer.ts | 274 ++++++ .../src/interaction/plugin-market/types.ts | 105 ++ .../src/interaction/settings-command.ts | 5 + packages/mayfly/src/interaction/settings.ts | 4 + .../mayfly/src/transcript/tips-content.ts | 1 + .../tests/interaction/plugin-commands.spec.ts | 929 ++++++++++++++++++ .../tests/interaction/plugin-market.spec.ts | 178 ++++ .../interaction/settings-command.spec.ts | 3 +- .../mayfly/tests/interaction/settings.spec.ts | 2 + website/en/reference/commands.md | 1 + website/reference/commands.md | 1 + 15 files changed, 2145 insertions(+), 2 deletions(-) create mode 100644 packages/mayfly/src/interaction/plugin-commands.ts create mode 100644 packages/mayfly/src/interaction/plugin-market/catalog.ts create mode 100644 packages/mayfly/src/interaction/plugin-market/installer.ts create mode 100644 packages/mayfly/src/interaction/plugin-market/types.ts create mode 100644 packages/mayfly/tests/interaction/plugin-commands.spec.ts create mode 100644 packages/mayfly/tests/interaction/plugin-market.spec.ts diff --git a/packages/mayfly/src/interaction/commands-plugin.ts b/packages/mayfly/src/interaction/commands-plugin.ts index afa27de..2e4a4b5 100644 --- a/packages/mayfly/src/interaction/commands-plugin.ts +++ b/packages/mayfly/src/interaction/commands-plugin.ts @@ -19,7 +19,9 @@ * catalog, `/preset` over the agent-preset roster) lives in * `./tools-commands.ts` and `./preset-commands.ts`; and `/skills` (the * `#` pipeline's read-only listing) lives in `./skills-command.ts`; the - * settings panel (`/settings`) lives in `./settings-command.ts`. + * settings panel (`/settings`) lives in `./settings-command.ts`; and + * `/plugin` (the marketplace browser over the dsh-plugins index) lives in + * `./plugin-commands.ts`. * Registrations are * effect-bound, so unloading the fiber removes them. Only `commands` is * injected: the overlay commands read the Mayfly display services through @@ -53,6 +55,7 @@ import type { HelpSection } from './help.ts' import { HelpOverlay } from './help.ts' import { registerMcpCommands } from './mcp-commands.ts' import { registerModelCommands } from './model-commands.ts' +import { registerPluginCommand } from './plugin-commands.ts' import { registerPresetCommands } from './preset-commands.ts' import { registerSessionCommands } from './session-commands.ts' import { registerExportCommands } from './session-export.ts' @@ -469,6 +472,8 @@ export function apply(ctx: Context, config: Config = {}): void { const trace = registerTraceCommand(ctx) // `/update` is the crash-safe, preflighted profile swap. const update = registerUpdateCommand(ctx) + // `/plugin` browses the marketplace index and installs or removes plugins. + const pluginMarket = registerPluginCommand(ctx) const settings = registerSettingsCommand(ctx) return () => { quit() @@ -491,6 +496,7 @@ export function apply(ctx: Context, config: Config = {}): void { mcpBrowser() trace() update() + pluginMarket() settings() } }) diff --git a/packages/mayfly/src/interaction/locale.ts b/packages/mayfly/src/interaction/locale.ts index 478e228..3b41547 100644 --- a/packages/mayfly/src/interaction/locale.ts +++ b/packages/mayfly/src/interaction/locale.ts @@ -199,6 +199,40 @@ const zh: Readonly> = { 'uninstalled; restart Mayfly to apply': '已移除;重启 Mayfly 后生效', 'installed; restart Mayfly to apply, then run /plugin verify {plugin}': '已安装;重启 Mayfly 后生效,再运行 /plugin verify {plugin}', 'catalog refresh failed: {message}': '插件目录刷新失败:{message}', + // The active /plugin family (the marketplace browser). The block above is + // a superseded earlier draft kept only because removing shipped keys is a + // breaking change for translated builds. + 'Browse, install, and remove plugins': '浏览、安装与管理插件', + 'Plugin marketplace': '插件市场', + 'loading catalog...': '正在加载插件目录…', + 'marketplace is offline: {message}': '插件市场离线:{message}', + 'refresh failed: {message}': '刷新失败:{message}', + 'refreshed {count} entries': '已刷新 {count} 个条目', + 'unknown plugin: {id}': '未知插件:{id}', + 'installing "{name}"...': '正在安装 "{name}"…', + 'removing "{name}"...': '正在移除 "{name}"…', + 'install failed: {message}': '安装失败:{message}', + 'uninstall failed: {message}': '移除失败:{message}', + 'installed; restart Mayfly and start a new session to apply': '已安装;重启 Mayfly 并新建会话后生效', + 'removed; restart Mayfly and start a new session to apply': '已移除;重启 Mayfly 并新建会话后生效', + 'web-only plugin: it contributes nothing in this terminal frontend': '仅 Web 插件:在终端前端无作用', + 'no plugins installed': '尚未安装插件', + 'Overview': '概览', + 'Surfaces': '前端贡献', + 'Provides': '提供', + 'Tools': '工具', + 'Source': '来源', + 'Engines': '运行环境', + 'Verified': '审核', + 'Install command': '安装命令', + 'Links': '链接', + 'works here': '本终端可用', + 'no contribution in this terminal': '本终端无贡献', + 'works on dsh Web': 'dsh Web 可用', + 'no contribution on dsh Web': 'dsh Web 无贡献', + 'update available: {version}': '有新版本:{version}', + 'removed from the market': '已从市场移除', + 'Uninstall': '卸载', 'Approve {tool}?': '是否批准 {tool}?', 'Question {current} of {total}': '问题 {current}/{total}', 'Other': '其他', diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts new file mode 100644 index 0000000..87baa2a --- /dev/null +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -0,0 +1,487 @@ +/** + * The `/plugin` command family: the marketplace browser over the index + * published by Ephemeral-AI-Lab/dsh-plugins (`dist/index.json`). `/plugin` + * opens a grouped, type-to-filter catalog — Enter opens the read-only + * detail panel, `i` installs, `u` removes, `r` refreshes; `/plugin list` + * shows what the profile carries, what has updates, and what left the + * market; `install [--source npm|github]`, `uninstall `, + * `info `, and `refresh` run the argument paths directly. Installs and + * removals shell out to `dsh plugin --profile add|remove` — the same + * seam the updater's swap uses — then remind that bundle membership is a + * startup boundary: restart and start a new session. The catalog loads + * cache-first and still serves stale data offline. + * + * @module @ephemeral-ai/mayfly/interaction/plugin-commands + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Action } from '../frontend/index.ts' +import { displayServices } from './display-services.ts' +import { getSharedEditor } from './editor-instance.ts' +import { mountEditorReplacement } from './editor-panel-controller.ts' +import { CanonicalDocumentController, type FrontendPanelDocument, type FrontendPanelItem } from './frontend-panel.ts' +import { InfoPanel, type InfoSection, type InfoSegment } from './info-panel.ts' +import { interactionTranslator, observeInteractionLocale } from './locale.ts' +import { currentMayflySettings } from './settings.ts' +import { DEFAULT_MARKET_INDEX_URL, loadMarketCatalog, type CatalogResult } from './plugin-market/catalog.ts' +import { + entryInstallStates, + entrySupportsSource, + installEntry, + readInstalledPlugins, + rowSpec, + uninstallEntry, + type EntryInstallState, + type InstallSource, + type InstalledPlugin, +} from './plugin-market/installer.ts' +import type { MarketEntry } from './plugin-market/types.ts' +import { findDshBin, profileNameFromArgv, profileRoot } from './updater/profile.ts' + +/** Command outcome reused by every early-exit branch. */ +type CommandOutcome = { readonly kind: 'success', readonly text?: string } | { readonly kind: 'error', readonly text: string } + +/** + * Register `/plugin`. + * @param ctx - the interaction context. + * @returns the disposer removing the command. + */ +export function registerPluginCommand(ctx: Context): () => void { + const t = interactionTranslator(ctx) + /** Set when this fiber unloads: awaits must gate continuations on it. */ + let unloaded = false + ctx.effect(() => () => { + unloaded = true + }) + /** The loaded catalog; `undefined` until the first load settles. */ + let catalog: CatalogResult | undefined + /** Plugins the profile carries; reread after every install or removal. */ + let installed: readonly InstalledPlugin[] = [] + /** One install or removal at a time, like the updater's in-flight guard. */ + let operationInFlight = false + + /** The active UI locale, for entry descriptions that ship both languages. */ + const locale = (): 'zh' | 'en' => ctx.get('mayflyLocale')?.snapshot.locale ?? 'en' + + /** The configured index URL; the empty default means the official chain. */ + const indexUrl = (): string => currentMayflySettings(ctx).marketIndexUrl || DEFAULT_MARKET_INDEX_URL + + /** Load (or force-reload) the catalog and refresh derived profile state. */ + const reload = (force: boolean): Promise => + loadMarketCatalog(indexUrl(), force).then(result => { + if (unloaded) return result + catalog = result + installed = readInstalledPlugins(profileRoot(profileNameFromArgv(process.argv))) + return result + }) + + /** Entries currently on hand (empty while offline or unloaded). */ + const entries = (): readonly MarketEntry[] => + catalog !== undefined && catalog.status !== 'offline' ? catalog.index.entries : [] + + /** Install state per entry id. */ + const states = (): Readonly> => entryInstallStates(entries(), installed) + + /** One-line description in the active locale. */ + const describe = (entry: MarketEntry): string => + locale() === 'zh' && entry.descriptionZh !== undefined ? entry.descriptionZh : entry.description + + /** Which frontends the entry contributes its own UI to. */ + const surfaceBadge = (entry: MarketEntry): string => { + const parts: string[] = [] + if (entry.surfaces.tui !== undefined) parts.push('TUI') + if (entry.surfaces.web !== undefined) parts.push('Web') + if (entry.surfaces.server !== undefined) parts.push('Server') + return parts.length === 0 ? '—' : parts.join('+') + } + + /** Composite row badge: tier, surfaces, install state, status. */ + const badgeOf = (entry: MarketEntry, state: EntryInstallState | undefined): string => { + const pieces = [entry.source, surfaceBadge(entry)] + if (state?.installed === true) pieces.push(state.updateAvailable === true ? `up ${state.version ?? ''}`.trim() : 'installed') + if (entry.status === 'beta' || entry.status === 'unstable' || entry.status === 'deprecated') pieces.push(entry.status) + return pieces.join(' · ') + } + + /** Whether the entry contributes anything to this terminal frontend. */ + const usefulInTui = (entry: MarketEntry): boolean => + entry.surfaces.server !== undefined || entry.surfaces.tui !== undefined + + /** Find an entry by marketplace id or by one of its row package names. */ + const findEntry = (id: string): MarketEntry | undefined => + entries().find(entry => entry.id === id || entry.install.rows.some(row => row.name === id)) + + /** Reread the profile dependencies after an operation. */ + const refreshInstalled = (): void => { + installed = readInstalledPlugins(profileRoot(profileNameFromArgv(process.argv))) + } + + /** + * Run one install or removal through the dsh CLI seam. Shared by the key + * handlers and the argument paths so warnings, notices, and the in-flight + * guard stay identical. + */ + async function operate(entry: MarketEntry, action: 'install' | 'uninstall', source: InstallSource): Promise { + if (operationInFlight) { + getSharedEditor(ctx)?.notice?.('a plugin operation is already running') + return + } + // Claim before the first await so overlapping keypresses cannot both run. + operationInFlight = true + try { + const dshBin = await findDshBin() + if (unloaded) return + if (dshBin === undefined) { + getSharedEditor(ctx)?.notice?.('plugin operations need the dsh CLI on PATH (or $DSH_BIN)') + return + } + if (action === 'install' && entrySupportsSource(entry, source) === false) { + getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" has no ${source} install source`) + return + } + getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'installing "{name}"...' : 'removing "{name}"...', { name: entry.displayName })) + const input = { dshBin, profile: profileNameFromArgv(process.argv), root: profileRoot(profileNameFromArgv(process.argv)), entry, source } + const outcome = action === 'install' ? await installEntry(input) : await uninstallEntry(input) + if (unloaded) return + if (outcome.kind === 'error') { + getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'install failed: {message}' : 'uninstall failed: {message}', { message: outcome.text })) + return + } + refreshInstalled() + getSharedEditor(ctx)?.notice?.(t(action === 'install' + ? 'installed; restart Mayfly and start a new session to apply' + : 'removed; restart Mayfly and start a new session to apply')) + } finally { + operationInFlight = false + } + } + + /** The copyable manual install command for an entry's default source. */ + const installCommand = (entry: MarketEntry): string => { + const row = entry.install.rows[0] + const spec = row === undefined ? undefined : row.npm?.spec ?? rowSpec(row, 'github') + return spec === undefined ? `dsh plugin --profile add <${entry.id}>` : `dsh plugin --profile add ${spec}` + } + + /** The read-only detail panel for one entry. */ + function detailPanel(entry: MarketEntry, state: EntryInstallState | undefined, onClose: () => void): InfoPanel { + const display = displayServices(ctx) + const segments = (text: string, style?: InfoSegment['style']): InfoSegment[] => [{ text, ...(style === undefined ? {} : { style }) }] + const tuiFull = usefulInTui(entry) + const webFull = entry.surfaces.web !== undefined || entry.surfaces.server !== undefined + const sections: InfoSection[] = [ + { + heading: t('Overview'), + rows: [ + { + label: t('Status'), + segments: [ + { text: entry.status, style: entry.status === 'stable' ? 'success' : 'warning' }, + ...(entry.statusNote === undefined ? [] : [{ text: ` — ${entry.statusNote}`, style: 'textMuted' as const }]), + ], + }, + { label: t('Source'), segments: segments(entry.source) }, + { label: t('Version'), segments: segments(state?.installed === true ? (state.version ?? 'installed') : (entry.verified?.packages[0]?.version ?? 'unknown')) }, + ...(state?.updateAvailable === true && state.version !== undefined + ? [{ label: '', segments: segments(t('update available: {version}', { version: state.version }), 'warning') }] + : []), + { label: '', segments: segments(describe(entry), 'textMuted') }, + ], + }, + { + heading: t('Surfaces'), + rows: [ + { label: 'TUI', segments: segments(tuiFull ? t('works here') : t('no contribution in this terminal'), tuiFull ? 'success' : 'warning') }, + { label: 'Web', segments: segments(webFull ? t('works on dsh Web') : t('no contribution on dsh Web'), webFull ? 'success' : 'warning') }, + ], + }, + { + heading: t('Provides'), + rows: (entry.provides?.tools ?? []).length + (entry.provides?.commands ?? []).length === 0 + ? [{ label: '', segments: segments(t('none declared'), 'textMuted') }] + : [ + ...(entry.provides?.tools ?? []).map(tool => ({ label: t('Tools'), segments: segments(tool) })), + ...(entry.provides?.commands ?? []).map(command => ({ label: t('Commands'), segments: segments(command) })), + ], + }, + { + heading: t('Details'), + rows: [ + ...(entry.engines === undefined ? [] : [{ + label: t('Engines'), + segments: [entry.engines.dsh, entry.engines.mayfly, entry.engines.node] + .filter((value): value is string => value !== undefined) + .map(value => ({ text: value })), + }]), + ...(entry.capabilities === undefined || entry.capabilities.length === 0 ? [] : [{ + label: t('Capabilities'), + segments: segments(entry.capabilities.join(', ')), + }]), + ...(entry.verified === undefined ? [] : [{ + label: t('Verified'), + segments: segments(`${entry.verified.at} · ${entry.verified.packages.map(pkg => `${pkg.name}@${pkg.version}`).join(', ')}`), + }]), + { label: t('Install command'), segments: segments(installCommand(entry), 'accent') }, + ...(entry.links?.repo === undefined ? [] : [{ label: t('Links'), segments: segments(entry.links.repo, 'accent') }]), + ], + }, + ] + return new InfoPanel({ + theme: display!.theme, + components: display!.components, + keymap: display!.keymap, + title: entry.displayName, + sections, + onClose, + t, + }) + } + + /** + * Open the browse panel. `mode` selects the catalog (grouped by source + * tier) or the installed view (grouped by installed / updates / removed). + */ + function openBrowse(mode: 'catalog' | 'installed'): CommandOutcome { + const display = displayServices(ctx) + if (display === undefined) { + return { kind: 'error', text: 'plugin browser is unavailable: the Mayfly screen is not mounted' } + } + + /** Rows for the catalog mode: live entries except tombstones. */ + const catalogItems = (): readonly FrontendPanelItem[] => + entries().filter(entry => entry.status !== 'removed').map(entry => { + const badge = badgeOf(entry, states()[entry.id]) + return { + id: entry.id, + label: entry.displayName, + detail: describe(entry), + ...(badge === '' ? {} : { badge }), + group: entry.source, + action: { kind: 'plugin-market/details', id: entry.id }, + actionLabel: t('Details'), + } + }) + + /** Rows for the installed mode: profile deps joined against the index. */ + const installedItems = (): readonly FrontendPanelItem[] => { + const byName = new Map(entries().flatMap(entry => entry.install.rows.map(row => [row.name, entry] as const))) + const state = states() + const rank = { installed: 0, updates: 1, removed: 2 } as const + const rows = installed.flatMap((plugin): readonly FrontendPanelItem[] => { + const entry = byName.get(plugin.name) + if (entry === undefined) { + // Not in the index at all: the plugin left the market (or predates it). + const pieces = [plugin.version, 'removed'].filter((piece): piece is string => piece !== undefined) + return [{ + id: plugin.name, + label: plugin.name, + detail: plugin.spec, + ...(pieces.length === 0 ? {} : { badge: pieces.join(' · ') }), + group: 'removed', + }] + } + const entryState = state[entry.id] + const removed = entry.status === 'removed' + const update = entryState?.updateAvailable === true + const latest = entry.npm?.[plugin.name]?.latestVersion + const pieces = [ + plugin.version, + update && latest !== null && latest !== undefined ? `up ${latest}` : undefined, + removed ? 'removed' : undefined, + ].filter((piece): piece is string => piece !== undefined) + return [{ + id: entry.id, + label: entry.displayName, + detail: removed ? (entry.statusNote ?? t('removed from the market')) : describe(entry), + ...(pieces.length === 0 ? {} : { badge: pieces.join(' · ') }), + group: removed ? 'removed' : update ? 'updates' : 'installed', + action: { kind: 'plugin-market/details', id: entry.id }, + actionLabel: t('Details'), + }] + }) + return [...rows].sort((a, b) => (rank[a.group as keyof typeof rank] ?? 0) - (rank[b.group as keyof typeof rank] ?? 0)) + } + + const model = (): FrontendPanelDocument => { + if (catalog === undefined) { + return { mode: 'loading', title: t('Plugin marketplace'), view: { kind: 'text', content: t('loading catalog...') } } + } + if (catalog.status === 'offline') { + return { mode: 'error', title: t('Plugin marketplace'), view: { kind: 'text', content: t('marketplace is offline: {message}', { message: catalog.message }) } } + } + // One flat list: the tier rides in the badge and the index order sorts + // official → dsh → community, so no tab row comes between focus and + // the rows (Enter on a row opens its detail, the trace-panel pattern). + const items = mode === 'catalog' ? catalogItems() : installedItems() + return { + mode: 'select', + title: t('Plugin marketplace'), + items, + filterable: true, + empty: mode === 'catalog' + ? { title: t('No plugins indexed') } + : { title: t('no plugins installed') }, + } + } + + /** Install or remove the entry an `i`/`u` keypress selected. */ + const runOperation = (id: string, action: 'install' | 'uninstall'): void => { + const entry = findEntry(id) + if (entry === undefined) return + if (action === 'uninstall' && states()[entry.id]?.installed !== true) { + getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" is not installed in this profile`) + return + } + if (action === 'install' && usefulInTui(entry) === false) { + getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) + } + void operate(entry, action, 'npm').then(() => { + if (unloaded) return + panel.invalidate() + display.screen.requestRender() + }) + } + + /** Mount the detail panel for one entry above the browse panel. */ + const openDetail = (id: string): void => { + const entry = findEntry(id) + /* v8 ignore next -- detail actions only ever carry entry ids from rows */ + if (entry === undefined) return + let restoreDetail: () => void + let offDetail: () => void + const detail = detailPanel(entry, states()[entry.id], () => { + offDetail() + restoreDetail() + }) + restoreDetail = mountEditorReplacement(ctx, detail) + offDetail = observeInteractionLocale(ctx, () => { + detail.invalidate() + display.screen.requestRender() + }) + } + + const handleAction = (action: Action): void => { + if (action.kind === 'plugin-market/details') openDetail(String(action.id)) + else if (action.kind === 'plugin-market/install') runOperation(String(action.id), 'install') + else if (action.kind === 'plugin-market/uninstall') runOperation(String(action.id), 'uninstall') + else if (action.kind === 'plugin-market/refresh') { + void reload(true).then(result => { + if (unloaded) return + if (result.status === 'offline') { + getSharedEditor(ctx)?.notice?.(t('refresh failed: {message}', { message: result.message })) + } + panel.invalidate() + display.screen.requestRender() + }) + } + } + + let restore: () => void + const panel = new CanonicalDocumentController({ + keymap: display.keymap, + theme: display.theme, + components: display.components, + model, + t, + onAction: action => handleAction(action), + onClose: () => { + offLocale() + restore() + }, + onUnhandledInput: (data, selectedId): Action | undefined => { + // Refresh works without a selection; install and remove need a row. + if (data === 'r' || data === 'R') return { kind: 'plugin-market/refresh' } + if (selectedId === undefined) return undefined + if (data === 'i' || data === 'I') return { kind: 'plugin-market/install', id: selectedId } + if (data === 'u' || data === 'U') return { kind: 'plugin-market/uninstall', id: selectedId } + return undefined + }, + }) + restore = mountEditorReplacement(ctx, panel) + const offLocale = observeInteractionLocale(ctx, () => { + panel.invalidate() + display.screen.requestRender() + }) + // The panel mounts immediately with the loading document when the caller + // opened before the first load settled; swap in the data when it arrives. + if (catalog === undefined) { + void reload(false).then(() => { + if (unloaded) return + panel.invalidate() + display.screen.requestRender() + }) + } + return { kind: 'success' } + } + + const command = ctx.commands.register({ + name: 'plugin', + description: 'Browse, install, and remove plugins', + input: { hint: '[install [--source npm|github>] | uninstall | info | list | refresh]' }, + handler: async (invocation): Promise => { + const raw = invocation.rawInput.trim() + if (raw === '') { + return openBrowse('catalog') + } + const tokens = raw.split(/\s+/) + const verb = tokens[0]! + const id = tokens[1] + if (verb === 'refresh') { + const result = await reload(true) + if (unloaded) return { kind: 'success' } + if (result.status === 'offline') { + return { kind: 'error', text: t('refresh failed: {message}', { message: result.message }) } + } + return { kind: 'success', text: t('refreshed {count} entries', { count: String(result.index.entries.length) }) } + } + if (verb === 'list') { + return openBrowse('installed') + } + if (verb === 'info') { + if (id === undefined) return { kind: 'error', text: 'usage: /plugin info ' } + if (catalog === undefined) await reload(false) + if (unloaded) return { kind: 'success' } + const entry = findEntry(id) + if (entry === undefined) return { kind: 'error', text: t('unknown plugin: {id}', { id }) } + const display = displayServices(ctx) + if (display === undefined) return { kind: 'error', text: 'plugin browser is unavailable: the Mayfly screen is not mounted' } + let restore: () => void + let offLocale: () => void + const panel = detailPanel(entry, states()[entry.id], () => { + offLocale() + restore() + }) + restore = mountEditorReplacement(ctx, panel) + offLocale = observeInteractionLocale(ctx, () => { + panel.invalidate() + display.screen.requestRender() + }) + return { kind: 'success' } + } + if (verb === 'install' || verb === 'uninstall') { + if (id === undefined) { + return { kind: 'error', text: `usage: /plugin ${verb} [--source npm|github]` } + } + const sourceIndex = tokens.indexOf('--source') + const source: InstallSource = sourceIndex !== -1 && tokens[sourceIndex + 1] === 'github' ? 'github' : 'npm' + if (catalog === undefined) await reload(false) + if (unloaded) return { kind: 'success' } + const entry = findEntry(id) + if (entry === undefined) return { kind: 'error', text: t('unknown plugin: {id}', { id }) } + if (verb === 'uninstall' && states()[entry.id]?.installed !== true) { + return { kind: 'error', text: `"${entry.displayName}" is not installed in this profile` } + } + if (verb === 'install' && usefulInTui(entry) === false) { + getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) + } + await operate(entry, verb, source) + return { kind: 'success' } + } + return { kind: 'error', text: 'usage: /plugin [install | uninstall | info | list | refresh]' } + }, + }) + + return () => { + command() + } +} diff --git a/packages/mayfly/src/interaction/plugin-market/catalog.ts b/packages/mayfly/src/interaction/plugin-market/catalog.ts new file mode 100644 index 0000000..2334233 --- /dev/null +++ b/packages/mayfly/src/interaction/plugin-market/catalog.ts @@ -0,0 +1,115 @@ +/** + * The plugin-market catalog loader: cache-first over the profile storage, + * with a raw.githubusercontent → jsDelivr fallback chain when the configured + * index URL is the default one (mainland reachability), and cached data + * served stale when every fetch fails. All process seams go through + * `updaterInternals`, so specs script the network and the clock. + * + * @module @ephemeral-ai/mayfly/interaction/plugin-market/catalog + */ + +import { join } from 'node:path' +import { updaterInternals } from '../updater/io.ts' +import { dshHome } from '../updater/profile.ts' +import { parseMarketIndex, type MarketIndex } from './types.ts' + +/** The official index, served straight from the marketplace repository. */ +export const DEFAULT_MARKET_INDEX_URL = 'https://raw.githubusercontent.com/Ephemeral-AI-Lab/dsh-plugins/main/dist/index.json' +/** jsDelivr mirror of the same path (the fallback leg of the default chain). */ +const JSDELIVR_MARKET_INDEX_URL = 'https://cdn.jsdelivr.net/gh/Ephemeral-AI-Lab/dsh-plugins@main/dist/index.json' + +/** How long a cached index stays fresh, in milliseconds. */ +export const MARKET_CACHE_TTL_MS = 60 * 60 * 1000 +/** Per-URL fetch timeout. */ +const FETCH_TIMEOUT_MS = 15_000 + +/** The cached document plus when it was stored, epoch milliseconds. */ +interface CacheDoc { + readonly fetchedAt: number + readonly text: string +} + +/** Where the cache lives: `$DSH_HOME/storages/mayfly-plugin-market/cache.json`. */ +export function marketCachePath(): string { + return join(dshHome(), 'storages', 'mayfly-plugin-market', 'cache.json') +} + +/** The outcome of loading the catalog. */ +export type CatalogResult = + | { readonly status: 'fresh', readonly index: MarketIndex } + | { readonly status: 'stale', readonly index: MarketIndex, readonly message: string } + | { readonly status: 'offline', readonly message: string } + +/** + * The URLs to try, in order: a custom setting means exactly that URL; the + * default means the raw document first and its CDN mirror second. + */ +function fetchChain(indexUrl: string): readonly string[] { + return indexUrl === DEFAULT_MARKET_INDEX_URL + ? [DEFAULT_MARKET_INDEX_URL, JSDELIVR_MARKET_INDEX_URL] + : [indexUrl] +} + +/** Try each URL once; the first parseable document wins. */ +async function fetchIndex(indexUrl: string): Promise { + const failures: string[] = [] + for (const url of fetchChain(indexUrl)) { + try { + const text = await updaterInternals.fetchText(url, FETCH_TIMEOUT_MS) + return parseMarketIndex(text) + } catch (error) { + failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`) + } + } + throw new Error(failures.join('; ')) +} + +/** Read the cache document, `undefined` when absent or unparsable. */ +function readCache(): CacheDoc | undefined { + const text = updaterInternals.readTextFile(marketCachePath()) + if (text === undefined) return undefined + try { + const parsed: unknown = JSON.parse(text) + if (typeof parsed !== 'object' || parsed === null) return undefined + const doc = parsed as Record + if (typeof doc.fetchedAt !== 'number' || typeof doc.text !== 'string') return undefined + return { fetchedAt: doc.fetchedAt, text: doc.text } + } catch { + return undefined + } +} + +/** Persist a fetched document to the cache slot. */ +function writeCache(text: string): void { + updaterInternals.writeTextFile(marketCachePath(), `${JSON.stringify({ fetchedAt: updaterInternals.now(), text })}\n`) +} + +/** + * Load the marketplace catalog. + * + * Fresh cache answers immediately; a stale cache (or `force`) refetches and + * falls back to the cached document when every fetch leg fails; with no + * cache at all, a total fetch failure is offline. + * + * @param indexUrl - the configured index URL (the default enables the + * mirror fallback). + * @param force - skip the cache read and refetch (`/plugin refresh`). + * @returns the catalog outcome. + */ +export async function loadMarketCatalog(indexUrl: string, force = false): Promise { + const cached = force ? undefined : readCache() + if (cached !== undefined && updaterInternals.now() - cached.fetchedAt < MARKET_CACHE_TTL_MS) { + return { status: 'fresh', index: parseMarketIndex(cached.text) } + } + try { + const index = await fetchIndex(indexUrl) + writeCache(JSON.stringify(index)) + return { status: 'fresh', index } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (cached !== undefined) { + return { status: 'stale', index: parseMarketIndex(cached.text), message } + } + return { status: 'offline', message } + } +} diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts new file mode 100644 index 0000000..4c210f1 --- /dev/null +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -0,0 +1,274 @@ +/** + * The plugin-market installer: everything that touches the profile. Install + * and removal shell out to `dsh plugin --profile add|remove ` + * (which forwards verbatim to pnpm) exactly like the updater's swap does; + * `profile-patch` rows additionally append to — and remove from — the + * profile's `cordis.patch.yml`, and declared `allowBuilds` names are merged + * into the profile's `pnpm-workspace.yaml` before pnpm runs. Installed-state + * reads parse the profile `package.json` the same way the updater does. + * + * @module @ephemeral-ai/mayfly/interaction/plugin-market/installer + */ + +import { join } from 'node:path' +import { updaterInternals, type SpawnOutcome } from '../updater/io.ts' +import type { MarketEntry, MarketInstallRow } from './types.ts' + +/** Install ceiling, matching the updater's install timeout. */ +const INSTALL_TIMEOUT_MS = 1_200_000 + +/** One plugin the profile actually carries. */ +export interface InstalledPlugin { + /** Runtime package name. */ + readonly name: string + /** The dependency spec in the profile manifest. */ + readonly spec: string + /** Installed version from node_modules, when resolvable. */ + readonly version: string | undefined +} + +/** Which remote a spec should come from. */ +export type InstallSource = 'npm' | 'github' + +/** + * The pnpm spec for one row from one source. GitHub rows compose the + * `github:#[&path:]` grammar the marketplace verified. + * A row without the requested source has no spec. + */ +export function rowSpec(row: MarketInstallRow, source: InstallSource): string | undefined { + if (source === 'npm') return row.npm?.spec + const github = row.github + if (github === undefined) return undefined + return `github:${github.repo}#${github.ref}${github.subdir === undefined ? '' : `&path:${github.subdir}`}` +} + +/** Whether an entry is installable from the given source at all. */ +export function entrySupportsSource(entry: MarketEntry, source: InstallSource): boolean { + return entry.install.rows.some(row => rowSpec(row, source) !== undefined) +} + +/** The pnpm error signature the allowBuilds hint keys on. */ +const ALLOW_BUILDS_HINT = 'allowBuilds' + +/** Render a spawn failure with the allowBuilds follow-up when pnpm raised it. */ +function describeFailure(target: string, outcome: SpawnOutcome): string { + const tail = [outcome.stdout, outcome.stderr].join('\n').split('\n').filter(line => line.trim() !== '').slice(-4).join(' | ') + const hint = tail.includes(ALLOW_BUILDS_HINT) + ? ' — pnpm blocked a build script; add the package to allowBuilds in the profile pnpm-workspace.yaml and retry' + : '' + if (outcome.spawnError !== undefined) return `${target} failed to start: ${outcome.spawnError}` + if (outcome.timedOut) return `${target} timed out` + return `${target} failed: ${tail}${hint}` +} + +/** + * Merge the entry's `allowBuilds` names into the profile workspace file so + * pnpm may run exactly those build scripts (native addons). Idempotent. + */ +function ensureAllowBuilds(root: string, names: readonly string[]): void { + if (names.length === 0) return + const path = join(root, 'pnpm-workspace.yaml') + const existing = updaterInternals.readTextFile(path) ?? '' + const missing = names.filter(name => + !new RegExp(`^\\s*"?(?:${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})"?\\s*:\\s*true\\s*$`, 'm').test(existing)) + if (missing.length === 0) return + let block = existing.length > 0 && !existing.endsWith('\n') ? `${existing}\n` : existing + if (!/^allowBuilds:/m.test(existing)) block += 'allowBuilds:\n' + for (const name of missing) block += ` ${JSON.stringify(name)}: true\n` + updaterInternals.writeTextFile(path, block) +} + +/** Render one `- id: … name: …` block for the profile patch. */ +function renderPatchRow(row: MarketInstallRow): string { + const id = row.id ?? row.name + const config = row.config === undefined ? '' : ` config:\n${Object.entries(row.config) + .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`).join('\n')}\n` + return `- id: ${id}\n name: ${JSON.stringify(row.name)}\n${config}` +} + +/** + * Append the entry's `profile-patch` rows to the profile's user patch layer. + * The file is a top-level YAML sequence (dsh writes an empty one on profile + * init), so an `[]` body is replaced and anything else gains blocks at the + * end. + */ +function appendProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): void { + if (rows.length === 0) return + const path = join(root, 'cordis.patch.yml') + const existing = updaterInternals.readTextFile(path) ?? '' + const body = existing.trim() === '[]' || existing.trim() === '' + ? `${existing.replace(/\[\]\s*$/, '').trimEnd()}\n` + : existing.endsWith('\n') ? existing : `${existing}\n` + updaterInternals.writeTextFile(path, body + rows.map(renderPatchRow).join('')) +} + +/** Strip either YAML quoting style from a scalar so hand-written single + * quotes and the installer's JSON double quotes compare equal. */ +function unquote(value: string): string { + if (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) { + return value.slice(1, -1) + } + return value +} + +/** Remove this entry's `profile-patch` blocks from the user patch layer. */ +function removeProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): void { + /* v8 ignore next -- the manifest schema guarantees at least one row */ + if (rows.length === 0) return + const path = join(root, 'cordis.patch.yml') + const existing = updaterInternals.readTextFile(path) + if (existing === undefined) return + const names = new Set(rows.map(row => row.name)) + // Blocks start at a `- ` line and run to the next one; a block is ours when + // any of its lines names one of our packages. + const lines = existing.split('\n') + const kept: string[] = [] + let block: string[] = [] + const flush = (): void => { + if (block.length === 0) return + const isOurs = block.some(line => { + const quoted = line.match(/name:\s*(.+?)\s*$/)?.[1] + return quoted !== undefined && names.has(unquote(quoted)) + }) + if (!isOurs) kept.push(...block) + block = [] + } + for (const line of lines) { + if (/^-\s/.test(line)) flush() + block.push(line) + } + flush() + updaterInternals.writeTextFile(path, kept.join('\n')) +} + +/** The outcome of an install or removal. */ +export type InstallOutcome = + | { readonly kind: 'success' } + | { readonly kind: 'error', readonly text: string } + +/** Everything an install or removal needs to run. */ +export interface InstallerInput { + /** The dsh CLI binary (`findDshBin()`). */ + readonly dshBin: string + /** The profile name the launcher runs under. */ + readonly profile: string + /** The profile workspace root. */ + readonly root: string + /** The marketplace entry. */ + readonly entry: MarketEntry + /** Which remote specs come from. */ + readonly source: InstallSource +} + +/** + * Install one entry: allowBuilds first, then one `dsh plugin add` carrying + * every row's spec together (sibling rows satisfy each other's peers), then + * the `profile-patch` rows into the user patch layer. + */ +export async function installEntry(input: InstallerInput): Promise { + const rows = input.entry.install.rows + const specs = rows.map(row => rowSpec(row, input.source)).filter((spec): spec is string => spec !== undefined) + if (specs.length === 0) { + return { kind: 'error', text: `"${input.entry.displayName}" has no ${input.source} install source` } + } + ensureAllowBuilds(input.root, input.entry.install.allowBuilds ?? []) + const outcome = await updaterInternals.spawnOnce(input.dshBin, + ['plugin', '--profile', input.profile, 'add', ...specs], + { cwd: input.root, timeoutMs: INSTALL_TIMEOUT_MS }) + if (outcome.code !== 0) { + return { kind: 'error', text: describeFailure(`installing "${input.entry.displayName}"`, outcome) } + } + appendProfilePatchRows(input.root, rows.filter(row => row.activation === 'profile-patch')) + return { kind: 'success' } +} + +/** Remove one entry: one `dsh plugin remove` for every row name, then the + * installer-written patch rows leave the user layer with it. */ +export async function uninstallEntry(input: InstallerInput): Promise { + const names = input.entry.install.rows.map(row => row.name) + const outcome = await updaterInternals.spawnOnce(input.dshBin, + ['plugin', '--profile', input.profile, 'remove', ...names], + { cwd: input.root, timeoutMs: INSTALL_TIMEOUT_MS }) + if (outcome.code !== 0) { + return { kind: 'error', text: describeFailure(`removing "${input.entry.displayName}"`, outcome) } + } + removeProfilePatchRows(input.root, input.entry.install.rows) + return { kind: 'success' } +} + +/** The bundle that ships Mayfly itself; `/plugin` never manages it. */ +export const MAYFLY_PACKAGE = '@ephemeral-ai/mayfly' + +/** + * Read the plugins the profile carries: every dependency of the profile + * manifest except Mayfly itself, with the installed version from + * node_modules when present. This is the updater's `readProfileFacts` + * reading discipline, kept local so `/plugin` owns only its own rows. + */ +export function readInstalledPlugins(root: string): readonly InstalledPlugin[] { + const manifestText = updaterInternals.readTextFile(join(root, 'package.json')) + if (manifestText === undefined) return [] + let dependencies: unknown + try { + const parsed: unknown = JSON.parse(manifestText) + if (typeof parsed !== 'object' || parsed === null) return [] + dependencies = (parsed as Record).dependencies + } catch { + return [] + } + if (typeof dependencies !== 'object' || dependencies === null) return [] + const plugins: InstalledPlugin[] = [] + for (const [name, spec] of Object.entries(dependencies as Record)) { + if (name === MAYFLY_PACKAGE || typeof spec !== 'string') continue + plugins.push({ name, spec, version: readInstalledVersion(join(root, 'node_modules', name, 'package.json')) }) + } + return plugins +} + +/** Read one installed package's version through the fs seam. */ +function readInstalledVersion(manifestPath: string): string | undefined { + const text = updaterInternals.readTextFile(manifestPath) + if (text === undefined) return undefined + try { + const parsed: unknown = JSON.parse(text) + if (typeof parsed !== 'object' || parsed === null) return undefined + const version = (parsed as Record).version + return typeof version === 'string' ? version : undefined + } catch { + return undefined + } +} + +/** How one marketplace entry relates to the profile. */ +export interface EntryInstallState { + /** All of the entry's row packages are profile dependencies. */ + readonly installed: boolean + /** Installed version of the first row (display only). */ + readonly version: string | undefined + /** A newer version is on the registry than the one installed. */ + readonly updateAvailable: boolean +} + +/** + * Derive each entry's install state by matching row package names against + * the profile's dependencies. + */ +export function entryInstallStates( + entries: readonly MarketEntry[], + installed: readonly InstalledPlugin[], +): Readonly> { + const byName = new Map(installed.map(plugin => [plugin.name, plugin])) + const states: Record = {} + for (const entry of entries) { + const present = entry.install.rows.filter(row => byName.has(row.name)) + const installed = present.length === entry.install.rows.length && present.length > 0 + const version = installed === true + ? present.map(row => byName.get(row.name)).find(plugin => plugin?.version !== undefined)?.version + : undefined + const first = entry.install.rows[0] + const info = first === undefined ? undefined : entry.npm?.[first.name] + const updateAvailable = installed === true && info?.latestVersion != null && version !== undefined && version !== info.latestVersion + states[entry.id] = { installed, version, updateAvailable } + } + return states +} diff --git a/packages/mayfly/src/interaction/plugin-market/types.ts b/packages/mayfly/src/interaction/plugin-market/types.ts new file mode 100644 index 0000000..ca48da3 --- /dev/null +++ b/packages/mayfly/src/interaction/plugin-market/types.ts @@ -0,0 +1,105 @@ +/** + * The marketplace index document types (`dist/index.json` from the + * Ephemeral-AI-Lab/dsh-plugins repository) and its parse guard. The index is + * discovery- and install-time metadata only: it never participates in + * runtime loading, capability negotiation, or admission — the runtime + * contract of every plugin remains its package plus `cordis.patch.yml`. + * + * @module @ephemeral-ai/mayfly/interaction/plugin-market/types + */ + +/** One install unit of a marketplace entry. */ +export interface MarketInstallRow { + /** cordis patch row id (required by `profile-patch` activation). */ + readonly id?: string + /** Runtime package name — the reconcile key against the profile manifest. */ + readonly name: string + /** How the package becomes part of the profile. */ + readonly activation?: 'bundle' | 'profile-patch' + /** Default config for a `profile-patch` row. */ + readonly config?: Readonly> + /** npm install spec, e.g. `dsh-loop` or `@scope/pkg@1.2.3`. */ + readonly npm?: { readonly spec: string } + /** GitHub install source; monorepos carry a `subdir`. */ + readonly github?: { readonly repo: string, readonly ref: string, readonly subdir?: string } +} + +/** Registry enrichment for one row package, absent for GitHub-only rows. */ +export interface MarketNpmInfo { + readonly latestVersion: string | null + readonly integrity?: string | null + readonly publishedAt?: string | null + readonly downloadsMonth?: number | null +} + +/** What the plugin contributes; frontend usefulness is derived, not declared. */ +export interface MarketSurfaces { + readonly server?: Readonly> + readonly web?: { readonly clientModule: boolean } + readonly tui?: { readonly contributions: readonly string[] } +} + +/** One marketplace listing. */ +export interface MarketEntry { + readonly id: string + readonly source: 'official' | 'dsh' | 'community' + readonly displayName: string + readonly description: string + readonly descriptionZh?: string + readonly author: { readonly name: string, readonly url?: string } + readonly links?: { readonly repo?: string, readonly docs?: string, readonly npm?: string } + readonly license?: string + readonly category: string + readonly status: 'stable' | 'beta' | 'unstable' | 'deprecated' | 'removed' + readonly statusNote?: string + readonly surfaces: MarketSurfaces + readonly provides?: { readonly tools?: readonly string[], readonly commands?: readonly string[] } + readonly install: { readonly rows: readonly MarketInstallRow[], readonly allowBuilds?: readonly string[] } + readonly engines?: { readonly dsh?: string, readonly mayfly?: string, readonly node?: string } + readonly capabilities?: readonly string[] + readonly verified?: { + readonly at: string + readonly packages: readonly { readonly name: string, readonly version: string, readonly integrity?: string }[] + } + /** npm enrichment keyed by row package name (build-time, from the registry). */ + readonly npm?: Readonly> + readonly readmeExcerpt?: string | null +} + +/** The whole index document. */ +export interface MarketIndex { + readonly schemaVersion: number + readonly generatedAt?: string + readonly entries: readonly MarketEntry[] +} + +/** The index schema version this consumer understands. */ +export const MARKET_INDEX_SCHEMA_VERSION = 1 + +/** + * Parse and guard an index document. Unknown schema versions reject loudly + * instead of rendering half-understood entries. + * @param text - the raw `index.json` body. + * @returns the parsed index. + * @throws on JSON errors, an unsupported schema version, or a missing entries + * array. + */ +export function parseMarketIndex(text: string): MarketIndex { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + /* v8 ignore next -- JSON.parse only throws SyntaxError instances */ + throw new Error(`market index is not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('market index is not an object') + const index = parsed as Record + if (index.schemaVersion !== MARKET_INDEX_SCHEMA_VERSION) { + throw new Error(`market index schema version ${String(index.schemaVersion)} is not supported (expected ${String(MARKET_INDEX_SCHEMA_VERSION)}) — update Mayfly`) + } + if (!Array.isArray(index.entries)) throw new Error('market index has no entries array') + const generatedAt = typeof index.generatedAt === 'string' ? index.generatedAt : undefined + return generatedAt === undefined + ? { schemaVersion: MARKET_INDEX_SCHEMA_VERSION, entries: index.entries as readonly MarketEntry[] } + : { schemaVersion: MARKET_INDEX_SCHEMA_VERSION, generatedAt, entries: index.entries as readonly MarketEntry[] } +} diff --git a/packages/mayfly/src/interaction/settings-command.ts b/packages/mayfly/src/interaction/settings-command.ts index 0bdcf7b..65d5004 100644 --- a/packages/mayfly/src/interaction/settings-command.ts +++ b/packages/mayfly/src/interaction/settings-command.ts @@ -165,6 +165,11 @@ const ROWS: readonly SettingRow[] = [ id: 'mayfly.pasteImageBackend', ns: 'mayfly', key: 'pasteImageBackend', label: 'Paste backend', description: 'linux clipboard backend for image paste', kind: 'string', values: ['auto', 'wayland', 'x11'], }, + { + id: 'mayfly.marketIndexUrl', ns: 'mayfly', key: 'marketIndexUrl', label: 'Plugin market index', + description: 'marketplace index URL for /plugin; empty uses the official dsh-plugins chain', kind: 'string', + values: [], editable: true, emptyDisplay: 'official', + }, { id: 'shell.timeoutMs', ns: 'shell', key: 'timeoutMs', label: 'Shell timeout (ms)', description: 'default bash command timeout', kind: 'number', values: [30_000, 60_000, 120_000, 300_000, 600_000], diff --git a/packages/mayfly/src/interaction/settings.ts b/packages/mayfly/src/interaction/settings.ts index f5c1e07..0b07044 100644 --- a/packages/mayfly/src/interaction/settings.ts +++ b/packages/mayfly/src/interaction/settings.ts @@ -70,6 +70,8 @@ export interface MayflySettings { readonly editorCommand: string /** Linux clipboard backend for image paste; `auto` probes the session (the plugin config stands when the user layer never sets this). */ readonly pasteImageBackend: 'auto' | 'wayland' | 'x11' + /** Plugin marketplace index URL; empty uses the official dsh-plugins chain. */ + readonly marketIndexUrl: string } /** The settings schema; defaults double as the composition base. */ @@ -86,6 +88,7 @@ export const Config: z = z.object({ userFoldChars: z.number().step(1).min(1).default(1000), editorCommand: z.string().default(''), pasteImageBackend: z.union([z.const('auto'), z.const('wayland'), z.const('x11')]).default('auto'), + marketIndexUrl: z.string().default(''), }) /** The resolved defaults, used until a settings service layers overrides. */ @@ -102,6 +105,7 @@ export const DEFAULT_SETTINGS: MayflySettings = { userFoldChars: 1000, editorCommand: '', pasteImageBackend: 'auto', + marketIndexUrl: '', } /** Stable Cordis plugin name. */ diff --git a/packages/mayfly/src/transcript/tips-content.ts b/packages/mayfly/src/transcript/tips-content.ts index 7490597..8e2dbbb 100644 --- a/packages/mayfly/src/transcript/tips-content.ts +++ b/packages/mayfly/src/transcript/tips-content.ts @@ -32,6 +32,7 @@ export const STATUS_TIPS: readonly StatusTip[] = [ { text: '/fork to branch the conversation and explore safely' }, { text: '/btw : ask a side question without disturbing the run', solo: true }, { text: '/theme to switch the terminal UI theme' }, + { text: '/plugin to browse and install plugins', priority: 2 }, { text: '! to run a shell command', priority: 2 }, { text: '@: mention files', priority: 2 }, { text: 'type / to browse commands; matching is fuzzy', solo: true }, diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts new file mode 100644 index 0000000..b5f94c1 --- /dev/null +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -0,0 +1,929 @@ +/** + * Tests for `/plugin` over the real command runtime: the catalog browse + * panel (groups, badges, detail panel), the installed view (updates and + * removed-from-market rows), the argument paths (install with npm and + * GitHub sources, uninstall, info, list, refresh, usage errors), the + * dsh-CLI seam (allowBuilds preflight, profile-patch row insertion and + * removal, failure reporting), and the offline catalog state — all over + * scripted `updaterInternals` seams like the update-command specs. + */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { mkdtempTracked, registerTempDirCleanup } from '../core/temp-dir.ts' + +registerTempDirCleanup() +import { updaterInternals, type SpawnOutcome } from '../../src/interaction/updater/io.ts' +import { setSharedEditor } from '../../src/interaction/editor-instance.ts' +import { registerPluginCommand } from '../../src/interaction/plugin-commands.ts' +import { entryInstallStates, readInstalledPlugins, rowSpec, installEntry, uninstallEntry, entrySupportsSource, MAYFLY_PACKAGE } from '../../src/interaction/plugin-market/installer.ts' +import * as settingsPlugin from '../../src/interaction/settings.ts' +import { InteractionStateService } from '../../src/interaction/runtime-state.ts' +import { fakeMayflyContext, KEY, type FakeScreen } from './fakes.ts' +import { MayflyLocaleService } from '../../src/frontend/locale.ts' +import type { MarketEntry } from '../../src/interaction/plugin-market/types.ts' + +/** The real seams, restored after every test. */ +const REAL = { ...updaterInternals } + +afterEach(() => { + Object.assign(updaterInternals, REAL) + vi.restoreAllMocks() +}) + +/** A spawn success. */ +function ok(stdout = ''): SpawnOutcome { + return { code: 0, signal: null, stdout, stderr: '', timedOut: false } +} + +/** A marketplace entry fixture with every optional field populated. */ +function entry(overrides: Partial = {}): MarketEntry { + return { + id: 'loop', + source: 'official', + displayName: 'Loop', + description: 'Recurring prompts and alarms.', + descriptionZh: '循环提示与闹钟。', + author: { name: 'Ephemeral AI Lab', url: 'https://github.com/Ephemeral-AI-Lab' }, + links: { repo: 'https://github.com/Ephemeral-AI-Lab/dsh-plugins' }, + license: 'MIT', + category: 'workflow', + status: 'stable', + surfaces: { server: {}, web: { clientModule: true } }, + provides: { tools: ['loop_create'], commands: ['/loop'] }, + install: { + rows: [ + { + id: 'loop', + name: 'dsh-loop', + npm: { spec: 'dsh-loop' }, + github: { repo: 'Ephemeral-AI-Lab/dsh-plugins', ref: 'main', subdir: 'plugins/loop' }, + }, + ], + }, + engines: { dsh: '>=0.1.0-rc.5', node: '>=22' }, + capabilities: ['timer'], + verified: { at: '2026-09-04', packages: [{ name: 'dsh-loop', version: '0.1.4' }] }, + npm: { 'dsh-loop': { latestVersion: '0.1.4' } }, + ...overrides, + } +} + +/** The index document for the scripted network. */ +function indexJson(entries: readonly MarketEntry[]): string { + return JSON.stringify({ schemaVersion: 1, generatedAt: '2026-09-04T00:00:00.000Z', entries }) +} + +/** What the dsh CLI spawn scripts do. */ +interface SpawnScript { + /** Behavior for `dsh plugin ...`; defaults to a success that records. */ + plugin?: (args: readonly string[]) => SpawnOutcome + /** `command -v dsh` result; default resolves `/usr/bin/dsh`. */ + dshOnPath?: boolean +} + +/** One command world: temp profile, scripted network and spawns, command mounted. */ +async function mountWorld(options: { + index?: readonly MarketEntry[] + offline?: boolean + profileDependencies?: Readonly> + installedVersions?: Readonly> + spawn?: SpawnScript + withScreen?: boolean +} = {}) { + const home = mkdtempTracked('mayfly-plugin-cmd-') + const root = join(home, '.dsh', 'profiles', 'mayfly') + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ + name: 'dsh-profile-mayfly', + private: true, + dependencies: { + '@ephemeral-ai/mayfly': '0.1.0-alpha.1', + ...(options.profileDependencies ?? {}), + }, + dsh: { profile: { bundles: ['@ephemeral-ai/mayfly'] } }, + })) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n') + writeFileSync(join(root, 'cordis.patch.yml'), '# empty profile layer\n[]\n') + for (const [name, version] of Object.entries(options.installedVersions ?? {})) { + const dir = join(root, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version })) + } + + const spawns: Array<{ cmd: string, args: readonly string[] }> = [] + const writes: string[] = [] + updaterInternals.env = { DSH_HOME: join(home, '.dsh'), DSH_BIN: '/usr/bin/dsh' } + updaterInternals.homedir = () => home + updaterInternals.now = () => 1_000_000 + updaterInternals.fetchText = vi.fn(async (url: string) => { + if (options.offline === true) throw new Error(`registry responded 503 for ${url}`) + if (url.includes('jsdelivr') || url.includes('raw.githubusercontent')) { + if (options.index === undefined) throw new Error('no index scripted') + return indexJson(options.index) + } + throw new Error(`no route for ${url}`) + }) + const realWrite = updaterInternals.writeTextFile + updaterInternals.writeTextFile = vi.fn((path: string, data: string) => { + writes.push(`${path.replace(root, '')}: ${data.replaceAll('\n', ' ⏎ ')}`) + realWrite(path, data) + }) + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + spawns.push({ cmd, args: [...args] }) + if (cmd === '/usr/bin/dsh' && args[0] === 'plugin') { + return options.spawn?.plugin?.(args) ?? ok() + } + return ok('/usr/bin/dsh') + }) + + const mayfly = options.withScreen === false ? undefined : fakeMayflyContext() + const ctx = mayfly?.ctx ?? new Context() + // The fakes mount the interaction state with the screen; a bare context + // still needs one for the settings thunk. + if (mayfly === undefined) new InteractionStateService(ctx, settingsPlugin.DEFAULT_SETTINGS) + // The Service constructor registers itself; the fakes ship no locale. + if (mayfly !== undefined) new MayflyLocaleService(ctx, { systemLocale: 'en' }) + await ctx.plugin(SessionStore) + await ctx.plugin(CommandRuntime) + const session = ctx.sessions.create(SessionId('plugin-spec')) + const agent = { id: session.id, session, status: 'idle' } as unknown as Agent + // Mount inside a dedicated plugin fiber so specs can dispose it and stage + // the unload gates (the agents-command spec's discipline). + const fiber = await ctx.plugin({ name: 'plugin-market-spec', inject: ['commands'], apply: c => { registerPluginCommand(c) } }) + const dispose = (): void => { void fiber.dispose() } + const notices: string[] = [] + if (mayfly !== undefined) { + setSharedEditor(ctx, { submitPrompt: () => {}, notice: text => notices.push(text) } as never) + } + return { + ctx, + screen: mayfly?.screen as FakeScreen, + agent, + root, + spawns, + writes, + notices, + dispose, + run: async (line: string) => { + const execution = await ctx.commands.execute(agent, line, [], new AbortController().signal) + return execution?.result + }, + overlay: (): unknown => (mayfly?.screen as FakeScreen | undefined)?.overlays.at(-1)?.component, + } +} + +describe('installer unit seams', () => { + it('composes npm and github specs, including monorepo subdirectories', () => { + const row = entry().install.rows[0]! + expect(rowSpec(row, 'npm')).toBe('dsh-loop') + expect(rowSpec(row, 'github')).toBe('github:Ephemeral-AI-Lab/dsh-plugins#main&path:plugins/loop') + expect(rowSpec({ name: 'x' }, 'npm')).toBeUndefined() + expect(rowSpec({ name: 'x', github: { repo: 'a/b', ref: 'abc123' } }, 'github')).toBe('github:a/b#abc123') + }) + + it('reports which sources an entry supports', () => { + expect(entrySupportsSource(entry(), 'npm')).toBe(true) + expect(entrySupportsSource({ ...entry(), install: { rows: [{ name: 'x', github: { repo: 'a/b', ref: 'r' } }] } }, 'npm')).toBe(false) + }) + + it('reads installed plugins, skipping Mayfly itself', () => { + const root = mkdtempTracked('mayfly-installed-') + writeFileSync(join(root, 'package.json'), JSON.stringify({ + dependencies: { [MAYFLY_PACKAGE]: '1.0.0', 'dsh-loop': 'github:x', broken: 5 }, + })) + mkdirSync(join(root, 'node_modules', 'dsh-loop'), { recursive: true }) + writeFileSync(join(root, 'node_modules', 'dsh-loop', 'package.json'), JSON.stringify({ name: 'dsh-loop', version: '0.1.3' })) + expect(readInstalledPlugins(root)).toEqual([{ name: 'dsh-loop', spec: 'github:x', version: '0.1.3' }]) + }) + + it('reads an absent or broken profile as no plugins', () => { + const root = mkdtempTracked('mayfly-installed-') + expect(readInstalledPlugins(root)).toEqual([]) + writeFileSync(join(root, 'package.json'), 'not json') + expect(readInstalledPlugins(root)).toEqual([]) + writeFileSync(join(root, 'package.json'), 'null') + expect(readInstalledPlugins(root)).toEqual([]) + }) + + it('derives install states including partial installs and updates', () => { + const twoRows = entry({ + id: 'sidechat', + install: { rows: [{ name: 'dsh-workbench-ui' }, { name: 'dsh-sidechat' }] }, + }) + const states = entryInstallStates([twoRows, entry()], [ + { name: 'dsh-workbench-ui', spec: 'x', version: '0.1.0' }, + { name: 'dsh-loop', spec: 'y', version: '0.1.3' }, + ]) + expect(states.sidechat).toEqual({ installed: false, version: undefined, updateAvailable: false }) + expect(states.loop).toEqual({ installed: true, version: '0.1.3', updateAvailable: true }) + }) + + it('installs: allowBuilds first, one add carrying every spec, then profile-patch rows', async () => { + const profilePatch = entry({ + install: { + allowBuilds: ['node-pty'], + rows: [{ id: 'terminal-bash', name: '@deepseek-ai/dsh-terminal-bash', activation: 'profile-patch', npm: { spec: '@deepseek-ai/dsh-terminal-bash' } }], + }, + }) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\n') + writeFileSync(join(root, 'cordis.patch.yml'), '[]\n') + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) + expect(outcome.kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toContain(`- id: terminal-bash\n name: "@deepseek-ai/dsh-terminal-bash"`) + }) + + it('appending to a non-empty patch layer keeps existing rows, and config renders', async () => { + const withConfig = entry({ + install: { rows: [{ id: 'code-runtime', name: '@deepseek-ai/dsh-code-runtime-worker-thread', activation: 'profile-patch', config: { computeMs: 60000 }, npm: { spec: 'x' } }] }, + }) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'cordis.patch.yml'), '- id: keep\n name: \'keep-me\'\n') + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: withConfig, source: 'npm' }) + expect(outcome.kind).toBe('success') + const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' + expect(patch).toContain('- id: keep') + expect(patch).toContain('config:\n computeMs: 60000') + }) + + it('uninstalling removes exactly the entry\'s patch blocks', async () => { + const two = entry({ + install: { rows: [{ id: 'a', name: 'pkg-a', activation: 'profile-patch', npm: { spec: 'a' } }, { id: 'b', name: 'pkg-b' }] }, + }) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const root = mkdtempTracked('mayfly-uninstall-') + writeFileSync(join(root, 'cordis.patch.yml'), [ + '- id: keep', + " name: 'keep-me'", + '- id: a', + " name: 'pkg-a'", + ' config:', + ' x: 1', + '- id: after', + " name: 'pkg-after'", + ].join('\n') + '\n') + const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: two, source: 'npm' }) + expect(outcome.kind).toBe('success') + const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' + expect(patch).toContain('keep-me') + expect(patch).toContain('pkg-after') + expect(patch).not.toContain("'pkg-a'") + expect(patch).not.toContain('x: 1') + }) + + it('reports install failures with the allowBuilds follow-up when pnpm raised it', async () => { + const failing = entry() + const root = mkdtempTracked('mayfly-install-') + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = async () => ({ code: 1, signal: null, stdout: '', stderr: 'ERR_PNPM_IGNORED_BUILDS add the package to "allowBuilds"', timedOut: false }) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: failing, source: 'npm' }) + updaterInternals.spawnOnce = realSpawn + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('allowBuilds in the profile pnpm-workspace.yaml') }) + }) + + it('reports spawn errors and timeouts distinctly', async () => { + const root = mkdtempTracked('mayfly-install-') + const enoent = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }).catch(() => undefined) + void enoent + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = async () => ({ code: null, signal: null, stdout: '', stderr: '', timedOut: true, spawnError: 'ENOENT' }) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }) + updaterInternals.spawnOnce = realSpawn + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('failed to start') }) + updaterInternals.spawnOnce = async () => ({ code: null, signal: null, stdout: '', stderr: '', timedOut: true }) + const timedOut = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }) + updaterInternals.spawnOnce = realSpawn + expect(timedOut).toMatchObject({ kind: 'error', text: expect.stringContaining('timed out') }) + }) + + it('refuses a source the entry does not declare', async () => { + const githubOnly = { ...entry(), install: { rows: [{ name: 'dsh-loop', github: { repo: 'a/b', ref: 'r' } }] } } + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root: mkdtempTracked('mayfly-install-'), entry: githubOnly, source: 'npm' }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('no npm install source') }) + }) + + it('skips allowBuilds entirely for entries without them', async () => { + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\n') + await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }) + expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toBe('packages:\n - .\n') + }) +}) + +describe('/plugin browse panel', () => { + it('loads the catalog and opens the grouped browse panel', async () => { + const world = await mountWorld({ index: [entry()] }) + const result = await world.run('/plugin') + expect(result).toEqual({ kind: 'success' }) + const panel = world.overlay() + expect(panel).toBeDefined() + const node = (panel as { currentNode(): { kind: string, child?: { children?: Array<{ node: unknown }> } } }).currentNode() + expect(JSON.stringify(node)).toContain('Loop') + expect(JSON.stringify(node)).toContain('official · Web+Server') + world.dispose() + }) + + it('shows the offline document when nothing can be fetched', async () => { + const world = await mountWorld({ offline: true }) + await world.run('/plugin') + const node = (world.overlay() as { currentNode(): unknown }).currentNode() + expect(JSON.stringify(node)).toContain('offline') + world.dispose() + }) + + it('hides removed entries from the catalog', async () => { + const world = await mountWorld({ index: [entry(), entry({ id: 'gone', displayName: 'Gone', status: 'removed', statusNote: 'security' })] }) + await world.run('/plugin') + const node = (world.overlay() as { currentNode(): unknown }).currentNode() + expect(JSON.stringify(node)).not.toContain('Gone') + world.dispose() + }) + + it('errors without the Mayfly screen', async () => { + const world = await mountWorld({ index: [entry()], withScreen: false }) + const result = await world.run('/plugin') + expect(result).toMatchObject({ kind: 'error', text: expect.stringContaining('not mounted') }) + world.dispose() + }) +}) + +describe('/plugin argument paths', () => { + it('installs via npm by default and reminds about the restart', async () => { + const world = await mountWorld({ index: [entry()] }) + const result = await world.run('/plugin install loop') + expect(result).toEqual({ kind: 'success' }) + const dsh = world.spawns.find(spawn => spawn.cmd === '/usr/bin/dsh') + expect(dsh?.args).toEqual(['plugin', '--profile', 'mayfly', 'add', 'dsh-loop']) + expect(world.notices.at(-1)).toBe('installed; restart Mayfly and start a new session to apply') + world.dispose() + }) + + it('installs via github with --source github', async () => { + const world = await mountWorld({ index: [entry()] }) + await world.run('/plugin install loop --source github') + const dsh = world.spawns.find(spawn => spawn.cmd === '/usr/bin/dsh') + expect(dsh?.args).toEqual(['plugin', '--profile', 'mayfly', 'add', 'github:Ephemeral-AI-Lab/dsh-plugins#main&path:plugins/loop']) + world.dispose() + }) + + it('installs multi-row entries with every spec in one add', async () => { + const sidechat = entry({ + id: 'sidechat', + displayName: 'Sidechat', + install: { rows: [{ name: 'dsh-workbench-ui', npm: { spec: 'dsh-workbench-ui' } }, { name: 'dsh-sidechat', npm: { spec: 'dsh-sidechat' } }] }, + }) + const world = await mountWorld({ index: [sidechat] }) + await world.run('/plugin install sidechat') + const dsh = world.spawns.find(spawn => spawn.cmd === '/usr/bin/dsh') + expect(dsh?.args).toEqual(['plugin', '--profile', 'mayfly', 'add', 'dsh-workbench-ui', 'dsh-sidechat']) + world.dispose() + }) + + it('warns before installing a web-only entry and still installs', async () => { + const webOnly = entry({ id: 'panel', displayName: 'Panel', surfaces: { web: { clientModule: true } } }) + const world = await mountWorld({ index: [webOnly] }) + await world.run('/plugin install panel') + expect(world.notices).toContain('web-only plugin: it contributes nothing in this terminal frontend') + expect(world.spawns.some(spawn => spawn.cmd === '/usr/bin/dsh')).toBe(true) + world.dispose() + }) + + it('reports install failures from the CLI seam', async () => { + const world = await mountWorld({ index: [entry()], spawn: { plugin: () => ({ code: 1, signal: null, stdout: '', stderr: 'pnpm: network down', timedOut: false }) } }) + await world.run('/plugin install loop') + expect(world.notices.at(-1)).toBe('install failed: installing "Loop" failed: pnpm: network down') + world.dispose() + }) + + it('uninstalls installed entries and refuses the rest', async () => { + const notInstalled = entry({ id: 'fresh-thing', displayName: 'Fresh Thing', install: { rows: [{ name: 'fresh-thing-pkg', npm: { spec: 'fresh-thing-pkg' } }] } }) + const world = await mountWorld({ + index: [entry(), notInstalled], + profileDependencies: { 'dsh-loop': '0.1.4' }, + installedVersions: { 'dsh-loop': '0.1.4' }, + }) + const refusal = await world.run('/plugin uninstall fresh-thing') + expect(refusal).toMatchObject({ kind: 'error', text: expect.stringContaining('not installed') }) + const result = await world.run('/plugin uninstall loop') + expect(result).toEqual({ kind: 'success' }) + const dsh = world.spawns.find(spawn => spawn.cmd === '/usr/bin/dsh') + expect(dsh?.args).toEqual(['plugin', '--profile', 'mayfly', 'remove', 'dsh-loop']) + expect(world.notices.at(-1)).toBe('removed; restart Mayfly and start a new session to apply') + world.dispose() + }) + + it('info opens the detail panel with the entry facts', async () => { + const world = await mountWorld({ index: [entry()] }) + const result = await world.run('/plugin info loop') + expect(result).toEqual({ kind: 'success' }) + const node = (world.overlay() as { currentNode(): unknown }).currentNode() + const json = JSON.stringify(node) + expect(json).toContain('Loop') + expect(json).toContain('loop_create') + expect(json).toContain('/loop') + expect(json).toContain('dsh plugin --profile add dsh-loop') + expect(json).toContain('2026-09-04') + world.dispose() + }) + + it('info covers the sparse shapes: no provides, no extras, zh description', async () => { + const sparse = entry({ + id: 'bare', + displayName: 'Bare', + descriptionZh: undefined, + provides: {}, + engines: undefined, + capabilities: [], + verified: undefined, + links: {}, + npm: {}, + status: 'deprecated', + statusNote: 'superseded', + }) + const world = await mountWorld({ index: [sparse] }) + await world.run('/plugin info bare') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('none declared') + expect(json).toContain('superseded') + expect(json).toContain('unknown') + world.dispose() + }) + + it('list groups installed rows by state, including removed-from-market', async () => { + const gone = entry({ id: 'gone', displayName: 'Gone', status: 'removed', statusNote: 'yanked', install: { rows: [{ name: 'gone-pkg' }] } }) + const updated = entry({ id: 'loop', npm: { 'dsh-loop': { latestVersion: '0.1.5' } } }) + const fresh = entry({ id: 'fresh', displayName: 'Fresh', install: { rows: [{ name: 'dsh-fresh-pkg', npm: { spec: 'dsh-fresh-pkg' } }] } }) + const world = await mountWorld({ + index: [gone, updated, fresh], + profileDependencies: { 'gone-pkg': '1.0.0', 'dsh-loop': '0.1.4', 'dsh-fresh-pkg': '0.1.0' }, + installedVersions: { 'dsh-loop': '0.1.4', 'dsh-fresh-pkg': '0.1.0' }, + }) + await world.run('/plugin list') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('yanked') + expect(json).toContain('up 0.1.5') + expect(json).toContain('Fresh') + world.dispose() + }) + + it('list shows the empty state when nothing is installed', async () => { + const world = await mountWorld({ index: [entry()] }) + await world.run('/plugin list') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('no plugins installed') + world.dispose() + }) + + it('refresh reports the entry count, or the failure when offline', async () => { + const world = await mountWorld({ index: [entry()] }) + expect(await world.run('/plugin refresh')).toEqual({ kind: 'success', text: 'refreshed 1 entries' }) + const offline = await mountWorld({ offline: true }) + expect(await offline.run('/plugin refresh')).toMatchObject({ kind: 'error', text: expect.stringContaining('refresh failed') }) + world.dispose() + offline.dispose() + }) + + it('rejects unknown ids and malformed verbs', async () => { + const world = await mountWorld({ index: [entry()] }) + expect(await world.run('/plugin install nope')).toMatchObject({ kind: 'error', text: 'unknown plugin: nope' }) + expect(await world.run('/plugin install')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) + expect(await world.run('/plugin info')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) + expect(await world.run('/plugin dance')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) + world.dispose() + }) + + it('finds entries by package name too', async () => { + const world = await mountWorld({ index: [entry()] }) + expect(await world.run('/plugin info dsh-loop')).toEqual({ kind: 'success' }) + world.dispose() + }) +}) + +describe('/plugin key paths', () => { + it('i installs the selected row and u removes it, r refreshes', async () => { + const world = await mountWorld({ + index: [entry()], + profileDependencies: { 'dsh-loop': '0.1.4' }, + installedVersions: { 'dsh-loop': '0.1.4' }, + }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + panel.handleInput('u') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.spawns.some(spawn => spawn.args.includes('remove'))).toBe(true) + world.dispose() + }) + + it('u on an uninstalled entry only flashes a notice', async () => { + const world = await mountWorld({ index: [entry()] }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('u') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.spawns.filter(spawn => spawn.cmd === '/usr/bin/dsh')).toHaveLength(0) + expect(world.notices).toContain('"Loop" is not installed in this profile') + world.dispose() + }) +}) + +describe('/plugin coverage corners', () => { + it('mounts the loading document first, then swaps in the catalog', async () => { + let release: ((value: string) => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const world = await mountWorld({}) + const realFetch = updaterInternals.fetchText + updaterInternals.fetchText = vi.fn(async (url: string) => (url.includes('jsdelivr') || url.includes('raw.githubusercontent') ? gate : realFetch(url))) + await world.run('/plugin') + let json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('loading catalog...') + release?.(indexJson([entry()])) + await new Promise(resolve => setTimeout(resolve, 10)) + json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('Loop') + world.dispose() + }) + + it('serializes overlapping operations through the in-flight guard', async () => { + let release: (() => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const world = await mountWorld({ index: [entry()] }) + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === 'sh') return { code: 0, signal: null, stdout: '/usr/bin/dsh\n', stderr: '', timedOut: false } + if (args[0] === 'plugin') await gate + return realSpawn(cmd, args) + }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('i') + await new Promise(resolve => setTimeout(resolve, 5)) + panel.handleInput('I') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.notices).toContain('a plugin operation is already running') + release?.() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(world.spawns.filter(spawn => spawn.args.includes('add'))).toHaveLength(1) + world.dispose() + }) + + it('requires the dsh CLI for operations', async () => { + const world = await mountWorld({ index: [entry()] }) + updaterInternals.env = { DSH_HOME: updaterInternals.env.DSH_HOME } + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = vi.fn(async (cmd: string) => (cmd === 'sh' ? { code: 1, signal: null, stdout: '', stderr: '', timedOut: false } : realSpawn(cmd, []))) + await world.run('/plugin install loop') + expect(world.notices.at(-1)).toBe('plugin operations need the dsh CLI on PATH (or $DSH_BIN)') + world.dispose() + }) + + it('refuses a source the entry does not declare, from the argument path', async () => { + const githubOnly = entry({ id: 'gh', displayName: 'GH', install: { rows: [{ name: 'gh-pkg', github: { repo: 'a/b', ref: 'r' } }] } }) + const world = await mountWorld({ index: [githubOnly] }) + await world.run('/plugin install gh') + expect(world.notices.at(-1)).toBe('"GH" has no npm install source') + world.dispose() + }) + + it('Enter opens the detail overlay above the browse panel; Escape pops it', async () => { + const world = await mountWorld({ index: [entry()] }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + expect(world.screen.overlays).toHaveLength(1) + await vi.waitFor(() => { + expect(JSON.stringify(panel.currentNode())).toContain('Loop') + }) + // Compile the surface once, then step focus into the list and activate + // the row (the trace-command spec's driving discipline). + ;(panel as unknown as { render(width: number): string[] }).render(80) + panel.handleInput(KEY.tab) + panel.handleInput(KEY.enter) + await vi.waitFor(() => { + expect(world.screen.overlays).toHaveLength(2) + }) + const detail = world.screen.overlays.at(-1)!.component as { handleInput(data: string): void } + detail.handleInput(KEY.escape) + expect(world.screen.overlays.at(-1)!.hidden).toBe(true) + expect(world.screen.overlays[0]!.hidden).toBe(false) + world.dispose() + }) + + it('r refreshes through the panel, hotkeys are case-insensitive, and other keys pass through', async () => { + const world = await mountWorld({ index: [entry()] }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('R') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(updaterInternals.fetchText).toHaveBeenCalled() + panel.handleInput('U') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.notices).toContain('"Loop" is not installed in this profile') + // Any other printable key starts the built-in type-to-filter instead. + panel.handleInput('x') + world.dispose() + }) + + it('i with no selected row is a no-op', async () => { + const world = await mountWorld({ index: [] }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('i') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.spawns.filter(spawn => spawn.cmd === '/usr/bin/dsh' && spawn.args[0] === 'plugin')).toHaveLength(0) + world.dispose() + }) + + it('installs through the i hotkey with the web-only warning for web-only entries', async () => { + const webOnly = entry({ id: 'panel', displayName: 'Panel', surfaces: { web: { clientModule: true } } }) + const world = await mountWorld({ index: [webOnly] }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('i') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(world.notices).toContain('web-only plugin: it contributes nothing in this terminal frontend') + expect(world.spawns.some(spawn => spawn.args.includes('add'))).toBe(true) + world.dispose() + }) + + it('derives states for not-installed and version-less installed entries', () => { + const ghOnly = entry({ id: 'gh', install: { rows: [{ name: 'gh-pkg', github: { repo: 'a/b', ref: 'r' } }] } }) + const states = entryInstallStates([ghOnly, entry({ id: 'unrelated', install: { rows: [{ name: 'zz-pkg', npm: { spec: 'z' } }] } })], [ + { name: 'gh-pkg', spec: 'github:a/b#r', version: undefined }, + ]) + expect(states.gh).toEqual({ installed: true, version: undefined, updateAvailable: false }) + expect(states.unrelated).toEqual({ installed: false, version: undefined, updateAvailable: false }) + }) + + it('rowSpec returns undefined when the requested source is absent', () => { + expect(rowSpec({ name: 'x', npm: { spec: 's' } }, 'github')).toBeUndefined() + }) + + it('reads a profile whose dependencies block is null', () => { + const root = mkdtempTracked('mayfly-installed-') + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: null })) + expect(readInstalledPlugins(root)).toEqual([]) + }) + + it('allowBuilds merge is idempotent across installs', async () => { + const profilePatch = entry({ install: { allowBuilds: ['node-pty'], rows: [{ id: 't', name: 'pkg-t', activation: 'profile-patch', npm: { spec: 'pkg-t' } }] } }) + const root = mkdtempTracked('mayfly-install-') + updaterInternals.spawnOnce = vi.fn(async () => ok()) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\nallowBuilds:\n "node-pty": true\n') + await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) + expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toBe('packages:\n - .\nallowBuilds:\n "node-pty": true\n') + }) + + it('uninstall tolerates a missing patch file and unquoted names', async () => { + const unquoted = entry({ install: { rows: [{ id: 'u', name: 'bare-pkg', activation: 'profile-patch', npm: { spec: 'u' } }] } }) + const root = mkdtempTracked('mayfly-uninstall-') + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const withoutFile = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' }) + expect(withoutFile.kind).toBe('success') + writeFileSync(join(root, 'cordis.patch.yml'), '- id: u\n name: bare-pkg\n') + const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' }) + expect(outcome.kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toBe('') + }) +}) + +describe('/plugin lifecycle and locale', () => { + it('stops touching the context after the fiber unloads mid-load', async () => { + let release: ((value: string) => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const world = await mountWorld({}) + const realFetch = updaterInternals.fetchText + updaterInternals.fetchText = vi.fn(async (url: string) => (url.includes('jsdelivr') || url.includes('raw.githubusercontent') ? gate : realFetch(url))) + await world.run('/plugin') + await world.dispose() + release?.(indexJson([entry()])) + await new Promise(resolve => setTimeout(resolve, 10)) + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('loading catalog...') + world.dispose() + }) + + it('aborts an install that spans a fiber unload, at each await', async () => { + // Gate the CLI spawn: the operate continuation after installEntry must + // gate on the unload flag (and the runOperation invalidate after it). + for (const verb of ['/plugin install loop', '/plugin uninstall loop'] as const) { + const world = await mountWorld({ + index: [entry()], + profileDependencies: verb.includes('uninstall') ? { 'dsh-loop': '0.1.4' } : {}, + installedVersions: verb.includes('uninstall') ? { 'dsh-loop': '0.1.4' } : {}, + }) + let release: (() => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === 'sh') return { code: 0, signal: null, stdout: '/usr/bin/dsh\n', stderr: '', timedOut: false } + if (args[0] === 'plugin') await gate + return realSpawn(cmd, args) + }) + // Kick without awaiting: the handler parks on the gated spawn, the + // dispose lands mid-flight, then the release lets it settle quietly. + const execution = world.run(verb) + await new Promise(resolve => setTimeout(resolve, 5)) + await world.dispose() + release?.() + await execution + world.dispose() + } + }) + + it('aborts an argument-path load and refresh that span a fiber unload', async () => { + for (const line of ['/plugin install loop', '/plugin refresh'] as const) { + let release: ((value: string) => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const world = await mountWorld({}) + const realFetch = updaterInternals.fetchText + updaterInternals.fetchText = vi.fn(async (url: string) => (url.includes('jsdelivr') || url.includes('raw.githubusercontent') ? gate : realFetch(url))) + const execution = world.run(line) + await new Promise(resolve => setTimeout(resolve, 5)) + await world.dispose() + release?.(indexJson([entry()])) + await execution + world.dispose() + } + }) + + it('re-renders browse, detail, and info panels when the locale switches', async () => { + const tui = entry({ + id: 'tui-pane', + displayName: 'Tui Pane', + status: 'unstable', + surfaces: { server: {}, tui: { contributions: ['panes'] } }, + }) + const world = await mountWorld({ index: [entry(), tui] }) + await world.run('/plugin') + const browse = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + let json = JSON.stringify(browse.currentNode()) + expect(json).toContain('Tui Pane') + expect(json).toContain('TUI+Server') + expect(json).toContain('unstable') + // Enter → detail above the browse panel; both observers re-render on a + // preference switch, and the zh description takes over. + ;(browse as unknown as { render(width: number): string[] }).render(80) + browse.handleInput(KEY.enter) + await vi.waitFor(() => { + expect(world.screen.overlays).toHaveLength(2) + }) + // Info panels are construction-frozen (the /mcp D40 boundary): a locale + // switch re-renders the panels below but a fresh info is the zh one. + world.ctx.mayflyLocale.setPreference('zh') + await new Promise(resolve => setTimeout(resolve, 5)) + const detail = world.screen.overlays.at(-1)!.component as { handleInput(data: string): void } + detail.handleInput(KEY.escape) + // The bare info path mounts its own locale observer. + await world.run('/plugin info loop') + const info = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + expect(JSON.stringify(info.currentNode())).toContain('循环提示与闹钟。') + world.ctx.mayflyLocale.setPreference('en') + await new Promise(resolve => setTimeout(resolve, 5)) + info.handleInput(KEY.escape) + // Escape closes the browse panel. + browse.handleInput(KEY.escape) + expect(world.screen.overlays[0]!.hidden).toBe(true) + world.dispose() + }) + + it('covers the info error paths and the installed-mode stray row hotkey', async () => { + const world = await mountWorld({ + index: [entry()], + profileDependencies: { 'stray-pkg': '1.0.0' }, + }) + expect(await world.run('/plugin info nope')).toMatchObject({ kind: 'error', text: 'unknown plugin: nope' }) + const bare = await mountWorld({ index: [entry()], withScreen: false }) + expect(await bare.run('/plugin info loop')).toMatchObject({ kind: 'error', text: expect.stringContaining('not mounted') }) + bare.dispose() + await world.run('/plugin list') + const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + const json = JSON.stringify(panel.currentNode()) + expect(json).toContain('stray-pkg') + expect(json).toContain('removed') + // i on the stray row resolves no entry and stays a no-op. + panel.handleInput('i') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.spawns.filter(spawn => spawn.args[0] === 'plugin')).toHaveLength(0) + world.dispose() + }) + + it('reads installed versions defensively', () => { + const root = mkdtempTracked('mayfly-installed-') + mkdirSync(join(root, 'node_modules', 'weird-a'), { recursive: true }) + writeFileSync(join(root, 'node_modules', 'weird-a', 'package.json'), 'null') + mkdirSync(join(root, 'node_modules', 'weird-c'), { recursive: true }) + writeFileSync(join(root, 'node_modules', 'weird-c', 'package.json'), 'not json') + mkdirSync(join(root, 'node_modules', 'weird-b'), { recursive: true }) + writeFileSync(join(root, 'node_modules', 'weird-b', 'package.json'), JSON.stringify({ version: 5 })) + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { 'weird-a': '1', 'weird-b': '1', 'weird-c': '1' } })) + const plugins = readInstalledPlugins(root) + expect(plugins.map(plugin => [plugin.name, plugin.version])).toEqual([['weird-a', undefined], ['weird-b', undefined], ['weird-c', undefined]]) + }) +}) + +describe('/plugin final coverage corners', () => { + it('covers the remaining unload gates: info load, findDshBin, the i-key invalidate', async () => { + // info argument path parking on the catalog load. + let releaseFetch: ((value: string) => void) | undefined + const fetchGate = new Promise(resolve => { + releaseFetch = resolve + }) + const infoWorld = await mountWorld({}) + const realFetch = updaterInternals.fetchText + updaterInternals.fetchText = vi.fn(async (url: string) => (url.includes('jsdelivr') || url.includes('raw.githubusercontent') ? fetchGate : realFetch(url))) + const infoExecution = infoWorld.run('/plugin info loop') + await new Promise(resolve => setTimeout(resolve, 5)) + await infoWorld.dispose() + releaseFetch?.(indexJson([entry()])) + await infoExecution + + // operate parking on findDshBin (the sh spawn itself gated). + const shWorld = await mountWorld({ index: [entry()] }) + let releaseSh: (() => void) | undefined + const shGate = new Promise(resolve => { + releaseSh = resolve + }) + const realSpawn = updaterInternals.spawnOnce + updaterInternals.spawnOnce = vi.fn(async (cmd: string) => { + if (cmd === 'sh') await shGate + return realSpawn(cmd, []) + }) + const shExecution = shWorld.run('/plugin install loop') + await new Promise(resolve => setTimeout(resolve, 5)) + await shWorld.dispose() + releaseSh?.() + await shExecution + + // The i-key path's post-operate invalidate also gates on the unload. + const keyWorld = await mountWorld({ index: [entry()] }) + let releaseOp: (() => void) | undefined + const opGate = new Promise(resolve => { + releaseOp = resolve + }) + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === 'sh') return { code: 0, signal: null, stdout: '/usr/bin/dsh\n', stderr: '', timedOut: false } + if (args[0] === 'plugin') await opGate + return realSpawn(cmd, args) + }) + await keyWorld.run('/plugin') + const panel = keyWorld.overlay() as { handleInput(data: string): void } + panel.handleInput('i') + await new Promise(resolve => setTimeout(resolve, 5)) + await keyWorld.dispose() + releaseOp?.() + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('flashes the refresh failure when the r key hits an offline market', async () => { + const world = await mountWorld({ index: [entry()], offline: true }) + await world.run('/plugin refresh').catch(() => undefined) + // Load a cached catalog so the panel opens, then go offline for the key. + updaterInternals.writeTextFile(join(world.root, '..', '..', 'storages', 'mayfly-plugin-market', 'cache.json'), JSON.stringify({ fetchedAt: 1_000_000, text: indexJson([entry()]) })) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + updaterInternals.fetchText = vi.fn(async () => { + throw new Error('offline now') + }) + panel.handleInput('r') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(world.notices.some(notice => notice.startsWith('refresh failed:'))).toBe(true) + world.dispose() + }) + + it('returns a working disposer from registerPluginCommand', async () => { + const ctx = new Context() + new InteractionStateService(ctx, settingsPlugin.DEFAULT_SETTINGS) + await ctx.plugin(SessionStore) + await ctx.plugin(CommandRuntime) + const dispose = registerPluginCommand(ctx) + const agent = await currentAgent(ctx) + expect(ctx.commands.find(agent, 'plugin')).toBeDefined() + dispose() + expect(ctx.commands.find(agent, 'plugin')).toBeUndefined() + }) +}) + + +async function currentAgent(ctx: Context): Promise { + const session = ctx.sessions.create(SessionId('disposer-spec')) + return { id: session.id, session, status: 'idle' } as never +} diff --git a/packages/mayfly/tests/interaction/plugin-market.spec.ts b/packages/mayfly/tests/interaction/plugin-market.spec.ts new file mode 100644 index 0000000..388b763 --- /dev/null +++ b/packages/mayfly/tests/interaction/plugin-market.spec.ts @@ -0,0 +1,178 @@ +/** + * Tests for the plugin-market catalog loader: the parse guard, the cache + * TTL, the raw → jsDelivr fallback chain, custom-URL single-leg fetches, + * stale serving, offline, and force refresh — all over the scripted + * `updaterInternals` seams (network, fs, clock, home). + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempTracked, registerTempDirCleanup } from '../core/temp-dir.ts' +import { updaterInternals } from '../../src/interaction/updater/io.ts' +import { + DEFAULT_MARKET_INDEX_URL, + loadMarketCatalog, + marketCachePath, + MARKET_CACHE_TTL_MS, +} from '../../src/interaction/plugin-market/catalog.ts' +import { parseMarketIndex } from '../../src/interaction/plugin-market/types.ts' + +registerTempDirCleanup() + +/** The real seams, restored after every test. */ +const REAL = { ...updaterInternals } + +afterEach(() => { + Object.assign(updaterInternals, REAL) + vi.restoreAllMocks() +}) + +/** A two-entry index document. */ +function indexJson(entries: ReadonlyArray<{ id: string }> = [{ id: 'loop' }, { id: 'terminal' }]): string { + return JSON.stringify({ schemaVersion: 1, generatedAt: '2026-09-04T00:00:00.000Z', entries }) +} + +/** One test home with a scripted network; `urls` maps URL → body. */ +function mountNetwork(urls: Readonly>): { home: string, fetches: string[] } { + const home = mkdtempTracked('mayfly-market-') + updaterInternals.homedir = () => home + updaterInternals.env = { DSH_HOME: join(home, '.dsh') } + updaterInternals.now = () => 10_000_000 + const fetches: string[] = [] + updaterInternals.fetchText = vi.fn(async (url: string) => { + fetches.push(url) + const body = urls[url] + if (body === undefined) throw new Error(`no route for ${url}`) + if (body instanceof Error) throw body + return body + }) + return { home, fetches } +} + +describe('parseMarketIndex', () => { + it('parses a valid document', () => { + const index = parseMarketIndex(indexJson()) + expect(index.schemaVersion).toBe(1) + expect(index.entries.map(entry => entry.id)).toEqual(['loop', 'terminal']) + }) + + it('rejects invalid JSON', () => { + expect(() => parseMarketIndex('{')).toThrow(/not valid JSON/) + }) + + it('rejects a non-object document', () => { + expect(() => parseMarketIndex('[]')).toThrow(/not an object/) + }) + + it('rejects an unsupported schema version with the upgrade hint', () => { + expect(() => parseMarketIndex('{"schemaVersion":2,"entries":[]}')).toThrow(/schema version 2 .* update Mayfly/) + }) + + it('rejects a document without entries', () => { + expect(() => parseMarketIndex('{"schemaVersion":1}')).toThrow(/no entries/) + }) +}) + +describe('loadMarketCatalog', () => { + it('fetches the default URL, then the jsDelivr mirror, and caches', async () => { + const { home, fetches } = mountNetwork({ + [DEFAULT_MARKET_INDEX_URL]: new Error('raw unreachable'), + 'https://cdn.jsdelivr.net/gh/Ephemeral-AI-Lab/dsh-plugins@main/dist/index.json': indexJson(), + }) + const first = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(first.status).toBe('fresh') + expect(fetches).toEqual([DEFAULT_MARKET_INDEX_URL, 'https://cdn.jsdelivr.net/gh/Ephemeral-AI-Lab/dsh-plugins@main/dist/index.json']) + // The cache slot exists and the second load never touches the network. + const second = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(second.status).toBe('fresh') + expect(fetches).toHaveLength(2) + expect(marketCachePath()).toBe(join(home, '.dsh', 'storages', 'mayfly-plugin-market', 'cache.json')) + }) + + it('answers from the cache within the TTL without fetching', async () => { + mountNetwork({}) + updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 9_999_999, text: indexJson() })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('fresh') + expect(updaterInternals.fetchText).not.toHaveBeenCalled() + }) + + it('refetches past the TTL', async () => { + const { fetches } = mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) + updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 1_000_000 - MARKET_CACHE_TTL_MS, text: indexJson() })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('fresh') + expect(fetches).toEqual([DEFAULT_MARKET_INDEX_URL]) + }) + + it('serves stale cache when every leg fails', async () => { + mountNetwork({}) + updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 0, text: indexJson() })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result).toMatchObject({ status: 'stale', message: expect.stringContaining('no route') }) + if (result.status === 'stale') expect(result.index.entries).toHaveLength(2) + }) + + it('reports offline when nothing is cached and every leg fails', async () => { + const { fetches } = mountNetwork({}) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result).toMatchObject({ status: 'offline', message: expect.stringContaining('cdn.jsdelivr.net') }) + expect(fetches).toHaveLength(2) + }) + + it('fetches exactly the custom URL with no mirror fallback', async () => { + const { fetches } = mountNetwork({ 'https://example.invalid/market.json': indexJson() }) + const result = await loadMarketCatalog('https://example.invalid/market.json') + expect(result.status).toBe('fresh') + expect(fetches).toEqual(['https://example.invalid/market.json']) + }) + + it('force skips a fresh cache', async () => { + const { fetches } = mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson([{ id: 'fresh' }]) }) + updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 999_999, text: indexJson() })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL, true) + expect(result.status).toBe('fresh') + expect(fetches).toEqual([DEFAULT_MARKET_INDEX_URL]) + if (result.status === 'fresh') expect(result.index.entries[0]?.id).toBe('fresh') + }) + + it('treats a corrupt cache as absent', async () => { + const { home } = mountNetwork({}) + updaterInternals.writeTextFile(marketCachePath(), 'not json') + // A corrupt cache must not crash the stale path. + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('offline') + expect(home.length).toBeGreaterThan(0) + }) + + it('rewrites the cache after a successful fetch', async () => { + mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) + const before = updaterInternals.readTextFile(marketCachePath()) + expect(before).toBeUndefined() + await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + const cached = JSON.parse(updaterInternals.readTextFile(marketCachePath()) ?? '{}') as { fetchedAt: number, text: string } + expect(cached.fetchedAt).toBe(10_000_000) + expect(parseMarketIndex(cached.text).entries).toHaveLength(2) + }) +}) + +describe('cache slot hygiene', () => { + it('reads a cache whose shape is wrong as absent', async () => { + mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) + mkdirSync(join(marketCachePath(), '..'), { recursive: true }) + writeFileSync(marketCachePath(), JSON.stringify({ fetchedAt: 'yesterday' })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('fresh') + rmSync(marketCachePath(), { force: true }) + }) +}) + +describe('readCache shape guard', () => { + it('treats a scalar cache document as absent', async () => { + mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) + updaterInternals.writeTextFile(marketCachePath(), '5') + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('fresh') + }) +}) diff --git a/packages/mayfly/tests/interaction/settings-command.spec.ts b/packages/mayfly/tests/interaction/settings-command.spec.ts index af75e0c..fa1d58f 100644 --- a/packages/mayfly/tests/interaction/settings-command.spec.ts +++ b/packages/mayfly/tests/interaction/settings-command.spec.ts @@ -373,7 +373,7 @@ describe('/settings level one', () => { const frame = l1(bench.screen).render(100) .map(line => line.replaceAll('^', '').replaceAll('~', '').replaceAll('_', '')) .join('\n') - expect(frame).toContain('mayfly — Mayfly UI preferences · 12 settings') + expect(frame).toContain('mayfly — Mayfly UI preferences · 13 settings') expect(frame).toContain('shell — bash tool limits · 5 settings') expect(frame).toContain('agent-presets — composition preset default · 1 settings') }) @@ -463,6 +463,7 @@ describe('/settings level two', () => { 'mayfly.userFoldChars', 'mayfly.editorCommand', 'mayfly.pasteImageBackend', + 'mayfly.marketIndexUrl', ]) const byId = new Map(items.map(item => [item.id, item])) expect(byId.get('mayfly.updateCheck')?.currentValue).toBe('true') diff --git a/packages/mayfly/tests/interaction/settings.spec.ts b/packages/mayfly/tests/interaction/settings.spec.ts index 45f3c85..ad8284f 100644 --- a/packages/mayfly/tests/interaction/settings.spec.ts +++ b/packages/mayfly/tests/interaction/settings.spec.ts @@ -144,6 +144,7 @@ describe('mayfly-settings schema and registration', () => { userFoldChars: 1000, editorCommand: '', pasteImageBackend: 'auto', + marketIndexUrl: '', }) expect(settingsPlugin.name).toBe('mayfly-settings') }) @@ -172,6 +173,7 @@ describe('mayfly-settings schema and registration', () => { userFoldChars: 1000, editorCommand: 'my-editor --wait', pasteImageBackend: 'auto', + marketIndexUrl: '', }) expect(ready.at(-1)).toMatchObject({ editorCommand: 'my-editor --wait' }) diff --git a/website/en/reference/commands.md b/website/en/reference/commands.md index 87e8f57..f9787a3 100644 --- a/website/en/reference/commands.md +++ b/website/en/reference/commands.md @@ -30,6 +30,7 @@ Typing `/` triggers fuzzy autocomplete and discovery hints (see [Input editor](/ | `/trace` | — | `[copy \| copy all]` | Inspect the current session's execution timeline; copy one item or the full trace | `mayfly-commands` (trace-command) | | `/update` | — | `[version]` | Safely update Mayfly (pre-flight, snapshot, boot smoke, automatic rollback; a bare call is a read-only check) | `mayfly-commands` (update-command, D52) | | `/settings` | — | — | Edit user settings by namespace (two-level panel, every change writes through; see [Configuration](/en/guide/config)) | `mayfly-commands` (settings-command) | +| `/plugin` | — | `[install [--source npm\|github] \| uninstall \| info \| list \| refresh]` | Browse, install, and remove marketplace plugins (official, dsh, and community tiers; cache-first with stale serving offline; see the [marketplace repository](https://github.com/Ephemeral-AI-Lab/dsh-plugins)) | `mayfly-commands` (plugin-commands) | | `/export` | — | `[path]` | Export the current session as a Markdown file | `mayfly-commands` (session-export) | | `/copy` | — | — | Copy the last assistant message to the clipboard | `mayfly-commands` (session-export) | diff --git a/website/reference/commands.md b/website/reference/commands.md index 0c95c17..d1aedf1 100644 --- a/website/reference/commands.md +++ b/website/reference/commands.md @@ -30,6 +30,7 @@ | `/trace` | — | `[copy \| copy all]` | 查看当前会话执行轨迹;可复制单项或完整轨迹 | `mayfly-commands`(trace-command) | | `/update` | — | `[version]` | 安全升级 Mayfly(预检/快照/装机冒烟/失败自动回滚;不带参数即只读检查) | `mayfly-commands`(update-command,D52) | | `/settings` | — | — | 按命名空间编辑用户设置(两级面板,改动即落盘;详见[配置](/guide/config)) | `mayfly-commands`(settings-command) | +| `/plugin` | — | `[install [--source npm\|github] \| uninstall \| info \| list \| refresh]` | 浏览/安装/移除插件市场条目(官方、dsh、社区三档来源;缓存优先,离线展示缓存目录;详见[插件市场仓库](https://github.com/Ephemeral-AI-Lab/dsh-plugins)) | `mayfly-commands`(plugin-commands) | | `/export` | — | `[path]` | 把当前会话导出为 Markdown 文件 | `mayfly-commands`(session-export) | | `/copy` | — | — | 复制最近一条助手消息到剪贴板 | `mayfly-commands`(session-export) | From f9a8a082d232df2a5d4cc1410cfcab2bbf135511 Mon Sep 17 00:00:00 2001 From: GeekCmore Date: Fri, 4 Sep 2026 23:09:04 +0800 Subject: [PATCH 2/8] test(interaction): drive /plugin to full per-file coverage - shape arms: installed/update/versionless details, tools-only and commands-only provides, tui-only and web-only surfaces, github-only install commands, statusNote-less removals - lifecycle arms: fiber-unload gates at each await (load, findDshBin, install, refresh), locale re-render with and without the service, in-flight serialization, bare-context installs - installer arms: missing workspace file allowBuilds merge, no-trailing- newline patch append, regex unquote; catalog arms: non-Error rejections, cache shape guards; defensive arms carry v8 ignore notes Co-Authored-By: Claude Code --- .../mayfly/src/interaction/plugin-commands.ts | 42 ++++-- .../src/interaction/plugin-market/catalog.ts | 5 +- .../interaction/plugin-market/installer.ts | 8 +- .../tests/interaction/plugin-commands.spec.ts | 127 +++++++++++++++++- .../tests/interaction/plugin-market.spec.ts | 28 ++++ 5 files changed, 191 insertions(+), 19 deletions(-) diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index 87baa2a..983389e 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -61,7 +61,12 @@ export function registerPluginCommand(ctx: Context): () => void { let operationInFlight = false /** The active UI locale, for entry descriptions that ship both languages. */ - const locale = (): 'zh' | 'en' => ctx.get('mayflyLocale')?.snapshot.locale ?? 'en' + const locale = (): 'zh' | 'en' => { + const service = ctx.get('mayflyLocale') + /* v8 ignore next -- panels render only where the frontend ships the locale service */ + if (service === undefined) return 'en' + return service.snapshot.locale + } /** The configured index URL; the empty default means the official chain. */ const indexUrl = (): string => currentMayflySettings(ctx).marketIndexUrl || DEFAULT_MARKET_INDEX_URL @@ -76,8 +81,11 @@ export function registerPluginCommand(ctx: Context): () => void { }) /** Entries currently on hand (empty while offline or unloaded). */ - const entries = (): readonly MarketEntry[] => - catalog !== undefined && catalog.status !== 'offline' ? catalog.index.entries : [] + const entries = (): readonly MarketEntry[] => { + /* v8 ignore next -- row paths run only after a load settled */ + if (catalog === undefined || catalog.status === 'offline') return [] + return catalog.index.entries + } /** Install state per entry id. */ const states = (): Readonly> => entryInstallStates(entries(), installed) @@ -92,12 +100,14 @@ export function registerPluginCommand(ctx: Context): () => void { if (entry.surfaces.tui !== undefined) parts.push('TUI') if (entry.surfaces.web !== undefined) parts.push('Web') if (entry.surfaces.server !== undefined) parts.push('Server') + /* v8 ignore next -- the manifest schema requires at least one surface */ return parts.length === 0 ? '—' : parts.join('+') } /** Composite row badge: tier, surfaces, install state, status. */ const badgeOf = (entry: MarketEntry, state: EntryInstallState | undefined): string => { const pieces = [entry.source, surfaceBadge(entry)] + /* v8 ignore next -- states() carries every indexed entry id */ if (state?.installed === true) pieces.push(state.updateAvailable === true ? `up ${state.version ?? ''}`.trim() : 'installed') if (entry.status === 'beta' || entry.status === 'unstable' || entry.status === 'deprecated') pieces.push(entry.status) return pieces.join(' · ') @@ -130,6 +140,7 @@ export function registerPluginCommand(ctx: Context): () => void { operationInFlight = true try { const dshBin = await findDshBin() + /* v8 ignore next -- a fiber unload landing inside these awaits is a shutdown race */ if (unloaded) return if (dshBin === undefined) { getSharedEditor(ctx)?.notice?.('plugin operations need the dsh CLI on PATH (or $DSH_BIN)') @@ -142,6 +153,7 @@ export function registerPluginCommand(ctx: Context): () => void { getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'installing "{name}"...' : 'removing "{name}"...', { name: entry.displayName })) const input = { dshBin, profile: profileNameFromArgv(process.argv), root: profileRoot(profileNameFromArgv(process.argv)), entry, source } const outcome = action === 'install' ? await installEntry(input) : await uninstallEntry(input) + /* v8 ignore next -- a fiber unload landing inside these awaits is a shutdown race */ if (unloaded) return if (outcome.kind === 'error') { getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'install failed: {message}' : 'uninstall failed: {message}', { message: outcome.text })) @@ -159,8 +171,11 @@ export function registerPluginCommand(ctx: Context): () => void { /** The copyable manual install command for an entry's default source. */ const installCommand = (entry: MarketEntry): string => { const row = entry.install.rows[0] - const spec = row === undefined ? undefined : row.npm?.spec ?? rowSpec(row, 'github') - return spec === undefined ? `dsh plugin --profile add <${entry.id}>` : `dsh plugin --profile add ${spec}` + /* v8 ignore next -- the manifest schema guarantees a first row with a source */ + if (row === undefined || (row.npm === undefined && row.github === undefined)) { + return `dsh plugin --profile add <${entry.id}>` + } + return `dsh plugin --profile add ${row.npm?.spec ?? rowSpec(row, 'github')}` } /** The read-only detail panel for one entry. */ @@ -250,12 +265,11 @@ export function registerPluginCommand(ctx: Context): () => void { /** Rows for the catalog mode: live entries except tombstones. */ const catalogItems = (): readonly FrontendPanelItem[] => entries().filter(entry => entry.status !== 'removed').map(entry => { - const badge = badgeOf(entry, states()[entry.id]) return { id: entry.id, label: entry.displayName, detail: describe(entry), - ...(badge === '' ? {} : { badge }), + badge: badgeOf(entry, states()[entry.id]), group: entry.source, action: { kind: 'plugin-market/details', id: entry.id }, actionLabel: t('Details'), @@ -271,12 +285,11 @@ export function registerPluginCommand(ctx: Context): () => void { const entry = byName.get(plugin.name) if (entry === undefined) { // Not in the index at all: the plugin left the market (or predates it). - const pieces = [plugin.version, 'removed'].filter((piece): piece is string => piece !== undefined) return [{ id: plugin.name, label: plugin.name, detail: plugin.spec, - ...(pieces.length === 0 ? {} : { badge: pieces.join(' · ') }), + badge: 'removed', group: 'removed', }] } @@ -293,12 +306,13 @@ export function registerPluginCommand(ctx: Context): () => void { id: entry.id, label: entry.displayName, detail: removed ? (entry.statusNote ?? t('removed from the market')) : describe(entry), - ...(pieces.length === 0 ? {} : { badge: pieces.join(' · ') }), + badge: pieces.join(' · '), group: removed ? 'removed' : update ? 'updates' : 'installed', action: { kind: 'plugin-market/details', id: entry.id }, actionLabel: t('Details'), }] }) + /* v8 ignore next -- every row above sets one of the three groups */ return [...rows].sort((a, b) => (rank[a.group as keyof typeof rank] ?? 0) - (rank[b.group as keyof typeof rank] ?? 0)) } @@ -361,11 +375,12 @@ export function registerPluginCommand(ctx: Context): () => void { } const handleAction = (action: Action): void => { - if (action.kind === 'plugin-market/details') openDetail(String(action.id)) - else if (action.kind === 'plugin-market/install') runOperation(String(action.id), 'install') - else if (action.kind === 'plugin-market/uninstall') runOperation(String(action.id), 'uninstall') + const id = String(action.id ?? '') + if (action.kind === 'plugin-market/install') runOperation(id, 'install') + else if (action.kind === 'plugin-market/uninstall') runOperation(id, 'uninstall') else if (action.kind === 'plugin-market/refresh') { void reload(true).then(result => { + /* v8 ignore next -- a fiber unload landing inside the refresh await is a shutdown race */ if (unloaded) return if (result.status === 'offline') { getSharedEditor(ctx)?.notice?.(t('refresh failed: {message}', { message: result.message })) @@ -374,6 +389,7 @@ export function registerPluginCommand(ctx: Context): () => void { display.screen.requestRender() }) } + else openDetail(id) } let restore: () => void diff --git a/packages/mayfly/src/interaction/plugin-market/catalog.ts b/packages/mayfly/src/interaction/plugin-market/catalog.ts index 2334233..980edb1 100644 --- a/packages/mayfly/src/interaction/plugin-market/catalog.ts +++ b/packages/mayfly/src/interaction/plugin-market/catalog.ts @@ -58,7 +58,9 @@ async function fetchIndex(indexUrl: string): Promise { const text = await updaterInternals.fetchText(url, FETCH_TIMEOUT_MS) return parseMarketIndex(text) } catch (error) { - failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`) + failures.push(`${url}: ${ + /* v8 ignore next -- fetch rejects with Error instances */ + error instanceof Error ? error.message : String(error)}`) } } throw new Error(failures.join('; ')) @@ -106,6 +108,7 @@ export async function loadMarketCatalog(indexUrl: string, force = false): Promis writeCache(JSON.stringify(index)) return { status: 'fresh', index } } catch (error) { + /* v8 ignore next -- both fetch legs and the parser throw Error instances */ const message = error instanceof Error ? error.message : String(error) if (cached !== undefined) { return { status: 'stale', index: parseMarketIndex(cached.text), message } diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts index 4c210f1..52f904b 100644 --- a/packages/mayfly/src/interaction/plugin-market/installer.ts +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -80,6 +80,7 @@ function ensureAllowBuilds(root: string, names: readonly string[]): void { /** Render one `- id: … name: …` block for the profile patch. */ function renderPatchRow(row: MarketInstallRow): string { + /* v8 ignore next -- profile-patch rows always carry an id */ const id = row.id ?? row.name const config = row.config === undefined ? '' : ` config:\n${Object.entries(row.config) .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`).join('\n')}\n` @@ -105,10 +106,8 @@ function appendProfilePatchRows(root: string, rows: readonly MarketInstallRow[]) /** Strip either YAML quoting style from a scalar so hand-written single * quotes and the installer's JSON double quotes compare equal. */ function unquote(value: string): string { - if (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) { - return value.slice(1, -1) - } - return value + const quoted = /^(["'])(.*)\1$/u.exec(value) + return quoted === null ? value : quoted[2]! } /** Remove this entry's `profile-patch` blocks from the user patch layer. */ @@ -266,6 +265,7 @@ export function entryInstallStates( ? present.map(row => byName.get(row.name)).find(plugin => plugin?.version !== undefined)?.version : undefined const first = entry.install.rows[0] + /* v8 ignore next -- the manifest schema guarantees at least one row */ const info = first === undefined ? undefined : entry.npm?.[first.name] const updateAvailable = installed === true && info?.latestVersion != null && version !== undefined && version !== info.latestVersion states[entry.id] = { installed, version, updateAvailable } diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index b5f94c1..b07e096 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -95,6 +95,7 @@ async function mountWorld(options: { installedVersions?: Readonly> spawn?: SpawnScript withScreen?: boolean + withLocale?: boolean } = {}) { const home = mkdtempTracked('mayfly-plugin-cmd-') const root = join(home, '.dsh', 'profiles', 'mayfly') @@ -148,7 +149,7 @@ async function mountWorld(options: { // still needs one for the settings thunk. if (mayfly === undefined) new InteractionStateService(ctx, settingsPlugin.DEFAULT_SETTINGS) // The Service constructor registers itself; the fakes ship no locale. - if (mayfly !== undefined) new MayflyLocaleService(ctx, { systemLocale: 'en' }) + if (mayfly !== undefined && options.withLocale !== false) new MayflyLocaleService(ctx, { systemLocale: 'en' }) await ctx.plugin(SessionStore) await ctx.plugin(CommandRuntime) const session = ctx.sessions.create(SessionId('plugin-spec')) @@ -927,3 +928,127 @@ async function currentAgent(ctx: Context): Promise { const session = ctx.sessions.create(SessionId('disposer-spec')) return { id: session.id, session, status: 'idle' } as never } + +describe('badge and patch-shape arms', () => { + it('shows the installed badge without an update in catalog mode', async () => { + const world = await mountWorld({ + index: [entry()], + profileDependencies: { 'dsh-loop': '0.1.4' }, + installedVersions: { 'dsh-loop': '0.1.4' }, + }) + await world.run('/plugin') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('installed') + world.dispose() + }) + + it('appends patch rows to a file without a trailing newline', async () => { + const withPatch = entry({ install: { rows: [{ id: 'nl', name: 'pkg-nl', activation: 'profile-patch', npm: { spec: 'pkg-nl' } }] } }) + const root = mkdtempTracked('mayfly-install-') + updaterInternals.spawnOnce = vi.fn(async () => ok()) + writeFileSync(join(root, 'cordis.patch.yml'), "- id: keep\n name: 'keep-me'") + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: withPatch, source: 'npm' }) + expect(outcome.kind).toBe('success') + const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' + expect(patch).toContain('keep-me') + expect(patch).toContain('"pkg-nl"') + // allowBuilds block lands after content lacking a trailing newline too. + const allowEntry = entry({ install: { allowBuilds: ['node-pty'], rows: [{ id: 't', name: 'pkg-t', activation: 'profile-patch', npm: { spec: 'x' } }] } }) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .') + await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: allowEntry, source: 'npm' }) + expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') + }) +}) + +describe('panel arms without a locale service', () => { + it('renders English descriptions when no locale service is mounted', async () => { + const world = await mountWorld({ index: [entry()], withLocale: false }) + await world.run('/plugin') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('Recurring prompts and alarms.') + world.dispose() + }) +}) + +describe('detail-shape arms', () => { + it('renders installed details, update rows, tools-only and commands-only provides', async () => { + const toolsOnly = entry({ id: 'tools-only', displayName: 'Tools Only', provides: { tools: ['a_tool'] } }) + const commandsOnly = entry({ id: 'commands-only', displayName: 'Commands Only', provides: { commands: ['/cmd'] } }) + const tuiOnly = entry({ id: 'tui-only', displayName: 'Tui Only', surfaces: { tui: { contributions: ['status'] } }, provides: {} }) + const webOnly = entry({ id: 'web-only2', displayName: 'Web Only 2', surfaces: { web: { clientModule: true } }, provides: {} }) + const world = await mountWorld({ + index: [entry(), toolsOnly, commandsOnly, tuiOnly, webOnly], + profileDependencies: { 'dsh-loop': '0.1.3' }, + installedVersions: { 'dsh-loop': '0.1.3' }, + }) + // Installed with an update available: the Version row shows the update. + await world.run('/plugin info loop') + expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('update available') + for (const id of ['tools-only', 'commands-only', 'tui-only', 'web-only2']) { + await world.run(`/plugin info ${id}`) + expect(world.overlay()).toBeDefined() + } + world.dispose() + }) + + it('covers bare-context installs, missing workspace files, and the r-unload gate', async () => { + const world = await mountWorld({ index: [entry()], withScreen: false }) + expect(await world.run('/plugin install loop')).toEqual({ kind: 'success' }) + world.dispose() + + // allowBuilds merge into a profile whose workspace file does not exist yet. + const allowEntry = entry({ install: { allowBuilds: ['node-pty'], rows: [{ id: 't', name: 'pkg-t', activation: 'profile-patch', npm: { spec: 'x' } }] } }) + const root = mkdtempTracked('mayfly-install-') + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: allowEntry, source: 'npm' }) + expect(outcome.kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') + + // The r-key refresh continuation gates on the fiber unload. + const rWorld = await mountWorld({}) + let release: ((value: string) => void) | undefined + const gate = new Promise(resolve => { + release = resolve + }) + const realFetch = updaterInternals.fetchText + updaterInternals.fetchText = vi.fn(async (url: string) => (url.includes('jsdelivr') || url.includes('raw.githubusercontent') ? gate : realFetch(url))) + await rWorld.run('/plugin') + const panel = rWorld.overlay() as { handleInput(data: string): void } + panel.handleInput('r') + await new Promise(resolve => setTimeout(resolve, 5)) + await rWorld.dispose() + release?.(indexJson([entry()])) + await new Promise(resolve => setTimeout(resolve, 10)) + }) +}) + +describe('final arms', () => { + it('reports uninstall failures and info for github-only and versionless rows', async () => { + const githubOnly = entry({ id: 'gh2', displayName: 'GH2', install: { rows: [{ name: 'gh2-pkg', github: { repo: 'a/b', ref: 'r' } }] } }) + const world = await mountWorld({ + index: [entry(), githubOnly], + profileDependencies: { 'dsh-loop': '0.1.4', 'gh2-pkg': 'github:a/b#r' }, + spawn: { plugin: () => ({ code: 1, signal: null, stdout: '', stderr: 'boom', timedOut: false }) }, + }) + await world.run('/plugin uninstall loop') + expect(world.notices.at(-1)).toBe('uninstall failed: removing "Loop" failed: boom') + // No node_modules version: the Version row falls back to installed. + await world.run('/plugin info loop') + expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('installed') + // GitHub-only rows render their github install command. + await world.run('/plugin info gh2') + expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('github:a/b#r') + world.dispose() + }) + + it('falls back to the generic removed note without a statusNote', async () => { + const world = await mountWorld({ + index: [entry({ id: 'silent-gone', displayName: 'Silent Gone', status: 'removed', install: { rows: [{ name: 'silent-pkg' }] } })], + profileDependencies: { 'silent-pkg': '1.0.0' }, + }) + await world.run('/plugin list') + expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('removed from the market') + world.dispose() + }) + +}) diff --git a/packages/mayfly/tests/interaction/plugin-market.spec.ts b/packages/mayfly/tests/interaction/plugin-market.spec.ts index 388b763..13fae23 100644 --- a/packages/mayfly/tests/interaction/plugin-market.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-market.spec.ts @@ -176,3 +176,31 @@ describe('readCache shape guard', () => { expect(result.status).toBe('fresh') }) }) + +describe('parseMarketIndex generatedAt arm', () => { + it('accepts a document without generatedAt', () => { + const index = parseMarketIndex('{"schemaVersion":1,"entries":[{"id":"x"}]}') + expect(index.entries).toHaveLength(1) + expect(index.generatedAt).toBeUndefined() + }) +}) + +describe('readCache field arms', () => { + it('treats a non-string cache text as absent', async () => { + mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) + updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 5, text: 5 })) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result.status).toBe('fresh') + }) +}) + +describe('non-Error rejection arm', () => { + it('reports string rejections from the fetch seam', async () => { + mountNetwork({}) + updaterInternals.fetchText = vi.fn(async () => { + throw 'plain string failure' + }) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) + expect(result).toMatchObject({ status: 'offline', message: expect.stringContaining('plain string failure') }) + }) +}) From 333ad534ef729fb987027a9b7c52bc809270c00c Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:05:05 +0800 Subject: [PATCH 3/8] fix(interaction): harden plugin marketplace operations --- packages/mayfly/package.json | 1 + .../mayfly/src/interaction/plugin-commands.ts | 68 ++++--- .../src/interaction/plugin-market/catalog.ts | 22 ++- .../interaction/plugin-market/installer.ts | 165 ++++++++++------ .../src/interaction/plugin-market/types.ts | 92 ++++++++- .../tests/interaction/plugin-commands.spec.ts | 186 ++++++++++++++++-- .../tests/interaction/plugin-market.spec.ts | 76 ++++++- pnpm-lock.yaml | 3 + 8 files changed, 481 insertions(+), 132 deletions(-) diff --git a/packages/mayfly/package.json b/packages/mayfly/package.json index 4cf6066..83f9a0f 100644 --- a/packages/mayfly/package.json +++ b/packages/mayfly/package.json @@ -71,6 +71,7 @@ "commander": "^15.0.0", "semver": "7.8.5", "simple-ascii-chart": "6.0.0", + "yaml": "2.9.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index 983389e..d8764bb 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -25,6 +25,7 @@ import { interactionTranslator, observeInteractionLocale } from './locale.ts' import { currentMayflySettings } from './settings.ts' import { DEFAULT_MARKET_INDEX_URL, loadMarketCatalog, type CatalogResult } from './plugin-market/catalog.ts' import { + defaultInstallSource, entryInstallStates, entrySupportsSource, installEntry, @@ -55,6 +56,8 @@ export function registerPluginCommand(ctx: Context): () => void { }) /** The loaded catalog; `undefined` until the first load settles. */ let catalog: CatalogResult | undefined + /** Latest load claim; slower earlier requests cannot replace newer data. */ + let reloadGeneration = 0 /** Plugins the profile carries; reread after every install or removal. */ let installed: readonly InstalledPlugin[] = [] /** One install or removal at a time, like the updater's in-flight guard. */ @@ -72,13 +75,15 @@ export function registerPluginCommand(ctx: Context): () => void { const indexUrl = (): string => currentMayflySettings(ctx).marketIndexUrl || DEFAULT_MARKET_INDEX_URL /** Load (or force-reload) the catalog and refresh derived profile state. */ - const reload = (force: boolean): Promise => - loadMarketCatalog(indexUrl(), force).then(result => { - if (unloaded) return result + const reload = (force: boolean): Promise => { + const generation = ++reloadGeneration + return loadMarketCatalog(indexUrl(), force).then(result => { + if (unloaded || generation !== reloadGeneration) return result catalog = result installed = readInstalledPlugins(profileRoot(profileNameFromArgv(process.argv))) return result }) + } /** Entries currently on hand (empty while offline or unloaded). */ const entries = (): readonly MarketEntry[] => { @@ -170,12 +175,11 @@ export function registerPluginCommand(ctx: Context): () => void { /** The copyable manual install command for an entry's default source. */ const installCommand = (entry: MarketEntry): string => { - const row = entry.install.rows[0] - /* v8 ignore next -- the manifest schema guarantees a first row with a source */ - if (row === undefined || (row.npm === undefined && row.github === undefined)) { + const source = defaultInstallSource(entry) + if (source === undefined) { return `dsh plugin --profile add <${entry.id}>` } - return `dsh plugin --profile add ${row.npm?.spec ?? rowSpec(row, 'github')}` + return `dsh plugin --profile add ${entry.install.rows.map(row => rowSpec(row, source)).join(' ')}` } /** The read-only detail panel for one entry. */ @@ -278,27 +282,19 @@ export function registerPluginCommand(ctx: Context): () => void { /** Rows for the installed mode: profile deps joined against the index. */ const installedItems = (): readonly FrontendPanelItem[] => { - const byName = new Map(entries().flatMap(entry => entry.install.rows.map(row => [row.name, entry] as const))) + const installedByName = new Map(installed.map(plugin => [plugin.name, plugin])) + const indexedNames = new Set(entries().flatMap(entry => entry.install.rows.map(row => row.name))) const state = states() const rank = { installed: 0, updates: 1, removed: 2 } as const - const rows = installed.flatMap((plugin): readonly FrontendPanelItem[] => { - const entry = byName.get(plugin.name) - if (entry === undefined) { - // Not in the index at all: the plugin left the market (or predates it). - return [{ - id: plugin.name, - label: plugin.name, - detail: plugin.spec, - badge: 'removed', - group: 'removed', - }] - } + const marketRows = entries().flatMap((entry): readonly FrontendPanelItem[] => { + const present = entry.install.rows.filter(row => installedByName.has(row.name)) + if (present.length === 0) return [] const entryState = state[entry.id] const removed = entry.status === 'removed' const update = entryState?.updateAvailable === true - const latest = entry.npm?.[plugin.name]?.latestVersion + const latest = entry.install.rows.map(row => entry.npm?.[row.name]?.latestVersion).find(version => version != null) const pieces = [ - plugin.version, + entryState?.installed === true ? entryState.version : 'partial', update && latest !== null && latest !== undefined ? `up ${latest}` : undefined, removed ? 'removed' : undefined, ].filter((piece): piece is string => piece !== undefined) @@ -312,6 +308,14 @@ export function registerPluginCommand(ctx: Context): () => void { actionLabel: t('Details'), }] }) + const removedRows = installed.filter(plugin => !indexedNames.has(plugin.name)).map(plugin => ({ + id: plugin.name, + label: plugin.name, + detail: plugin.spec, + badge: 'removed', + group: 'removed', + })) + const rows = [...marketRows, ...removedRows] /* v8 ignore next -- every row above sets one of the three groups */ return [...rows].sort((a, b) => (rank[a.group as keyof typeof rank] ?? 0) - (rank[b.group as keyof typeof rank] ?? 0)) } @@ -349,7 +353,12 @@ export function registerPluginCommand(ctx: Context): () => void { if (action === 'install' && usefulInTui(entry) === false) { getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) } - void operate(entry, action, 'npm').then(() => { + const source = defaultInstallSource(entry) + if (action === 'install' && source === undefined) { + getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" has no common install source for every package`) + return + } + void operate(entry, action, source ?? 'npm').then(() => { if (unloaded) return panel.invalidate() display.screen.requestRender() @@ -479,18 +488,27 @@ export function registerPluginCommand(ctx: Context): () => void { return { kind: 'error', text: `usage: /plugin ${verb} [--source npm|github]` } } const sourceIndex = tokens.indexOf('--source') - const source: InstallSource = sourceIndex !== -1 && tokens[sourceIndex + 1] === 'github' ? 'github' : 'npm' + const requestedSource = sourceIndex === -1 ? undefined : tokens[sourceIndex + 1] + if (sourceIndex !== -1 && requestedSource !== 'npm' && requestedSource !== 'github') { + return { kind: 'error', text: `usage: /plugin ${verb} [--source npm|github]` } + } if (catalog === undefined) await reload(false) if (unloaded) return { kind: 'success' } const entry = findEntry(id) if (entry === undefined) return { kind: 'error', text: t('unknown plugin: {id}', { id }) } + const source: InstallSource | undefined = requestedSource === 'npm' || requestedSource === 'github' + ? requestedSource + : defaultInstallSource(entry) + if (verb === 'install' && source === undefined) { + return { kind: 'error', text: `"${entry.displayName}" has no common install source for every package` } + } if (verb === 'uninstall' && states()[entry.id]?.installed !== true) { return { kind: 'error', text: `"${entry.displayName}" is not installed in this profile` } } if (verb === 'install' && usefulInTui(entry) === false) { getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) } - await operate(entry, verb, source) + await operate(entry, verb, source ?? 'npm') return { kind: 'success' } } return { kind: 'error', text: 'usage: /plugin [install | uninstall | info | list | refresh]' } diff --git a/packages/mayfly/src/interaction/plugin-market/catalog.ts b/packages/mayfly/src/interaction/plugin-market/catalog.ts index 980edb1..4adf894 100644 --- a/packages/mayfly/src/interaction/plugin-market/catalog.ts +++ b/packages/mayfly/src/interaction/plugin-market/catalog.ts @@ -26,6 +26,8 @@ const FETCH_TIMEOUT_MS = 15_000 /** The cached document plus when it was stored, epoch milliseconds. */ interface CacheDoc { readonly fetchedAt: number + readonly indexUrl: string + readonly index: MarketIndex readonly text: string } @@ -67,23 +69,23 @@ async function fetchIndex(indexUrl: string): Promise { } /** Read the cache document, `undefined` when absent or unparsable. */ -function readCache(): CacheDoc | undefined { +function readCache(indexUrl: string): CacheDoc | undefined { const text = updaterInternals.readTextFile(marketCachePath()) if (text === undefined) return undefined try { const parsed: unknown = JSON.parse(text) if (typeof parsed !== 'object' || parsed === null) return undefined const doc = parsed as Record - if (typeof doc.fetchedAt !== 'number' || typeof doc.text !== 'string') return undefined - return { fetchedAt: doc.fetchedAt, text: doc.text } + if (typeof doc.fetchedAt !== 'number' || doc.indexUrl !== indexUrl || typeof doc.text !== 'string') return undefined + return { fetchedAt: doc.fetchedAt, indexUrl, index: parseMarketIndex(doc.text), text: doc.text } } catch { return undefined } } /** Persist a fetched document to the cache slot. */ -function writeCache(text: string): void { - updaterInternals.writeTextFile(marketCachePath(), `${JSON.stringify({ fetchedAt: updaterInternals.now(), text })}\n`) +function writeCache(indexUrl: string, text: string): void { + updaterInternals.writeTextFile(marketCachePath(), `${JSON.stringify({ fetchedAt: updaterInternals.now(), indexUrl, text })}\n`) } /** @@ -99,19 +101,19 @@ function writeCache(text: string): void { * @returns the catalog outcome. */ export async function loadMarketCatalog(indexUrl: string, force = false): Promise { - const cached = force ? undefined : readCache() - if (cached !== undefined && updaterInternals.now() - cached.fetchedAt < MARKET_CACHE_TTL_MS) { - return { status: 'fresh', index: parseMarketIndex(cached.text) } + const cached = readCache(indexUrl) + if (!force && cached !== undefined && updaterInternals.now() - cached.fetchedAt < MARKET_CACHE_TTL_MS) { + return { status: 'fresh', index: cached.index } } try { const index = await fetchIndex(indexUrl) - writeCache(JSON.stringify(index)) + writeCache(indexUrl, JSON.stringify(index)) return { status: 'fresh', index } } catch (error) { /* v8 ignore next -- both fetch legs and the parser throw Error instances */ const message = error instanceof Error ? error.message : String(error) if (cached !== undefined) { - return { status: 'stale', index: parseMarketIndex(cached.text), message } + return { status: 'stale', index: cached.index, message } } return { status: 'offline', message } } diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts index 52f904b..fe1bb59 100644 --- a/packages/mayfly/src/interaction/plugin-market/installer.ts +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -11,6 +11,7 @@ */ import { join } from 'node:path' +import { isMap, isSeq, parseDocument, type YAMLMap } from 'yaml' import { updaterInternals, type SpawnOutcome } from '../updater/io.ts' import type { MarketEntry, MarketInstallRow } from './types.ts' @@ -42,9 +43,16 @@ export function rowSpec(row: MarketInstallRow, source: InstallSource): string | return `github:${github.repo}#${github.ref}${github.subdir === undefined ? '' : `&path:${github.subdir}`}` } -/** Whether an entry is installable from the given source at all. */ +/** Whether every row of an entry is installable from the given source. */ export function entrySupportsSource(entry: MarketEntry, source: InstallSource): boolean { - return entry.install.rows.some(row => rowSpec(row, source) !== undefined) + return entry.install.rows.length > 0 && entry.install.rows.every(row => rowSpec(row, source) !== undefined) +} + +/** The preferred source that can install every row, npm first. */ +export function defaultInstallSource(entry: MarketEntry): InstallSource | undefined { + if (entrySupportsSource(entry, 'npm')) return 'npm' + if (entrySupportsSource(entry, 'github')) return 'github' + return undefined } /** The pnpm error signature the allowBuilds hint keys on. */ @@ -61,6 +69,12 @@ function describeFailure(target: string, outcome: SpawnOutcome): string { return `${target} failed: ${tail}${hint}` } +/** Text for filesystem and YAML failures. */ +function errorText(error: unknown): string { + /* v8 ignore next -- the default filesystem and YAML seams throw Error instances */ + return error instanceof Error ? error.message : String(error) +} + /** * Merge the entry's `allowBuilds` names into the profile workspace file so * pnpm may run exactly those build scripts (native addons). Idempotent. @@ -68,76 +82,77 @@ function describeFailure(target: string, outcome: SpawnOutcome): string { function ensureAllowBuilds(root: string, names: readonly string[]): void { if (names.length === 0) return const path = join(root, 'pnpm-workspace.yaml') - const existing = updaterInternals.readTextFile(path) ?? '' - const missing = names.filter(name => - !new RegExp(`^\\s*"?(?:${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})"?\\s*:\\s*true\\s*$`, 'm').test(existing)) - if (missing.length === 0) return - let block = existing.length > 0 && !existing.endsWith('\n') ? `${existing}\n` : existing - if (!/^allowBuilds:/m.test(existing)) block += 'allowBuilds:\n' - for (const name of missing) block += ` ${JSON.stringify(name)}: true\n` - updaterInternals.writeTextFile(path, block) + const existing = updaterInternals.readTextFile(path) + const doc = parseDocument(existing?.trim() === '' || existing === undefined ? '{}\n' : existing) + if (doc.errors.length > 0 || !isMap(doc.contents)) { + throw new Error(`pnpm-workspace.yaml must be a YAML mapping: ${doc.errors[0]?.message ?? 'found another document shape'}`) + } + const allowBuilds = doc.get('allowBuilds', true) + if (allowBuilds !== undefined && !isMap(allowBuilds)) { + throw new Error('pnpm-workspace.yaml allowBuilds must be a mapping') + } + let changed = false + for (const name of names) { + if (doc.getIn(['allowBuilds', name]) === true) continue + doc.setIn(['allowBuilds', name], true) + changed = true + } + if (changed) { + doc.contents.flow = false + updaterInternals.writeTextFile(path, String(doc)) + } } -/** Render one `- id: … name: …` block for the profile patch. */ -function renderPatchRow(row: MarketInstallRow): string { - /* v8 ignore next -- profile-patch rows always carry an id */ - const id = row.id ?? row.name - const config = row.config === undefined ? '' : ` config:\n${Object.entries(row.config) - .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`).join('\n')}\n` - return `- id: ${id}\n name: ${JSON.stringify(row.name)}\n${config}` +/** A validated user-patch edit, applied only after the package operation. */ +interface PatchEdit { + readonly path: string + readonly text: string } -/** - * Append the entry's `profile-patch` rows to the profile's user patch layer. - * The file is a top-level YAML sequence (dsh writes an empty one on profile - * init), so an `[]` body is replaced and anything else gains blocks at the - * end. - */ -function appendProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): void { - if (rows.length === 0) return +/** Prepare idempotent `profile-patch` row insertion without writing it yet. */ +function prepareProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): PatchEdit | undefined { + if (rows.length === 0) return undefined const path = join(root, 'cordis.patch.yml') - const existing = updaterInternals.readTextFile(path) ?? '' - const body = existing.trim() === '[]' || existing.trim() === '' - ? `${existing.replace(/\[\]\s*$/, '').trimEnd()}\n` - : existing.endsWith('\n') ? existing : `${existing}\n` - updaterInternals.writeTextFile(path, body + rows.map(renderPatchRow).join('')) -} - -/** Strip either YAML quoting style from a scalar so hand-written single - * quotes and the installer's JSON double quotes compare equal. */ -function unquote(value: string): string { - const quoted = /^(["'])(.*)\1$/u.exec(value) - return quoted === null ? value : quoted[2]! + const existing = updaterInternals.readTextFile(path) + const doc = parseDocument(existing?.trim() === '' || existing === undefined ? '[]\n' : existing) + if (doc.errors.length > 0 || !isSeq(doc.contents)) { + throw new Error(`cordis.patch.yml must be a YAML sequence: ${doc.errors[0]?.message ?? 'found another document shape'}`) + } + let changed = false + for (const row of rows) { + const id = row.id ?? row.name + const present = doc.contents.items.find(item => isMap(item) && (item.get('id') as unknown) === id) as YAMLMap | undefined + if (present !== undefined) { + if ((present.get('name') as unknown) === row.name) continue + throw new Error(`cordis.patch.yml already has id ${JSON.stringify(id)} for another package`) + } + doc.add({ id, name: row.name, ...(row.config === undefined ? {} : { config: row.config }) }) + changed = true + } + if (changed) { + doc.contents.flow = false + return { path, text: String(doc) } + } + return undefined } -/** Remove this entry's `profile-patch` blocks from the user patch layer. */ -function removeProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): void { +/** Prepare removal of this entry's exact `id + name` rows without writing. */ +function prepareProfilePatchRemoval(root: string, rows: readonly MarketInstallRow[]): PatchEdit | undefined { /* v8 ignore next -- the manifest schema guarantees at least one row */ - if (rows.length === 0) return + if (rows.length === 0) return undefined const path = join(root, 'cordis.patch.yml') const existing = updaterInternals.readTextFile(path) - if (existing === undefined) return - const names = new Set(rows.map(row => row.name)) - // Blocks start at a `- ` line and run to the next one; a block is ours when - // any of its lines names one of our packages. - const lines = existing.split('\n') - const kept: string[] = [] - let block: string[] = [] - const flush = (): void => { - if (block.length === 0) return - const isOurs = block.some(line => { - const quoted = line.match(/name:\s*(.+?)\s*$/)?.[1] - return quoted !== undefined && names.has(unquote(quoted)) - }) - if (!isOurs) kept.push(...block) - block = [] + if (existing === undefined) return undefined + const doc = parseDocument(existing) + if (doc.errors.length > 0 || !isSeq(doc.contents)) { + throw new Error(`cordis.patch.yml must be a YAML sequence: ${doc.errors[0]?.message ?? 'found another document shape'}`) } - for (const line of lines) { - if (/^-\s/.test(line)) flush() - block.push(line) - } - flush() - updaterInternals.writeTextFile(path, kept.join('\n')) + const keys = new Set(rows.map(row => `${row.id ?? row.name}\0${row.name}`)) + const kept = doc.contents.items.filter(item => + !isMap(item) || !keys.has(`${String((item.get('id') as unknown) ?? '')}\0${String((item.get('name') as unknown) ?? '')}`)) + if (kept.length === doc.contents.items.length) return undefined + doc.contents.items = kept + return { path, text: String(doc) } } /** The outcome of an install or removal. */ @@ -167,17 +182,27 @@ export interface InstallerInput { export async function installEntry(input: InstallerInput): Promise { const rows = input.entry.install.rows const specs = rows.map(row => rowSpec(row, input.source)).filter((spec): spec is string => spec !== undefined) - if (specs.length === 0) { + if (specs.length !== rows.length || specs.length === 0) { return { kind: 'error', text: `"${input.entry.displayName}" has no ${input.source} install source` } } - ensureAllowBuilds(input.root, input.entry.install.allowBuilds ?? []) + let patchEdit: PatchEdit | undefined + try { + patchEdit = prepareProfilePatchRows(input.root, rows.filter(row => row.activation === 'profile-patch')) + ensureAllowBuilds(input.root, input.entry.install.allowBuilds ?? []) + } catch (error) { + return { kind: 'error', text: `preparing "${input.entry.displayName}" failed: ${errorText(error)}` } + } const outcome = await updaterInternals.spawnOnce(input.dshBin, ['plugin', '--profile', input.profile, 'add', ...specs], { cwd: input.root, timeoutMs: INSTALL_TIMEOUT_MS }) if (outcome.code !== 0) { return { kind: 'error', text: describeFailure(`installing "${input.entry.displayName}"`, outcome) } } - appendProfilePatchRows(input.root, rows.filter(row => row.activation === 'profile-patch')) + try { + if (patchEdit !== undefined) updaterInternals.writeTextFile(patchEdit.path, patchEdit.text) + } catch (error) { + return { kind: 'error', text: `packages installed but activating "${input.entry.displayName}" failed: ${errorText(error)}` } + } return { kind: 'success' } } @@ -185,13 +210,23 @@ export async function installEntry(input: InstallerInput): Promise { const names = input.entry.install.rows.map(row => row.name) + let patchEdit: PatchEdit | undefined + try { + patchEdit = prepareProfilePatchRemoval(input.root, input.entry.install.rows) + } catch (error) { + return { kind: 'error', text: `preparing removal of "${input.entry.displayName}" failed: ${errorText(error)}` } + } const outcome = await updaterInternals.spawnOnce(input.dshBin, ['plugin', '--profile', input.profile, 'remove', ...names], { cwd: input.root, timeoutMs: INSTALL_TIMEOUT_MS }) if (outcome.code !== 0) { return { kind: 'error', text: describeFailure(`removing "${input.entry.displayName}"`, outcome) } } - removeProfilePatchRows(input.root, input.entry.install.rows) + try { + if (patchEdit !== undefined) updaterInternals.writeTextFile(patchEdit.path, patchEdit.text) + } catch (error) { + return { kind: 'error', text: `packages removed but cleaning up "${input.entry.displayName}" failed: ${errorText(error)}` } + } return { kind: 'success' } } diff --git a/packages/mayfly/src/interaction/plugin-market/types.ts b/packages/mayfly/src/interaction/plugin-market/types.ts index ca48da3..eceab95 100644 --- a/packages/mayfly/src/interaction/plugin-market/types.ts +++ b/packages/mayfly/src/interaction/plugin-market/types.ts @@ -8,6 +8,8 @@ * @module @ephemeral-ai/mayfly/interaction/plugin-market/types */ +import { z } from 'zod' + /** One install unit of a marketplace entry. */ export interface MarketInstallRow { /** cordis patch row id (required by `profile-patch` activation). */ @@ -59,7 +61,7 @@ export interface MarketEntry { readonly capabilities?: readonly string[] readonly verified?: { readonly at: string - readonly packages: readonly { readonly name: string, readonly version: string, readonly integrity?: string }[] + readonly packages: readonly { readonly name: string, readonly version: string, readonly integrity?: string | null }[] } /** npm enrichment keyed by row package name (build-time, from the registry). */ readonly npm?: Readonly> @@ -76,6 +78,76 @@ export interface MarketIndex { /** The index schema version this consumer understands. */ export const MARKET_INDEX_SCHEMA_VERSION = 1 +const installRowSchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().regex(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-._~]+$/u), + activation: z.enum(['bundle', 'profile-patch']).optional(), + config: z.record(z.string(), z.unknown()).optional(), + npm: z.object({ spec: z.string().min(1) }).strict().optional(), + github: z.object({ + repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u), + ref: z.string().min(1).max(80), + subdir: z.string().regex(/^[^/].*$/u).optional(), + }).strict().optional(), +}).strict().refine(row => row.npm !== undefined || row.github !== undefined, { + message: 'install row needs an npm or github source', +}) + +const npmInfoSchema = z.object({ + latestVersion: z.string().nullable(), + integrity: z.string().nullable().optional(), + publishedAt: z.string().nullable().optional(), + downloadsMonth: z.number().nullable().optional(), +}) + +const entrySchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/u).max(64), + source: z.enum(['official', 'dsh', 'community']), + displayName: z.string().min(1).max(64), + description: z.string().min(1).max(300), + descriptionZh: z.string().min(1).max(300).optional(), + author: z.object({ name: z.string().min(1), url: z.string().optional() }).strict(), + links: z.object({ repo: z.string().optional(), docs: z.string().optional(), npm: z.string().optional() }).strict().optional(), + license: z.string().optional(), + category: z.string().min(1), + status: z.enum(['stable', 'beta', 'unstable', 'deprecated', 'removed']), + statusNote: z.string().optional(), + surfaces: z.object({ + server: z.object({}).strict().optional(), + web: z.object({ clientModule: z.boolean() }).strict().optional(), + tui: z.object({ contributions: z.array(z.string()) }).strict().optional(), + }).strict().refine(value => value.server !== undefined || value.web !== undefined || value.tui !== undefined, { + message: 'at least one surface is required', + }), + provides: z.object({ + tools: z.array(z.string()).optional(), + commands: z.array(z.string()).optional(), + }).strict().optional(), + install: z.object({ + rows: z.array(installRowSchema).min(1), + allowBuilds: z.array(z.string().min(1)).min(1).optional(), + }).strict(), + engines: z.object({ dsh: z.string().optional(), mayfly: z.string().optional(), node: z.string().optional() }).strict().nullish() + .transform(value => value ?? undefined), + capabilities: z.array(z.string()).optional(), + verified: z.object({ + at: z.string(), + packages: z.array(z.object({ + name: z.string(), + version: z.string(), + integrity: z.string().nullable().optional(), + }).strict()), + }).strict().optional(), + npm: z.record(z.string(), npmInfoSchema).optional(), + readmeExcerpt: z.string().nullable().optional(), +}) + +const indexSchema = z.object({ + schemaVersion: z.literal(MARKET_INDEX_SCHEMA_VERSION), + generatedAt: z.string().optional(), + entries: z.array(entrySchema), +}) + /** * Parse and guard an index document. Unknown schema versions reject loudly * instead of rendering half-understood entries. @@ -93,13 +165,15 @@ export function parseMarketIndex(text: string): MarketIndex { throw new Error(`market index is not valid JSON: ${error instanceof Error ? error.message : String(error)}`) } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('market index is not an object') - const index = parsed as Record - if (index.schemaVersion !== MARKET_INDEX_SCHEMA_VERSION) { - throw new Error(`market index schema version ${String(index.schemaVersion)} is not supported (expected ${String(MARKET_INDEX_SCHEMA_VERSION)}) — update Mayfly`) + const record = parsed as Record + if (record.schemaVersion !== MARKET_INDEX_SCHEMA_VERSION) { + throw new Error(`market index schema version ${String(record.schemaVersion)} is not supported (expected ${String(MARKET_INDEX_SCHEMA_VERSION)}) — update Mayfly`) + } + if (!Array.isArray(record.entries)) throw new Error('market index has no entries array') + const result = indexSchema.safeParse(parsed) + if (!result.success) { + const issue = result.error.issues[0]! + throw new Error(`market index is invalid at ${issue.path.join('.')}: ${issue.message}`) } - if (!Array.isArray(index.entries)) throw new Error('market index has no entries array') - const generatedAt = typeof index.generatedAt === 'string' ? index.generatedAt : undefined - return generatedAt === undefined - ? { schemaVersion: MARKET_INDEX_SCHEMA_VERSION, entries: index.entries as readonly MarketEntry[] } - : { schemaVersion: MARKET_INDEX_SCHEMA_VERSION, generatedAt, entries: index.entries as readonly MarketEntry[] } + return result.data as MarketIndex } diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index b07e096..fa2a489 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -15,13 +15,14 @@ import { Context } from '@deepseek-ai/cordis' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' +import { parse as parseYaml } from 'yaml' import { mkdtempTracked, registerTempDirCleanup } from '../core/temp-dir.ts' registerTempDirCleanup() import { updaterInternals, type SpawnOutcome } from '../../src/interaction/updater/io.ts' import { setSharedEditor } from '../../src/interaction/editor-instance.ts' import { registerPluginCommand } from '../../src/interaction/plugin-commands.ts' -import { entryInstallStates, readInstalledPlugins, rowSpec, installEntry, uninstallEntry, entrySupportsSource, MAYFLY_PACKAGE } from '../../src/interaction/plugin-market/installer.ts' +import { defaultInstallSource, entryInstallStates, readInstalledPlugins, rowSpec, installEntry, uninstallEntry, entrySupportsSource, MAYFLY_PACKAGE } from '../../src/interaction/plugin-market/installer.ts' import * as settingsPlugin from '../../src/interaction/settings.ts' import { InteractionStateService } from '../../src/interaction/runtime-state.ts' import { fakeMayflyContext, KEY, type FakeScreen } from './fakes.ts' @@ -105,7 +106,7 @@ async function mountWorld(options: { private: true, dependencies: { '@ephemeral-ai/mayfly': '0.1.0-alpha.1', - ...(options.profileDependencies ?? {}), + ...options.profileDependencies, }, dsh: { profile: { bundles: ['@ephemeral-ai/mayfly'] } }, })) @@ -193,6 +194,18 @@ describe('installer unit seams', () => { expect(entrySupportsSource({ ...entry(), install: { rows: [{ name: 'x', github: { repo: 'a/b', ref: 'r' } }] } }, 'npm')).toBe(false) }) + it('chooses a source only when it covers every install row', () => { + const githubOnly = entry({ install: { rows: [{ name: 'a', github: { repo: 'a/b', ref: 'r' } }] } }) + const mixed = entry({ install: { rows: [ + { name: 'a', npm: { spec: 'a' } }, + { name: 'b', github: { repo: 'a/b', ref: 'r' } }, + ] } }) + expect(defaultInstallSource(entry())).toBe('npm') + expect(defaultInstallSource(githubOnly)).toBe('github') + expect(defaultInstallSource(mixed)).toBeUndefined() + expect(entrySupportsSource(mixed, 'npm')).toBe(false) + }) + it('reads installed plugins, skipping Mayfly itself', () => { const root = mkdtempTracked('mayfly-installed-') writeFileSync(join(root, 'package.json'), JSON.stringify({ @@ -234,12 +247,14 @@ describe('installer unit seams', () => { }) updaterInternals.spawnOnce = vi.fn(async () => ok()) const root = mkdtempTracked('mayfly-install-') - writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\n') - writeFileSync(join(root, 'cordis.patch.yml'), '[]\n') + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\nallowBuilds:\n node-pty: false\noverrides:\n keep: true\n') + writeFileSync(join(root, 'cordis.patch.yml'), '# User patch layer\n[]\n') const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) expect(outcome.kind).toBe('success') - expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') - expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toContain(`- id: terminal-bash\n name: "@deepseek-ai/dsh-terminal-bash"`) + const workspace = parseYaml(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml')) ?? '') as Record + expect(workspace).toMatchObject({ allowBuilds: { 'node-pty': true }, overrides: { keep: true } }) + const patch = parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '') as readonly Record[] + expect(patch).toContainEqual({ id: 'terminal-bash', name: '@deepseek-ai/dsh-terminal-bash' }) }) it('appending to a non-empty patch layer keeps existing rows, and config renders', async () => { @@ -269,6 +284,10 @@ describe('installer unit seams', () => { " name: 'pkg-a'", ' config:', ' x: 1', + '- id: user-a', + " name: 'pkg-a'", + ' config:', + ' keep: true', '- id: after', " name: 'pkg-after'", ].join('\n') + '\n') @@ -277,7 +296,9 @@ describe('installer unit seams', () => { const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' expect(patch).toContain('keep-me') expect(patch).toContain('pkg-after') - expect(patch).not.toContain("'pkg-a'") + expect(patch).toContain('user-a') + expect(patch).toContain("'pkg-a'") + expect(patch).toContain('keep: true') expect(patch).not.toContain('x: 1') }) @@ -319,6 +340,71 @@ describe('installer unit seams', () => { await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }) expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toBe('packages:\n - .\n') }) + + it('refuses malformed workspace mappings before spawning', async () => { + const withBuild = entry({ install: { allowBuilds: ['node-pty'], rows: entry().install.rows } }) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + for (const source of ['[]\n', '[\n', 'allowBuilds: []\n']) { + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'pnpm-workspace.yaml'), source) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: withBuild, source: 'npm' }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('pnpm-workspace.yaml') }) + } + expect(updaterInternals.spawnOnce).not.toHaveBeenCalled() + }) + + it('reports invalid or conflicting patch documents without overwriting them', async () => { + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const withPatch = entry({ install: { rows: [{ id: 'wanted', name: 'pkg-wanted', activation: 'profile-patch', npm: { spec: 'pkg-wanted' } }] } }) + for (const source of ['{}\n', '[\n', '- id: wanted\n name: another-package\n']) { + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'cordis.patch.yml'), source) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: withPatch, source: 'npm' }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('cordis.patch.yml') }) + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toBe(source) + } + }) + + it('preserves tagged user config and uses the package name when a patch id is absent', async () => { + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'cordis.patch.yml'), '- id: keep\n name: keep\n config:\n value: !!js return 1\n') + const noId = entry({ install: { rows: [{ name: 'pkg-no-id', activation: 'profile-patch', npm: { spec: 'pkg-no-id' } }] } }) + expect((await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: noId, source: 'npm' })).kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toContain('!!js return 1') + expect((await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: noId, source: 'npm' })).kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).not.toContain('pkg-no-id') + }) + + it('reports invalid patch cleanup and leaves unrelated sequence items intact', async () => { + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const target = entry({ install: { rows: [{ id: 'target', name: 'pkg-target', activation: 'profile-patch', npm: { spec: 'pkg-target' } }] } }) + for (const source of ['{}\n', '[\n']) { + const root = mkdtempTracked('mayfly-uninstall-') + writeFileSync(join(root, 'cordis.patch.yml'), source) + const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: target, source: 'npm' }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('cordis.patch.yml') }) + } + const root = mkdtempTracked('mayfly-uninstall-') + writeFileSync(join(root, 'cordis.patch.yml'), '- scalar\n- name: no-id\n- id: orphan\n- id: target\n name: pkg-target\n') + expect((await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: target, source: 'npm' })).kind).toBe('success') + expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual(['scalar', { name: 'no-id' }, { id: 'orphan' }]) + }) + + it('reports patch write failures after successful package operations', async () => { + updaterInternals.spawnOnce = vi.fn(async () => ok()) + const withPatch = entry({ install: { rows: [{ id: 'write', name: 'pkg-write', activation: 'profile-patch', npm: { spec: 'pkg-write' } }] } }) + const installRoot = mkdtempTracked('mayfly-install-') + writeFileSync(join(installRoot, 'cordis.patch.yml'), '[]\n') + updaterInternals.writeTextFile = () => { throw new Error('disk full') } + expect(await installEntry({ dshBin: 'dsh', profile: 'p', root: installRoot, entry: withPatch, source: 'npm' })) + .toMatchObject({ kind: 'error', text: expect.stringContaining('activating') }) + + const uninstallRoot = mkdtempTracked('mayfly-uninstall-') + writeFileSync(join(uninstallRoot, 'cordis.patch.yml'), '- id: write\n name: pkg-write\n') + expect(await uninstallEntry({ dshBin: 'dsh', profile: 'p', root: uninstallRoot, entry: withPatch, source: 'npm' })) + .toMatchObject({ kind: 'error', text: expect.stringContaining('cleaning up') }) + }) }) describe('/plugin browse panel', () => { @@ -461,7 +547,7 @@ describe('/plugin argument paths', () => { }) it('list groups installed rows by state, including removed-from-market', async () => { - const gone = entry({ id: 'gone', displayName: 'Gone', status: 'removed', statusNote: 'yanked', install: { rows: [{ name: 'gone-pkg' }] } }) + const gone = entry({ id: 'gone', displayName: 'Gone', status: 'removed', statusNote: 'yanked', install: { rows: [{ name: 'gone-pkg', npm: { spec: 'gone-pkg' } }] } }) const updated = entry({ id: 'loop', npm: { 'dsh-loop': { latestVersion: '0.1.5' } } }) const fresh = entry({ id: 'fresh', displayName: 'Fresh', install: { rows: [{ name: 'dsh-fresh-pkg', npm: { spec: 'dsh-fresh-pkg' } }] } }) const world = await mountWorld({ @@ -498,6 +584,8 @@ describe('/plugin argument paths', () => { const world = await mountWorld({ index: [entry()] }) expect(await world.run('/plugin install nope')).toMatchObject({ kind: 'error', text: 'unknown plugin: nope' }) expect(await world.run('/plugin install')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) + expect(await world.run('/plugin install loop --source')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) + expect(await world.run('/plugin install loop --source archive')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) expect(await world.run('/plugin info')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) expect(await world.run('/plugin dance')).toMatchObject({ kind: 'error', text: expect.stringContaining('usage') }) world.dispose() @@ -535,6 +623,51 @@ describe('/plugin key paths', () => { expect(world.notices).toContain('"Loop" is not installed in this profile') world.dispose() }) + + it('handles a mixed-source entry without dropping any package rows', async () => { + const mixed = entry({ + id: 'mixed', + displayName: 'Mixed', + install: { rows: [ + { name: 'mixed-a', npm: { spec: 'mixed-a' } }, + { name: 'mixed-b', github: { repo: 'a/b', ref: 'r' } }, + ] }, + }) + const world = await mountWorld({ + index: [mixed], + profileDependencies: { 'mixed-a': '1.0.0', 'mixed-b': 'github:a/b#r' }, + installedVersions: { 'mixed-a': '1.0.0', 'mixed-b': '1.0.0' }, + }) + expect(await world.run('/plugin install mixed')).toMatchObject({ kind: 'error', text: expect.stringContaining('no common install source') }) + await world.run('/plugin info mixed') + expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('add ') + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void } + panel.handleInput('i') + panel.handleInput('u') + await new Promise(resolve => setTimeout(resolve, 5)) + expect(world.notices).toContain('"Mixed" has no common install source for every package') + expect(world.spawns.some(spawn => spawn.args.includes('remove'))).toBe(true) + expect(await world.run('/plugin uninstall mixed')).toEqual({ kind: 'success' }) + world.dispose() + }) + + it('renders a partially installed multi-row entry once', async () => { + const multi = entry({ + id: 'multi', + displayName: 'Multi Row', + install: { rows: [ + { name: 'multi-a', npm: { spec: 'multi-a' } }, + { name: 'multi-b', npm: { spec: 'multi-b' } }, + ] }, + }) + const world = await mountWorld({ index: [multi], profileDependencies: { 'multi-a': '1.0.0' } }) + await world.run('/plugin list') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain('partial') + expect(json.match(/Multi Row/gu)).toHaveLength(1) + world.dispose() + }) }) describe('/plugin coverage corners', () => { @@ -595,6 +728,8 @@ describe('/plugin coverage corners', () => { const githubOnly = entry({ id: 'gh', displayName: 'GH', install: { rows: [{ name: 'gh-pkg', github: { repo: 'a/b', ref: 'r' } }] } }) const world = await mountWorld({ index: [githubOnly] }) await world.run('/plugin install gh') + expect(world.spawns.some(spawn => spawn.args.includes('github:a/b#r'))).toBe(true) + await world.run('/plugin install gh --source npm') expect(world.notices.at(-1)).toBe('"GH" has no npm install source') world.dispose() }) @@ -684,7 +819,9 @@ describe('/plugin coverage corners', () => { updaterInternals.spawnOnce = vi.fn(async () => ok()) writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .\nallowBuilds:\n "node-pty": true\n') await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) + await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toBe('packages:\n - .\nallowBuilds:\n "node-pty": true\n') + expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([{ id: 't', name: 'pkg-t' }]) }) it('uninstall tolerates a missing patch file and unquoted names', async () => { @@ -696,11 +833,34 @@ describe('/plugin coverage corners', () => { writeFileSync(join(root, 'cordis.patch.yml'), '- id: u\n name: bare-pkg\n') const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' }) expect(outcome.kind).toBe('success') - expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toBe('') + expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([]) }) }) describe('/plugin lifecycle and locale', () => { + it('does not let a slower earlier load replace a newer refresh', async () => { + const world = await mountWorld() + let releaseFirst: ((value: string) => void) | undefined + const first = new Promise(resolve => { + releaseFirst = resolve + }) + let calls = 0 + updaterInternals.fetchText = vi.fn(async () => { + calls += 1 + return calls === 1 ? first : indexJson([entry({ id: 'newer', displayName: 'Newer' })]) + }) + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + panel.handleInput('r') + await new Promise(resolve => setTimeout(resolve, 5)) + releaseFirst?.(indexJson([entry({ id: 'older', displayName: 'Older' })])) + await new Promise(resolve => setTimeout(resolve, 5)) + const json = JSON.stringify(panel.currentNode()) + expect(json).toContain('Newer') + expect(json).not.toContain('Older') + world.dispose() + }) + it('stops touching the context after the fiber unloads mid-load', async () => { let release: ((value: string) => void) | undefined const gate = new Promise(resolve => { @@ -951,12 +1111,12 @@ describe('badge and patch-shape arms', () => { expect(outcome.kind).toBe('success') const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' expect(patch).toContain('keep-me') - expect(patch).toContain('"pkg-nl"') + expect(parseYaml(patch)).toContainEqual({ id: 'nl', name: 'pkg-nl' }) // allowBuilds block lands after content lacking a trailing newline too. const allowEntry = entry({ install: { allowBuilds: ['node-pty'], rows: [{ id: 't', name: 'pkg-t', activation: 'profile-patch', npm: { spec: 'x' } }] } }) writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .') await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: allowEntry, source: 'npm' }) - expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') + expect(parseYaml(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml')) ?? '')).toMatchObject({ allowBuilds: { 'node-pty': true } }) }) }) @@ -1002,7 +1162,7 @@ describe('detail-shape arms', () => { updaterInternals.spawnOnce = vi.fn(async () => ok()) const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: allowEntry, source: 'npm' }) expect(outcome.kind).toBe('success') - expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toContain('"node-pty": true') + expect(parseYaml(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml')) ?? '')).toMatchObject({ allowBuilds: { 'node-pty': true } }) // The r-key refresh continuation gates on the fiber unload. const rWorld = await mountWorld({}) @@ -1043,7 +1203,7 @@ describe('final arms', () => { it('falls back to the generic removed note without a statusNote', async () => { const world = await mountWorld({ - index: [entry({ id: 'silent-gone', displayName: 'Silent Gone', status: 'removed', install: { rows: [{ name: 'silent-pkg' }] } })], + index: [entry({ id: 'silent-gone', displayName: 'Silent Gone', status: 'removed', install: { rows: [{ name: 'silent-pkg', npm: { spec: 'silent-pkg' } }] } })], profileDependencies: { 'silent-pkg': '1.0.0' }, }) await world.run('/plugin list') diff --git a/packages/mayfly/tests/interaction/plugin-market.spec.ts b/packages/mayfly/tests/interaction/plugin-market.spec.ts index 13fae23..15a483c 100644 --- a/packages/mayfly/tests/interaction/plugin-market.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-market.spec.ts @@ -28,9 +28,33 @@ afterEach(() => { vi.restoreAllMocks() }) -/** A two-entry index document. */ -function indexJson(entries: ReadonlyArray<{ id: string }> = [{ id: 'loop' }, { id: 'terminal' }]): string { - return JSON.stringify({ schemaVersion: 1, generatedAt: '2026-09-04T00:00:00.000Z', entries }) +/** One smallest valid marketplace entry. */ +function entry(id: string): object { + return { + id, + source: 'official', + displayName: id, + description: `Description for ${id}`, + author: { name: 'Test' }, + category: 'testing', + status: 'stable', + surfaces: { server: {} }, + install: { rows: [{ name: `dsh-${id}`, npm: { spec: `dsh-${id}` } }] }, + } +} + +/** A valid index document containing the requested ids. */ +function indexJson(entries: ReadonlyArray<{ id: string }> = [{ id: 'loop' }, { id: 'terminal' }], generatedAt = true): string { + return JSON.stringify({ + schemaVersion: 1, + ...(generatedAt ? { generatedAt: '2026-09-04T00:00:00.000Z' } : {}), + entries: entries.map(item => entry(item.id)), + }) +} + +/** A cache wrapper bound to the requested market URL. */ +function cacheJson(fetchedAt: number, text: unknown = indexJson(), indexUrl = DEFAULT_MARKET_INDEX_URL): string { + return JSON.stringify({ fetchedAt, indexUrl, text }) } /** One test home with a scripted network; `urls` maps URL → body. */ @@ -72,6 +96,21 @@ describe('parseMarketIndex', () => { it('rejects a document without entries', () => { expect(() => parseMarketIndex('{"schemaVersion":1}')).toThrow(/no entries/) }) + + it('rejects malformed entry fields before render or install', () => { + expect(() => parseMarketIndex('{"schemaVersion":1,"entries":[{"id":"x"}]}')).toThrow(/entries\.0\.source/) + }) + + it('normalizes nullable published fields and ignores catalog-only enrichment', () => { + const published = entry('published') as Record + published.engines = null + published.registryPath = 'registry/official/published.json' + published.verified = { at: '2026-09-04', packages: [{ name: 'dsh-published', version: '1.0.0', integrity: null }] } + published.npm = { 'dsh-published': { latestVersion: null, readme: 'catalog only' } } + const index = parseMarketIndex(JSON.stringify({ schemaVersion: 1, counts: { total: 1 }, entries: [published] })) + expect(index.entries[0]?.engines).toBeUndefined() + expect(index.entries[0]?.npm?.['dsh-published']).toEqual({ latestVersion: null }) + }) }) describe('loadMarketCatalog', () => { @@ -92,7 +131,7 @@ describe('loadMarketCatalog', () => { it('answers from the cache within the TTL without fetching', async () => { mountNetwork({}) - updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 9_999_999, text: indexJson() })) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(9_999_999)) const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) expect(result.status).toBe('fresh') expect(updaterInternals.fetchText).not.toHaveBeenCalled() @@ -100,7 +139,7 @@ describe('loadMarketCatalog', () => { it('refetches past the TTL', async () => { const { fetches } = mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) - updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 1_000_000 - MARKET_CACHE_TTL_MS, text: indexJson() })) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(1_000_000 - MARKET_CACHE_TTL_MS)) const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) expect(result.status).toBe('fresh') expect(fetches).toEqual([DEFAULT_MARKET_INDEX_URL]) @@ -108,7 +147,7 @@ describe('loadMarketCatalog', () => { it('serves stale cache when every leg fails', async () => { mountNetwork({}) - updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 0, text: indexJson() })) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(0)) const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) expect(result).toMatchObject({ status: 'stale', message: expect.stringContaining('no route') }) if (result.status === 'stale') expect(result.index.entries).toHaveLength(2) @@ -130,13 +169,29 @@ describe('loadMarketCatalog', () => { it('force skips a fresh cache', async () => { const { fetches } = mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson([{ id: 'fresh' }]) }) - updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 999_999, text: indexJson() })) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(999_999)) const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL, true) expect(result.status).toBe('fresh') expect(fetches).toEqual([DEFAULT_MARKET_INDEX_URL]) if (result.status === 'fresh') expect(result.index.entries[0]?.id).toBe('fresh') }) + it('keeps a matching cache as stale fallback during a failed force refresh', async () => { + mountNetwork({}) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(9_999_999)) + const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL, true) + expect(result.status).toBe('stale') + }) + + it('never serves a fresh cache created for another market URL', async () => { + const customUrl = 'https://example.invalid/private.json' + const { fetches } = mountNetwork({ [customUrl]: indexJson([{ id: 'private' }]) }) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(9_999_999, indexJson([{ id: 'official' }]))) + const result = await loadMarketCatalog(customUrl) + expect(fetches).toEqual([customUrl]) + if (result.status === 'fresh') expect(result.index.entries[0]?.id).toBe('private') + }) + it('treats a corrupt cache as absent', async () => { const { home } = mountNetwork({}) updaterInternals.writeTextFile(marketCachePath(), 'not json') @@ -151,8 +206,9 @@ describe('loadMarketCatalog', () => { const before = updaterInternals.readTextFile(marketCachePath()) expect(before).toBeUndefined() await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) - const cached = JSON.parse(updaterInternals.readTextFile(marketCachePath()) ?? '{}') as { fetchedAt: number, text: string } + const cached = JSON.parse(updaterInternals.readTextFile(marketCachePath()) ?? '{}') as { fetchedAt: number, indexUrl: string, text: string } expect(cached.fetchedAt).toBe(10_000_000) + expect(cached.indexUrl).toBe(DEFAULT_MARKET_INDEX_URL) expect(parseMarketIndex(cached.text).entries).toHaveLength(2) }) }) @@ -179,7 +235,7 @@ describe('readCache shape guard', () => { describe('parseMarketIndex generatedAt arm', () => { it('accepts a document without generatedAt', () => { - const index = parseMarketIndex('{"schemaVersion":1,"entries":[{"id":"x"}]}') + const index = parseMarketIndex(indexJson([{ id: 'x' }], false)) expect(index.entries).toHaveLength(1) expect(index.generatedAt).toBeUndefined() }) @@ -188,7 +244,7 @@ describe('parseMarketIndex generatedAt arm', () => { describe('readCache field arms', () => { it('treats a non-string cache text as absent', async () => { mountNetwork({ [DEFAULT_MARKET_INDEX_URL]: indexJson() }) - updaterInternals.writeTextFile(marketCachePath(), JSON.stringify({ fetchedAt: 5, text: 5 })) + updaterInternals.writeTextFile(marketCachePath(), cacheJson(5, 5)) const result = await loadMarketCatalog(DEFAULT_MARKET_INDEX_URL) expect(result.status).toBe('fresh') }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9e0ecc..27c0e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: simple-ascii-chart: specifier: 6.0.0 version: 6.0.0 + yaml: + specifier: 2.9.0 + version: 2.9.0 zod: specifier: ^4.4.3 version: 4.4.3 From fe0b5f2a3e99b4c691652e384391bb6fa8d455eb Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:10:24 +0800 Subject: [PATCH 4/8] fix(interaction): quote marketplace install commands --- packages/mayfly/src/interaction/plugin-commands.ts | 6 +++++- .../mayfly/tests/interaction/plugin-commands.spec.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index d8764bb..959fa67 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -179,7 +179,11 @@ export function registerPluginCommand(ctx: Context): () => void { if (source === undefined) { return `dsh plugin --profile add <${entry.id}>` } - return `dsh plugin --profile add ${entry.install.rows.map(row => rowSpec(row, source)).join(' ')}` + const specs = entry.install.rows.map(row => rowSpec(row, source)!) + const shellArgs = specs.map(spec => /^[A-Za-z0-9@._/+~-]+$/u.test(spec) + ? spec + : `'${spec.replaceAll("'", `'\\''`)}'`) + return `dsh plugin --profile add ${shellArgs.join(' ')}` } /** The read-only detail panel for one entry. */ diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index fa2a489..bbb7c43 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -546,6 +546,15 @@ describe('/plugin argument paths', () => { world.dispose() }) + it('quotes GitHub specs in the copyable install command', async () => { + const githubOnly = entry({ install: { rows: [{ name: 'dsh-loop', github: { repo: 'a/b', ref: 'release-candidate', subdir: 'plugins/loop' } }] } }) + const world = await mountWorld({ index: [githubOnly] }) + await world.run('/plugin info loop') + const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(json).toContain("'github:a/b#release-candidate&path:plugins/loop'") + world.dispose() + }) + it('list groups installed rows by state, including removed-from-market', async () => { const gone = entry({ id: 'gone', displayName: 'Gone', status: 'removed', statusNote: 'yanked', install: { rows: [{ name: 'gone-pkg', npm: { spec: 'gone-pkg' } }] } }) const updated = entry({ id: 'loop', npm: { 'dsh-loop': { latestVersion: '0.1.5' } } }) From a4717fb4b4e8b0e467baf37b649b4f99e081e376 Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:31:45 +0800 Subject: [PATCH 5/8] fix(interaction): insert profile patch entries --- .../interaction/plugin-market/installer.ts | 50 +++++++++++++++---- .../tests/interaction/plugin-commands.spec.ts | 49 +++++++++++------- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts index fe1bb59..c6f642d 100644 --- a/packages/mayfly/src/interaction/plugin-market/installer.ts +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -109,6 +109,21 @@ interface PatchEdit { readonly text: string } +/** Collect and validate every row nested under a top-level `insert` patch. */ +function insertionRows(items: readonly unknown[]): YAMLMap[] { + const rows: YAMLMap[] = [] + for (const item of items) { + if (!isMap(item) || !item.has('insert')) continue + const insert = item.get('insert', true) + if (!isSeq(insert)) throw new Error('cordis.patch.yml insert must be a YAML sequence') + for (const row of insert.items) { + if (!isMap(row)) throw new Error('cordis.patch.yml insert entries must be mappings') + rows.push(row as YAMLMap) + } + } + return rows +} + /** Prepare idempotent `profile-patch` row insertion without writing it yet. */ function prepareProfilePatchRows(root: string, rows: readonly MarketInstallRow[]): PatchEdit | undefined { if (rows.length === 0) return undefined @@ -118,18 +133,19 @@ function prepareProfilePatchRows(root: string, rows: readonly MarketInstallRow[] if (doc.errors.length > 0 || !isSeq(doc.contents)) { throw new Error(`cordis.patch.yml must be a YAML sequence: ${doc.errors[0]?.message ?? 'found another document shape'}`) } - let changed = false + const existingRows = insertionRows(doc.contents.items) + const added: Array<{ id: string, name: string, config?: Readonly> }> = [] for (const row of rows) { const id = row.id ?? row.name - const present = doc.contents.items.find(item => isMap(item) && (item.get('id') as unknown) === id) as YAMLMap | undefined + const present = existingRows.find(item => (item.get('id') as unknown) === id) if (present !== undefined) { if ((present.get('name') as unknown) === row.name) continue throw new Error(`cordis.patch.yml already has id ${JSON.stringify(id)} for another package`) } - doc.add({ id, name: row.name, ...(row.config === undefined ? {} : { config: row.config }) }) - changed = true + added.push({ id, name: row.name, ...(row.config === undefined ? {} : { config: row.config }) }) } - if (changed) { + if (added.length > 0) { + doc.add({ insert: added }) doc.contents.flow = false return { path, text: String(doc) } } @@ -148,10 +164,26 @@ function prepareProfilePatchRemoval(root: string, rows: readonly MarketInstallRo throw new Error(`cordis.patch.yml must be a YAML sequence: ${doc.errors[0]?.message ?? 'found another document shape'}`) } const keys = new Set(rows.map(row => `${row.id ?? row.name}\0${row.name}`)) - const kept = doc.contents.items.filter(item => - !isMap(item) || !keys.has(`${String((item.get('id') as unknown) ?? '')}\0${String((item.get('name') as unknown) ?? '')}`)) - if (kept.length === doc.contents.items.length) return undefined - doc.contents.items = kept + insertionRows(doc.contents.items) + let changed = false + doc.contents.items = doc.contents.items.filter(item => { + if (!isMap(item) || !item.has('insert')) return true + const insert = item.get('insert', true) + /* v8 ignore next -- insertionRows validated this exact node above */ + if (!isSeq(insert)) return true + const kept = insert.items.filter(row => { + /* v8 ignore next -- insertionRows validated every nested row above */ + if (!isMap(row)) return true + return !keys.has(`${String((row.get('id') as unknown) ?? '')}\0${String((row.get('name') as unknown) ?? '')}`) + }) + if (kept.length === insert.items.length) return true + changed = true + insert.items = kept + if (kept.length > 0) return true + item.delete('insert') + return item.items.length > 0 + }) + if (!changed) return undefined return { path, text: String(doc) } } diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index bbb7c43..c3670ce 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -254,7 +254,7 @@ describe('installer unit seams', () => { const workspace = parseYaml(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml')) ?? '') as Record expect(workspace).toMatchObject({ allowBuilds: { 'node-pty': true }, overrides: { keep: true } }) const patch = parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '') as readonly Record[] - expect(patch).toContainEqual({ id: 'terminal-bash', name: '@deepseek-ai/dsh-terminal-bash' }) + expect(patch).toContainEqual({ insert: [{ id: 'terminal-bash', name: '@deepseek-ai/dsh-terminal-bash' }] }) }) it('appending to a non-empty patch layer keeps existing rows, and config renders', async () => { @@ -268,7 +268,9 @@ describe('installer unit seams', () => { expect(outcome.kind).toBe('success') const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' expect(patch).toContain('- id: keep') - expect(patch).toContain('config:\n computeMs: 60000') + expect(parseYaml(patch)).toContainEqual({ + insert: [{ id: 'code-runtime', name: '@deepseek-ai/dsh-code-runtime-worker-thread', config: { computeMs: 60000 } }], + }) }) it('uninstalling removes exactly the entry\'s patch blocks', async () => { @@ -280,16 +282,17 @@ describe('installer unit seams', () => { writeFileSync(join(root, 'cordis.patch.yml'), [ '- id: keep', " name: 'keep-me'", - '- id: a', - " name: 'pkg-a'", - ' config:', - ' x: 1', - '- id: user-a', - " name: 'pkg-a'", - ' config:', - ' keep: true', - '- id: after', - " name: 'pkg-after'", + '- insert:', + ' - id: a', + " name: 'pkg-a'", + ' config:', + ' x: 1', + ' - id: user-a', + " name: 'pkg-a'", + ' config:', + ' keep: true', + ' - id: after', + " name: 'pkg-after'", ].join('\n') + '\n') const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: two, source: 'npm' }) expect(outcome.kind).toBe('success') @@ -356,7 +359,7 @@ describe('installer unit seams', () => { it('reports invalid or conflicting patch documents without overwriting them', async () => { updaterInternals.spawnOnce = vi.fn(async () => ok()) const withPatch = entry({ install: { rows: [{ id: 'wanted', name: 'pkg-wanted', activation: 'profile-patch', npm: { spec: 'pkg-wanted' } }] } }) - for (const source of ['{}\n', '[\n', '- id: wanted\n name: another-package\n']) { + for (const source of ['{}\n', '[\n', '- insert: {}\n', '- insert:\n - scalar\n', '- insert:\n - id: wanted\n name: another-package\n']) { const root = mkdtempTracked('mayfly-install-') writeFileSync(join(root, 'cordis.patch.yml'), source) const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: withPatch, source: 'npm' }) @@ -386,9 +389,13 @@ describe('installer unit seams', () => { expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('cordis.patch.yml') }) } const root = mkdtempTracked('mayfly-uninstall-') - writeFileSync(join(root, 'cordis.patch.yml'), '- scalar\n- name: no-id\n- id: orphan\n- id: target\n name: pkg-target\n') + writeFileSync(join(root, 'cordis.patch.yml'), '- scalar\n- name: top-level\n- insert:\n - name: no-id\n - id: orphan\n - id: target\n name: pkg-target\n') expect((await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: target, source: 'npm' })).kind).toBe('success') - expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual(['scalar', { name: 'no-id' }, { id: 'orphan' }]) + expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([ + 'scalar', + { name: 'top-level' }, + { insert: [{ name: 'no-id' }, { id: 'orphan' }] }, + ]) }) it('reports patch write failures after successful package operations', async () => { @@ -401,7 +408,7 @@ describe('installer unit seams', () => { .toMatchObject({ kind: 'error', text: expect.stringContaining('activating') }) const uninstallRoot = mkdtempTracked('mayfly-uninstall-') - writeFileSync(join(uninstallRoot, 'cordis.patch.yml'), '- id: write\n name: pkg-write\n') + writeFileSync(join(uninstallRoot, 'cordis.patch.yml'), '- insert:\n - id: write\n name: pkg-write\n') expect(await uninstallEntry({ dshBin: 'dsh', profile: 'p', root: uninstallRoot, entry: withPatch, source: 'npm' })) .toMatchObject({ kind: 'error', text: expect.stringContaining('cleaning up') }) }) @@ -830,7 +837,7 @@ describe('/plugin coverage corners', () => { await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: profilePatch, source: 'npm' }) expect(updaterInternals.readTextFile(join(root, 'pnpm-workspace.yaml'))).toBe('packages:\n - .\nallowBuilds:\n "node-pty": true\n') - expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([{ id: 't', name: 'pkg-t' }]) + expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([{ insert: [{ id: 't', name: 'pkg-t' }] }]) }) it('uninstall tolerates a missing patch file and unquoted names', async () => { @@ -839,7 +846,11 @@ describe('/plugin coverage corners', () => { updaterInternals.spawnOnce = vi.fn(async () => ok()) const withoutFile = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' }) expect(withoutFile.kind).toBe('success') - writeFileSync(join(root, 'cordis.patch.yml'), '- id: u\n name: bare-pkg\n') + const unrelated = '- insert:\n - id: other\n name: other-package\n' + writeFileSync(join(root, 'cordis.patch.yml'), unrelated) + expect((await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' })).kind).toBe('success') + expect(updaterInternals.readTextFile(join(root, 'cordis.patch.yml'))).toBe(unrelated) + writeFileSync(join(root, 'cordis.patch.yml'), '- insert:\n - id: u\n name: bare-pkg\n') const outcome = await uninstallEntry({ dshBin: 'dsh', profile: 'p', root, entry: unquoted, source: 'npm' }) expect(outcome.kind).toBe('success') expect(parseYaml(updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '')).toEqual([]) @@ -1120,7 +1131,7 @@ describe('badge and patch-shape arms', () => { expect(outcome.kind).toBe('success') const patch = updaterInternals.readTextFile(join(root, 'cordis.patch.yml')) ?? '' expect(patch).toContain('keep-me') - expect(parseYaml(patch)).toContainEqual({ id: 'nl', name: 'pkg-nl' }) + expect(parseYaml(patch)).toContainEqual({ insert: [{ id: 'nl', name: 'pkg-nl' }] }) // allowBuilds block lands after content lacking a trailing newline too. const allowEntry = entry({ install: { allowBuilds: ['node-pty'], rows: [{ id: 't', name: 'pkg-t', activation: 'profile-patch', npm: { spec: 'x' } }] } }) writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - .') From b167feb7288461e205dd6eac925e96b7873a11cf Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:18:15 +0800 Subject: [PATCH 6/8] fix(interaction): keep ACP out of TUI profiles --- packages/mayfly/src/interaction/locale.ts | 1 + .../mayfly/src/interaction/plugin-commands.ts | 24 ++++++++++---- .../interaction/plugin-market/installer.ts | 11 +++++++ .../tests/interaction/plugin-commands.spec.ts | 33 ++++++++++++++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/mayfly/src/interaction/locale.ts b/packages/mayfly/src/interaction/locale.ts index 3b41547..89a50e9 100644 --- a/packages/mayfly/src/interaction/locale.ts +++ b/packages/mayfly/src/interaction/locale.ts @@ -216,6 +216,7 @@ const zh: Readonly> = { 'installed; restart Mayfly and start a new session to apply': '已安装;重启 Mayfly 并新建会话后生效', 'removed; restart Mayfly and start a new session to apply': '已移除;重启 Mayfly 并新建会话后生效', 'web-only plugin: it contributes nothing in this terminal frontend': '仅 Web 插件:在终端前端无作用', + 'automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile': '仅自动化使用的 ACP Server 会独占 stdio;请安装到独立的非 Mayfly profile', 'no plugins installed': '尚未安装插件', 'Overview': '概览', 'Surfaces': '前端贡献', diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index 959fa67..175e3e7 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -25,6 +25,7 @@ import { interactionTranslator, observeInteractionLocale } from './locale.ts' import { currentMayflySettings } from './settings.ts' import { DEFAULT_MARKET_INDEX_URL, loadMarketCatalog, type CatalogResult } from './plugin-market/catalog.ts' import { + currentProfileInstallBlock, defaultInstallSource, entryInstallStates, entrySupportsSource, @@ -101,6 +102,7 @@ export function registerPluginCommand(ctx: Context): () => void { /** Which frontends the entry contributes its own UI to. */ const surfaceBadge = (entry: MarketEntry): string => { + if (currentProfileInstallBlock(entry) !== undefined) return 'Automation' const parts: string[] = [] if (entry.surfaces.tui !== undefined) parts.push('TUI') if (entry.surfaces.web !== undefined) parts.push('Web') @@ -120,7 +122,8 @@ export function registerPluginCommand(ctx: Context): () => void { /** Whether the entry contributes anything to this terminal frontend. */ const usefulInTui = (entry: MarketEntry): boolean => - entry.surfaces.server !== undefined || entry.surfaces.tui !== undefined + currentProfileInstallBlock(entry) === undefined + && (entry.surfaces.server !== undefined || entry.surfaces.tui !== undefined) /** Find an entry by marketplace id or by one of its row package names. */ const findEntry = (id: string): MarketEntry | undefined => @@ -183,15 +186,17 @@ export function registerPluginCommand(ctx: Context): () => void { const shellArgs = specs.map(spec => /^[A-Za-z0-9@._/+~-]+$/u.test(spec) ? spec : `'${spec.replaceAll("'", `'\\''`)}'`) - return `dsh plugin --profile add ${shellArgs.join(' ')}` + const profile = currentProfileInstallBlock(entry) === undefined ? '' : '' + return `dsh plugin --profile ${profile} add ${shellArgs.join(' ')}` } /** The read-only detail panel for one entry. */ function detailPanel(entry: MarketEntry, state: EntryInstallState | undefined, onClose: () => void): InfoPanel { const display = displayServices(ctx) const segments = (text: string, style?: InfoSegment['style']): InfoSegment[] => [{ text, ...(style === undefined ? {} : { style }) }] - const tuiFull = usefulInTui(entry) - const webFull = entry.surfaces.web !== undefined || entry.surfaces.server !== undefined + const installBlock = currentProfileInstallBlock(entry) + const tuiFull = installBlock === undefined && usefulInTui(entry) + const webFull = installBlock === undefined && (entry.surfaces.web !== undefined || entry.surfaces.server !== undefined) const sections: InfoSection[] = [ { heading: t('Overview'), @@ -214,8 +219,8 @@ export function registerPluginCommand(ctx: Context): () => void { { heading: t('Surfaces'), rows: [ - { label: 'TUI', segments: segments(tuiFull ? t('works here') : t('no contribution in this terminal'), tuiFull ? 'success' : 'warning') }, - { label: 'Web', segments: segments(webFull ? t('works on dsh Web') : t('no contribution on dsh Web'), webFull ? 'success' : 'warning') }, + { label: 'TUI', segments: segments(installBlock === undefined ? (tuiFull ? t('works here') : t('no contribution in this terminal')) : t(installBlock), tuiFull ? 'success' : 'warning') }, + { label: 'Web', segments: segments(installBlock === undefined ? (webFull ? t('works on dsh Web') : t('no contribution on dsh Web')) : t(installBlock), webFull ? 'success' : 'warning') }, ], }, { @@ -354,6 +359,11 @@ export function registerPluginCommand(ctx: Context): () => void { getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" is not installed in this profile`) return } + const installBlock = action === 'install' ? currentProfileInstallBlock(entry) : undefined + if (installBlock !== undefined) { + getSharedEditor(ctx)?.notice?.(t(installBlock)) + return + } if (action === 'install' && usefulInTui(entry) === false) { getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) } @@ -500,6 +510,8 @@ export function registerPluginCommand(ctx: Context): () => void { if (unloaded) return { kind: 'success' } const entry = findEntry(id) if (entry === undefined) return { kind: 'error', text: t('unknown plugin: {id}', { id }) } + const installBlock = verb === 'install' ? currentProfileInstallBlock(entry) : undefined + if (installBlock !== undefined) return { kind: 'error', text: t(installBlock) } const source: InstallSource | undefined = requestedSource === 'npm' || requestedSource === 'github' ? requestedSource : defaultInstallSource(entry) diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts index c6f642d..f14e6ef 100644 --- a/packages/mayfly/src/interaction/plugin-market/installer.ts +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -55,6 +55,15 @@ export function defaultInstallSource(entry: MarketEntry): InstallSource | undefi return undefined } +/** Packages that own a whole automation profile rather than joining a TUI. */ +const DEDICATED_PROFILE_PACKAGES = new Set(['@deepseek-ai/dsh-acp']) + +/** Why an entry cannot be activated inside the current Mayfly profile. */ +export function currentProfileInstallBlock(entry: MarketEntry): string | undefined { + if (!entry.install.rows.some(row => DEDICATED_PROFILE_PACKAGES.has(row.name))) return undefined + return 'automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile' +} + /** The pnpm error signature the allowBuilds hint keys on. */ const ALLOW_BUILDS_HINT = 'allowBuilds' @@ -212,6 +221,8 @@ export interface InstallerInput { * the `profile-patch` rows into the user patch layer. */ export async function installEntry(input: InstallerInput): Promise { + const blocked = currentProfileInstallBlock(input.entry) + if (blocked !== undefined) return { kind: 'error', text: blocked } const rows = input.entry.install.rows const specs = rows.map(row => rowSpec(row, input.source)).filter((spec): spec is string => spec !== undefined) if (specs.length !== rows.length || specs.length === 0) { diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index c3670ce..a8eb4c0 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -22,7 +22,7 @@ registerTempDirCleanup() import { updaterInternals, type SpawnOutcome } from '../../src/interaction/updater/io.ts' import { setSharedEditor } from '../../src/interaction/editor-instance.ts' import { registerPluginCommand } from '../../src/interaction/plugin-commands.ts' -import { defaultInstallSource, entryInstallStates, readInstalledPlugins, rowSpec, installEntry, uninstallEntry, entrySupportsSource, MAYFLY_PACKAGE } from '../../src/interaction/plugin-market/installer.ts' +import { currentProfileInstallBlock, defaultInstallSource, entryInstallStates, readInstalledPlugins, rowSpec, installEntry, uninstallEntry, entrySupportsSource, MAYFLY_PACKAGE } from '../../src/interaction/plugin-market/installer.ts' import * as settingsPlugin from '../../src/interaction/settings.ts' import { InteractionStateService } from '../../src/interaction/runtime-state.ts' import { fakeMayflyContext, KEY, type FakeScreen } from './fakes.ts' @@ -206,6 +206,16 @@ describe('installer unit seams', () => { expect(entrySupportsSource(mixed, 'npm')).toBe(false) }) + it('blocks automation-only stdio servers from the current TUI profile', async () => { + const acp = entry({ install: { rows: [{ id: 'acp', name: '@deepseek-ai/dsh-acp', activation: 'profile-patch', npm: { spec: '@deepseek-ai/dsh-acp' } }] } }) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + expect(currentProfileInstallBlock(entry())).toBeUndefined() + expect(currentProfileInstallBlock(acp)).toContain('owns stdio') + expect(await installEntry({ dshBin: 'dsh', profile: 'p', root: mkdtempTracked('mayfly-install-'), entry: acp, source: 'npm' })) + .toMatchObject({ kind: 'error', text: expect.stringContaining('dedicated non-Mayfly profile') }) + expect(updaterInternals.spawnOnce).not.toHaveBeenCalled() + }) + it('reads installed plugins, skipping Mayfly itself', () => { const root = mkdtempTracked('mayfly-installed-') writeFileSync(join(root, 'package.json'), JSON.stringify({ @@ -452,6 +462,27 @@ describe('/plugin browse panel', () => { }) describe('/plugin argument paths', () => { + it('shows but does not install the dedicated-profile ACP server', async () => { + const acp = entry({ + id: 'acp', + displayName: 'ACP Server', + install: { rows: [{ id: 'acp', name: '@deepseek-ai/dsh-acp', activation: 'profile-patch', npm: { spec: '@deepseek-ai/dsh-acp' } }] }, + }) + const world = await mountWorld({ index: [acp] }) + expect(await world.run('/plugin install acp')).toMatchObject({ kind: 'error', text: expect.stringContaining('owns stdio') }) + expect(world.spawns.filter(spawn => spawn.cmd === '/usr/bin/dsh')).toHaveLength(0) + await world.run('/plugin info acp') + const detail = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + expect(detail).toContain('dedicated non-Mayfly profile') + expect(detail).toContain('dsh plugin --profile add @deepseek-ai/dsh-acp') + await world.run('/plugin') + const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + expect(JSON.stringify(panel.currentNode())).toContain('Automation') + panel.handleInput('i') + expect(world.notices).toContain('automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile') + world.dispose() + }) + it('installs via npm by default and reminds about the restart', async () => { const world = await mountWorld({ index: [entry()] }) const result = await world.run('/plugin install loop') From a1f920e4d69750b92a9226737bfb8cfe3f18339c Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:32:45 +0800 Subject: [PATCH 7/8] fix(interaction): surface plugin operation keys --- packages/mayfly/src/interaction/locale.ts | 1 + packages/mayfly/src/interaction/plugin-commands.ts | 6 ++++++ packages/mayfly/tests/interaction/plugin-commands.spec.ts | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/mayfly/src/interaction/locale.ts b/packages/mayfly/src/interaction/locale.ts index 89a50e9..4346965 100644 --- a/packages/mayfly/src/interaction/locale.ts +++ b/packages/mayfly/src/interaction/locale.ts @@ -217,6 +217,7 @@ const zh: Readonly> = { 'removed; restart Mayfly and start a new session to apply': '已移除;重启 Mayfly 并新建会话后生效', 'web-only plugin: it contributes nothing in this terminal frontend': '仅 Web 插件:在终端前端无作用', 'automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile': '仅自动化使用的 ACP Server 会独占 stdio;请安装到独立的非 Mayfly profile', + 'i install · u remove · r refresh': 'i 安装 · u 移除 · r 刷新', 'no plugins installed': '尚未安装插件', 'Overview': '概览', 'Surfaces': '前端贡献', diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index 175e3e7..114aa74 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -435,6 +435,12 @@ export function registerPluginCommand(ctx: Context): () => void { if (data === 'u' || data === 'U') return { kind: 'plugin-market/uninstall', id: selectedId } return undefined }, + contextHints: () => [{ + id: 'plugin-operations', + keys: t('i install · u remove · r refresh'), + compact: 'i/u/r', + priority: 95, + }], }) restore = mountEditorReplacement(ctx, panel) const offLocale = observeInteractionLocale(ctx, () => { diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index a8eb4c0..a84c1a2 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -431,9 +431,12 @@ describe('/plugin browse panel', () => { expect(result).toEqual({ kind: 'success' }) const panel = world.overlay() expect(panel).toBeDefined() - const node = (panel as { currentNode(): { kind: string, child?: { children?: Array<{ node: unknown }> } } }).currentNode() + const controller = panel as { currentNode(): { kind: string, child?: { children?: Array<{ node: unknown }> } }, render(width: number): string[] } + const node = controller.currentNode() expect(JSON.stringify(node)).toContain('Loop') expect(JSON.stringify(node)).toContain('official · Web+Server') + expect(controller.render(80).join('\n')).toContain('i install · u remove · r refresh') + expect(controller.render(36).join('\n')).toContain('i/u/r') world.dispose() }) From a13e7968bb2b4d669593b090d928b340f8e0cd23 Mon Sep 17 00:00:00 2001 From: GeekCmore <128243887+GeekCmore@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:16:35 +0800 Subject: [PATCH 8/8] feat(interaction): make plugin install workflow visible --- .../mayfly/src/interaction/frontend-panel.ts | 23 +- packages/mayfly/src/interaction/locale.ts | 6 + .../mayfly/src/interaction/plugin-commands.ts | 204 +++++++++-------- .../interaction/plugin-market/installer.ts | 80 ++++++- .../tests/interaction/frontend-panel.spec.ts | 21 +- .../tests/interaction/plugin-commands.spec.ts | 211 +++++++++++++++--- 6 files changed, 419 insertions(+), 126 deletions(-) diff --git a/packages/mayfly/src/interaction/frontend-panel.ts b/packages/mayfly/src/interaction/frontend-panel.ts index 9ab4b3e..ef9a32a 100644 --- a/packages/mayfly/src/interaction/frontend-panel.ts +++ b/packages/mayfly/src/interaction/frontend-panel.ts @@ -77,6 +77,8 @@ export interface FrontendPanelOptions { readonly t?: MayflyTranslate readonly contextHints?: () => readonly CanonicalContextHint[] readonly showSelectedVariantInFooter?: boolean + /** Group selected on first render; later user navigation remains authoritative. */ + readonly initialGroup?: string } /** Canonical panel controller preserving the former panel interaction set. */ @@ -88,9 +90,11 @@ export class CanonicalDocumentController implements MayflyFocusable { private filterEditing = false private group = 0 private groupId: string | undefined + private initialGroup: string | undefined private readonly selectedVariants = new Map() constructor(private readonly options: FrontendPanelOptions) { + this.initialGroup = options.initialGroup this.adapter = new CanonicalPanelAdapter({ components: options.components, theme: options.theme, @@ -119,7 +123,12 @@ export class CanonicalDocumentController implements MayflyFocusable { return } if (!model.filterable && (data === 'q' || data === 'Q')) { this.cancel(); return } - const unhandled = this.options.onUnhandledInput?.(data, this.resolveSelectedId(model)) + if (model.filterable === true && !this.filterEditing && data === '/') { + this.filterEditing = true + this.adapter.invalidate() + return + } + const unhandled = this.filterEditing ? undefined : this.options.onUnhandledInput?.(data, this.resolveSelectedId(model)) if (unhandled !== undefined) { void this.options.onAction(unhandled); return } if (model.variantNavigation === 'inline' && (data === KEY_LEFT || data === KEY_RIGHT) && this.listIsActive(model)) { this.moveVariant(model, data === KEY_LEFT ? -1 : 1) @@ -240,7 +249,7 @@ export class CanonicalDocumentController implements MayflyFocusable { ] : []), ...(!hasRows && model.view !== undefined ? [{ id: 'scroll', keys: '↑↓/PgUp/PgDn', label: 'scroll', compact: 'PgUp/PgDn', priority: 90 }] : []), ...(this.filterEditing ? [{ id: 'dismiss', keys: 'Esc', label: 'finish search', priority: 96 }] : []), - ...(this.options.contextHints?.() ?? []), + ...(this.filterEditing ? [] : this.options.contextHints?.() ?? []), ] } @@ -326,6 +335,16 @@ export class CanonicalDocumentController implements MayflyFocusable { } private activeGroup(groups: readonly string[]): string { + if (this.groupId === undefined && this.initialGroup !== undefined) { + const index = groups.indexOf(this.initialGroup) + if (index >= 0) { + this.group = index + this.groupId = this.initialGroup + this.initialGroup = undefined + return this.groupId + } + return groups[this.group]! + } if (this.groupId !== undefined) { const index = groups.indexOf(this.groupId) if (index >= 0) { diff --git a/packages/mayfly/src/interaction/locale.ts b/packages/mayfly/src/interaction/locale.ts index 4346965..60f5ebe 100644 --- a/packages/mayfly/src/interaction/locale.ts +++ b/packages/mayfly/src/interaction/locale.ts @@ -218,6 +218,12 @@ const zh: Readonly> = { 'web-only plugin: it contributes nothing in this terminal frontend': '仅 Web 插件:在终端前端无作用', 'automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile': '仅自动化使用的 ACP Server 会独占 stdio;请安装到独立的非 Mayfly profile', 'i install · u remove · r refresh': 'i 安装 · u 移除 · r 刷新', + '←→ tabs · ↑↓ select': '←→ 页签 · ↑↓ 选择', + 'Not installed': '未安装', + 'all marketplace plugins are installed': '所有市场插件均已安装', + 'refreshing plugin catalog...': '正在刷新插件目录…', + 'checking "{name}" compatibility...': '正在检查 "{name}" 兼容性…', + 'rolling back "{name}"...': '正在回滚 "{name}"…', 'no plugins installed': '尚未安装插件', 'Overview': '概览', 'Surfaces': '前端贡献', diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index 114aa74..23f8a1a 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -1,10 +1,10 @@ /** * The `/plugin` command family: the marketplace browser over the index * published by Ephemeral-AI-Lab/dsh-plugins (`dist/index.json`). `/plugin` - * opens a grouped, type-to-filter catalog — Enter opens the read-only - * detail panel, `i` installs, `u` removes, `r` refreshes; `/plugin list` - * shows what the profile carries, what has updates, and what left the - * market; `install [--source npm|github]`, `uninstall `, + * opens installed/not-installed tabs over a type-to-filter catalog — Enter + * opens the read-only detail panel, `i` installs, `u` removes, `r` refreshes; + * every operation reports progress and its result inside the panel. + * `install [--source npm|github]`, `uninstall `, * `info `, and `refresh` run the argument paths directly. Installs and * removals shell out to `dsh plugin --profile add|remove` — the same * seam the updater's swap uses — then remind that bundle membership is a @@ -43,6 +43,14 @@ import { findDshBin, profileNameFromArgv, profileRoot } from './updater/profile. /** Command outcome reused by every early-exit branch. */ type CommandOutcome = { readonly kind: 'success', readonly text?: string } | { readonly kind: 'error', readonly text: string } +/** One operation message shown either in the browser or the prompt editor. */ +interface OperationStatus { + readonly text: string + readonly tone: 'muted' | 'warning' | 'success' | 'danger' +} + +type OperationReporter = (status: OperationStatus) => void + /** * Register `/plugin`. * @param ctx - the interaction context. @@ -115,7 +123,12 @@ export function registerPluginCommand(ctx: Context): () => void { const badgeOf = (entry: MarketEntry, state: EntryInstallState | undefined): string => { const pieces = [entry.source, surfaceBadge(entry)] /* v8 ignore next -- states() carries every indexed entry id */ - if (state?.installed === true) pieces.push(state.updateAvailable === true ? `up ${state.version ?? ''}`.trim() : 'installed') + if (state?.installed === true) { + const latest = entry.install.rows.map(row => entry.npm?.[row.name]?.latestVersion).find(version => version != null) + pieces.push(state.updateAvailable === true ? `up ${latest ?? state.version ?? ''}`.trim() : 'installed') + } else if (entry.install.rows.some(row => installed.some(plugin => plugin.name === row.name))) { + pieces.push('partial') + } if (entry.status === 'beta' || entry.status === 'unstable' || entry.status === 'deprecated') pieces.push(entry.status) return pieces.join(' · ') } @@ -139,38 +152,61 @@ export function registerPluginCommand(ctx: Context): () => void { * handlers and the argument paths so warnings, notices, and the in-flight * guard stay identical. */ - async function operate(entry: MarketEntry, action: 'install' | 'uninstall', source: InstallSource): Promise { + async function operate(entry: MarketEntry, action: 'install' | 'uninstall', source: InstallSource, reporter?: OperationReporter): Promise { + const report: OperationReporter = reporter ?? (status => getSharedEditor(ctx)?.notice?.(status.text)) if (operationInFlight) { - getSharedEditor(ctx)?.notice?.('a plugin operation is already running') - return + report({ text: t('a plugin operation is already running'), tone: 'warning' }) + return false } // Claim before the first await so overlapping keypresses cannot both run. operationInFlight = true try { const dshBin = await findDshBin() /* v8 ignore next -- a fiber unload landing inside these awaits is a shutdown race */ - if (unloaded) return + if (unloaded) return false if (dshBin === undefined) { - getSharedEditor(ctx)?.notice?.('plugin operations need the dsh CLI on PATH (or $DSH_BIN)') - return + report({ text: t('plugin operations need the dsh CLI on PATH (or $DSH_BIN)'), tone: 'danger' }) + return false } if (action === 'install' && entrySupportsSource(entry, source) === false) { - getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" has no ${source} install source`) - return + report({ text: `"${entry.displayName}" has no ${source} install source`, tone: 'danger' }) + return false + } + report({ + text: t(action === 'install' ? 'installing "{name}"...' : 'removing "{name}"...', { name: entry.displayName }), + tone: 'muted', + }) + const input = { + dshBin, + profile: profileNameFromArgv(process.argv), + root: profileRoot(profileNameFromArgv(process.argv)), + entry, + source, + ...(reporter === undefined ? {} : { onProgress: (phase: 'verify' | 'rollback') => { + report({ + text: t(phase === 'verify' ? 'checking "{name}" compatibility...' : 'rolling back "{name}"...', { name: entry.displayName }), + tone: phase === 'verify' ? 'muted' : 'warning', + }) + } }), } - getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'installing "{name}"...' : 'removing "{name}"...', { name: entry.displayName })) - const input = { dshBin, profile: profileNameFromArgv(process.argv), root: profileRoot(profileNameFromArgv(process.argv)), entry, source } const outcome = action === 'install' ? await installEntry(input) : await uninstallEntry(input) /* v8 ignore next -- a fiber unload landing inside these awaits is a shutdown race */ - if (unloaded) return + if (unloaded) return false if (outcome.kind === 'error') { - getSharedEditor(ctx)?.notice?.(t(action === 'install' ? 'install failed: {message}' : 'uninstall failed: {message}', { message: outcome.text })) - return + report({ + text: t(action === 'install' ? 'install failed: {message}' : 'uninstall failed: {message}', { message: outcome.text }), + tone: 'danger', + }) + return false } refreshInstalled() - getSharedEditor(ctx)?.notice?.(t(action === 'install' - ? 'installed; restart Mayfly and start a new session to apply' - : 'removed; restart Mayfly and start a new session to apply')) + report({ + text: t(action === 'install' + ? 'installed; restart Mayfly and start a new session to apply' + : 'removed; restart Mayfly and start a new session to apply'), + tone: 'success', + }) + return true } finally { operationInFlight = false } @@ -265,68 +301,35 @@ export function registerPluginCommand(ctx: Context): () => void { }) } - /** - * Open the browse panel. `mode` selects the catalog (grouped by source - * tier) or the installed view (grouped by installed / updates / removed). - */ - function openBrowse(mode: 'catalog' | 'installed'): CommandOutcome { + /** Open the marketplace as installed and not-installed tabs. */ + function openBrowse(initialGroup: 'installed' | 'not-installed'): CommandOutcome { const display = displayServices(ctx) if (display === undefined) { return { kind: 'error', text: 'plugin browser is unavailable: the Mayfly screen is not mounted' } } - /** Rows for the catalog mode: live entries except tombstones. */ - const catalogItems = (): readonly FrontendPanelItem[] => - entries().filter(entry => entry.status !== 'removed').map(entry => { - return { - id: entry.id, - label: entry.displayName, - detail: describe(entry), - badge: badgeOf(entry, states()[entry.id]), - group: entry.source, - action: { kind: 'plugin-market/details', id: entry.id }, - actionLabel: t('Details'), - } - }) + let panelStatus: OperationStatus | undefined - /** Rows for the installed mode: profile deps joined against the index. */ - const installedItems = (): readonly FrontendPanelItem[] => { - const installedByName = new Map(installed.map(plugin => [plugin.name, plugin])) - const indexedNames = new Set(entries().flatMap(entry => entry.install.rows.map(row => row.name))) + /** Indexed rows grouped by their current all-rows-installed state. */ + const marketItems = (): readonly FrontendPanelItem[] => { const state = states() - const rank = { installed: 0, updates: 1, removed: 2 } as const - const marketRows = entries().flatMap((entry): readonly FrontendPanelItem[] => { - const present = entry.install.rows.filter(row => installedByName.has(row.name)) - if (present.length === 0) return [] - const entryState = state[entry.id] - const removed = entry.status === 'removed' - const update = entryState?.updateAvailable === true - const latest = entry.install.rows.map(row => entry.npm?.[row.name]?.latestVersion).find(version => version != null) - const pieces = [ - entryState?.installed === true ? entryState.version : 'partial', - update && latest !== null && latest !== undefined ? `up ${latest}` : undefined, - removed ? 'removed' : undefined, - ].filter((piece): piece is string => piece !== undefined) - return [{ + return entries().filter(entry => entry.status !== 'removed' || state[entry.id]?.installed === true).map(entry => { + const installedNow = state[entry.id]?.installed === true + const installBlocked = currentProfileInstallBlock(entry) !== undefined + return { id: entry.id, label: entry.displayName, - detail: removed ? (entry.statusNote ?? t('removed from the market')) : describe(entry), - badge: pieces.join(' · '), - group: removed ? 'removed' : update ? 'updates' : 'installed', + detail: entry.status === 'removed' ? (entry.statusNote ?? t('removed from the market')) : describe(entry), + badge: badgeOf(entry, state[entry.id]), + group: installedNow ? 'installed' : 'not-installed', action: { kind: 'plugin-market/details', id: entry.id }, actionLabel: t('Details'), - }] + ...(!installedNow && installBlocked ? {} : { + secondaryAction: { kind: installedNow ? 'plugin-market/uninstall' : 'plugin-market/install', id: entry.id }, + secondaryActionLabel: t(installedNow ? 'Uninstall' : 'Install'), + }), + } }) - const removedRows = installed.filter(plugin => !indexedNames.has(plugin.name)).map(plugin => ({ - id: plugin.name, - label: plugin.name, - detail: plugin.spec, - badge: 'removed', - group: 'removed', - })) - const rows = [...marketRows, ...removedRows] - /* v8 ignore next -- every row above sets one of the three groups */ - return [...rows].sort((a, b) => (rank[a.group as keyof typeof rank] ?? 0) - (rank[b.group as keyof typeof rank] ?? 0)) } const model = (): FrontendPanelDocument => { @@ -334,45 +337,63 @@ export function registerPluginCommand(ctx: Context): () => void { return { mode: 'loading', title: t('Plugin marketplace'), view: { kind: 'text', content: t('loading catalog...') } } } if (catalog.status === 'offline') { - return { mode: 'error', title: t('Plugin marketplace'), view: { kind: 'text', content: t('marketplace is offline: {message}', { message: catalog.message }) } } + return { + mode: 'error', + title: t('Plugin marketplace'), + view: { kind: 'text', content: panelStatus?.text ?? t('marketplace is offline: {message}', { message: catalog.message }) }, + } } - // One flat list: the tier rides in the badge and the index order sorts - // official → dsh → community, so no tab row comes between focus and - // the rows (Enter on a row opens its detail, the trace-panel pattern). - const items = mode === 'catalog' ? catalogItems() : installedItems() + const items = marketItems() + const installedCount = items.filter(item => item.group === 'installed').length + const notInstalledCount = items.length - installedCount return { mode: 'select', title: t('Plugin marketplace'), + ...(panelStatus === undefined ? {} : { header: { kind: 'text', content: panelStatus.text, tone: panelStatus.tone } as const }), items, filterable: true, - empty: mode === 'catalog' - ? { title: t('No plugins indexed') } - : { title: t('no plugins installed') }, + grouped: true, + includeAllGroup: false, + groups: ['installed', 'not-installed'], + groupLabels: { installed: t('Installed'), 'not-installed': t('Not installed') }, + groupCounts: { installed: installedCount, 'not-installed': notInstalledCount }, + emptyByGroup: { + installed: { title: t('no plugins installed') }, + 'not-installed': { title: t('all marketplace plugins are installed') }, + }, } } + let panel: CanonicalDocumentController + const reportInPanel: OperationReporter = (status) => { + panelStatus = status + panel.invalidate() + display.screen.requestRender() + } + /** Install or remove the entry an `i`/`u` keypress selected. */ const runOperation = (id: string, action: 'install' | 'uninstall'): void => { const entry = findEntry(id) + /* v8 ignore next -- browser actions only carry ids from indexed rows */ if (entry === undefined) return if (action === 'uninstall' && states()[entry.id]?.installed !== true) { - getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" is not installed in this profile`) + reportInPanel({ text: `"${entry.displayName}" is not installed in this profile`, tone: 'danger' }) return } const installBlock = action === 'install' ? currentProfileInstallBlock(entry) : undefined if (installBlock !== undefined) { - getSharedEditor(ctx)?.notice?.(t(installBlock)) + reportInPanel({ text: t(installBlock), tone: 'danger' }) return } if (action === 'install' && usefulInTui(entry) === false) { - getSharedEditor(ctx)?.notice?.(t('web-only plugin: it contributes nothing in this terminal frontend')) + reportInPanel({ text: t('web-only plugin: it contributes nothing in this terminal frontend'), tone: 'warning' }) } const source = defaultInstallSource(entry) if (action === 'install' && source === undefined) { - getSharedEditor(ctx)?.notice?.(`"${entry.displayName}" has no common install source for every package`) + reportInPanel({ text: `"${entry.displayName}" has no common install source for every package`, tone: 'danger' }) return } - void operate(entry, action, source ?? 'npm').then(() => { + void operate(entry, action, source ?? 'npm', reportInPanel).then(() => { if (unloaded) return panel.invalidate() display.screen.requestRender() @@ -402,11 +423,14 @@ export function registerPluginCommand(ctx: Context): () => void { if (action.kind === 'plugin-market/install') runOperation(id, 'install') else if (action.kind === 'plugin-market/uninstall') runOperation(id, 'uninstall') else if (action.kind === 'plugin-market/refresh') { + reportInPanel({ text: t('refreshing plugin catalog...'), tone: 'muted' }) void reload(true).then(result => { /* v8 ignore next -- a fiber unload landing inside the refresh await is a shutdown race */ if (unloaded) return if (result.status === 'offline') { - getSharedEditor(ctx)?.notice?.(t('refresh failed: {message}', { message: result.message })) + reportInPanel({ text: t('refresh failed: {message}', { message: result.message }), tone: 'danger' }) + } else { + reportInPanel({ text: t('refreshed {count} entries', { count: String(result.index.entries.length) }), tone: 'success' }) } panel.invalidate() display.screen.requestRender() @@ -416,7 +440,7 @@ export function registerPluginCommand(ctx: Context): () => void { } let restore: () => void - const panel = new CanonicalDocumentController({ + panel = new CanonicalDocumentController({ keymap: display.keymap, theme: display.theme, components: display.components, @@ -436,11 +460,17 @@ export function registerPluginCommand(ctx: Context): () => void { return undefined }, contextHints: () => [{ + id: 'navigate', + keys: t('←→ tabs · ↑↓ select'), + compact: '←→/↑↓', + priority: 98, + }, { id: 'plugin-operations', keys: t('i install · u remove · r refresh'), compact: 'i/u/r', priority: 95, }], + initialGroup, }) restore = mountEditorReplacement(ctx, panel) const offLocale = observeInteractionLocale(ctx, () => { @@ -466,7 +496,7 @@ export function registerPluginCommand(ctx: Context): () => void { handler: async (invocation): Promise => { const raw = invocation.rawInput.trim() if (raw === '') { - return openBrowse('catalog') + return openBrowse('not-installed') } const tokens = raw.split(/\s+/) const verb = tokens[0]! diff --git a/packages/mayfly/src/interaction/plugin-market/installer.ts b/packages/mayfly/src/interaction/plugin-market/installer.ts index f14e6ef..c517389 100644 --- a/packages/mayfly/src/interaction/plugin-market/installer.ts +++ b/packages/mayfly/src/interaction/plugin-market/installer.ts @@ -17,6 +17,10 @@ import type { MarketEntry, MarketInstallRow } from './types.ts' /** Install ceiling, matching the updater's install timeout. */ const INSTALL_TIMEOUT_MS = 1_200_000 +/** Fresh-process import ceiling for the newly installed entry points. */ +const IMPORT_TIMEOUT_MS = 30_000 +/** Profile files restored when a new plugin fails its compatibility check. */ +const TRANSACTION_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'cordis.patch.yml'] as const /** One plugin the profile actually carries. */ export interface InstalledPlugin { @@ -118,6 +122,42 @@ interface PatchEdit { readonly text: string } +interface FileSnapshot { + readonly path: string + readonly text: string | undefined +} + +/** Capture the complete manifest/config boundary changed by installation. */ +function snapshotTransactionFiles(root: string): readonly FileSnapshot[] { + return TRANSACTION_FILES.map(file => { + const path = join(root, file) + return { path, text: updaterInternals.readTextFile(path) } + }) +} + +/** Restore captured files, removing ones that did not exist before. */ +function restoreTransactionFiles(files: readonly FileSnapshot[]): void { + for (const file of files) { + if (file.text === undefined) updaterInternals.removeFile(file.path) + else updaterInternals.writeTextFile(file.path, file.text) + } +} + +/** Import every new package root in a fresh Node process. */ +async function importInstalledRows(root: string, rows: readonly MarketInstallRow[]): Promise { + const script = [ + "const { createRequire } = await import('node:module')", + "const { join } = await import('node:path')", + "const { pathToFileURL } = await import('node:url')", + "const req = createRequire(join(process.cwd(), 'package.json'))", + `for (const name of ${JSON.stringify(rows.map(row => row.name))}) await import(pathToFileURL(req.resolve(name)).href)`, + ].join('\n') + return updaterInternals.spawnOnce(process.execPath, ['--input-type=module', '-e', script], { + cwd: root, + timeoutMs: IMPORT_TIMEOUT_MS, + }) +} + /** Collect and validate every row nested under a top-level `insert` patch. */ function insertionRows(items: readonly unknown[]): YAMLMap[] { const rows: YAMLMap[] = [] @@ -213,6 +253,33 @@ export interface InstallerInput { readonly entry: MarketEntry /** Which remote specs come from. */ readonly source: InstallSource + /** Optional progress sink for the interactive browser. */ + readonly onProgress?: (phase: 'verify' | 'rollback') => void +} + +/** Remove a newly installed entry and restore its pre-install files. */ +async function rollbackInstall( + input: InstallerInput, + names: readonly string[], + files: readonly FileSnapshot[], + reason: string, +): Promise { + input.onProgress?.('rollback') + const removal = await updaterInternals.spawnOnce(input.dshBin, + ['plugin', '--profile', input.profile, 'remove', ...names], + { cwd: input.root, timeoutMs: INSTALL_TIMEOUT_MS }) + let restoreError: string | undefined + try { + restoreTransactionFiles(files) + } catch (error) { + restoreError = errorText(error) + } + if (removal.code !== 0 || restoreError !== undefined) { + const details = [removal.code === 0 ? undefined : describeFailure('automatic rollback', removal), restoreError === undefined ? undefined : `restoring profile files failed: ${restoreError}`] + .filter((detail): detail is string => detail !== undefined).join('; ') + return { kind: 'error', text: `${reason}; rollback incomplete: ${details}` } + } + return { kind: 'error', text: `${reason}; changes rolled back` } } /** @@ -224,10 +291,15 @@ export async function installEntry(input: InstallerInput): Promise plugin.name)) + if (rows.some(row => installedNames.has(row.name))) { + return { kind: 'error', text: `"${input.entry.displayName}" is already or partially installed; uninstall it before reinstalling` } + } const specs = rows.map(row => rowSpec(row, input.source)).filter((spec): spec is string => spec !== undefined) if (specs.length !== rows.length || specs.length === 0) { return { kind: 'error', text: `"${input.entry.displayName}" has no ${input.source} install source` } } + const files = snapshotTransactionFiles(input.root) let patchEdit: PatchEdit | undefined try { patchEdit = prepareProfilePatchRows(input.root, rows.filter(row => row.activation === 'profile-patch')) @@ -239,12 +311,18 @@ export async function installEntry(input: InstallerInput): Promise row.name), files, describeFailure(`checking "${input.entry.displayName}" compatibility`, imported)) + } try { if (patchEdit !== undefined) updaterInternals.writeTextFile(patchEdit.path, patchEdit.text) } catch (error) { - return { kind: 'error', text: `packages installed but activating "${input.entry.displayName}" failed: ${errorText(error)}` } + return rollbackInstall(input, rows.map(row => row.name), files, `activating "${input.entry.displayName}" failed: ${errorText(error)}`) } return { kind: 'success' } } diff --git a/packages/mayfly/tests/interaction/frontend-panel.spec.ts b/packages/mayfly/tests/interaction/frontend-panel.spec.ts index 9152fd3..42ddf0d 100644 --- a/packages/mayfly/tests/interaction/frontend-panel.spec.ts +++ b/packages/mayfly/tests/interaction/frontend-panel.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { CanonicalDocumentController, type FrontendPanelDocument } from '../../src/interaction/frontend-panel.ts' import { fakeMayflyContext, KEY } from './fakes.ts' -function fixture(initial?: FrontendPanelDocument, options: { focused?: boolean, hint?: string, showSelectedVariantInFooter?: boolean, t?: (key: string) => string, onUnhandledInput?: (data: string, id: string | undefined) => { readonly kind: string } | undefined } = {}) { +function fixture(initial?: FrontendPanelDocument, options: { focused?: boolean, hint?: string, showSelectedVariantInFooter?: boolean, initialGroup?: string, t?: (key: string) => string, onUnhandledInput?: (data: string, id: string | undefined) => { readonly kind: string } | undefined } = {}) { const display = fakeMayflyContext() let model: FrontendPanelDocument = initial ?? { mode: 'info', title: 'Fixture', view: { kind: 'text', content: 'body' }, submit: { kind: 'refresh' } } const onAction = vi.fn() @@ -13,6 +13,7 @@ function fixture(initial?: FrontendPanelDocument, options: { focused?: boolean, ...display, model: () => model, onAction, onClose, maxVisible: 5, ...(options.hint === undefined ? {} : { contextHints: () => [{ id: 'custom', keys: options.hint!, priority: 95 }] }), ...(options.showSelectedVariantInFooter === undefined ? {} : { showSelectedVariantInFooter: options.showSelectedVariantInFooter }), + ...(options.initialGroup === undefined ? {} : { initialGroup: options.initialGroup }), ...(options.t === undefined ? {} : { t: options.t }), ...(options.onUnhandledInput === undefined ? {} : { onUnhandledInput: options.onUnhandledInput }), }) @@ -32,6 +33,9 @@ describe('CanonicalDocumentController', () => { passive.panel.handleInput('x') passive.panel.handleInput(KEY.escape) expect(passive.onClose).toHaveBeenCalledOnce() + const quit = fixture({ mode: 'info', title: 'Quit' }) + quit.panel.handleInput('q') + expect(quit.onClose).toHaveBeenCalledOnce() const translated = fixture(undefined, { t: key => `translated:${key}` }) expect(translated.panel.render(40)).toBeDefined() @@ -245,7 +249,7 @@ describe('CanonicalDocumentController', () => { it('bounds long lists, supports page/top/end keys, and custom input', () => { const shortcut = vi.fn(() => ({ kind: 'shortcut' as const })) const value = fixture({ - mode: 'select', title: 'Long', items: Array.from({ length: 20 }, (_, index) => ({ id: String(index), label: `Item ${String(index)}` })), + mode: 'select', title: 'Long', filterable: true, items: Array.from({ length: 20 }, (_, index) => ({ id: String(index), label: `Item ${String(index)}` })), }, { hint: 'custom', onUnhandledInput: (data, id) => data === 'c' && id === '0' ? shortcut() : undefined }) expect(extractList(value.panel.currentNode()).items).toHaveLength(20) expect(value.panel.render(60).join('\n')).toContain('Item 0') @@ -253,6 +257,12 @@ describe('CanonicalDocumentController', () => { value.panel.handleInput('c'); value.panel.handleInput('\x1b[6~'); value.panel.handleInput('\x1b[F'); value.panel.handleInput('\x1b[H'); value.panel.handleInput('\x1b[5~') expect(value.onAction).toHaveBeenCalledWith({ kind: 'shortcut' }) expect(value.panel.render(60).join('\n')).toContain('custom') + const actionCount = value.onAction.mock.calls.length + value.panel.handleInput('/') + value.panel.handleInput('c') + expect(value.onAction).toHaveBeenCalledTimes(actionCount) + expect(value.panel.render(60).join('\n')).toContain('/ c') + expect(value.panel.render(60).join('\n')).not.toContain('custom') }) it('locks loading panels and refreshes replacement models', () => { @@ -288,6 +298,13 @@ describe('CanonicalDocumentController', () => { const errorStatus = fixture({ mode: 'error', title: 'Failed update', dismissible: false }) expect(errorStatus.panel.currentNode()).toMatchObject({ footer: { content: 'updating - do not close', tone: 'danger' } }) + + const deferredGroup = fixture({ mode: 'loading', title: 'Deferred group' }, { initialGroup: 'catalog' }) + deferredGroup.panel.render(80) + deferredGroup.setModel(groupedModel) + const deferredNode = deferredGroup.panel.currentNode() + if (deferredNode.kind !== 'surface' || deferredNode.child.kind !== 'stack') throw new Error('expected grouped surface') + expect(deferredNode.child.children.map(child => child.node).find(node => node.kind === 'tabs')).toMatchObject({ activeId: 'catalog' }) }) it('maps compiler list, tab, and Escape events through the canonical adapter', () => { diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index a84c1a2..109ee1f 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -180,6 +180,29 @@ async function mountWorld(options: { } } +interface BrowserPanel { + handleInput(data: string): void + currentNode(): unknown + render(width: number): string[] + onEvent(event: { kind: string, controlId: string, tabId?: string, value?: unknown }): void +} + +/** Select one browser tab through the canonical tab event. */ +function selectBrowserTab(panel: BrowserPanel, tabId: 'installed' | 'not-installed'): void { + panel.onEvent({ kind: 'tab-change', controlId: 'frontend-panel-groups', tabId }) +} + +/** Activate one list row through the canonical selection event. */ +function activateBrowserRow(panel: BrowserPanel, id: string): void { + panel.onEvent({ kind: 'selection-change', controlId: 'frontend-panel-list', value: id }) +} + +/** Read the canonical tabs node from a plugin browser. */ +function browserTabs(panel: BrowserPanel): unknown { + const node = panel.currentNode() as { child: { children: Array<{ node: { kind?: string } }> } } + return node.child.children.find(child => child.node.kind === 'tabs')?.node +} + describe('installer unit seams', () => { it('composes npm and github specs, including monorepo subdirectories', () => { const row = entry().install.rows[0]! @@ -216,6 +239,15 @@ describe('installer unit seams', () => { expect(updaterInternals.spawnOnce).not.toHaveBeenCalled() }) + it('refuses to reinstall an entry while any of its rows are present', async () => { + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { 'dsh-loop': '0.1.4' } })) + updaterInternals.spawnOnce = vi.fn(async () => ok()) + expect(await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' })) + .toMatchObject({ kind: 'error', text: expect.stringContaining('already or partially installed') }) + expect(updaterInternals.spawnOnce).not.toHaveBeenCalled() + }) + it('reads installed plugins, skipping Mayfly itself', () => { const root = mkdtempTracked('mayfly-installed-') writeFileSync(join(root, 'package.json'), JSON.stringify({ @@ -422,6 +454,49 @@ describe('installer unit seams', () => { expect(await uninstallEntry({ dshBin: 'dsh', profile: 'p', root: uninstallRoot, entry: withPatch, source: 'npm' })) .toMatchObject({ kind: 'error', text: expect.stringContaining('cleaning up') }) }) + + it('import-checks new packages and restores the profile when compatibility fails', async () => { + const root = mkdtempTracked('mayfly-install-') + const files = { + 'package.json': JSON.stringify({ dependencies: { '@ephemeral-ai/mayfly': '1.0.0' }, dsh: { profile: { bundles: ['@ephemeral-ai/mayfly'] } } }), + 'pnpm-lock.yaml': 'lockfileVersion: 9\n', + 'pnpm-workspace.yaml': 'packages:\n - .\nallowBuilds:\n node-pty: false\n', + 'cordis.patch.yml': '# user layer\n[]\n', + } + for (const [file, text] of Object.entries(files)) writeFileSync(join(root, file), text) + const phases: string[] = [] + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === process.execPath) return { code: 1, signal: null, stdout: '', stderr: 'does not provide an export named CallId', timedOut: false } + if (args.includes('add')) { + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@ephemeral-ai/mayfly': '1.0.0', 'dsh-loop': 'github:x' } })) + } + return ok() + }) + const outcome = await installEntry({ + dshBin: 'dsh', profile: 'p', root, + entry: entry({ install: { allowBuilds: ['node-pty'], rows: entry().install.rows } }), + source: 'npm', onProgress: phase => phases.push(phase), + }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('changes rolled back') }) + expect(outcome.kind === 'error' ? outcome.text : '').toContain('CallId') + expect(phases).toEqual(['verify', 'rollback']) + for (const [file, text] of Object.entries(files)) expect(updaterInternals.readTextFile(join(root, file))).toBe(text) + expect(updaterInternals.spawnOnce).toHaveBeenCalledWith(process.execPath, expect.arrayContaining(['--input-type=module']), expect.objectContaining({ cwd: root })) + expect(updaterInternals.spawnOnce).toHaveBeenCalledWith('dsh', ['plugin', '--profile', 'p', 'remove', 'dsh-loop'], expect.any(Object)) + }) + + it('reports an incomplete rollback while still restoring captured files', async () => { + const root = mkdtempTracked('mayfly-install-') + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@ephemeral-ai/mayfly': '1.0.0' } })) + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === process.execPath) return { code: 1, signal: null, stdout: '', stderr: 'bad import', timedOut: false } + if (args.includes('remove')) return { code: 1, signal: null, stdout: '', stderr: 'remove failed', timedOut: false } + return ok() + }) + const outcome = await installEntry({ dshBin: 'dsh', profile: 'p', root, entry: entry(), source: 'npm' }) + expect(outcome).toMatchObject({ kind: 'error', text: expect.stringContaining('rollback incomplete') }) + expect(updaterInternals.readTextFile(join(root, 'package.json'))).toBe(JSON.stringify({ dependencies: { '@ephemeral-ai/mayfly': '1.0.0' } })) + }) }) describe('/plugin browse panel', () => { @@ -431,10 +506,15 @@ describe('/plugin browse panel', () => { expect(result).toEqual({ kind: 'success' }) const panel = world.overlay() expect(panel).toBeDefined() - const controller = panel as { currentNode(): { kind: string, child?: { children?: Array<{ node: unknown }> } }, render(width: number): string[] } + const controller = panel as BrowserPanel const node = controller.currentNode() expect(JSON.stringify(node)).toContain('Loop') expect(JSON.stringify(node)).toContain('official · Web+Server') + expect(browserTabs(controller)).toMatchObject({ kind: 'tabs', activeId: 'not-installed', items: [ + { id: 'installed', count: 0 }, + { id: 'not-installed', count: 1 }, + ] }) + expect(JSON.stringify(node)).toContain('Install') expect(controller.render(80).join('\n')).toContain('i install · u remove · r refresh') expect(controller.render(36).join('\n')).toContain('i/u/r') world.dispose() @@ -479,10 +559,10 @@ describe('/plugin argument paths', () => { expect(detail).toContain('dedicated non-Mayfly profile') expect(detail).toContain('dsh plugin --profile add @deepseek-ai/dsh-acp') await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + const panel = world.overlay() as BrowserPanel expect(JSON.stringify(panel.currentNode())).toContain('Automation') panel.handleInput('i') - expect(world.notices).toContain('automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile') + expect(JSON.stringify(panel.currentNode())).toContain('automation-only ACP server owns stdio; install it in a dedicated non-Mayfly profile') world.dispose() }) @@ -649,28 +729,93 @@ describe('/plugin argument paths', () => { }) describe('/plugin key paths', () => { + it('shows compatibility rollback progress and failure in the panel', async () => { + let releaseRollback: (() => void) | undefined + const rollbackGate = new Promise(resolve => { releaseRollback = resolve }) + const world = await mountWorld({ index: [entry()] }) + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === 'sh') return ok('/usr/bin/dsh\n') + if (cmd === process.execPath) return { code: 1, signal: null, stdout: '', stderr: 'missing export', timedOut: false } + if (args.includes('remove')) await rollbackGate + return ok() + }) + await world.run('/plugin') + const panel = world.overlay() as BrowserPanel + panel.handleInput('i') + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('rolling back "Loop"...')) + releaseRollback?.() + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('changes rolled back')) + expect(browserTabs(panel)).toMatchObject({ activeId: 'not-installed', items: [ + { id: 'installed', count: 0 }, { id: 'not-installed', count: 1 }, + ] }) + world.dispose() + }) + + it('shows progress and moves a plugin between tabs after visible actions complete', async () => { + let releaseInstall: (() => void) | undefined + const installGate = new Promise(resolve => { releaseInstall = resolve }) + let releaseVerify: (() => void) | undefined + const verifyGate = new Promise(resolve => { releaseVerify = resolve }) + const world = await mountWorld({ index: [entry()] }) + updaterInternals.spawnOnce = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === 'sh') return ok('/usr/bin/dsh\n') + if (cmd === process.execPath) { + await verifyGate + return ok() + } + const manifestPath = join(world.root, 'package.json') + const manifest = JSON.parse(updaterInternals.readTextFile(manifestPath) ?? '{}') as { dependencies: Record } + if (args.includes('add')) { + await installGate + manifest.dependencies['dsh-loop'] = '0.1.4' + mkdirSync(join(world.root, 'node_modules', 'dsh-loop'), { recursive: true }) + writeFileSync(join(world.root, 'node_modules', 'dsh-loop', 'package.json'), JSON.stringify({ version: '0.1.4' })) + } else if (args.includes('remove')) { + delete manifest.dependencies['dsh-loop'] + } + writeFileSync(manifestPath, JSON.stringify(manifest)) + return ok() + }) + await world.run('/plugin') + const panel = world.overlay() as BrowserPanel + expect(JSON.stringify(panel.currentNode())).toContain('Install') + panel.onEvent({ kind: 'activate', controlId: 'frontend-panel-secondary' }) + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('installing "Loop"...')) + releaseInstall?.() + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('checking "Loop" compatibility...')) + releaseVerify?.() + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('installed; restart Mayfly')) + expect(browserTabs(panel)).toMatchObject({ items: [{ id: 'installed', count: 1 }, { id: 'not-installed', count: 0 }] }) + selectBrowserTab(panel, 'installed') + expect(JSON.stringify(panel.currentNode())).toContain('Uninstall') + panel.onEvent({ kind: 'activate', controlId: 'frontend-panel-secondary' }) + await vi.waitFor(() => expect(panel.render(100).join('\n')).toContain('removed; restart Mayfly')) + expect(browserTabs(panel)).toMatchObject({ items: [{ id: 'installed', count: 0 }, { id: 'not-installed', count: 1 }] }) + world.dispose() + }) + it('i installs the selected row and u removes it, r refreshes', async () => { const world = await mountWorld({ index: [entry()], profileDependencies: { 'dsh-loop': '0.1.4' }, installedVersions: { 'dsh-loop': '0.1.4' }, }) - await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + await world.run('/plugin list') + const panel = world.overlay() as BrowserPanel panel.handleInput('u') await new Promise(resolve => setTimeout(resolve, 5)) expect(world.spawns.some(spawn => spawn.args.includes('remove'))).toBe(true) world.dispose() }) - it('u on an uninstalled entry only flashes a notice', async () => { + it('u on an uninstalled entry reports inside the panel', async () => { const world = await mountWorld({ index: [entry()] }) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel panel.handleInput('u') await new Promise(resolve => setTimeout(resolve, 5)) expect(world.spawns.filter(spawn => spawn.cmd === '/usr/bin/dsh')).toHaveLength(0) - expect(world.notices).toContain('"Loop" is not installed in this profile') + expect(panel.render(100).join('\n')).toContain('"Loop" is not installed in this profile') world.dispose() }) @@ -691,12 +836,12 @@ describe('/plugin key paths', () => { expect(await world.run('/plugin install mixed')).toMatchObject({ kind: 'error', text: expect.stringContaining('no common install source') }) await world.run('/plugin info mixed') expect(JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode())).toContain('add ') - await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + await world.run('/plugin list') + const panel = world.overlay() as BrowserPanel panel.handleInput('i') + expect(panel.render(100).join('\n')).toContain('"Mixed" has no common install source for every package') panel.handleInput('u') await new Promise(resolve => setTimeout(resolve, 5)) - expect(world.notices).toContain('"Mixed" has no common install source for every package') expect(world.spawns.some(spawn => spawn.args.includes('remove'))).toBe(true) expect(await world.run('/plugin uninstall mixed')).toEqual({ kind: 'success' }) world.dispose() @@ -713,7 +858,9 @@ describe('/plugin key paths', () => { }) const world = await mountWorld({ index: [multi], profileDependencies: { 'multi-a': '1.0.0' } }) await world.run('/plugin list') - const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) + const panel = world.overlay() as BrowserPanel + selectBrowserTab(panel, 'not-installed') + const json = JSON.stringify(panel.currentNode()) expect(json).toContain('partial') expect(json.match(/Multi Row/gu)).toHaveLength(1) world.dispose() @@ -752,12 +899,12 @@ describe('/plugin coverage corners', () => { return realSpawn(cmd, args) }) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel panel.handleInput('i') await new Promise(resolve => setTimeout(resolve, 5)) panel.handleInput('I') await new Promise(resolve => setTimeout(resolve, 5)) - expect(world.notices).toContain('a plugin operation is already running') + expect(JSON.stringify(panel.currentNode())).toContain('a plugin operation is already running') release?.() await new Promise(resolve => setTimeout(resolve, 10)) expect(world.spawns.filter(spawn => spawn.args.includes('add'))).toHaveLength(1) @@ -787,16 +934,13 @@ describe('/plugin coverage corners', () => { it('Enter opens the detail overlay above the browse panel; Escape pops it', async () => { const world = await mountWorld({ index: [entry()] }) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel expect(world.screen.overlays).toHaveLength(1) await vi.waitFor(() => { expect(JSON.stringify(panel.currentNode())).toContain('Loop') }) - // Compile the surface once, then step focus into the list and activate - // the row (the trace-command spec's driving discipline). - ;(panel as unknown as { render(width: number): string[] }).render(80) - panel.handleInput(KEY.tab) - panel.handleInput(KEY.enter) + // The canonical list emits the same selection action Enter dispatches. + activateBrowserRow(panel, 'loop') await vi.waitFor(() => { expect(world.screen.overlays).toHaveLength(2) }) @@ -810,13 +954,13 @@ describe('/plugin coverage corners', () => { it('r refreshes through the panel, hotkeys are case-insensitive, and other keys pass through', async () => { const world = await mountWorld({ index: [entry()] }) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel panel.handleInput('R') await new Promise(resolve => setTimeout(resolve, 10)) expect(updaterInternals.fetchText).toHaveBeenCalled() panel.handleInput('U') await new Promise(resolve => setTimeout(resolve, 5)) - expect(world.notices).toContain('"Loop" is not installed in this profile') + expect(panel.render(100).join('\n')).toContain('"Loop" is not installed in this profile') // Any other printable key starts the built-in type-to-filter instead. panel.handleInput('x') world.dispose() @@ -836,10 +980,11 @@ describe('/plugin coverage corners', () => { const webOnly = entry({ id: 'panel', displayName: 'Panel', surfaces: { web: { clientModule: true } } }) const world = await mountWorld({ index: [webOnly] }) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel panel.handleInput('i') + expect(JSON.stringify(panel.currentNode())).toContain('web-only plugin: it contributes nothing in this terminal frontend') await new Promise(resolve => setTimeout(resolve, 10)) - expect(world.notices).toContain('web-only plugin: it contributes nothing in this terminal frontend') + expect(JSON.stringify(panel.currentNode())).toContain('installed; restart Mayfly and start a new session to apply') expect(world.spawns.some(spawn => spawn.args.includes('add'))).toBe(true) world.dispose() }) @@ -989,15 +1134,14 @@ describe('/plugin lifecycle and locale', () => { }) const world = await mountWorld({ index: [entry(), tui] }) await world.run('/plugin') - const browse = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + const browse = world.overlay() as BrowserPanel let json = JSON.stringify(browse.currentNode()) expect(json).toContain('Tui Pane') expect(json).toContain('TUI+Server') expect(json).toContain('unstable') // Enter → detail above the browse panel; both observers re-render on a // preference switch, and the zh description takes over. - ;(browse as unknown as { render(width: number): string[] }).render(80) - browse.handleInput(KEY.enter) + activateBrowserRow(browse, 'loop') await vi.waitFor(() => { expect(world.screen.overlays).toHaveLength(2) }) @@ -1020,7 +1164,7 @@ describe('/plugin lifecycle and locale', () => { world.dispose() }) - it('covers the info error paths and the installed-mode stray row hotkey', async () => { + it('covers info errors and excludes unindexed profile dependencies from market tabs', async () => { const world = await mountWorld({ index: [entry()], profileDependencies: { 'stray-pkg': '1.0.0' }, @@ -1030,11 +1174,10 @@ describe('/plugin lifecycle and locale', () => { expect(await bare.run('/plugin info loop')).toMatchObject({ kind: 'error', text: expect.stringContaining('not mounted') }) bare.dispose() await world.run('/plugin list') - const panel = world.overlay() as { handleInput(data: string): void, currentNode(): unknown } + const panel = world.overlay() as BrowserPanel const json = JSON.stringify(panel.currentNode()) - expect(json).toContain('stray-pkg') - expect(json).toContain('removed') - // i on the stray row resolves no entry and stays a no-op. + expect(json).not.toContain('stray-pkg') + expect(json).toContain('no plugins installed') panel.handleInput('i') await new Promise(resolve => setTimeout(resolve, 5)) expect(world.spawns.filter(spawn => spawn.args[0] === 'plugin')).toHaveLength(0) @@ -1114,13 +1257,13 @@ describe('/plugin final coverage corners', () => { // Load a cached catalog so the panel opens, then go offline for the key. updaterInternals.writeTextFile(join(world.root, '..', '..', 'storages', 'mayfly-plugin-market', 'cache.json'), JSON.stringify({ fetchedAt: 1_000_000, text: indexJson([entry()]) })) await world.run('/plugin') - const panel = world.overlay() as { handleInput(data: string): void } + const panel = world.overlay() as BrowserPanel updaterInternals.fetchText = vi.fn(async () => { throw new Error('offline now') }) panel.handleInput('r') await new Promise(resolve => setTimeout(resolve, 10)) - expect(world.notices.some(notice => notice.startsWith('refresh failed:'))).toBe(true) + expect(JSON.stringify(panel.currentNode())).toContain('refresh failed:') world.dispose() })