Files
deta-agent-plugins/plugins/writer/bin/deta-login.mjs
T
ringo 7d3f9e2933
CI / contract (push) Has been cancelled
refactor(plugin)!: 插件改名 studio、新增 writer、统一本机 stdio 管道
命名结构(品牌=市场,产品=插件):市场名保持 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。
2026-09-20 11:56:23 +08:00

369 lines
14 KiB
JavaScript
Executable File
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
/**
* 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));
});