Files
deta-agent-plugins/plugins/studio/bin/deta-cli.mjs
T
ringo 3ab20c23b0
CI / contract (push) Has been cancelled
feat(runtime): 领域服务运行时随插件分发 + 启动器自动供给(修 P0:同事机器无开发检出就起不来)
根因:启动器的运行时解析第三顺位是开发检出 ~/Dev/ant/ant-copilot,同事机器没有该目录 → 插件装上、MCP 起不来;而私有 registry 匿名读返回 401,走 npm 安装等于要求每台机器都配 Gitea token。

改法:① 打包产物随插件分发(plugins/<产品>/runtime/,3.96MB 自包含单文件 + manifest 记录来源版本/提交/大小/sha256);② 启动器每次拉起比对 sha256,缺失或被改动即从插件包安装到 ~/.deta/runtime(首次启动零操作、升级自动同步);③ 新增 deta install-runtime(显式安装 + 补装可选依赖 @resvg/resvg-js + 装完立刻做一次真实 MCP 握手自证),status/doctor 显示运行时来源与预览图依赖状态;④ 契约校验加 E6:运行时包必须自洽(sha256/字节数/manifest.pluginVersion 与插件版本一致),拦住“升版本忘重跑 sync-runtime”。

实测:全新 HOME(模拟同事机器、无开发检出、无凭据)下供给探针 10/10 通过 —— 首次启动自动安装、启动行指向沙箱运行时、install-runtime 握手 39 个工具、重复安装幂等、运行时被改动自动修复、插件包缺运行时两处都给打包缺陷指引;已装副本 0.3.14 独立验证(含 npm 自动补装 @resvg 2 包)。studio 0.3.14 / writer 0.1.5。
2026-09-20 16:40:33 +08:00

296 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } 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(launcher, 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(launcher, profile) {
return new Promise((resolveP) => {
const child = spawn('bash', [launcher, '--profile', profile], { 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(launcher, 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);
}
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 '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 status 查看凭据来源 / 运行时 / 工作区
deta doctor 端到端自检(运行时、凭据、GSA、MCP 握手)
运行时通常无需手动安装:启动器会在插件升级后自动同步到 ~/.deta/runtime。
登录后开新会话即可让宿主读到凭据(无需 source / launchctl / 重启应用)。`);
break;
default:
console.error(`未知命令:${cmd}(可用:login / install-runtime / status / doctor)`);
process.exit(2);
}