311 lines
15 KiB
JavaScript
311 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ensure-anysearch-admin.cjs —— 给指定实例接入 AnySearch 联网搜索(档案 64 · B 方案)
|
||
*
|
||
* 背景:平台现用 DeepSeek 官方搜索(`dsh-web-search-deepseek`,走 Anthropic Messages
|
||
* 的原生 `web_search` server tool)——**一次搜索 = 一次模型 turn**,成本偏高。本脚本把
|
||
* `@anysearch/anysearch-dsh` 装进目标用户 profile,并把 key 写进该用户的 dsh 凭据文件,
|
||
* 让 `web_search` 改走 AnySearch REST `/v1/search`(返回结构化 title/url/snippet)。
|
||
*
|
||
* 三个设计点(用户 2026-09-12 拍板):
|
||
* ① **分两步走**:先只装 admin 验证效果与配额,确认后再铺普通用户 —— 故默认只处理 admin。
|
||
* ② **保留本地抓取**:插件自带的 patch 会把 `fetchProvider` 也换成 anysearch;这里在
|
||
* profile 层追加覆写段把它拉回本地 `http`(保留 SSRF 防护:拒非公网地址、逐跳校验重定向)。
|
||
* ③ **key 只写该用户自己的 `.credentials.yaml`**(`refs` 段按环境变量名存),
|
||
* 不注入实例 env、不改平台 `baseEnv` —— 少一处外泄点。
|
||
*
|
||
* 顺序不可颠倒:**先写 key、再装插件**。profile 的 `patchReload: live` 有热加载可能,
|
||
* 反序会出现「provider 已切到 anysearch、key 还没配」的窗口。
|
||
*
|
||
* 用法:
|
||
* node ensure-anysearch-admin.cjs # 干跑(只打印计划,默认目标 admin)
|
||
* node ensure-anysearch-admin.cjs --apply # 执行(写 key + patch + 装插件)
|
||
* node ensure-anysearch-admin.cjs --apply --restart # 执行并停实例(新 bundle 才生效)
|
||
* node ensure-anysearch-admin.cjs --apply --restart guest # 第二步:铺普通用户
|
||
*
|
||
* key 从环境变量 `ANYSEARCH_API_KEY` 读,**不落盘到本脚本**:
|
||
* ANYSEARCH_API_KEY=as_sk_… node ensure-anysearch-admin.cjs --apply --restart
|
||
*
|
||
* 幂等:凭据已含该 key / patch 已含管理标记 / 依赖已是该 tgz → 各自跳过。
|
||
*/
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ⛔ 2026-09-13 已退役(档案 65 §7.3 第 3 条 · 档案 70 §九)
|
||
// AnySearch 已按用户决定【彻底放弃】(候选池条目下架 + 存量插件下架)。
|
||
// 本脚本「写 provider 覆写段 + 装 anysearch 插件」的职责,已由**平台托管段**
|
||
// (档案 65:功能插件启停 ↔ web provider 配置联动)接管。
|
||
// 若两段并存,后者会覆盖前者 → 产生难以察觉的配置漂移,故在此**硬拦**。
|
||
// 确需运行(考古 / 回滚)时,显式设 DSH_ALLOW_RETIRED_ANYSEARCH=1。
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
if (process.env.DSH_ALLOW_RETIRED_ANYSEARCH !== "1") {
|
||
console.error("⛔ ensure-anysearch-admin.cjs 已退役:AnySearch 已彻底放弃,provider 托管段由平台(档案 65)接管。");
|
||
console.error(" 确需运行请显式设置 DSH_ALLOW_RETIRED_ANYSEARCH=1(仅考古/回滚用)。");
|
||
process.exit(2);
|
||
}
|
||
|
||
const { execFileSync } = require('node:child_process')
|
||
const {
|
||
chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
|
||
} = require('node:fs')
|
||
const { basename, dirname, join, resolve } = require('node:path')
|
||
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||
|
||
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
|
||
const PKG = '@anysearch/anysearch-dsh'
|
||
const PROFILE = 'web'
|
||
const KEY_ENV = 'ANYSEARCH_API_KEY'
|
||
const MARK = 'platform: anysearch-search'
|
||
const PATCH_BLOCK = [
|
||
'',
|
||
`# >>> ${MARK} (managed by ensure-anysearch-admin.cjs)`,
|
||
'# 只换 search:AnySearch 提供联网搜索;fetch 仍走本地 http provider(保留 SSRF 防护)。',
|
||
'# ⚠ 本块对 dsh-web 条目是 **整体替换 config**(非字段级合并),所以两个字段都必须写全:',
|
||
'# 实测只写 fetchProvider 时,插件自带 patch 的 searchProvider 会被一并冲掉,',
|
||
'# 而 search registry 上 deepseek-official 与 anysearch 都 available()=true、又无显式选择,',
|
||
'# → 命中 WEB_PROVIDER_AMBIGUOUS(不是自动选一个,是直接报错)。',
|
||
'- id: web',
|
||
' config:',
|
||
' searchProvider: anysearch',
|
||
' fetchProvider: http',
|
||
`# <<< ${MARK}`,
|
||
'',
|
||
].join('\n')
|
||
|
||
const argv = process.argv.slice(2)
|
||
const APPLY = argv.includes('--apply')
|
||
const RESTART = argv.includes('--restart')
|
||
const positional = []
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i]
|
||
if (a === '--user') { positional.push(argv[++i] ?? ''); continue }
|
||
if (a.startsWith('--')) continue
|
||
positional.push(a)
|
||
}
|
||
const wanted = positional.filter(Boolean)
|
||
const KEY = process.env[KEY_ENV] ?? ''
|
||
|
||
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
|
||
function artifactPath() {
|
||
const prefix = 'anysearch-dsh-'
|
||
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(prefix) && f.endsWith('.tgz'))
|
||
if (cands.length === 0) throw new Error(`no ${prefix}*.tgz under ${ART_DIR}`)
|
||
const ver = (f) => f.slice(prefix.length, -4).split('.').map(Number)
|
||
cands.sort((a, b) => {
|
||
const va = ver(a); const vb = ver(b)
|
||
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
|
||
return 0
|
||
})
|
||
return join(ART_DIR, cands[cands.length - 1])
|
||
}
|
||
|
||
/** 读既有 node_modules 的 storeDir(沿用旧 store,否则 pnpm 会要求全量重装)。 */
|
||
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:` 依赖,否则 pnpm add 会在解析阶段 ENOENT 失败(档案 63)。 */
|
||
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 [] }
|
||
}
|
||
|
||
function isBundle(dir, dep) {
|
||
try {
|
||
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
|
||
return pkg.dsh?.bundle?.patch !== undefined
|
||
} catch { return false }
|
||
}
|
||
|
||
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
|
||
function reconcileBundles(dir) {
|
||
const path = join(dir, 'package.json')
|
||
const pkg = JSON.parse(readFileSync(path, '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 dep of deps) if (!kept.includes(dep) && isBundle(dir, 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
|
||
}
|
||
|
||
/** 停掉该 uid 名下所有 dsh scope(下次访问由编排器自动拉起)。 */
|
||
function stopInstance(uid) {
|
||
let out = ''
|
||
try {
|
||
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
||
} catch { return 0 }
|
||
let n = 0
|
||
for (const line of out.split('\n')) {
|
||
const unit = line.trim().split(/\s+/)[0]
|
||
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
|
||
try {
|
||
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
||
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
||
n += 1
|
||
} catch { /* 单个 scope 停不掉不阻断 */ }
|
||
}
|
||
return n
|
||
}
|
||
|
||
/**
|
||
* 在凭据文档里放一个 `refs.<ENV>` 条目(refs 段按环境变量名存 key 值)。
|
||
* 文档只有 `version` / `refs` / `records` 三个顶层段,其余一律拒绝加载(dsh 的行为)。
|
||
* 已存在同名条目 → 跳过(不覆盖用户可能已改过的值)。
|
||
*/
|
||
function ensureCredential(credPath, value) {
|
||
const text = readFileSync(credPath, 'utf8')
|
||
if (new RegExp(`(^|\\n)\\s*${KEY_ENV}:`, 'm').test(text)) return 'present'
|
||
let next
|
||
if (/^refs:[ \t]*$/m.test(text)) {
|
||
next = text.replace(/^refs:[ \t]*$/m, `refs:\n ${KEY_ENV}: ${value}`)
|
||
} else if (/^version: 1[ \t]*$/m.test(text)) {
|
||
next = text.replace(/^version: 1[ \t]*$/m, `version: 1\nrefs:\n ${KEY_ENV}: ${value}`)
|
||
} else {
|
||
throw new Error('凭据文档结构异常:找不到 "version: 1" 行,拒绝写入')
|
||
}
|
||
writeFileSync(credPath, next)
|
||
return 'inserted'
|
||
}
|
||
|
||
/** 幂等写入覆写段:无则追加,有但内容落后(缺字段)则原地替换整块。 */
|
||
function ensurePatch(patchPath) {
|
||
const text = readFileSync(patchPath, 'utf8')
|
||
const open = `# >>> ${MARK}`
|
||
const close = `# <<< ${MARK}`
|
||
const start = text.indexOf(open)
|
||
const end = text.indexOf(close)
|
||
if (start >= 0 && end > start) {
|
||
const tail = end + close.length
|
||
const next = text.slice(0, start) + PATCH_BLOCK.trimStart() + text.slice(tail)
|
||
if (next === text) return 'present'
|
||
writeFileSync(patchPath, next)
|
||
return 'updated'
|
||
}
|
||
writeFileSync(patchPath, text.replace(/\s*$/, '\n') + PATCH_BLOCK)
|
||
return 'appended'
|
||
}
|
||
|
||
// ---- main ----
|
||
if (!existsSync(DB_PATH)) { console.error('✗ 找不到平台 DB'); process.exit(1) }
|
||
const db = new Database(DB_PATH, { readonly: true })
|
||
const all = db.prepare('SELECT id, username, uid, role, home_dir FROM users').all()
|
||
db.close()
|
||
const users = all.filter((u) => (wanted.length > 0
|
||
? (wanted.includes(u.username) || wanted.includes(u.id) || wanted.includes(String(u.uid)))
|
||
: u.username === 'admin'))
|
||
if (users.length === 0) { console.error(`✗ 没匹配到用户:${wanted.join(',') || 'admin'}`); process.exit(1) }
|
||
if (APPLY && KEY === '') { console.error(`✗ 需要环境变量 ${KEY_ENV} 提供 key`); process.exit(1) }
|
||
|
||
let artifact
|
||
try { artifact = artifactPath() } catch (err) { console.error(`✗ ${err.message}`); process.exit(1) }
|
||
console.log(`artifact = ${artifact}`)
|
||
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}${RESTART ? ' + RESTART' : ''}`)
|
||
console.log(`targets = ${users.map((u) => `${u.username}(uid ${u.uid})`).join(', ')}`)
|
||
|
||
for (const u of users) {
|
||
console.log(`\n=== ${u.username} (uid ${u.uid}) ===`)
|
||
const root = join(u.home_dir, '..')
|
||
const ws = join(root, 'ws')
|
||
const dir = join(u.home_dir, 'profiles', PROFILE)
|
||
const pkgPath = join(dir, 'package.json')
|
||
if (!existsSync(dir) || !existsSync(pkgPath)) { console.log(' · 无 profile(未首登)→ 跳过'); continue }
|
||
|
||
const credPath = join(u.home_dir, '.credentials.yaml')
|
||
const patchPath = join(dir, 'cordis.patch.yml')
|
||
const pkg0 = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||
const hasDep = Object.keys(pkg0.dependencies ?? {}).includes(PKG)
|
||
const inBundles = (pkg0.dsh?.profile?.bundles ?? []).includes(PKG)
|
||
const staged = join(u.home_dir, '.dsh-stage', basename(artifact))
|
||
|
||
console.log(` [计划] 凭据 ${credPath} → refs.${KEY_ENV}`)
|
||
console.log(` [计划] patch ${patchPath} → 追加 fetchProvider: http 覆写段`)
|
||
console.log(` [计划] 安装 ${PKG}(现 deps=${hasDep ? '有' : '无'} / bundles=${inBundles ? '有' : '无'})`)
|
||
console.log(` [计划] 停实例 scope:${RESTART ? '是' : '否'}`)
|
||
if (!APPLY) continue
|
||
|
||
// 1) 先写 key(顺序不可颠倒)
|
||
if (!existsSync(credPath)) { console.log(` ✗ 缺凭据文件 ${credPath} → 跳过该用户`); continue }
|
||
const credBackup = `${credPath}.bak-anysearch`
|
||
if (!existsSync(credBackup)) { copyFileSync(credPath, credBackup); chmodSync(credBackup, 0o600) }
|
||
try {
|
||
const credAction = ensureCredential(credPath, KEY)
|
||
chownSync(credPath, u.uid, u.uid)
|
||
chmodSync(credPath, 0o600)
|
||
console.log(` ✓ 凭据 ${credAction}(已恢复 600 + uid ${u.uid})`)
|
||
} catch (err) {
|
||
console.log(` ✗ 凭据写入失败:${err.message} → 跳过该用户(插件未装,避免无 key 窗口)`)
|
||
continue
|
||
}
|
||
|
||
// 2) 再写 profile patch(保留本地 fetch)
|
||
if (existsSync(patchPath)) {
|
||
const patchBackup = `${patchPath}.bak-anysearch`
|
||
if (!existsSync(patchBackup)) copyFileSync(patchBackup === patchPath ? patchPath : patchPath, patchBackup)
|
||
const patchAction = ensurePatch(patchPath)
|
||
chownSync(patchPath, u.uid, u.uid)
|
||
console.log(` ✓ patch ${patchAction}`)
|
||
} else {
|
||
console.log(` ⚠ 无 ${patchPath} → 跳过覆写(fetch 将跟随插件默认走 anysearch)`)
|
||
}
|
||
|
||
// 3) 装插件
|
||
try {
|
||
const pruned = pruneBrokenFileDeps(pkgPath)
|
||
if (pruned.length > 0) {
|
||
console.log(` · 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
|
||
try { chownSync(pkgPath, u.uid, u.uid) } catch { /* 尽力而为 */ }
|
||
}
|
||
const stageDir = join(u.home_dir, '.dsh-stage')
|
||
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||
if (!existsSync(staged)) copyFileSync(artifact, staged)
|
||
chmodSync(staged, 0o444)
|
||
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
|
||
const legacyStore = existingStoreDir(dir)
|
||
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
|
||
const legacyCache = join(ws, '.cache', 'pnpm')
|
||
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
|
||
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||
const args = [
|
||
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
|
||
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
|
||
'--store-dir', storeDir, '--cache-dir', cacheDir,
|
||
]
|
||
if (isRoot) args.push('-w')
|
||
args.push(`file:${staged}`)
|
||
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
|
||
console.log(` ✓ 已安装 ${PKG}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'})`)
|
||
const final = reconcileBundles(dir)
|
||
console.log(` ✓ bundles=${final.length}(含 ${PKG}: ${final.includes(PKG)})`)
|
||
} catch (err) {
|
||
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
|
||
console.log(` ✗ 安装失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
|
||
continue
|
||
}
|
||
|
||
// 4) 停实例(新 bundle 才生效)
|
||
if (RESTART) {
|
||
const n = stopInstance(u.uid)
|
||
console.log(` ✓ 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
|
||
} else {
|
||
console.log(' · 未停实例:bundle 尚未生效,需后续重启')
|
||
}
|
||
}
|
||
console.log('\ndone')
|