- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
190 lines
7.9 KiB
JavaScript
190 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')
|