把散落在代码里的真实部署值统一收进 config/,代码改为引用配置, 使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。 新增 config/:platform.env.example(模板)· load.sh(shell 加载器)· index.cjs(node 加载器)· README.md(键一览与优先级)。 真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。 TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用): platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。 config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由 DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径, src/** 注释中性化 116 行/53 文件。 scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径 一律改从配置取;web/wake.html 的注册域白名单改为运行时从 location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737 保留值,并把「内置种子必须为空」固化为回归断言。 取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有); 全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归 (/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
#!/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 暂存到 <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('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
|
||
}
|
||
}
|
||
|
||
/** 用户根目录(<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(<platform-dir> 是 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')
|