#!/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(/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 --uid --tgz [选项] * * 必填:--home 该用户的 home(= /home,其下应有 profiles/) * --uid 该用户的 uid(用 setpriv 降权执行 pnpm;⛔ 绝不用 root 装 ⇒ R10:属主变 root 实例崩) * --tgz 插件 tgz 的绝对路径(**须对该 uid 可读**,建议 0644) * 选项:--profile web(默认 web) * --expect 断言装完的版本(不符 ⇒ 退出码 2,便于编排层判定) * --dry-run 只打印计划,不动盘 * --no-stop 装完不停实例 scope(默认停;下次访问平台自动拉起 ⇒ 新 bundle 才生效) * * 退出码:0 = 成功(或 dry-run)/2 = 版本断言不符/1 = 其它失败 * * 姿势与既有脚本完全一致(勿改): * tgz 暂存到 /.dsh-stage/(chown uid、0444)→ setpriv 切 uid → `pnpm add file:` * (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 --uid --tgz ' + ' [--profile web] [--expect ] [--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')