diff --git a/packages/plugin/console/lib/index.js b/packages/plugin/console/lib/index.js index adecd57..32c6a43 100644 --- a/packages/plugin/console/lib/index.js +++ b/packages/plugin/console/lib/index.js @@ -119,24 +119,34 @@ window.__ModuleLoader__.load({ lineHeight: "18px", color: "var(--dsw-alias-state-success-primary)" }; - /** 版本行:v当前 · latest(可更新时高亮);本地/非 registry 包无 latest。 */ - function versionText(plugin, latest, checked) { + /** 版本行:v当前 · latest(可更新时高亮);本地/非 registry 包无 latest; + * 检查失败(error 非空)显示失败原因,与「本地包」区分开。 */ + function versionText(plugin, latest, checked, error) { const current = plugin.version === void 0 ? "?" : `v${plugin.version}`; if (!checked) return { text: `${current} · 待检查`, - canUpdate: false + canUpdate: false, + failed: false + }; + if (error !== null && error !== void 0) return { + text: `${current} · 检查失败(${error})`, + canUpdate: false, + failed: true }; if (latest === null) return { - text: `${current} · 本地`, - canUpdate: false + text: `${current} · 本地/非 registry`, + canUpdate: false, + failed: false }; if (latest === plugin.version) return { text: `${current} · 已最新`, - canUpdate: false + canUpdate: false, + failed: false }; return { text: `${current} → v${latest}`, - canUpdate: true + canUpdate: true, + failed: false }; } /** @@ -165,6 +175,7 @@ window.__ModuleLoader__.load({ const [installMsg, setInstallMsg] = (0, react.useState)(void 0); const [versions, setVersions] = (0, react.useState)({}); const [versionChecked, setVersionChecked] = (0, react.useState)({}); + const [versionErrors, setVersionErrors] = (0, react.useState)({}); const refresh = (0, react.useCallback)(async () => { try { const [installedRes, versionsRes] = await Promise.all([fetch("/api/plugin-console/installed", { headers: { accept: "application/json" } }), fetch("/api/plugin-console/versions", { headers: { accept: "application/json" } })]); @@ -173,12 +184,15 @@ window.__ModuleLoader__.load({ setInstalled(installedBody.plugins ?? []); const map = {}; const checkedMap = {}; + const errorsMap = {}; for (const row of versionsBody.versions ?? []) { map[row.name] = row.latest; checkedMap[row.name] = row.checked === true; + errorsMap[row.name] = row.error ?? null; } setVersions(map); setVersionChecked(checkedMap); + setVersionErrors(errorsMap); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); } finally { @@ -214,12 +228,15 @@ window.__ModuleLoader__.load({ })).json(); const map = {}; const checkedMap = {}; + const errorsMap = {}; for (const row of versionBody.versions ?? []) { map[row.name] = row.latest; checkedMap[row.name] = row.checked === true; + errorsMap[row.name] = row.error ?? null; } setVersions(map); setVersionChecked(checkedMap); + setVersionErrors(errorsMap); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); } finally { @@ -401,7 +418,7 @@ window.__ModuleLoader__.load({ })), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { style: rowsStyle, children: [shown.map((plugin) => { - const version = versionText(plugin, versions[plugin.name] ?? null, versionChecked[plugin.name] === true); + const version = versionText(plugin, versions[plugin.name] ?? null, versionChecked[plugin.name] === true, versionErrors[plugin.name] ?? null); const isUserRow = !isOfficial(plugin) && !isSelf(plugin); return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { style: rowCardStyle, @@ -449,7 +466,10 @@ window.__ModuleLoader__.load({ ] })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - style: versionLineStyle, + style: version.failed ? { + ...versionLineStyle, + color: "var(--dsw-alias-state-error-primary)" + } : versionLineStyle, children: version.text })] }, showAll ? `a${plugin.id}` : `u${plugin.id}`); @@ -469,7 +489,7 @@ window.__ModuleLoader__.load({ //#region src/client/index.ts /** Cordis 插件名。 */ const name = "plugin-console-client"; - /** 需要 slots(settings.section 插槽)。 */ + /** 需要 slots(settings.plugins.tab 子 tab 插槽)。 */ const inject = ["slots"]; /** * 插头图标(plugin-line,参考 Clarity 图标库,dsh 风格:fill @@ -493,18 +513,18 @@ window.__ModuleLoader__.load({ host.dataset.dshConsoleIcon = "1"; } } - /** 注册设置页「插件」面板 + 替换 tab 图标(设置页随时打开/关闭,全程监听)。 */ + /** 注册官方「插件」页内的「插件控制台」子 tab + 替换顶层 tab 图标(设置页随时打开/关闭,全程监听)。 */ function apply(ctx) { new MutationObserver(patchPluginTabIcon).observe(document.body, { childList: true, subtree: true }); patchPluginTabIcon(); - ctx.slots.inject("settings.section", () => ctx.slots.register({ - name: "settings.section", + ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({ + name: "settings.plugins.tab", id: "plugin-console", - order: 60, - label: () => "插件", + order: 10, + label: () => "插件控制台", inject: () => ({}) }, ConsolePanel)); } diff --git a/packages/plugin/console/lib/index.mjs b/packages/plugin/console/lib/index.mjs index a09a79c..b6408a8 100644 --- a/packages/plugin/console/lib/index.mjs +++ b/packages/plugin/console/lib/index.mjs @@ -597,6 +597,45 @@ function createPluginTools(deps) { ]; } //#endregion +//#region src/versions.ts +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; +/** npm registry 根(npm_config_registry 环境变量优先,兼容镜像源)。 */ +function registryRoot() { + const configured = process.env.npm_config_registry; + return (configured !== void 0 && configured.trim() !== "" ? configured : DEFAULT_REGISTRY).replace(/\/+$/, ""); +} +/** scoped 包名(@scope/name)在 registry URL 路径中需把 / 编码为 %2f。 */ +function registryPackagePath(name) { + return name.startsWith("@") ? name.replace("/", "%2f") : name; +} +/** +* 查询某包在 registry 上的最新版本(GET //latest)。 +* 永不抛出:失败折叠为 { latest: null, error },由调用方记录/展示。 +*/ +async function npmViewLatest(name, fetchFn = fetch) { + const url = `${registryRoot()}/${registryPackagePath(name)}/latest`; + try { + const res = await fetchFn(url, { signal: AbortSignal.timeout(15e3) }); + if (res.status === 404) return { + latest: null, + error: null + }; + if (!res.ok) return { + latest: null, + error: `registry ${res.status}` + }; + return { + latest: (await res.json()).version ?? null, + error: null + }; + } catch (caught) { + return { + latest: null, + error: caught instanceof Error ? caught.message : String(caught) + }; + } +} +//#endregion //#region src/index.ts /** * 薄控制台 Node half(0811 适配):读写 web profile 的安装态—— @@ -978,49 +1017,45 @@ async function collectLoaderEntries(ctx) { for (const row of byId.values()) if (row.disabled && presetIds.has(row.id)) row.presetMounted = true; return [...byId.values()]; } -/** 版本检查缓存:name -> { latest, checkedAt }(进程内存)。 */ +/** 版本检查缓存:name -> { latest, error, checkedAt }(进程内存)。 */ const versionCache = /* @__PURE__ */ new Map(); const VERSION_REFRESH_MIN_MS = 3e4; let lastVersionRefreshAt = 0; -/** npm view version(registry 最新版);失败/非 registry 包返回 null。 */ -function npmViewLatest(name) { - let latest = null; - try { - const result = spawnSync("npm", [ - "view", - name, - "version" - ], { - encoding: "utf8", - timeout: 15e3, - stdio: [ - "ignore", - "pipe", - "pipe" - ] +/** 批量强制刷新版本缓存(可选 force):registry 查询走原生 fetch, +* 不 spawn 子进程(受限宿主环境管道捕获会被拦);404 = 非 registry 包。 */ +async function refreshVersions(ctx, force) { + const now = Date.now(); + if (!force && now - lastVersionRefreshAt < VERSION_REFRESH_MIN_MS) return false; + lastVersionRefreshAt = now; + const names = await userPluginNames(ctx); + await Promise.all(names.map(async (name) => { + const result = await npmViewLatest(name); + if (result.error !== null) ctx.logger.warn(`[plugin-console] version check failed for ${name}: ${result.error}`); + versionCache.set(name, { + latest: result.latest, + error: result.error, + checkedAt: Date.now() }); - const text = (result.stdout ?? "").trim(); - if (result.status === 0 && /^\d+(\.\d+)+/.test(text)) latest = text.split("\n")[0].trim(); - } catch {} - versionCache.set(name, { - latest, - checkedAt: Date.now() + })); + return true; +} +/** 版本行(缓存内容;error 区分「本地包」与「检查失败」)。 */ +function versionRows(names) { + return names.map((name) => { + const cached = versionCache.get(name); + return { + name, + latest: cached?.latest ?? null, + checked: cached !== void 0, + error: cached?.error ?? null + }; }); - return latest; } /** 用户插件名列表(排除官方命名空间)。 */ async function userPluginNames(ctx) { const entries = await collectLoaderEntries(ctx); return [...new Set(entries.map((row) => row.name).filter((name) => !name.startsWith("@deepseek-ai/") && !name.startsWith("@cordisjs/") && !name.startsWith("cordis:")))]; } -/** 批量强制刷新版本缓存(可选 force)。 */ -async function refreshVersions(ctx, force) { - const now = Date.now(); - if (!force && now - lastVersionRefreshAt < VERSION_REFRESH_MIN_MS) return false; - lastVersionRefreshAt = now; - for (const name of await userPluginNames(ctx)) npmViewLatest(name); - return true; -} /** Cordis 插件名。 */ const name = "plugin-console"; /** 需要宿主 web server(web 组合)+ loader(读/改 loader 树条目)+ tools(注册 plugin_* 管理工具)+ agentPresets(预设挂载标注)。 */ @@ -1136,14 +1171,7 @@ function apply(ctx) { if (method === "GET" && (path === "/api/plugin-console/versions" || path === "/api/plugin-console/versions/")) { json(200, { ok: true, - versions: (await userPluginNames(ctx)).map((name) => { - const cached = versionCache.get(name); - return { - name, - latest: cached?.latest ?? null, - checked: cached !== void 0 - }; - }) + versions: versionRows(await userPluginNames(ctx)) }); return; } @@ -1151,14 +1179,7 @@ function apply(ctx) { json(200, { ok: true, refreshed: await refreshVersions(ctx, false), - versions: (await userPluginNames(ctx)).map((name) => { - const cached = versionCache.get(name); - return { - name, - latest: cached?.latest ?? null, - checked: cached !== void 0 - }; - }) + versions: versionRows(await userPluginNames(ctx)) }); return; } diff --git a/packages/plugin/console/package.json b/packages/plugin/console/package.json index eda1042..d378e17 100644 --- a/packages/plugin/console/package.json +++ b/packages/plugin/console/package.json @@ -8,7 +8,7 @@ "main": "lib/index.mjs", "scripts": { "build": "tsdown --config tsdown.config.ts", - "test": "TSX_TSCONFIG_PATH=tests/tsconfig.json node --import tsx --test tests/discovery/*.spec.ts" + "test": "cd tests && node --import tsx --test \"**/*.spec.ts\"" }, "exports": { ".": { diff --git a/packages/plugin/console/pnpm-lock.yaml b/packages/plugin/console/pnpm-lock.yaml index 9d1b224..6b0e052 100644 --- a/packages/plugin/console/pnpm-lock.yaml +++ b/packages/plugin/console/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: ^2.4.0 version: 2.9.0 devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -224,42 +227,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.3': resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.3': resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.3': resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.3': resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.3': resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.3': resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} @@ -282,6 +279,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -312,37 +312,31 @@ packages: resolution: {integrity: sha512-g6LnHrR0Rfqq5cXs7olwR2+LlVDc876pw7Hh7YXbukVSiBxQkaxqsEO/trO25z2g99zWzgXwoYvQZ1TnwA2wEw==} cpu: [arm] os: [linux] - libc: [glibc] '@yuku-codegen/binding-linux-arm-musl@0.8.4': resolution: {integrity: sha512-7XAPHrROPEFuJWXEGZeLZQN4xR8ENQ65+HSCtCHjSzCwGgnX53GUjM9ExVcHopV0a5g4vu57v2wwtyWIM5iNOQ==} cpu: [arm] os: [linux] - libc: [musl] '@yuku-codegen/binding-linux-arm64-gnu@0.8.4': resolution: {integrity: sha512-fnBm7NLuuwFXy7F1vRIuyOc+RW9ADUjuCKVGY87Dj8jtr9XgESNrwb+B9VLSFY7nZ0rCdK/Sm1fBqJpI7eLdKQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@yuku-codegen/binding-linux-arm64-musl@0.8.4': resolution: {integrity: sha512-6vTw4ZHO9nm4SUkw36uG+UE6/qifZ0E8HIef7Mx/U/c2Zxu3JLBfXtU7U/NN2GMkDmcJkgwjXfpQoYw4Ch5Y1w==} cpu: [arm64] os: [linux] - libc: [musl] '@yuku-codegen/binding-linux-x64-gnu@0.8.4': resolution: {integrity: sha512-+vuC3V3Lw+DB4oJgHV9pVDfQZlsZJnxmbdods7HzxgEALU3P5+czwdAcw3wfh7Ebabt0Ny8eLUb1k9RV5OB/+w==} cpu: [x64] os: [linux] - libc: [glibc] '@yuku-codegen/binding-linux-x64-musl@0.8.4': resolution: {integrity: sha512-QH60PE4eZecmgNGa1/T1cKPhrfxt6ANtu4lrQ1FZ50F9b0GS9WjGmIjrfdSUeQa+f2Iqk3oEFSJdgVHBg1KPNg==} cpu: [x64] os: [linux] - libc: [musl] '@yuku-codegen/binding-win32-arm64@0.8.4': resolution: {integrity: sha512-6r68c0nKZPIBRXZIBiD7zjlEukBR+xRxpTOVj4n1Fsjcdh4YbbJfjFfmIzdbo9jXR1xL+dPueDVdsRGOUH5MoQ==} @@ -378,37 +372,31 @@ packages: resolution: {integrity: sha512-Fo3r5fYhGDcFnl+KN+L9PgtiQPS4AIE1n1mG1o5jZ11p7g5yZ/1EjLFmSHAUsoXreun8KjTsFjEL7P1Sb93PZQ==} cpu: [arm] os: [linux] - libc: [glibc] '@yuku-parser/binding-linux-arm-musl@0.8.4': resolution: {integrity: sha512-BEB31vUEgXPWf7WkoMPSzzJhpC/wWCBXyysRCCPsw47BJ/OtbQsvJbxX9fFDuSRLy3kbyIV/WbdUTgbQ9COxiw==} cpu: [arm] os: [linux] - libc: [musl] '@yuku-parser/binding-linux-arm64-gnu@0.8.4': resolution: {integrity: sha512-xGLCRcHn9xVz7JVNyyKtiNJSf503qtUmih9XVSsghgzOmiKUMnHObs69OMMwXN3788tg1jsl11fNjjQlB8idMA==} cpu: [arm64] os: [linux] - libc: [glibc] '@yuku-parser/binding-linux-arm64-musl@0.8.4': resolution: {integrity: sha512-3kNRi8NJT2q6FQRVCUFHIQ99+kXdi8cVJEEUi6+xtFqpMgNrZNnbgAj28ILsDC7zmclaU+v47eUCeJoKLSCbww==} cpu: [arm64] os: [linux] - libc: [musl] '@yuku-parser/binding-linux-x64-gnu@0.8.4': resolution: {integrity: sha512-isi62oMy94Z3OXwGs2l2rkqRiRyqLmfHeTRHpA/uWZbsNhnm7IdVvkF7e7wHNKAtd5jwGzOqYSroxKv2fOm7Cw==} cpu: [x64] os: [linux] - libc: [glibc] '@yuku-parser/binding-linux-x64-musl@0.8.4': resolution: {integrity: sha512-9RsEw2xYHqU/pjSRBTOupWN3sF8uz9stjJdARfz6o0llvF+yfrj5QHmTiocGGBeAI80fvKmDtr2RIQClUzAPcA==} cpu: [x64] os: [linux] - libc: [musl] '@yuku-parser/binding-win32-arm64@0.8.4': resolution: {integrity: sha512-VEZHo9rEGOBKR20sA3vCO00aQvwWND5aLu7YxeX+YupMZJh9hd1f17AbClJN37Q2iL1PCHht+wDTOKX6tZYqXg==} @@ -583,6 +571,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -731,6 +722,10 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} '@types/react@18.3.31': @@ -963,6 +958,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@6.21.0: {} + verkit@0.3.2: {} yaml@2.9.0: {} diff --git a/packages/plugin/console/src/client/Panel.tsx b/packages/plugin/console/src/client/Panel.tsx index 43415cc..fe923bd 100644 --- a/packages/plugin/console/src/client/Panel.tsx +++ b/packages/plugin/console/src/client/Panel.tsx @@ -80,13 +80,15 @@ const savedStyle: React.CSSProperties = { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-success-primary)', } -/** 版本行:v当前 · latest(可更新时高亮);本地/非 registry 包无 latest。 */ -function versionText(plugin: LoadedPluginRow, latest: string | null, checked: boolean): { text: string; canUpdate: boolean } { +/** 版本行:v当前 · latest(可更新时高亮);本地/非 registry 包无 latest; + * 检查失败(error 非空)显示失败原因,与「本地包」区分开。 */ +function versionText(plugin: LoadedPluginRow, latest: string | null, checked: boolean, error: string | null): { text: string; canUpdate: boolean; failed: boolean } { const current = plugin.version === undefined ? '?' : `v${plugin.version}` - if (!checked) return { text: `${current} · 待检查`, canUpdate: false } - if (latest === null) return { text: `${current} · 本地`, canUpdate: false } - if (latest === plugin.version) return { text: `${current} · 已最新`, canUpdate: false } - return { text: `${current} → v${latest}`, canUpdate: true } + if (!checked) return { text: `${current} · 待检查`, canUpdate: false, failed: false } + if (error !== null && error !== undefined) return { text: `${current} · 检查失败(${error})`, canUpdate: false, failed: true } + if (latest === null) return { text: `${current} · 本地/非 registry`, canUpdate: false, failed: false } + if (latest === plugin.version) return { text: `${current} · 已最新`, canUpdate: false, failed: false } + return { text: `${current} → v${latest}`, canUpdate: true, failed: false } } /** @@ -113,6 +115,7 @@ export function ConsolePanel(): React.ReactNode { const [installMsg, setInstallMsg] = useState(undefined) const [versions, setVersions] = useState>({}) const [versionChecked, setVersionChecked] = useState>({}) + const [versionErrors, setVersionErrors] = useState>({}) const refresh = useCallback(async (): Promise => { try { @@ -121,16 +124,19 @@ export function ConsolePanel(): React.ReactNode { fetch('/api/plugin-console/versions', { headers: { accept: 'application/json' } }), ]) const installedBody = (await installedRes.json()) as { plugins?: LoadedPluginRow[]; ok?: boolean } - const versionsBody = (await versionsRes.json()) as { versions?: { name: string; latest: string | null; checked?: boolean }[]; ok?: boolean } + const versionsBody = (await versionsRes.json()) as { versions?: { name: string; latest: string | null; checked?: boolean; error?: string | null }[]; ok?: boolean } setInstalled(installedBody.plugins ?? []) const map: Record = {} const checkedMap: Record = {} + const errorsMap: Record = {} for (const row of versionsBody.versions ?? []) { map[row.name] = row.latest checkedMap[row.name] = row.checked === true + errorsMap[row.name] = row.error ?? null } setVersions(map) setVersionChecked(checkedMap) + setVersionErrors(errorsMap) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) } finally { @@ -164,15 +170,18 @@ export function ConsolePanel(): React.ReactNode { setError(undefined) try { const versionRes = await fetch('/api/plugin-console/versions/refresh', { method: 'POST', headers: { accept: 'application/json' } }) - const versionBody = (await versionRes.json()) as { versions?: { name: string; latest: string | null; checked?: boolean }[] } + const versionBody = (await versionRes.json()) as { versions?: { name: string; latest: string | null; checked?: boolean; error?: string | null }[] } const map: Record = {} const checkedMap: Record = {} + const errorsMap: Record = {} for (const row of versionBody.versions ?? []) { map[row.name] = row.latest checkedMap[row.name] = row.checked === true + errorsMap[row.name] = row.error ?? null } setVersions(map) setVersionChecked(checkedMap) + setVersionErrors(errorsMap) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) } finally { @@ -311,7 +320,7 @@ export function ConsolePanel(): React.ReactNode { )}
{shown.map(plugin => { - const version = versionText(plugin, versions[plugin.name] ?? null, versionChecked[plugin.name] === true) + const version = versionText(plugin, versions[plugin.name] ?? null, versionChecked[plugin.name] === true, versionErrors[plugin.name] ?? null) const isUserRow = !isOfficial(plugin) && !isSelf(plugin) return (
@@ -336,7 +345,7 @@ export function ConsolePanel(): React.ReactNode { {statePill(plugin)}
- {version.text} + {version.text}
) })} diff --git a/packages/plugin/console/src/client/index.ts b/packages/plugin/console/src/client/index.ts index 50cba92..dff39ab 100644 --- a/packages/plugin/console/src/client/index.ts +++ b/packages/plugin/console/src/client/index.ts @@ -1,8 +1,12 @@ /** - * 薄控制台 browser half:设置页「插件」面板(0811 适配)。列出 - * insert 插件(profile patch insert 行,实时挂载/卸载)+ 已加载插件 + * 薄控制台 browser half:官方「插件」设置页内的「插件控制台」子 tab。 + * 列出 insert 插件(profile patch insert 行,实时挂载/卸载)+ 已加载插件 * (启停持久化)+ bundle 安装。fetch 自建路由 `/api/plugin-console`, * 零官方改动。 + * + * 挂载点:官方 settings-plugins 节(id=plugins)声明了 `settings.plugins.tab` + * 子 tab 插槽——第三方管理面板应挂在这里,而不是再注册一个顶层 + * 「插件」节(那会让设置页导航出现多个同名「插件」tab)。 */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { ConsolePanel } from './Panel.tsx' @@ -10,7 +14,7 @@ import { ConsolePanel } from './Panel.tsx' /** Cordis 插件名。 */ export const name = 'plugin-console-client' -/** 需要 slots(settings.section 插槽)。 */ +/** 需要 slots(settings.plugins.tab 子 tab 插槽)。 */ export const inject = ['slots'] /** @@ -38,17 +42,17 @@ function patchPluginTabIcon(): void { } } -/** 注册设置页「插件」面板 + 替换 tab 图标(设置页随时打开/关闭,全程监听)。 */ +/** 注册官方「插件」页内的「插件控制台」子 tab + 替换顶层 tab 图标(设置页随时打开/关闭,全程监听)。 */ export function apply(ctx: ClientContext): void { const observer = new MutationObserver(patchPluginTabIcon) observer.observe(document.body, { childList: true, subtree: true }) patchPluginTabIcon() - ctx.slots.inject('settings.section', () => + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ - name: 'settings.section', + name: 'settings.plugins.tab', id: 'plugin-console', - order: 60, - label: () => '插件', + order: 10, + label: () => '插件控制台', inject: () => ({}), }, ConsolePanel)) } diff --git a/packages/plugin/console/src/index.ts b/packages/plugin/console/src/index.ts index e39f287..d6c283d 100644 --- a/packages/plugin/console/src/index.ts +++ b/packages/plugin/console/src/index.ts @@ -13,6 +13,7 @@ import { execFile, spawnSync } from 'node:child_process' import { join } from 'node:path' import type { Context } from 'cordis' import { createPluginTools } from './discovery/tools.ts' +import { npmViewLatest } from './versions.ts' /** 解析 resolveDshHome(官方 dsh-paths)。 */ function resolveDshHome(): string { @@ -521,25 +522,34 @@ async function collectLoaderEntries(ctx: ConsoleCtx): Promise /* ---------------- 版本检查 ---------------- */ -/** 版本检查缓存:name -> { latest, checkedAt }(进程内存)。 */ -const versionCache = new Map() +/** 版本检查缓存:name -> { latest, error, checkedAt }(进程内存)。 */ +const versionCache = new Map() const VERSION_REFRESH_MIN_MS = 30 * 1000 let lastVersionRefreshAt = 0 -/** npm view version(registry 最新版);失败/非 registry 包返回 null。 */ -function npmViewLatest(name: string): string | null { - let latest: string | null = null - try { - const result = spawnSync('npm', ['view', name, 'version'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] }) - const text = (result.stdout ?? '').trim() - if (result.status === 0 && /^\d+(\.\d+)+/.test(text)) { - latest = text.split('\n')[0]!.trim() +/** 批量强制刷新版本缓存(可选 force):registry 查询走原生 fetch, + * 不 spawn 子进程(受限宿主环境管道捕获会被拦);404 = 非 registry 包。 */ +async function refreshVersions(ctx: ConsoleCtx, force: boolean): Promise { + const now = Date.now() + if (!force && now - lastVersionRefreshAt < VERSION_REFRESH_MIN_MS) return false + lastVersionRefreshAt = now + const names = await userPluginNames(ctx) + await Promise.all(names.map(async (name) => { + const result = await npmViewLatest(name) + if (result.error !== null) { + ctx.logger.warn(`[plugin-console] version check failed for ${name}: ${result.error}`) } - } catch { - // 保持 null(无法查询 = 非 registry 包或网络问题)。 - } - versionCache.set(name, { latest, checkedAt: Date.now() }) - return latest + versionCache.set(name, { latest: result.latest, error: result.error, checkedAt: Date.now() }) + })) + return true +} + +/** 版本行(缓存内容;error 区分「本地包」与「检查失败」)。 */ +function versionRows(names: string[]): Array<{ name: string; latest: string | null; checked: boolean; error: string | null }> { + return names.map(name => { + const cached = versionCache.get(name) + return { name, latest: cached?.latest ?? null, checked: cached !== undefined, error: cached?.error ?? null } + }) } /** 用户插件名列表(排除官方命名空间)。 */ @@ -549,17 +559,6 @@ async function userPluginNames(ctx: ConsoleCtx): Promise { .filter(name => !name.startsWith('@deepseek-ai/') && !name.startsWith('@cordisjs/') && !name.startsWith('cordis:')))] } -/** 批量强制刷新版本缓存(可选 force)。 */ -async function refreshVersions(ctx: ConsoleCtx, force: boolean): Promise { - const now = Date.now() - if (!force && now - lastVersionRefreshAt < VERSION_REFRESH_MIN_MS) return false - lastVersionRefreshAt = now - for (const name of await userPluginNames(ctx)) { - void npmViewLatest(name) - } - return true -} - /* ---------------- 路由与装配 ---------------- */ interface WebServerLike { @@ -674,21 +673,13 @@ export function apply(ctx: ConsoleCtx): void { // bundle 版本:只读缓存(零网络——registry 查询由启动延迟预扫描 + // 手动刷新触发,避免面板每次打开打 registry) if (method === 'GET' && (path === '/api/plugin-console/versions' || path === '/api/plugin-console/versions/')) { - const versions = (await userPluginNames(ctx)).map(name => { - const cached = versionCache.get(name) - return { name, latest: cached?.latest ?? null, checked: cached !== undefined } - }) - json(200, { ok: true, versions }) + json(200, { ok: true, versions: versionRows(await userPluginNames(ctx)) }) return } // 手动检查最新版本(POST /versions/refresh,30s 最小间隔防抖) if (method === 'POST' && (path === '/api/plugin-console/versions/refresh' || path === '/api/plugin-console/versions/refresh/')) { const did = await refreshVersions(ctx, false) - const versions = (await userPluginNames(ctx)).map(name => { - const cached = versionCache.get(name) - return { name, latest: cached?.latest ?? null, checked: cached !== undefined } - }) - json(200, { ok: true, refreshed: did, versions }) + json(200, { ok: true, refreshed: did, versions: versionRows(await userPluginNames(ctx)) }) return } // 统一安装入口(POST /install,body {source}):pnpm add → diff --git a/packages/plugin/console/src/versions.ts b/packages/plugin/console/src/versions.ts new file mode 100644 index 0000000..b1405f8 --- /dev/null +++ b/packages/plugin/console/src/versions.ts @@ -0,0 +1,47 @@ +/** + * 版本检查:原生 fetch 查询 npm registry JSON API(零子进程)。 + * + * 背景:旧实现 spawn `npm view version` 并靠管道读 stdout——在受限 + * 宿主环境(sandboxed host)里子进程管道捕获会被拦截(EPERM),查询必然 + * 失败;且失败被空 catch 折叠成 null、照常写缓存,UI 永远显示「已检查但 + * 无更新」,用户无法区分「已是最新」「本地包」「检查失败」。 + * + * 本实现不发任何子进程,任何环境可工作;404(非 registry 包:git/link + * 依赖)与网络/HTTP 错误分开表达,调用方可区分「本地包」与「检查失败」。 + */ +export interface VersionCheckResult { + /** registry 最新版;非 registry 包(404)为 null。 */ + latest: string | null + /** 检查失败原因(网络/超时/非 404 HTTP 错误);成功或 404 为 null。 */ + error: string | null +} + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org' + +/** npm registry 根(npm_config_registry 环境变量优先,兼容镜像源)。 */ +export function registryRoot(): string { + const configured = process.env.npm_config_registry + return (configured !== undefined && configured.trim() !== '' ? configured : DEFAULT_REGISTRY).replace(/\/+$/, '') +} + +/** scoped 包名(@scope/name)在 registry URL 路径中需把 / 编码为 %2f。 */ +export function registryPackagePath(name: string): string { + return name.startsWith('@') ? name.replace('/', '%2f') : name +} + +/** + * 查询某包在 registry 上的最新版本(GET //latest)。 + * 永不抛出:失败折叠为 { latest: null, error },由调用方记录/展示。 + */ +export async function npmViewLatest(name: string, fetchFn: typeof fetch = fetch): Promise { + const url = `${registryRoot()}/${registryPackagePath(name)}/latest` + try { + const res = await fetchFn(url, { signal: AbortSignal.timeout(15_000) }) + if (res.status === 404) return { latest: null, error: null } + if (!res.ok) return { latest: null, error: `registry ${res.status}` } + const data = await res.json() as { version?: string } + return { latest: data.version ?? null, error: null } + } catch (caught) { + return { latest: null, error: caught instanceof Error ? caught.message : String(caught) } + } +} diff --git a/packages/plugin/console/tests/discovery/enumerate.spec.ts b/packages/plugin/console/tests/discovery/enumerate.spec.ts index f484f76..002d954 100644 --- a/packages/plugin/console/tests/discovery/enumerate.spec.ts +++ b/packages/plugin/console/tests/discovery/enumerate.spec.ts @@ -30,8 +30,8 @@ function indexSource(home: string, file: string): PluginSource { describe('parseGithubUrl', () => { it('parses bare and .git URLs', () => { - assert.deepEqual(parseGithubUrl('https://github.com/vlln/whale-girl'), { owner: 'dsh-external', repo: 'whale-girl' }) - assert.deepEqual(parseGithubUrl('https://github.com/vlln/whale-girl'), { owner: 'dsh-external', repo: 'whale-girl' }) + assert.deepEqual(parseGithubUrl('https://github.com/vlln/whale-girl'), { owner: 'vlln', repo: 'whale-girl' }) + assert.deepEqual(parseGithubUrl('https://github.com/vlln/whale-girl.git'), { owner: 'vlln', repo: 'whale-girl' }) }) it('rejects non-github URLs', () => { assert.equal(parseGithubUrl('https://example.com/x'), null) diff --git a/packages/plugin/console/tests/versions.spec.ts b/packages/plugin/console/tests/versions.spec.ts new file mode 100644 index 0000000..9c966cf --- /dev/null +++ b/packages/plugin/console/tests/versions.spec.ts @@ -0,0 +1,74 @@ +/** + * 版本检查语义测试:原生 fetch 查询 registry—— + * 200 返回最新版 / 404 视为非 registry 包(null 无 error)/ + * 非 404 HTTP 错误与网络错误返回 error(UI 可显示「检查失败」)。 + */ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { npmViewLatest, registryPackagePath, registryRoot } from '../src/versions.ts' + +/** 假 fetch:返回固定状态码/体的 Response 形状,并记录调用 URL。 */ +function fakeFetch(status: number, body?: unknown): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = [] + const fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + const ok = status >= 200 && status < 300 + return { status, ok, json: async () => body } as unknown as Response + }) as typeof fetch + return { fetch, calls } +} + +describe('npmViewLatest', () => { + it('200 → latest 版本,无 error', async () => { + const { fetch, calls } = fakeFetch(200, { version: '0.1.1' }) + const result = await npmViewLatest('dsh-monitor', fetch) + assert.equal(result.latest, '0.1.1') + assert.equal(result.error, null) + assert.ok(calls[0]!.endsWith('/dsh-monitor/latest')) + }) + + it('scoped 包名把 / 编码为 %2f', async () => { + const { fetch, calls } = fakeFetch(200, { version: '1.2.3' }) + await npmViewLatest('@linxin666/dsh-skins', fetch) + assert.ok(calls[0]!.includes('/@linxin666%2fdsh-skins/latest')) + }) + + it('404 → null 无 error(非 registry 包:git/link 依赖)', async () => { + const { fetch } = fakeFetch(404) + const result = await npmViewLatest('some-git-dep', fetch) + assert.deepEqual(result, { latest: null, error: null }) + }) + + it('非 404 HTTP 错误 → error', async () => { + const { fetch } = fakeFetch(503) + const result = await npmViewLatest('dsh-monitor', fetch) + assert.equal(result.latest, null) + assert.equal(result.error, 'registry 503') + }) + + it('网络错误 → error 且不抛出', async () => { + const broken = (async () => { throw new Error('fetch failed') }) as typeof fetch + const result = await npmViewLatest('dsh-monitor', broken) + assert.equal(result.latest, null) + assert.match(result.error ?? '', /fetch failed/) + }) + + it('registry 走 npm_config_registry 镜像源', async (t) => { + const old = process.env.npm_config_registry + process.env.npm_config_registry = 'https://registry.npmmirror.com/' + t.after(() => { + if (old === undefined) delete process.env.npm_config_registry + else process.env.npm_config_registry = old + }) + assert.equal(registryRoot(), 'https://registry.npmmirror.com') + const { fetch, calls } = fakeFetch(200, { version: '0.1.1' }) + await npmViewLatest('dsh-monitor', fetch) + assert.ok(calls[0]!.startsWith('https://registry.npmmirror.com/dsh-monitor/latest')) + }) + + it('registryPackagePath 编码', () => { + assert.equal(registryPackagePath('dsh-monitor'), 'dsh-monitor') + assert.equal(registryPackagePath('@a/b'), '@a%2fb') + }) +})