Files
dsh_shenxian/scripts/ensure-portal-entry.cjs
T

240 lines
10 KiB
JavaScript
Raw 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
/**
* 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 暂存到 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store/cache 显式指向 <home>。
* **绝不**把 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 <uuid> # 指定用户 id
* node ensure-portal-entry.cjs --tgz <path> # 指定产物
* 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('/opt/dshs/node_modules/better-sqlite3')
const DB = '/var/lib/dshs/dshs.db'
const ARTIFACTS = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
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
}
}
/** 用户根目录(<dataRoot>/users/<id>)—— home_dir 恒为 <userRoot>/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 "<userRoot>/ws/.local/share/pnpm/store/v3"
* pnpm now wants to use the store at "<home>/.pnpm-store/v3"
* 存量 profile 的 node_modules 全部诞生于「HOME=<ws>」时代的安装,storeDir 记为 ws 内路径,
* 因此**沿用旧 store** 才装得动。若硬换新位置,pnpm 要求先 `pnpm install` 重建全量依赖
* (需联网重拉整棵 dsh 依赖树)—— 风险与耗时都不成比例。
* 全新 profile(无 node_modules)没有历史包袱 → 走 <home>/.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(/opt/dsh 是 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 → <home>/.pnpm-store(干净,不写进 ws)。
// cache 同理:沿用既有 ws/.cache/pnpm 可免重新拉 metadata;新 profile 用 <home>/.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')