#!/usr/bin/env node // 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务 // 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer // 详见 workflow: .gitea/workflows/sync-registry.yml // // 用法: // REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \ // REGISTRY_MCP_TOKEN= \ // REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \ // node scripts/sync-registry.mjs [--dry-run] // // 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。 // 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。 const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp' const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || '' const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w' const IDC = process.env.REGISTRY_IDC || '1' const ENV = process.env.REGISTRY_ENVIRONMENT || '1' const DRY_RUN = process.argv.includes('--dry-run') // CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key) const SECRET_MAP = [ { env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' }, { env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' }, { env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' }, { env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' }, { env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' }, { env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' }, { env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' }, ] if (!MCP_TOKEN) { console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)') process.exit(2) } let sessionId = null let nextId = 0 function parseSse(text) { let data = '' for (const line of text.split(/\r?\n/)) { if (line.startsWith('data:')) data += line.slice(5).trim() } if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`) const obj = JSON.parse(data) if (obj.error) { const e = new Error(`${obj.error.code}: ${obj.error.message}`) e.mcpError = obj.error throw e } return obj } async function rpc(method, params) { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', } if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}` if (sessionId) headers['mcp-session-id'] = sessionId const res = await fetch(MCP_URL, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }), }) const sid = res.headers.get('mcp-session-id') if (sid) sessionId = sid const text = await res.text() return parseSse(text) } async function callTool(name, args) { const payload = await rpc('tools/call', { name, arguments: args }) if (payload.result?.isError) { throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`) } const sc = payload.result?.structuredContent if (sc !== undefined && sc !== null) return sc const content = payload.result?.content || [] const text = content.map((c) => c.text || '').join('\n').trim() if (text) { try { return JSON.parse(text) } catch { return { text } } } return payload.result || {} } async function main() { console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`) // 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header) await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'iboard-ci', version: '1.0.0' }, }) // 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复 const mode = (await callTool('get_mode', {})).mode console.log(`当前模式: ${mode}`) const needEdit = mode !== 'edit' if (needEdit) { if (DRY_RUN) { console.log('(dry-run) 待切换到 edit 模式') } else { await callTool('set_mode', { mode: 'edit' }) console.log('已临时切换为 edit 模式') } } try { // 3) 拉取现有配置, 键为 group/idc/environment/key const list = await callTool('list_configs', { projectId: PROJECT_ID }) const configs = Array.isArray(list.configs) ? list.configs : list const existing = new Map() for (const c of configs || []) { if (!c || !c.key) continue existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c) } // 4) 逐个 upsert let updated = 0 let created = 0 let skipped = 0 for (const { env, key, group, type, description } of SECRET_MAP) { const value = process.env[env] if (value === undefined || value === '') { console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`) skipped++ continue } const found = existing.get(`${IDC}|${ENV}|${group}|${key}`) const target = `${group}/${key}` if (found) { if (DRY_RUN) { console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`) } else { await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value }) console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`) } updated++ } else { if (DRY_RUN) { console.log(`- [dry-run] create ${target} <- ${env}`) } else { await callTool('create_config', { projectId: PROJECT_ID, key, value, type, description, idc: IDC, environment: ENV, group, }) console.log(`- [create] ${target} <- ${env}`) } created++ } } console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`) } finally { // 5) 恢复原模式 (本次未实际切模式时无操作) if (needEdit && !DRY_RUN) { await callTool('set_mode', { mode }) console.log(`已恢复模式: ${mode}`) } } } main().catch((err) => { console.error(`\n同步失败: ${err.message}`) process.exit(1) })