档案 91 ——「模型设置」页折行 + 复刻官方交互(插件 0.3.15) · 折行根因不是"窄":原「我已添加的厂家」是 4 列表格(列宽 auto)⇒ 名称/状态/两按钮三处折行同源; 改官方 dsh-client-ui-settings-models 的**卡片行**(rowHead + rowActions margin-left:auto,两侧 nowrap) · 「新增」改官方**两步式**:默认两个虚线按钮 → 点开才出卡片,主字段只剩「API 密钥」 (官方原文:页面从不询问环境变量名);次要字段收进 <details>「自定义设置」 · CSS 数值逐值照抄官方 CSS 模块;顺修「由 admin 配置」「密钥可留空」两处错文案,补删除二次确认 档案 92 —— 官方推荐插件列表按 dsh 版本过滤(插件 0.3.16) · 官方目录不带 dsh 版本字段 ⇒ 逐包查 npm 声明与平台真实版本比,分类 match/none/newer/older/unknown · 默认隐藏「仅兼容更旧版本」,「需更高版本」橙色标注;勾选框为逃生口;说明行给隐藏计数(不静默消失) · ⚠️ 判 satisfies 必须带 includePrerelease(平台是 prerelease,默认语义会把在跑的 dshmarket 之类判成更旧) 档案 93 —— 兼容判定收口(服务器 CI 由红转绿) · 「单测长期红」真相 = 服务器源码落后于 git(本机 48/0 全绿)⇒ 同步即修,非代码问题 · 伞包 @deepseek-ai/dsh 收口到 dsh-install.platformPackageVersion()(它不在自己的 node_modules 里, 原先恒判 null ⇒ 声明在伞包上的"要求更高版本"全被放行);闸门与列表共用同一份版本表 · 闸门判据 A 加 prerelease 兜底:实测抽样 300 里 prerelease-artifact 117 条(≈39% 假阳性, 含在跑的 dshmarket)⇒ 容忍下能满足则只计数(prereleaseOnly)不阻断 校验 · 新增 scripts/verify-dsh-compat.mjs(21 条回归锚点)与 verify-models-dict.mjs / verify-models-render.cjs · verify-platform-admin-section.mjs:断言从旧 UI(表格/下拉「自定义厂家…」)改成断言新 UI (含模拟点击两个新增入口 + 选中目录厂家) · 两个仓库外的动作:服务器 npm run build + ci.sh(CI OK)+ systemctl restart dshs;插件 0.3.16 已铺发 ⚠️ 本提交未包含 src/supervisor/orchestrator.ts —— 那是别人在途的内存配额改动 (BASE 160→448 / MIN 384→512 / MAX 1024→1536 / mcn 128→256),其对应的客户端常量尚未同步, 本机 verify-mem-model 因此有 4 项红;提交后仓库树两边均为旧值,该断言在 HEAD 上一致。
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
||
* 插件兼容性预检 —— 判据实现在此(档案 71 / 交接单 T05)
|
||
*
|
||
* 背景:`@anysearch/anysearch-dsh` 与平台 dsh 0.1.2-rc.1 不兼容,装进实例后
|
||
* `plugin tree failed to load`(缺 `assertNever`)→ 实例崩溃循环(档案 70)。
|
||
* 现有链路只查「来源/内容安全」,不查「与平台版本兼不兼容」→ 本模块补上。
|
||
*
|
||
* 两条判据(均已在生产实测复现该 bug):
|
||
*
|
||
* A. 依赖范围(semver,**默认语义,不传 includePrerelease**)
|
||
* 插件声明的 `@deepseek-ai/*` 依赖范围必须**接受**平台内置的同名包版本。
|
||
* ⚠️ 为什么必须用默认语义:npm/pnpm 的实际安装判定就是默认语义 ——
|
||
* anysearch 写 `>=0.1.1-rc.1 <0.1.2`,默认语义下 **不满足** 0.1.2-rc.1,
|
||
* 于是 pnpm 给它装了自带的 `[email protected]` → 与平台 dsh-llm 冲突。
|
||
* 若传 includePrerelease 会把它误判为「满足」而漏掉这个 bug(实测已确认)。
|
||
*
|
||
* B. 导出符号(运行时真值)
|
||
* 插件 bundle 里 `import { X } from '@deepseek-ai/Y'`,`X` 必须在平台包 Y 的
|
||
* **运行时真实导出**里存在。取法 = `await import()` 后取 `Object.keys()`,
|
||
* 比解析 `.d.ts` 可靠(实测平台 223 个包中 220 个可取到导出清单)。
|
||
*
|
||
* 覆盖边界(重要):判据 B 只看**被扫描目录内**的 import。若插件把平台 API 的调用
|
||
* 藏在**传递依赖**里(anysearch 就是:越界的是它的依赖 dsh-tool-web,不在 tgz 内),
|
||
* 本模块扫不出来 —— 那种情况由「启用前扫 profile node_modules」那一层负责(T05 §五.4)。
|
||
*
|
||
* 设计原则:**任何异常都不得让上传流程失败** —— 判不出来就返回 `unknown`(放行 + 标记待复核),
|
||
* 绝不误伤正常插件。
|
||
*/
|
||
|
||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||
import { join, extname } from 'node:path'
|
||
import { pathToFileURL } from 'node:url'
|
||
// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`),
|
||
// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。
|
||
// 统一走 `dsh-install.ts` 的按序探测。
|
||
import { dshScopeDir, platformPackageVersion } from './dsh-install.js'
|
||
|
||
const DS_SCOPE = '@deepseek-ai/'
|
||
|
||
export interface CompatFinding {
|
||
/** dep-range = 依赖范围不接受平台版本;missing-export = 平台包缺该导出 */
|
||
kind: 'dep-range' | 'missing-export'
|
||
pkg: string
|
||
detail: string
|
||
file?: string
|
||
}
|
||
|
||
export interface CompatResult {
|
||
/** incompatible = 已判定不兼容(调用方默认拒绝,admin 可显式信任);unknown = 静态判不了(放行 + 标记) */
|
||
level: 'ok' | 'incompatible' | 'unknown'
|
||
findings: CompatFinding[]
|
||
/** 平台内置包数量(快照,供回显) */
|
||
platformPkgs: number
|
||
/** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */
|
||
sawPlatformRef: boolean
|
||
/**
|
||
* 有几条声明「按 semver 默认语义不满足、按 prerelease 容忍满足」——**平台是 prerelease**
|
||
* 的语义产物,**不计入 `findings`、不阻断**,只留个可观测计数(2026-09-14,档案 92)。
|
||
*/
|
||
prereleaseOnly?: number
|
||
/** 判定过程出错时的说明(此时 level=unknown) */
|
||
note?: string
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const exportCache = new Map<string, Set<string> | null>()
|
||
|
||
function shortName(pkg: string): string {
|
||
return pkg.startsWith(DS_SCOPE) ? pkg.slice(DS_SCOPE.length) : pkg
|
||
}
|
||
|
||
function platformPkgDir(pkg: string): string {
|
||
return join(dshScopeDir(), shortName(pkg))
|
||
}
|
||
|
||
/** 是否已经就「平台包目录不可读」告过一次警(避免刷日志)。 */
|
||
let warnedScopeUnreadable = false
|
||
|
||
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
|
||
export function platformPkgCount(): number {
|
||
try {
|
||
return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length
|
||
} catch {
|
||
// ⚠️ 这里**不能只返回 0**:调用方会把它当成"没有平台包"⇒ 预检直接退化成
|
||
// 「平台包目录不可读」——**安全网静默失效**(上传不兼容插件不再被拦)。
|
||
// 2026-09-14:加一次告警,让"读不到"这件事可观测(见 `04-调整方案/88`)。
|
||
if (!warnedScopeUnreadable) {
|
||
warnedScopeUnreadable = true
|
||
console.warn(
|
||
`[plugin-compat] 平台包目录不可读:${dshScopeDir()} —— 插件兼容性预检将退化为「不检查」。` +
|
||
' 检查 DSHS_DSH_BIN / DSH_COMPAT_ROOT 是否正确(见 src/web/dsh-install.ts)。',
|
||
)
|
||
}
|
||
return 0
|
||
}
|
||
}
|
||
|
||
/** 平台内置某包的版本;平台没有该包 → `null`。
|
||
*
|
||
* 2026-09-14(档案 92):改为走 `dsh-install.platformPackageVersion()` —— 原实现只查
|
||
* `<scope>/<短名>/package.json`,而**伞包 `@deepseek-ai/dsh` 不在自己的 `node_modules` 里**
|
||
* ⇒ 对它恒判 `null`,被当作「平台没这个包,不算不兼容」而放行(实测那 3 条"要求更高版本"的
|
||
* 插件全部只写在伞包上)。收口后该判据**含伞包**,与 `plugin-dsh-compat` 共用同一份版本表。
|
||
*/
|
||
function platformVersion(pkg: string): string | null {
|
||
return platformPackageVersion(pkg)
|
||
}
|
||
|
||
function resolveEntryFile(pkg: string): string | null {
|
||
const base = platformPkgDir(pkg)
|
||
const cands: string[] = []
|
||
try {
|
||
const meta = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')) as {
|
||
main?: unknown
|
||
module?: unknown
|
||
exports?: unknown
|
||
}
|
||
if (typeof meta.module === 'string') cands.push(meta.module)
|
||
if (typeof meta.main === 'string') cands.push(meta.main)
|
||
} catch {
|
||
/* 忽略:退回默认候选 */
|
||
}
|
||
cands.push('lib/index.js', 'dist/index.js', 'esm/index.js', 'index.js', 'lib/index.mjs')
|
||
for (const c of cands) {
|
||
const f = join(base, c)
|
||
try {
|
||
if (existsSync(f) && statSync(f).isFile()) return f
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 平台某包的运行时导出符号集合。返回 null = 无法判定(**不能**据此断言不兼容)。
|
||
* 只对「插件真的 import 了的包」调用,避免启动成本。
|
||
*/
|
||
async function platformExports(pkg: string): Promise<Set<string> | null> {
|
||
const hit = exportCache.get(pkg)
|
||
if (hit !== undefined) return hit
|
||
let keys: Set<string> | null = null
|
||
try {
|
||
const entry = resolveEntryFile(pkg)
|
||
if (entry !== null) {
|
||
const mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown>
|
||
keys = new Set(Object.keys(mod))
|
||
}
|
||
} catch {
|
||
keys = null // import 失败可能是环境原因,不能当作「插件不兼容」的证据
|
||
}
|
||
exportCache.set(pkg, keys)
|
||
return keys
|
||
}
|
||
|
||
type SemverLike = { satisfies(v: string, r: string, o?: { includePrerelease?: boolean }): boolean }
|
||
let semverPromise: Promise<SemverLike | null> | null = null
|
||
|
||
function loadSemver(): Promise<SemverLike | null> {
|
||
semverPromise ??= (async () => {
|
||
try {
|
||
const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike
|
||
return (m.default ?? m) as SemverLike
|
||
} catch {
|
||
return null
|
||
}
|
||
})()
|
||
return semverPromise
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 插件侧:依赖声明 + bundle 内的 @deepseek-ai import
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const MAX_SCAN_FILES = 4000
|
||
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs'])
|
||
const IMPORT_RE =
|
||
/(?:import|export)\s*(?:\*\s*as\s*[\w$]+|\{([^}]*)\})\s*from\s*["'](@deepseek-ai\/[^"']+)["']/g
|
||
|
||
interface ImportHit {
|
||
pkg: string
|
||
named: string[]
|
||
file: string
|
||
}
|
||
|
||
function findPackageJson(root: string): string | null {
|
||
const direct = join(root, 'package.json')
|
||
if (existsSync(direct)) return direct
|
||
try {
|
||
for (const e of readdirSync(root, { withFileTypes: true })) {
|
||
if (e.isDirectory()) {
|
||
const nested = findPackageJson(join(root, e.name))
|
||
if (nested !== null) return nested
|
||
}
|
||
}
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
return null
|
||
}
|
||
|
||
function collectJsFiles(dir: string, out: string[] = [], depth = 0): string[] {
|
||
if (out.length >= MAX_SCAN_FILES || depth > 8) return out
|
||
let entries: import('node:fs').Dirent[]
|
||
try {
|
||
entries = readdirSync(dir, { withFileTypes: true })
|
||
} catch {
|
||
return out
|
||
}
|
||
for (const e of entries) {
|
||
if (out.length >= MAX_SCAN_FILES) break
|
||
if (e.isDirectory()) {
|
||
if (e.name === 'node_modules' || e.name === '.git') continue
|
||
collectJsFiles(join(dir, e.name), out, depth + 1)
|
||
} else if (SCAN_EXT.has(extname(e.name))) {
|
||
out.push(join(dir, e.name))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
function scanImports(root: string): { hits: ImportHit[]; fileCount: number } {
|
||
const files = collectJsFiles(root)
|
||
const hits: ImportHit[] = []
|
||
for (const f of files) {
|
||
let text: string
|
||
try {
|
||
text = readFileSync(f, 'utf8')
|
||
} catch {
|
||
continue
|
||
}
|
||
if (!text.includes(DS_SCOPE)) continue
|
||
IMPORT_RE.lastIndex = 0
|
||
let m: RegExpExecArray | null
|
||
while ((m = IMPORT_RE.exec(text)) !== null) {
|
||
const spec = m[1] ?? ''
|
||
const named = spec
|
||
.split(',')
|
||
.map((s) => s.trim().split(/\s+as\s+/)[0]?.trim() ?? '')
|
||
.filter((s) => s !== '' && !s.startsWith('type '))
|
||
hits.push({ pkg: m[2], named, file: f.slice(root.length + 1) })
|
||
}
|
||
}
|
||
return { hits, fileCount: files.length }
|
||
}
|
||
|
||
function declaredDeepseekDeps(pkgPath: string): Array<[string, string]> {
|
||
let meta: {
|
||
dependencies?: Record<string, string>
|
||
peerDependencies?: Record<string, string>
|
||
optionalDependencies?: Record<string, string>
|
||
}
|
||
try {
|
||
meta = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof meta
|
||
} catch {
|
||
return []
|
||
}
|
||
const merged = { ...meta.peerDependencies, ...meta.optionalDependencies, ...meta.dependencies }
|
||
return Object.entries(merged).filter(([k]) => k.startsWith(DS_SCOPE))
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 主入口
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* 对一个**已解包**的插件目录做兼容性预检(只读)。
|
||
* @param root 解包后的插件根目录(含 package.json 的那一层或其上层)
|
||
*/
|
||
export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
||
const out: CompatResult = {
|
||
level: 'unknown',
|
||
findings: [],
|
||
platformPkgs: platformPkgCount(),
|
||
sawPlatformRef: false,
|
||
}
|
||
try {
|
||
if (out.platformPkgs === 0) {
|
||
out.note = `平台包目录不可读:${dshScopeDir()}`
|
||
return out
|
||
}
|
||
const pkgPath = findPackageJson(root)
|
||
if (pkgPath === null) {
|
||
out.note = '未找到 package.json'
|
||
return out
|
||
}
|
||
|
||
// ---- 判据 A:依赖范围 ----
|
||
const deps = declaredDeepseekDeps(pkgPath)
|
||
if (deps.length > 0) out.sawPlatformRef = true
|
||
const semver = deps.length > 0 ? await loadSemver() : null
|
||
for (const [pkg, range] of deps) {
|
||
const v = platformVersion(pkg)
|
||
if (v === null) continue // 平台没这个包:不算不兼容(插件自带)
|
||
if (semver === null) {
|
||
out.note = 'semver 不可用,依赖范围未判定'
|
||
continue
|
||
}
|
||
let ok: boolean
|
||
let prereleaseOnly = false
|
||
try {
|
||
// ① 先按**默认语义**判(与 pnpm 的实际安装判定一致)
|
||
ok = semver.satisfies(v, range)
|
||
if (!ok) {
|
||
// ② 不满足时再看是不是**prerelease 语义产物**。
|
||
// 平台版本是 prerelease(`0.1.5-rc.1`、子包 `0.1.5-rc.2`),而 semver 默认语义下
|
||
// prerelease **不满足** `*`、`^0.1.2` 这类不含 prerelease 的范围 —— 于是
|
||
// 「声明 `*`(= 任意版本)」会被判成不兼容。
|
||
// 实测 2026-09-14(抽样 300,见 `04-调整方案/92`):
|
||
// real-newer 3 | narrow-pin 22 | **prerelease-artifact 117** | match 98
|
||
// ⇒ 若把这 117 也当"不兼容",闸门会有 ~39% 假阳性(`dshmarket`、`dsh-context`
|
||
// 这些**在平台上跑得好好的**都会被拦)。所以:**只有 prerelease 容忍下也不满足,
|
||
// 才算真不兼容**;容忍下能满足的只记一条 note(可观测,不阻断)。
|
||
if (semver.satisfies(v, range, { includePrerelease: true })) {
|
||
ok = true
|
||
prereleaseOnly = true
|
||
}
|
||
}
|
||
} catch {
|
||
continue // 范围写法无法解析:不据此断言
|
||
}
|
||
if (!ok) {
|
||
out.findings.push({
|
||
kind: 'dep-range',
|
||
pkg,
|
||
detail: `平台为 ${v},插件要求 ${range}(不满足)`,
|
||
})
|
||
} else if (prereleaseOnly) {
|
||
// 保持可观测:这一条是"按 pnpm 语义不满足、按 prerelease 容忍满足"的平台效应
|
||
out.prereleaseOnly = (out.prereleaseOnly ?? 0) + 1
|
||
}
|
||
}
|
||
|
||
// ---- 判据 B:导出符号 ----
|
||
const { hits, fileCount } = scanImports(root)
|
||
if (hits.length > 0) out.sawPlatformRef = true
|
||
for (const h of hits) {
|
||
if (h.named.length === 0) continue
|
||
if (platformVersion(h.pkg) === null) continue // 非平台内置包:跳过
|
||
const exports = await platformExports(h.pkg)
|
||
if (exports === null) continue // 拿不到导出清单 → 不判定
|
||
const missing = h.named.filter((n) => !exports.has(n))
|
||
if (missing.length > 0) {
|
||
out.findings.push({
|
||
kind: 'missing-export',
|
||
pkg: h.pkg,
|
||
detail: `平台包未导出:${missing.join(', ')}`,
|
||
file: h.file,
|
||
})
|
||
}
|
||
}
|
||
|
||
out.level = out.findings.length > 0 ? 'incompatible' : out.sawPlatformRef ? 'ok' : 'unknown'
|
||
if (out.level === 'unknown' && out.note === undefined) {
|
||
out.note = `插件未声明也未直接 import 任何 @deepseek-ai 包(扫描 ${fileCount} 个 js 文件)→ 需装后扫 node_modules 复核`
|
||
}
|
||
return out
|
||
} catch (err) {
|
||
// 任何意外都不影响上传流程:降级为 unknown(放行 + 标记)
|
||
out.level = 'unknown'
|
||
out.note = `预检异常(已降级为待复核):${err instanceof Error ? err.message : String(err)}`
|
||
return out
|
||
}
|
||
}
|