文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
1 parent
3d8f50e366
commit
e6207aa691
239 files changed
+34477
-14633
No files matched your search
@@ -35,12 +35,22 @@ function layerOf(file) {
|
||||
const p = posixify(file)
|
||||
if (p === 'src/cli.ts' || p.startsWith('src/web/')) return 1
|
||||
if (p.startsWith('src/domain/')) return 2
|
||||
if (/^src\/(supervisor|db|fs|worker|net|nginx)\//.test(p)) return 3
|
||||
if (/^src\/(supervisor|db|fs|worker|net|nginx|im)\//.test(p)) return 3
|
||||
if (/^src\/(config|crypto|isolation|index)\.ts$/.test(p)) return 4
|
||||
return null // 未归类 ⇒ 新增目录必须先想清"它属于哪一层"
|
||||
}
|
||||
const NAME = { 1: '①入口', 2: '②领域', 3: '③能力', 4: '④基础' }
|
||||
|
||||
// 定层留痕(2026-09-22 补 · `src/im` 曾长期"未归类" ⇒ 分层检查对它静默不判)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// `src/im/**`(含 `src/im/sdk/**`)定 **③能力层**,依据 = 实测依赖方向:
|
||||
// · 只 import `../db/{connection,schema}.js` ⇒ ③→③ **同层**(R2 允许)
|
||||
// · 零 `../web/` `../domain/` 引用 ⇒ **无回指**,符合 R1
|
||||
// · 反向扇入 3 处(`web/server.ts` 挂载 · `supervisor/{orchestrator,remote-spawner}`
|
||||
// 的 `import type`)⇒ 全是**上层依赖下层**,符合 R1 方向
|
||||
// · 反向扇入 `web/server.ts` 是层①,`supervisor/*` 与自身同为层③ ⇒ 无越层
|
||||
// ⚠️ 若将来 `src/im` 需要 import `../web/**` ⇒ 定层即错,必须改为"由层①注入依赖"。
|
||||
|
||||
function walk(dir) {
|
||||
const out = []
|
||||
for (const e of readdirSync(dir)) {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* install-plugin-for-user.cjs —— **给「一个具体用户」的 dsh profile 装/升一个功能插件**(与所在机器无关)
|
||||
*
|
||||
* 为什么单独有这个脚本(2026-09-20 立)
|
||||
* ---------------------------------------------------------------
|
||||
* 既有的 `ensure-biz-plugins.cjs` / `ensure-portal-entry.cjs` / `ensure-workspace-picker.cjs` 都是
|
||||
* **"在本机枚举全部用户 → 逐个装"**,于是有两处硬伤:
|
||||
* ① **跨 Worker 静默跳过**:脚本用 `existsSync(<home>/profiles/web/package.json)` 过滤
|
||||
* ⇒ 用户迁到别的 Worker(如 guest 在 w-106)时,Manager 上跑同一脚本只会打印 `NO_PROFILE / 无 profile`
|
||||
* 然后**当成"正常跳过"** —— 2026-09-20 实测:portal-entry 0.5.6 只落到 admin,guest 被漏,
|
||||
* 而输出里看不出这是"没装"还是"装失败"。
|
||||
* ② **没有"针对某个用户"的入口**:修单个用户只能临时写一次性脚本(09-13 的 upgrade-mcn-suite.cjs 就是)。
|
||||
* 本脚本把「**给某台机上的某个用户装某个 tgz**」收敛成一个可复用、可编排、可断点重试的原子动作:
|
||||
* Manager 侧按 Worker 分组后 **逐机执行同一条命令** —— 这正是"几百上千用户"的编排基元。
|
||||
*
|
||||
* 用法
|
||||
* ---------------------------------------------------------------
|
||||
* node install-plugin-for-user.cjs --home <home_dir> --uid <uid> --tgz <abs path> [选项]
|
||||
*
|
||||
* 必填:--home 该用户的 home(= <userRoot>/home,其下应有 profiles/<profile>)
|
||||
* --uid 该用户的 uid(用 setpriv 降权执行 pnpm;⛔ 绝不用 root 装 ⇒ R10:属主变 root 实例崩)
|
||||
* --tgz 插件 tgz 的绝对路径(**须对该 uid 可读**,建议 0644)
|
||||
* 选项:--profile web(默认 web)
|
||||
* --expect <ver> 断言装完的版本(不符 ⇒ 退出码 2,便于编排层判定)
|
||||
* --dry-run 只打印计划,不动盘
|
||||
* --no-stop 装完不停实例 scope(默认停;下次访问平台自动拉起 ⇒ 新 bundle 才生效)
|
||||
*
|
||||
* 退出码:0 = 成功(或 dry-run)/2 = 版本断言不符/1 = 其它失败
|
||||
*
|
||||
* 姿势与既有脚本完全一致(勿改):
|
||||
* tgz 暂存到 <home>/.dsh-stage/(chown uid、0444)→ setpriv 切 uid → `pnpm add file:<staged>`
|
||||
* (store/cache 沿用该 profile 既有的,**绝不**新建 ⇒ 避免重复下载/搬迁的旧事故)
|
||||
* → reconcileBundles(把 deps 里带 `dsh.bundle.patch` 的包补进 `dsh.profile.bundles`)
|
||||
* → 停该 uid 的实例 scope。
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { existsSync, copyFileSync, chmodSync, readFileSync, writeFileSync, mkdirSync, statSync } = require('node:fs')
|
||||
const { join, basename } = require('node:path')
|
||||
|
||||
// ── 参数 ──────────────────────────────────────────────────────────
|
||||
const argv = process.argv.slice(2)
|
||||
const val = (flag) => {
|
||||
const i = argv.indexOf(flag)
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
|
||||
}
|
||||
const HOME = val('--home')
|
||||
const UID_ = val('--uid')
|
||||
const TGZ = val('--tgz')
|
||||
const PROFILE = val('--profile') || 'web'
|
||||
const EXPECT = val('--expect')
|
||||
const DRY = argv.includes('--dry-run')
|
||||
const NO_STOP = argv.includes('--no-stop')
|
||||
|
||||
if (HOME === '' || UID_ === '' || TGZ === '') {
|
||||
console.error('用法:node install-plugin-for-user.cjs --home <home_dir> --uid <uid> --tgz <abs path>' +
|
||||
' [--profile web] [--expect <ver>] [--dry-run] [--no-stop]')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!/^\d+$/.test(UID_)) {
|
||||
console.error('✗ --uid 必须是数字(got ' + JSON.stringify(UID_) + ')')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(TGZ)) {
|
||||
console.error('✗ tgz 不存在:' + TGZ)
|
||||
process.exit(1)
|
||||
}
|
||||
const fmode = statSync(TGZ).mode & 0o777
|
||||
if ((fmode & 0o004) === 0) {
|
||||
console.error('✗ tgz 对 other 不可读(mode ' + fmode.toString(8) + ')⇒ uid ' + UID_ + ' 读不到;请 chmod 0644')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PROFILE_DIR = join(HOME, 'profiles', PROFILE)
|
||||
if (!existsSync(join(PROFILE_DIR, 'package.json'))) {
|
||||
console.error('✗ 该 home 下没有 profile:' + join(PROFILE_DIR, 'package.json'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ── 从 tgz 里读包名/版本(权威,不靠参数传)──────────────────────────
|
||||
function tgzField(field) {
|
||||
const out = execFileSync('tar', ['--force-local', '-xzOf', TGZ, 'package/package.json'],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
const pkg = JSON.parse(out)
|
||||
if (field === 'version') return pkg.version
|
||||
if (field === 'name') return pkg.name
|
||||
if (field === 'isBundle') return !!(pkg.dsh && pkg.dsh.bundle && pkg.dsh.bundle.patch)
|
||||
throw new Error('unknown field ' + field)
|
||||
}
|
||||
const PKG_NAME = tgzField('name')
|
||||
const PKG_VER = tgzField('version')
|
||||
if (!tgzField('isBundle')) {
|
||||
console.error('✗ 该 tgz 不带 dsh.bundle.patch ⇒ 不是"功能插件 bundle",本脚本不适用:' + PKG_NAME)
|
||||
process.exit(1)
|
||||
}
|
||||
const INSTALL_DIR = join(PROFILE_DIR, 'node_modules', PKG_NAME)
|
||||
|
||||
function installedVersion() {
|
||||
try { return JSON.parse(readFileSync(join(INSTALL_DIR, 'package.json'), 'utf8')).version } catch (e) { return null }
|
||||
}
|
||||
const BEFORE = installedVersion()
|
||||
|
||||
console.log('目标用户 home : ' + HOME + '(uid ' + UID_ + ',profile ' + PROFILE + ')')
|
||||
console.log('插件 : ' + PKG_NAME + '@' + PKG_VER + '(当前 ' + (BEFORE || '未安装') + ')')
|
||||
if (DRY) {
|
||||
console.log('[dry-run] 将执行:pnpm add file:' + TGZ + '(cwd=' + PROFILE_DIR + ',HOME=' + HOME + ')' +
|
||||
' + reconcileBundles' + (NO_STOP ? '' : ' + 停 uid ' + UID_ + ' 的实例 scope'))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── ① 暂存(对该 uid 可读)──────────────────────────────────────────
|
||||
const stageDir = join(HOME, '.dsh-stage')
|
||||
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||||
const staged = join(stageDir, basename(TGZ))
|
||||
copyFileSync(TGZ, staged)
|
||||
chmodSync(staged, 0o444)
|
||||
execFileSync('chown', [UID_ + ':' + UID_, stageDir, staged], { stdio: 'pipe' })
|
||||
console.log('① 已暂存 -> ' + staged)
|
||||
|
||||
// ── ② 沿用该 profile 既有的 store/cache(⛔ 不新建)─────────────────
|
||||
function existingStoreDir() {
|
||||
try {
|
||||
const txt = readFileSync(join(PROFILE_DIR, 'node_modules', '.modules.yaml'), 'utf8')
|
||||
const m = /^storeDir:[ \t]*(.+)$/m.exec(txt)
|
||||
return m ? m[1].trim() : ''
|
||||
} catch (e) { return '' }
|
||||
}
|
||||
const STORE_BEFORE = existingStoreDir()
|
||||
const storeDir = STORE_BEFORE || join(HOME, '.pnpm-store')
|
||||
const legacyCache = join(HOME, '..', 'ws', '.cache', 'pnpm')
|
||||
const cacheDir = existsSync(legacyCache) ? legacyCache : join(HOME, '.pnpm-cache')
|
||||
const isRoot = existsSync(join(PROFILE_DIR, 'pnpm-workspace.yaml'))
|
||||
|
||||
const args = ['--reuid', UID_, '--regid', UID_, '--clear-groups', 'env', 'HOME=' + HOME,
|
||||
'pnpm', 'add', '--store-dir', storeDir, '--cache-dir', cacheDir]
|
||||
if (isRoot) args.push('-w')
|
||||
args.push('file:' + staged)
|
||||
try {
|
||||
execFileSync('setpriv', args, { cwd: PROFILE_DIR, timeout: 300000, stdio: 'pipe' })
|
||||
} catch (err) {
|
||||
const detail = String((err && err.stderr) || '').trim() || err.message
|
||||
console.error('✗ pnpm add 失败:' + detail.split('\n').slice(0, 4).join(' | '))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('② pnpm add 完成(store ' + (STORE_BEFORE ? '沿用既有:' + STORE_BEFORE : '新建于 ' + storeDir) + ')')
|
||||
|
||||
// ── ③ reconcileBundles(deps 里带 dsh.bundle.patch 的进 bundles)────
|
||||
function isBundleDep(dep) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(PROFILE_DIR, 'node_modules', dep, 'package.json'), 'utf8')).dsh.bundle.patch !== undefined
|
||||
} catch (e) { return false }
|
||||
}
|
||||
function reconcileBundles() {
|
||||
const path = join(PROFILE_DIR, 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
||||
const deps = Object.keys(pkg.dependencies || {})
|
||||
const bundles = (pkg.dsh && pkg.dsh.profile && pkg.dsh.profile.bundles) || []
|
||||
const kept = bundles.filter((b) => b.indexOf('@deepseek-ai/') === 0 || deps.indexOf(b) >= 0)
|
||||
for (const dep of deps) if (kept.indexOf(dep) < 0 && isBundleDep(dep)) kept.push(dep)
|
||||
pkg.dsh = pkg.dsh || {}
|
||||
pkg.dsh.profile = pkg.dsh.profile || {}
|
||||
pkg.dsh.profile.bundles = kept
|
||||
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||
return kept
|
||||
}
|
||||
const bundles = reconcileBundles()
|
||||
const inBundles = bundles.indexOf(PKG_NAME) >= 0
|
||||
console.log('③ bundles=' + bundles.length + '(含 ' + PKG_NAME + ': ' + inBundles + ')')
|
||||
if (!inBundles) {
|
||||
console.error('✗ 该包未进 dsh.profile.bundles ⇒ 实例启动不会加载它')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ── ④ 停该 uid 的实例 scope(下次访问平台自动拉起 ⇒ 新 bundle 才生效)──
|
||||
if (!NO_STOP) {
|
||||
let n = 0
|
||||
try {
|
||||
const out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
||||
for (const line of out.split('\n')) {
|
||||
const unit = line.trim().split(/\s+/)[0]
|
||||
if (!unit || unit.indexOf('dsh-' + UID_ + '-') !== 0 || unit.slice(-6) !== '.scope') continue
|
||||
try {
|
||||
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
||||
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
||||
n += 1
|
||||
} catch (e) { /* 单个停不掉不阻断 */ }
|
||||
}
|
||||
} catch (e) { /* list-units 失败不阻断 */ }
|
||||
console.log('④ 已停 ' + n + ' 个实例 scope(下次访问自动拉起)')
|
||||
}
|
||||
|
||||
// ── ⑤ 断言 ────────────────────────────────────────────────────────
|
||||
const AFTER = installedVersion()
|
||||
console.log('⑤ 实装版本 = ' + AFTER + '(期望 ' + PKG_VER + ')')
|
||||
if (AFTER !== PKG_VER) {
|
||||
console.error('✗ 版本未达预期(pnpm 会按"同名同版本"复用缓存 ⇒ 升号是必须的)')
|
||||
process.exit(2)
|
||||
}
|
||||
if (EXPECT !== '' && AFTER !== EXPECT) {
|
||||
console.error('✗ 与 --expect ' + EXPECT + ' 不符')
|
||||
process.exit(2)
|
||||
}
|
||||
console.log('OK')
|
||||
@@ -57,7 +57,10 @@ ok('label = 用户设置(转义 unicode \\u7528\\u6237\\u8bbe\\u7f6e)', src.
|
||||
|
||||
// ── ④ 语言切换(2026-09-15 搬入)──
|
||||
ok('inject 里要了 locale 服务', /const inject = \["slots", "locale"\]/.test(src))
|
||||
ok('读当前语言走官方 ctx.locale.getLocale()', /hostCtx\.locale\.getLocale\(\)/.test(src))
|
||||
ok('读当前语言走官方 ctx.locale.getLocale()', /\.getLocale\(\)/.test(src))
|
||||
// 2026-09-20:dsh 0.1.5-rc.1 的 `getLocale()` 返回**快照对象** {active,locales,revision}
|
||||
// (0.1.2-rc.1 时代返回 id 字符串)⇒ 必须取 `.active`,否则下拉永远显示第一项、切换看着"没反应"。
|
||||
ok('getLocale() 返回快照对象时取 .active(0.1.5-rc.1)', /typeof v === "object"\) v = v\.active/.test(src))
|
||||
ok('切语言走官方 ctx.locale.setLocale()', /hostCtx\.locale\.setLocale\(id\)/.test(src))
|
||||
ok('注册了自己的双语词典(ctx.locale.register)', /locale\.register\(PE_NS, PE_DICT\)/.test(src))
|
||||
ok('语言项 en / zh 都在', src.includes('{ id: "en"') && src.includes('{ id: "zh"'))
|
||||
|
||||
Reference in new issue
Block a user