fix(login): 登录标识与门户对齐 + 失败可诊断
- 端点:走 /login(已存在账号的密码校验),不走门户的 /login-or-register(会触发登录即注册) - 新增 -u/--identifier:标识可用 用户名/已验证邮箱/已验证手机号(服务端 LoginIdentifierResolver 解析) - 新增 --email-code:邮箱验证码登录(账号未设密码时的可用路径) - 登录前匿名预检 /login/check:区分「标识未注册」与「密码不匹配」,避免误导性提示 - 失败提示按服务端 msg 映射可执行下一步(未验证/停用/登录方式未开放) - 修复 readLine:TTY 下一次粘贴多行会丢弃换行之后的剩余字符(第二次读取挂起)
This commit is contained in:
+18
-1
@@ -37,7 +37,24 @@ codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
|
|||||||
codex plugin add deta@deta
|
codex plugin add deta@deta
|
||||||
```
|
```
|
||||||
|
|
||||||
登录助手也支持自建/预发环境:`node bin/deta-login.mjs --base-url https://<host>`。
|
登录助手选项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node bin/deta-login.mjs -u <用户名|已验证邮箱|已验证手机号> # 免交互输入标识
|
||||||
|
node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(未设密码的账号用)
|
||||||
|
node bin/deta-login.mjs --base-url https://<host> # 自建/预发环境
|
||||||
|
```
|
||||||
|
|
||||||
|
**标识(identifier)填门户登录页上用的那一个**:用户名、已验证邮箱、已验证手机号,服务端都会解析到同一账号。
|
||||||
|
助手先匿名调用 `/login/check` 预检标识是否已注册,再走 `/login` 做密码校验(不走门户的「登录即注册」端点,
|
||||||
|
不会误建账号)。失败时按服务端提示给出下一步:
|
||||||
|
|
||||||
|
| 服务端提示 | 含义与处理 |
|
||||||
|
|------------|------------|
|
||||||
|
| `该标识在 <host> 未注册` | 标识不是门户上用的那一个;邮箱验证码注册的账号标识填邮箱 |
|
||||||
|
| `用户不存在/密码错误` | 服务端不区分「账号不存在」与「密码不匹配」;已确认标识存在,即密码不符,或改用 `--email-code` |
|
||||||
|
| `该邮箱/手机号尚未验证…` | 先用验证码方式登录一次门户完成验证 |
|
||||||
|
| `该登录方式未开放` | 用 `GET <base>/prod-api/login/methods` 查当前启用项 |
|
||||||
|
|
||||||
## 安装(dsh)
|
## 安装(dsh)
|
||||||
|
|
||||||
|
|||||||
+209
-69
@@ -4,13 +4,19 @@
|
|||||||
*
|
*
|
||||||
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
|
* 用 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. 交互式读取用户名/密码(密码不回显);
|
* 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册;
|
||||||
* 2. POST <base>/prod-api/login { username, password };
|
* 2. 读入密码(不回显);POST <base>/prod-api/login { username: 标识, password };
|
||||||
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
|
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
|
||||||
* 4. 失败 → 打印服务端 msg,退出码非 0。
|
* 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。
|
||||||
*
|
*
|
||||||
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
|
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
|
||||||
* 不写日志、不写 stderr、不写其它位置。
|
* 不写日志、不写 stderr、不写其它位置。
|
||||||
@@ -28,26 +34,52 @@ function fail(message) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `--base-url <url>` / `--base-url=<url>`;缺省 https://deta.city,末尾斜杠去掉 */
|
const USAGE = [
|
||||||
function parseBaseUrl(argv) {
|
'用法:node bin/deta-login.mjs [选项]',
|
||||||
let baseUrl = process.env.DETA_BASE_URL || DEFAULT_BASE_URL;
|
' -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++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (arg === '--base-url') {
|
const takeValue = (name) => {
|
||||||
const next = argv[i + 1];
|
const next = argv[i + 1];
|
||||||
if (!next) fail('--base-url 缺少取值');
|
if (!next) fail(`${name} 缺少取值`);
|
||||||
baseUrl = next;
|
|
||||||
i++;
|
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=')) {
|
} 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') {
|
} else if (arg === '-h' || arg === '--help') {
|
||||||
console.log('用法:node bin/deta-login.mjs [--base-url https://deta.city]');
|
console.log(USAGE);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} 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 = '';
|
let pendingInput = '';
|
||||||
|
|
||||||
/** 从暂存缓冲里切出一行(跨 chunk 保留剩余数据),无完整行返回 null */
|
/**
|
||||||
function takeLine() {
|
* 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。
|
||||||
const index = pendingInput.search(/[\r\n]/);
|
*
|
||||||
if (index < 0) return null;
|
* 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理,
|
||||||
const line = pendingInput.slice(0, index);
|
* 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此
|
||||||
const skip = pendingInput[index] === '\r' && pendingInput[index + 1] === '\n' ? 2 : 1;
|
* 一次粘贴「标识 + 密码」两行不会丢第二行。
|
||||||
pendingInput = pendingInput.slice(index + skip);
|
*/
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLine({ hidden }) {
|
function readLine({ hidden }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 上一次读取可能已经缓冲了后续行(管道一次性写入多行)
|
|
||||||
const buffered = takeLine();
|
|
||||||
if (buffered !== null) {
|
|
||||||
resolve(buffered);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdin = process.stdin;
|
const stdin = process.stdin;
|
||||||
const isTTY = Boolean(stdin.isTTY);
|
const isTTY = Boolean(stdin.isTTY);
|
||||||
let buffer = '';
|
let buffer = pendingInput;
|
||||||
|
pendingInput = '';
|
||||||
let settled = false;
|
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 = () => {
|
const cleanup = () => {
|
||||||
stdin.removeListener('data', onData);
|
stdin.removeListener('data', onData);
|
||||||
stdin.removeListener('end', onEnd);
|
stdin.removeListener('end', onEnd);
|
||||||
@@ -99,13 +133,22 @@ function readLine({ hidden }) {
|
|||||||
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
||||||
if (!isTTY) {
|
if (!isTTY) {
|
||||||
// 非交互:不回显,按行切分,剩余数据留给下一次读取
|
// 非交互:不回显,按行切分,剩余数据留给下一次读取
|
||||||
pendingInput += text;
|
buffer += text;
|
||||||
const line = takeLine();
|
const line = splitLine();
|
||||||
if (line !== null) finish(line);
|
if (line !== null) finish(line);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const ch of [...text]) {
|
const chars = [...text];
|
||||||
if (ch === '\n' || ch === '\r') return finish(buffer);
|
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') {
|
if (ch === '\u0003') {
|
||||||
// Ctrl-C
|
// Ctrl-C
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
@@ -121,16 +164,7 @@ function readLine({ hidden }) {
|
|||||||
if (!hidden) process.stdout.write(ch);
|
if (!hidden) process.stdout.write(ch);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 输入结束:非 TTY 时把残留(末行无换行)也交出去
|
const onEnd = () => finish(buffer);
|
||||||
const onEnd = () => {
|
|
||||||
if (!isTTY && pendingInput) {
|
|
||||||
const rest = pendingInput;
|
|
||||||
pendingInput = '';
|
|
||||||
finish(rest);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish(buffer);
|
|
||||||
};
|
|
||||||
const onError = (err) => {
|
const onError = (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -138,6 +172,14 @@ function readLine({ hidden }) {
|
|||||||
reject(err);
|
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);
|
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
|
||||||
stdin.resume();
|
stdin.resume();
|
||||||
stdin.on('data', onData);
|
stdin.on('data', onData);
|
||||||
@@ -157,45 +199,97 @@ async function writePrivateFile(filePath, content) {
|
|||||||
await chmod(filePath, 0o600).catch(() => undefined);
|
await chmod(filePath, 0o600).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
/** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */
|
||||||
const baseUrl = parseBaseUrl(process.argv.slice(2));
|
const PHONE_REGEX = /^1[3-9]\d{9}$/;
|
||||||
|
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/;
|
||||||
|
|
||||||
process.stdout.write(`DETA 登录(${baseUrl})\n`);
|
function identifierField(identifier) {
|
||||||
process.stdout.write('用户名:');
|
if (EMAIL_REGEX.test(identifier)) return 'email';
|
||||||
const username = (await readLine({ hidden: false })).trim();
|
if (PHONE_REGEX.test(identifier)) return 'phone';
|
||||||
if (!username) fail('用户名不能为空');
|
return 'username';
|
||||||
|
}
|
||||||
|
|
||||||
process.stdout.write('密码:');
|
/** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */
|
||||||
const password = await readLine({ hidden: true });
|
async function postJson(baseUrl, path, body) {
|
||||||
if (!password) fail('密码不能为空');
|
|
||||||
|
|
||||||
const loginUrl = `${baseUrl}/prod-api/login`;
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(loginUrl, {
|
response = await fetch(`${baseUrl}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(`无法连接 ${loginUrl}(${err instanceof Error ? err.message : String(err)})`);
|
fail(`无法连接 ${baseUrl}${path}(${err instanceof Error ? err.message : String(err)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = null;
|
let payload = null;
|
||||||
try {
|
try {
|
||||||
payload = await response.json();
|
payload = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
payload = null;
|
payload = null;
|
||||||
}
|
}
|
||||||
|
return { response, payload };
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginUsername = typeof payload.username === 'string' && payload.username ? payload.username : username;
|
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');
|
const dir = join(homedir(), '.deta');
|
||||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
await chmod(dir, 0o700).catch(() => undefined);
|
await chmod(dir, 0o700).catch(() => undefined);
|
||||||
@@ -216,11 +310,57 @@ async function main() {
|
|||||||
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
|
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// token 只在这里(stdout)出现一次;stderr 与其它文件均不含凭据
|
|
||||||
console.log(`DETA_TOKEN=${token}`);
|
console.log(`DETA_TOKEN=${token}`);
|
||||||
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
|
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
|
||||||
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
|
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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user