CI / contract (push) Has been cancelled
根因:启动器的运行时解析第三顺位是开发检出 ~/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。
296 lines
15 KiB
JavaScript
296 lines
15 KiB
JavaScript
#!/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);
|
||
}
|