refactor(plugin)!: 插件改名 studio、新增 writer、统一本机 stdio 管道
CI / contract (push) Has been cancelled

命名结构(品牌=市场,产品=插件):市场名保持 deta;插件 deta → studio(displayName DETA Studio),新增 writer(DETA Writer)。安装标识 studio@deta / writer@deta;MCP 服务名 studio_mcp / writer_mcp,可同时安装。plugins/deta → plugins/studio(git mv 保留历史),新增 plugins/writer。

连接形态统一为单一本机 stdio 管道:.mcp.json 与 cordis.patch.yml 均声明 stdio → bin/deta-mcp.sh --profile <产品>,并按官方插件写法补 cwd: "."(缺它时 Cwd=- ,相对命令按宿主进程 cwd 解析会静默起不来);启动器新增 --profile 档位(工作区 ~/.deta/<产品>/workspace、工具面缺省 full),运行时按 DETA_SERVER_ROOT → ~/.deta/runtime → 开发检出解析,入口优先打包产物;DETA_TOKEN 可选注入(缺省时云类工具返回 auth_required)。原远程端点声明(含 OAuth/401 段落)从插件移除。

文档与治理:根 README 与两个插件 README 改为单一管道口径;技能 aml-design 对齐本机全量面;新增 scripts/check-plugin-contract.mjs(E1 禁工具清单 / E2 必须给出 list_capabilities / E3 禁解释内部实现 / E4 市场条目一致性 / W1 开发检出依赖)与 .gitea/workflows/ci.yml。版本:studio 0.3.1、writer 0.1.0。
This commit is contained in:
ringo
2026-09-20 11:56:23 +08:00
parent 80fbd162d3
commit 7d3f9e2933
28 changed files with 1354 additions and 418 deletions
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env node
/**
* DETA 登录助手(Node ESM,零第三方依赖)
*
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
*
* node bin/deta-login.mjs [--identifier <用户名|邮箱|手机号>] [--base-url https://deta.city]
* node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(无密码账号用)
*
* 标识(identifier)就是你在门户登录页填的那一个:
* 用户名 / 已验证邮箱 / 已验证手机号,服务端都会解析到同一账号。
* 注意 deta.city 门户的密码登录端点与 RuoYi 内置的 /login 不是同一个,
* 本助手统一走 /login(已存在账号的密码校验),不会触发「登录即注册」。
*
* 流程:
* 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册;
* 2. 读入密码(不回显);POST <base>/prod-api/login { username: 标识, password };
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
* 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。
*
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
* 不写日志、不写 stderr、不写其它位置。
*/
import { mkdir, writeFile, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
const DEFAULT_BASE_URL = 'https://deta.city';
const REQUEST_TIMEOUT_MS = 20_000;
function fail(message) {
console.error(`[deta-login] ${message}`);
process.exit(1);
}
const USAGE = [
'用法:node bin/deta-login.mjs [选项]',
' -u, --identifier <值> 登录标识(用户名/已验证邮箱/已验证手机号);省略则交互式输入',
' --email-code <邮箱> 用邮箱验证码登录(门户只用验证码登录、未设密码时用)',
' --base-url <url> 服务地址,缺省 https://deta.city',
' -h, --help 显示本帮助',
].join('\n');
/** 解析命令行;缺省 baseUrl 取 DETA_BASE_URL 或 https://deta.city,末尾斜杠去掉 */
function parseArgs(argv) {
const options = {
baseUrl: process.env.DETA_BASE_URL || DEFAULT_BASE_URL,
identifier: '',
emailCode: '',
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const takeValue = (name) => {
const next = argv[i + 1];
if (!next) fail(`${name} 缺少取值`);
i++;
return next;
};
if (arg === '-u' || arg === '--identifier') {
options.identifier = takeValue(arg);
} else if (arg.startsWith('--identifier=')) {
options.identifier = arg.slice('--identifier='.length);
} else if (arg === '--email-code') {
options.emailCode = takeValue(arg);
} else if (arg.startsWith('--email-code=')) {
options.emailCode = arg.slice('--email-code='.length);
} else if (arg === '--base-url') {
options.baseUrl = takeValue(arg);
} else if (arg.startsWith('--base-url=')) {
options.baseUrl = arg.slice('--base-url='.length);
} else if (arg === '-h' || arg === '--help') {
console.log(USAGE);
process.exit(0);
} else {
fail(`未知参数:${arg}\n${USAGE}`);
}
}
options.baseUrl = options.baseUrl.trim().replace(/\/+$/, '');
options.identifier = options.identifier.trim();
options.emailCode = options.emailCode.trim();
return options;
}
/**
* 读一行输入。hidden=true 时不回显(密码);
* 非 TTY(管道/CI)时同样按行读取,便于自动化。
*/
let pendingInput = '';
/**
* 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。
*
* 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理,
* 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此
* 一次粘贴「标识 + 密码」两行不会丢第二行。
*/
function readLine({ hidden }) {
return new Promise((resolve, reject) => {
const stdin = process.stdin;
const isTTY = Boolean(stdin.isTTY);
let buffer = pendingInput;
pendingInput = '';
let settled = false;
/** 从 buffer 切出一整行(含行尾判定),并把剩余数据留到 pendingInput;无完整行返回 null */
const splitLine = () => {
const index = buffer.search(/[\r\n]/);
if (index < 0) return null;
const line = buffer.slice(0, index);
const skip = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
pendingInput = buffer.slice(index + skip);
buffer = '';
return line;
};
const cleanup = () => {
stdin.removeListener('data', onData);
stdin.removeListener('end', onEnd);
stdin.removeListener('error', onError);
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
stdin.pause();
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
// TTY 下 raw mode 不回显回车,这里补一个换行;非 TTY(管道)不额外输出
if (isTTY) process.stdout.write('\n');
resolve(value);
};
const onData = (chunk) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
if (!isTTY) {
// 非交互:不回显,按行切分,剩余数据留给下一次读取
buffer += text;
const line = splitLine();
if (line !== null) finish(line);
return;
}
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
if (ch === '\n' || ch === '\r') {
// 本 chunk 换行符之后的字符属于后续读取(一次粘贴多行):存入 pendingInput 后立即返回
let next = i + 1;
if (ch === '\r' && chars[next] === '\n') next++; // CRLF 视作一个行尾
const rest = chars.slice(next).join('');
if (rest) pendingInput = rest + pendingInput;
return finish(buffer);
}
if (ch === '\u0003') {
// Ctrl-C
process.stdout.write('\n');
process.exit(130);
}
if (ch === '\u0004') return finish(buffer); // Ctrl-D
if (ch === '\u007f' || ch === '\b') {
if (buffer && !hidden) process.stdout.write('\b \b');
buffer = buffer.slice(0, -1);
continue;
}
buffer += ch;
if (!hidden) process.stdout.write(ch);
}
};
const onEnd = () => finish(buffer);
const onError = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
// 缓冲里已有完整行(上一次读取的遗留)→ 立即返回,不等待输入
const buffered = splitLine();
if (buffered !== null) {
if (isTTY) process.stdout.write('\n');
resolve(buffered);
return;
}
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
stdin.resume();
stdin.on('data', onData);
stdin.on('end', onEnd);
stdin.on('error', onError);
});
}
/** 单引号安全转义,供 `export DETA_TOKEN='...'` 使用 */
function shellSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
async function writePrivateFile(filePath, content) {
await writeFile(filePath, content, { mode: 0o600 });
// 已存在的文件不被 mode 改写,显式收紧
await chmod(filePath, 0o600).catch(() => undefined);
}
/** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */
const PHONE_REGEX = /^1[3-9]\d{9}$/;
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/;
function identifierField(identifier) {
if (EMAIL_REGEX.test(identifier)) return 'email';
if (PHONE_REGEX.test(identifier)) return 'phone';
return 'username';
}
/** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */
async function postJson(baseUrl, path, body) {
let response;
try {
response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
fail(`无法连接 ${baseUrl}${path}(${err instanceof Error ? err.message : String(err)})`);
}
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
return { response, payload };
}
function serverMessage(payload, response) {
return (payload && typeof payload.msg === 'string' && payload.msg) || `请求失败(HTTP ${response.status})`;
}
/**
* 匿名查询标识是否已注册;接口异常返回 null(不据此阻断登录)。
* 只填标识所属类型的那一个字段:服务端按 phone → email → username 取第一个非空字段。
*/
async function accountRegistered(baseUrl, identifier) {
const { response, payload } = await postJson(baseUrl, '/prod-api/login/check', {
[identifierField(identifier)]: identifier,
});
if (!response.ok || !payload || payload.code !== 200) return null;
return payload.registered === true;
}
/** 服务端提示 → 可执行的下一步 */
function loginHint(message, identifier) {
if (message.includes('用户不存在/密码错误')) {
return [
`该提示不区分「账号不存在」与「密码不匹配」(已确认 ${identifier} 是已注册标识,故为密码不符或标识与账号不对应)。`,
'请确认:',
' 1) 标识与门户登录页填写的一致(用户名 / 已验证邮箱 / 已验证手机号);',
' 2) 密码与门户一致(区分大小写,注意首尾空格);',
' 3) 若门户只用邮箱验证码登录、从未设过密码,改用 --email-code <邮箱>。',
].join('\n ');
}
if (message.includes('尚未验证')) {
return '该邮箱/手机号在账号里尚未标记为已验证:请先用验证码方式登录一次门户完成验证,再重试。';
}
if (message.includes('停用')) return '账号已被停用,请联系管理员。';
if (message.includes('未开放')) {
return '该登录方式未开放:改用已启用的方式(GET /prod-api/login/methods 可查当前启用项)。';
}
return '';
}
/** 邮箱验证码登录:发码 → 读入验证码 → 校验 */
async function loginByEmailCode(baseUrl, email) {
const send = await postJson(baseUrl, '/prod-api/login/email/send-code', { email });
if (!send.response.ok || send.payload?.code !== 200) {
fail(`发送验证码失败:${serverMessage(send.payload, send.response)}`);
}
process.stdout.write('邮箱验证码:');
const code = (await readLine({ hidden: false })).trim();
if (!code) fail('验证码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login/email', { email, code });
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
fail(`${serverMessage(payload, response)}\n ${loginHint(serverMessage(payload, response), email)}`.trimEnd());
}
return payload;
}
/** 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);token 只在 stdout 出现一次 */
async function writeCredentials(baseUrl, identifier, payload) {
const token = payload.token;
const loginUsername =
typeof payload.username === 'string' && payload.username ? payload.username : identifier;
const dir = join(homedir(), '.deta');
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700).catch(() => undefined);
const credentials = {
token,
username: loginUsername,
baseUrl,
loginAt: new Date().toISOString(),
};
const credentialsPath = join(dir, 'credentials.json');
const envPath = join(dir, 'env');
await writePrivateFile(credentialsPath, `${JSON.stringify(credentials, null, 2)}\n`);
await writePrivateFile(
envPath,
`# DETA MCP 凭据(由 bin/deta-login.mjs 生成,勿提交版本库)\n` +
`# 用法:source ~/.deta/env\n` +
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
);
console.log(`DETA_TOKEN=${token}`);
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
console.log(
`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`,
);
}
async function main() {
const { baseUrl, identifier: presetIdentifier, emailCode } = parseArgs(process.argv.slice(2));
process.stdout.write(`DETA 登录(${baseUrl})\n`);
if (emailCode) {
process.stdout.write(`邮箱验证码登录:${emailCode}\n`);
return writeCredentials(baseUrl, emailCode, await loginByEmailCode(baseUrl, emailCode));
}
let identifier = presetIdentifier;
if (identifier) {
process.stdout.write(`登录标识:${identifier}\n`);
} else {
process.stdout.write('登录标识(用户名/已验证邮箱/已验证手机号):');
identifier = (await readLine({ hidden: false })).trim();
}
if (!identifier) fail('登录标识不能为空');
// 匿名预检:区分「标识未注册」与「密码不匹配」,避免密码错时给出误导性提示
if ((await accountRegistered(baseUrl, identifier)) === false) {
fail(
`该标识在 ${baseUrl} 未注册:${identifier}\n` +
' 请填门户登录页上实际使用的标识(用户名 / 已验证邮箱 / 已验证手机号)。\n' +
' 邮箱验证码注册的账号,标识请填邮箱。',
);
}
process.stdout.write('密码:');
const password = await readLine({ hidden: true });
if (!password) fail('密码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login', {
username: identifier,
password,
});
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
const message = serverMessage(payload, response);
const hint = loginHint(message, identifier);
fail(hint ? `${message}\n ${hint}` : message);
}
return writeCredentials(baseUrl, identifier, payload);
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# DETA 产品插件的本机 MCP stdio 启动器(唯一入口,多产品共用)
#
# 两个宿主(Codex 插件 / dsh 组合包)都通过本脚本拉起同一个领域服务;
# 产品差异只由 --profile 决定(工作区默认位置、工具面),宿主侧只声明"调哪个脚本、带哪个档位"。
#
# 用法:deta-mcp.sh [--profile <studio|writer>]
#
# 依赖:node + 已构建的领域服务运行时
#
# 环境变量:
# DETA_SERVER_ROOT 领域服务运行时根目录(未设时自动查找,见下)
# DETA_WORKSPACE AML 工作目录(引擎侧工作区真值;缺省 ~/.deta/<profile>/workspace)
# ANT_HEADLESS_WORKSPACE 同上(兼容旧名,DETA_WORKSPACE 优先)
# DETA_TOKEN deta.city 门户令牌(可选;不注入则云类工具返回 auth_required)
# ANT_WORKSPACE_BRIDGE_URL 网关基址(可选;与项目 ID 一起启用 Studio 执行器中继)
# DETA_SURFACE 工具面覆盖(缺省 full)
set -euo pipefail
# ── 产品档位:宿主在声明里传 --profile;缺省 studio ──
PROFILE="${DETA_PROFILE:-writer}"
while [ $# -gt 0 ]; do
case "$1" in
--profile) PROFILE="${2:?--profile 需要一个值}"; shift 2 ;;
*) break ;;
esac
done
# ── 运行时解析:显式 DETA_SERVER_ROOT → 本机引擎包安装位置 → 本机开发检出(打印提示)──
ROOT="${DETA_SERVER_ROOT:-}"
if [ -z "$ROOT" ]; then
for cand in "$HOME/.deta/runtime" "$HOME/Dev/ant/ant-copilot"; do
if [ -f "$cand/dist/deta-mcp.mjs" ] || [ -f "$cand/dist/src/headless/mcp-server.js" ]; then ROOT="$cand"; break; fi
done
fi
if ! command -v node >/dev/null 2>&1; then
echo "[deta] 未找到 node,请先安装 Node.js" >&2
exit 1
fi
# 入口优先取 esbuild 打包形态:tsc 产物在 Node ESM 下会因依赖包的目录 import 直接失败
ENTRY=""
if [ -n "$ROOT" ]; then
if [ -f "$ROOT/dist/deta-mcp.mjs" ]; then
ENTRY="$ROOT/dist/deta-mcp.mjs"
elif [ -f "$ROOT/dist/src/headless/mcp-server.js" ]; then
ENTRY="$ROOT/dist/src/headless/mcp-server.js"
echo "[deta] 警告:回落到未打包产物,请先在该运行时目录执行 npm run build:mcp" >&2
fi
fi
if [ -z "$ENTRY" ]; then
echo "[deta] 未找到领域服务运行时(已尝试:DETA_SERVER_ROOT、\$HOME/.deta/runtime、\$HOME/Dev/ant/ant-copilot)" >&2
echo "[deta] 请安装本机引擎包,或设置 DETA_SERVER_ROOT 指向已构建的领域服务仓库" >&2
exit 1
fi
if [ "$ROOT" = "$HOME/Dev/ant/ant-copilot" ]; then
echo "[deta] 提示:使用本机开发检出 ${ROOT}(非打包运行时)" >&2
fi
# ── 工作目录:宿主不会传项目目录(cwd 通常是插件根),按产品档位定稳定位置 ──
WS="${DETA_WORKSPACE:-${ANT_HEADLESS_WORKSPACE:-$HOME/.deta/$PROFILE/workspace}}"
mkdir -p "$WS"
export ANT_HEADLESS_WORKSPACE="$WS"
# ── 工具面:产品档位缺省全量面;DETA_SURFACE 可覆盖(如 engine)──
export DETA_TOOL_SURFACE="${DETA_SURFACE:-full}"
echo "[deta] 档位=${PROFILE} 运行时=${ROOT} 工作区=${WS} 工具面=${DETA_TOOL_SURFACE} 云令牌=$([ -n "${DETA_TOKEN:-}" ] && echo 已注入 || echo 未注入)" >&2
exec node "$ENTRY" "$@"