245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
#!/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 <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')
|
||
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=<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')
|