Files

328 lines
15 KiB
JavaScript
Raw Permalink 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-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等)
*
* 做两件事(缺一不可):
* ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add)
* ② 把「平台段」写进该用户的 <profile>/cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker)
*
* 用法:
* node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新
* node ensure-workspace-picker.cjs --tgz <path> # 指定插件 tgz
* node ensure-workspace-picker.cjs --dry-run # 只打印计划
* node ensure-workspace-picker.cjs --restart # 写完后 kill 实例(崩溃自愈会用新配置拉起)
* node ensure-workspace-picker.cjs admin guest # 指定用户名
* node ensure-workspace-picker.cjs --user-id <uuid> # 指定用户(编排器自愈用,档案 18 v3 第二层)
*
* 幂等性:
* · 平台段以 BEGIN/END 标记包裹;已存在即跳过(不改内容)
* · 插件按已装版本比对;版本一致即跳过安装
* 安全:只追加平台段、不触碰既有内容(角色 patch 等);失败逐用户隔离,不影响他人。
*/
const { execFileSync } = require('node:child_process')
const {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} = require('node:fs')
const { dirname, join, resolve } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB = '/var/lib/dshs/dshs.db'
const ARTIFACTS = '/opt/dsh/artifacts'
const PROFILE = 'web'
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)'
const END = '# <<< platform: workspace-scoped-picker'
// marker 宽松匹配:历史上有过 `…picker-patch.cjs` 的旧标记形式,必须同样识别(2026-09-11 事故)
const BEGIN_RE = /^# >>> platform: workspace-scoped-picker/
const END_RE = /^# <<< platform: workspace-scoped-picker/
/**
* 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。
*
* 2026-09-11 事故修复(D1):同时丢掉**裸 \`[]\` 文档行**。
* dsh 新建 profile 的模板是「3 行注释 + []」;注释会被保留 → 正文变成
* \`# …\\n[]\`,既不是空串也不是 \`'[]'\` → 下游 \`body === '[]'\` 判定失效 →
* 空数组文档被原样留下、平台段又追加在其后 → 同一 YAML 流出现两个文档且无 \`---\`
* → dsh 启动报 \`YAMLException: end of the stream or a document separator is expected\`
* → **实例永远起不来**。空数组本身就是"无 patch",丢掉永远安全。
*/
function stripPlatformSegments(text) {
const kept = []
let skipping = false
let removed = 0
for (const line of text.split('\n')) {
if (BEGIN_RE.test(line)) {
skipping = true
removed += 1
continue
}
if (skipping) {
if (END_RE.test(line)) skipping = false
continue
}
if (line.trim() === '[]') continue // ← D1:裸空数组文档行,丢弃
kept.push(line)
}
return { body: kept.join('\n').trim(), removed }
}
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')]
// 位置参数 = 用户名(排除各 flag 的取值)
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
/** 读出既有 node_modules 记录的 storeDir(不存在 → 空串)。详细理由见 scripts/ensure-biz-plugins.cjs 同名函数。 */
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 ''
}
}
/**
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
* 与 scripts/ensure-biz-plugins.cjs 同名函数同源:依赖断裂时 `pnpm add` 会在解析阶段 ENOENT。
*/
function pruneBrokenFileDeps(pkgPath) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const deps = pkg.dependencies ?? {}
const removed = []
for (const [k, v] of Object.entries(deps)) {
if (typeof v !== 'string' || !v.startsWith('file:')) continue
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
if (!existsSync(abs)) {
delete deps[k]
removed.push(`${k} → ${v}`)
}
}
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
return removed
} catch {
return []
}
}
/**
* 把 `dependencies` 里的 `@dsh-local/*` 补回 `dsh.profile.bundles`。
*
* 为什么必须做(2026-09-12 实测事故):`ensure-biz-plugins.cjs` 的 reconcile 有过滤规则
* `bundles.filter(b => b.startsWith('@deepseek-ai/') || deps.includes(b))` —— 一旦本包的
* dep 被 prune 掉,它就会**连带把本包从 bundles 剔除** ⇒ 档案 18 的「目录选择器收敛为仅见
* 自有目录」(R5 安全收敛)**静默失效**。本脚本原先无 reconcile,补不回来,只能手工改。
*/
function reconcileOwnBundles(profileDir) {
const p = join(profileDir, 'package.json')
const pkg = JSON.parse(readFileSync(p, '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 d of deps) if (d.startsWith('@dsh-local/') && !kept.includes(d)) kept.push(d)
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
function pickTgz() {
if (tgzFlag !== '') return tgzFlag
const files = readdirSync(ARTIFACTS)
.filter((f) => /^workspace-scoped-picker-.*\.tgz$/.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到插件产物`)
return join(ARTIFACTS, files[files.length - 1])
}
const TGZ = pickTgz()
const VER = (TGZ.match(/workspace-scoped-picker-(.+)\.tgz$/) || [])[1] || 'unknown'
const BLOCK = [
BEGIN,
'# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),',
'# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。',
'- insert:',
' - id: workspace-scoped-picker',
' name: "@dsh-local/workspace-scoped-picker"',
' - id: ui-directory-picker-browse',
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
'- id: directory-picker',
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
' disabled: true',
END,
'',
].join('\n')
const db = new Database(DB, { readonly: true })
const users = db
.prepare('SELECT id, username, uid, home_dir FROM users')
.all()
.filter(
(u) =>
(only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
)
console.log(`插件产物: ${TGZ}(版本 ${VER})`)
for (const user of users) {
const profileDir = join(user.home_dir, 'profiles', PROFILE)
const patchPath = join(profileDir, 'cordis.patch.yml')
// 2026-09-11 修复:不再把 tgz 复制进用户工作区、也不再让 pnpm 把 store/cache 写进 ws。
// 旧做法(HOME=<ws> pnpm add file:<ws>/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、
// .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。
// 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 <home>。
//
// 2026-09-12(档案 61):store 位置改为**自适应** —— 存量 profile 的 node_modules 是由
// **ws 内旧 store** 链接而来的(「HOME=<ws>」时代的产物,.modules.yaml 里记着它);此处若硬用
// <home>/.pnpm-store,pnpm 会直接拒绝:ERR_PNPM_UNEXPECTED_STORE(档案 57 在 portal-entry 上实测)。
// 因此:**已有安装沿用旧 store,只有全新 profile 才用 <home>/.pnpm-store**。
const legacyStore = existingStoreDir(profileDir)
const storeDir = legacyStore !== '' ? legacyStore : join(user.home_dir, '.pnpm-store')
const legacyCache = join(user.home_dir, '..', 'ws', '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(user.home_dir, '.pnpm-cache')
if (!existsSync(profileDir)) {
console.log(` ${user.username}: NO_PROFILE(用户还没首登 spawn;下次 provisioning/启动会补)`)
continue
}
// ① 平台段(先剥离所有旧段再比对 → 天然自愈重复/旧标记)
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
const { body, removed } = stripPlatformSegments(raw)
const normalized = body === '' || body === '[]' ? '' : body + '\n\n'
const want = normalized + BLOCK
const needPatch = want !== raw
// ② 插件版本
const installed = (() => {
try {
const pj = join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json')
return JSON.parse(readFileSync(pj, 'utf8')).version
} catch {
return null
}
})()
const needInstall = installed !== VER
// 2026-09-12 加固:在做 skip 判定**之前**先自愈「断裂依赖」与「bundles 掉落」。
// 否则本包会一直"假装已装"(node_modules 里有、dependencies 里却没了),
// 且 reconcile 会把本包从 bundles 剔除 → 档案 18 的目录选择器收敛(R5)静默失效。
const pkgPath = join(profileDir, 'package.json')
const BUNDLE_KEY = '@dsh-local/workspace-scoped-picker'
const pruned = pruneBrokenFileDeps(pkgPath)
if (pruned.length > 0) console.log(` ${user.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
let depMissing = false
try {
const pkgNow = JSON.parse(readFileSync(pkgPath, 'utf8'))
depMissing = !(pkgNow.dependencies ?? {})[BUNDLE_KEY]
} catch { /* ignore */ }
const bundlesFixed = reconcileOwnBundles(profileDir)
if (pruned.length > 0 || depMissing) {
console.log(` ${user.username}: 依赖/清单已自愈(bundles ${bundlesFixed.length} 项,dep${depMissing ? ' 缺失→重装' : ' 在位'})`)
// root 写过 package.json → 属主收回给用户
try { execFileSync('chown', [`${user.uid}:${user.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
}
if (!needPatch && !needInstall && !depMissing) {
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
continue
}
if (DRY) {
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
continue
}
// ★ 顺序修正(2026-09-11 事故,D2):**先装插件,后写平台段**。
// 原顺序(先写段 → 装插件失败仅记日志 continue)会留下"段引用了不存在的插件"的 profile
// → dsh 启动即 `ERR_MODULE_NOT_FOUND '@dsh-local/workspace-scoped-picker'` → 崩溃循环 → 熔断
// → 用户实例永远起不来。现在:插件不在位就**绝不写段**,且反向剥离已有段(自愈)。
if (needInstall) {
try {
// D4:`/opt/dsh` 是 drwx------ root,用户 uid 读不到其中 artifacts 的 tgz
// (实测 EACCES)。先把产物暂存到**用户自己的 home** —— 不扩大任何宿主权限(R5 安全),
// 再以用户身份安装。缓存文件名带版本号,跨版本自动重取。
const stageDir = join(user.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, `workspace-scoped-picker-${VER}.tgz`)
if (!existsSync(staged)) copyFileSync(TGZ, staged)
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
// profile 若为 pnpm workspace 根,必须 -w(否则 ERR_PNPM_ADDING_TO_ROOT)
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
const args = ['--reuid', String(user.uid), '--regid', String(user.uid), '--clear-groups',
'env', `HOME=${user.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 })
console.log(` ${user.username}: 插件已装 v${VER}${isRoot ? '(-w)' : ''}(store/cache 在 home,ws 保持干净)`)
} catch (err) {
console.log(` ${user.username}: 插件安装失败 → ${String(err.message || err).split('\n')[0]}`)
}
}
// 复查插件是否真的在位(安装可能已失败)
const installedAfter = (() => {
try {
return JSON.parse(
readFileSync(join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json'), 'utf8'),
).version
} catch {
return null
}
})()
const pluginOk = installedAfter === VER
if (pluginOk) {
if (needPatch) {
writeFileSync(patchPath, want, 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
}
} else {
// D2 反向自愈:没有插件就绝不能留平台段,否则实例启动即崩。
if (/^# >>> platform: workspace-scoped-picker/m.test(raw)) {
writeFileSync(patchPath, body === '' || body === '[]' ? '' : body + '\n', 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: ⚠ 插件缺失 → 已剥离平台段(保证实例可启动)`)
} else {
console.log(` ${user.username}: ⚠ 插件缺失 → 未写平台段(保证实例可启动)`)
}
}
continue
if (RESTART) {
try {
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const [pid, uname] = line.trim().split(/\s+/)
if (pid && uname === `dsh-${user.uid}`) {
try {
process.kill(Number(pid))
} catch {}
}
}
console.log(` ${user.username}: 实例已重启(自愈拉起)`)
} catch {}
}
}
db.close()
console.log('done')