#!/usr/bin/env node /** * ensure-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等) * * 做两件事(缺一不可): * ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add) * ② 把「平台段」写进该用户的 /cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker) * * 用法: * node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新 * node ensure-workspace-picker.cjs --tgz # 指定插件 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 # 指定用户(编排器自愈用,档案 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= pnpm add file:/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、 // .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。 // 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 。 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')