build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(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/;交接单不入库(政策)。
206 lines
10 KiB
JavaScript
206 lines
10 KiB
JavaScript
#!/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')
|