328 lines
15 KiB
JavaScript
328 lines
15 KiB
JavaScript
#!/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')
|