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
@@ -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 { 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 {
|
||||
return (
|
||||
process.env.PI_AI_DATA_DIR ??
|
||||
join(DSH_PACKAGE_DIR, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
|
||||
)
|
||||
return piAiDataDir()
|
||||
}
|
||||
|
||||
/** 目录里一个厂家。 */
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
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 } 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/'
|
||||
|
||||
export interface CompatFinding {
|
||||
@@ -69,13 +69,13 @@ function shortName(pkg: string): string {
|
||||
}
|
||||
|
||||
function platformPkgDir(pkg: string): string {
|
||||
return join(SCOPE_DIR, shortName(pkg))
|
||||
return join(dshScopeDir(), shortName(pkg))
|
||||
}
|
||||
|
||||
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
|
||||
export function platformPkgCount(): number {
|
||||
try {
|
||||
return readdirSync(SCOPE_DIR).filter((n) => !n.startsWith('.')).length
|
||||
return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
@@ -269,7 +269,7 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
||||
}
|
||||
try {
|
||||
if (out.platformPkgs === 0) {
|
||||
out.note = `平台包目录不可读:${SCOPE_DIR}`
|
||||
out.note = `平台包目录不可读:${dshScopeDir()}`
|
||||
return out
|
||||
}
|
||||
const pkgPath = findPackageJson(root)
|
||||
|
||||
Reference in new issue
Block a user