fix(web): 内置 dsh 安装路径改为按序探测 —— 修厂家目录/平台包目录在 /usr/lib 布局下静默失效
根因:npm root -g 的落点随发行版变(Debian 系 /usr/local/lib;发行版包管理器装的 Node 常见 /usr/lib),而三处把它写死 ⇒ 定位失败**不报错**、只静默降级:
- src/web/model-catalog.ts:30 → 厂家目录读成空 ⇒ GET /api/me/model-providers 返回 {"providers":[]}
- src/web/plugin-compat.ts:36 → platformPkgCount() = 0 ⇒ 预检退化成「平台包目录不可读」
- poc/workspace-scoped-picker/lib/index.js:34-35(**交接单漏掉的第 3 处**)→ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用
改动:
- 新增 src/web/dsh-install.ts(全平台唯一入口;只依赖 node 内建,可单独在目标机验证):env → 平台配置的 dsh 可执行文件解软链反推包根(最可靠)→ 常见全局根 → npm root -g → 历史默认值
⚠️ env 名两侧都认:源仓 DSHS_DSH_BIN / 导出侧 DSH_USERS_PLATFORM_DSH_BIN(交接件只用了后者,照抄会让该分支在源仓侧永不生效)
- 三处调用点改走该模块;插件侧跑在实例进程内、拿不到平台代码,自带同思路一份(两处需同步改)
- 新增 scripts/verify-dsh-install.mjs(15 项断言)并接入 npm run verify
验证:本机 tsc 零错误|verify 全套绿|单测 16/0;服务器回归 platformPkgCount=223、厂家目录 39;**测试服 test106(/usr/lib 布局 = 原故障机)在「无 env / DSHS_DSH_BIN / DSH_USERS_PLATFORM_DSH_BIN」三种姿势下均解析到 /usr/lib 且 39 个厂家文件**
This commit is contained in:
1 parent
022b1f770c
commit
cbaf3ad653
7 files changed
+401
-23
No files matched your search
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
"prepare": "npm run build",
|
"prepare": "npm run build",
|
||||||
"dev": "node lib/cli.js",
|
"dev": "node lib/cli.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs",
|
"verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs",
|
||||||
"test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js",
|
"test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js",
|
||||||
"smoke": "node scripts/smoke.mjs",
|
"smoke": "node scripts/smoke.mjs",
|
||||||
"smoke:admin": "node scripts/smoke-admin.mjs",
|
"smoke:admin": "node scripts/smoke-admin.mjs",
|
||||||
|
|||||||
@@ -33,8 +33,17 @@ HOME=<userRoot>/ws pnpm add file:<userRoot>/ws/workspace-scoped-picker-0.1.0.tgz
|
|||||||
## 依赖解析
|
## 依赖解析
|
||||||
|
|
||||||
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
|
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
|
||||||
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`,
|
路径**按序探测**而成 —— env(`DSH_SEAM_DIRECTORY_PICKER` 显式指定,或 `DSH_PACKAGE_DIR` /
|
||||||
可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。
|
`DSHS_PACKAGE_DIR` / `DSH_COMPAT_ROOT` / `DSHS_COMPAT_ROOT` 给包根)→ 由平台配置的 dsh
|
||||||
|
可执行文件解软链反推包根(`DSHS_DSH_BIN` / `DSH_USERS_PLATFORM_DSH_BIN`)→ 常见全局根
|
||||||
|
(`/usr/local/lib/node_modules`、`/usr/lib/node_modules`)各取「嵌套 / 扁平」两种布局 →
|
||||||
|
最后兜底问一次 `npm root -g`。
|
||||||
|
|
||||||
|
⚠️ 2026-09-14 修:此前是**写死** `/usr/local/lib/node_modules/...` 两个字面量;在
|
||||||
|
`npm root -g` 落到 `/usr/lib/node_modules` 的机器(发行版包管理器装的 Node)上两个候选
|
||||||
|
**都不存在** ⇒ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用(测试服已实测复现)。
|
||||||
|
平台侧同思路的另一份实现在 `src/web/dsh-install.ts`(本插件跑在实例进程内、拿不到平台代码,
|
||||||
|
故自带一份;**改一侧记得同步另一侧**)。
|
||||||
|
|
||||||
## 自检
|
## 自检
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
* @module @dsh-local/workspace-scoped-picker
|
* @module @dsh-local/workspace-scoped-picker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
|
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
|
||||||
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
@@ -29,11 +31,83 @@ import { pathToFileURL } from 'node:url'
|
|||||||
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
|
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
|
||||||
const MAX_ENTRIES = 1000
|
const MAX_ENTRIES = 1000
|
||||||
|
|
||||||
/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */
|
/** 平台内置 dsh 的包名(用来确认"这个目录确实是 dsh 包根")。 */
|
||||||
const DEFAULT_SEAM_CANDIDATES = [
|
const PKG_NAME = '@deepseek-ai/dsh'
|
||||||
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
/** 要解析的官方包:目录选择器的 seam 基类。 */
|
||||||
'/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
const SEAM_PKG = 'dsh-host-directory-picker'
|
||||||
]
|
|
||||||
|
/**
|
||||||
|
* 平台内置 dsh 的**包根候选**(按序探测,命中即用)。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-09-14 修:原先把两个 seam 候选写成 `/usr/local/lib/node_modules/...` 的**绝对字面量**
|
||||||
|
* —— 而 `npm i -g` 的落点随发行版 / npm prefix 而变(以发行版包管理器装的 Node 常见
|
||||||
|
* `/usr/lib/node_modules`;本平台测试服 `test106` 就是这种)。写死 ⇒ 两个候选**都不存在**
|
||||||
|
* ⇒ 本模块 `import` 即抛错 ⇒ **实例内目录选择器不可用**(已实测复现)。
|
||||||
|
*
|
||||||
|
* 平台侧 `src/web/dsh-install.ts` 是**同思路的另一份实现** —— 本插件跑在**实例进程**内、
|
||||||
|
* 拿不到平台代码,所以必须自带一份。改一侧时记得同步另一侧。
|
||||||
|
*/
|
||||||
|
function dshPackageRootCandidates() {
|
||||||
|
const out = []
|
||||||
|
for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) {
|
||||||
|
const v = process.env[k]
|
||||||
|
if (typeof v === 'string' && v !== '') out.push(v)
|
||||||
|
}
|
||||||
|
// 由平台配置的 dsh 可执行文件解软链反推包根(最可靠的一路;实例内一般拿不到该变量,故非必中)
|
||||||
|
for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) {
|
||||||
|
const bin = process.env[k]
|
||||||
|
if (typeof bin !== 'string' || bin === '') continue
|
||||||
|
try {
|
||||||
|
let cur = resolve(realpathSync(bin))
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const pj = join(cur, 'package.json')
|
||||||
|
if (existsSync(pj)) {
|
||||||
|
const pkg = JSON.parse(readFileSync(pj, 'utf8'))
|
||||||
|
if (pkg !== null && typeof pkg === 'object' && pkg.name === PKG_NAME) {
|
||||||
|
out.push(cur)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const up = dirname(cur)
|
||||||
|
if (up === cur) break
|
||||||
|
cur = up
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略:继续用下面的常见全局根 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) out.push(join(r, PKG_NAME))
|
||||||
|
return [...new Set(out)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。
|
||||||
|
* 每个包根各出两种布局:dsh 自己的 `node_modules` 下(嵌套)与全局 scope 下(扁平)。
|
||||||
|
*/
|
||||||
|
const DEFAULT_SEAM_CANDIDATES = dshPackageRootCandidates().flatMap((root) => {
|
||||||
|
const globalRoot = dirname(dirname(root)) // <globalRoot>/@deepseek-ai/dsh → <globalRoot>
|
||||||
|
return [
|
||||||
|
join(root, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
|
||||||
|
join(globalRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 常见根全落空时,再问一次 npm(慢,故只在真落空时跑;失败/超时一律当作没有)。
|
||||||
|
if (!DEFAULT_SEAM_CANDIDATES.some((c) => existsSync(c))) {
|
||||||
|
try {
|
||||||
|
const npmRoot = execFileSync('npm', ['root', '-g'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim()
|
||||||
|
if (npmRoot !== '') {
|
||||||
|
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', PKG_NAME, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
|
||||||
|
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析并导入官方 seam 基类。 */
|
/** 解析并导入官方 seam 基类。 */
|
||||||
async function loadSeam() {
|
async function loadSeam() {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/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())
|
||||||
|
|
||||||
|
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 全绿')
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 「平台内置 dsh」安装位置解析 —— **全平台唯一入口**(档案 87 补做 · 2026-09-14)。
|
||||||
|
*
|
||||||
|
* 为什么必须有这一层:**`npm i -g` 的落点随发行版 / npm prefix 而变** —— 实测
|
||||||
|
* · Debian 系常见 `/usr/local/lib/node_modules`(npm 默认 `prefix=/usr/local`)
|
||||||
|
* · 以发行版包管理器装的 Node(OpenCloudOS / CentOS / 宝塔 等)常见 `/usr/lib/node_modules`
|
||||||
|
* (本平台测试服 `test106` 就是这种:`/usr/bin/dsh` → `/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js`)
|
||||||
|
* 而定位失败**不会报错**:调用方只会静默降级(厂家目录读成空、平台包计数为 0),
|
||||||
|
* 表现成「这个功能没做」,排查成本极高(实测:`/api/me/model-providers` 返回 `{"providers":[]}`)。
|
||||||
|
* ⇒ 不能写死单一路径,必须**按序探测**。
|
||||||
|
*
|
||||||
|
* 解析顺序(命中即停):
|
||||||
|
* 1. env `DSH_PACKAGE_DIR` / `DSH_COMPAT_ROOT`(+ 本仓前缀别名 `DSHS_PACKAGE_DIR` /
|
||||||
|
* `DSHS_COMPAT_ROOT`)—— 显式覆盖,也是测试注入点,**无条件生效**;
|
||||||
|
* 2. 平台配置的 dsh 可执行文件(`DSHS_DSH_BIN`,同时接受导出侧改名后的
|
||||||
|
* `DSH_USERS_PLATFORM_DSH_BIN`)→ `realpath` 后向上找含 `name=@deepseek-ai/dsh`
|
||||||
|
* 的 package.json。**最可靠的一路**:它就是实例真正在跑的那个包;
|
||||||
|
* 3. 常见全局根逐一探测(`/usr/local/lib/node_modules`、`/usr/lib/node_modules`);
|
||||||
|
* 4. `npm root -g`(慢,只在前面全落空时跑一次,5s 超时);
|
||||||
|
* 5. 全落空 ⇒ 返回历史默认值(**保持旧行为**,由上层继续按「目录不可读」降级)。
|
||||||
|
*
|
||||||
|
* 判定用 `package.json` 的 `name` 而非「目录存在」,避免撞上同名空目录。结果缓存。
|
||||||
|
*
|
||||||
|
* ⚠️ `poc/workspace-scoped-picker/lib/index.js` 里有一份**同思路的独立实现**
|
||||||
|
* (它跑在**实例进程**内、拿不到本模块 —— 实例侧不加载平台代码),改这里时别忘了那一处。
|
||||||
|
*
|
||||||
|
* ⚠️ 本模块**只依赖 node 内建**(无内部 import)⇒ 可单独编译、单独在目标机上验证。
|
||||||
|
*
|
||||||
|
* @module dshs/web/dsh-install
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
/** 平台内建包的包名(`package.json.name`)。 */
|
||||||
|
const PKG_NAME = '@deepseek-ai/dsh'
|
||||||
|
|
||||||
|
/** 历史默认值(也是最后兜底);仅在探测全落空时使用。 */
|
||||||
|
const LEGACY_DEFAULT_ROOT = '/usr/local/lib/node_modules/' + PKG_NAME
|
||||||
|
|
||||||
|
/** 常见的全局 `node_modules` 落点(候选,非假设)。 */
|
||||||
|
const COMMON_ROOTS = ['/usr/local/lib/node_modules', '/usr/lib/node_modules']
|
||||||
|
|
||||||
|
/** 第一个非空的 env 值。 */
|
||||||
|
function firstEnv(names: readonly string[]): string | null {
|
||||||
|
for (const n of names) {
|
||||||
|
const v = process.env[n]
|
||||||
|
if (v !== undefined && v !== '') return v
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedRoot: string | null = null
|
||||||
|
|
||||||
|
/** 该目录是不是 dsh 包根(看 `package.json.name`,避免撞上同名空目录)。 */
|
||||||
|
function isDshPackageDir(dir: string): boolean {
|
||||||
|
try {
|
||||||
|
if (!statSync(join(dir, 'package.json')).isFile()) return false
|
||||||
|
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
|
||||||
|
return pkg.name === PKG_NAME
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从某个路径向上找包根(最多 6 层,够到 `<root>/lib/bin.js` 这种深度)。 */
|
||||||
|
function findPackageRootUpwards(from: string): string | null {
|
||||||
|
let cur = resolve(from)
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
if (isDshPackageDir(cur)) return cur
|
||||||
|
const up = dirname(cur)
|
||||||
|
if (up === cur) break
|
||||||
|
cur = up
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由平台配置的 dsh 可执行文件反推包根(解软链)。 */
|
||||||
|
function rootFromConfiguredBin(): string | null {
|
||||||
|
const bin = firstEnv(['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN'])
|
||||||
|
if (bin === null) return null
|
||||||
|
try {
|
||||||
|
return findPackageRootUpwards(realpathSync(bin))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 `npm root -g` 反推包根(兜底;失败 / 超时一律当作没有)。 */
|
||||||
|
function rootFromNpm(): string | null {
|
||||||
|
try {
|
||||||
|
const root = execFileSync('npm', ['root', '-g'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim()
|
||||||
|
if (root === '') return null
|
||||||
|
const candidate = join(root, PKG_NAME)
|
||||||
|
return isDshPackageDir(candidate) ? candidate : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台内置 dsh 的包根目录。
|
||||||
|
* @returns 绝对路径;**不保证存在**(全落空时返回历史默认值,由调用方按「目录不可读」降级)。
|
||||||
|
*/
|
||||||
|
export function dshPackageRoot(): string {
|
||||||
|
if (cachedRoot !== null) return cachedRoot
|
||||||
|
|
||||||
|
const envRoot = firstEnv(['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT'])
|
||||||
|
if (envRoot !== null) {
|
||||||
|
cachedRoot = envRoot
|
||||||
|
return envRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
const probes: Array<string | null> = [
|
||||||
|
rootFromConfiguredBin(),
|
||||||
|
...COMMON_ROOTS.map((r) => join(r, PKG_NAME)),
|
||||||
|
rootFromNpm(),
|
||||||
|
]
|
||||||
|
for (const p of probes) {
|
||||||
|
if (p !== null && isDshPackageDir(p)) {
|
||||||
|
cachedRoot = p
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedRoot = LEGACY_DEFAULT_ROOT
|
||||||
|
return LEGACY_DEFAULT_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台内建包所在 scope 目录(`<root>/node_modules/@deepseek-ai`)。 */
|
||||||
|
export function dshScopeDir(): string {
|
||||||
|
return join(dshPackageRoot(), 'node_modules', '@deepseek-ai')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@earendil-works/pi-ai` 的**厂家目录**数据位置。
|
||||||
|
* @returns 绝对路径;**不保证存在**(不存在时返回首选候选,由调用方降级为空目录)。
|
||||||
|
*/
|
||||||
|
export function piAiDataDir(): string {
|
||||||
|
const envDir = firstEnv(['PI_AI_DATA_DIR', 'DSHS_PI_AI_DATA_DIR'])
|
||||||
|
if (envDir !== null) return envDir
|
||||||
|
|
||||||
|
const root = dshPackageRoot()
|
||||||
|
const first = join(root, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
|
||||||
|
// 另一种合法布局:pi-ai 被提升到 `dsh-llm-pi-ai` 自己的 node_modules 下。
|
||||||
|
const nested = join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
'@deepseek-ai',
|
||||||
|
'dsh-llm-pi-ai',
|
||||||
|
'node_modules',
|
||||||
|
'@earendil-works',
|
||||||
|
'pi-ai',
|
||||||
|
'dist',
|
||||||
|
'providers',
|
||||||
|
'data',
|
||||||
|
)
|
||||||
|
if (existsSync(first)) return first
|
||||||
|
if (existsSync(nested)) return nested
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清解析缓存(改了 env 之后重新解析;测试用)。 */
|
||||||
|
export function resetInstallPathsCache(): void {
|
||||||
|
cachedRoot = null
|
||||||
|
}
|
||||||
@@ -25,16 +25,18 @@
|
|||||||
|
|
||||||
import { readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { piAiDataDir } from './dsh-install.js'
|
||||||
|
|
||||||
/** dsh 包安装目录(可用 env 覆盖,便于测试与其它部署形态)。 */
|
/**
|
||||||
const DSH_PACKAGE_DIR = process.env.DSH_PACKAGE_DIR ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh'
|
* 厂家目录数据目录。
|
||||||
|
*
|
||||||
/** 目录数据目录。 */
|
* ⚠️ 2026-09-14 修:原先写死 `/usr/local/lib/node_modules/...`,在 `npm root -g` 落到
|
||||||
|
* `/usr/lib/node_modules` 的机器上会**静默返回空目录**(测试服实测
|
||||||
|
* `GET /api/me/model-providers` → `{"providers":[]}`,而真实位置有 39 个厂家文件)。
|
||||||
|
* 改由 `dsh-install.ts` 按序探测(env → dsh 可执行文件解软链 → 常见全局根 → `npm root -g`)。
|
||||||
|
*/
|
||||||
export function catalogDir(): string {
|
export function catalogDir(): string {
|
||||||
return (
|
return piAiDataDir()
|
||||||
process.env.PI_AI_DATA_DIR ??
|
|
||||||
join(DSH_PACKAGE_DIR, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 目录里一个厂家。 */
|
/** 目录里一个厂家。 */
|
||||||
|
|||||||
@@ -30,11 +30,11 @@
|
|||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
import { join, extname } from 'node:path'
|
import { join, extname } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`),
|
||||||
|
// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。
|
||||||
|
// 统一走 `dsh-install.ts` 的按序探测。
|
||||||
|
import { dshScopeDir } from './dsh-install.js'
|
||||||
|
|
||||||
/** 平台内置包根目录(本部署事实;env 可覆盖,便于测试)。 */
|
|
||||||
const PLATFORM_DSH_ROOT =
|
|
||||||
process.env.DSH_COMPAT_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh'
|
|
||||||
const SCOPE_DIR = join(PLATFORM_DSH_ROOT, 'node_modules', '@deepseek-ai')
|
|
||||||
const DS_SCOPE = '@deepseek-ai/'
|
const DS_SCOPE = '@deepseek-ai/'
|
||||||
|
|
||||||
export interface CompatFinding {
|
export interface CompatFinding {
|
||||||
@@ -69,13 +69,13 @@ function shortName(pkg: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function platformPkgDir(pkg: string): string {
|
function platformPkgDir(pkg: string): string {
|
||||||
return join(SCOPE_DIR, shortName(pkg))
|
return join(dshScopeDir(), shortName(pkg))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
|
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
|
||||||
export function platformPkgCount(): number {
|
export function platformPkgCount(): number {
|
||||||
try {
|
try {
|
||||||
return readdirSync(SCOPE_DIR).filter((n) => !n.startsWith('.')).length
|
return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length
|
||||||
} catch {
|
} catch {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (out.platformPkgs === 0) {
|
if (out.platformPkgs === 0) {
|
||||||
out.note = `平台包目录不可读:${SCOPE_DIR}`
|
out.note = `平台包目录不可读:${dshScopeDir()}`
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
const pkgPath = findPackageJson(root)
|
const pkgPath = findPackageJson(root)
|
||||||
|
|||||||
Reference in new issue
Block a user