2026-09-13 16:18:10 +08:00
|
|
|
|
#!/usr/bin/env node
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const cfg = require('../config/index.cjs')
|
2026-09-13 16:18:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ensure-biz-plugins.cjs —— 给用户铺「功能插件」分区(档案 36 · 批次 2)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 背景:`@dsh-local/business-plugins` 是「dsh 设置面板 → 功能插件」分区的 client bundle
|
|
|
|
|
|
* (列 admin 投放的插件 + 批量启停 + 确认 + 重启)。它原先只装在 admin profile 里,
|
|
|
|
|
|
* 普通用户看不到该分区 → 本脚本把它铺给普通用户。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 幂等:判定依据是 **bundles**(包内 cordis.patch.yml 只对 bundles 成员生效),
|
|
|
|
|
|
* 不是 dependencies —— 只有 dep 而没进 bundles 时只需 reconcile,不必重装。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用法:
|
|
|
|
|
|
* node ensure-biz-plugins.cjs # 所有 role=active 的非 admin 用户
|
|
|
|
|
|
* node ensure-biz-plugins.cjs <userId|username>...
|
|
|
|
|
|
* node ensure-biz-plugins.cjs --all # 含 admin(自检用)
|
|
|
|
|
|
* node ensure-biz-plugins.cjs --restart # 铺完停掉其实例 scope(下次访问自动拉起,bundle 才生效)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const { execFileSync } = require('node:child_process')
|
|
|
|
|
|
const { chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
|
|
|
|
|
|
const { basename, dirname, join, resolve } = require('node:path')
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const Database = require('better-sqlite3')
|
2026-09-13 16:18:10 +08:00
|
|
|
|
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const DB_PATH = cfg.dbFile()
|
2026-09-13 16:18:10 +08:00
|
|
|
|
const BUNDLE = '@dsh-local/business-plugins'
|
|
|
|
|
|
// 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本)
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const ART_DIR = cfg.artifactDir()
|
2026-09-13 16:18:10 +08:00
|
|
|
|
const ARTIFACT = (function () {
|
|
|
|
|
|
const PREFIX = 'business-plugins-'
|
|
|
|
|
|
const cands = readdirSync(ART_DIR).filter((f) => f.indexOf(PREFIX) === 0 && f.slice(-4) === '.tgz')
|
|
|
|
|
|
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
|
|
|
|
|
|
cands.sort((a, b) => {
|
|
|
|
|
|
const va = ver(a)
|
|
|
|
|
|
const vb = ver(b)
|
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
|
const x = va[i] || 0
|
|
|
|
|
|
const y = vb[i] || 0
|
|
|
|
|
|
if (x !== y) return x - y
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
})
|
|
|
|
|
|
if (cands.length === 0) throw new Error('no ' + PREFIX + '*.tgz under ' + ART_DIR)
|
|
|
|
|
|
return join(ART_DIR, cands[cands.length - 1])
|
|
|
|
|
|
})()
|
|
|
|
|
|
const PROFILE = 'web'
|
|
|
|
|
|
/** guest 的 profile 里有 pnpm-workspace.yaml → pnpm 视为 workspace root 而拒绝 add;
|
|
|
|
|
|
* `--ignore-workspace-root-check` 让它在两种 profile 下都能工作。 */
|
|
|
|
|
|
const WFLAG = '--ignore-workspace-root-check'
|
|
|
|
|
|
|
|
|
|
|
|
const argv = process.argv.slice(2)
|
|
|
|
|
|
const ALL = argv.includes('--all')
|
|
|
|
|
|
const RESTART = argv.includes('--restart')
|
|
|
|
|
|
const wanted = argv.filter((a) => !a.startsWith('--'))
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 读出既有 node_modules 记录的 storeDir(不存在则返回空串)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 ——
|
|
|
|
|
|
* ERR_PNPM_UNEXPECTED_STORE(档案 57 实测):存量 profile 的 node_modules 诞生于
|
|
|
|
|
|
* 「HOME=<ws>」时代,storeDir 记为 ws 内路径;脚本若硬换 <home>/.pnpm-store,
|
|
|
|
|
|
* pnpm 要求先 `pnpm install` 重建全量依赖(需联网重拉整棵依赖树)。
|
|
|
|
|
|
* 所以:**已有安装沿用旧 store,全新 profile 才用 <home>/.pnpm-store**。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function existingStoreDir(profileDir) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
|
|
|
|
|
|
const m = /^storeDir:\s*(.+)$/m.exec(txt)
|
|
|
|
|
|
return m ? m[1].trim() : ''
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 由来(2026-09-12,档案 63):档案 60 把 ws 里的安装残留 tgz 移入 trash 后,profile 的
|
|
|
|
|
|
* `dependencies` 里 `file:<ws>/xxx.tgz` 的 spec 就断了 —— 此后**任何** `pnpm add` 都会在
|
|
|
|
|
|
* 解析阶段直接 ENOENT 失败(两个用户全中,用户侧表现为「安装失败」)。这正是档案 57 §五.2
|
|
|
|
|
|
* 预警过的隐患。此处自愈:解析不到的 `file:` 依赖先摘除,随后 add 新包会写入正确的新路径。
|
|
|
|
|
|
* 安全边界:只删 `file:` 且**目标确实不存在**的条目;registry 依赖与其他依赖一概不动。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function pruneBrokenFileDeps(pkgPath) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
|
|
|
|
const deps = pkg.dependencies ?? {}
|
|
|
|
|
|
const removed = []
|
|
|
|
|
|
for (const [k, v] of Object.entries(deps)) {
|
|
|
|
|
|
if (typeof v !== 'string' || !v.startsWith('file:')) continue
|
|
|
|
|
|
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
|
|
|
|
|
|
if (!existsSync(abs)) {
|
|
|
|
|
|
delete deps[k]
|
|
|
|
|
|
removed.push(`${k} → ${v}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
|
|
|
|
return removed
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isBundle(dir, dep) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
|
|
|
|
|
|
return pkg.dsh?.bundle?.patch !== undefined
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
|
|
|
|
|
|
function reconcileBundles(dir) {
|
|
|
|
|
|
const path = join(dir, 'package.json')
|
|
|
|
|
|
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
|
|
|
|
|
const deps = Object.keys(pkg.dependencies ?? {})
|
|
|
|
|
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
|
|
|
|
|
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
|
|
|
|
|
|
for (const dep of deps) if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
|
|
|
|
|
|
pkg.dsh = pkg.dsh ?? {}
|
|
|
|
|
|
pkg.dsh.profile = pkg.dsh.profile ?? {}
|
|
|
|
|
|
pkg.dsh.profile.bundles = kept
|
|
|
|
|
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
|
|
|
|
|
return kept
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 停掉该 uid 名下所有 dsh scope(与编排器 stopScopesByPrefix 同法)。 */
|
|
|
|
|
|
function stopInstance(uid) {
|
|
|
|
|
|
let out = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
let n = 0
|
|
|
|
|
|
for (const line of out.split('\n')) {
|
|
|
|
|
|
const unit = line.trim().split(/\s+/)[0]
|
|
|
|
|
|
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
|
|
|
|
|
|
try {
|
|
|
|
|
|
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
|
|
|
|
|
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* 单个 scope 停不掉不阻断 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!existsSync(ARTIFACT)) {
|
|
|
|
|
|
console.error(`✗ 缺产物:${ARTIFACT}(用 04-调整方案/poc/business-plugins 重新打包)`)
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const db = new Database(DB_PATH, { readonly: true })
|
|
|
|
|
|
const users = db
|
|
|
|
|
|
.prepare('SELECT id, username, uid, role, home_dir FROM users')
|
|
|
|
|
|
.all()
|
|
|
|
|
|
.filter((u) => (wanted.length > 0 ? wanted.includes(u.id) || wanted.includes(u.username) : true))
|
|
|
|
|
|
.filter((u) => (ALL || wanted.length > 0 ? true : u.role === 'active' && u.username !== 'admin'))
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
if (users.length === 0) {
|
|
|
|
|
|
console.log('没有匹配的用户')
|
|
|
|
|
|
process.exit(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const u of users) {
|
|
|
|
|
|
const root = join(u.home_dir, '..')
|
|
|
|
|
|
const ws = join(root, 'ws')
|
|
|
|
|
|
const dir = join(u.home_dir, 'profiles', PROFILE)
|
|
|
|
|
|
const pkgPath = join(dir, 'package.json')
|
|
|
|
|
|
if (!existsSync(dir) || !existsSync(pkgPath)) {
|
|
|
|
|
|
console.log(` ${u.username}: 无 profile(尚未启动过实例)→ 跳过`)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
|
|
|
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
|
|
|
|
|
const inBundles = bundles.includes(BUNDLE)
|
|
|
|
|
|
const inDeps = Object.keys(pkg.dependencies ?? {}).includes(BUNDLE)
|
|
|
|
|
|
const wantBase = basename(ARTIFACT)
|
|
|
|
|
|
const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? "")
|
|
|
|
|
|
const upToDate = depSpec.endsWith(wantBase)
|
|
|
|
|
|
if (inBundles && upToDate) {
|
|
|
|
|
|
console.log(` ${u.username}: 已是 ${wantBase} → 跳过`)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if (inBundles && !upToDate) {
|
|
|
|
|
|
console.log(` ${u.username}: 版本落后(${depSpec.split("/").pop() || "无"} → ${wantBase}),升级中…`)
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!inDeps || !upToDate) {
|
|
|
|
|
|
// 2026-09-12(档案 61):安装姿势与 ensure-portal-entry.cjs 对齐。
|
|
|
|
|
|
// 旧姿势 =「复制 tgz 进 ws + root 身份 + HOME=<ws> 跑 pnpm」,实测三个副作用:
|
|
|
|
|
|
// ① ws 里堆 `*.tgz` / `.local` / `.cache`(档案 60 实测:admin 4 个 · guest 3 个残留)
|
|
|
|
|
|
// ② 用户家目录留 root 属主项 → 用户 `pip install --user` 报 Permission denied(档案 43)
|
|
|
|
|
|
// ③ **升级存量 node_modules 时会撞 ERR_PNPM_UNEXPECTED_STORE**(老依赖由 ws 内 store
|
|
|
|
|
|
// 链接而来,换位置即被 pnpm 拒绝)—— 档案 57 已在 portal-entry 上实测到
|
|
|
|
|
|
// 新姿势:tgz 暂存 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store 位置自适应。
|
|
|
|
|
|
// (注:原 WFLAG 常量随之弃用,保留定义不影响运行。)
|
|
|
|
|
|
// 安装前自愈:先清掉指向已不存在文件的 `file:` 依赖,否则下面的 `pnpm add` 必定
|
|
|
|
|
|
// 在解析阶段 ENOENT 失败(2026-09-12 实测两用户全中)。
|
|
|
|
|
|
const pruned = pruneBrokenFileDeps(pkgPath)
|
|
|
|
|
|
if (pruned.length > 0) {
|
|
|
|
|
|
console.log(` ${u.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
|
|
|
|
|
|
// 以 root 改写过 package.json → 属主收回给该用户,避免用户侧读到 root 属主文件。
|
|
|
|
|
|
try { execFileSync('chown', [`${u.uid}:${u.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
const stageDir = join(u.home_dir, '.dsh-stage')
|
|
|
|
|
|
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
|
|
|
|
|
const staged = join(stageDir, basename(ARTIFACT))
|
|
|
|
|
|
if (!existsSync(staged)) copyFileSync(ARTIFACT, staged)
|
|
|
|
|
|
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
|
|
|
|
|
|
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
|
|
|
|
|
|
const legacyStore = existingStoreDir(dir)
|
|
|
|
|
|
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
|
|
|
|
|
|
const legacyCache = join(ws, '.cache', 'pnpm')
|
|
|
|
|
|
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
|
|
|
|
|
|
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
|
|
|
|
|
|
const args = [
|
|
|
|
|
|
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
|
|
|
|
|
|
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
|
|
|
|
|
|
'--store-dir', storeDir, '--cache-dir', cacheDir,
|
|
|
|
|
|
]
|
|
|
|
|
|
if (isRoot) args.push('-w')
|
|
|
|
|
|
args.push(`file:${staged}`)
|
|
|
|
|
|
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
` ${u.username}: 已安装/更新依赖${isRoot ? '(-w)' : ''}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'},ws 保持干净)`,
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(` ${u.username}: 依赖已是最新,仅 reconcile`)
|
|
|
|
|
|
}
|
|
|
|
|
|
const final = reconcileBundles(dir)
|
|
|
|
|
|
console.log(` ${u.username}: ✓ bundles=${final.length}(含 ${BUNDLE}: ${final.includes(BUNDLE)})`)
|
|
|
|
|
|
if (RESTART) {
|
|
|
|
|
|
const n = stopInstance(u.uid)
|
|
|
|
|
|
console.log(` 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
|
|
|
|
|
|
console.log(` ${u.username}: ✗ 失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('done')
|