#!/usr/bin/env node /** * bin/deta-cli.mjs — DETA 命令行门面(登录 / 自检) * * 为什么需要:登录助手原先只在插件目录里(`<插件缓存>/bin/deta-login.mjs`), * 普通用户既不知道插件装在哪,也没有稳定入口;工具回执里让用户「跑登录助手」时, * 给不出一个可复制的命令。启动器会把本文件注册为 `~/.deta/bin/deta`(幂等维护), * 于是各处统一口径就变成: * * ~/.deta/bin/deta login # 登录 deta.city(写 ~/.deta/credentials.json 与 env) * ~/.deta/bin/deta status # 看凭据来源 / 运行时 / 工作区 * ~/.deta/bin/deta doctor # 端到端自检(运行时、凭据、编译服务、MCP 握手) * * 纪律:本文件**永不打印令牌**,只报告来源与长度;密码/验证码一律由 deta-login.mjs * 在用户自己的终端里交互读取(不回显)。 */ import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { dirname, join, resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const HOME = homedir(); const DETA_DIR = join(HOME, '.deta'); /** 产品档位:优先读插件清单的 name——开发态是 `plugins/studio`,而宿主缓存布局是 * `<市场>/<插件>/<版本>/`,从路径取目录名会把版本号当成产品名(实测踩过)。 */ function detectProfile() { if (process.env.DETA_PROFILE) return process.env.DETA_PROFILE; try { const manifest = JSON.parse(readFileSync(join(HERE, '..', '.codex-plugin', 'plugin.json'), 'utf8')); if (typeof manifest?.name === 'string' && manifest.name) return manifest.name; } catch { /* 清单不可读时回落目录名(仅开发态正确) */ } return basename(join(HERE, '..')); } const PROFILE = detectProfile(); function readToken() { if (process.env.DETA_TOKEN) return { source: '环境变量 DETA_TOKEN', token: process.env.DETA_TOKEN }; const envPath = join(DETA_DIR, 'env'); if (existsSync(envPath)) { const m = /DETA_TOKEN=('([^']*)'|"([^"]*)"|(\S+))/.exec(readFileSync(envPath, 'utf8')); const token = (m?.[2] ?? m?.[3] ?? m?.[4] ?? '').trim(); if (token) return { source: envPath, token }; } const credPath = join(DETA_DIR, 'credentials.json'); if (existsSync(credPath)) { try { const c = JSON.parse(readFileSync(credPath, 'utf8')); if (typeof c.token === 'string' && c.token) return { source: credPath, token: c.token, meta: c }; } catch { /* 忽略损坏的凭据文件,按未登录处理 */ } } return { source: '', token: '' }; } function cmdLogin(argv) { const loginScript = join(HERE, 'deta-login.mjs'); if (!existsSync(loginScript)) { console.error(`✗ 找不到登录助手:${loginScript}`); process.exit(1); } // 直接交给登录助手,stdio 全继承:密码由用户在终端输入(本进程不经手) const child = spawn(process.execPath, [loginScript, ...argv], { stdio: 'inherit' }); child.on('exit', (code) => process.exit(code ?? 1)); } /** * 运行时供给:插件包自带打包产物 → 安装到 `~/.deta/runtime`。 * * 为什么随插件分发而不是 npm 安装:`@ringoarch/ant-copilot` 在私有 registry 上,匿名读 401, * 走 npm 等于要求每台同事机器都配 token。打包产物是自包含单文件,插件包带上即可「装插件就能用」。 * 启动器每次拉起都会做同样的幂等检查(缺失/不一致就补)——本命令是它的**显式版 + 自证版**, * 用于首次核对、补装可选依赖、以及排障(装完立刻做一次真实 MCP 握手)。 */ /** 插件包内的运行时位置 */ function bundledRuntime() { const dir = join(HERE, '..', 'runtime'); return { dir, entry: join(dir, 'dist', 'deta-mcp.mjs'), manifestPath: join(dir, 'manifest.json') }; } function sha256File(p) { try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return ''; } } function readJson(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } } /** 运行时溯源描述(给 status / doctor 用;永不打印凭据) */ function runtimeProvenance(root) { const m = readJson(join(root, 'manifest.json')); if (!m) return ''; const sha = sha256File(join(root, 'dist', 'deta-mcp.mjs')); const verdict = sha && sha === m.sha256 ? 'sha 一致' : 'sha 不一致(运行时被改动或拷贝不完整,跑 deta install-runtime --force 重装)'; const size = typeof m.bytes === 'number' ? ` · ${(m.bytes / 1048576).toFixed(2)}MB` : ''; return `${m.source ?? '?'}@${m.version ?? '?'} (${m.gitSha ?? '?'})${size} · ${verdict}`; } /** 候选预览图的栅格化依赖(可选):缺失只影响候选预览图 */ function resvgReady(root) { return existsSync(join(root, 'node_modules', '@resvg', 'resvg-js', 'package.json')); } async function cmdInstallRuntime(argv) { const force = argv.includes('--force'); const noResvg = argv.includes('--no-resvg'); const b = bundledRuntime(); if (!existsSync(b.entry)) { console.error(`✗ 插件包内没有运行时:${b.entry}`); console.error(' 这是打包缺陷(插件应由 scripts/sync-runtime.mjs 带上运行时)。'); console.error(' 处置:重装插件(codex plugin remove <插件>@deta && codex plugin add <插件>@deta);'); console.error(' 开发态则在插件仓执行 node scripts/sync-runtime.mjs。'); process.exit(1); } const manifest = readJson(b.manifestPath) ?? {}; const srcSha = sha256File(b.entry); if (manifest.sha256 && srcSha !== manifest.sha256) { console.error(`✗ 插件包内运行时与 manifest 不一致(包内 sha ${String(srcSha).slice(0, 12)}… ≠ manifest ${String(manifest.sha256).slice(0, 12)}…)`); console.error(' 处置:在插件仓重跑 node scripts/sync-runtime.mjs 并重装插件。'); process.exit(1); } const target = join(DETA_DIR, 'runtime'); const targetEntry = join(target, 'dist', 'deta-mcp.mjs'); const installedSha = sha256File(targetEntry); const same = installedSha && installedSha === srcSha; const sizeMb = (statSync(b.entry).size / 1048576).toFixed(2); if (same && !force) { console.log(`✓ 运行时已是最新:${target}(${sizeMb}MB · ${b.manifestPath ? `来自 ${manifest.source ?? '?'}@${manifest.version ?? '?'} (${manifest.gitSha ?? '?'})` : ''})`); } else { mkdirSync(join(target, 'dist'), { recursive: true }); copyFileSync(b.entry, targetEntry); if (existsSync(b.manifestPath)) copyFileSync(b.manifestPath, join(target, 'manifest.json')); const verify = sha256File(targetEntry); if (verify !== srcSha) { console.error(`✗ 安装后校验失败:${targetEntry} 与源不一致`); process.exit(1); } console.log(`✓ 已安装运行时到 ${target}(${sizeMb}MB · sha ${srcSha.slice(0, 12)}… · 来自 ${manifest.source ?? '?'}@${manifest.version ?? '?'} (${manifest.gitSha ?? '?'}))`); } // 可选依赖:候选预览图的 SVG 栅格化(缺了只是没有预览图,不影响其他能力) if (noResvg) { console.log('· 已按 --no-resvg 跳过 @resvg/resvg-js(候选预览图不可用)'); } else if (resvgReady(target) && !force) { console.log('✓ 候选预览图依赖 @resvg/resvg-js 已就绪'); } else { console.log('· 正在安装候选预览图依赖 @resvg/resvg-js(可选,需要联网访问公共 npm)…'); const r = spawnSync('npm', ['i', '--prefix', target, '--no-save', '--no-audit', '--no-fund', '--loglevel=error', '@resvg/resvg-js@^2'], { stdio: 'inherit' }); if (r.error || r.status !== 0) { console.log('! @resvg/resvg-js 未装上(自动安装失败)——其余能力不受影响,仅候选预览图不可用。'); console.log(` 手动补装:npm i --prefix "${target}" @resvg/resvg-js@^2`); if (r.error) console.log(` 原因:${r.error.message}`); } else { console.log('✓ 候选预览图依赖 @resvg/resvg-js 已就绪'); } } // 装完立刻做一次真实 MCP 握手——「装上了」不等于「起得来」 const launcher = join(HERE, 'deta-mcp.sh'); const r = await smokeMcp('bash', [launcher, '--profile', PROFILE]); if (r.ok) { console.log(`✓ MCP 握手通过(--profile ${PROFILE}):${r.tools} 个工具${r.tokenLine ? ` · ${r.tokenLine.trim()}` : ''}`); console.log('\n宿主里的下一步:退出并重启宿主应用(或开新会话)即可看到工具。'); } else { console.error(`✗ MCP 握手失败:${r.detail}`); console.error(' 排障:~/.deta/bin/deta doctor;仍不行时看启动器输出的运行时路径是否正确。'); process.exit(1); } } function cmdStatus() { const { source, token, meta } = readToken(); console.log(`凭据:${token ? `已存在(来源 ${source},长度 ${token.length})` : '未登录'}`); if (meta?.username) console.log(`账号:${meta.username}(登录于 ${meta.loginAt ?? '未知'})`); console.log(`工作区:${process.env.DETA_WORKSPACE || join(DETA_DIR, PROFILE, 'workspace')}`); const runtime = resolveRuntime(); console.log(`运行时:${runtime || '未找到(插件包自带运行时,正常应由启动器自动安装到 ~/.deta/runtime)'}`); if (runtime) { const prov = runtimeProvenance(runtime); console.log(` · 来源:${prov || '开发检出(非打包运行时,仅开发机可见)'}`); console.log(` · 候选预览图依赖 @resvg/resvg-js:${resvgReady(runtime) ? '已就绪' : '缺失(跑 deta install-runtime 补装;不影响其他能力)'}`); } if (!token) console.log('\n未登录时云类工具(项目列表 / 同步 / 打开 Studio)会返回 auth_required;跑 `deta login` 即可。'); } /** 运行时解析,与 bin/deta-mcp.sh 保持同一顺序 */ function resolveRuntime() { const candidates = [ process.env.DETA_SERVER_ROOT || '', join(DETA_DIR, 'runtime'), join(HOME, 'Dev/ant/ant-copilot'), ].filter(Boolean); for (const c of candidates) { if (existsSync(join(c, 'dist/deta-mcp.mjs')) || existsSync(join(c, 'dist/src/headless/mcp-server.js'))) return c; } return ''; } function smokeMcp(cmd, args) { return new Promise((resolveP) => { const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = ''; let err = ''; let done = false; const finish = (verdict) => { if (!done) { done = true; child.kill(); resolveP(verdict); } }; child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { err += d; }); child.on('error', (e) => finish({ ok: false, detail: `无法启动:${e.message}` })); const send = (o) => { try { child.stdin.write(JSON.stringify(o) + '\n'); } catch { /* 进程已退出 */ } }; send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'deta-doctor', version: '1' } } }); setTimeout(() => send({ jsonrpc: '2.0', method: 'notifications/initialized' }), 150); setTimeout(() => send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), 400); setTimeout(() => { const lines = out.split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); const init = lines.find((x) => x.id === 1)?.result; const list = lines.find((x) => x.id === 2)?.result; const tokenLine = err.split('\n').find((l) => l.includes('云令牌')) || ''; if (!init) return finish({ ok: false, detail: 'MCP 未应答 initialize' }); if (!list) return finish({ ok: false, detail: 'MCP 未应答 tools/list' }); finish({ ok: true, server: init.serverInfo, tools: (list.tools ?? []).length, tokenLine }); }, 5000); }); } async function cmdDoctor() { let failed = 0; const say = (ok, label, detail) => { console.log(`${ok ? '✓' : '✗'} ${label}${detail ? ` —— ${detail}` : ''}`); if (!ok) failed += 1; }; const runtime = resolveRuntime(); say(Boolean(runtime), '领域服务运行时', runtime || '未找到:插件包自带时应由启动器自动装到 ~/.deta/runtime;否则设 DETA_SERVER_ROOT'); if (runtime) { console.log(` · 来源:${runtimeProvenance(runtime) || '开发检出(非打包运行时)'}`); const hasResvg = resvgReady(runtime); console.log(` · 候选预览图依赖 @resvg/resvg-js:${hasResvg ? '已就绪' : '缺失 —— 跑 deta install-runtime 补装(只影响候选预览图)'}`); } const { source, token } = readToken(); say(Boolean(token), '云凭据', token ? `已存在(来源 ${source},长度 ${token.length})` : '未登录:跑 `~/.deta/bin/deta login`'); const gsa = process.env.GSA_BASE_URL || 'http://localhost:8000'; let gsaOk = false; try { const res = await fetch(`${gsa}/health`, { signal: AbortSignal.timeout(4000) }); gsaOk = res.ok; } catch { gsaOk = false; } say(gsaOk, '编译服务 GSA', gsaOk ? `${gsa}/health 通` : `${gsa} 不可达(编译/统计类工具会失败)`); if (runtime) { const launcher = join(HERE, 'deta-mcp.sh'); const profile = PROFILE; const r = await smokeMcp('bash', [launcher, '--profile', profile]); say(r.ok, `MCP 握手(--profile ${profile})`, r.ok ? `${r.tools} 个工具 · ${r.server?.name ?? '?'}${r.tokenLine ? ` · ${r.tokenLine.trim()}` : ''}` : r.detail); } console.log(failed ? `\n自检完成:${failed} 项需要处理。` : '\n自检完成:全部通过。'); process.exit(failed ? 1 : 0); } /** * 接入 WorkBuddy:把本档位的 MCP 以 stdio 写进 ~/.workbuddy/mcp.json,并把领域技能装进 * ~/.workbuddy/skills/。WorkBuddy 的自定义 MCP 支持 command/args/env 形态 * (取证:其配置哈希 calculateConfigHash 先判 entry.command),本机 stdio = 与 Codex 同款 * full 工具面。--http 回落为远程引擎面(适合没装本机运行时的机器)。 */ async function cmdInstallWorkbuddy(argv) { const useHttp = argv.includes('--http'); const wbDir = join(HOME, '.workbuddy'); if (!existsSync(wbDir)) { console.error('✗ 未检测到 WorkBuddy(没有 ~/.workbuddy 目录)。'); console.error(' 请先安装并启动一次 WorkBuddy,再重跑本命令。'); process.exit(1); } // 1) 稳定入口(与启动器维护的那份内容一致;启动器未跑过时这里先写出来) const binDir = join(DETA_DIR, 'bin'); const shim = join(binDir, `deta-mcp-${PROFILE}`); const launcher = join(HERE, 'deta-mcp.sh'); const shimBody = '#!/bin/bash\n# DETA MCP 稳定入口(由插件自动维护,勿手工编辑)\n' + 'export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"\n' + `exec bash "${launcher}" --profile ${PROFILE} "$@"\n`; mkdirSync(binDir, { recursive: true }); writeFileSync(shim, shimBody); chmodSync(shim, 0o755); // 2) 合并 ~/.workbuddy/mcp.json(备份 → 合并 → 写完再校验) const mcpPath = join(wbDir, 'mcp.json'); const serverName = `deta_${PROFILE}`; const entry = useHttp ? { type: 'streamable-http', url: 'https://deta.city/mcp-api/mcp', disabled: false } : { type: 'stdio', command: shim, args: [], disabled: false }; let cfg = { mcpServers: {} }; if (existsSync(mcpPath)) { const raw = readFileSync(mcpPath, 'utf8'); try { cfg = JSON.parse(raw); } catch (e) { console.error(`✗ ${mcpPath} 不是合法 JSON:${e.message}`); console.error(' 未做任何改动;请先手工修复或备份后删除该文件。'); process.exit(1); } copyFileSync(mcpPath, `${mcpPath}.bak-${Date.now()}`); } if (!cfg.mcpServers || typeof cfg.mcpServers !== 'object') cfg.mcpServers = {}; // 旧的远程条目与新的本机条目指向同一套工具 → 并存会出双工具面,替换之 const replaced = []; if (!useHttp && cfg.mcpServers.deta_mcp) { delete cfg.mcpServers.deta_mcp; replaced.push('deta_mcp(远程引擎面)'); } if (cfg.mcpServers[serverName]) replaced.push(`${serverName}(旧配置)`); cfg.mcpServers[serverName] = entry; writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + '\n'); console.log(`✓ MCP 已写入 ${mcpPath}:${serverName}(${useHttp ? '远程 streamable-http' : `本机 stdio → ${shim}`})`); if (replaced.length) console.log(` · 已替换旧条目:${replaced.join('、')}(防同服务双工具面)`); // 3) 技能:把插件内的全部技能原样装进 WB 技能目录(单一事实源,不另写一份; // 各档位的技能不同——studio 是 aml-design,writer 是 report-authoring) const skillsSrc = join(HERE, '..', 'skills'); const skillsDst = join(wbDir, 'skills'); const logo = join(HERE, '..', 'assets', 'logo.svg'); let installedSkills = 0; if (existsSync(skillsSrc)) { for (const name of readdirSync(skillsSrc)) { const src = join(skillsSrc, name); if (!existsSync(join(src, 'SKILL.md'))) continue; const dst = join(skillsDst, name); cpSync(src, dst, { recursive: true }); if (existsSync(logo)) copyFileSync(logo, join(dst, 'icon.svg')); installedSkills += 1; console.log(`✓ 技能已安装到 ${dst}`); } } if (!installedSkills) { console.log(`! 插件包内未找到技能(${skillsSrc} 为空,打包缺陷);已跳过技能安装`); } // 4) 自证:用 WorkBuddy 将要执行的同一条命令做一次真实握手 if (!useHttp) { const r = await smokeMcp('bash', [shim]); if (r.ok) { console.log(`✓ 握手自证通过:${r.tools} 个工具(${r.server?.name ?? '?'})`); } else { console.error(`✗ 握手自证失败:${r.detail}`); console.error(' 排障:~/.deta/bin/deta doctor'); process.exit(1); } } else { try { const res = await fetch('https://deta.city/mcp-api/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', }); const challenge = res.headers.get('www-authenticate') ?? ''; if (res.status === 401 && challenge.includes('resource_metadata')) { console.log('✓ 远程端点在线且 OAuth 通道在位(401 + resource_metadata)'); } else { console.log(`! 远程端点响应异常(HTTP ${res.status})——请检查网络后重试`); } } catch (e) { console.log(`! 远程端点不可达:${e.message}`); } } console.log('\nWorkBuddy 里的下一步:'); console.log(' 1) 退出并重启 WorkBuddy(或在设置里刷新 MCP 列表)'); console.log(` 2) 首次会弹「是否信任 MCP 服务器 ${serverName}」→ 点允许(路径稳定,插件升级后不再询问)`); console.log(' 3) 开新对话,说「列出 deta 的能力」核对工具面'); console.log(' 4) 首次使用云能力时调 login 工具走浏览器授权(与 Codex 同一套,不需要终端)'); } const [cmd = 'help', ...rest] = process.argv.slice(2); switch (cmd) { case 'login': cmdLogin(rest); break; case 'status': cmdStatus(); break; case 'doctor': await cmdDoctor(); break; case 'install-runtime': await cmdInstallRuntime(rest); break; case 'install-workbuddy': await cmdInstallWorkbuddy(rest); break; case 'help': case '--help': case '-h': console.log(`DETA 命令行(插件自动注册为 ~/.deta/bin/deta) deta login [--identifier <账号>] [--email-code <邮箱>] 登录 deta.city(凭据写入 ~/.deta/) deta install-runtime [--force] [--no-resvg] 安装/更新领域服务运行时 + 可选预览图依赖 deta install-workbuddy [--http] 接入 WorkBuddy(写 mcp.json + 装技能 + 握手自证) deta status 查看凭据来源 / 运行时 / 工作区 deta doctor 端到端自检(运行时、凭据、GSA、MCP 握手) 运行时通常无需手动安装:启动器会在插件升级后自动同步到 ~/.deta/runtime。 登录后开新会话即可让宿主读到凭据(无需 source / launchctl / 重启应用)。`); break; default: console.error(`未知命令:${cmd}(可用:login / install-runtime / install-workbuddy / status / doctor)`); process.exit(2); }