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
+151
View File
@@ -0,0 +1,151 @@
import {
quietCommand,
addFlags,
flagValues,
isArgumentError,
assertOptionValues,
} from './options';
import type { CommandSpec } from './registry';
import { fail } from '../errors';
import { translate } from '../i18n/index';
import { globalOptions, helpFor, type CommandRegistry } from './registry';
export interface Invocation {
name: string;
values: Record<string, string | boolean | undefined>;
positionals: string[];
json: boolean;
help?: string;
}
export function integer(
value: string,
min = 1,
max = Number.MAX_SAFE_INTEGER,
): number {
if (
!/^\d+$/.test(value) ||
!Number.isSafeInteger(Number(value)) ||
Number(value) < min ||
Number(value) > max
) {
fail(translate(process.env, '请输入支持范围内的整数。'), 2);
}
return Number(value);
}
export function parse(args: string[], registry: CommandRegistry): Invocation {
const { commands, aliases, surface, options: localOptions } = registry;
const entry = 'ql';
const input = [...args];
// Aliases are vocabulary, while Commander owns command/option parsing.
let offset = 0;
while (input[offset] === '--json') offset++;
if (input[offset] && Object.hasOwn(aliases, input[offset]!))
input.splice(offset, 1, ...aliases[input[offset]!]!);
if (input[offset] === 'help') input.splice(offset, 1, '--help');
const root = quietCommand('ql').enablePositionalOptions();
root.action(() => {});
addFlags(root, globalOptions);
let spec: CommandSpec | undefined;
let group: string | undefined;
let values: Invocation['values'] = {};
let positionals: string[] = [];
const groups = new Map<string, ReturnType<typeof quietCommand>>();
for (const item of commands.filter((item) =>
surface === 'local' ? item.local : !item.local,
)) {
const [parentName, action] = item.name.split(' ');
if (!groups.has(parentName!)) {
const parent = quietCommand(parentName!).enablePositionalOptions();
addFlags(parent, globalOptions);
parent.action(() => {
group = parentName;
values = flagValues(parent, globalOptions);
});
root.addCommand(parent);
groups.set(parentName!, parent);
}
const schema = {
...globalOptions,
...(item.local ? localOptions : {}),
...item.options,
};
const command = quietCommand(action!).argument('[arguments...]');
addFlags(command, schema);
command.action(() => {
spec = item;
group = parentName;
values = flagValues(command, schema);
positionals = command.args;
const parentValues = flagValues(groups.get(parentName!)!, globalOptions);
for (const key of ['json', 'help']) {
if (parentValues[key] && values[key])
fail(
translate(process.env, '选项重复,请运行 %s --help。', entry),
2,
);
if (parentValues[key]) values[key] = parentValues[key];
}
});
groups.get(parentName!)!.addCommand(command);
}
try {
assertOptionValues(
input,
Object.assign({}, globalOptions, ...commands.map((item) => item.options)),
);
root.parse(input, { from: 'user' });
} catch (error) {
if (!isArgumentError(error)) throw error;
const code = (error as { code: string }).code;
const unknown =
code === 'commander.unknownCommand' ||
(code === 'commander.excessArguments' && !spec);
fail(
translate(
process.env,
unknown
? '未知命令,请运行 %s --help。'
: '选项未知或缺少选项值,请运行 %s --help。',
entry,
),
2,
);
}
const inherited = flagValues(root, globalOptions);
for (const key of ['json', 'help']) {
if (inherited[key] && values[key])
fail(translate(process.env, '选项重复,请运行 %s --help。', entry), 2);
if (inherited[key]) values[key] = inherited[key];
}
const helpOnly = !spec;
const schema = {
...globalOptions,
...(spec?.local ? localOptions : {}),
...spec?.options,
};
const result: Invocation = {
name: spec?.name ?? 'help',
positionals,
values,
json: values.json === true,
};
if (helpOnly || values.help)
return { ...result, help: helpFor(registry, spec, group) };
if (
result.positionals.length < (spec?.minimum ?? 0) ||
result.positionals.length > (spec?.maximum ?? 0)
)
fail(translate(process.env, '参数多余或缺少必要参数。'), 2);
for (const [name, option] of Object.entries(schema)) {
if (option.required && !values[name])
fail(translate(process.env, '缺少 --%s。', name), 2);
if (option.choices && !option.choices.includes(values[name] as string))
fail(translate(process.env, '--%s 无效。', name), 2);
if (option.integer && typeof values[name] === 'string')
integer(values[name] as string, option.integer.min, option.integer.max);
}
registry.validate?.(result);
return result;
}
+2
View File
@@ -0,0 +1,2 @@
// One shared bundled copy: other CLI modules stay separate and lazy-loaded.
export { Command, Option, CommanderError } from 'commander';
+49
View File
@@ -0,0 +1,49 @@
import { parse } from './arguments';
import { translate } from '../i18n/index';
import { CliError } from '../errors';
import type { Invocation } from './arguments';
import type { CommandRegistry } from './registry';
export async function invoke(
args: string[],
registry: CommandRegistry,
dispatch: (command: Invocation) => Promise<unknown>,
signal?: AbortSignal,
interruptionCode?: () => number | undefined,
): Promise<number> {
let json = args
.slice(0, args.indexOf('--') < 0 ? undefined : args.indexOf('--'))
.includes('--json');
try {
const command = parse(args, registry);
json = command.json;
if (command.help) {
process.stdout.write(
(json
? JSON.stringify({ code: 200, data: { help: command.help } })
: command.help) + '\n',
);
return 0;
}
const result = await dispatch(command);
signal?.throwIfAborted();
process.stdout.write(JSON.stringify(result, null, json ? 0 : 2) + '\n');
return 0;
} catch (error) {
const cancelled = signal?.aborted;
const code = cancelled
? interruptionCode?.() ?? 1
: error instanceof CliError
? error.exitCode
: 1;
const message = cancelled
? translate(process.env, 'CLI 操作已取消。')
: error instanceof CliError
? error.message
: translate(process.env, 'CLI 操作失败,请检查本机配置和权限。');
process.stderr.write(
(json ? JSON.stringify({ code, message }) : message) + '\n',
);
return code;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Command, CommanderError, Option } from './commander';
import { fail } from '../errors';
import { translate } from '../i18n/index';
export interface FlagDefinition {
type: 'string' | 'boolean';
short?: string;
description?: string;
default?: string | boolean;
}
export function quietCommand(name: string): Command {
return new Command(name)
.helpOption(false)
.addHelpCommand(false)
.exitOverride()
.configureOutput({ writeOut() {}, writeErr() {} });
}
export function addFlags(
command: Command,
schema: Record<string, FlagDefinition>,
rejectDuplicates = true,
): void {
const seen = new Set<string>();
for (const [name, definition] of Object.entries(schema)) {
const option = new Option(
`${definition.short ? `-${definition.short}, ` : ''}--${name}${definition.type === 'string' ? ' <value>' : ''}`,
definition.description,
);
// --no-startup is a positive application flag, not Commander negation.
option.negate = false;
if (definition.default !== undefined) option.default(definition.default);
command.addOption(option);
command.on(`option:${option.name()}`, () => {
if (rejectDuplicates && seen.has(name))
fail(translate(process.env, '选项重复,请运行 %s --help。', 'ql'), 2);
seen.add(name);
});
}
}
export function flagValues(
command: Command,
schema: Record<string, FlagDefinition>,
): Record<string, string | boolean | undefined> {
const raw = command.opts();
const result: Record<string, string | boolean | undefined> = {};
for (const name of Object.keys(schema)) {
const option = command.options.find(item => item.long === `--${name}`)!;
if (raw[option.attributeName()] !== undefined)
result[name] = raw[option.attributeName()];
}
return result;
}
// Commander permits --url --json as a string value. Preserve the previous
// strict contract: leading-dash values need --url=--json (or -p-1).
export function assertOptionValues(
args: string[],
schema: Record<string, FlagDefinition>,
passThrough = false,
): void {
for (let index = 0; index < args.length; index++) {
const token = args[index]!;
if (token === '--') break;
if (!token.startsWith('-') || token === '-') {
if (passThrough) break;
continue;
}
let expectsValue = false;
if (token.startsWith('--')) {
if (!token.includes('=')) expectsValue = schema[token.slice(2)]?.type === 'string';
} else {
for (let offset = 1; offset < token.length; offset++) {
const definition = Object.values(schema).find(option => option.short === token[offset]);
if (!definition) break;
if (definition.type === 'string') {
expectsValue = offset === token.length - 1;
break;
}
}
}
if (expectsValue) {
const next = args[++index];
if (next === undefined || (next.length > 1 && next.startsWith('-')))
throw new CommanderError(2, 'commander.optionMissingArgument', 'Missing option value');
}
}
}
export function parseFlags(
args: string[],
schema: Record<string, FlagDefinition>,
options: { passThrough?: boolean; rejectDuplicates?: boolean } = {},
): { values: Record<string, string | boolean | undefined>; positionals: string[] } {
const command = quietCommand('ql').allowExcessArguments(true);
if (options.passThrough) command.passThroughOptions();
addFlags(command, schema, options.rejectDuplicates ?? true);
assertOptionValues(args, schema, options.passThrough);
command.parse(args, { from:'user' });
return { values:flagValues(command, schema), positionals:command.args };
}
export function isArgumentError(error: unknown): boolean {
return error instanceof CommanderError;
}
+93
View File
@@ -0,0 +1,93 @@
import { helpText } from '../i18n/help';
import { quietCommand, addFlags } from './options';
export interface OptionSpec {
type: 'string' | 'boolean';
short?: string;
description: string;
default?: string | boolean;
required?: boolean;
choices?: readonly string[];
integer?: { min: number; max: number };
}
export interface CommandSpec {
name: string;
summary: string;
arguments?: string;
minimum?: number;
maximum?: number;
options?: Record<string, OptionSpec>;
local?: boolean;
}
export type CommandSurface = 'public' | 'local';
export interface CommandRegistry {
commands: readonly CommandSpec[];
aliases: Record<string, string[]>;
options: Record<string, OptionSpec>;
surface: CommandSurface;
validate?: (command: import('./arguments').Invocation) => void;
}
export const globalOptions: Record<string, OptionSpec> = {
json: {
type: 'boolean',
description: 'One JSON result on stdout; diagnostics on stderr',
},
help: { type: 'boolean', short: 'h', description: 'Show help' },
};
export function helpFor(
registry: CommandRegistry,
spec?: CommandSpec,
group?: string,
): string {
const { commands, surface } = registry;
const selected = spec
? [spec]
: commands.filter(
(item) =>
(surface === 'local' ? item.local : !item.local) &&
(!group || item.name.startsWith(`${group} `)),
);
const displayName = (name: string) =>
surface === 'local' ? name.replace(/^local /, '') : name;
const program = quietCommand(
spec ? `ql ${displayName(spec.name)}` : `ql${group ? ' ' + group : ''}`,
);
program.description('QingLong 2.x CLI');
if (spec) {
if (spec.arguments) program.arguments(spec.arguments);
program.description(helpText(spec.summary));
} else {
for (const item of selected) {
const child = quietCommand(displayName(item.name)).description(
helpText(item.summary),
);
if (item.arguments) child.arguments(item.arguments);
program.addCommand(child);
}
}
const options = {
...globalOptions,
...registry.options,
...spec?.options,
};
addFlags(
program,
Object.fromEntries(
Object.entries(options).map(([name, option]) => [
name,
{ ...option, description: helpText(option.description) },
]),
),
);
return program
.helpInformation()
.replace(/^Usage:/m, helpText('Usage:'))
.replace(/^Options:/m, helpText('Options:'))
.replace(
/^Commands:/m,
process.env.QL_LANG === 'en' ? 'Commands:' : '命令:',
);
}
+23
View File
@@ -0,0 +1,23 @@
import { quietCommand } from './options';
// Delegate to a lazily loaded command implementation in this process. Keep
// the original argv slice: Commander consumes a leading -- during parsing,
// but the script/legacy parsers must receive that boundary unchanged.
export async function routeCommands(
name: string,
args: string[],
handlers: Record<string, (args: string[]) => Promise<number>>,
fallback: () => Promise<number>,
): Promise<number> {
let result: number | undefined;
const root = quietCommand(name).enablePositionalOptions()
.passThroughOptions().allowUnknownOption().argument('[arguments...]');
root.action(async () => { result = await fallback(); });
for (const [command, handler] of Object.entries(handlers)) {
root.addCommand(quietCommand(command).allowUnknownOption()
.allowExcessArguments(true).passThroughOptions()
.action(async () => { result = await handler(args.slice(1)); }));
}
await root.parseAsync(args, { from:'user' });
return result ?? await fallback();
}
+12
View File
@@ -0,0 +1,12 @@
export type ExitCode = 1 | 2 | 3;
export class CliError extends Error {
constructor(message: string, readonly exitCode: ExitCode = 1) {
super(message);
this.name = 'CliError';
}
}
export function fail(message: string, exitCode: ExitCode = 1): never {
throw new CliError(message, exitCode);
}
+320
View File
@@ -0,0 +1,320 @@
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
export const english: Record<string, string> = {
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
'已选择的 CLI 安装路径必须为绝对路径。':
'The selected CLI installation path must be absolute.',
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
'Preserving CLI entries requires a verified 2.x upgrade payload.',
'无法确认已选择的 CLI 命令加载器,请检查安装。':
'Cannot verify the selected CLI command loader. Check the installation.',
'容器入口仅接受通过环境变量配置。':
'Container entry accepts configuration through environment variables only.',
'任务延迟过滤已取消。': 'Task delay filtering cancelled.',
'任务延迟过滤失败(退出码 %s)。': 'Task delay filtering failed (exit %s).',
'直接运行的后端在启动期间退出(%s)。':
'Direct backend exited during startup (%s).',
'无法获取本机系统令牌,请检查运行中的面板。':
'Cannot obtain a local system token. Check the running panel.',
'本机面板端口无效。': 'Invalid local panel port.',
'本机 API 拒绝请求(HTTP %s)。': 'Local API rejected request (HTTP %s).',
'本机 API 请求失败;请先检查操作结果,不要直接重试写入。':
'Local API request failed; do not retry a mutation without checking its result.',
'本机 API 拒绝请求,请检查面板日志和权限。':
'Local API rejected request. Check panel logs and permissions.',
'任务列表响应无效。': 'Invalid task list response.',
'任务响应无效。': 'Invalid task response.',
'日志响应无效。': 'Invalid log response.',
'订阅响应无效。': 'Invalid subscription response.',
'订阅列表响应无效。': 'Invalid subscription list response.',
'返回的订阅 ID 与请求不一致。': 'Unexpected subscription ID.',
'订阅日志响应无效。': 'Invalid subscription log response.',
'不支持的订阅操作。': 'Unsupported subscription operation.',
'路径必须位于其管理目录内部。':
'Path must be a descendant of its managed directory.',
'仓库路径无效。': 'Invalid repository path.',
'单文件订阅需要 HTTP(S) 地址。': 'Raw subscriptions require HTTP(S).',
'订阅任务同步返回了无效任务列表。':
'Invalid task list during subscription reconciliation.',
'无法启动必要的可执行程序:%s': 'Cannot start required executable: %s',
'子进程输出写入失败。': 'Child output sink failed.',
'子进程输出超过配置的捕获上限。':
'Child output exceeded the configured capture limit.',
'必要的可执行程序失败(%s,退出码 %s)。':
'Required executable failed (%s, exit %s).',
'CLI 安装路径必须为绝对路径。': 'CLI installation paths must be absolute.',
'CLI 入口必须为普通文件。': 'CLI entry is not a regular file.',
'命令日志目录无效。': 'Invalid command log directory.',
'QlPort 必须在 1 到 65535 之间。': 'QlPort must be between 1 and 65535.',
'CLI 调用失败。': 'CLI invocation failed.',
'不支持此部署操作系统:%s。': 'Unsupported deployment OS: %s.',
'Python 返回了无效版本。': 'Python returned an invalid version.',
'启动需要以 /data 结尾的绝对数据目录。':
'Startup requires an absolute data directory ending in /data.',
'容器安装目录和数据目录必须为绝对路径。':
'Container root and data directory must be absolute paths.',
'容器目录不可写或不可访问:%s(UID %s)。请检查挂载目录的所有者和权限。':
'Container directory is not writable/searchable: %s (UID %s). Check the mounted directory ownership and permissions.',
'无法初始化 %s:%s': 'Cannot initialize %s: %s',
'QL_SCHEDULER 必须为 node 或 system。':
'QL_SCHEDULER must be node or system.',
'容器调度器意外退出(%s)。': 'Container scheduler exited unexpectedly (%s).',
'Bot 安装不支持此操作系统:%s。': 'Bot installation does not support OS: %s.',
'Bot 仓库包含指向目录外部的符号链接。':
'Bot repository contains an external symlink.',
'Bot 仓库包含不支持的文件类型。':
'Bot repository contains an unsupported entry.',
'Bot 源必须为普通目录。': 'Bot source must be a regular directory.',
'Bot 配置模板必须为普通文件。':
'Bot configuration template must be a regular file.',
'Bot 进程管理需要 Linux。': 'Bot process management requires Linux.',
'Bot 安装和进程管理需要 Linux。':
'Bot installation and process management require Linux.',
'Bot 在启动期间退出(%s)。': 'Bot exited during startup (%s).',
'订阅路径包含换行或空字符,无法安全过滤。':
'Subscription paths with line breaks cannot be filtered safely.',
'订阅 POSIX 正则表达式无效。':
'Invalid subscription POSIX regular expression.',
'订阅过滤失败(退出码 %s)。': 'Subscription filtering failed (exit %s).',
'账号选择格式无效。': 'Invalid account selection.',
'账号选择超出环境变量的账号范围。':
'Account selection is outside the environment variable range.',
'超时必须为正时长,例如 30s 或 5m。':
'Timeout must be a positive duration, e.g. 30s or 5m.',
'超时时长超出支持范围。': 'Timeout is outside the supported range.',
'必须指定脚本或可执行程序。': 'A script or executable is required.',
'必须指定有效的账号环境变量名。':
'A valid account environment variable is required.',
'cron 环境变量值无效。': 'Invalid cron environment value',
'cron 安装路径必须为绝对路径。': 'Cron installation paths must be absolute',
'拒绝覆盖不属于此 CLI 的用户 crontab 命令。':
'Refusing to replace an unrelated user crontab command',
'无法安装或读取面板 crontab,请检查环境路径和系统 cron 工具。':
'Cannot install or read the selected panel crontab. Check its environment paths and system cron tool.',
'后端进程未在 5 秒内停止,已取消重启。':
'Direct backend did not stop within 5 seconds; restart cancelled.',
'无法检查运行中的后端进程。': 'Cannot inspect running backend processes.',
'升级文件包含指向目录外部的符号链接。':
'Upgrade payload contains an external symlink.',
'升级文件包含不支持的文件类型。':
'Upgrade payload contains unsupported filesystem entries.',
'升级归档包含无效路径。': 'Upgrade archive contains an invalid path.',
'升级归档包含符号链接。': 'Upgrade archive contains symlinks.',
'暂存升级指针无效。': 'Invalid staged upgrade pointer.',
'暂存升级目录无效。': 'Invalid staged upgrade directory.',
'暂存升级就绪记录与目录不匹配。':
'Staged upgrade readiness does not match its directory.',
'替换源与目标相同。': 'Replacement source equals destination.',
'替换源位于目标目录内部。': 'Replacement source is inside its destination.',
'重载失败;文件已恢复,但原服务无法重新启动。':
'Reload failed; files restored, but the previous service could not restart.',
'数据重载需要独立的数据目录。':
'Data reload requires a dedicated data directory.',
'暂存数据与已安装的数据目录必须分离。':
'Staged data must be separate from the installed data directory.',
'主机开机注册需要 OpenRC 或 systemd;仅在其他管理器负责服务时使用 --no-startup。':
'Host startup registration requires OpenRC or systemd. Use --no-startup only when another supervisor owns the services.',
'本机命令需要通过 --root 或 QL_DIR 指定绝对路径。':
'Local commands require an absolute --root or QL_DIR.',
'面板安装目录不存在或不是目录。': 'Panel root does not exist.',
'用户配置加载已取消。': 'User configuration evaluation cancelled.',
'用户配置加载失败。': 'User configuration evaluation failed.',
'用户配置返回了无效的环境数据。':
'User configuration returned invalid environment data.',
'用法:ql-compat update [true|false]': 'Usage: ql-compat update [true|false]',
'用法:ql-compat reload [services|system|data]':
'Usage: ql-compat reload [services|system|data]',
'未知旧命令,请运行 ql-compat --help。':
'Unknown legacy command. Run ql-compat --help.',
'命令已中断。': 'Command interrupted.',
'旧命令适配器执行失败。': 'Legacy command adapter failed.',
'用法:ql-subscription-worker repo|raw <url> [旧订阅参数]':
'Usage: ql-subscription-worker repo|raw <url> [legacy subscription arguments]',
'订阅布尔参数无效,只能使用 true 或 false。': 'Invalid subscription boolean.',
'订阅参数过多。': 'Too many subscription arguments.',
'SUB_ID 无效,必须为正整数。': 'Invalid SUB_ID.',
'订阅执行器失败,请检查本机日志。':
'Subscription worker failed. Check local logs.',
'任务执行器选项无效。': 'Invalid runner options.',
'本机任务在加载配置时已取消。':
'Local task execution cancelled during configuration.',
'本机任务执行失败,请检查面板环境和任务日志。':
'Local task execution failed. Check the panel environment and task log.',
'应用 ID': 'Client ID',
应用密钥: 'Client secret',
订阅: 'subscription',
任务: 'task',
应用凭据: 'application credentials',
'应用凭据和 %s 权限': 'application credentials and %s permission',
未知: 'unknown',
' 执行结果可能未知,请先检查%s状态再考虑重试。':
' Execution outcome may be unknown; check %s status before retrying.',
'API 请求被拒绝(HTTP %s),请检查%s。%s':
'API request rejected (HTTP %s). Check %s.%s',
'请求失败或响应无效,执行结果未知。请先检查%s状态再考虑重试。':
'Request failed or returned an invalid response; execution outcome is unknown. Check %s status before retrying.',
'请求失败或返回的 JSON 无效,请检查实例 URL、网络和 TLS 证书。':
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
'API 请求被拒绝(code %s),请检查%s。':
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置无效,请运行 ql login。':
'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
'Cannot read CLI config. Check file permissions and run ql login.',
'请输入有效的面板 URL。': 'A valid panel URL is required.',
'请使用不含凭据、查询参数或片段的 HTTP(S) 面板 URL。':
'Use an HTTP(S) panel URL without credentials, query or fragment.',
'远程认证需要 HTTPS;HTTP 仅允许回环地址。':
'Remote authentication requires HTTPS; HTTP is allowed only for loopback.',
'非交互登录需要 QL_CLIENT_ID 和 QL_CLIENT_SECRET。':
'Non-interactive login requires QL_CLIENT_ID and QL_CLIENT_SECRET.',
'登录已取消。': 'Login cancelled.',
'凭据不能为空。': 'Credentials cannot be empty.',
'认证响应无效。': 'Invalid authentication response.',
'请输入支持范围内的整数。': 'Expected an integer within the supported range.',
'未知命令,请运行 %s --help。': 'Unknown command. Run %s --help.',
'选项未知或缺少选项值,请运行 %s --help。':
'Unknown or missing option. Run %s --help.',
'选项重复,请运行 %s --help。': 'Duplicate option. Run %s --help.',
'参数多余或缺少必要参数。':
'Unexpected argument or missing required argument.',
'缺少 --%s。': 'Missing --%s.',
'--%s 无效。': 'Invalid --%s.',
'CLI 操作已取消。': 'CLI operation cancelled.',
'CLI 操作失败,请检查本机配置和权限。':
'CLI operation failed. Check local configuration and permissions.',
'任务状态上报失败,请检查本机面板。\n':
'Task lifecycle reporting failed; check the local panel.\n',
'任务统计上报失败,请检查本机面板。\n':
'Task statistics reporting failed; check the local panel.\n',
'命令状态上报失败,请检查本机面板。\n':
'Command lifecycle reporting failed; check the local panel.\n',
'订阅同步已完成,但通知发送失败,请检查面板通知设置。\n':
'Subscription synchronized, but notification delivery failed. Check panel notification settings.\n',
'\n## 执行结束... %s 退出码 %s\n': '\n## Finished... %s exit code %s\n',
'任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n':
'Task delayed %s seconds, will start at %s. Set RandomDelay to empty to cancel delay\n',
'开始执行...\n': 'Starting execution...\n',
已停止: 'Stopped',
完成: 'Completed',
'失败(退出码 %s)': 'Failed (exit code %s)',
'安装 %s 依赖包...\n': 'Installing %s dependencies...\n',
'开始拉取仓库 %s 到 %s\n': 'Cloning repository %s to %s\n',
添加成功: 'Added successfully',
'添加失败(%s)': 'Add failed (%s)',
更新成功: 'Updated successfully',
'更新失败(%s)': 'Update failed (%s)',
成功: 'Success',
'失败(%s)': 'Failed (%s)',
'通知发送成功🎉': 'Notification sent successfully 🎉',
'通知失败(%s)': 'Notification failed (%s)',
'当前有以下脚本可以运行:': 'Available scripts:',
暂无脚本可以执行: 'No scripts available',
'警告:工作目录不存在 %s': 'Warning: working directory does not exist: %s',
'\n缺少并发运行的环境变量参数': '\nMissing concurrency environment variable',
'\n缺少单独运行的参数 task xxx.js desi Test':
'\nMissing parameter: task xxx.js desi Test',
'暂不支持此系统 %s': 'Unsupported system: %s',
'检测到 pm2 服务正在运行': 'pm2 service is running',
'npm 模块位置: %s': 'npm module location: %s',
'导入数据成功 %s': 'Data imported successfully: %s',
'导出数据成功 %s': 'Data exported successfully: %s',
'当前版本: %s / 最新版本: %s': 'Current: %s / Latest: %s',
已是最新版本: 'Already up to date',
'%s 个定时任务正在运行': '%s scheduled tasks running',
'执行前置命令\n': 'Running pre-command\n',
'\n执行前置命令结束\n': '\nPre-command finished\n',
'\n执行后置命令\n': '\nRunning post-command\n',
'\n执行后置命令结束': '\nPost-command finished',
'警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容':
'Warning: PM2 start failed (exit code: %s), possibly due to hardware incompatibility',
'正在尝试直接使用 Node.js 启动服务...':
'Attempting to start service with Node.js directly...',
'已使用 Node.js 直接启动服务 (PID: %s)':
'Service started with Node.js directly (PID: %s)',
'注意: 使用此模式时,部分 PM2 管理功能将不可用':
'Note: some PM2 management features are unavailable in this mode',
'## 开始执行... %s\n': '## Starting... %s\n',
'\n## 已停止 🛑... %s 耗时 %s 秒%s':
'\n## Stopped 🛑... %s took %s seconds%s',
'\n## 完成 ✅... %s 耗时 %s 秒%s':
'\n## Completed ✅... %s took %s seconds%s',
'\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s':
'\n## Failed ❌(exit code %s)... %s took %s seconds%s',
'%s -> 添加成功': '%s -> Added successfully',
'%s -> 添加失败(%s)': '%s -> Add failed (%s)',
'%s -> 更新成功': '%s -> Updated successfully',
'%s -> 更新失败(%s)': '%s -> Update failed (%s)',
'%s成功🎉': '%s succeeded 🎉',
'%s失败(%s)': '%s failed (%s)',
'检测到有%s的定时任务:': 'Found %s scheduled tasks:',
'\n开始尝试自动删除失效的定时任务...':
'\nAttempting to remove invalid scheduled tasks...',
'\n开始尝试自动添加定时任务...': '\nAttempting to add scheduled tasks...',
'拉取 %s 成功...\n': 'Pull %s succeeded...\n',
'拉取 %s 失败,请检查日志...\n': 'Pull %s failed, check logs...\n',
'开始下载:%s 保存路径:%s\n': 'Downloading: %s to: %s\n',
'下载 %s 成功...\n': 'Download %s succeeded...\n',
'下载 %s 失败,保留之前正常下载的版本...\n':
'Download %s failed, keeping previous version...\n',
'%s文件不存在,跳过执行...\n': '%s does not exist, skipping...\n',
'使用 %s 源更新...\n': 'Updating using %s mirror...\n',
'更新青龙源文件成功...\n': 'Qinglong source updated successfully\n',
'更新青龙源文件失败,请检查网络...\n':
'Qinglong source update failed, check network\n',
'更新青龙静态资源成功...\n': 'Static assets updated successfully\n',
'更新青龙静态资源失败,请检查网络...\n':
'Static assets update failed, check network\n',
'\n开始检测依赖...\n': '\nChecking dependencies...\n',
'\n依赖检测安装成功...\n': '\nDependencies installed successfully\n',
'更新包下载成功...\n': 'Package download succeeded\n',
'\n依赖检测安装失败,请检查网络...\n':
'\nDependency installation failed, check network\n',
'\n1、安装bot依赖...\n': '\n1. Installing bot dependencies...\n',
'\nbot依赖安装成功...\n': '\nBot dependencies installed\n',
'2、下载bot所需文件...\n': '2. Downloading bot files...\n',
'\nbot文件下载成功...\n': '\nBot files downloaded\n',
'3、安装python3依赖...\n': '3. Installing python3 dependencies...\n',
'\npython3依赖安装成功...\n': '\nPython3 dependencies installed\n',
'4、启动bot程序...\n': '4. Starting bot...\n',
'bot启动成功...\n': 'Bot started successfully\n',
'---> 1. 开始检测配置文件\n': '---> 1. Checking config...\n',
'---> 配置文件检测完成\n': '---> Config check complete\n',
'---> 2. 开始安装青龙依赖\n': '---> 2. Installing qinglong dependencies...\n',
'---> 青龙依赖安装完成\n': '---> Dependencies installed\n',
'---> 脚本依赖安装完成\n': '---> Script dependencies installed\n',
'---> 1. 复制通知文件\n': '---> 1. Copying notification files...\n',
'---> 复制一份 %s 为 %s\n': '---> Copying %s to %s\n',
'---> 通知文件复制完成\n': '---> Notification files copied\n',
'---> pm2日志': '---> pm2 log',
'\n=====> 检测面板': '\n=====> Checking panel',
'=====> 面板服务启动正常\n': '=====> Panel service running normally\n',
'\n=====> 检测后台': '\n=====> Checking backend',
'=====> 后台服务启动正常\n': '=====> Backend service running normally\n',
'=====> 开始检测': '=====> Starting check',
'\n=====> 检测结束\n': '\n=====> Check complete\n',
'查询文件 %s': 'Checking file: %s',
'删除中~': 'Deleting...',
'正在被 %s 使用,跳过~': 'In use by %s, skipping...',
'查找旧日志文件中...\n': 'Looking for old log files...\n',
'删除旧日志执行完毕\n': 'Old log cleanup complete\n',
'未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装':
'Module not found. Run: npm i -g @whyour/qinglong',
'请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务':
'Set env: export QL_DIR=%s, then run qinglong to start',
'请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量':
'Set QL_DATA_DIR (absolute path ending with /data, e.g. /ql/data)',
'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中':
'QL_DATA_DIR must end with /data, e.g. /ql/data',
'暂不支持此系统部署 %s': 'Unsupported system for deployment: %s',
'命令输入错误...\n': 'Invalid command...\n',
};
+99
View File
@@ -0,0 +1,99 @@
const chinese: Record<string, string> = {
'Query object as JSON, @file or - for stdin': '查询参数 JSON;支持 @文件或 - 从 stdin 读取',
'Request body as JSON, @file or - for stdin': '请求体 JSON;支持 @文件或 - 从 stdin 读取',
'Request timeout in seconds': '请求超时秒数(默认 30,最多 3600)',
'Local file to upload': '要上传的本机文件',
'Save response to a new local file': '响应保存到新文件(不覆盖已有文件)',
'Resource name': '资源名称',
'Command executed by the remote panel': '由远程面板执行的命令',
'Cron schedule': '定时规则',
'Comma-separated application permissions': '应用权限,用逗号分隔',
'Explicitly include application credentials in output': '显式输出应用密钥(默认隐藏)',
'Call a registered OpenAPI route': '调用已登记的 OpenAPI 路由',
'List supported OpenAPI routes and commands': '列出支持的 OpenAPI 路由和对应命令',
'Subscription type': '订阅类型:public-repo/private-repo/file',
'Subscription url': '订阅地址',
'Subscription alias': '订阅别名',
'Subscription schedule': '订阅定时规则',
'Subscription schedule-type': '订阅定时类型:crontab/interval',
'Subscription branch': '订阅分支',
'Subscription whitelist': '订阅包含规则',
'Subscription blacklist': '订阅排除规则',
'Skip OS boot registration (for containers)': '跳过系统开机注册(用于容器)',
'Authenticate with a remote panel application': '使用远程面板应用凭据登录',
'Panel URL including optional base path': '面板地址,可包含基础路径',
'Verify authentication and resource permission': '检查登录状态和资源权限',
'Permission to check': '要检查的权限',
'Remove local credentials (does not revoke the server token)':
'清除本机凭据(不会撤销服务端令牌)',
'List remote tasks': '列出远程任务',
'Search task name or command': '按任务名称或命令搜索',
'Page number': '页码',
'Page size': '每页数量',
'Read the latest task log': '读取最新任务日志',
'Last N lines': '最后 N 行',
'List panel subscriptions': '列出面板订阅',
'Search subscriptions': '搜索订阅',
'Read latest subscription log': '读取最新订阅日志',
'Remove expired logs not referenced by a task':
'删除过期且未被任务引用的日志',
'Execute the user extra.sh hook': '执行用户的 extra.sh 钩子',
'Restore missing configuration templates and runtime directories':
'恢复缺失的配置模板和运行目录',
'Install runtime dependencies, repair files, diagnose and reload services':
'安装运行依赖、修复文件、诊断并重载服务',
'Install dependencies and start the optional local Telegram bot':
'安装依赖并启动可选的本机 Telegram 机器人',
'Install runtime prerequisites and start nginx and panel services':
'安装运行环境并启动 nginx 和面板服务',
'Restart services without installing packages or starting optional hooks':
'重启服务,跳过依赖安装和可选钩子',
'Restart local services or apply staged system/data files':
'重启本机服务或应用已准备的系统/数据文件',
'Reload target': '重载目标',
'Stage and apply a local panel upgrade': '准备并应用本机面板升级',
'Archive source': '升级包来源',
'Prepare upgrade without replacing files or restarting services':
'仅准备升级包,不替换文件或重启服务',
'One JSON result on stdout; diagnostics on stderr':
'标准输出返回一个 JSON 结果,诊断写入标准错误',
'Show help': '显示帮助',
'Installed panel root (defaults to QL_DIR)':
'已安装面板的根目录(默认 QL_DIR)',
'Panel data directory (defaults to QL_DATA_DIR)':
'面板数据目录(默认 QL_DATA_DIR)',
'Usage:': '用法:',
'Options:': '选项:',
'default:': '默认:',
'Alias: login = auth login.': '别名:login = auth login。',
'Local operator commands require an installed panel.':
'本机运维命令需要已安装的面板。',
};
const actions: Record<string, string> = {
get: '查看',
run: '运行',
stop: '停止',
enable: '启用',
disable: '禁用',
};
for (const [action, text] of Object.entries(actions)) {
chinese[`${action} a remote task by ID`] = `按 ID ${text}远程任务`;
chinese[`${action} a panel subscription by ID`] = `按 ID ${text}面板订阅`;
}
for (const [action, text] of Object.entries({
resetlet: '清除登录限制',
resettfa: '关闭双因素认证',
resetpwd: '重置密码',
resetname: '重置用户名',
}))
chinese[`Local panel ${action}`] = `本机面板:${text}`;
export function helpText(
text: string,
env: NodeJS.ProcessEnv = process.env,
): string {
if (env.QL_LANG !== 'en' && /^(GET|POST|PUT|DELETE) \/open\//.test(text))
return `远程 API:${text}`;
return env.QL_LANG === 'en' ? text : chinese[text] ?? text;
}
+13
View File
@@ -0,0 +1,13 @@
import { english } from './en';
export function translate(
env: NodeJS.ProcessEnv,
key: string,
...values: unknown[]
): string {
const template = env.QL_LANG === 'en' ? english[key] ?? key : key;
let index = 0;
return template.replace(/%%|%s/g, (token) =>
token === '%%' ? '%' : String(values[index++] ?? ''),
);
}
+3
View File
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
+9
View File
@@ -0,0 +1,9 @@
export interface ApiResponse<T> {
code: 200;
data: T;
}
export interface LogResponse extends ApiResponse<string> {
logStatus?: string;
truncated: boolean;
}