From d73e907ec452bfd408ba0c4e04b2d406f658bebc Mon Sep 17 00:00:00 2001 From: ringo Date: Thu, 10 Sep 2026 11:08:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(login):=20=E7=99=BB=E5=BD=95=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E4=B8=8E=E9=97=A8=E6=88=B7=E5=AF=B9=E9=BD=90=20+=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 端点:走 /login(已存在账号的密码校验),不走门户的 /login-or-register(会触发登录即注册) - 新增 -u/--identifier:标识可用 用户名/已验证邮箱/已验证手机号(服务端 LoginIdentifierResolver 解析) - 新增 --email-code:邮箱验证码登录(账号未设密码时的可用路径) - 登录前匿名预检 /login/check:区分「标识未注册」与「密码不匹配」,避免误导性提示 - 失败提示按服务端 msg 映射可执行下一步(未验证/停用/登录方式未开放) - 修复 readLine:TTY 下一次粘贴多行会丢弃换行之后的剩余字符(第二次读取挂起) --- plugins/deta/README.md | 19 ++- plugins/deta/bin/deta-login.mjs | 272 ++++++++++++++++++++++++-------- 2 files changed, 224 insertions(+), 67 deletions(-) diff --git a/plugins/deta/README.md b/plugins/deta/README.md index d0b4016..3514045 100644 --- a/plugins/deta/README.md +++ b/plugins/deta/README.md @@ -37,7 +37,24 @@ codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git codex plugin add deta@deta ``` -登录助手也支持自建/预发环境:`node bin/deta-login.mjs --base-url https://`。 +登录助手选项: + +```bash +node bin/deta-login.mjs -u <用户名|已验证邮箱|已验证手机号> # 免交互输入标识 +node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(未设密码的账号用) +node bin/deta-login.mjs --base-url https:// # 自建/预发环境 +``` + +**标识(identifier)填门户登录页上用的那一个**:用户名、已验证邮箱、已验证手机号,服务端都会解析到同一账号。 +助手先匿名调用 `/login/check` 预检标识是否已注册,再走 `/login` 做密码校验(不走门户的「登录即注册」端点, +不会误建账号)。失败时按服务端提示给出下一步: + +| 服务端提示 | 含义与处理 | +|------------|------------| +| `该标识在 未注册` | 标识不是门户上用的那一个;邮箱验证码注册的账号标识填邮箱 | +| `用户不存在/密码错误` | 服务端不区分「账号不存在」与「密码不匹配」;已确认标识存在,即密码不符,或改用 `--email-code` | +| `该邮箱/手机号尚未验证…` | 先用验证码方式登录一次门户完成验证 | +| `该登录方式未开放` | 用 `GET /prod-api/login/methods` 查当前启用项 | ## 安装(dsh) diff --git a/plugins/deta/bin/deta-login.mjs b/plugins/deta/bin/deta-login.mjs index c76898e..60560ee 100755 --- a/plugins/deta/bin/deta-login.mjs +++ b/plugins/deta/bin/deta-login.mjs @@ -4,13 +4,19 @@ * * 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用: * - * node bin/deta-login.mjs [--base-url https://deta.city] + * 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. 交互式读取用户名/密码(密码不回显); - * 2. POST /prod-api/login { username, password }; + * 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册; + * 2. 读入密码(不回显);POST /prod-api/login { username: 标识, password }; * 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600); - * 4. 失败 → 打印服务端 msg,退出码非 0。 + * 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。 * * 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里, * 不写日志、不写 stderr、不写其它位置。 @@ -28,26 +34,52 @@ function fail(message) { process.exit(1); } -/** `--base-url ` / `--base-url=`;缺省 https://deta.city,末尾斜杠去掉 */ -function parseBaseUrl(argv) { - let baseUrl = process.env.DETA_BASE_URL || DEFAULT_BASE_URL; +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]; - if (arg === '--base-url') { + const takeValue = (name) => { const next = argv[i + 1]; - if (!next) fail('--base-url 缺少取值'); - baseUrl = next; + 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=')) { - baseUrl = arg.slice('--base-url='.length); + options.baseUrl = arg.slice('--base-url='.length); } else if (arg === '-h' || arg === '--help') { - console.log('用法:node bin/deta-login.mjs [--base-url https://deta.city]'); + console.log(USAGE); process.exit(0); } else { - fail(`未知参数:${arg}`); + fail(`未知参数:${arg}\n${USAGE}`); } } - return baseUrl.trim().replace(/\/+$/, ''); + options.baseUrl = options.baseUrl.trim().replace(/\/+$/, ''); + options.identifier = options.identifier.trim(); + options.emailCode = options.emailCode.trim(); + return options; } /** @@ -56,30 +88,32 @@ function parseBaseUrl(argv) { */ let pendingInput = ''; -/** 从暂存缓冲里切出一行(跨 chunk 保留剩余数据),无完整行返回 null */ -function takeLine() { - const index = pendingInput.search(/[\r\n]/); - if (index < 0) return null; - const line = pendingInput.slice(0, index); - const skip = pendingInput[index] === '\r' && pendingInput[index + 1] === '\n' ? 2 : 1; - pendingInput = pendingInput.slice(index + skip); - return line; -} - +/** + * 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。 + * + * 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理, + * 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此 + * 一次粘贴「标识 + 密码」两行不会丢第二行。 + */ function readLine({ hidden }) { return new Promise((resolve, reject) => { - // 上一次读取可能已经缓冲了后续行(管道一次性写入多行) - const buffered = takeLine(); - if (buffered !== null) { - resolve(buffered); - return; - } - const stdin = process.stdin; const isTTY = Boolean(stdin.isTTY); - let buffer = ''; + 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); @@ -99,13 +133,22 @@ function readLine({ hidden }) { const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); if (!isTTY) { // 非交互:不回显,按行切分,剩余数据留给下一次读取 - pendingInput += text; - const line = takeLine(); + buffer += text; + const line = splitLine(); if (line !== null) finish(line); return; } - for (const ch of [...text]) { - if (ch === '\n' || ch === '\r') return finish(buffer); + 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'); @@ -121,16 +164,7 @@ function readLine({ hidden }) { if (!hidden) process.stdout.write(ch); } }; - // 输入结束:非 TTY 时把残留(末行无换行)也交出去 - const onEnd = () => { - if (!isTTY && pendingInput) { - const rest = pendingInput; - pendingInput = ''; - finish(rest); - return; - } - finish(buffer); - }; + const onEnd = () => finish(buffer); const onError = (err) => { if (settled) return; settled = true; @@ -138,6 +172,14 @@ function readLine({ hidden }) { 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); @@ -157,45 +199,97 @@ async function writePrivateFile(filePath, content) { await chmod(filePath, 0o600).catch(() => undefined); } -async function main() { - const baseUrl = parseBaseUrl(process.argv.slice(2)); +/** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */ +const PHONE_REGEX = /^1[3-9]\d{9}$/; +const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/; - process.stdout.write(`DETA 登录(${baseUrl})\n`); - process.stdout.write('用户名:'); - const username = (await readLine({ hidden: false })).trim(); - if (!username) fail('用户名不能为空'); +function identifierField(identifier) { + if (EMAIL_REGEX.test(identifier)) return 'email'; + if (PHONE_REGEX.test(identifier)) return 'phone'; + return 'username'; +} - process.stdout.write('密码:'); - const password = await readLine({ hidden: true }); - if (!password) fail('密码不能为空'); - - const loginUrl = `${baseUrl}/prod-api/login`; +/** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */ +async function postJson(baseUrl, path, body) { let response; try { - response = await fetch(loginUrl, { + response = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { - fail(`无法连接 ${loginUrl}(${err instanceof Error ? err.message : String(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)) { - const msg = payload?.msg || `登录失败(HTTP ${response.status})`; - fail(msg); + fail(`${serverMessage(payload, response)}\n ${loginHint(serverMessage(payload, response), email)}`.trimEnd()); } + return payload; +} - const loginUsername = typeof payload.username === 'string' && payload.username ? payload.username : username; +/** 写 ~/.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); @@ -216,11 +310,57 @@ async function main() { `export DETA_TOKEN=${shellSingleQuote(token)}\n`, ); - // token 只在这里(stdout)出现一次;stderr 与其它文件均不含凭据 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)`); + 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) => {