#!/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 /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 服务地址,缺省 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)); });