189 lines
7.9 KiB
JavaScript
189 lines
7.9 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 { existsSync, readFileSync, readdirSync, writeFileSync } = require('node:fs')
|
|||
|
|
const { join } = 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/
|
|||
|
|
|
|||
|
|
/** 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。 */
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
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))
|
|||
|
|
|
|||
|
|
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>。
|
|||
|
|
const storeDir = join(user.home_dir, '.pnpm-store')
|
|||
|
|
const cacheDir = 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
|
|||
|
|
|
|||
|
|
if (!needPatch && !needInstall) {
|
|||
|
|
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if (DRY) {
|
|||
|
|
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (needPatch) {
|
|||
|
|
writeFileSync(patchPath, want, 'utf8')
|
|||
|
|
try {
|
|||
|
|
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
|||
|
|
} catch {}
|
|||
|
|
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (needInstall) {
|
|||
|
|
try {
|
|||
|
|
// 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:${TGZ}`)
|
|||
|
|
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]}`)
|
|||
|
|
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')
|