#!/usr/bin/env node const cfg = require('../config/index.cjs') /** * ensure-portal-entry.cjs —— 给**所有**用户铺「设置面板 → 用户管理」入口 * * 背景:`@dsh-local/portal-entry` 的 client 面在 dsh 设置面板注册「用户管理」分区 * (管理员:门户地址 + 打开管理台 + 退出登录;普通用户:仅退出登录)。 * 它原先只装在 admin 的 profile 里(用户要求"普通用户也要有用户管理入口")→ 本脚本铺给全员。 * * 与其他铺开脚本的区别(重要): * · portal-entry **不需要**写 cordis.patch.yml 平台段 —— 它由 profile 的 * `package.json → dsh.profile.bundles` 加载(与 admin 现状一致)。 * · 安装姿势沿用 ensure-workspace-picker.cjs 的 **2026-09-11 修复版**: * tgz 暂存到 /.dsh-stage/、以该用户 uid 执行 setpriv、store/cache 显式指向 。 * **绝不**把 tgz 复制进工作区、**绝不**让 pnpm 把 store 写进 ws * (旧姿势实测污染 admin ws 达 17MB / 2045 文件)。 * * 幂等:判定依据是 node_modules 里已装版本 + dep spec 是否指向本次产物;两者都对即跳过。 * * 用法: * node ensure-portal-entry.cjs # 全部用户兜底 * node ensure-portal-entry.cjs --all # 同上(显式) * node ensure-portal-entry.cjs admin guest # 指定用户名 * node ensure-portal-entry.cjs --user-id # 指定用户 id * node ensure-portal-entry.cjs --tgz # 指定产物 * node ensure-portal-entry.cjs --dry-run # 只打印计划 * node ensure-portal-entry.cjs --restart # 装完停掉其实例 scope(下次访问自动拉起才生效) */ const { execFileSync } = require('node:child_process') const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require('node:fs') const { basename, dirname, join } = require('node:path') const Database = require('better-sqlite3') const DB = cfg.dbFile() const ARTIFACTS = cfg.artifactDir() const PROFILE = 'web' const BUNDLE = '@dsh-local/portal-entry' const PKG_DIR = '@dsh-local/portal-entry' const PREFIX = 'portal-entry-' const argv = process.argv.slice(2) const DRY = argv.includes('--dry-run') const RESTART = argv.includes('--restart') const valueOf = (flag) => { const i = argv.indexOf(flag) return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : '' } const tgzFlag = valueOf('--tgz') const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')] const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== '')) const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a)) function pickTgz() { if (tgzFlag !== '') return tgzFlag const files = readdirSync(ARTIFACTS) .filter((f) => f.startsWith(PREFIX) && f.slice(-4) === '.tgz') .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到 ${PREFIX}*.tgz`) return join(ARTIFACTS, files[files.length - 1]) } const TGZ = pickTgz() if (!existsSync(TGZ)) { console.error(`✗ 缺产物:${TGZ}`) process.exit(1) } const VER = (TGZ.match(new RegExp(`${PREFIX.replace(/\./g, '\\.')}(.+)\\.tgz$`)) || [])[1] || 'unknown' const WANT_BASE = basename(TGZ) /** 已装版本(读该用户 profile 的 node_modules)。 */ function installedVersion(profileDir) { try { return JSON.parse(readFileSync(join(profileDir, 'node_modules', PKG_DIR, 'package.json'), 'utf8')).version } catch { return null } } /** 用户根目录(/users/)—— home_dir 恒为 /home。 */ function userRootOf(u) { return dirname(u.home_dir) } /** * 读出既有 node_modules 记录的 storeDir。 * * 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 —— * ERR_PNPM_UNEXPECTED_STORE(实测 2026-09-11): * dependencies at ".../profiles/web/node_modules" are currently linked from the * store at "/ws/.local/share/pnpm/store/v3" * pnpm now wants to use the store at "/.pnpm-store/v3" * 存量 profile 的 node_modules 全部诞生于「HOME=」时代的安装,storeDir 记为 ws 内路径, * 因此**沿用旧 store** 才装得动。若硬换新位置,pnpm 要求先 `pnpm install` 重建全量依赖 * (需联网重拉整棵 dsh 依赖树)—— 风险与耗时都不成比例。 * 全新 profile(无 node_modules)没有历史包袱 → 走 /.pnpm-store(不污染 ws)。 */ 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 '' } } /** 该包是否声明了 dsh.bundle.patch —— dsh 只把这类 dep 视为 bundle 成员。 */ function isBundle(profileDir, dep) { try { const pkg = JSON.parse(readFileSync(join(profileDir, '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(profileDir) { const path = join(profileDir, '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(profileDir, 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 } const db = new Database(DB, { readonly: true }) const users = db .prepare('SELECT id, username, uid, role, home_dir FROM users') .all() .filter( (u) => (only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id), ) db.close() if (users.length === 0) { console.log('没有匹配的用户') process.exit(0) } console.log(`产物: ${TGZ}(版本 ${VER})`) for (const u of users) { const profileDir = join(u.home_dir, 'profiles', PROFILE) if (!existsSync(profileDir)) { console.log(` ${u.username}: NO_PROFILE(尚未首登 spawn)→ 跳过`) continue } const pkgPath = join(profileDir, 'package.json') if (!existsSync(pkgPath)) { console.log(` ${u.username}: 无 package.json → 跳过`) continue } const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? '') const inBundles = (pkg.dsh?.profile?.bundles ?? []).includes(BUNDLE) const installed = installedVersion(profileDir) const upToDate = installed === VER && depSpec.endsWith(WANT_BASE) && inBundles if (upToDate) { console.log(` ${u.username}: skip(已装 v${installed} 且在 bundles)`) continue } if (DRY) { console.log( ` ${u.username}: [dry-run] 已装=${installed ?? '无'} 目标=${VER} bundles=${inBundles} spec=${depSpec.split('/').pop() || '无'}`, ) continue } try { // ① 暂存产物到该用户自己的 home( 是 drwx------ root,用户 uid 读不到其中文件) const stageDir = join(u.home_dir, '.dsh-stage') mkdirSync(stageDir, { recursive: true, mode: 0o755 }) const staged = join(stageDir, WANT_BASE) if (!existsSync(staged)) copyFileSync(TGZ, staged) chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改 execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' }) // ② 以该用户身份安装。store 位置**自适应**(见 existingStoreDir 的说明): // 已有安装 → 沿用其 .modules.yaml 记录的 store(否则 ERR_PNPM_UNEXPECTED_STORE); // 全新 profile → /.pnpm-store(干净,不写进 ws)。 // cache 同理:沿用既有 ws/.cache/pnpm 可免重新拉 metadata;新 profile 用 /.pnpm-cache。 // profile 若为 pnpm workspace 根必须 -w,否则 ERR_PNPM_ADDING_TO_ROOT。 const legacyStore = existingStoreDir(profileDir) const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store') const legacyCache = join(userRootOf(u), 'ws', '.cache', 'pnpm') const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache') const isRoot = existsSync(join(profileDir, '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: profileDir, stdio: 'pipe', timeout: 180000 }) // ③ bundles reconcile(dsh 只加载 bundles 成员;hook 未进 bundles 则插件不生效) const final = reconcileBundles(profileDir) const ok = final.includes(BUNDLE) console.log( ` ${u.username}: ✓ v${installedVersion(profileDir) ?? '?'}(${isRoot ? '-w,' : ''}bundles=${final.length},含本插件=${ok},store=${legacyStore !== '' ? '沿用旧(ws 内)' : '新建 home'})`, ) 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')