Files
qinglong/cli/src/remote/commands/auth.ts
T
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
2026-09-25 23:24:41 +08:00

124 lines
3.6 KiB
TypeScript

import { translate } from '../../shared/i18n/index';
import readline from 'node:readline';
import { Writable } from 'node:stream';
import { authenticate, request } from '../api/client';
import { readConfig, saveConfig, logout } from '../auth/store';
import { panelUrl } from '../auth/url';
import { CliError, fail } from '../../shared/errors';
import type { ApiResponse } from '../../shared/types';
import type { Command, Session } from '../types';
export async function promptCredential(
label: string,
hidden = false,
): Promise<string> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
fail(
translate(
process.env,
'非交互登录需要 QL_CLIENT_ID 和 QL_CLIENT_SECRET。',
),
2,
);
}
// Suppress readline echo through a stream, without using its private methods.
const output = new Writable({
write(chunk, _encoding, callback) {
if (!hidden) process.stderr.write(chunk);
callback();
},
});
const terminal = readline.createInterface({
input: process.stdin,
output,
terminal: true,
});
process.stderr.write(`${label}: `);
try {
return await new Promise<string>((resolve, reject) => {
const cancelled = () =>
reject(new CliError(translate(process.env, '登录已取消。'), 2));
terminal.once('SIGINT', cancelled);
terminal.once('close', cancelled);
terminal.question('', resolve);
});
} finally {
terminal.close();
output.end();
if (hidden) process.stderr.write('\n');
}
}
export async function session(): Promise<Session> {
if (process.env.QL_ACCESS_TOKEN || process.env.QL_URL) {
if (!process.env.QL_ACCESS_TOKEN || !process.env.QL_URL)
fail(
translate(process.env, 'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。'),
2,
);
return {
url: panelUrl(process.env.QL_URL),
token: process.env.QL_ACCESS_TOKEN,
clientId: '',
clientSecret: '',
expiration: 0,
};
}
const config = readConfig();
if (
config.token &&
typeof config.expiration === 'number' &&
Number.isFinite(config.expiration) &&
config.expiration > Date.now() / 1000 + 60
) {
return { ...config, token: config.token, expiration: config.expiration };
}
const refreshed = await authenticate(config);
saveConfig(refreshed);
return refreshed;
}
export async function auth(
command: Extract<Command, { kind: 'login' | 'logout' | 'status' }>,
): Promise<ApiResponse<unknown>> {
if (command.kind === 'logout') {
logout();
return { code: 200, data: { authenticated: false, localOnly: true } };
}
if (command.kind === 'login') {
const url = panelUrl(command.url);
const clientId =
process.env.QL_CLIENT_ID ||
(await promptCredential(translate(process.env, '应用 ID')));
const clientSecret =
process.env.QL_CLIENT_SECRET ||
(await promptCredential(translate(process.env, '应用密钥'), true));
if (!clientId.trim() || !clientSecret.trim())
fail(translate(process.env, '凭据不能为空。'), 2);
const config = await authenticate({ url, clientId, clientSecret });
saveConfig(config);
return { code: 200, data: { authenticated: true, url } };
}
const config = await session();
const scope = command.scope ?? 'crons';
await request(
config,
(
{
crons: 'crons?page=1&size=1',
configs: 'configs/files',
dashboard: 'dashboard/overview',
} as Record<string, string>
)[scope] ?? scope,
);
return {
code: 200,
data: {
authenticated: true,
url: config.url,
expiration: config.expiration,
scopeChecked: scope,
},
};
}