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
This commit is contained in:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+145
View File
@@ -0,0 +1,145 @@
import { saveResponse } from './download';
import { translate } from '../../shared/i18n/index';
import { CliError, fail } from '../../shared/errors';
import type { Credentials, Session, StoredConfig } from '../types';
import { isRecord } from '../../shared/record';
export { isRecord } from '../../shared/record';
interface RequestOptions {
method?: 'GET' | 'PUT' | 'POST' | 'DELETE';
body?: unknown;
output?: string;
text?: boolean;
timeoutMs?: number;
authenticated?: boolean;
}
export async function request(
config: StoredConfig,
endpoint: string,
options: RequestOptions = {},
): Promise<Record<string, unknown>> {
const { method = 'GET', body, authenticated = true } = options;
const scope = endpoint.split(/[/?]/, 1)[0]!;
const subscription = scope === 'subscriptions';
const resource =
scope === 'crons' || subscription
? translate(process.env, subscription ? '订阅' : '任务')
: scope;
const credentialsHint = authenticated
? translate(process.env, '应用凭据和 %s 权限', scope)
: translate(process.env, '应用凭据');
let response: Response;
let result: unknown;
try {
response = await fetch(`${config.url}/open/${endpoint}`, {
method,
redirect: 'error',
signal: AbortSignal.timeout(options.timeoutMs ?? 30000),
headers: {
...(authenticated ? { Authorization: `Bearer ${config.token}` } : {}),
...(body !== undefined && !(body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
},
body:
body instanceof FormData
? body
: body !== undefined
? JSON.stringify(body)
: undefined,
});
// Check HTTP status before decoding (proxies may return HTML on denial).
if (!response.ok) {
await response.body?.cancel();
const suffix =
method !== 'GET' && response.status >= 500
? translate(
process.env,
' 执行结果可能未知,请先检查%s状态再考虑重试。',
resource,
)
: '';
fail(
translate(
process.env,
'API 请求被拒绝(HTTP %s),请检查%s。%s',
response.status,
credentialsHint,
suffix,
),
[401, 403].includes(response.status) ? 3 : 1,
);
}
if (
options.output &&
(response.headers.get('content-disposition')?.startsWith('attachment') ||
!response.headers.get('content-type')?.includes('application/json'))
)
return await saveResponse(response, options.output);
if (
options.text &&
!response.headers.get('content-type')?.includes('application/json')
)
return { code: 200, data: await response.text() };
result = await response.json();
} catch (error) {
// Never expose a fetch error, URL or server error that may include secrets.
if (error instanceof CliError) throw error;
fail(
method !== 'GET'
? translate(
process.env,
'请求失败或响应无效,执行结果未知。请先检查%s状态再考虑重试。',
resource,
)
: translate(
process.env,
'请求失败或返回的 JSON 无效,请检查实例 URL、网络和 TLS 证书。',
),
);
}
if (endpoint.split('?')[0] === 'user/login' && isRecord(result) && result.code === 420)
fail(translate(process.env, '需要双因素验证,请调用 user two-factor-login。'), 3);
if (!isRecord(result) || result.code !== 200) {
const status =
isRecord(result) && typeof result.code === 'number'
? result.code
: undefined;
fail(
translate(
process.env,
'API 请求被拒绝(code %s),请检查%s。',
status ?? translate(process.env, '未知'),
credentialsHint,
),
status === 401 || status === 403 ? 3 : 1,
);
}
if (options.output)
fail(translate(process.env, '接口返回 JSON,未保存下载文件。'));
return result;
}
export async function authenticate(config: Credentials): Promise<Session> {
const query = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
});
const result = await request(config, `auth/token?${query}`, {
authenticated: false,
});
const data = result.data;
if (
!isRecord(data) ||
typeof data.token !== 'string' ||
!data.token ||
typeof data.expiration !== 'number' ||
!Number.isFinite(data.expiration) ||
data.expiration <= Date.now() / 1000
) {
fail(translate(process.env, '认证响应无效。'));
}
return { ...config, token: data.token, expiration: data.expiration };
}
+25
View File
@@ -0,0 +1,25 @@
import { open, unlink } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import { resolve } from 'node:path';
// Never overwrite an existing file; remove only a file created by this call.
export async function saveResponse(
response: Response,
output: string,
): Promise<Record<string, unknown>> {
const path = resolve(output);
const handle = await open(path, 'wx', 0o600);
try {
if (!response.body) throw new Error('Missing response body');
const stream = handle.createWriteStream();
await pipeline(Readable.fromWeb(response.body as ReadableStream<Uint8Array>), stream);
return { code: 200, data: { path, bytes: stream.bytesWritten } };
} catch (error) {
await unlink(path).catch(() => undefined);
throw error;
} finally {
await handle.close();
}
}
+586
View File
@@ -0,0 +1,586 @@
// Reviewed against back/api routes. The coverage test detects route additions/removals.
// All paths are relative to /open; these commands never operate on local panel files.
export interface OpenOperation {
name: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
existing?: boolean;
anonymous?: boolean;
}
export const openOperations: readonly OpenOperation[] = [
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
{
name: 'task view-create',
method: 'POST',
path: 'crons/views',
body: 'json',
},
{
name: 'task view-update',
method: 'PUT',
path: 'crons/views',
params: ['id'],
body: 'object-id',
},
{
name: 'task view-delete',
method: 'DELETE',
path: 'crons/views',
body: 'ids',
},
{
name: 'task view-move',
method: 'PUT',
path: 'crons/views/move',
body: 'json',
},
{
name: 'task view-disable',
method: 'PUT',
path: 'crons/views/disable',
body: 'ids',
},
{
name: 'task view-enable',
method: 'PUT',
path: 'crons/views/enable',
body: 'ids',
},
{ name: 'task list', method: 'GET', path: 'crons', existing: true },
{ name: 'task detail', method: 'GET', path: 'crons/detail' },
{ name: 'task create', method: 'POST', path: 'crons', body: 'json' },
{
name: 'task run',
method: 'PUT',
path: 'crons/run',
body: 'ids',
existing: true,
},
{
name: 'task stop',
method: 'PUT',
path: 'crons/stop',
body: 'ids',
existing: true,
},
{
name: 'task labels-delete',
method: 'DELETE',
path: 'crons/labels',
body: 'json',
},
{
name: 'task labels-create',
method: 'POST',
path: 'crons/labels',
body: 'json',
},
{ name: 'task disable', method: 'PUT', path: 'crons/disable', body: 'ids' },
{ name: 'task enable', method: 'PUT', path: 'crons/enable', body: 'ids' },
{
name: 'task logs',
method: 'GET',
path: 'crons/:id/log',
params: ['id'],
existing: true,
},
{
name: 'task update',
method: 'PUT',
path: 'crons',
params: ['id'],
body: 'object-id',
},
{ name: 'task delete', method: 'DELETE', path: 'crons', body: 'ids' },
{ name: 'task pin', method: 'PUT', path: 'crons/pin', body: 'ids' },
{ name: 'task unpin', method: 'PUT', path: 'crons/unpin', body: 'ids' },
{ name: 'task import', method: 'GET', path: 'crons/import' },
{
name: 'task get',
method: 'GET',
path: 'crons/:id',
params: ['id'],
existing: true,
},
{ name: 'task status', method: 'PUT', path: 'crons/status', body: 'json' },
{
name: 'task instances',
method: 'GET',
path: 'crons/:id/instances',
params: ['id'],
},
{
name: 'task instance-stop',
method: 'POST',
path: 'crons/:id/instances/:instanceId/stop',
params: ['id', 'instanceId'],
},
{
name: 'task log-files',
method: 'GET',
path: 'crons/:id/logs',
params: ['id'],
},
{
name: 'subscription list',
method: 'GET',
path: 'subscriptions',
existing: true,
},
{
name: 'subscription create',
method: 'POST',
path: 'subscriptions',
body: 'json',
},
{
name: 'subscription run',
method: 'PUT',
path: 'subscriptions/run',
body: 'ids',
existing: true,
},
{
name: 'subscription stop',
method: 'PUT',
path: 'subscriptions/stop',
body: 'ids',
existing: true,
},
{
name: 'subscription disable',
method: 'PUT',
path: 'subscriptions/disable',
body: 'ids',
existing: true,
},
{
name: 'subscription enable',
method: 'PUT',
path: 'subscriptions/enable',
body: 'ids',
existing: true,
},
{
name: 'subscription logs',
method: 'GET',
path: 'subscriptions/:id/log',
params: ['id'],
existing: true,
},
{
name: 'subscription update',
method: 'PUT',
path: 'subscriptions',
params: ['id'],
body: 'object-id',
},
{
name: 'subscription delete',
method: 'DELETE',
path: 'subscriptions',
body: 'ids',
},
{
name: 'subscription get',
method: 'GET',
path: 'subscriptions/:id',
params: ['id'],
existing: true,
},
{
name: 'subscription status',
method: 'PUT',
path: 'subscriptions/status',
body: 'json',
},
{
name: 'subscription log-files',
method: 'GET',
path: 'subscriptions/:id/logs',
params: ['id'],
},
{ name: 'app list', method: 'GET', path: 'apps' },
{ name: 'app create', method: 'POST', path: 'apps', body: 'json' },
{
name: 'app update',
method: 'PUT',
path: 'apps',
params: ['id'],
body: 'object-id',
},
{ name: 'app delete', method: 'DELETE', path: 'apps', body: 'ids' },
{
name: 'app reset-secret',
method: 'PUT',
path: 'apps/:id/reset-secret',
params: ['id'],
},
{
name: 'auth login',
method: 'GET',
path: 'auth/token',
existing: true,
anonymous: true,
},
{ name: 'env list', method: 'GET', path: 'envs' },
{ name: 'env create', method: 'POST', path: 'envs', body: 'json' },
{
name: 'env update',
method: 'PUT',
path: 'envs',
params: ['id'],
body: 'object-id',
},
{ name: 'env delete', method: 'DELETE', path: 'envs', body: 'ids' },
{
name: 'env move',
method: 'PUT',
path: 'envs/:id/move',
params: ['id'],
body: 'json',
},
{ name: 'env disable', method: 'PUT', path: 'envs/disable', body: 'ids' },
{ name: 'env enable', method: 'PUT', path: 'envs/enable', body: 'ids' },
{ name: 'env rename', method: 'PUT', path: 'envs/name', body: 'json' },
{ name: 'env get', method: 'GET', path: 'envs/:id', params: ['id'] },
{ name: 'env pin', method: 'PUT', path: 'envs/pin', body: 'ids' },
{ name: 'env unpin', method: 'PUT', path: 'envs/unpin', body: 'ids' },
{
name: 'env labels-create',
method: 'POST',
path: 'envs/labels',
body: 'json',
},
{
name: 'env labels-delete',
method: 'DELETE',
path: 'envs/labels',
body: 'json',
},
{
name: 'env upload',
method: 'POST',
path: 'envs/upload',
body: 'json',
upload: 'env',
},
{ name: 'config samples', method: 'GET', path: 'configs/samples' },
{ name: 'config list', method: 'GET', path: 'configs/files' },
{ name: 'config get', method: 'GET', path: 'configs/detail' },
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
{ name: 'script list', method: 'GET', path: 'scripts' },
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
{
name: 'script create',
method: 'POST',
path: 'scripts',
body: 'json',
upload: 'file',
},
{
name: 'script update',
method: 'PUT',
path: 'scripts',
body: 'json',
upload: 'file',
},
{ name: 'script delete', method: 'DELETE', path: 'scripts', body: 'json' },
{
name: 'script download',
method: 'POST',
path: 'scripts/download',
body: 'json',
download: true,
},
{ name: 'script run', method: 'PUT', path: 'scripts/run', body: 'json' },
{ name: 'script stop', method: 'PUT', path: 'scripts/stop', body: 'json' },
{
name: 'script rename',
method: 'PUT',
path: 'scripts/rename',
body: 'json',
},
{ name: 'log list', method: 'GET', path: 'logs' },
{ name: 'log get', method: 'GET', path: 'logs/detail' },
{ name: 'log delete', method: 'DELETE', path: 'logs', body: 'json' },
{
name: 'log download',
method: 'POST',
path: 'logs/download',
body: 'json',
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency create',
method: 'POST',
path: 'dependencies',
body: 'json',
},
{
name: 'dependency update',
method: 'PUT',
path: 'dependencies',
params: ['id'],
body: 'object-id',
},
{
name: 'dependency delete',
method: 'DELETE',
path: 'dependencies',
body: 'ids',
},
{
name: 'dependency force-delete',
method: 'DELETE',
path: 'dependencies/force',
body: 'ids',
},
{
name: 'dependency get',
method: 'GET',
path: 'dependencies/:id',
params: ['id'],
},
{
name: 'dependency reinstall',
method: 'PUT',
path: 'dependencies/reinstall',
body: 'ids',
},
{
name: 'dependency cancel',
method: 'PUT',
path: 'dependencies/cancel',
body: 'ids',
},
{ name: 'system info', method: 'GET', path: 'system' },
{ name: 'system config-get', method: 'GET', path: 'system/config' },
{
name: 'system config-log-remove-frequency',
method: 'PUT',
path: 'system/config/log-remove-frequency',
body: 'json',
},
{
name: 'system config-cron-concurrency',
method: 'PUT',
path: 'system/config/cron-concurrency',
body: 'json',
},
{
name: 'system config-dependence-proxy',
method: 'PUT',
path: 'system/config/dependence-proxy',
body: 'json',
},
{
name: 'system config-node-mirror',
method: 'PUT',
path: 'system/config/node-mirror',
body: 'json',
},
{
name: 'system config-python-mirror',
method: 'PUT',
path: 'system/config/python-mirror',
body: 'json',
},
{
name: 'system config-linux-mirror',
method: 'PUT',
path: 'system/config/linux-mirror',
body: 'json',
},
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
{ name: 'system update', method: 'PUT', path: 'system/update' },
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
{
name: 'system command-run',
method: 'PUT',
path: 'system/command-run',
body: 'json',
download: true,
},
{
name: 'system command-stop',
method: 'PUT',
path: 'system/command-stop',
body: 'json',
},
{
name: 'system data-export',
method: 'PUT',
path: 'system/data/export',
body: 'json',
download: true,
},
{
name: 'system data-import',
method: 'PUT',
path: 'system/data/import',
body: 'json',
upload: 'data',
},
{ name: 'system logs', method: 'GET', path: 'system/log' },
{ name: 'system logs-delete', method: 'DELETE', path: 'system/log' },
{
name: 'system auth-reset',
method: 'PUT',
path: 'system/auth/reset',
body: 'json',
},
{
name: 'system config-timezone',
method: 'PUT',
path: 'system/config/timezone',
body: 'json',
},
{
name: 'system config-lang',
method: 'PUT',
path: 'system/config/lang',
body: 'json',
},
{
name: 'system config-panel-title',
method: 'PUT',
path: 'system/config/panel-title',
body: 'json',
},
{
name: 'system config-global-ssh-key',
method: 'PUT',
path: 'system/config/global-ssh-key',
body: 'json',
},
{
name: 'system config-dependence-clean',
method: 'PUT',
path: 'system/config/dependence-clean',
body: 'json',
},
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
{ name: 'dashboard top-count', method: 'GET', path: 'dashboard/top-count' },
{ name: 'dashboard runtime', method: 'GET', path: 'dashboard/runtime' },
{ name: 'dashboard labels', method: 'GET', path: 'dashboard/labels' },
{ name: 'dashboard system', method: 'GET', path: 'dashboard/system' },
{
name: 'system client-ip-get',
method: 'GET',
path: 'system/client-ip/config',
},
{
name: 'system client-ip-set',
method: 'PUT',
path: 'system/client-ip/config',
body: 'json',
},
{
name: 'system client-ip-diagnose',
method: 'GET',
path: 'system/client-ip/diagnose',
},
{
name: 'system retention-set',
method: 'PUT',
path: 'system/storage-retention/config',
body: 'json',
},
{
name: 'system retention-preview',
method: 'POST',
path: 'system/storage-retention/preview',
body: 'json',
},
{
name: 'system retention-cleanup',
method: 'POST',
path: 'system/storage-retention/cleanup',
body: 'json',
},
{
name: 'user login',
method: 'POST',
path: 'user/login',
body: 'json',
anonymous: true,
},
{ name: 'user logout', method: 'POST', path: 'user/logout' },
{ name: 'user update', method: 'PUT', path: 'user', body: 'json' },
{ name: 'user get', method: 'GET', path: 'user' },
{ name: 'user two-factor-init', method: 'GET', path: 'user/two-factor/init' },
{
name: 'user two-factor-active',
method: 'PUT',
path: 'user/two-factor/active',
body: 'json',
},
{
name: 'user two-factor-deactivate',
method: 'PUT',
path: 'user/two-factor/deactivate',
},
{
name: 'user two-factor-login',
method: 'PUT',
path: 'user/two-factor/login',
body: 'json',
anonymous: true,
},
{ name: 'user login-log', method: 'GET', path: 'user/login-log' },
{ name: 'user ip-blacklist', method: 'GET', path: 'user/ip-blacklist' },
{
name: 'user ip-blacklist-set',
method: 'PUT',
path: 'user/ip-blacklist',
body: 'json',
},
{
name: 'user ip-blacklist-delete',
method: 'DELETE',
path: 'user/ip-blacklist',
body: 'json',
},
{ name: 'user notification-get', method: 'GET', path: 'user/notification' },
{
name: 'user notification-set',
method: 'PUT',
path: 'user/notification',
body: 'json',
},
{
name: 'user init',
method: 'PUT',
path: 'user/init',
body: 'json',
anonymous: true,
},
{
name: 'user notification-init',
method: 'PUT',
path: 'user/notification/init',
body: 'json',
anonymous: true,
},
{
name: 'user avatar',
method: 'PUT',
path: 'user/avatar',
body: 'json',
upload: 'avatar',
},
{ name: 'system apply-reload', method: 'PUT', path: 'update/reload' },
{ name: 'system apply-system', method: 'PUT', path: 'update/system' },
{ name: 'system apply-data', method: 'PUT', path: 'update/data' },
{ name: 'health get', method: 'GET', path: 'health' },
];
+90
View File
@@ -0,0 +1,90 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { CliError, fail } from '../../shared/errors';
import type { StoredConfig } from '../types';
import { panelUrl } from './url';
export function configPath(): string {
return path.resolve(
process.env.QL_CLI_CONFIG ||
path.join(os.homedir(), '.config/qinglong/cli.json'),
);
}
export function readConfig(): StoredConfig {
let fd: number | undefined;
try {
fd = fs.openSync(
configPath(),
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
const stat = fs.fstatSync(fd);
if (
!stat.isFile() ||
stat.mode & 0o077 ||
(process.getuid && stat.uid !== process.getuid())
) {
fail(
translate(
process.env,
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
),
);
}
const config: unknown = JSON.parse(fs.readFileSync(fd, 'utf8'));
if (!config || typeof config !== 'object')
fail(translate(process.env, 'CLI 配置无效,请运行 ql login。'));
const value = config as Record<string, unknown>;
if (
typeof value.url !== 'string' ||
typeof value.clientId !== 'string' ||
!value.clientId.trim() ||
typeof value.clientSecret !== 'string' ||
!value.clientSecret.trim()
) {
fail(translate(process.env, 'CLI 配置无效,请运行 ql login。'));
}
return {
url: panelUrl(value.url),
clientId: value.clientId,
clientSecret: value.clientSecret,
token: typeof value.token === 'string' ? value.token : undefined,
expiration:
typeof value.expiration === 'number' ? value.expiration : undefined,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
fail(translate(process.env, '尚未登录,请运行 ql login。'), 3);
if (error instanceof CliError) throw error;
return fail(
translate(
process.env,
'无法读取 CLI 配置,请检查文件权限并运行 ql login。',
),
);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
export function saveConfig(config: StoredConfig): void {
const file = configPath();
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const temporary = `${file}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(config) + '\n', {
mode: 0o600,
flag: 'wx',
});
fs.renameSync(temporary, file);
} finally {
fs.rmSync(temporary, { force: true });
}
}
export function logout(): void {
fs.rmSync(configPath(), { force: true });
}
+36
View File
@@ -0,0 +1,36 @@
import { translate } from '../../shared/i18n/index';
import { fail } from '../../shared/errors';
export function panelUrl(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
fail(translate(process.env, '请输入有效的面板 URL。'), 2);
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
fail(
translate(
process.env,
'请使用不含凭据、查询参数或片段的 HTTP(S) 面板 URL。',
),
2,
);
}
if (
url.protocol === 'http:' &&
!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
) {
fail(
translate(process.env, '远程认证需要 HTTPS;HTTP 仅允许回环地址。'),
2,
);
}
return url.href.replace(/\/+$/, '');
}
+123
View File
@@ -0,0 +1,123 @@
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,
},
};
}
+46
View File
@@ -0,0 +1,46 @@
import { openCommands } from './openCommands';
import type { ApiResponse, LogResponse } from '../../shared/types';
import type { Invocation } from '../../shared/cli/arguments';
import { fail } from '../../shared/errors';
import { translate } from '../../shared/i18n/index';
export async function dispatchRemote(
command: Invocation,
): Promise<ApiResponse<unknown> | LogResponse> {
const { name, values, positionals } = command;
if (openCommands.some((op) => op.name === name)) {
const { openCommand } = await import('./open');
return openCommand(command);
}
if (name.startsWith('auth ')) {
const { auth } = await import('./auth');
if (name === 'auth login')
return auth({ kind: 'login', url: values.url as string });
return name === 'auth logout'
? auth({ kind: 'logout' })
: auth({
kind: 'status',
scope: values.scope as string,
});
}
if (name.startsWith('task ')) {
const { task } = await import('./task');
if (name === 'task list')
return task({
kind: 'list',
page: Number(values.page),
size: Number(values.size),
search: values.search as string | undefined,
});
const id = Number(positionals[0]);
if (name === 'task logs')
return task({ kind: 'logs', id, tail: Number(values.tail) });
return task({ kind: name.slice(5) as 'get' | 'run' | 'stop', id });
}
if (name.startsWith('subscription ')) {
const { subscription } = await import('./subscription');
return subscription(command);
}
return fail(translate(process.env, '未知命令,请运行 %s --help。', 'ql'), 2);
}
+252
View File
@@ -0,0 +1,252 @@
import { readFile, stat, lstat } from 'node:fs/promises';
import { openAsBlob } from 'node:fs';
import { basename } from 'node:path';
import {
openOperations,
type OpenOperation,
} from '../api/openOperations';
import { request, isRecord } from '../api/client';
import { session } from './auth';
import { panelUrl } from '../auth/url';
import { integer, type Invocation } from '../../shared/cli/arguments';
import { fail } from '../../shared/errors';
import type { ApiResponse } from '../../shared/types';
function usage(zh: string, en: string): never {
return fail(process.env.QL_LANG === 'en' ? en : zh, 2);
}
async function input(value: string | boolean | undefined): Promise<unknown> {
if (typeof value !== 'string') return undefined;
let source = value;
try {
if (value === '-') {
if (process.stdin.isTTY)
usage('请通过管道提供 JSON。', 'Pipe JSON to stdin.');
const chunks: Buffer[] = [];
let length = 0;
for await (const chunk of process.stdin) {
const buffer = Buffer.from(chunk);
length += buffer.length;
if (length > 50 * 1024 * 1024) throw new Error('Too large');
chunks.push(buffer);
}
source = Buffer.concat(chunks).toString('utf8');
} else if (value.startsWith('@')) {
const file = value.slice(1);
const info = await stat(file);
if (!info.isFile() || info.size > 50 * 1024 * 1024)
throw new Error('Invalid input');
source = await readFile(file, 'utf8');
}
return JSON.parse(source);
} catch {
return usage(
'JSON 输入无效或文件无法读取。',
'Invalid JSON input or unreadable file.',
);
}
}
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
).test(path),
);
if (!match)
usage(
'方法或路径不属于当前支持的 OpenAPI。',
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
return match;
}
function redactApps(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactApps);
if (!isRecord(value)) return value;
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !['client_secret', 'tokens'].includes(key))
.map(([key, item]) => [key, redactApps(item)]),
);
}
export async function openCommand(
command: Invocation,
): Promise<ApiResponse<unknown>> {
if (command.name === 'api routes') return { code: 200, data: openOperations };
const generic = command.name === 'api request';
const values = command.values;
if (values.data === '-' && values.query === '-')
usage(
'--data 和 --query 不能同时读取 stdin。',
'--data and --query cannot both read stdin.',
);
let endpoint = generic
? command.positionals[1]!.replace(/^\/open\//, '').replace(/^\//, '')
: '';
if (generic && (!/^[\w/-]+$/.test(endpoint) || endpoint.includes('//')))
usage(
'请使用相对 OpenAPI 路径,查询参数通过 --query 提供。',
'Use a relative OpenAPI path; pass query parameters with --query.',
);
const op = generic
? route(command.positionals[0]!.toUpperCase(), endpoint)
: openOperations.find((item) => item.name === command.name)!;
if (!op) usage('未知 OpenAPI 命令。', 'Unknown OpenAPI command.');
let body = await input(values.data);
const query = await input(values.query);
if (query !== undefined && !isRecord(query))
usage('--query 必须为 JSON 对象。', '--query must be a JSON object.');
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
usage('--data 必须为 JSON 对象。', '--data must be a JSON object.');
if (isRecord(body) && body.id !== undefined && body.id !== value)
usage(
'请求体 ID 与命令 ID 不一致。',
'Body ID differs from command ID.',
);
body = { ...(body as Record<string, unknown>), id: value };
}
}
if (op.body === 'ids')
body = command.positionals.map((value) => integer(value));
for (const key of [
'name',
'command',
'schedule',
'type',
'url',
'alias',
'schedule-type',
'branch',
'whitelist',
'blacklist',
'scopes',
]) {
if (values[key] === undefined) continue;
if (body !== undefined && !isRecord(body))
usage('--data 必须为 JSON 对象。', '--data must be a JSON object.');
const field = key.replace(/-/g, '_');
if (isRecord(body) && Object.hasOwn(body, field))
usage(
'字段不能同时通过 --data 和选项提供。',
'Do not provide a field in both --data and flags.',
);
body = {
...(body as Record<string, unknown>),
[field]:
key === 'scopes'
? String(values[key])
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: values[key],
};
}
const required: Record<string, string[]> = {
'task create': ['command', 'schedule'],
'task update': ['command', 'schedule'],
'subscription create': ['type', 'url', 'alias', 'schedule_type'],
'subscription update': ['type', 'url', 'alias'],
};
if (
(required[op.name] ?? []).some(
(key) => !isRecord(body) || typeof body[key] !== 'string' || !body[key],
)
)
usage(
'缺少必需字段,请查看命令帮助和 OpenAPI 参考。',
'Missing required fields; see command help and OpenAPI reference.',
);
}
if (op.method === 'GET' && body !== undefined)
usage('GET 不接受请求体。', 'GET does not accept a body.');
if (op.body && body === undefined && !values.file)
usage(
'此命令需要 --data 或对应字段选项。',
'This command needs --data or field options.',
);
if (values.file) {
if (!op.upload)
usage('此接口不支持文件上传。', 'This route does not accept uploads.');
if (body !== undefined && !isRecord(body))
usage(
'上传字段必须为 JSON 对象。',
'Upload fields must be a JSON object.',
);
const form = new FormData();
for (const [key, value] of Object.entries(body ?? {})) {
if (!['string', 'number', 'boolean'].includes(typeof value))
usage('上传字段必须为标量。', 'Upload fields must be scalars.');
form.append(key, String(value));
}
try {
const file = String(values.file);
if (!(await stat(file)).isFile()) throw new Error('Not a file');
form.append(op.upload, await openAsBlob(file), basename(file));
} catch {
usage('上传文件无法读取。', 'Cannot read upload file.');
}
body = form;
} else if (op.upload && op.path !== 'scripts')
usage('此接口需要 --file。', 'This route requires --file.');
if (op.download && !values.output)
usage('此接口需要 --output 保存响应。', 'This route requires --output.');
if (values.output) {
if (!op.download)
usage('此接口不支持 --output。', 'This route does not support --output.');
try {
await lstat(String(values.output));
usage('输出文件已存在。', 'Output file already exists.');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query ?? {})) {
for (const item of Array.isArray(value) ? value : [value]) {
if (
item === null ||
!['string', 'number', 'boolean'].includes(typeof item)
)
usage(
'查询参数必须为标量或标量数组。',
'Query values must be scalars or arrays of scalars.',
);
params.append(key, String(item));
}
}
if (params.size) endpoint += `?${params}`;
const config = op.anonymous
? {
url: panelUrl(process.env.QL_URL || ''),
clientId: '',
clientSecret: '',
}
: await session();
const result = await request(config, endpoint, {
method: op.method,
body,
authenticated: !op.anonymous,
output: values.output as string | undefined,
text: op.path === 'system/log',
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
});
const data =
op.path.startsWith('apps') && !values['show-secrets']
? redactApps(result.data)
: result.data;
return { ...result, code: 200, data: data ?? null };
}
+93
View File
@@ -0,0 +1,93 @@
import { openOperations } from '../api/openOperations';
import type { CommandSpec, OptionSpec } from '../../shared/cli/registry';
const string = (description: string): OptionSpec => ({
type: 'string',
description,
});
export const openCommands: CommandSpec[] = openOperations
.filter((op) => !op.existing)
.map((op) => {
const options: Record<string, OptionSpec> = {
query: string('Query object as JSON, @file or - for stdin'),
timeout: {
...string('Request timeout in seconds'),
integer: { min: 1, max: 3600 },
},
};
if (op.body && op.body !== 'ids')
options.data = string('Request body as JSON, @file or - for stdin');
if (op.upload) options.file = string('Local file to upload');
if (op.download)
options.output = {
...string('Save response to a new local file'),
required: true,
};
if (
[
'task create',
'task update',
'subscription create',
'subscription update',
'app create',
'app update',
].includes(op.name)
) {
options.name = string('Resource name');
if (op.name.startsWith('task ')) {
options.command = string('Command executed by the remote panel');
options.schedule = string('Cron schedule');
} else if (op.name.startsWith('subscription ')) {
for (const key of [
'type',
'url',
'alias',
'schedule',
'schedule-type',
'branch',
'whitelist',
'blacklist',
])
options[key] = string(`Subscription ${key}`);
} else options.scopes = string('Comma-separated application permissions');
}
if (op.name.startsWith('app '))
options['show-secrets'] = {
type: 'boolean',
description: 'Explicitly include application credentials in output',
};
const params = op.params ?? [];
return {
name: op.name,
summary: `${op.method} /open/${op.path}`,
arguments:
op.body === 'ids' ? '<id...>' : params.map((p) => `<${p}>`).join(' '),
minimum: op.body === 'ids' ? 1 : params.length,
maximum: op.body === 'ids' ? 10000 : params.length,
options,
};
});
openCommands.push(
{
name: 'api request',
summary: 'Call a registered OpenAPI route',
arguments: '<method> <path>',
minimum: 2,
maximum: 2,
options: {
data: string('Request body as JSON, @file or - for stdin'),
query: string('Query object as JSON, @file or - for stdin'),
file: string('Local file to upload'),
output: string('Save response to a new local file'),
timeout: {
...string('Request timeout in seconds'),
integer: { min: 1, max: 3600 },
},
'show-secrets': {
type: 'boolean',
description: 'Explicitly include application credentials in output',
},
},
},
{ name: 'api routes', summary: 'List supported OpenAPI routes and commands' },
);
+128
View File
@@ -0,0 +1,128 @@
import { openCommands } from './openCommands';
import { openOperations } from '../api/openOperations';
import type { CommandSpec, CommandRegistry } from '../../shared/cli/registry';
import { integer } from '../../shared/cli/arguments';
const positiveId = '<id>';
export const commands: readonly CommandSpec[] = [
...openCommands,
{
name: 'auth login',
summary: 'Authenticate with a remote panel application',
options: {
url: {
type: 'string',
description: 'Panel URL including optional base path',
required: true,
},
},
},
{
name: 'auth status',
summary: 'Verify authentication and resource permission',
options: {
scope: {
type: 'string',
description: 'Permission to check',
default: 'crons',
choices: [
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
].filter((scope) => !['auth', 'update'].includes(scope)),
},
},
},
{
name: 'auth logout',
summary: 'Remove local credentials (does not revoke the server token)',
},
{
name: 'task list',
summary: 'List remote tasks',
options: {
search: { type: 'string', description: 'Search task name or command' },
page: {
type: 'string',
short: 'p',
description: 'Page number',
default: '1',
integer: { min: 1, max: Number.MAX_SAFE_INTEGER },
},
size: {
type: 'string',
description: 'Page size',
default: '50',
integer: { min: 1, max: 200 },
},
},
},
...['get', 'run', 'stop'].map(
(action): CommandSpec => ({
name: `task ${action}`,
summary: `${action} a remote task by ID`,
arguments: positiveId,
minimum: 1,
maximum: 1,
}),
),
{
name: 'task logs',
summary: 'Read the latest task log',
arguments: positiveId,
minimum: 1,
maximum: 1,
options: {
tail: {
type: 'string',
short: 'n',
description: 'Last N lines',
default: '200',
integer: { min: 1, max: 10000 },
},
},
},
{
name: 'subscription list',
summary: 'List panel subscriptions',
options: {
search: { type: 'string', description: 'Search subscriptions' },
},
},
...['get', 'run', 'stop', 'enable', 'disable'].map(
(action): CommandSpec => ({
name: `subscription ${action}`,
summary: `${action} a panel subscription by ID`,
arguments: '<id>',
minimum: 1,
maximum: 1,
}),
),
{
name: 'subscription logs',
summary: 'Read latest subscription log',
arguments: '<id>',
minimum: 1,
maximum: 1,
options: {
tail: {
type: 'string',
short: 'n',
description: 'Last N lines',
default: '200',
integer: { min: 1, max: 10000 },
},
},
},
];
export const registry: CommandRegistry = {
commands,
aliases: { login: ['auth', 'login'] },
options: {},
surface: 'public',
validate(command) {
if (
!openCommands.some((op) => op.name === command.name) &&
/^(task|subscription) /.test(command.name) &&
!command.name.endsWith(' list')
)
integer(command.positionals[0]!);
},
};
+91
View File
@@ -0,0 +1,91 @@
import { translate } from '../../shared/i18n/index';
import type { Invocation } from '../../shared/cli/arguments';
import { isRecord, request } from '../api/client';
import { session } from './auth';
import { fail } from '../../shared/errors';
import type { ApiResponse, LogResponse } from '../../shared/types';
// Subscription API records can contain private repository credentials and hooks.
// The management view deliberately exposes only the fields needed for selection.
const visibleFields = [
'id',
'name',
'alias',
'type',
'status',
'is_disabled',
'schedule',
'schedule_type',
'interval_schedule',
'branch',
'whitelist',
'blacklist',
'dependences',
'extensions',
'autoAddCron',
'autoDelCron',
'createdAt',
'updatedAt',
];
function projectSubscription(value: unknown): Record<string, unknown> {
if (
!isRecord(value) ||
typeof value.id !== 'number' ||
!Number.isSafeInteger(value.id) ||
value.id <= 0
)
fail(translate(process.env, '订阅响应无效。'));
return Object.fromEntries(
visibleFields
.filter((field) => value[field] !== undefined)
.map((field) => [field, value[field]]),
);
}
export async function subscription(
command: Invocation,
): Promise<ApiResponse<unknown> | LogResponse> {
const config = await session();
const action = command.name.split(' ')[1]!;
if (action === 'list') {
const search =
typeof command.values.search === 'string'
? `?${new URLSearchParams({ searchValue: command.values.search })}`
: '';
const result = await request(config, `subscriptions${search}`);
if (!Array.isArray(result.data))
fail(translate(process.env, '订阅列表响应无效。'));
return { code: 200, data: result.data.map(projectSubscription) };
}
const id = Number(command.positionals[0]);
if (action === 'get') {
const result = projectSubscription(
(await request(config, `subscriptions/${id}`)).data,
);
if (result.id !== id)
fail(translate(process.env, '返回的订阅 ID 与请求不一致。'));
return { code: 200, data: result };
}
if (action === 'logs') {
const result = await request(config, `subscriptions/${id}/log`);
if (typeof result.data !== 'string')
fail(translate(process.env, '订阅日志响应无效。'));
const lines = result.data.replace(/\r\n/g, '\n').split('\n');
if (lines.at(-1) === '') lines.pop();
const count = Number(command.values.tail);
return {
code: 200,
data: lines.slice(-count).join('\n'),
truncated: result.truncated === true || lines.length > count,
};
}
if (!['run', 'stop', 'enable', 'disable'].includes(action))
fail(translate(process.env, '不支持的订阅操作。'), 2);
await request(config, `subscriptions/${action}`, {
method: 'PUT',
body: [id],
});
return { code: 200, data: { subscriptionId: id, action, accepted: true } };
}
+76
View File
@@ -0,0 +1,76 @@
import { translate } from '../../shared/i18n/index';
import { isRecord, request } from '../api/client';
import { fail } from '../../shared/errors';
import type { ApiResponse, LogResponse } from '../../shared/types';
import type { Command, Task } from '../types';
import { session } from './auth';
type TaskCommand = Extract<
Command,
{ kind: 'list' | 'get' | 'logs' | 'run' | 'stop' }
>;
function isTask(value: unknown): value is Task {
return (
isRecord(value) &&
typeof value.id === 'number' &&
Number.isSafeInteger(value.id) &&
value.id > 0
);
}
export async function task(
command: TaskCommand,
): Promise<ApiResponse<unknown> | LogResponse> {
const config = await session();
if (command.kind === 'list') {
const query = new URLSearchParams({
page: String(command.page),
size: String(command.size),
});
if (command.search !== undefined) query.set('searchValue', command.search);
const result = await request(config, `crons?${query}`);
const data = result.data;
if (
!isRecord(data) ||
!Array.isArray(data.data) ||
!data.data.every(isTask) ||
typeof data.total !== 'number' ||
!Number.isSafeInteger(data.total) ||
data.total < 0
) {
fail(translate(process.env, '任务列表响应无效。'));
}
return { code: 200, data: { data: data.data, total: data.total } };
}
if (command.kind === 'get') {
const result = await request(config, `crons/${command.id}`);
if (!isTask(result.data) || result.data.id !== command.id)
fail(translate(process.env, '任务响应无效。'));
return { code: 200, data: result.data };
}
if (command.kind === 'logs') {
const result = await request(config, `crons/${command.id}/log`);
if (
typeof result.data !== 'string' ||
(result.logStatus !== undefined && typeof result.logStatus !== 'string')
)
fail(translate(process.env, '日志响应无效。'));
const lines = result.data.replace(/\r\n/g, '\n').split('\n');
if (lines.at(-1) === '') lines.pop();
return {
code: 200,
data: lines.slice(-command.tail).join('\n'),
logStatus: result.logStatus as string | undefined,
truncated: result.truncated === true || lines.length > command.tail,
};
}
await request(config, `crons/${command.kind}`, {
method: 'PUT',
body: [command.id],
});
return {
code: 200,
data: { taskId: command.id, action: command.kind, accepted: true },
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface Credentials {
url: string;
clientId: string;
clientSecret: string;
}
export interface StoredConfig extends Credentials {
token?: string;
expiration?: number;
}
export interface Session extends Credentials {
token: string;
expiration: number;
}
// Preserve additional 2.x fields without depending on backend modules or ORM types.
export interface Task {
id: number;
[field: string]: unknown;
}
export interface TaskList {
data: Task[];
total: number;
}
export type Command =
| { kind: 'help' }
| { kind: 'login'; url: string }
| { kind: 'status'; scope?: string }
| { kind: 'logout' }
| { kind: 'list'; search?: string; page: number; size: number }
| { kind: 'get'; id: number }
| { kind: 'logs'; id: number; tail: number }
| { kind: 'run' | 'stop'; id: number };