feat(plugin): 稳定命令行入口 ~/.deta/bin/deta(login / status / doctor)
CI / contract (push) Has been cancelled

为什么需要:登录助手原先只在插件目录里(宿主缓存路径 <缓存>/bin/deta-login.mjs),普通用户不知道插件装在哪;工具回执让用户「跑登录助手」时给不出可复制的命令。

实现:新增 bin/deta-cli.mjs(login 转交登录助手、status 看凭据来源/运行时/工作区、doctor 自检运行时+凭据+GSA health+MCP 握手,永不打印令牌);启动器每次拉起时幂等注册 ~/.deta/bin/deta(路径随插件更新自动刷新,幂等比较避免重复写);技能与三份 README 的登录口径统一为 \`~/.deta/bin/deta login\`。版本 studio 0.3.3 / writer 0.1.1。

实测:shim 生成与 help/status/doctor 均符合预期,doctor 准确报出唯一缺口(未登录)并握手成功 37 工具。
This commit is contained in:
ringo
2026-09-20 12:10:49 +08:00
parent e7d372f939
commit 54924cd4fc
14 changed files with 362 additions and 21 deletions
+155
View File
@@ -0,0 +1,155 @@
#!/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 } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
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');
/** 产品档位:由本文件所在插件目录名决定(studio / writer),DETA_PROFILE 可覆盖 */
const PROFILE = process.env.DETA_PROFILE || basename(join(HERE, '..'));
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));
}
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_SERVER_ROOT)'}`);
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');
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 'help':
case '--help':
case '-h':
console.log(`DETA 命令行(插件自动注册为 ~/.deta/bin/deta)
deta login [--identifier <账号>] [--email-code <邮箱>] 登录 deta.city(凭据写入 ~/.deta/)
deta status 查看凭据来源 / 运行时 / 工作区
deta doctor 端到端自检(运行时、凭据、GSA、MCP 握手)
登录后开新会话即可让宿主读到凭据(无需 source / launchctl / 重启应用)。`);
break;
default:
console.error(`未知命令:${cmd}(可用:login / status / doctor)`);
process.exit(2);
}
+15 -1
View File
@@ -68,6 +68,20 @@ export ANT_HEADLESS_WORKSPACE="$WS"
# ── 工具面:产品档位缺省全量面;DETA_SURFACE 可覆盖(如 engine)──
export DETA_TOOL_SURFACE="${DETA_SURFACE:-full}"
# ── 稳定入口:把命令行门面注册为 ~/.deta/bin/deta(幂等,路径随插件更新自动刷新)──
# 插件装在宿主缓存目录,用户看不见这个路径;而用户与工具回执都需要一条可复制的命令。
PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BIN_DIR="$HOME/.deta/bin"
SHIM="$BIN_DIR/deta"
SHIM_BODY="#!/bin/sh
# DETA 命令行门面(由插件启动器自动维护,勿手工编辑)
exec \"$(command -v node)\" \"$PLUGIN_ROOT/bin/deta-cli.mjs\" \"\$@\"
"
mkdir -p "$BIN_DIR" 2>/dev/null || true
if [ ! -f "$SHIM" ] || [ "$(cat "$SHIM" 2>/dev/null)" != "$SHIM_BODY" ]; then
printf '%s' "$SHIM_BODY" > "$SHIM" 2>/dev/null && chmod 0755 "$SHIM" 2>/dev/null || true
fi
# ── 云令牌:显式环境变量 → ~/.deta/env → ~/.deta/credentials.json ──
# 桌面应用(Finder/Dock 启动)不会继承终端的 export,也不是每个人都有 launchctl 注入,
# 因此登录助手写下的凭据必须由启动器自己解析——这是"登录一次即可用"的关键路径。
@@ -89,7 +103,7 @@ fi
if [ -n "${DETA_TOKEN:-}" ]; then
CLOUD_STATE="已注入(来源=${TOKEN_SOURCE})"
else
CLOUD_STATE="未注入——云类工具将返回 auth_required:运行 bin/deta-login.mjs 登录后开新会话"
CLOUD_STATE="未注入——云类工具将返回 auth_required:运行 ~/.deta/bin/deta login 登录后开新会话"
fi
echo "[deta] 档位=${PROFILE} 运行时=${ROOT} 工作区=${WS} 工具面=${DETA_TOOL_SURFACE} 云令牌=${CLOUD_STATE}" >&2