mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
@@ -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]!);
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user