#!/usr/bin/env node /** * 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 ... * 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') const Database = require('/opt/dshs/node_modules/better-sqlite3') const DB_PATH = '/var/lib/dshs/dshs.db' const BUNDLE = '@dsh-local/business-plugins' // 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本) const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts' 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=」时代,storeDir 记为 ws 内路径;脚本若硬换 /.pnpm-store, * pnpm 要求先 `pnpm install` 重建全量依赖(需联网重拉整棵依赖树)。 * 所以:**已有安装沿用旧 store,全新 profile 才用 /.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:/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= 跑 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 暂存 /.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')