2026-09-14 04:38:58 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* verify-dsh-install.mjs —— 「平台内置 dsh 安装位置解析」回归(2026-09-14 · 修 P1)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须有:定位失败**不报错** —— 厂家目录读成空 ⇒ 前端「新增模型条目」只剩内置
|
|
|
|
|
|
* DeepSeek;平台包计数为 0 ⇒ 兼容性预检退化成「平台包目录不可读」。两者都只在**另一种
|
|
|
|
|
|
* 安装布局**(`npm root -g` = `/usr/lib/node_modules`)上出现,本机 `/usr/local` 布局
|
|
|
|
|
|
* **永远测不出来** ⇒ 只有把解析规则钉死成断言,才能防回归。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用法:node scripts/verify-dsh-install.mjs 退出码 0=全绿 / 1=有失败
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
|
|
|
import { dirname, join } from 'node:path'
|
|
|
|
|
|
|
|
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
|
|
const M = await import(pathToFileURL(join(ROOT, 'lib/web/dsh-install.js')).href)
|
|
|
|
|
|
const { dshPackageRoot, dshScopeDir, piAiDataDir, resetInstallPathsCache } = M
|
|
|
|
|
|
|
|
|
|
|
|
let failed = 0
|
|
|
|
|
|
const ok = (name, cond, extra = '') => {
|
|
|
|
|
|
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
|
|
|
|
|
if (!cond) failed++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清掉所有相关 env,保证用例互不干扰。 */
|
|
|
|
|
|
const ENVS = [
|
|
|
|
|
|
'DSH_PACKAGE_DIR',
|
|
|
|
|
|
'DSHS_PACKAGE_DIR',
|
|
|
|
|
|
'DSH_COMPAT_ROOT',
|
|
|
|
|
|
'DSHS_COMPAT_ROOT',
|
|
|
|
|
|
'DSHS_DSH_BIN',
|
|
|
|
|
|
'DSH_USERS_PLATFORM_DSH_BIN',
|
|
|
|
|
|
'PI_AI_DATA_DIR',
|
|
|
|
|
|
'DSHS_PI_AI_DATA_DIR',
|
|
|
|
|
|
]
|
|
|
|
|
|
const clearEnv = () => {
|
|
|
|
|
|
for (const k of ENVS) delete process.env[k]
|
|
|
|
|
|
resetInstallPathsCache()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('verify-dsh-install(P1:安装位置解析)\n')
|
|
|
|
|
|
|
|
|
|
|
|
// ── 1. env 覆盖(无条件优先,含本仓前缀别名与导出侧旧名)────────────────────────
|
|
|
|
|
|
console.log('[1] env 显式覆盖')
|
|
|
|
|
|
for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) {
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env[k] = '/tmp/fake-root-' + k
|
|
|
|
|
|
ok(`${k} 生效`, dshPackageRoot() === '/tmp/fake-root-' + k, dshPackageRoot())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 2. 由「配置的 dsh 可执行文件」向上解出包根(**最可靠的一路**)────────────────
|
|
|
|
|
|
console.log('\n[2] 由 dsh 可执行文件解出包根(模拟 /usr/lib 布局)')
|
|
|
|
|
|
const T = mkdtempSync(join(tmpdir(), 'dsh-install-'))
|
|
|
|
|
|
const pkgRoot = join(T, 'node_modules', '@deepseek-ai', 'dsh')
|
|
|
|
|
|
mkdirSync(join(pkgRoot, 'lib'), { recursive: true })
|
|
|
|
|
|
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.0.0-test' }))
|
|
|
|
|
|
const binPath = join(pkgRoot, 'lib', 'bin.js')
|
|
|
|
|
|
writeFileSync(binPath, '// stub\n')
|
|
|
|
|
|
|
|
|
|
|
|
for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) {
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env[k] = binPath
|
|
|
|
|
|
ok(`${k} → 解出包根`, dshPackageRoot() === pkgRoot, dshPackageRoot().replace(T, '<T>'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 反例:bin 指向一个**不是 dsh 包**的目录 ⇒ 不许误判
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
const otherRoot = join(T, 'node_modules', '@other', 'thing')
|
|
|
|
|
|
mkdirSync(join(otherRoot, 'lib'), { recursive: true })
|
|
|
|
|
|
writeFileSync(join(otherRoot, 'package.json'), JSON.stringify({ name: '@other/thing' }))
|
|
|
|
|
|
const otherBin = join(otherRoot, 'lib', 'bin.js')
|
|
|
|
|
|
writeFileSync(otherBin, '// stub\n')
|
|
|
|
|
|
process.env.DSHS_DSH_BIN = otherBin
|
|
|
|
|
|
ok('包名不匹配时不误认(回落到默认值)', !dshPackageRoot().startsWith(otherRoot), dshPackageRoot().replace(T, '<T>'))
|
|
|
|
|
|
|
|
|
|
|
|
// ── 3. 派生量 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
console.log('\n[3] 派生:scope 目录 / pi-ai 数据目录')
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env.DSH_PACKAGE_DIR = '/opt/whatever/dsh'
|
|
|
|
|
|
ok(
|
|
|
|
|
|
'dshScopeDir = <root>/node_modules/@deepseek-ai',
|
|
|
|
|
|
dshScopeDir() === join('/opt/whatever/dsh', 'node_modules', '@deepseek-ai'),
|
|
|
|
|
|
dshScopeDir(),
|
|
|
|
|
|
)
|
|
|
|
|
|
ok(
|
|
|
|
|
|
'piAiDataDir 落在 <root>/node_modules/@earendil-works/pi-ai/dist/providers/data',
|
|
|
|
|
|
piAiDataDir() === join('/opt/whatever/dsh', 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data'),
|
|
|
|
|
|
piAiDataDir(),
|
|
|
|
|
|
)
|
|
|
|
|
|
for (const k of ['PI_AI_DATA_DIR', 'DSHS_PI_AI_DATA_DIR']) {
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env.DSH_PACKAGE_DIR = '/opt/whatever/dsh'
|
|
|
|
|
|
process.env[k] = '/tmp/data-' + k
|
|
|
|
|
|
ok(`${k} 覆盖 piAiDataDir`, piAiDataDir() === '/tmp/data-' + k, piAiDataDir())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 4. 缓存语义(env 改了不 reset 就用旧值;reset 后重新解析)────────────────────
|
|
|
|
|
|
console.log('\n[4] 缓存语义')
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env.DSH_PACKAGE_DIR = '/first'
|
|
|
|
|
|
const first = dshPackageRoot()
|
|
|
|
|
|
process.env.DSH_PACKAGE_DIR = '/second'
|
|
|
|
|
|
ok('缓存生效(不 reset 仍是旧值)', dshPackageRoot() === first && first === '/first', dshPackageRoot())
|
|
|
|
|
|
resetInstallPathsCache()
|
|
|
|
|
|
ok('reset 后重新解析', dshPackageRoot() === '/second', dshPackageRoot())
|
|
|
|
|
|
|
|
|
|
|
|
// ── 5. 本机实况:必须返回可用的绝对路径(不抛错)───────────────────────────────
|
|
|
|
|
|
console.log('\n[5] 本机实况')
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
const real = dshPackageRoot()
|
|
|
|
|
|
ok('返回非空绝对路径', typeof real === 'string' && real.length > 0 && real.startsWith('/'), real)
|
|
|
|
|
|
ok('dshScopeDir 形状正确', dshScopeDir().endsWith(join('node_modules', '@deepseek-ai')), dshScopeDir())
|
|
|
|
|
|
|
2026-09-14 04:52:35 +08:00
|
|
|
|
// ── 6. 目录可读性诊断:**「读不到」必须与「目录为空」可区分**(2026-09-14 留痕)─────
|
|
|
|
|
|
console.log('\n[6] 目录可读性诊断(静默降级 → 可观测)')
|
|
|
|
|
|
const cat = await import(pathToFileURL(join(ROOT, 'lib/web/model-catalog.js')).href)
|
|
|
|
|
|
const warnings = []
|
|
|
|
|
|
const realWarn = console.warn
|
|
|
|
|
|
console.warn = (...a) => warnings.push(a.join(' '))
|
|
|
|
|
|
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
process.env.PI_AI_DATA_DIR = join(T, 'no-such-data-dir') // 不存在 ⇒ 读不到
|
|
|
|
|
|
process.env.DSH_PACKAGE_DIR = '/nonexistent'
|
|
|
|
|
|
await cat.listCatalogProviders(true)
|
|
|
|
|
|
const d1 = cat.catalogDiagnostics()
|
|
|
|
|
|
ok('读不到时 readable=false', d1 !== null && d1.readable === false, JSON.stringify(d1))
|
|
|
|
|
|
ok('读不到时 count=0', d1 !== null && d1.count === 0)
|
|
|
|
|
|
ok('读不到时**告警过一次**(不再静默)', warnings.some((w) => w.includes('厂家目录不可读')), warnings[0]?.slice(0, 60) ?? '(无)')
|
|
|
|
|
|
|
|
|
|
|
|
clearEnv()
|
|
|
|
|
|
const emptyDir = join(T, 'empty-data')
|
|
|
|
|
|
mkdirSync(emptyDir, { recursive: true })
|
|
|
|
|
|
process.env.PI_AI_DATA_DIR = emptyDir // 存在但没有任何 <id>.json ⇒ "目录为空"
|
|
|
|
|
|
await cat.listCatalogProviders(true)
|
|
|
|
|
|
const d2 = cat.catalogDiagnostics()
|
|
|
|
|
|
ok('目录可读但为空时 readable=true(与"读不到"可区分)', d2 !== null && d2.readable === true && d2.count === 0, JSON.stringify(d2))
|
|
|
|
|
|
ok('已告警过就不再刷日志', warnings.filter((w) => w.includes('厂家目录不可读')).length === 1, String(warnings.length))
|
|
|
|
|
|
console.warn = realWarn
|
|
|
|
|
|
|
2026-09-14 04:38:58 +08:00
|
|
|
|
rmSync(T, { recursive: true, force: true })
|
|
|
|
|
|
console.log('')
|
|
|
|
|
|
if (failed > 0) {
|
|
|
|
|
|
console.log(`✗ verify-dsh-install 失败 ${failed} 项`)
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('✓ verify-dsh-install 全绿')
|