Files
dsh_shenxian/scripts/install-plugin-for-user.cjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

206 lines
10 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
/**
* install-plugin-for-user.cjs —— **给「一个具体用户」的 dsh profile 装/升一个功能插件**(与所在机器无关)
*
* 为什么单独有这个脚本(2026-09-20 立)
* ---------------------------------------------------------------
* 既有的 `ensure-biz-plugins.cjs` / `ensure-portal-entry.cjs` / `ensure-workspace-picker.cjs` 都是
* **"在本机枚举全部用户 → 逐个装"**,于是有两处硬伤:
* ① **跨 Worker 静默跳过**:脚本用 `existsSync(<home>/profiles/web/package.json)` 过滤
* ⇒ 用户迁到别的 Worker(如 guest 在 w-106)时,Manager 上跑同一脚本只会打印 `NO_PROFILE / 无 profile`
* 然后**当成"正常跳过"** —— 2026-09-20 实测:portal-entry 0.5.6 只落到 admin,guest 被漏,
* 而输出里看不出这是"没装"还是"装失败"。
* ② **没有"针对某个用户"的入口**:修单个用户只能临时写一次性脚本(09-13 的 upgrade-mcn-suite.cjs 就是)。
* 本脚本把「**给某台机上的某个用户装某个 tgz**」收敛成一个可复用、可编排、可断点重试的原子动作:
* Manager 侧按 Worker 分组后 **逐机执行同一条命令** —— 这正是"几百上千用户"的编排基元。
*
* 用法
* ---------------------------------------------------------------
* node install-plugin-for-user.cjs --home <home_dir> --uid <uid> --tgz <abs path> [选项]
*
* 必填:--home 该用户的 home(= <userRoot>/home,其下应有 profiles/<profile>)
* --uid 该用户的 uid(用 setpriv 降权执行 pnpm;⛔ 绝不用 root 装 ⇒ R10:属主变 root 实例崩)
* --tgz 插件 tgz 的绝对路径(**须对该 uid 可读**,建议 0644)
* 选项:--profile web(默认 web)
* --expect <ver> 断言装完的版本(不符 ⇒ 退出码 2,便于编排层判定)
* --dry-run 只打印计划,不动盘
* --no-stop 装完不停实例 scope(默认停;下次访问平台自动拉起 ⇒ 新 bundle 才生效)
*
* 退出码:0 = 成功(或 dry-run)/2 = 版本断言不符/1 = 其它失败
*
* 姿势与既有脚本完全一致(勿改):
* tgz 暂存到 <home>/.dsh-stage/(chown uid、0444)→ setpriv 切 uid → `pnpm add file:<staged>`
* (store/cache 沿用该 profile 既有的,**绝不**新建 ⇒ 避免重复下载/搬迁的旧事故)
* → reconcileBundles(把 deps 里带 `dsh.bundle.patch` 的包补进 `dsh.profile.bundles`)
* → 停该 uid 的实例 scope。
*/
'use strict'
const { execFileSync } = require('node:child_process')
const { existsSync, copyFileSync, chmodSync, readFileSync, writeFileSync, mkdirSync, statSync } = require('node:fs')
const { join, basename } = require('node:path')
// ── 参数 ──────────────────────────────────────────────────────────
const argv = process.argv.slice(2)
const val = (flag) => {
const i = argv.indexOf(flag)
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
}
const HOME = val('--home')
const UID_ = val('--uid')
const TGZ = val('--tgz')
const PROFILE = val('--profile') || 'web'
const EXPECT = val('--expect')
const DRY = argv.includes('--dry-run')
const NO_STOP = argv.includes('--no-stop')
if (HOME === '' || UID_ === '' || TGZ === '') {
console.error('用法:node install-plugin-for-user.cjs --home <home_dir> --uid <uid> --tgz <abs path>' +
' [--profile web] [--expect <ver>] [--dry-run] [--no-stop]')
process.exit(1)
}
if (!/^\d+$/.test(UID_)) {
console.error('✗ --uid 必须是数字(got ' + JSON.stringify(UID_) + ')')
process.exit(1)
}
if (!existsSync(TGZ)) {
console.error('✗ tgz 不存在:' + TGZ)
process.exit(1)
}
const fmode = statSync(TGZ).mode & 0o777
if ((fmode & 0o004) === 0) {
console.error('✗ tgz 对 other 不可读(mode ' + fmode.toString(8) + ')⇒ uid ' + UID_ + ' 读不到;请 chmod 0644')
process.exit(1)
}
const PROFILE_DIR = join(HOME, 'profiles', PROFILE)
if (!existsSync(join(PROFILE_DIR, 'package.json'))) {
console.error('✗ 该 home 下没有 profile:' + join(PROFILE_DIR, 'package.json'))
process.exit(1)
}
// ── 从 tgz 里读包名/版本(权威,不靠参数传)──────────────────────────
function tgzField(field) {
const out = execFileSync('tar', ['--force-local', '-xzOf', TGZ, 'package/package.json'],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
const pkg = JSON.parse(out)
if (field === 'version') return pkg.version
if (field === 'name') return pkg.name
if (field === 'isBundle') return !!(pkg.dsh && pkg.dsh.bundle && pkg.dsh.bundle.patch)
throw new Error('unknown field ' + field)
}
const PKG_NAME = tgzField('name')
const PKG_VER = tgzField('version')
if (!tgzField('isBundle')) {
console.error('✗ 该 tgz 不带 dsh.bundle.patch ⇒ 不是"功能插件 bundle",本脚本不适用:' + PKG_NAME)
process.exit(1)
}
const INSTALL_DIR = join(PROFILE_DIR, 'node_modules', PKG_NAME)
function installedVersion() {
try { return JSON.parse(readFileSync(join(INSTALL_DIR, 'package.json'), 'utf8')).version } catch (e) { return null }
}
const BEFORE = installedVersion()
console.log('目标用户 home : ' + HOME + '(uid ' + UID_ + ',profile ' + PROFILE + ')')
console.log('插件 : ' + PKG_NAME + '@' + PKG_VER + '(当前 ' + (BEFORE || '未安装') + ')')
if (DRY) {
console.log('[dry-run] 将执行:pnpm add file:' + TGZ + '(cwd=' + PROFILE_DIR + ',HOME=' + HOME + ')' +
' + reconcileBundles' + (NO_STOP ? '' : ' + 停 uid ' + UID_ + ' 的实例 scope'))
process.exit(0)
}
// ── ① 暂存(对该 uid 可读)──────────────────────────────────────────
const stageDir = join(HOME, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, basename(TGZ))
copyFileSync(TGZ, staged)
chmodSync(staged, 0o444)
execFileSync('chown', [UID_ + ':' + UID_, stageDir, staged], { stdio: 'pipe' })
console.log('① 已暂存 -> ' + staged)
// ── ② 沿用该 profile 既有的 store/cache(⛔ 不新建)─────────────────
function existingStoreDir() {
try {
const txt = readFileSync(join(PROFILE_DIR, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:[ \t]*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch (e) { return '' }
}
const STORE_BEFORE = existingStoreDir()
const storeDir = STORE_BEFORE || join(HOME, '.pnpm-store')
const legacyCache = join(HOME, '..', 'ws', '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(HOME, '.pnpm-cache')
const isRoot = existsSync(join(PROFILE_DIR, 'pnpm-workspace.yaml'))
const args = ['--reuid', UID_, '--regid', UID_, '--clear-groups', 'env', 'HOME=' + HOME,
'pnpm', 'add', '--store-dir', storeDir, '--cache-dir', cacheDir]
if (isRoot) args.push('-w')
args.push('file:' + staged)
try {
execFileSync('setpriv', args, { cwd: PROFILE_DIR, timeout: 300000, stdio: 'pipe' })
} catch (err) {
const detail = String((err && err.stderr) || '').trim() || err.message
console.error('✗ pnpm add 失败:' + detail.split('\n').slice(0, 4).join(' | '))
process.exit(1)
}
console.log('② pnpm add 完成(store ' + (STORE_BEFORE ? '沿用既有:' + STORE_BEFORE : '新建于 ' + storeDir) + ')')
// ── ③ reconcileBundles(deps 里带 dsh.bundle.patch 的进 bundles)────
function isBundleDep(dep) {
try {
return JSON.parse(readFileSync(join(PROFILE_DIR, 'node_modules', dep, 'package.json'), 'utf8')).dsh.bundle.patch !== undefined
} catch (e) { return false }
}
function reconcileBundles() {
const path = join(PROFILE_DIR, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8'))
const deps = Object.keys(pkg.dependencies || {})
const bundles = (pkg.dsh && pkg.dsh.profile && pkg.dsh.profile.bundles) || []
const kept = bundles.filter((b) => b.indexOf('@deepseek-ai/') === 0 || deps.indexOf(b) >= 0)
for (const dep of deps) if (kept.indexOf(dep) < 0 && isBundleDep(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
}
const bundles = reconcileBundles()
const inBundles = bundles.indexOf(PKG_NAME) >= 0
console.log('③ bundles=' + bundles.length + '(含 ' + PKG_NAME + ': ' + inBundles + ')')
if (!inBundles) {
console.error('✗ 该包未进 dsh.profile.bundles ⇒ 实例启动不会加载它')
process.exit(1)
}
// ── ④ 停该 uid 的实例 scope(下次访问平台自动拉起 ⇒ 新 bundle 才生效)──
if (!NO_STOP) {
let n = 0
try {
const out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const unit = line.trim().split(/\s+/)[0]
if (!unit || unit.indexOf('dsh-' + UID_ + '-') !== 0 || unit.slice(-6) !== '.scope') continue
try {
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
n += 1
} catch (e) { /* 单个停不掉不阻断 */ }
}
} catch (e) { /* list-units 失败不阻断 */ }
console.log('④ 已停 ' + n + ' 个实例 scope(下次访问自动拉起)')
}
// ── ⑤ 断言 ────────────────────────────────────────────────────────
const AFTER = installedVersion()
console.log('⑤ 实装版本 = ' + AFTER + '(期望 ' + PKG_VER + ')')
if (AFTER !== PKG_VER) {
console.error('✗ 版本未达预期(pnpm 会按"同名同版本"复用缓存 ⇒ 升号是必须的)')
process.exit(2)
}
if (EXPECT !== '' && AFTER !== EXPECT) {
console.error('✗ 与 --expect ' + EXPECT + ' 不符')
process.exit(2)
}
console.log('OK')