Files
dsh_shenxian/scripts/ensure-biz-plugins.cjs
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

246 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const cfg = require('../config/index.cjs')
/**
* 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('better-sqlite3')
const DB_PATH = cfg.dbFile()
const BUNDLE = '@dsh-local/business-plugins'
// 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本)
const ART_DIR = cfg.artifactDir()
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')