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
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
import { translate } from '../shared/i18n/index';
import { standaloneHelp } from '../internal/help/standalone';
import {
withCommandCancellation,
interruptedCode,
} from '../internal/runtime/cancellation';
import { CliError, fail } from '../shared/errors';
export interface CompatibilityRoute {
surface: 'local' | 'subscription';
args: string[];
}
export function compatibilityRoute(input: string[]): CompatibilityRoute {
const args = input[0] === '-l' ? input.slice(1) : [...input];
const [action, ...values] = args;
if (action === 'repo' || action === 'raw')
return { surface: 'subscription', args };
if (action === 'update') {
if (
values.length > 1 ||
(values.length && !['true', 'false'].includes(values[0]!))
)
fail(translate(process.env, '用法:ql-compat update [true|false]'), 2);
return {
surface: 'local',
args: ['update', ...(values[0] === 'false' ? ['--download-only'] : [])],
};
}
if (action === 'reload') {
if (
values.length > 1 ||
(values.length && !['services', 'system', 'data'].includes(values[0]!))
)
fail(
translate(process.env, '用法:ql-compat reload [services|system|data]'),
2,
);
return {
surface: 'local',
args: ['reload', '--target', values[0] || 'services'],
};
}
if (
action &&
[
'rmlog',
'extra',
'bot',
'check',
'resetlet',
'resettfa',
'resetpwd',
'resetname',
].includes(action)
) {
// A legacy positional value beginning with '-' is data, never a CLI option.
return { surface: 'local', args: [action, '--', ...values] };
}
fail(translate(process.env, '未知旧命令,请运行 ql-compat --help。'), 2);
}
export async function compatibilityMain(
args = process.argv.slice(2),
): Promise<number> {
return withCommandCancellation(async (signal) => {
try {
if (
!args.length ||
(args.length === 1 && ['--help', '-h'].includes(args[0]!))
) {
process.stdout.write(standaloneHelp('compat'));
return 0;
}
const route = compatibilityRoute(args);
if (route.surface === 'subscription') {
const { subscriptionWorker } = await import(
'../internal/subscription/worker'
);
return subscriptionWorker(route.args);
}
const { parse } = await import('../shared/cli/arguments');
const { localContext } = await import('../internal/runtime/context');
const { maintenance } = await import(
'../internal/maintenance/maintenance'
);
const { loggedOperation } = await import(
'../internal/runtime/commandLog'
);
const { registry } = await import('../internal/commands/registry');
const command = parse(route.args, registry);
const context = await localContext(command, { signal });
const { result, logPath } = await loggedOperation(
context,
route.args[0]!,
() => maintenance(command, context),
signal,
);
process.stdout.write(
JSON.stringify({ code: 200, data: result, logPath }) + '\n',
);
return 0;
} catch (error) {
const code =
interruptedCode(signal) ??
(error instanceof CliError ? error.exitCode : 1);
process.stderr.write(
JSON.stringify({
code,
message: signal.aborted
? translate(process.env, '命令已中断。')
: error instanceof CliError
? error.message
: translate(process.env, '旧命令适配器执行失败。'),
}) + '\n',
);
return code;
}
});
}
if (require.main === module)
void compatibilityMain().then((code) => {
process.exitCode = code;
});
+11
View File
@@ -0,0 +1,11 @@
import type { CommandSurface } from '../shared/cli/registry';
// Compatibility for existing programmatic callers; product entries select one surface.
export async function main(
args = process.argv.slice(2),
surface: CommandSurface = 'public',
signal?: AbortSignal,
): Promise<number> {
return surface === 'public'
? (await import('../entrypoints/remote')).remoteMain(args)
: (await import('../internal/commands/main')).internalMain(args, signal);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import { standaloneHelp } from '../internal/help/standalone';
import {
withCommandCancellation,
cancellableOperation,
interruptedCode,
} from '../internal/runtime/cancellation';
// The legacy qinglong command starts the host installation; reload is its only
// positional mode. All remaining validation belongs to the shared registry.
export function startupArguments(args: string[]): string[] {
return args[0] === 'reload'
? ['start', '--reload', ...args.slice(1)]
: ['start', ...args];
}
export async function startupMain(
args = process.argv.slice(2),
): Promise<number> {
if (
args.length &&
args.every((arg) => ['--help', '-h', '--json'].includes(arg)) &&
args.some((arg) => arg === '--help' || arg === '-h')
) {
const help = standaloneHelp('startup');
process.stdout.write(
(args.includes('--json')
? JSON.stringify({ code: 200, data: { help } })
: help) + '\n',
);
return 0;
}
return withCommandCancellation(async (signal) => {
try {
const { main } = await import('./main');
return await cancellableOperation(signal, () =>
main(startupArguments(args), 'local', signal),
);
} catch (error) {
const code = interruptedCode(signal);
if (code !== undefined) return code;
throw error;
}
});
}
if (require.main === module)
void startupMain().then((code) => {
process.exitCode = code;
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { main } from '../compatibility/main';
import {
withCommandCancellation,
cancellableOperation,
interruptedCode,
} from '../internal/runtime/cancellation';
void withCommandCancellation(async (signal) => {
try {
return await cancellableOperation(signal, () =>
main(process.argv.slice(2), 'local', signal),
);
} catch (error) {
const code = interruptedCode(signal);
if (code !== undefined) return code;
throw error;
}
}).then((code) => {
process.exitCode = code;
});
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { translate } from '../shared/i18n/index';
import { createContext } from '../internal/runtime/context';
import { withCommandCancellation } from '../internal/runtime/cancellation';
import { runContainer } from '../internal/runtime/containerRuntime';
const event = (value: Record<string, unknown>) =>
process.stdout.write(`${JSON.stringify(value)}\n`);
void withCommandCancellation(async (signal) => {
if (process.argv.length > 2)
throw new Error(translate(process.env, '容器入口仅接受通过环境变量配置。'));
return runContainer(
createContext({}, { ...process.env, QL_DIR: process.env.QL_DIR || '/ql' }),
signal,
{ event },
);
}).then(
(code) => {
process.exitCode = code;
},
(error: unknown) => {
process.stderr.write(
`${JSON.stringify({
event: 'error',
message: error instanceof Error ? error.message : String(error),
})}\n`,
);
process.exitCode = 1;
},
);
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { main } from '../compatibility/main';
void main().then((code) => {
process.exitCode = code;
});
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
import { internalMain } from '../internal/commands/main';
import { routeCommands } from '../shared/cli/router';
import { commands } from '../internal/commands/registry';
import { helpText } from '../shared/i18n/help';
import { quietCommand } from '../shared/cli/options';
import { standaloneHelp } from '../internal/help/standalone';
// Preserve the old ambiguous vocabulary without importing the remote command tree.
const remoteTaskActions = new Set([
'view-list',
'view-create',
'view-update',
'view-delete',
'view-move',
'view-disable',
'view-enable',
'detail',
'create',
'labels-delete',
'labels-create',
'disable',
'enable',
'update',
'delete',
'pin',
'unpin',
'import',
'status',
'instances',
'instance-stop',
'log-files',
'list',
'get',
'run',
'stop',
'logs',
]);
const operatorActions = new Set(
commands
.filter((command) => command.local)
.map((command) => command.name.slice(6)),
);
// Normalize only the leading legacy positional token. Values belonging to
// named options and arguments after -- must never be reinterpreted.
export function operatorArguments(args: string[]): string[] {
const [action, value, ...rest] = args;
if (action === 'update' && (value === 'true' || value === 'false'))
return [action, ...(value === 'false' ? ['--download-only'] : []), ...rest];
if (
action === 'reload' &&
['services', 'system', 'data'].includes(value ?? '')
)
return [action, '--target', value!, ...rest];
return args;
}
function splitPrefix(args: string[]): { prefix: string[]; body: string[] } {
let offset = 0;
while (args[offset] === '--json') offset++;
return { prefix: args.slice(0, offset), body: args.slice(offset) };
}
function writeHelp(help: string, json: boolean): number {
process.stdout.write(
(json ? JSON.stringify({ code: 200, data: { help } }) : help) + '\n',
);
return 0;
}
function groupHelp(group: 'root' | 'task' | 'local'): string {
const english = process.env.QL_LANG === 'en';
if (group === 'task') return standaloneHelp('runner');
const program = quietCommand(group === 'local' ? 'ql local' : 'ql');
program.description(
english ? 'QingLong panel-internal tools' : '青龙面板内部工具',
);
// Operator help and routing both derive from the command registry.
for (const spec of commands.filter((command) => command.local)) {
const child = quietCommand(spec.name.slice(6));
child.description(helpText(spec.summary));
if (spec.arguments) child.arguments(spec.arguments);
program.addCommand(child);
}
for (const [name, description] of [
...(group === 'root'
? [
[
'task',
english
? 'Execute local scripts (alias: task)'
: '执行本机脚本(简写:task)',
],
]
: []),
['repo', english ? 'Synchronize a repository locally' : '本机仓库同步'],
['raw', english ? 'Download a script locally' : '本机脚本下载'],
])
program.addCommand(quietCommand(name!).description(description!));
program
.option('--json', english ? 'JSON output' : 'JSON 输出')
.option('-h, --help', english ? 'Show help' : '显示帮助');
return (
program
.helpInformation()
.replace(/^Usage:/m, english ? 'Usage:' : '用法:')
.replace(/^Options:/m, english ? 'Options:' : '选项:')
.replace(/^Commands:/m, english ? 'Commands:' : '命令:') +
(english
? '\nLocal tools only. Remote management uses the separate @whyour/qinglong-cli npm entry. ql local remains a maintenance alias.\n'
: '\n仅操作本机;远程管理使用独立的 @whyour/qinglong-cli npm 入口。ql local 保留为运维兼容别名。\n')
);
}
function internalOnly(args: string[]): number {
const message =
process.env.QL_LANG === 'en'
? 'Unknown or remote command. This entry only operates the local panel; use the separate @whyour/qinglong-cli entry for remote management. Use task exec for scripts named like API actions.'
: '未知命令或远程命令。此入口仅操作本机面板;远程管理请使用独立的 @whyour/qinglong-cli 入口。同名脚本请使用 task exec。';
process.stderr.write(
(args.includes('--json') ? JSON.stringify({ code: 2, message }) : message) +
'\n',
);
return 2;
}
export async function taskMain(args = process.argv.slice(2)): Promise<number> {
const { prefix, body } = splitPrefix(args);
if (
body.some((arg) => ['--help', '-h'].includes(arg)) &&
body.every((arg) => ['--help', '-h', '--json'].includes(arg))
)
return writeHelp(groupHelp('task'), args.includes('--json'));
const execute = async (scriptArgs: string[]) => {
const { runnerMain } = await import('../internal/execution/runner');
return runnerMain([...prefix, ...scriptArgs]);
};
return routeCommands(
'task',
body,
{
...Object.fromEntries(
[...remoteTaskActions].map((action) => [
action,
async () => internalOnly(args),
]),
),
exec: execute,
},
() => execute(body),
);
}
export async function qlMain(args = process.argv.slice(2)): Promise<number> {
const { prefix, body } = splitPrefix(args);
const [group, ...rest] = body;
if (
!group ||
body.every((arg) => ['help', '--help', '-h', '--json'].includes(arg))
)
return writeHelp(groupHelp('root'), args.includes('--json'));
const local = async (localArgs: string[]): Promise<number> => {
if (
!localArgs.length ||
localArgs.every((arg) => ['--help', '-h', '--json'].includes(arg))
)
return writeHelp(groupHelp('local'), args.includes('--json'));
if (localArgs[0] === 'repo' || localArgs[0] === 'raw') {
if (
localArgs.slice(1).some((arg) => arg === '--help' || arg === '-h') &&
localArgs
.slice(1)
.every((arg) => ['--help', '-h', '--json'].includes(arg))
)
return writeHelp(standaloneHelp('worker'), args.includes('--json'));
const { subscriptionWorker } = await import(
'../internal/subscription/worker'
);
return subscriptionWorker(localArgs);
}
const { withCommandCancellation, cancellableOperation, interruptedCode } =
await import('../internal/runtime/cancellation');
return withCommandCancellation(async (signal) => {
try {
return await cancellableOperation(signal, () =>
internalMain(
[...prefix, 'local', ...operatorArguments(localArgs)],
signal,
),
);
} catch (error) {
const code = interruptedCode(signal);
if (code !== undefined) return code;
throw error;
}
});
};
// The legacy -l prefix predates option-based command routing.
if (!prefix.length && group === '-l') {
const { compatibilityMain } = await import('../compatibility/legacy');
return compatibilityMain(body);
}
return routeCommands(
'ql',
body,
{
task: (rest) => taskMain([...prefix, ...rest]),
local,
...Object.fromEntries(
[...operatorActions, 'repo', 'raw'].map((action) => [
action,
(rest: string[]) => local([action, ...rest]),
]),
),
},
async () => internalOnly(args),
);
}
if (require.main === module)
void qlMain().then((code) => {
process.exitCode = code;
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { registry } from '../remote/commands/registry';
import { invoke } from '../shared/cli/invoke';
import { dispatchRemote } from '../remote/commands/dispatch';
export function remoteMain(args = process.argv.slice(2)): Promise<number> {
return invoke(args, registry, dispatchRemote);
}
if (require.main === module)
void remoteMain().then((code) => {
process.exitCode = code;
});
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { taskMain } from './ql';
export { taskMain } from './ql';
if (require.main === module)
void taskMain().then(code => { process.exitCode = code; });
+42
View File
@@ -0,0 +1,42 @@
import { fail } from '../../shared/errors';
import type { Invocation } from '../../shared/cli/arguments';
import type { ApiResponse, LogResponse } from '../../shared/types';
export async function dispatch(
command: Invocation,
): Promise<ApiResponse<unknown> | LogResponse> {
const { name } = command;
if (!name.startsWith('local '))
fail('Internal dispatcher requires a local command.', 2);
const { maintenance } = await import('../maintenance/maintenance');
const action = name.slice('local '.length);
if (
[
'update',
'reload',
'rmlog',
'extra',
'bot',
'check',
'resetlet',
'resettfa',
'resetpwd',
'resetname',
].includes(action)
) {
const { localContext } = await import('../runtime/context');
const { loggedOperation } = await import('../runtime/commandLog');
const { operationSignal } = await import('../runtime/cancellation');
const signal = operationSignal();
const context = await localContext(command, { signal });
const { result, logPath } = await loggedOperation(
context,
action,
() => maintenance(command, context),
signal,
);
const response = { code: 200 as const, data: result, logPath };
return response;
}
return { code: 200, data: await maintenance(command) };
}
+14
View File
@@ -0,0 +1,14 @@
import { invoke } from '../../shared/cli/invoke';
import { registry } from './registry';
import { dispatch } from './dispatch';
export async function internalMain(
args: string[],
signal?: AbortSignal,
): Promise<number> {
const interrupted = signal
? await import('../runtime/cancellation')
: undefined;
return invoke(args, registry, dispatch, signal, () =>
interrupted?.interruptedCode(signal!),
);
}
+133
View File
@@ -0,0 +1,133 @@
import type {
CommandSpec,
CommandRegistry,
OptionSpec,
} from '../../shared/cli/registry';
import { integer } from '../../shared/cli/arguments';
export const commands: readonly CommandSpec[] = [
{
name: 'local rmlog',
summary: 'Remove expired logs not referenced by a task',
arguments: '<days>',
minimum: 1,
maximum: 1,
local: true,
},
{
name: 'local extra',
summary: 'Execute the user extra.sh hook',
local: true,
},
{
name: 'local repair-config',
summary: 'Restore missing configuration templates and runtime directories',
local: true,
},
{
name: 'local check',
summary:
'Install runtime dependencies, repair files, diagnose and reload services',
local: true,
},
{
name: 'local bot',
summary: 'Install dependencies and start the optional local Telegram bot',
local: true,
},
{
name: 'local start',
summary: 'Install runtime prerequisites and start nginx and panel services',
local: true,
options: {
'no-startup': {
type: 'boolean',
description: 'Skip OS boot registration (for containers)',
},
reload: {
type: 'boolean',
description:
'Restart services without installing packages or starting optional hooks',
},
},
},
{
name: 'local reload',
summary: 'Restart local services or apply staged system/data files',
local: true,
options: {
target: {
type: 'string',
description: 'Reload target',
default: 'services',
choices: ['services', 'system', 'data'],
},
},
},
{
name: 'local update',
summary: 'Stage and apply a local panel upgrade',
local: true,
options: {
mirror: {
type: 'string',
description: 'Archive source',
default: 'github',
choices: ['github', 'gitee'],
},
'download-only': {
type: 'boolean',
description:
'Prepare upgrade without replacing files or restarting services',
},
},
},
...['resetlet', 'resettfa', 'resetpwd', 'resetname'].map(
(action): CommandSpec => ({
name: `local ${action}`,
summary: `Local panel ${action}`,
arguments: ['resetpwd', 'resetname'].includes(action)
? '<value>'
: undefined,
minimum: ['resetpwd', 'resetname'].includes(action) ? 1 : 0,
maximum: ['resetpwd', 'resetname'].includes(action) ? 1 : 0,
local: true,
}),
),
];
export const aliases: Record<string, string[]> = {
rmlog: ['local', 'rmlog'],
extra: ['local', 'extra'],
'repair-config': ['local', 'repair-config'],
check: ['local', 'check'],
bot: ['local', 'bot'],
start: ['local', 'start'],
reload: ['local', 'reload'],
update: ['local', 'update'],
resetlet: ['local', 'resetlet'],
resettfa: ['local', 'resettfa'],
resetpwd: ['local', 'resetpwd'],
resetname: ['local', 'resetname'],
};
export const localOptions: Record<string, OptionSpec> = {
root: {
type: 'string',
description: 'Installed panel root (defaults to QL_DIR)',
},
'data-dir': {
type: 'string',
description: 'Panel data directory (defaults to QL_DATA_DIR)',
},
};
export const registry: CommandRegistry = {
commands,
aliases,
options: localOptions,
surface: 'local',
validate(command) {
if (command.name === 'local rmlog')
integer(command.positionals[0]!, 0, 365000);
},
};
+48
View File
@@ -0,0 +1,48 @@
import fs from 'node:fs/promises';
import { createReadStream, writeSync } from 'node:fs';
import path from 'node:path';
// Preserve legacy account-order output while all account processes run concurrently.
// Spool to private files instead of retaining output buffers in the parent process.
export async function orderedConcurrent<T>(
root: string,
accounts: number[],
invoke: (account: number, output: (chunk: Buffer) => void) => Promise<T>,
output: (chunk: Buffer) => void,
): Promise<T[]> {
await fs.mkdir(root, { recursive: true });
const directory = await fs.mkdtemp(path.join(root, '.concurrent-'));
try {
const results = await Promise.allSettled(
accounts.map(async (account, index) => {
const file = await fs.open(
path.join(directory, `${index}.log`),
'wx',
0o600,
);
try {
return await invoke(account, (chunk) => {
let offset = 0;
while (offset < chunk.length)
offset += writeSync(file.fd, chunk, offset);
});
} finally {
await file.close();
}
}),
);
// Wait for every writer even if one child could not start, then preserve its
// peers' diagnostics before surfacing that failure.
for (let index = 0; index < accounts.length; index++) {
const filename = path.join(directory, `${index}.log`);
if (!(await fs.stat(filename).catch(() => undefined))) continue;
for await (const chunk of createReadStream(filename))
output(chunk as Buffer);
}
const failure = results.find((result) => result.status === 'rejected');
if (failure?.status === 'rejected') throw failure.reason;
return results.map((result) => (result as PromiseFulfilledResult<T>).value);
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { quietCommand, addFlags } from '../../shared/cli/options';
// The parser and Commander help share the same options. Script modes and
// arguments after the script remain the legacy execution adapter's concern.
export const runnerOptions = {
root: {
type: 'string',
description: 'Installed panel root',
chinese: '面板安装目录',
},
'data-dir': {
type: 'string',
description: 'Panel data directory',
chinese: '面板数据目录',
},
timeout: {
type: 'string',
short: 'm',
description: 'Execution timeout',
chinese: '执行超时',
},
log: {
type: 'boolean',
short: 'l',
description: 'Legacy compatibility flag',
chinese: '旧命令兼容选项',
},
json: {
type: 'boolean',
description: 'JSON result; script output on stderr',
chinese: 'JSON 结果;脚本输出写入 stderr',
},
help: {
type: 'boolean',
short: 'h',
description: 'Show help',
chinese: '显示帮助',
},
} as const;
export function runnerHelp(env: NodeJS.ProcessEnv): string {
const english = env.QL_LANG === 'en';
const command = quietCommand('ql task exec')
.arguments('[script] [args...]')
.description(english ? 'QingLong local task runner' : '青龙本机任务执行器');
addFlags(
command,
Object.fromEntries(
Object.entries(runnerOptions).map(([name, option]) => [
name,
{
...option,
description: english ? option.description : option.chinese,
},
]),
),
);
const notes = english
? 'task is shorthand for ql task. Omit the script to list available JS scripts.\nCLI options precede the script; -- passes script arguments through.\nModes: <script> now; <script> conc|desi <variable> [account-ranges...].\nRemote task actions require the separate @whyour/qinglong-cli npm entry. Use task exec for scripts with reserved API action names.'
: 'task 是 ql task 的简写。不指定脚本时列出可用 JS 脚本。\nCLI 选项放在脚本之前;-- 后的参数原样传给脚本。\n模式:<script> now;<script> conc|desi <variable> [account-ranges...]。\n远程任务操作使用独立的 @whyour/qinglong-cli npm 入口;同名脚本使用 task exec。';
return (
command
.helpInformation()
.replace(/^Usage:/m, english ? 'Usage:' : '用法:')
.replace(/^Options:/m, english ? 'Options:' : '选项:') +
'\n' +
notes +
'\n'
);
}
@@ -0,0 +1,37 @@
import path from 'node:path';
import type { LocalContext } from '../runtime/context';
import { runProcess } from '../runtime/process';
export async function taskDependencyEnvironment(
context: LocalContext,
): Promise<NodeJS.ProcessEnv> {
const env = { ...context.env };
env.PREV_NODE_PATH = env.NODE_PATH || '';
const paths = [env.NODE_PATH, context.paths.dir_dep].filter(Boolean);
// pnpm is optional for task execution. Bound both probe time and captured output.
try {
const result = await runProcess('pnpm', ['root', '-g'], {
cwd: context.root,
env,
capture: true,
output: () => {},
timeoutMs: 5000,
graceMs: 100,
maxCaptureBytes: 65536,
});
const global = result.stdout.trim();
if (
result.code === 0 &&
global &&
path.isAbsolute(global) &&
!/[\r\n\0]/.test(global)
) {
env.QL_NODE_GLOBAL_PATH = global;
paths.push(global);
} else delete env.QL_NODE_GLOBAL_PATH;
} catch {
delete env.QL_NODE_GLOBAL_PATH;
}
env.NODE_PATH = paths.join(path.delimiter);
return env;
}
@@ -0,0 +1,41 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
nodePreloadSource,
pythonPreloadSource,
esmLoaderSource,
} from './preloadSource';
// Keep runtime adapters private to an execution; generated panel modules remain
// live links so account/config updates retain the panel's existing semantics.
export async function prepareLanguagePreload(source: string): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-'));
try {
for (const name of await fs.readdir(source)) {
if (
[
'sitecustomize.js',
'sitecustomize.py',
'esm-loader.mjs',
'__pycache__',
].includes(name)
)
continue;
await fs.symlink(path.join(source, name), path.join(directory, name));
}
await fs.writeFile(
path.join(directory, 'sitecustomize.js'),
nodePreloadSource,
);
await fs.writeFile(
path.join(directory, 'sitecustomize.py'),
pythonPreloadSource,
);
await fs.writeFile(path.join(directory, 'esm-loader.mjs'), esmLoaderSource);
return directory;
} catch (error) {
await fs.rm(directory, { recursive: true, force: true });
throw error;
}
}
+327
View File
@@ -0,0 +1,327 @@
// Runtime language adapters derived from the retained panel preloaders.
// Keep these sources readable: the build embeds them without extra runtime files.
export const nodePreloadSource: string = [
"const { execFileSync } = require('child_process');",
"const Module = require('module');",
"const path = require('path');",
"const client = require('./client.js');",
'require(`./env.js`);',
'',
'// 注册 ESM loader,使全局安装的包也可通过 import 导入',
'try {',
" Module.register(require('node:url').pathToFileURL(path.join(__dirname, 'esm-loader.mjs')).href);",
'} catch (_) {}',
'',
'function preferGlobalNodeModules() {',
' const { QL_NODE_GLOBAL_PATH } = process.env;',
' if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) {',
' return;',
' }',
'',
' const originalResolveFilename = Module._resolveFilename;',
' Module._resolveFilename = function (request, parent, isMain, options) {',
' if (',
' !Module.builtinModules.includes(request) &&',
" !request.startsWith('node:') &&",
" !request.startsWith('.') &&",
' !path.isAbsolute(request)',
' ) {',
' try {',
' return originalResolveFilename.call(this, request, parent, isMain, {',
' ...options,',
' paths: [QL_NODE_GLOBAL_PATH],',
' });',
' } catch (error) {}',
' }',
'',
' return originalResolveFilename.call(this, request, parent, isMain, options);',
' };',
' Module._qlGlobalPathPatched = true;',
'}',
'',
'function expandRange(rangeStr, max) {',
' const tempRangeStr = rangeStr',
' .trim()',
' .replace(/-max/g, `-${max}`)',
' .replace(/max-/g, `${max}-`);',
'',
" return tempRangeStr.split(' ').flatMap((part) => {",
' const rangeMatch = part.match(/^(\\d+)([-~_])(\\d+)$/);',
' if (rangeMatch) {',
' const [, start, , end] = rangeMatch.map(Number);',
' const step = start < end ? 1 : -1;',
' return Array.from(',
' { length: Math.abs(end - start) + 1 },',
' (_, i) => start + i * step,',
' );',
' }',
' return Number(part);',
' });',
'}',
'',
'function run() {',
' const {',
' envParam,',
' numParam,',
' file_task_before,',
' file_task_before_js,',
' dir_scripts,',
' task_before,',
' PREV_NODE_OPTIONS,',
' } = process.env;',
'',
' try {',
' process.env.NODE_OPTIONS = PREV_NODE_OPTIONS;',
'',
" const splitStr = '__sitecustomize__';",
" const fileName = process.argv[1].replace(`${dir_scripts}/`, '');",
' const tempFile = `/tmp/env_${process.pid}.json`;',
'',
' const commands = `file="$1"; output="$2"; inline="$3"; shift 3',
'source "$file" "$@" &&',
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
" if (task_before) console.log('执行前置命令\\n');",
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
' });',
'',
' const [output] = res.split(splitStr);',
'',
' try {',
" const envStr = require('fs').readFileSync(tempFile, 'utf-8');",
' const newEnvObject = JSON.parse(envStr);',
" if (typeof newEnvObject === 'object' && newEnvObject !== null) {",
' for (const key in newEnvObject) {',
' if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) {',
' process.env[key] = newEnvObject[key];',
' }',
' }',
' }',
" require('fs').unlinkSync(tempFile);",
' } catch (jsonError) {',
' console.log(',
" '\\ue926 Failed to parse environment variables:',",
' jsonError.message,',
' );',
' try {',
" require('fs').unlinkSync(tempFile);",
' } catch (e) {}',
' }',
'',
' if (output) {',
' console.log(output);',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
' }',
' } catch (error) {',
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
' console.log(`\\ue926 run task before error: `, error);',
' } else {',
' // environment variable is too large',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
' }',
' }',
'',
' require(file_task_before_js);',
'',
' if (envParam && numParam) {',
" const array = (process.env[envParam] || '').split('&');",
' const runArr = expandRange(numParam, array.length);',
' const arrayRun = runArr.map((i) => array[i - 1]);',
" const envStr = arrayRun.join('&');",
' process.env[envParam] = envStr;',
' }',
'}',
'',
'try {',
' if (!process.argv[1]) {',
' return;',
' }',
'',
' preferGlobalNodeModules();',
'',
" process.on('SIGTERM', (code) => {",
' process.exit(15);',
' });',
'',
' run();',
'',
" const { sendNotify } = require('./__ql_notify__.js');",
' global.QLAPI = {',
' notify: sendNotify,',
' ...client,',
' };',
'} catch (error) {',
" console.log(`run builtin code error: `, error, '\\n');",
'}',
'',
].join('\n');
export const pythonPreloadSource: string = [
'import os',
'import re',
'import subprocess',
'import json',
'import builtins',
'import sys',
'import env',
'import signal',
'from client import Client',
'',
'',
'def try_parse_int(value):',
' try:',
' return int(value)',
' except ValueError:',
' return None',
'',
'',
'def expand_range(range_str, max_value):',
' temp_range_str = (',
' range_str.strip()',
' .replace("-max", f"-{max_value}")',
' .replace("max-", f"{max_value}-")',
' )',
'',
' result = []',
' for part in temp_range_str.split(" "):',
' range_match = re.match(r"^(\\d+)([-~_])(\\d+)$", part)',
' if range_match:',
' start, _, end = map(try_parse_int, range_match.groups())',
' step = 1 if start < end else -1',
' result.extend(range(start, end + step, step))',
' else:',
' result.append(int(part))',
'',
' return result',
'',
'',
'def run():',
' try:',
' prev_pythonpath = os.getenv("PREV_PYTHONPATH", "")',
' os.environ["PYTHONPATH"] = prev_pythonpath',
'',
' split_str = "__sitecustomize__"',
' file_name = sys.argv[0].replace(f"{os.getenv(\'dir_scripts\')}/", "")',
' ',
' # 创建临时文件路径',
' temp_file = f"/tmp/env_{os.getpid()}.json"',
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("执行前置命令\\n")',
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
'source "$file" "$@" &&',
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'python3 -c \'import os,json,sys; f=open(sys.argv[1],"w"); json.dump(dict(os.environ),f); f.close()\' "$output"',
'"""',
' hook_args = json.loads(os.getenv("QL_CLI_PRELOAD_ARGS", json.dumps([file_name])))',
' res = subprocess.check_output(["/bin/bash", "-c", command, "ql-before",',
' os.environ["file_task_before"], temp_file, task_before, *hook_args], encoding="utf-8")',
' output = res.split(split_str)[0]',
'',
' try:',
" with open(temp_file, 'r') as f:",
' env_json = json.loads(f.read())',
'',
' for key, value in env_json.items():',
' os.environ[key] = value',
'',
' os.unlink(temp_file)',
' except Exception as json_error:',
' print(f"\\ue926 Failed to parse environment variables: {json_error}")',
' try:',
' os.unlink(temp_file)',
' except:',
' pass',
'',
' if len(output) > 0:',
' print(output)',
' if task_before:',
' print("执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' except OSError as error:',
' error_message = str(error)',
' if "Argument list too long" not in error_message:',
' print(f"\\ue926 run task before error: {error}")',
' # else:',
' # environment variable is too large',
' if task_before:',
' print("执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
'',
' import task_before',
'',
' env_param = os.getenv("envParam")',
' num_param = os.getenv("numParam")',
'',
' if env_param and num_param:',
' array = (os.getenv(env_param) or "").split("&")',
' run_arr = expand_range(num_param, len(array))',
' array_run = [array[i - 1] for i in run_arr if i - 1 < len(array) and i > 0]',
' env_str = "&".join(array_run)',
' os.environ[env_param] = env_str',
'',
'',
'def handle_sigterm(signum, frame):',
' sys.exit(15)',
'',
'',
'try:',
' signal.signal(signal.SIGTERM, handle_sigterm)',
'',
' run()',
'',
' from __ql_notify__ import send',
'',
' class BaseApi(Client):',
' def notify(self, *args, **kwargs):',
' return send(*args, **kwargs)',
'',
' QLAPI = BaseApi()',
' builtins.QLAPI = QLAPI',
'except Exception as error:',
' print(f"run builtin code error: {error}\\n")',
'',
].join('\n');
// Native ESM resolution with global-first package selection.
export const esmLoaderSource: string = [
"import { existsSync } from 'node:fs';",
"import { isBuiltin } from 'node:module';",
"import { join } from 'node:path';",
"import { pathToFileURL } from 'node:url';",
'',
'export function resolve(specifier, context, nextResolve) {',
' // Leave builtins, package imports, paths and URL schemes to Node unchanged.',
' if (isBuiltin(specifier) || /^[.#/]/.test(specifier) || /^[a-zA-Z][a-zA-Z\\d+.-]*:/.test(specifier))',
' return nextResolve(specifier, context);',
" const parts = specifier.split('/');",
" const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];",
" for (const base of [process.env.QL_NODE_GLOBAL_PATH, '/usr/local/lib/node_modules'].filter(Boolean)) {",
' const directory = join(base, name);',
' if (!existsSync(directory)) continue;',
" // Resolve from inside the package, retaining Node's exports, import conditions",
' // and package-boundary checks. A virtual file need not exist for resolution.',
' return nextResolve(specifier, {',
' ...context,',
" parentURL: pathToFileURL(join(directory, '__ql_resolve__.mjs')).href,",
' });',
' }',
' return nextResolve(specifier, context);',
'}',
'',
].join('\n');
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
import { runnerOptions } from './definition';
import { standaloneHelp } from '../help/standalone';
import { translate } from '../../shared/i18n/index';
import { parseFlags, isArgumentError } from '../../shared/cli/options';
import { createContext, localContext } from '../runtime/context';
import { executeTask, type ExecutionOptions } from './taskRunner';
import { CliError, fail } from '../../shared/errors';
import { cancellationExitCode } from '../runtime/process';
import { commandSignals } from '../runtime/cancellation';
export function parseExecution(args: string[]): {
values: Record<string, string | boolean | undefined>;
execution?: ExecutionOptions;
} {
const schema = runnerOptions;
let parsed: ReturnType<typeof parseFlags>;
try {
// Commander stops parsing at the first positional argument. Everything
// from the script onward belongs to the legacy runner, including flags.
parsed = parseFlags(args, schema, {
passThrough: true,
rejectDuplicates: false,
});
} catch (error) {
if (!isArgumentError(error)) throw error;
fail(translate(process.env, '任务执行器选项无效。'), 2);
}
const { values, positionals: body } = parsed;
if (values.help || !body.length) return { values };
const separator = body.indexOf('--');
const positional = separator >= 0 ? body.slice(0, separator) : body;
const scriptArgs = separator >= 0 ? body.slice(separator + 1) : [];
const mode = ['now', 'conc', 'desi'].includes(positional[1] ?? '')
? (positional[1] as 'now' | 'conc' | 'desi')
: 'normal';
return {
values,
execution: {
argv: mode === 'normal' ? positional : [positional[0]!],
mode,
scriptArgs,
variable: mode === 'conc' || mode === 'desi' ? positional[2] : undefined,
selection: positional.slice(3).join(' '),
timeout: values.timeout as string | undefined,
},
};
}
export async function runnerMain(
args = process.argv.slice(2),
): Promise<number> {
// Parsing can fail before returning its values. Preserve an explicit JSON
// request in that case; successful parsing still owns the script boundary.
let json = args
.slice(0, args.indexOf('--') < 0 ? undefined : args.indexOf('--'))
.includes('--json');
const controller = new AbortController();
const cancel = (signal: NodeJS.Signals) => controller.abort(signal);
try {
const parsed = parseExecution(args);
json = parsed.values.json === true;
if (!parsed.execution) {
const usage = standaloneHelp('runner');
const scripts =
!parsed.values.help && (parsed.values.root || process.env.QL_DIR)
? await (
await import('./scriptInventory')
).listTaskScripts(createContext(parsed.values))
: undefined;
const listing =
scripts === undefined
? ''
: '\n' +
(scripts.length
? translate(process.env, '当前有以下脚本可以运行:') +
'\n' +
scripts
.map(
(script, index) =>
`${index + 1}. ${script.name ?? script.file}:${
script.file
}`,
)
.join('\n')
: translate(process.env, '暂无脚本可以执行')) +
'\n';
process.stdout.write(
json
? JSON.stringify({ code: 200, data: { help: usage, scripts } }) + '\n'
: usage + listing,
);
return 0;
}
for (const signal of commandSignals) process.on(signal, cancel);
const context = await localContext(
{
values: parsed.values,
positionals: parsed.execution.argv,
},
{ signal: controller.signal },
);
const result = await executeTask(context, {
...parsed.execution,
signal: controller.signal,
output: (chunk) => {
(json ? process.stderr : process.stdout).write(chunk);
},
});
if (json)
process.stdout.write(JSON.stringify({ code: 200, data: result }) + '\n');
return result.exitCode;
} catch (error) {
const cancelled =
controller.signal.aborted &&
error instanceof Error &&
error.name === 'AbortError' &&
(error as NodeJS.ErrnoException).code === 'ABORT_ERR';
const code = cancelled
? cancellationExitCode(controller.signal)
: error instanceof CliError
? error.exitCode
: 1;
const message = cancelled
? translate(process.env, '本机任务在加载配置时已取消。')
: error instanceof CliError
? error.message
: translate(process.env, '本机任务执行失败,请检查面板环境和任务日志。');
process.stderr.write(
(json ? JSON.stringify({ code, message }) : message) + '\n',
);
return code;
} finally {
for (const signal of commandSignals) process.removeListener(signal, cancel);
}
}
if (require.main === module)
void runnerMain().then((code) => {
process.exitCode = code;
});
@@ -0,0 +1,41 @@
import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import path from 'node:path';
import { createInterface } from 'node:readline';
import type { LocalContext } from '../runtime/context';
// Inspect legacy top-level JS candidates without importing or executing them.
export async function listTaskScripts(
context: LocalContext,
): Promise<{ file: string; name: string | null }[]> {
const directory = context.paths.dir_scripts!;
const entries = await fs
.readdir(directory)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return [];
throw error;
});
const scripts: { file: string; name: string | null }[] = [];
for (const file of entries.sort()) {
if (!file.endsWith('.js') || file === 'sendNotify.js') continue;
const target = path.join(directory, file);
if (!(await fs.stat(target)).isFile()) continue;
const input = createReadStream(target, { encoding: 'utf8' });
const lines = createInterface({ input, crlfDelay: Infinity });
let name: string | null = null;
try {
for await (const line of lines) {
const match = /\bnew\s+Env\s*\(\s*(['"])(.*?)\1/.exec(line);
if (match) {
name = match[2]!;
break;
}
}
} finally {
lines.close();
input.destroy();
}
scripts.push({ file, name });
}
return scripts;
}
@@ -0,0 +1,20 @@
// Restore the captured state, including Bash 3.x without native BASHOPTS.
export const shellOptionPrelude = `
if [ "\${QL_CLI_SHELLOPTS+x}" = x ]; then
while read -r __ql_option __ql_state; do
case ":$QL_CLI_SHELLOPTS:" in
*:"$__ql_option":*) ;;
*) set +o "$__ql_option" ;;
esac
done < <(set -o)
fi
if [ "\${QL_CLI_BASHOPTS+x}" = x ]; then
while read -r __ql_builtin __ql_state __ql_option; do
case ":$QL_CLI_BASHOPTS:" in
*:"$__ql_option":*) shopt -s "$__ql_option" ;;
*) shopt -u "$__ql_option" ;;
esac
done < <(shopt -p)
fi
unset __ql_option __ql_state __ql_builtin
`;
@@ -0,0 +1,98 @@
import type { LocalContext } from '../runtime/context';
// Bash is the interpreter for user-owned files. The CLI supplies paths/argv as
// separate arguments; no user argument is interpolated into this fixed bridge.
export function shellSessionArguments(
context: LocalContext,
argv: string[],
scriptArgs: string[],
command = false,
timeoutMarker = '',
): string[] {
const scriptIndex = command
? argv.findIndex((arg) => /\.(?:js|mjs|py|pyc|sh|ts)$/.test(arg))
: 0;
const bridge = `
__ql_env=$1; __ql_before=$2; __ql_after=$3; __ql_command=$4; __ql_index=$5; __ql_count=$6; __ql_timeout=$7; shift 7
__ql_hook_args=( "\${@:1:__ql_count}" ); shift "$__ql_count"
__ql_script_args=( "$@" )
# Reserve an internal descriptor before sourcing user code. In particular, fd 3
# and fd 9 remain available for user redirections and locks. Bash 3 also supports
# this numeric descriptor, unlike Bash 4's dynamic descriptor syntax.
if [ -n "$__ql_timeout" ]; then exec 19>&3 3>&-; fi
__ql_after_started=false
__ql_run_after() {
__ql_after_started=true
if [ -n "$__ql_timeout" ]; then printf A >&19; fi
if [ "$__ql_nounset" = true ]; then set -u; fi
export NODE_PATH="\${PREV_NODE_PATH:-}"
unset QL_NODE_GLOBAL_PATH
if [ -f "$__ql_after" ]; then . "$__ql_after" "\${__ql_hook_args[@]}"; fi
if [ -n "\${task_after:-}" ]; then eval "\${task_after%;}"; fi
}
__ql_timeout_exit() {
if [ -f "$__ql_timeout" ] && [ "$__ql_after_started" = false ]; then
_task_exit_code=124
__ql_run_after
exit 124
fi
}
if [ -f "$__ql_env" ]; then . "$__ql_env"; fi
if [ -f "$__ql_before" ]; then . "$__ql_before" "\${__ql_hook_args[@]}"; fi
if [ -n "\${task_before:-}" ]; then eval "\${task_before%;}"; fi
__ql_nounset=false
case $- in *u*) __ql_nounset=true; set +u;; esac
if [ -n "\${__ql_selected_name:-}" ]; then
printf -v "$__ql_selected_name" '%s' "$__ql_selected_value"
export "$__ql_selected_name"
fi
unset __ql_selected_name __ql_selected_value
__ql_args=( "\${__ql_hook_args[@]}" )
if [ "$__ql_index" -lt 0 ]; then __ql_index=0; fi
__ql_file=\${__ql_args[__ql_index]}
__ql_resolve=false
cd "$dir_scripts" || exit 1
if [ -n "\${work_dir:-}" ] && [ -d "$work_dir" ]; then
cd "$work_dir" || exit 1
__ql_resolve=true
elif [[ "$__ql_file" == */* ]] && [ -d "\${__ql_file%/*}" ]; then
cd "\${__ql_file%/*}" || exit 1
__ql_resolve=true
fi
if [ "$__ql_resolve" = true ] && [[ "$__ql_file" == */* ]]; then
__ql_args[__ql_index]="./\${__ql_file##*/}"
fi
if [ -n "$__ql_timeout" ]; then
# Preserve a user-owned EXIT trap. If it prevents our cleanup, the parent
# performs the fallback after the process group has stopped.
if [ -z "$(trap -p EXIT)" ]; then trap '__ql_timeout_exit' EXIT; fi
printf T >&19
fi
if [ "$__ql_command" = true ]; then
"\${__ql_args[@]}" "\${__ql_script_args[@]}"
else
. "\${__ql_args[0]}" "\${__ql_script_args[@]}"
fi
_task_exit_code=$?
if [ -n "$__ql_timeout" ] && [ -f "$__ql_timeout" ]; then _task_exit_code=124; fi
__ql_run_after
exit "$_task_exit_code"
`;
return [
'--noprofile',
'--norc',
'-c',
bridge,
'ql-shell-session',
context.paths.file_env!,
context.paths.file_task_before!,
context.paths.file_task_after!,
String(command),
String(scriptIndex),
String(argv.length),
timeoutMarker,
...argv,
...(command ? [] : argv.slice(1)),
...scriptArgs,
];
}
+43
View File
@@ -0,0 +1,43 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runProcess } from '../runtime/process';
import { fail } from '../../shared/errors';
// Legacy random_delay substitutes literal spaces with ERE alternatives.
// Preserve the host grep engine and locale rather than translating ERE to JS.
export async function matchesDelayExtension(
filename: string,
env: NodeJS.ProcessEnv,
signal?: AbortSignal,
): Promise<boolean> {
const extensions = env.RandomDelayFileExtensions ?? 'js';
if (!extensions) return true;
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-'));
try {
const listing = path.join(directory, 'filename');
await fs.writeFile(listing, filename + '\n', { mode: 0o600 });
const pattern = `\\.${extensions.replace(/ /g, '$|\\.')}$`;
const result = await runProcess(
'grep',
['-qE', '-e', pattern, '--', listing],
{
env,
signal,
timeoutMs: 10000,
},
);
if (signal?.aborted)
throw Object.assign(new Error(translate(env, '任务延迟过滤已取消。')), {
name: 'AbortError',
code: 'ABORT_ERR',
});
// Original `if ! grep` skips delay for both no match and invalid patterns.
if (result.code === 0) return true;
if (result.code === 1 || result.code === 2) return false;
fail(translate(env, '任务延迟过滤失败(退出码 %s)。', result.code));
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
+542
View File
@@ -0,0 +1,542 @@
import { extendedLifecycle } from '../runtime/lifecycle';
import fs from 'node:fs/promises';
import { writeSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomInt, randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import type { LocalContext } from '../runtime/context';
import { sourceEnvironment } from '../runtime/context';
import { LocalApi } from '../runtime/api';
import { cancellationExitCode, runProcess } from '../runtime/process';
import { within } from '../runtime/files';
import { fail } from '../../shared/errors';
import { orderedConcurrent } from './concurrent';
import { shellSessionArguments } from './shellSession';
import { taskDependencyEnvironment } from './dependencies';
import { translate } from '../../shared/i18n/index';
import { matchesDelayExtension } from './taskDelay';
import { prepareLanguagePreload } from './languagePreload';
export interface ExecutionOptions {
argv: string[];
scriptArgs?: string[];
mode?: 'normal' | 'now' | 'conc' | 'desi';
variable?: string;
selection?: string;
timeout?: string;
output?: (chunk: Buffer) => void;
signal?: AbortSignal;
}
export interface ExecutionResult {
exitCode: number;
logPath: string;
durationSeconds: number;
executionId?: string;
taskId?: number;
timedOut: boolean;
}
export function selectedAccounts(
selection: string,
count: number,
environment: NodeJS.ProcessEnv = process.env,
): number[] {
const result = new Set<number>();
for (const item of (selection || '1-max').trim().split(/\s+/)) {
const expanded = item.replace(/max/g, String(count));
const match = expanded.match(/^(\d+)(?:[-~_](\d+))?$/);
if (!match) fail(translate(environment, '账号选择格式无效。'), 2);
const first = Number(match[1]),
last = Number(match[2] ?? match[1]);
if (
![first, last].every(
(value) => Number.isSafeInteger(value) && value >= 1 && value <= count,
)
)
fail(translate(environment, '账号选择超出环境变量的账号范围。'), 2);
const step = first <= last ? 1 : -1;
for (let value = first; ; value += step) {
result.add(value);
if (value === last) break;
}
}
return [...result];
}
export function durationMs(
value?: string,
environment: NodeJS.ProcessEnv = process.env,
): number | undefined {
if (!value) return undefined;
const match = value.match(/^(\d+(?:\.\d+)?)(s|m|h|d)?$/);
if (!match)
fail(translate(environment, '超时必须为正时长,例如 30s 或 5m。'), 2);
const result =
Number(match[1]) *
{ s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2] || 's']!;
if (!Number.isFinite(result) || result <= 0 || result > 2147483647)
fail(translate(environment, '超时时长超出支持范围。'), 2);
return result;
}
function timestamp(now: Date): string {
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
pad(now.getMilliseconds(), 3),
].join('-');
}
async function exists(file: string): Promise<boolean> {
return (await fs.stat(file).catch(() => undefined))?.isFile() ?? false;
}
async function taskIdentity(
context: LocalContext,
first: string,
): Promise<number | undefined> {
if (/^[1-9]\d*$/.test(context.env.ID ?? '')) return Number(context.env.ID);
const list = await fs
.readFile(context.paths.list_crontab_user!, 'utf8')
.catch(() => '');
for (const line of list.split('\n')) {
if (!line.includes(` ${first}`)) continue;
const id = line.match(/\bID=(?:["']?)(\d+)/)?.[1];
if (id && Number(id) > 0) return Number(id);
}
return undefined;
}
async function prepareProgram(
context: LocalContext,
argv: string[],
): Promise<{
program: string;
args: string[];
cwd: string;
languagePreload: boolean;
shellScript: boolean;
shellCommand: boolean;
}> {
const extensions = /\.(?:js|mjs|py|pyc|sh|ts)$/;
const first = argv[0]!;
const index = argv.findIndex((arg) => extensions.test(arg));
const candidate = index >= 0 ? argv[index]! : first;
let cwd = context.paths.dir_scripts!;
const workdir = context.env.work_dir;
const explicit = workdir ? path.resolve(cwd, workdir) : undefined;
let resolveBasename = false;
if (
explicit &&
(await fs.stat(explicit).catch(() => undefined))?.isDirectory()
) {
cwd = explicit;
resolveBasename = true;
} else if (candidate.includes('/')) {
const directory = path.dirname(path.resolve(cwd, candidate));
if ((await fs.stat(directory).catch(() => undefined))?.isDirectory()) {
cwd = directory;
resolveBasename = true;
}
}
const adjusted = [...argv];
if (index >= 0 && candidate.includes('/') && resolveBasename)
adjusted[index] = path.basename(candidate);
const extension = path.extname(first);
const program = (
{
'.js': process.execPath,
'.mjs': process.execPath,
'.py': 'python3',
'.pyc': 'python3',
'.ts': 'ts-node-transpile-only',
'.sh': 'bash',
} as Record<string, string>
)[extension];
const languagePreload =
!!program && extension !== '.sh' && (await exists(context.paths.file_env!));
const shellScript = extension === '.sh';
if (shellScript)
return {
program: 'bash',
args: [
'--noprofile',
'--norc',
'-c',
'file="$1"; shift; . "$file" "$@"',
'ql-script',
path.resolve(cwd, adjusted[0]!),
...adjusted.slice(1),
],
cwd,
languagePreload,
shellScript,
shellCommand: false,
};
const executable =
index < 0 && resolveBasename && first.includes('/')
? path.resolve(cwd, path.basename(first))
: first;
return {
program: program ?? 'bash',
// Bash resolves exported user functions and builtins as well as PATH commands.
// Keep command/arguments as argv data, never interpolate or eval them.
args: program
? adjusted
: ['--noprofile', '--norc', '-c', '"$@"', 'ql-command', executable, ...adjusted.slice(1)],
cwd,
languagePreload,
shellScript,
shellCommand: !program,
};
}
export async function executeTask(
context: LocalContext,
options: ExecutionOptions,
): Promise<ExecutionResult> {
if (!options.argv.length)
fail(translate(context.env, '必须指定脚本或可执行程序。'), 2);
const start = Date.now();
const started = Math.floor(start / 1000);
const first = options.argv[0]!;
const taskId = await taskIdentity(context, first);
const extended = await extendedLifecycle(context);
const executionId =
extended && context.env.QL_EXECUTION_ORIGIN === 'scheduled_system'
? `legacy-system:${started}:${randomUUID()}`
: undefined;
const fileName = path.basename(first, path.extname(first));
const parent = first.includes('/') ? path.basename(path.dirname(first)) : '';
const folder =
context.env.log_name ||
`${parent ? parent + '_' : ''}${fileName}${taskId ? '_' + taskId : ''}`;
const logPath =
folder === '/dev/null'
? '/dev/null'
: context.env.real_log_path ||
path.join(folder, `${timestamp(new Date(start))}.log`);
const fullLog =
logPath === '/dev/null'
? logPath
: within(context.paths.dir_log!, logPath, context.env);
const discardOutput = folder === '/dev/null';
const persistLog = context.env.real_time !== 'true' && !discardOutput;
if (persistLog) await fs.mkdir(path.dirname(fullLog), { recursive: true });
const log = persistLog ? await fs.open(fullLog, 'a', 0o600) : undefined;
const output = (chunk: Buffer) => {
if (discardOutput) return;
// Regular-file writes apply backpressure without retaining task output in memory.
let offset = 0;
while (log && offset < chunk.length)
offset += writeSync(log.fd, chunk, offset);
if (context.env.no_tee !== 'true' || context.env.real_time === 'true')
(options.output ?? ((value) => process.stderr.write(value)))(chunk);
};
const stderrOutput = discardOutput
? options.output ??
((value: Buffer) => {
process.stderr.write(value);
})
: undefined;
const api = new LocalApi(context);
const report = async (final: boolean, code = 0, duration = 0) => {
if (!taskId) return;
try {
await api.call('crons/status', 'PUT', {
ids: [taskId],
status: final ? '1' : '0',
pid: String(process.pid),
log_path: logPath,
last_execution_time: started,
last_running_time: duration,
...(final && extended ? { exit_code: code } : {}),
...(executionId ? { execution_id: executionId } : {}),
});
} catch {
output(
Buffer.from(
translate(context.env, '任务状态上报失败,请检查本机面板。\n'),
),
);
}
if (final && extended) {
try {
await api.call('dashboard/record', 'POST', {
ref_id: taskId,
code,
elapsed: duration,
});
} catch {
output(
Buffer.from(
translate(context.env, '任务统计上报失败,请检查本机面板。\n'),
),
);
}
}
};
let exitCode = 1,
timedOut = false;
let languageDirectory: string | undefined;
try {
await report(false);
output(
Buffer.from(
translate(
context.env,
'## 开始执行... %s\n',
new Date(start).toISOString(),
),
),
);
let program = await prepareProgram(context, options.argv);
const sharedShell =
(program.shellScript || program.shellCommand) &&
(!options.mode || ['normal', 'now', 'desi'].includes(options.mode));
let env = await taskDependencyEnvironment(context);
// The panel preloads own generated environment variables and language hooks.
if (program.languagePreload) {
languageDirectory = await prepareLanguagePreload(
context.paths.dir_preload!,
);
env.QL_CLI_PRELOAD_ARGS = JSON.stringify([
...options.argv,
...(options.scriptArgs ?? []),
]);
env.PREV_NODE_OPTIONS = env.NODE_OPTIONS || '';
env.PREV_PYTHONPATH = env.PYTHONPATH || '';
if (/\.(?:js|mjs|ts)$/.test(first))
env.NODE_OPTIONS = `--require ${JSON.stringify(
path.join(languageDirectory, 'sitecustomize.js'),
)} ${env.NODE_OPTIONS || ''}`;
else
env.PYTHONPATH = [
languageDirectory,
context.paths.dir_config,
env.PYTHONPATH,
]
.filter(Boolean)
.join(path.delimiter);
} else if (!sharedShell) {
env = await sourceEnvironment(
env,
[context.paths.file_env!, context.paths.file_task_before!],
options.argv,
{
command:
'if [ -n "${task_before:-}" ]; then eval "${task_before%;}"; fi',
signal: options.signal,
output,
stderrOutput,
},
);
program = await prepareProgram({ ...context, env }, options.argv);
}
const mode = options.mode ?? 'normal';
let accounts: number[] = [];
let allAccounts: string[] = [];
if (mode === 'conc' || mode === 'desi') {
if (
!options.variable ||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(options.variable)
)
fail(translate(env, '必须指定有效的账号环境变量名。'), 2);
const accountEnv =
program.languagePreload || sharedShell
? await sourceEnvironment(
context.env,
[context.paths.file_env!],
[],
{ signal: options.signal },
)
: env;
allAccounts = (accountEnv[options.variable] ?? '').split('&');
accounts = selectedAccounts(
options.selection ?? '1-max',
allAccounts.length,
env,
);
}
const maxDelay = Number(env.RandomDelay || 0);
const ignored = (env.RandomDelayIgnoredMinutes ?? '0 30')
.split(/\s+/)
.filter(Boolean)
.map(Number);
if (
mode === 'normal' &&
options.argv.length === 1 &&
/\.(?:js|mjs|py|pyc|sh|ts)$/.test(first) &&
context.env.real_time !== 'true' &&
context.env.no_delay !== 'true' &&
Number.isSafeInteger(maxDelay) &&
maxDelay > 0 &&
!ignored.includes(new Date().getMinutes()) &&
(await matchesDelayExtension(first, env, options.signal))
) {
const seconds = randomInt(1, maxDelay + 1);
output(
Buffer.from(
translate(
env,
'任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n',
seconds,
new Date(Date.now() + seconds * 1000).toISOString(),
),
),
);
await delay(seconds * 1000, undefined, { signal: options.signal });
}
const invoke = async (selection?: number[], sink = output) => {
const childEnv = { ...env };
delete childEnv.__ql_selected_name;
delete childEnv.__ql_selected_value;
if (selection && options.variable) {
if (program.languagePreload) {
childEnv.envParam = options.variable;
childEnv.numParam = selection.join(' ');
} else {
childEnv[options.variable] = selection
.map((number) => allAccounts[number - 1]!)
.join('&');
if (sharedShell) {
childEnv.__ql_selected_name = options.variable;
childEnv.__ql_selected_value = childEnv[options.variable];
}
}
}
const timeoutMs = durationMs(options.timeout ?? env.CommandTimeoutTime, env);
const timeoutDirectory = sharedShell && timeoutMs
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-task-timeout-'))
: undefined;
const timeoutMarker = timeoutDirectory ? path.join(timeoutDirectory, 'expired') : '';
try {
return await runProcess(
program.program,
sharedShell
? shellSessionArguments(
context,
options.argv,
options.scriptArgs ?? [],
program.shellCommand,
timeoutMarker,
)
: [...program.args, ...(options.scriptArgs ?? [])],
{
cwd: sharedShell ? context.root : program.cwd,
env: childEnv,
// Preserve the legacy task's pipe/file input in every execution mode.
stdin: 'inherit',
output: sink,
// Legacy concurrent children merge stderr into their per-account log.
stderrOutput: mode === 'conc' ? undefined : stderrOutput,
timeoutMs,
...(timeoutMarker ? { timeoutControl: { marker: timeoutMarker } } : {}),
signal: options.signal,
},
);
} finally {
if (timeoutDirectory) await fs.rm(timeoutDirectory, { recursive: true, force: true });
}
};
const results =
mode === 'conc'
? await orderedConcurrent(
context.paths.dir_list_tmp!,
accounts,
(account, sink) => invoke([account], sink),
output,
)
: [await invoke(mode === 'desi' ? accounts : undefined)];
exitCode = results.find((result) => result.code !== 0)?.code ?? 0;
timedOut = results.some((result) => result.timedOut);
if (!sharedShell || results.some((result) => result.timedOut && !result.cleanupStarted)) {
// JS/Python pre-task hooks run inside the preloader; after hooks run once per task.
const afterEnv: NodeJS.ProcessEnv = {
...env,
_task_exit_code: String(exitCode),
};
afterEnv.NODE_PATH = env.PREV_NODE_PATH;
delete afterEnv.QL_NODE_GLOBAL_PATH;
if (program.languagePreload) {
afterEnv.NODE_OPTIONS = env.PREV_NODE_OPTIONS;
afterEnv.PYTHONPATH = env.PREV_PYTHONPATH;
}
await runProcess(
'bash',
[
'--noprofile',
'--norc',
'-c',
'file="$1"; shift; if [ -f "$file" ]; then . "$file" "$@"; fi; if [ -n "${task_after:-}" ]; then eval "${task_after%;}"; fi',
'ql-after',
context.paths.file_task_after!,
...options.argv,
],
{
cwd: program.cwd,
env: afterEnv,
output,
stderrOutput,
signal: options.signal,
},
);
if (options.signal?.aborted)
exitCode = cancellationExitCode(options.signal);
}
} catch (error) {
// The timer rejects before any task subprocess exists. Preserve the same
// cancellation result and lifecycle reporting as an interrupted process.
if (
options.signal?.aborted &&
error instanceof Error &&
error.name === 'AbortError' &&
(error as NodeJS.ErrnoException).code === 'ABORT_ERR'
) {
exitCode = cancellationExitCode(options.signal);
} else throw error;
} finally {
if (languageDirectory)
await fs.rm(languageDirectory, { recursive: true, force: true });
const durationSeconds = Math.max(
1,
Math.floor((Date.now() - start) / 1000),
);
await report(true, exitCode, durationSeconds);
output(
Buffer.from(
exitCode === 0
? translate(
context.env,
'\n## 完成 ✅... %s 耗时 %s 秒%s',
new Date().toISOString(),
durationSeconds,
'     \n',
)
: translate(
context.env,
'\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s',
exitCode,
new Date().toISOString(),
durationSeconds,
'     \n',
),
),
);
await log?.close();
}
return {
exitCode,
logPath,
durationSeconds: Math.max(1, Math.floor((Date.now() - start) / 1000)),
executionId,
taskId,
timedOut,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { runnerHelp } from '../execution/definition';
type Entry = 'runner' | 'compat' | 'worker' | 'startup';
const english: Record<Exclude<Entry, 'runner'>, string> = {
startup:
'Usage: qinglong-cli [reload] [--root PATH] [--data-dir PATH] [--no-startup] [--json]\nCompatibility entry for the legacy qinglong host startup command.\nNo mode: install prerequisites and start services. reload: restart without installing dependencies or registering host startup.\nQL_DIR and QL_DATA_DIR provide environment defaults. Use ql start --help for all options.\n',
compat:
'Usage: ql-compat [-l] <update|reload|repo|raw|rmlog|extra|bot|check|resetlet|resettfa|resetpwd|resetname> [legacy arguments]\nLocal compatibility adapter; requires QL_DIR. Select it for installed ql via QL_CLI_ROOT.\nUse the separate @whyour/qinglong-cli npm entry for authenticated panel management, ql task exec for local task execution.\n',
worker:
'Usage: ql repo|raw <url> [legacy subscription arguments]\nInternal local executor; manage panel subscriptions with the separate @whyour/qinglong-cli npm entry.\nRequires QL_DIR and optional QL_DATA_DIR/SUB_ID.\n',
};
const chinese: Record<Exclude<Entry, 'runner'>, string> = {
startup:
'用法:qinglong-cli [reload] [--root PATH] [--data-dir PATH] [--no-startup] [--json]\n兼容原 qinglong 宿主机启动命令。\n无模式参数:安装依赖并启动服务;reload:跳过依赖安装及开机注册后重载。\n可通过 QL_DIR、QL_DATA_DIR 设置目录。完整选项见 ql start --help。\n',
compat:
'用法:ql-compat [-l] <update|reload|repo|raw|rmlog|extra|bot|check|resetlet|resettfa|resetpwd|resetname> [legacy arguments]\n本机兼容适配器,需要 QL_DIR。可通过 QL_CLI_ROOT 将其选为已安装的 ql 入口。\n面板认证管理使用独立的 @whyour/qinglong-cli npm 入口,本机任务执行使用 ql task exec。\n',
worker:
'用法:ql repo|raw <url> [legacy subscription arguments]\n内部本机执行器;面板订阅管理使用独立的 @whyour/qinglong-cli npm 入口。\n需要 QL_DIR,可选 QL_DATA_DIR/SUB_ID。\n',
};
export function standaloneHelp(
entry: Entry,
env: NodeJS.ProcessEnv = process.env,
): string {
if (entry === 'runner') return runnerHelp(env);
return (env.QL_LANG === 'en' ? english : chinese)[entry];
}
@@ -0,0 +1,175 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import syncFs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
const begin = '# BEGIN QINGLONG CLI ENVIRONMENT\n';
const end = '# END QINGLONG CLI ENVIRONMENT\n';
const schedule =
/^(\s*(?:@[a-z]+|(?:[\w*\/,?#-]+[ \t]+){4}[\w*\/,?#-]+)[ \t]+)(.*)$/gm;
const marker = '// QingLong CLI crontab bridge';
interface CronInstallation {
language?: string;
executable: string;
sourceFile: string;
environment: Record<string, string>;
}
export function runCrontab(
args: string[],
installation: CronInstallation,
): number {
const language = { QL_LANG: process.env.QL_LANG ?? installation.language };
let directory: string | undefined;
try {
let forwarded = args;
if (
args.length === 1 &&
path.resolve(args[0]!) === installation.sourceFile
) {
const prefix =
'export ' +
Object.entries(installation.environment)
.map(([key, value]) => {
if (!/^[A-Z_]+$/.test(key) || /[\r\n\0%]/.test(value))
throw new Error(translate(language, 'cron 环境变量值无效。'));
return `${key}='${value.replace(/'/g, "'\\''")}'`;
})
.join(' ') +
'; ';
const source = syncFs.readFileSync(installation.sourceFile, 'utf8');
const header =
begin + '# ' + Buffer.from(prefix).toString('base64') + '\n' + end;
directory = syncFs.mkdtempSync(path.join(os.tmpdir(), 'ql-crontab-'));
const file = path.join(directory, 'table');
syncFs.writeFileSync(
file,
header +
source.replace(schedule, (line, timing, command) =>
line.trimStart().startsWith('#')
? line
: `${timing}${prefix}${command}`,
),
{ mode: 0o600 },
);
forwarded = [file];
}
const child = spawnSync(installation.executable, forwarded, {
stdio: ['inherit', 'pipe', 'pipe'],
timeout: 15000,
maxBuffer: 16 * 1024 * 1024,
});
let stdout = child.stdout || Buffer.alloc(0);
if (args.length === 1 && args[0] === '-l') {
const contents = stdout.toString('utf8');
if (contents.startsWith(begin) && contents.includes(end)) {
const headerEnd = contents.indexOf(end);
const encoded = contents.slice(begin.length, headerEnd).trim();
if (encoded.startsWith('# ')) {
const prefix = Buffer.from(encoded.slice(2), 'base64').toString(
'utf8',
);
stdout = Buffer.from(
contents
.slice(headerEnd + end.length)
.replace(schedule, (line, timing, command) =>
prefix && command.startsWith(prefix)
? timing + command.slice(prefix.length)
: line,
),
);
}
}
}
process.stdout.write(stdout);
if (child.stderr) process.stderr.write(child.stderr);
if (child.error) throw child.error;
return (
child.status ??
(child.signal ? 128 + os.constants.signals[child.signal] : 1)
);
} catch {
process.stderr.write(
translate(
language,
'无法安装或读取面板 crontab,请检查环境路径和系统 cron 工具。',
) + '\n',
);
return 1;
} finally {
if (directory) syncFs.rmSync(directory, { recursive: true, force: true });
}
}
export async function installCronEntrypoint(
commandDir: string,
cliRoot: string,
sourceFile: string,
environment: NodeJS.ProcessEnv,
): Promise<void> {
if (![commandDir, cliRoot, sourceFile].every(path.isAbsolute))
throw new Error(translate(environment, 'cron 安装路径必须为绝对路径。'));
let executable: string | undefined;
for (const directory of (environment.PATH || '').split(path.delimiter)) {
if (!directory || path.resolve(directory) === path.resolve(commandDir))
continue;
const candidate = path.join(directory, 'crontab');
try {
await fs.access(candidate, syncFs.constants.X_OK);
executable = path.resolve(candidate);
break;
} catch {}
}
// Node-only installations need not have a system cron utility.
if (!executable) return;
const target = path.join(commandDir, 'crontab');
try {
if (!(await fs.readFile(target, 'utf8')).includes(marker))
throw new Error(
translate(environment, '拒绝覆盖不属于此 CLI 的用户 crontab 命令。'),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const selected: Record<string, string> = {};
for (const key of [
'QL_DIR',
'QL_DATA_DIR',
'QL_CLI_ROOT',
'PYTHONPATH',
'NODE_PATH',
])
if (environment[key] !== undefined) selected[key] = environment[key]!;
selected.PATH = [
commandDir,
...(environment.PATH || '')
.split(path.delimiter)
.filter((value) => value && value !== commandDir),
].join(path.delimiter);
const installation: CronInstallation = {
language: environment.QL_LANG,
executable,
sourceFile: path.resolve(sourceFile),
environment: selected,
};
const module = path.join(cliRoot, 'dist/local/cronEntrypoint.js');
await fs.access(module);
await fs.mkdir(commandDir, { recursive: true });
const temporary = `${target}.${process.pid}.tmp`;
try {
await fs.writeFile(
temporary,
`#!${
process.execPath
}\n${marker}\nprocess.exitCode=require(${JSON.stringify(
module,
)}).runCrontab(process.argv.slice(2),${JSON.stringify(installation)});\n`,
{ flag: 'wx', mode: 0o755 },
);
await fs.rename(temporary, target);
} finally {
await fs.rm(temporary, { force: true });
}
}
@@ -0,0 +1,39 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
// Called by the 2.x startup loader only after explicit QL_CLI_ROOT selection.
export async function installCliEntrypoints(
commandDir: string,
cliRoot: string,
environment: NodeJS.ProcessEnv = process.env,
): Promise<void> {
if (!path.isAbsolute(commandDir) || !path.isAbsolute(cliRoot))
throw new Error(translate(environment, 'CLI 安装路径必须为绝对路径。'));
const entries = [
{ name: 'ql', module: 'ql.js', method: 'qlMain' },
{ name: 'task', module: 'task.js', method: 'taskMain' },
];
// Validate the entire selected installation before replacing either command.
for (const entry of entries) {
const filename = path.join(cliRoot, 'dist', entry.module);
if (!(await fs.stat(filename)).isFile())
throw new Error(translate(environment, 'CLI 入口必须为普通文件。'));
}
await fs.mkdir(commandDir, { recursive: true });
for (const entry of entries) {
const target = path.join(commandDir, entry.name);
const temp = `${target}.${randomUUID()}.tmp`;
const modulePath = path.join(cliRoot, 'dist', entry.module);
// Base64 transports the path as data; it cannot terminate a JS string.
const encodedPath = Buffer.from(modulePath, 'utf8').toString('base64');
const source = `#!${process.execPath}\n'use strict';\nrequire(Buffer.from('${encodedPath}', 'base64').toString('utf8')).${entry.method}().then(code => { process.exitCode = code; }).catch(() => { process.stderr.write(process.env.QL_LANG === 'en' ? 'CLI invocation failed.\\n' : 'CLI 调用失败。\\n'); process.exitCode = 1; });\n`;
try {
await fs.writeFile(temp, source, { flag: 'wx', mode: 0o755 });
await fs.rename(temp, target);
} finally {
await fs.rm(temp, { force: true });
}
}
}
+259
View File
@@ -0,0 +1,259 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { LocalContext } from '../runtime/context';
import { checkedProcess, runProcess } from '../runtime/process';
import { repairConfiguration, startPanel } from './operator';
import { operatingSystem } from './bot';
import { fail } from '../../shared/errors';
import { registerHostServices } from '../runtime/hostServices';
export function bootstrapPackages(
os: string,
environment: NodeJS.ProcessEnv = process.env,
): {
program: string;
calls: string[][];
} {
if (os === 'alpine')
return {
program: 'apk',
calls: [
['update'],
[
'add',
'-f',
'bash',
'coreutils',
'git',
'curl',
'wget',
'tzdata',
'perl',
'openssl',
'jq',
'nginx',
'openssh',
'procps',
'netcat-openbsd',
],
],
};
if (os === 'debian' || os === 'ubuntu')
return {
program: 'apt-get',
calls: [
['update'],
[
'install',
'-y',
'git',
'curl',
'wget',
'tzdata',
'perl',
'openssl',
'jq',
'nginx',
'procps',
'netcat-openbsd',
'openssh-client',
],
],
};
fail(
translate(environment, '不支持此部署操作系统:%s。', os || 'unknown'),
2,
);
}
export function runtimeEnvironment(
context: LocalContext,
pythonVersion: string,
): NodeJS.ProcessEnv {
if (!/^\d+\.\d+$/.test(pythonVersion))
fail(translate(context.env, 'Python 返回了无效版本。'));
const node = path.join(context.data, 'dep_cache/node');
const python = path.join(context.data, 'dep_cache/python3');
return {
...context.env,
PYTHON_SHORT_VERSION: pythonVersion,
PNPM_HOME: node,
PYTHON_HOME: python,
PYTHONUSERBASE: python,
PIP_CACHE_DIR: path.join(python, 'pip'),
PATH: [
context.env.PATH,
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
node,
path.join(python, 'bin'),
]
.filter(Boolean)
.join(path.delimiter),
NODE_PATH: [
'/usr/local/bin',
'/usr/local/lib/node_modules',
path.join(node, 'global/5/node_modules'),
].join(path.delimiter),
PYTHONPATH: [
python,
path.join(python, `lib/python${pythonVersion}`),
path.join(python, `lib/python${pythonVersion}/site-packages`),
].join(path.delimiter),
};
}
export async function launchStartupHook(
context: LocalContext,
action: 'bot' | 'extra',
): Promise<number> {
const log = await fs.open(
path.join(context.paths.dir_log!, `${action}.log`),
'w',
0o600,
);
try {
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../entrypoints/admin.js'),
action,
'--root',
context.root,
'--data-dir',
context.data,
'--json',
],
{
cwd: context.root,
env: context.env,
detached: true,
stdio: ['ignore', log.fd, log.fd],
},
);
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
return child.pid!;
} finally {
await log.close();
}
}
export interface BootstrapSystem {
nginxConfig: string;
nginxIncludes: string;
nginxRun: string;
background: typeof launchStartupHook;
registerServices?: typeof registerHostServices;
}
const systemDefaults: BootstrapSystem = {
nginxConfig: '/etc/nginx/nginx.conf',
nginxIncludes: '/etc/nginx/conf.d',
nginxRun: '/run/nginx',
background: launchStartupHook,
};
export async function bootstrapPanel(
context: LocalContext,
reload = false,
system: BootstrapSystem = systemDefaults,
startupOptions: { registerStartup?: boolean } = {},
): Promise<unknown> {
const registerStartup = startupOptions.registerStartup !== false;
if (path.basename(context.data) !== 'data')
fail(translate(context.env, '启动需要以 /data 结尾的绝对数据目录。'), 2);
const os = await operatingSystem(context);
const packages = bootstrapPackages(os, context.env);
if (!reload) {
const root = process.getuid?.() === 0;
for (const args of packages.calls)
await checkedProcess(
root ? packages.program : 'sudo',
root ? args : [packages.program, ...args],
{ cwd: context.root, env: context.env },
);
await checkedProcess(
'npm',
['install', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
}
const python = await checkedProcess(
'python3',
[
'-c',
'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")',
],
{ cwd: context.root, env: context.env, capture: true },
);
const runtime = {
...context,
env: runtimeEnvironment(context, python.stdout.trim()),
};
if (!reload)
await checkedProcess(
'pip3',
['install', '--prefix', runtime.env.PYTHON_HOME!, 'requests'],
{ cwd: runtime.root, env: runtime.env },
);
try {
await fs.copyFile(
path.join(runtime.root, '.env.example'),
path.join(runtime.root, '.env'),
fs.constants.COPYFILE_EXCL,
);
await fs.chmod(path.join(runtime.root, '.env'), 0o600);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
}
const restored = await repairConfiguration(runtime);
await fs.mkdir(system.nginxIncludes, { recursive: true });
await fs.mkdir(system.nginxRun, { recursive: true });
const options = { cwd: runtime.root, env: runtime.env };
await checkedProcess('pm2', ['l'], options);
const nginx = await runProcess(
'nginx',
['-c', system.nginxConfig, '-s', 'reload'],
options,
);
if (nginx.code !== 0)
await checkedProcess('nginx', ['-c', system.nginxConfig], options);
const service = await startPanel(runtime);
const background: { action: string; pid: number }[] = [];
if (!reload) {
if (runtime.env.AutoStartBot === 'true')
background.push({
action: 'bot',
pid: await system.background(runtime, 'bot'),
});
if (runtime.env.EnableExtraShell === 'true')
background.push({
action: 'extra',
pid: await system.background(runtime, 'extra'),
});
if (service.manager === 'pm2') {
if (registerStartup) {
await (system.registerServices ?? registerHostServices)(runtime, os);
await checkedProcess('pm2', ['startup'], options);
}
await checkedProcess('pm2', ['save'], options);
}
}
return {
startup:
reload || service.manager !== 'pm2'
? 'not-requested'
: registerStartup
? 'registered'
: 'skipped',
mode: reload ? 'reload' : 'install',
os,
restored,
service,
background,
};
}
+272
View File
@@ -0,0 +1,272 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { LocalContext } from '../runtime/context';
import { checkedProcess } from '../runtime/process';
import { replaceAndReload } from './upgrade';
export function botSystemPackages(
os: string,
environment: NodeJS.ProcessEnv = process.env,
): {
program: string;
args: string[];
} {
if (os === 'alpine')
return {
program: 'apk',
args: [
'--no-cache',
'add',
'-f',
'zlib-dev',
'gcc',
'jpeg-dev',
'python3-dev',
'musl-dev',
'freetype-dev',
],
};
if (os === 'debian' || os === 'ubuntu')
return {
program: 'apt-get',
args: [
'install',
'-y',
'gcc',
'python3-dev',
'musl-dev',
'zlib1g-dev',
'libjpeg-dev',
'libfreetype-dev',
],
};
throw new Error(
translate(environment, 'Bot 安装不支持此操作系统:%s。', os || 'unknown'),
);
}
export async function operatingSystem(context: LocalContext): Promise<string> {
if (context.env.QL_OS_TYPE) return context.env.QL_OS_TYPE;
const release = await fs.readFile('/etc/os-release', 'utf8').catch(() => '');
return release.match(/^ID=["']?([a-zA-Z0-9_-]+)["']?\s*$/m)?.[1] ?? '';
}
// Validate source links before copying: bot-owned links must stay inside its tree.
async function validateBotTree(
root: string,
environment: NodeJS.ProcessEnv,
boundary = root,
): Promise<void> {
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
const file = path.join(root, entry.name);
if (entry.isDirectory()) await validateBotTree(file, environment, boundary);
else if (entry.isSymbolicLink()) {
const relative = path.relative(
await fs.realpath(boundary),
await fs.realpath(file),
);
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
)
throw new Error(
translate(environment, 'Bot 仓库包含指向目录外部的符号链接。'),
);
} else if (!entry.isFile())
throw new Error(translate(environment, 'Bot 仓库包含不支持的文件类型。'));
}
}
export async function prepareBot(context: LocalContext): Promise<{
repository: string;
staged: string;
requirements: string;
config: string;
}> {
const packages = botSystemPackages(
await operatingSystem(context),
context.env,
);
const privileged = process.getuid?.() === 0;
await checkedProcess(
privileged ? packages.program : 'sudo',
privileged ? packages.args : [packages.program, ...packages.args],
{ cwd: context.root, env: context.env },
);
await fs.mkdir(context.paths.dir_repo!, { recursive: true });
await fs.mkdir(context.data, { recursive: true });
await fs.mkdir(context.paths.dir_config!, { recursive: true });
const repository = path.join(
context.paths.dir_repo!,
context.env.BotRepoUrl ? 'diybot' : 'dockerbot',
);
const url = context.env.BotRepoUrl || 'https://github.com/SuMaiKaDe/bot.git';
const git = await fs
.stat(path.join(repository, '.git'))
.catch(() => undefined);
if (!git?.isDirectory()) {
const temp = await fs.mkdtemp(
path.join(context.paths.dir_repo!, '.bot-clone-'),
);
try {
await checkedProcess(
'git',
[
'clone',
'--depth=1',
'--branch',
'main',
'--',
url,
path.join(temp, 'repository'),
],
{ cwd: context.root, env: context.env },
);
await fs.access(path.join(temp, 'repository/jbot/requirements.txt'));
const backup = `${repository}.previous-${randomUUID()}`;
const exists = !!(await fs.lstat(repository).catch(() => undefined));
if (exists) await fs.rename(repository, backup);
try {
await fs.rename(path.join(temp, 'repository'), repository);
} catch (error) {
if (exists) await fs.rename(backup, repository);
throw error;
}
if (exists) await fs.rm(backup, { recursive: true, force: true });
} finally {
await fs.rm(temp, { recursive: true, force: true });
}
}
const source = path.join(repository, 'jbot');
if (!(await fs.lstat(source)).isDirectory())
throw new Error(translate(context.env, 'Bot 源必须为普通目录。'));
await validateBotTree(source, context.env);
const staged = await fs.mkdtemp(path.join(context.data, '.bot-stage-'));
try {
await fs.cp(source, path.join(staged, 'jbot'), {
recursive: true,
verbatimSymlinks: true,
});
const requirements = path.join(staged, 'jbot/requirements.txt');
const config = path.join(repository, 'config/bot.json');
if (!(await fs.lstat(config)).isFile())
throw new Error(translate(context.env, 'Bot 配置模板必须为普通文件。'));
await checkedProcess(
'pip3',
['--default-timeout=100', 'install', '-r', requirements],
{ cwd: context.data, env: context.env },
);
return { repository, staged, requirements, config };
} catch (error) {
await fs.rm(staged, { recursive: true, force: true });
throw error;
}
}
export async function stopBot(context: LocalContext): Promise<void> {
// Supported installations are Linux. Match cwd as well as argv so another
// QingLong instance's Telegram bot is never selected by a global pkill.
if (process.platform !== 'linux')
throw new Error(translate(context.env, 'Bot 进程管理需要 Linux。'));
const cwd = await fs.realpath(context.data);
for (const pid of await fs.readdir('/proc')) {
if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
try {
const args = (await fs.readFile(`/proc/${pid}/cmdline`, 'utf8'))
.split('\0')
.filter(Boolean);
if (
!/^python3(?:\.\d+)?$/.test(path.basename(args[0] ?? '')) ||
args[1] !== '-m' ||
args[2] !== 'jbot'
)
continue;
if ((await fs.realpath(`/proc/${pid}/cwd`)) !== cwd) continue;
// Legacy script used SIGKILL; retain deterministic stop before replacement.
process.kill(Number(pid), 'SIGKILL');
} catch (error) {
if (
!['ENOENT', 'ESRCH', 'EACCES'].includes(
(error as NodeJS.ErrnoException).code ?? '',
)
)
throw error;
}
}
}
export async function launchBot(
context: LocalContext,
): Promise<{ pid: number }> {
const directory = path.join(context.paths.dir_log!, 'bot');
await fs.mkdir(directory, { recursive: true });
const log = await fs.open(path.join(directory, 'nohup.log'), 'w', 0o600);
try {
const child = spawn('python3', ['-m', 'jbot'], {
cwd: context.data,
env: context.env,
detached: true,
stdio: ['ignore', log.fd, log.fd],
});
await new Promise<void>((resolve, reject) => {
const failed = (code: number | null) => {
clearTimeout(timer);
reject(
new Error(translate(context.env, 'Bot 在启动期间退出(%s)。', code)),
);
};
const timer = setTimeout(() => {
child.removeListener('exit', failed);
resolve();
}, 1000);
child.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.once('exit', failed);
});
child.unref();
return { pid: child.pid! };
} finally {
await log.close();
}
}
export async function installAndStartBot(
context: LocalContext,
lifecycle = { stop: stopBot, start: launchBot },
): Promise<unknown> {
if (lifecycle.stop === stopBot && process.platform !== 'linux')
throw new Error(translate(context.env, 'Bot 安装和进程管理需要 Linux。'));
const prepared = await prepareBot(context);
try {
const config = path.join(context.paths.dir_config!, 'bot.json');
try {
await fs.copyFile(prepared.config, config, fs.constants.COPYFILE_EXCL);
await fs.chmod(config, 0o600);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
}
const result = await replaceAndReload(
context,
[
{
source: path.join(prepared.staged, 'jbot'),
target: path.join(context.data, 'jbot'),
},
{
source: prepared.requirements,
target: path.join(context.data, 'requirements.txt'),
},
],
lifecycle,
);
return { repository: prepared.repository, result };
} finally {
await fs.rm(prepared.staged, { recursive: true, force: true });
}
}
+182
View File
@@ -0,0 +1,182 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { checkedProcess } from '../runtime/process';
import {
repairConfiguration,
installPanelDependencies,
startPanel,
} from './operator';
export interface HealthObservation {
healthy: boolean;
status?: number;
error?: string;
}
// Probe loopback directly: no inherited HTTP proxy, redirects or app credentials.
async function probe(
port: number,
route: string,
matches: (body: string) => boolean,
): Promise<HealthObservation> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: HealthObservation) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve(result);
};
const request = http.get(
{ hostname: '127.0.0.1', port, path: route, headers: { Accept: '*/*' } },
(response) => {
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > 1024 * 1024) {
finish({
healthy: false,
status: response.statusCode,
error: 'Response exceeds 1 MiB.',
});
request.destroy();
} else chunks.push(chunk);
});
response.on('end', () => {
const status = response.statusCode ?? 0;
finish({
healthy:
status >= 200 &&
status < 300 &&
matches(Buffer.concat(chunks).toString('utf8')),
status,
});
});
response.on('error', () =>
finish({ healthy: false, error: 'Response interrupted.' }),
);
},
);
const deadline = setTimeout(() => {
finish({ healthy: false, error: 'Probe timed out.' });
request.destroy();
}, 5000);
request.on('error', () =>
finish({ healthy: false, error: 'Loopback request failed.' }),
);
});
}
export async function inspectPanel(
context: LocalContext,
): Promise<{ panel: HealthObservation; backend: HealthObservation }> {
const portText = context.env.QlPort || '5700';
if (
!/^\d+$/.test(portText) ||
Number(portText) < 1 ||
Number(portText) > 65535
)
throw new Error(translate(context.env, 'QlPort 必须在 1 到 65535 之间。'));
const port = Number(portText);
const basePath = (context.env.QlBaseUrl || '/').replace(/\/+$/, '');
const [panel, backend] = await Promise.all([
probe(port, `${basePath}/`, (body) =>
/<div\s+id=["']root["']\s*>\s*<\/div>/.test(body),
),
probe(port, `${basePath}/api/health?t=${Math.floor(Date.now() / 1000)}`, (body) => {
try {
const response: unknown = JSON.parse(body);
return (
!!response &&
typeof response === 'object' &&
'code' in response &&
response.code === 200 &&
'data' in response && !!response.data &&
typeof response.data === 'object' &&
'status' in response.data && response.data.status === 'ok'
);
} catch {
return false;
}
}),
]);
return { panel, backend };
}
export async function diagnosticLog(file: string): Promise<{
file: string;
text?: string;
truncated?: boolean;
error?: string;
}> {
let handle;
try {
handle = await fs.open(file, 'r');
const stat = await handle.stat();
if (!stat.isFile()) return { file, error: 'Not a regular log file.' };
const length = Math.min(stat.size, 256 * 1024);
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(
buffer,
0,
length,
stat.size - length,
);
let text = buffer.subarray(0, bytesRead).toString('utf8');
// Drop a partial UTF-8/line prefix when reading only the end of a file.
if (stat.size > length)
text = text.includes('\n') ? text.slice(text.indexOf('\n') + 1) : '';
const lines = text.replace(/\r\n/g, '\n').split('\n');
if (lines.at(-1) === '') lines.pop();
return {
file,
text: lines.slice(-300).join('\n'),
truncated: stat.size > length || lines.length > 300,
};
} catch (error) {
return {
file,
error:
(error as NodeJS.ErrnoException).code === 'ENOENT'
? 'Log is absent.'
: 'Cannot read log.',
};
} finally {
await handle?.close();
}
}
export async function checkAndRepair(context: LocalContext): Promise<unknown> {
await checkedProcess(
'npm',
['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
const restored = await repairConfiguration(context);
await installPanelDependencies(context);
const copied: string[] = [];
for (const language of ['py', 'js']) {
const destination = context.paths[`file_notify_${language}`]!;
await atomicWrite(
destination,
await fs.readFile(context.paths[`file_notify_${language}_sample`]!),
);
copied.push(destination);
}
const before = await inspectPanel(context);
const pm2Home =
context.env.PM2_HOME || path.join(context.env.HOME || '/root', '.pm2');
const logs = await Promise.all(
['qinglong-out.log', 'qinglong-error.log'].map((name) =>
diagnosticLog(path.join(pm2Home, 'logs', name)),
),
);
const service = await startPanel(context);
const after = await inspectPanel(context);
return { restored, copied, before, service, after, logs };
}
+114
View File
@@ -0,0 +1,114 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { Invocation } from '../../shared/cli/arguments';
import { localContext, type LocalContext } from '../runtime/context';
import { LocalApi } from '../runtime/api';
import { regularFiles } from '../runtime/files';
import { checkedProcess } from '../runtime/process';
export async function pruneLogs(
context: LocalContext,
days: number,
): Promise<{ removed: string[]; retained: string[] }> {
const api = new LocalApi(context);
const root = context.paths.dir_log!;
const removed: string[] = [],
retained: string[] = [];
for (const relative of await regularFiles(root)) {
if (!relative.endsWith('.log')) continue;
const file = path.join(root, relative);
const datePart = path.basename(file).match(/^(\d{4}-\d{2}-\d{2})/)?.[1];
const modified = datePart
? new Date(`${datePart}T00:00:00`)
: (await fs.stat(file)).mtime;
// Legacy Linux retention compares calendar dates, including undated logs.
const age = modified.setHours(0, 0, 0, 0);
if (!Number.isFinite(age) || Date.now() - age <= days * 86400000) continue;
const response = await api.call(
`crons/detail?${new URLSearchParams({ log_path: relative })}`,
);
if (response.data) {
retained.push(relative);
continue;
}
await fs.unlink(file);
removed.push(relative);
}
for (const entry of await fs
.readdir(root, { withFileTypes: true })
.catch(() => [])) {
if (entry.isDirectory())
await fs
.rmdir(path.join(root, entry.name))
.catch((error: NodeJS.ErrnoException) => {
if (!['ENOTEMPTY', 'ENOENT'].includes(error.code ?? '')) throw error;
});
}
return { removed, retained };
}
export async function maintenance(
command: Invocation,
prepared?: LocalContext,
): Promise<unknown> {
const context = prepared ?? (await localContext(command));
const action = command.name.split(' ')[1];
if (action === 'start') {
const { bootstrapPanel } = await import('./bootstrap');
return bootstrapPanel(context, command.values.reload === true, undefined, {
registerStartup: command.values['no-startup'] !== true,
});
}
if (action === 'bot') {
const { installAndStartBot } = await import('./bot');
return installAndStartBot(context);
}
if (action === 'check') {
const { checkAndRepair } = await import('./check');
return checkAndRepair(context);
}
if (action === 'repair-config') {
const { repairConfiguration } = await import('./operator');
return { restored: await repairConfiguration(context) };
}
if (action === 'reload' || action === 'update') {
const { reloadPanel, stageUpgrade } = await import('./upgrade');
if (action === 'reload')
return reloadPanel(
context,
command.values.target as 'services' | 'system' | 'data',
);
const { repairConfiguration } = await import('./operator');
await repairConfiguration(context);
const staged = await stageUpgrade(
context,
command.values.mirror as 'github' | 'gitee',
);
return command.values['download-only']
? { staged }
: { staged, result: await reloadPanel(context, 'system', staged) };
}
if (action === 'rmlog')
return pruneLogs(context, Number(command.positionals[0]));
if (action === 'extra') {
const file = context.paths.file_extra_shell!;
if (!(await fs.stat(file).catch(() => undefined))?.isFile())
return { skipped: true, reason: 'extra.sh is absent' };
await checkedProcess(
'bash',
['--noprofile', '--norc', '-c', '. "$1"', 'ql-extra', file],
{ cwd: context.root, env: context.env },
);
return { completed: true };
}
const payload =
action === 'resetlet'
? { retries: 0 }
: action === 'resettfa'
? { twoFactorActivated: false }
: action === 'resetpwd'
? { password: command.positionals[0] }
: { username: command.positionals[0] };
await new LocalApi(context).call('system/auth/reset', 'PUT', payload);
return { action, completed: true };
}
+204
View File
@@ -0,0 +1,204 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { runProcess, checkedProcess } from '../runtime/process';
import { findDirectBackendPids } from '../runtime/backendProcesses';
export async function repairConfiguration(
context: LocalContext,
): Promise<string[]> {
for (const name of [
'tmp',
'static',
'data',
'config',
'log',
'db',
'scripts',
'list_tmp',
'repo',
'raw',
'update_log',
'dep',
])
await fs.mkdir(context.paths[`dir_${name}`]!, { recursive: true });
const restored: string[] = [];
const templates: [string, string, boolean][] = [
['file_config_user', 'file_config_sample', true],
['file_task_before', 'file_task_sample', false],
['file_task_after', 'file_task_sample', false],
['file_extra_shell', 'file_extra_sample', false],
['file_notify_py', 'file_notify_py_sample', true],
['file_notify_js', 'file_notify_js_sample', true],
['file_test_js', 'file_test_js_sample', true],
['file_test_py', 'file_test_py_sample', true],
['dep_notify_js', 'file_notify_js_sample', true],
['dep_notify_py', 'file_notify_py_sample', true],
];
for (const [destination, source, fillEmpty] of templates) {
const target = context.paths[destination]!;
const stat = await fs.stat(target).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return undefined;
});
if (stat && (!fillEmpty || stat.size > 0)) continue;
await atomicWrite(target, await fs.readFile(context.paths[source]!));
restored.push(target);
}
return restored;
}
export async function executableAvailable(
program: string,
env: NodeJS.ProcessEnv,
): Promise<boolean> {
for (const directory of (env.PATH ?? '').split(path.delimiter)) {
if (!directory) continue;
try {
await fs.access(path.join(directory, program), fs.constants.X_OK);
return true;
} catch {
/* Try the next PATH entry. */
}
}
return false;
}
export async function installPanelDependencies(
context: LocalContext,
cwd = context.root,
): Promise<void> {
const termux = context.env.is_termux === '1';
const pnpm = !termux && (await executableAvailable('pnpm', context.env));
await checkedProcess(
pnpm ? 'pnpm' : 'npm',
pnpm
? ['install', '--loglevel', 'error', '--production']
: ['install', '--production', ...(termux ? ['--no-bin-links'] : [])],
{ cwd, env: context.env },
);
}
// Match only this installation's backend; the legacy broad pkill pattern could
// terminate another panel running on the same host.
async function stopDirectBackend(context: LocalContext): Promise<void> {
const entry = path.join(context.paths.dir_static!, 'build/app.js');
if (process.platform === 'linux') {
const stopping = new Set(await findDirectBackendPids(entry));
for (const pid of stopping) {
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
const deadline = Date.now() + 5000;
while (
(await findDirectBackendPids(entry)).some((pid) => stopping.has(pid))
) {
if (Date.now() >= deadline)
throw new Error(
translate(context.env, '后端进程未在 5 秒内停止,已取消重启。'),
);
await delay(50);
}
return;
}
const result = await runProcess('ps', ['-eo', 'pid=,args='], {
capture: true,
env: context.env,
});
if (result.code !== 0)
throw new Error(translate(context.env, '无法检查运行中的后端进程。'));
for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
if (match[3] !== entry) continue;
const pid = Number(match[1]);
if (pid === process.pid) continue;
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
}
export async function stopPanel(context: LocalContext): Promise<void> {
if (await executableAvailable('pm2', context.env))
await runProcess('pm2', ['delete', 'ecosystem.config.js'], {
cwd: context.root,
env: context.env,
});
await stopDirectBackend(context);
}
export async function startPanel(
context: LocalContext,
): Promise<{ manager: 'pm2' | 'node'; pid?: number }> {
// Match container startup even when invoked from a fresh administrative CLI
// process. Legacy dotenv files can still contain BACK_PORT=5600.
const options = {
cwd: context.root,
env: {
...context.env,
BACK_PORT: context.env.QlPort || '5700',
GRPC_PORT: context.env.QlGrpcPort || '5500',
},
};
if (await executableAvailable('pm2', context.env)) {
const flushed = await runProcess('pm2', ['flush'], options);
if (flushed.code === 0) {
const started = await runProcess(
'pm2',
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
options,
);
if (started.code === 0) return { manager: 'pm2' };
}
}
await stopDirectBackend(context);
const entry = path.join(context.paths.dir_static!, 'build/app.js');
await fs.access(entry);
await fs.mkdir(context.paths.dir_log!, { recursive: true });
const log = await fs.open(
path.join(context.paths.dir_log!, 'qinglong.log'),
'a',
0o600,
);
try {
const child = spawn(process.execPath, [entry], {
...options,
detached: true,
stdio: ['ignore', log.fd, log.fd],
});
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
const failed = (code: number | null) => {
clearTimeout(timer);
reject(
new Error(
translate(
context.env,
'直接运行的后端在启动期间退出(%s)。',
code,
),
),
);
};
const timer = setTimeout(() => {
child.removeListener('exit', failed);
resolve();
}, 1000);
child.once('exit', failed);
});
child.unref();
return { manager: 'node', pid: child.pid };
} finally {
await log.close();
}
}
+379
View File
@@ -0,0 +1,379 @@
import { prepareUpgradeSelection } from './upgradeSelection';
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { LocalContext } from '../runtime/context';
import { checkedProcess } from '../runtime/process';
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
import { installPanelDependencies, startPanel, stopPanel } from './operator';
export function releaseBranch(context: LocalContext): string {
return ['develop', 'debian', 'debian-dev'].includes(
context.env.QL_BRANCH ?? '',
)
? context.env.QL_BRANCH!
: 'master';
}
async function validateTree(
root: string,
environment: NodeJS.ProcessEnv,
boundary = root,
): Promise<void> {
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
const filename = path.join(root, entry.name);
if (entry.isSymbolicLink()) {
const resolved = await fs.realpath(filename);
const relation = path.relative(await fs.realpath(boundary), resolved);
if (
!relation ||
relation === '..' ||
relation.startsWith(`..${path.sep}`) ||
path.isAbsolute(relation)
)
throw new Error(
translate(environment, '升级文件包含指向目录外部的符号链接。'),
);
continue;
}
if (!entry.isFile() && !entry.isDirectory())
throw new Error(translate(environment, '升级文件包含不支持的文件类型。'));
if (entry.isDirectory())
await validateTree(filename, environment, boundary);
}
}
export async function stageUpgrade(
context: LocalContext,
mirror: 'github' | 'gitee',
): Promise<{ source: string; static: string }> {
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
const workspace = await fs.mkdtemp(
path.join(context.paths.dir_tmp!, 'upgrade-'),
);
const branch = releaseBranch(context);
try {
for (const repository of ['qinglong', 'qinglong-static']) {
const url =
mirror === 'github'
? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip`
: `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`;
const archive = path.join(workspace, `${repository}.zip`);
await checkedProcess(
'curl',
[
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--output',
archive,
url,
],
{ env: context.env },
);
const listing = await checkedProcess('unzip', ['-Z1', archive], {
env: context.env,
capture: true,
});
const expected = `${repository}-${branch}`;
for (const name of listing.stdout.split('\n').filter(Boolean)) {
if (
name.includes('\\') ||
name.includes('\r') ||
name.startsWith('/') ||
name.split('/').includes('..') ||
name.split('/')[0] !== expected
)
throw new Error(translate(context.env, '升级归档包含无效路径。'));
}
// Reject Unix symlinks before extraction, including links that could redirect
// a later archive entry outside the staging directory.
const modes = await checkedProcess('unzip', ['-Z', '-l', archive], {
env: context.env,
capture: true,
});
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
throw new Error(translate(context.env, '升级归档包含符号链接。'));
await checkedProcess('unzip', ['-oq', archive, '-d', workspace], {
env: context.env,
});
await validateTree(path.join(workspace, expected), context.env);
}
const source = path.join(workspace, `qinglong-${branch}`);
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
await fs.access(path.join(staticRoot, 'build/app.js'));
const manifest = await fs.readFile(path.join(source, 'package.json'));
const existing = await fs.readFile(path.join(context.root, 'package.json'));
if (!manifest.equals(existing))
await installPanelDependencies(context, source);
// Publish a marker only after both archives and dependency installation succeed.
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({ source, static: staticRoot }),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{
flag: 'wx',
mode: 0o600,
},
);
await fs.rename(
pointer,
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
);
return { source, static: staticRoot };
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
async function selectedUpgrade(
context: LocalContext,
): Promise<{ source: string; static: string }> {
const branch = releaseBranch(context);
const fallback = {
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`),
};
const pointer = await fs
.readFile(
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
'utf8',
)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (pointer === undefined) return fallback;
const directory: unknown = JSON.parse(pointer)?.directory;
if (
typeof directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(directory)
)
throw new Error(translate(context.env, '暂存升级指针无效。'));
const workspace = path.join(context.paths.dir_tmp!, directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
for (const location of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(location);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error(translate(context.env, '暂存升级目录无效。'));
}
const ready = JSON.parse(
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
);
if (ready?.source !== selected.source || ready?.static !== selected.static)
throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
return selected;
}
export interface ReloadLifecycle {
stop(context: LocalContext): Promise<void>;
start(context: LocalContext): Promise<unknown>;
}
// All old entries stay on the same filesystem until service start succeeds.
// A failed replacement rolls back in reverse order before restarting the old app.
export async function replaceAndReload(
context: LocalContext,
replacements: { source?: string; target: string }[],
lifecycle: ReloadLifecycle = { stop: stopPanel, start: startPanel },
): Promise<unknown> {
for (const item of replacements) {
// Missing source denotes deletion, backed up and rolled back like replacement.
if (item.source === undefined) continue;
await fs.lstat(item.source);
if (path.resolve(item.source) === path.resolve(item.target))
throw new Error(translate(context.env, '替换源与目标相同。'));
const relation = path.relative(
path.resolve(item.target),
path.resolve(item.source),
);
if (
!relation.startsWith(`..${path.sep}`) &&
relation !== '..' &&
!path.isAbsolute(relation)
)
throw new Error(translate(context.env, '替换源位于目标目录内部。'));
}
const changed: { target: string; backup: string; existed: boolean }[] = [];
const signal = operationSignal();
signal?.throwIfAborted();
let result: unknown;
let attemptedStart = false;
try {
await lifecycle.stop(context);
signal?.throwIfAborted();
for (const { source, target } of replacements) {
signal?.throwIfAborted();
await fs.mkdir(path.dirname(target), { recursive: true });
const backup = `${target}.ql-backup-${randomUUID()}`;
const existed = !!(await fs
.lstat(target)
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return undefined;
}));
let recorded = false;
if (existed) {
try {
await fs.rename(target, backup);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
// OverlayFS can reject renaming a lower-layer directory even within
// one mount. Finish its backup before removing any original entry.
try {
await fs.cp(target, backup, {
recursive: true,
verbatimSymlinks: true,
dereference: false,
errorOnExist: true,
force: false,
});
} catch (copyError) {
await fs.rm(backup, { recursive: true, force: true });
throw copyError;
}
changed.push({ target, backup, existed });
recorded = true;
await fs.rm(target, { recursive: true });
}
}
if (!recorded) changed.push({ target, backup, existed });
// Copy supports an external QL_DATA_DIR on a different filesystem.
if (source !== undefined)
await fs.cp(source, target, {
recursive: true,
verbatimSymlinks: true,
dereference: false,
errorOnExist: true,
force: false,
});
}
signal?.throwIfAborted();
attemptedStart = true;
result = await lifecycle.start(context);
signal?.throwIfAborted();
} catch (error) {
await withoutCancellation(async () => {
// A failed or interrupted start can leave a partially started service.
// Stop it before restoring files; retain backups if it cannot be stopped.
if (attemptedStart) await lifecycle.stop(context);
for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) await fs.rename(item.backup, item.target);
}
try {
await lifecycle.start(context);
} catch {
throw new Error(
translate(
context.env,
'重载失败;文件已恢复,但原服务无法重新启动。',
),
{ cause: error },
);
}
});
throw error;
}
// Cleanup failure must never initiate rollback after backups have been removed.
const retainedBackups: string[] = [];
for (const item of changed)
if (item.existed) {
try {
await fs.rm(item.backup, { recursive: true, force: true });
} catch {
retainedBackups.push(item.backup);
}
}
return { service: result, retainedBackups };
}
export async function reloadPanel(
context: LocalContext,
target: 'services' | 'system' | 'data',
staged?: { source: string; static: string },
): Promise<unknown> {
if (target === 'services') {
await stopPanel(context);
return startPanel(context);
}
if (target === 'data') {
if (
context.data === path.parse(context.data).root ||
context.data === context.root ||
context.root.startsWith(`${context.data}${path.sep}`)
)
throw new Error(translate(context.env, '数据重载需要独立的数据目录。'));
const source = path.join(context.paths.dir_tmp!, 'data');
if (
source === context.data ||
source.startsWith(`${context.data}${path.sep}`) ||
context.data.startsWith(`${source}${path.sep}`)
)
throw new Error(
translate(context.env, '暂存数据与已安装的数据目录必须分离。'),
);
await validateTree(source, context.env);
await fs.mkdir(context.data, { recursive: true });
const incoming = new Set(await fs.readdir(source));
const entries = new Set([...(await fs.readdir(context.data)), ...incoming]);
// A Docker volume's root cannot be renamed. Keep it in place and transact
// its children, including removals, with backups on the same filesystem.
return replaceAndReload(
context,
[...entries].map((name) => ({
source: incoming.has(name) ? path.join(source, name) : undefined,
target: path.join(context.data, name),
})),
);
}
const selected = staged ?? (await selectedUpgrade(context));
const source = selected.source;
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(staticRoot, 'build/app.js'));
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
if (
relativeData &&
!relativeData.startsWith('..') &&
!path.isAbsolute(relativeData)
)
reserved.add(relativeData.split(path.sep)[0]!);
const replacements = entries
.filter((name) => !reserved.has(name))
.map((name) => ({
source: path.join(source, name),
target: path.join(context.root, name),
}));
replacements.push({ source: staticRoot, target: context.paths.dir_static! });
replacements.push({
source: path.join(source, 'sample/config.sample.sh'),
target: path.join(context.paths.dir_config!, 'config.sample.sh'),
});
const selection = await prepareUpgradeSelection(context, source, staticRoot);
if (selection)
replacements.push({ source: selection.source, target: selection.target });
try {
return await replaceAndReload(context, replacements);
} finally {
if (selection)
await fs.rm(selection.directory, { recursive: true, force: true });
}
}
@@ -0,0 +1,65 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { LocalContext } from '../runtime/context';
import { fail } from '../../shared/errors';
import { translate } from '../../shared/i18n/index';
/** Preserve only the explicitly installed 2.x command-selection integration. */
export async function prepareUpgradeSelection(
context: LocalContext,
sourceRoot: string,
staticRoot: string,
): Promise<{ source: string; target: string; directory: string } | undefined> {
const cliRoot = context.env.QL_CLI_ROOT;
if (!cliRoot) return undefined;
if (!path.isAbsolute(cliRoot))
fail(translate(context.env, '已选择的 CLI 安装路径必须为绝对路径。'), 2);
for (const entry of ['entrypoints.js', 'cronEntrypoint.js'])
await fs.access(path.join(cliRoot, 'dist/local', entry));
const manifest = JSON.parse(
await fs.readFile(path.join(sourceRoot, 'package.json'), 'utf8'),
);
const version =
manifest.version ||
(await fs.readFile(path.join(sourceRoot, 'version.yaml'), 'utf8')).match(
/^\uFEFF?version:\s*["']?([^\s"']+)/m,
)?.[1];
if (typeof version !== 'string' || !/^2\./.test(version))
fail(
translate(context.env, '保留 CLI 入口仅支持已确认的 2.x 升级载荷。'),
2,
);
const relative = 'build/loaders/deps.js';
const target = path.join(context.paths.dir_static!, relative);
const incoming = path.join(staticRoot, relative);
if (
!(await fs.lstat(target)).isFile() ||
!(await fs.lstat(incoming)).isFile()
)
fail(
translate(context.env, '无法确认已选择的 CLI 命令加载器,请检查安装。'),
2,
);
const loader = await fs.readFile(target, 'utf8');
if (
!loader.includes('dist/local/entrypoints.js') ||
!loader.includes('dist/local/cronEntrypoint.js') ||
!loader.includes('QL_CLI_ROOT')
)
fail(
translate(context.env, '无法确认已选择的 CLI 命令加载器,请检查安装。'),
2,
);
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
const directory = await fs.mkdtemp(
path.join(context.paths.dir_tmp!, 'selected-loader-'),
);
try {
const source = path.join(directory, 'deps.js');
await fs.writeFile(source, loader, { mode: 0o600, flag: 'wx' });
return { source, target, directory };
} catch (error) {
await fs.rm(directory, { recursive: true, force: true });
throw error;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { LocalContext } from './context';
import { checkedProcess } from './process';
import { fail } from '../../shared/errors';
import { isRecord } from '../../shared/record';
import { operationSignal, requestCancellation } from './cancellation';
export class LocalApi {
private token?: string;
constructor(private readonly context: LocalContext) {}
private async credential(): Promise<string> {
if (this.token) return this.token;
const read = async () => {
try {
const value: unknown = JSON.parse(
await fs.readFile(this.context.paths.file_auth_token!, 'utf8'),
);
if (
isRecord(value) &&
typeof value.value === 'string' &&
typeof value.expiration === 'number' &&
value.expiration > Date.now() / 1000
)
return value.value;
} catch {
/* Existing local token generator owns missing/expired credentials. */
}
return undefined;
};
this.token = await read();
if (!this.token) {
const compiled = path.join(this.context.root, 'static/build/token.js');
const exists = await fs.stat(compiled).then(
() => true,
() => false,
);
await checkedProcess(
exists ? process.execPath : 'ts-node-transpile-only',
[exists ? compiled : path.join(this.context.root, 'back/token.ts')],
{
cwd: this.context.root,
env: this.context.env,
capture: true,
output: () => {},
timeoutMs: 30000,
},
);
this.token = await read();
}
if (!this.token)
fail(
translate(
this.context.env,
'无法获取本机系统令牌,请检查运行中的面板。',
),
3,
);
return this.token;
}
async call(
endpoint: string,
method = 'GET',
body?: unknown,
): Promise<Record<string, unknown>> {
operationSignal()?.throwIfAborted();
const token = await this.credential();
const port = this.context.env.QlPort || '5700';
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)
fail(translate(this.context.env, '本机面板端口无效。'), 2);
const request = requestCancellation(30000);
let result: unknown;
try {
const response = await fetch(
`http://127.0.0.1:${port}/open/${endpoint}`,
{
method,
headers: {
Authorization: `Bearer ${token}`,
...(body !== undefined
? { 'Content-Type': 'application/json' }
: {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
redirect: 'error',
signal: request.signal,
},
);
if (!response.ok) {
await response.body?.cancel();
fail(
translate(
this.context.env,
'本机 API 拒绝请求(HTTP %s)。',
response.status,
),
);
}
result = await response.json();
} catch {
fail(
translate(
this.context.env,
'本机 API 请求失败;请先检查操作结果,不要直接重试写入。',
),
);
} finally {
request.dispose();
}
if (!isRecord(result) || result.code !== 200)
fail(
translate(
this.context.env,
'本机 API 拒绝请求,请检查面板日志和权限。',
),
);
return result;
}
}
@@ -0,0 +1,51 @@
import fs from 'node:fs/promises';
import path from 'node:path';
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
// each process's cwd, not from the operator's cwd or a broad ps substring match.
export async function findDirectBackendPids(
entry: string,
procRoot = '/proc',
): Promise<number[]> {
const expected = await fs
.realpath(entry)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (!expected) return [];
const matches: number[] = [];
for (const name of await fs.readdir(procRoot)) {
if (!/^[1-9]\d*$/.test(name)) continue;
const pid = Number(name);
if (!Number.isSafeInteger(pid) || pid === process.pid) continue;
const directory = path.join(procRoot, name);
try {
const command = await fs.readFile(
path.join(directory, 'cmdline'),
'utf8',
);
const args = command.split('\0');
if (args.at(-1) === '') args.pop();
// Both the legacy fallback and the TS fallback launch one script without
// Node flags or application arguments. Reject other command shapes.
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
continue;
const script = args[1]!;
if (!script) continue;
const absolute = path.isAbsolute(script)
? script
: path.resolve(await fs.realpath(path.join(directory, 'cwd')), script);
if ((await fs.realpath(absolute)) === expected) matches.push(pid);
} catch (error) {
// Processes may exit while enumerating; foreign users can deny inspection.
if (
!['ENOENT', 'ESRCH', 'EACCES', 'EPERM'].includes(
(error as NodeJS.ErrnoException).code ?? '',
)
)
throw error;
}
}
return matches;
}
+86
View File
@@ -0,0 +1,86 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { constants } from 'node:os';
const storage = new AsyncLocalStorage<AbortSignal>();
// Match the six signals handled by the retained 2.x task.sh entrypoint.
export const commandSignals = [
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGQUIT',
'SIGALRM',
'SIGTSTP',
] as const;
export function commandSignal(
reason: unknown,
): (typeof commandSignals)[number] {
return commandSignals.find((name) => name === reason) ?? 'SIGTERM';
}
export const operationSignal = (): AbortSignal | undefined =>
storage.getStore();
export function interruptedCode(signal?: AbortSignal): number | undefined {
if (!signal?.aborted) return undefined;
const reason: unknown = signal.reason;
const name = commandSignal(reason);
return 128 + constants.signals[name];
}
export async function cancellableOperation<T>(
signal: AbortSignal | undefined,
operation: () => Promise<T>,
): Promise<T> {
if (!signal) return operation();
signal.throwIfAborted();
const result = await storage.run(signal, operation);
signal.throwIfAborted();
return result;
}
export async function withCommandCancellation(
operation: (signal: AbortSignal) => Promise<number>,
): Promise<number> {
const controller = new AbortController();
const listeners = commandSignals.map((name) => {
const listener = () => controller.abort(name);
process.on(name, listener);
return { name, listener };
});
try {
return await operation(controller.signal);
} finally {
for (const { name, listener } of listeners) process.off(name, listener);
}
}
// Recovery must finish even after the operation that triggered it was cancelled.
export function withoutCancellation<T>(
operation: () => Promise<T>,
): Promise<T> {
return storage.exit(operation);
}
export function requestCancellation(timeoutMs: number): {
signal: AbortSignal;
dispose: () => void;
} {
const parent = operationSignal();
const controller = new AbortController();
const abort = () => controller.abort(parent?.reason);
const timeout = setTimeout(
() => controller.abort(new Error('Request timed out.')),
timeoutMs,
);
timeout.unref();
parent?.addEventListener('abort', abort, { once: true });
if (parent?.aborted) abort();
return {
signal: controller.signal,
dispose: () => {
clearTimeout(timeout);
parent?.removeEventListener('abort', abort);
},
};
}
+117
View File
@@ -0,0 +1,117 @@
import { translate } from '../../shared/i18n/index';
import { extendedLifecycle } from './lifecycle';
import { within } from './files';
import fs from 'node:fs/promises';
import { writeSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { LocalContext } from './context';
import { LocalApi } from './api';
import { CliError } from '../../shared/errors';
import { withOperationOutput } from './output';
import { cancellableOperation, interruptedCode } from './cancellation';
export async function loggedOperation<T>(
context: LocalContext,
action: string,
operation: () => Promise<T>,
signal?: AbortSignal,
): Promise<{ result: T; logPath: string }> {
if (!/^[a-z]+$/.test(action))
throw new Error(translate(context.env, '命令日志目录无效。'));
const start = Date.now(),
started = Math.floor(start / 1000);
const date = new Date(start);
const stamp = [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
]
.map((value) => String(value).padStart(2, '0'))
.join('-');
const logPath = context.env.real_log_path || `${action}/${stamp}.log`;
const file = within(context.paths.dir_log!, logPath, context.env);
const persist = context.env.real_time !== 'true';
if (persist) await fs.mkdir(path.dirname(file), { recursive: true });
const log = persist ? await fs.open(file, 'a', 0o600) : undefined;
const output = (chunk: Buffer) => {
let offset = 0;
while (log && offset < chunk.length)
offset += writeSync(log.fd, chunk, offset);
if (context.env.real_time === 'true' || context.env.no_tee !== 'true')
process.stderr.write(chunk);
};
const id = Number(context.env.ID);
const extended = await extendedLifecycle(context);
const executionId =
extended && context.env.QL_EXECUTION_ORIGIN === 'scheduled_system'
? `legacy-system:${started}:${randomUUID()}`
: undefined;
const api = new LocalApi(context);
const report = async (final: boolean, code: number) => {
if (!Number.isSafeInteger(id) || id <= 0) return;
try {
await api.call('crons/status', 'PUT', {
ids: [id],
status: final ? '1' : '0',
pid: String(process.pid),
log_path: logPath,
last_execution_time: started,
last_running_time: final ? Math.floor((Date.now() - start) / 1000) : 0,
...(final && extended ? { exit_code: code } : {}),
...(executionId ? { execution_id: executionId } : {}),
});
} catch {
output(
Buffer.from(
translate(context.env, '命令状态上报失败,请检查本机面板。\n'),
),
);
}
};
let code = 1;
try {
await report(false, 0);
if (!['repo', 'raw'].includes(action))
output(
Buffer.from(
translate(context.env, '## 开始执行... %s\n', date.toISOString()),
),
);
const result = await withOperationOutput(output, () =>
cancellableOperation(signal, operation),
);
code = 0;
return { result, logPath };
} catch (error) {
code =
interruptedCode(signal) ??
(error instanceof CliError ? error.exitCode : 1);
output(
Buffer.from(
`Command failed (exit ${code}); inspect the command result.\n`,
),
);
throw error;
} finally {
try {
await report(true, code);
if (!['repo', 'raw'].includes(action))
output(
Buffer.from(
translate(
context.env,
'\n## 执行结束... %s 退出码 %s\n',
new Date().toISOString(),
code,
),
),
);
} finally {
await log?.close();
}
}
}
@@ -0,0 +1,144 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { runProcess } from './process';
import { fail } from '../../shared/errors';
export interface ContainerEnvironmentOptions {
root: string;
data: string;
env: NodeJS.ProcessEnv;
systemDirectory?: string;
}
async function writableDirectory(directory: string): Promise<boolean> {
try {
if (!(await fs.stat(directory)).isDirectory()) return false;
await fs.access(directory, fs.constants.W_OK | fs.constants.X_OK);
return true;
} catch {
return false;
}
}
async function requireContainerDirectory(
directory: string,
recursive: boolean,
env: NodeJS.ProcessEnv,
): Promise<void> {
if (await writableDirectory(directory)) return;
const uid = process.getuid?.();
const gid = process.getgid?.();
if (uid !== undefined && gid !== undefined && uid !== 0) {
await runProcess(
'chown',
[...(recursive ? ['-R'] : []), `${uid}:${gid}`, directory],
{ env, capture: true, stderrOutput: () => {} },
).catch(() => undefined);
if (await writableDirectory(directory)) return;
}
fail(
translate(
env,
'容器目录不可写或不可访问:%s(UID %s)。请检查挂载目录的所有者和权限。',
directory,
uid ?? 'unknown',
),
);
}
async function appendNetworkEntries(
filename: string,
entries: { matches: (line: string) => boolean; value: string }[],
warnings: string[],
env: NodeJS.ProcessEnv,
): Promise<void> {
try {
let content: string;
try {
content = await fs.readFile(filename, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
content = '';
}
const lines = content
.split(/\r?\n/)
.map((line) => line.split('#', 1)[0]!.trim());
const missing = entries.filter((entry) => !lines.some(entry.matches));
if (!missing.length) return;
// Append in place: Docker may mount these files individually, so rename
// based replacement would fail with EBUSY. Keep existing administrator data.
await fs.appendFile(
filename,
`${content && !content.endsWith('\n') ? '\n' : ''}${missing
.map((entry) => entry.value)
.join('\n')}\n`,
);
} catch (error) {
warnings.push(
translate(
env,
'无法初始化 %s:%s',
filename,
(error as NodeJS.ErrnoException).code ?? 'IO_ERROR',
),
);
}
}
/** Container-only preparation; no package installation or service startup. */
export async function prepareContainerEnvironment(
options: ContainerEnvironmentOptions,
): Promise<{ env: NodeJS.ProcessEnv; warnings: string[] }> {
const { root, data } = options;
if (!path.isAbsolute(root) || !path.isAbsolute(data))
fail(translate(options.env, '容器安装目录和数据目录必须为绝对路径。'), 2);
const env = { ...options.env };
await requireContainerDirectory(root, true, env);
await requireContainerDirectory(data, false, env);
// Do not create probes inside a user volume, or remove an existing .tmp.
if (!env.HOME || !(await writableDirectory(env.HOME))) {
env.HOME = path.join(root, '.tmp');
await fs.mkdir(env.HOME, { recursive: true });
await requireContainerDirectory(env.HOME, false, env);
}
const warnings: string[] = [];
const system = options.systemDirectory ?? '/etc';
const alpine = await fs.stat(path.join(system, 'alpine-release')).then(
() => true,
() => false,
);
if (alpine)
await appendNetworkEntries(
path.join(system, 'resolv.conf'),
[
{
matches: (line) =>
/^options\s/.test(line) && line.split(/\s+/).includes('ndots:0'),
value: 'options ndots:0',
},
],
warnings,
env,
);
await appendNetworkEntries(
path.join(system, 'hosts'),
[
{
matches: (line) =>
/^127\.0\.0\.1\s/.test(line) &&
line.split(/\s+/).slice(1).includes('localhost'),
value: '127.0.0.1 localhost',
},
{
matches: (line) =>
/^::1\s/.test(line) &&
line.split(/\s+/).slice(1).includes('localhost'),
value: '::1 localhost ip6-localhost ip6-loopback',
},
],
warnings,
env,
);
return { env, warnings };
}
@@ -0,0 +1,165 @@
import { translate } from '../../shared/i18n/index';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { type LocalContext, sourceEnvironment } from './context';
import { prepareContainerEnvironment } from './containerEnvironment';
import {
executableAvailable,
repairConfiguration,
startPanel,
stopPanel,
} from '../maintenance/operator';
import { launchStartupHook } from '../maintenance/bootstrap';
import { stopBot } from '../maintenance/bot';
import { runProcess } from './process';
import {
cancellableOperation,
interruptedCode,
withoutCancellation,
} from './cancellation';
export interface ContainerServices {
start: typeof startPanel;
stop: typeof stopPanel;
hook: typeof launchStartupHook;
}
async function stopStartupGroups(pids: number[]): Promise<void> {
const alive = new Set(pids);
const send = (pid: number, signal: NodeJS.Signals | 0): boolean => {
try {
process.kill(-pid, signal);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
throw error;
}
};
for (const pid of alive) if (!send(pid, 'SIGTERM')) alive.delete(pid);
// The hook CLI gives its subprocess groups ten seconds before SIGKILL.
// Let that owner finish cleanup before terminating the owner itself.
const deadline = Date.now() + 15000;
while (alive.size && Date.now() < deadline) {
await delay(50);
for (const pid of alive) if (!send(pid, 0)) alive.delete(pid);
}
for (const pid of alive) send(pid, 'SIGKILL');
}
/** Owns a dedicated container, with an external init responsible for orphan reaping. */
export async function runContainer(
context: LocalContext,
signal: AbortSignal,
options: {
systemDirectory?: string;
services?: ContainerServices;
event?: (event: Record<string, unknown>) => void;
} = {},
): Promise<number> {
const services = options.services ?? {
start: startPanel,
stop: stopPanel,
hook: launchStartupHook,
};
let runtime = context;
let serviceAttempted = false;
let botAttempted = false;
const hooks: number[] = [];
try {
return await cancellableOperation(signal, async () => {
const prepared = await prepareContainerEnvironment({
root: context.root,
data: context.data,
env: context.env,
systemDirectory: options.systemDirectory,
});
runtime = { ...context, env: prepared.env };
for (const warning of prepared.warnings)
options.event?.({ event: 'warning', message: warning });
await repairConfiguration(runtime);
runtime.env = await sourceEnvironment(
runtime.env,
[
runtime.paths.file_config_user!,
path.join(runtime.paths.dir_preload!, 'lang_env.sh'),
],
[],
{ signal },
);
runtime.env.BACK_PORT = runtime.env.QlPort || '5700';
runtime.env.GRPC_PORT = runtime.env.QlGrpcPort || '5500';
const scheduler =
runtime.env.QL_SCHEDULER ||
((await executableAvailable('crond', runtime.env)) ? 'system' : 'node');
if (scheduler !== 'node' && scheduler !== 'system')
throw new Error(
translate(runtime.env, 'QL_SCHEDULER 必须为 node 或 system。'),
);
runtime.env.QL_SCHEDULER = scheduler;
// Set scheduler mode before the backend starts, so both agree on ownership.
signal.throwIfAborted();
serviceAttempted = true;
const service = await services.start(runtime);
signal.throwIfAborted();
for (const [setting, action] of [
['AutoStartBot', 'bot'],
['EnableExtraShell', 'extra'],
] as const) {
if (runtime.env[setting] === 'true') {
if (action === 'bot') botAttempted = true;
hooks.push(await services.hook(runtime, action));
}
signal.throwIfAborted();
}
options.event?.({
event: 'started',
scheduler,
manager: service.manager,
});
if (scheduler === 'system') {
const result = await runProcess('crond', ['-f'], {
cwd: runtime.root,
env: runtime.env,
signal,
graceMs: 3000,
});
if (signal.aborted) return interruptedCode(signal)!;
// A foreground scheduler exiting, even with zero, means supervision failed.
throw new Error(
translate(runtime.env, '容器调度器意外退出(%s)。', result.code),
);
}
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const keepAlive = setInterval(() => {}, 60000);
signal.addEventListener(
'abort',
() => {
clearInterval(keepAlive);
resolve();
},
{ once: true },
);
});
return interruptedCode(signal)!;
});
} catch (error) {
if (signal.aborted) return interruptedCode(signal)!;
throw error;
} finally {
await withoutCancellation(async () => {
try {
await stopStartupGroups(hooks);
} finally {
try {
// The installer can exit after detaching Python into a new session.
// Stop it only after installation/recovery has finished, scoped to
// this container's data directory rather than the installer PID.
if (botAttempted) await stopBot(runtime);
} finally {
if (serviceAttempted) await services.stop(runtime);
}
}
});
}
}
+213
View File
@@ -0,0 +1,213 @@
import path from 'node:path';
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs';
import { runProcess } from './process';
import { fail } from '../../shared/errors';
import type { Invocation } from '../../shared/cli/arguments';
export interface LocalContext {
root: string;
data: string;
env: NodeJS.ProcessEnv;
paths: Record<string, string>;
}
export function createContext(
options: Record<string, string | boolean | undefined> = {},
inherited: NodeJS.ProcessEnv = process.env,
): LocalContext {
const configuredRoot =
typeof options.root === 'string' ? options.root : inherited.QL_DIR;
if (!configuredRoot || !path.isAbsolute(configuredRoot))
fail(
translate(inherited, '本机命令需要通过 --root 或 QL_DIR 指定绝对路径。'),
2,
);
const root = path.resolve(configuredRoot);
const data = path.resolve(
typeof options['data-dir'] === 'string'
? options['data-dir']
: inherited.QL_DATA_DIR || path.join(root, 'data'),
);
if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory())
fail(translate(inherited, '面板安装目录不存在或不是目录。'), 2);
const paths: Record<string, string> = {};
for (const [name, value] of Object.entries({
root,
data,
tmp: path.join(root, '.tmp'),
static: path.join(root, 'static'),
shell: path.join(root, 'shell'),
preload: path.join(root, 'shell/preload'),
sample: path.join(root, 'sample'),
config: path.join(data, 'config'),
scripts: path.join(data, 'scripts'),
repo: path.join(data, 'repo'),
raw: path.join(data, 'raw'),
log: path.join(data, 'log'),
db: path.join(data, 'db'),
dep: path.join(data, 'deps'),
list_tmp: path.join(data, 'log/.tmp'),
update_log: path.join(data, 'log/update'),
}))
paths[`dir_${name}`] = value;
for (const [name, value] of Object.entries({
config_sample: 'sample/config.sample.sh',
auth_sample: 'sample/auth.sample.json',
task_sample: 'sample/task.sample.sh',
extra_sample: 'sample/extra.sample.sh',
notify_py_sample: 'sample/notify.py',
notify_js_sample: 'sample/notify.js',
test_js_sample: 'sample/ql_sample.js',
test_py_sample: 'sample/ql_sample.py',
env: 'shell/preload/env.sh',
preload_js: 'shell/preload/sitecustomize.js',
}))
paths[`file_${name}`] = path.join(root, value);
for (const [name, value] of Object.entries({
config_user: 'config/config.sh',
auth_user: 'config/auth.json',
auth_token: 'config/token.json',
extra_shell: 'config/extra.sh',
task_before: 'config/task_before.sh',
task_before_js: 'config/task_before.js',
task_before_py: 'config/task_before.py',
task_after: 'config/task_after.sh',
notify_py: 'scripts/notify.py',
notify_js: 'scripts/sendNotify.js',
test_js: 'scripts/ql_sample.js',
test_py: 'scripts/ql_sample.py',
sharecode: 'config/sharecode.sh',
}))
paths[`file_${name}`] = path.join(data, value);
paths.dep_notify_py = path.join(data, 'deps/notify.py');
paths.dep_notify_js = path.join(data, 'deps/sendNotify.js');
paths.list_crontab_user = path.join(data, 'config/crontab.list');
return {
root,
data,
paths,
env: {
...inherited,
...paths,
QL_DIR: root,
QL_DATA_DIR: data,
PYTHONUNBUFFERED: '1',
cmd_task: 'task',
cmd_ql: 'ql',
is_macos: process.platform === 'darwin' ? '1' : '0',
is_termux: inherited.PATH?.includes('com.termux') ? '1' : '0',
},
};
}
// This bridge interprets user configuration only; it never sources product shell/*.sh.
// Move the parent transport from fd 3 to an internal descriptor before user
// configuration can reuse fd 3 (or fd 9 for a lock).
export async function sourceEnvironment(
env: NodeJS.ProcessEnv,
files: string[],
args: string[] = [],
options: {
command?: string;
signal?: AbortSignal;
output?: (chunk: Buffer) => void;
stderrOutput?: (chunk: Buffer) => void;
} = {},
): Promise<NodeJS.ProcessEnv> {
const script = `exec 19>&3 3>&-
__ql_env="$1"; __ql_files="$2"; __ql_command="$3"; shift 3
set -a
while IFS= read -r __ql_file; do
if [ -f "$__ql_file" ]; then . "$__ql_file" "$@"; fi
done <<< "$__ql_files"
if [ -n "$__ql_command" ]; then eval "$__ql_command"; fi
__ql_shopts=""
while read -r __ql_builtin __ql_state __ql_option; do
if [ "$__ql_state" = '-s' ]; then
__ql_shopts="\${__ql_shopts:+$__ql_shopts:}$__ql_option"
fi
done < <(shopt -p)
__ql_shellopts="$SHELLOPTS" __ql_bashopts="$__ql_shopts" "$__ql_env" -0 >&19
`;
const execution = await runProcess(
'bash',
[
'--noprofile',
'--norc',
'-c',
script,
'ql-config',
'/usr/bin/env',
files.join('\n'),
options.command ?? '',
...args,
],
{
env,
capture: true,
captureFd: 3,
maxCaptureBytes: 4 * 1024 * 1024,
timeoutMs: 30000,
graceMs: 1000,
output: options.output,
stderrOutput: options.stderrOutput,
signal: options.signal,
},
);
if (options.signal?.aborted) {
const error = new Error(
translate(env, '用户配置加载已取消。'),
) as NodeJS.ErrnoException;
error.name = 'AbortError';
error.code = 'ABORT_ERR';
throw error;
}
if (execution.code !== 0)
throw new Error(translate(env, '用户配置加载失败。'));
try {
// NUL framing preserves newlines, '=' characters and exported Bash
// functions without launching another Node process to serialize JSON.
if (!execution.stdout.endsWith('\0')) throw new Error();
const result: NodeJS.ProcessEnv = Object.create(null);
for (const entry of execution.stdout.slice(0, -1).split('\0')) {
const separator = entry.indexOf('=');
if (separator <= 0) throw new Error();
result[entry.slice(0, separator)] = entry.slice(separator + 1);
}
if (
typeof result.__ql_shellopts !== 'string' ||
typeof result.__ql_bashopts !== 'string'
)
throw new Error();
result.SHELLOPTS = result.__ql_shellopts
.split(':')
.filter((value) => value !== 'allexport')
.join(':');
result.BASHOPTS = result.__ql_bashopts;
result.QL_CLI_SHELLOPTS = result.SHELLOPTS;
result.QL_CLI_BASHOPTS = result.BASHOPTS;
for (const key of Object.keys(result))
if (key.startsWith('__ql_')) delete result[key];
return result;
} catch {
throw new Error(translate(env, '用户配置返回了无效的环境数据。'));
}
}
export async function localContext(
command: Pick<Invocation, 'values' | 'positionals'>,
options: { signal?: AbortSignal } = {},
): Promise<LocalContext> {
const context = createContext(command.values);
context.env = await sourceEnvironment(
context.env,
[
context.paths.file_config_user!,
path.join(context.paths.dir_preload!, 'lang_env.sh'),
],
command.positionals,
options,
);
return context;
}
+60
View File
@@ -0,0 +1,60 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { fail } from '../../shared/errors';
export function within(
root: string,
relative: string,
environment: NodeJS.ProcessEnv = process.env,
): string {
const target = path.resolve(root, relative);
const relation = path.relative(root, target);
if (
!relation ||
relation === '..' ||
relation.startsWith(`..${path.sep}`) ||
path.isAbsolute(relation)
)
fail(translate(environment, '路径必须位于其管理目录内部。'), 2);
return target;
}
export async function regularFiles(root: string): Promise<string[]> {
const pending = [''];
const result: string[] = [];
while (pending.length) {
const relative = pending.pop()!;
let entries;
try {
entries = await fs.readdir(path.join(root, relative), {
withFileTypes: true,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw error;
}
for (const entry of entries) {
const name = path.join(relative, entry.name);
if (entry.isDirectory()) pending.push(name);
else if (entry.isFile()) result.push(name);
}
}
return result.sort();
}
export async function atomicWrite(
target: string,
contents: string | Buffer,
mode = 0o600,
): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
const temp = `${target}.${randomUUID()}.tmp`;
try {
await fs.writeFile(temp, contents, { mode, flag: 'wx' });
await fs.rename(temp, target);
} finally {
await fs.rm(temp, { force: true });
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { LocalContext } from './context';
import { executableAvailable } from '../maintenance/operator';
import { checkedProcess } from './process';
import { fail } from '../../shared/errors';
import { translate } from '../../shared/i18n/index';
export async function registerHostServices(
context: LocalContext,
os: string,
): Promise<void> {
const options = { cwd: context.root, env: context.env };
const run = (program: string, args: string[]) =>
checkedProcess(
process.getuid?.() === 0 ? program : 'sudo',
process.getuid?.() === 0 ? args : [program, ...args],
options,
);
if (await executableAvailable('rc-update', context.env)) {
for (const name of ['nginx', 'crond']) {
await run('rc-update', ['add', name, 'default']);
// Bootstrap already started/reloaded nginx directly. Starting it again
// through init cannot adopt that PID and can fail or compete for its port.
if (name === 'crond') await run('rc-service', [name, 'start']);
}
return;
}
if (
['debian', 'ubuntu'].includes(os) &&
(await executableAvailable('systemctl', context.env))
) {
// Minimal Debian/Ubuntu installations need not contain a cron daemon.
await run('apt-get', ['install', '-y', 'cron']);
await run('systemctl', ['enable', 'nginx']);
await run('systemctl', ['enable', '--now', 'cron']);
return;
}
fail(
translate(
context.env,
'主机开机注册需要 OpenRC 或 systemd;仅在其他管理器负责服务时使用 --no-startup。',
),
2,
);
}
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { LocalContext } from './context';
// The released 2.20 API rejects unknown lifecycle fields and has no dashboard record API.
// Read the installed panel version, never the independently versioned CLI package.
export async function extendedLifecycle(
context: LocalContext,
): Promise<boolean> {
if (context.env.QL_CLI_LIFECYCLE === 'legacy') return false;
if (context.env.QL_CLI_LIFECYCLE === 'extended') return true;
let version: string | undefined;
try {
const manifest: unknown = JSON.parse(
await fs.readFile(path.join(context.root, 'package.json'), 'utf8'),
);
if (
manifest &&
typeof manifest === 'object' &&
'version' in manifest &&
typeof manifest.version === 'string'
)
version = manifest.version;
} catch {}
if (version === undefined) {
try {
// Official 2.x images may omit package.version. Read only the top-level
// scalar release field; no YAML dependency or expression evaluation.
const release = await fs.readFile(
path.join(context.root, 'version.yaml'),
'utf8',
);
const entries = release
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.filter((line) => /^version:/.test(line));
if (entries.length !== 1) return false;
const scalar =
/^version:[ \t]*(?:"([^"]+)"|'([^']+)'|([^\s#]+))[ \t]*(?:#.*)?$/.exec(
entries[0]!,
);
version = scalar?.[1] ?? scalar?.[2] ?? scalar?.[3];
} catch {
return false;
}
}
const match = /^2\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(
version ?? '',
);
return !!match && Number(match[1]) >= 21;
}
+17
View File
@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from 'node:async_hooks';
type Sink = (chunk: Buffer) => void;
const storage = new AsyncLocalStorage<Sink>();
export function operationOutput(chunk: Buffer): void {
const sink = storage.getStore();
if (sink) sink(chunk);
else process.stderr.write(chunk);
}
export function withOperationOutput<T>(
sink: Sink,
operation: () => Promise<T>,
): Promise<T> {
return storage.run(sink, operation);
}
+265
View File
@@ -0,0 +1,265 @@
import { translate } from '../../shared/i18n/index';
import { spawn, type ChildProcess } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { constants } from 'node:os';
import { fail } from '../../shared/errors';
import { operationOutput } from './output';
import { commandSignal, operationSignal } from './cancellation';
import { shellOptionPrelude } from '../execution/shellOptions';
export interface ProcessOptions {
stdin?: 'inherit' | 'ignore';
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
// A private fd reports T(ask)/A(fter). Only the task phase owns the timer.
timeoutControl?: { marker: string };
graceMs?: number;
capture?: boolean;
captureFd?: 1 | 3;
output?: (chunk: Buffer) => void;
stderrOutput?: (chunk: Buffer) => void;
maxCaptureBytes?: number;
signal?: AbortSignal;
}
export interface ProcessResult {
code: number;
stdout: string;
signal: NodeJS.Signals | null;
timedOut: boolean;
cleanupStarted?: boolean;
}
function cancellationSignal(signal?: AbortSignal): NodeJS.Signals {
// Only runner-supported termination signals may cross the abort boundary.
// Generic AbortController callers retain the default SIGTERM contract.
const reason: unknown = signal?.reason;
return commandSignal(reason);
}
export function cancellationExitCode(signal?: AbortSignal): number {
return 128 + constants.signals[cancellationSignal(signal)];
}
function signalGroup(child: ChildProcess, signal: NodeJS.Signals): void {
if (!child.pid) return;
// A POSIX process group can outlive its leader while descendants still hold
// stdout/stderr. Keep signalling the group until runProcess receives close.
if (
process.platform === 'win32' &&
(child.exitCode !== null || child.signalCode !== null)
)
return;
try {
process.kill(process.platform === 'win32' ? child.pid : -child.pid, signal);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
child.kill(signal);
return;
}
throw error;
}
}
export async function runProcess(
program: string,
args: readonly string[],
options: ProcessOptions = {},
): Promise<ProcessResult> {
options = { ...options, signal: options.signal ?? operationSignal() };
// Recognize only the command forms used by our fixed Bash bridges. A -c
// after a script name or option terminator belongs to the script's argv.
let commandIndex = 0;
while (['--noprofile', '--norc'].includes(args[commandIndex] ?? ''))
commandIndex++;
if (
/(?:^|\/)bash$/.test(program) &&
args[commandIndex] === '-c' &&
typeof args[commandIndex + 1] === 'string' &&
typeof (options.env ?? process.env).QL_CLI_SHELLOPTS === 'string'
) {
args = [...args];
(args as string[])[commandIndex + 1] =
shellOptionPrelude + args[commandIndex + 1];
}
if (options.signal?.aborted)
return {
code: cancellationExitCode(options.signal),
stdout: '',
signal: cancellationSignal(options.signal),
timedOut: false,
};
return new Promise((resolve, reject) => {
const child = spawn(program, [...args], {
cwd: options.cwd,
env: options.env ?? process.env,
detached: process.platform !== 'win32',
stdio:
options.captureFd === 3 || options.timeoutControl
? [options.stdin ?? 'ignore', 'pipe', 'pipe', 'pipe']
: [options.stdin ?? 'ignore', 'pipe', 'pipe'],
});
const chunks: Buffer[] = [];
let bytes = 0;
let timedOut = false;
let overflow = false;
let outputFailure: unknown;
let aborted = false;
let terminating = false;
let cleanupStarted = false;
let timeout: NodeJS.Timeout | undefined;
let escalation: NodeJS.Timeout | undefined;
const terminate = (signal: NodeJS.Signals) => {
terminating = true;
signalGroup(child, signal);
escalation ??= setTimeout(
() => signalGroup(child, 'SIGKILL'),
options.graceMs ?? 10000,
);
escalation.unref();
};
const abort = () => {
aborted = true;
terminate(cancellationSignal(options.signal));
};
const startTimeout = () => {
if (!options.timeoutMs || options.timeoutMs <= 0 || timedOut || aborted) return;
clearTimeout(timeout);
timeout = setTimeout(() => {
timedOut = true;
if (options.timeoutControl) {
try {
writeFileSync(options.timeoutControl.marker, '', { mode: 0o600 });
} catch (error) {
outputFailure = error;
}
}
terminate('SIGINT');
}, options.timeoutMs);
};
const cleanup = () => {
clearTimeout(timeout);
clearTimeout(escalation);
options.signal?.removeEventListener('abort', abort);
};
child.once('error', () => {
cleanup();
reject(
new Error(
translate(
options.env ?? process.env,
'无法启动必要的可执行程序:%s',
program,
),
),
);
});
const emit = (chunk: Buffer, sink = options.output) => {
if (outputFailure) return;
try {
(sink ?? operationOutput)(chunk);
} catch (error) {
outputFailure =
error ||
new Error(
translate(options.env ?? process.env, '子进程输出写入失败。'),
);
signalGroup(child, 'SIGKILL');
}
};
const capture = (chunk: Buffer) => {
if (options.capture) {
bytes += chunk.length;
if (bytes > (options.maxCaptureBytes ?? 1024 * 1024)) {
if (!overflow) {
overflow = true;
signalGroup(child, 'SIGKILL');
}
} else chunks.push(chunk);
} else emit(chunk);
};
child.stdout!.on('data', (chunk: Buffer) => {
if (options.captureFd === 3) emit(chunk);
else capture(chunk);
});
if (options.captureFd === 3) child.stdio[3]!.on('data', capture);
if (options.timeoutControl) child.stdio[3]!.on('data', (chunk: Buffer) => {
for (const phase of chunk.toString()) {
if (phase === 'T') startTimeout();
if (phase === 'A') {
cleanupStarted = true;
clearTimeout(timeout);
// Allow the Shell's cleanup to finish after a cooperative timeout.
if (timedOut && !aborted) {
clearTimeout(escalation);
escalation = undefined;
}
}
}
});
child.stderr!.on('data', (chunk: Buffer) =>
emit(chunk, options.stderrOutput ?? options.output),
);
child.once('exit', () => {
// close waits for inherited pipes. Once the Shell and its cleanup have
// exited, stop surviving descendants before waiting for those pipes.
if (terminating && process.platform !== 'win32')
signalGroup(child, 'SIGKILL');
});
child.once('close', (code, signal) => {
// Descendants may close their pipes but ignore the first signal. Do not
// cancel escalation and leave them running when the leader closes early.
if (terminating && process.platform !== 'win32')
signalGroup(child, 'SIGKILL');
cleanup();
if (outputFailure) {
reject(outputFailure);
return;
}
if (overflow) {
reject(
new Error(
translate(
options.env ?? process.env,
'子进程输出超过配置的捕获上限。',
),
),
);
return;
}
resolve({
code: timedOut
? 124
: aborted
? cancellationExitCode(options.signal)
: code ?? (signal ? 128 + constants.signals[signal] : 1),
stdout: Buffer.concat(chunks).toString('utf8'),
signal,
timedOut,
...(options.timeoutControl ? { cleanupStarted } : {}),
});
});
if (!options.timeoutControl) startTimeout();
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
});
}
export async function checkedProcess(
program: string,
args: readonly string[],
options: ProcessOptions = {},
): Promise<ProcessResult> {
const result = await runProcess(program, args, options);
if (result.code !== 0)
fail(
translate(
options.env ?? process.env,
'必要的可执行程序失败(%s,退出码 %s)。',
program,
result.code,
),
);
return result;
}
@@ -0,0 +1,42 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { runProcess } from '../runtime/process';
import { fail } from '../../shared/errors';
// Use the installation's POSIX ERE implementation, just as legacy egrep did.
// Patterns are argv data; no shell is involved.
export async function matchSubscriptionPaths(
files: string[],
pattern: string | undefined,
workspace: string,
env: NodeJS.ProcessEnv,
): Promise<Set<string>> {
if (!pattern) return new Set(files);
if (files.some((file) => /[\r\n\0]/.test(file)))
fail(translate(env, '订阅路径包含换行或空字符,无法安全过滤。'), 2);
const listing = path.join(workspace, `filter-${randomUUID()}.list`);
const input = files.length ? files.join('\n') + '\n' : '';
await fs.writeFile(listing, input, { mode: 0o600, flag: 'wx' });
try {
const result = await runProcess(
'grep',
['-E', '-e', pattern, '--', listing],
{
env,
capture: true,
maxCaptureBytes: Buffer.byteLength(input) + 1,
timeoutMs: 10000,
},
);
if (result.code === 1) return new Set();
if (result.code === 2)
fail(translate(env, '订阅 POSIX 正则表达式无效。'), 2);
if (result.code !== 0)
fail(translate(env, '订阅过滤失败(退出码 %s)。', result.code));
return new Set(result.stdout.split('\n').filter(Boolean));
} finally {
await fs.rm(listing, { force: true });
}
}
@@ -0,0 +1,386 @@
import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomInt, randomUUID } from 'node:crypto';
import type { LocalContext } from '../runtime/context';
import { LocalApi } from '../runtime/api';
import { checkedProcess } from '../runtime/process';
import { regularFiles, within } from '../runtime/files';
import { isRecord } from '../../shared/record';
import { fail } from '../../shared/errors';
import { operationOutput } from '../runtime/output';
import { matchSubscriptionPaths } from './subscriptionFilter';
export interface SubscriptionInput {
url: string;
branch?: string;
include?: string;
exclude?: string;
dependencies?: string;
extensions?: string;
proxy?: string;
autoAdd?: boolean;
autoDelete?: boolean;
subscriptionId?: number;
}
export function repositoryName(
url: string,
branch?: string,
environment: NodeJS.ProcessEnv = process.env,
): string {
const normalized = url.replace(/\/$/, '');
const last = normalized.lastIndexOf('/');
const base = normalized.slice(last + 1).replace(/\.[^.]*$/, '');
const owner = normalized
.slice(0, last)
.split('/')
.at(-1)!
.split(':')
.at(-1)!
.split('.')
.at(-1)!;
const result = `${owner}_${base}${branch ? '_' + branch : ''}`;
if (
!result ||
result.split('/').some((part) => !part || part === '.' || part === '..') ||
/[\0\r\n]/.test(result)
)
fail(translate(environment, '仓库路径无效。'), 2);
return result;
}
export function cronMetadata(
contents: string,
filename: string,
fallback: string,
): { name: string; schedule: string } {
const name =
contents.match(/new\s+Env\(\s*['"]([^'"\r\n]+)['"]/)?.[1] ||
contents.match(/^\s*(?:#\s*)?name:\s*(.+)$/m)?.[1]?.trim() ||
path.basename(filename);
const explicit =
contents
.match(/\bcron:\s*([^\r\n]+)/)?.[1]
?.replace(/["',]+$/, '')
.trim() || contents.match(/\bcron\s+["']([^"']+)["']/)?.[1];
// Legacy add_cron selects a filename-bearing expression before annotations,
// deduplicates/sorts the matches, and takes the first expression.
const matching = contents
.split('\n')
.filter((item) => item.includes(path.basename(filename)))
.map((item) =>
item
.match(
/(?:[\d*](?:[\d*/,-]*[\d*])?\s+){4,5}[\d*](?:[\d*/,-]*[\d*])?/,
)?.[0]
?.trim(),
)
.filter((value): value is string => !!value);
const schedule = [...new Set(matching)].sort()[0] || explicit || fallback;
return { name, schedule };
}
function taskPath(command: unknown): string | undefined {
if (typeof command !== 'string') return undefined;
const match = command.match(
/^task\s+(?:'((?:[^']|'\\'')*)'|"([^"\n]*)"|(\S+))/,
);
return match
? match[1]?.replace(/'\\''/g, "'") ?? match[2] ?? match[3]
: undefined;
}
function quote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
async function copyRegular(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.rm(destination, { force: true });
await fs.copyFile(source, destination);
}
export async function replaceDirectory(
staged: string,
destination: string,
): Promise<void> {
const backup = `${destination}.${randomUUID()}.previous`;
const incoming = `${destination}.${randomUUID()}.incoming`;
let moved = false;
await fs.mkdir(path.dirname(destination), { recursive: true });
try {
try {
await fs.rename(staged, incoming);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
await fs.cp(staged, incoming, {
recursive: true,
verbatimSymlinks: true,
errorOnExist: true,
force: false,
});
}
try {
await fs.rename(destination, backup);
moved = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
await fs.rename(incoming, destination);
} catch (error) {
if (moved) await fs.rename(backup, destination);
throw error;
} finally {
await fs.rm(incoming, { force: true, recursive: true });
}
if (moved) await fs.rm(backup, { force: true, recursive: true });
}
async function reconcile(
context: LocalContext,
input: SubscriptionInput,
namespace: string,
paths: string[],
): Promise<{ added: number; removed: number }> {
const autoAdd = input.autoAdd ?? context.env.AutoAddCron === 'true';
const autoDelete = input.autoDelete ?? context.env.AutoDelCron === 'true';
if (!autoAdd && !autoDelete) return { added: 0, removed: 0 };
const api = new LocalApi(context);
const response = await api.call('crons');
if (!isRecord(response.data) || !Array.isArray(response.data.data))
fail(translate(context.env, '订阅任务同步返回了无效任务列表。'));
const scoped = response.data.data.filter(
(row): row is Record<string, unknown> =>
isRecord(row) &&
typeof row.id === 'number' &&
Number.isSafeInteger(row.id) &&
row.id > 0 &&
(input.subscriptionId === undefined ||
row.sub_id === input.subscriptionId) &&
(() => {
const file = taskPath(row.command);
if (!file || file.includes('\\') || path.posix.normalize(file) !== file)
return false;
return namespace.endsWith('/')
? file.startsWith(namespace)
: file === namespace;
})(),
);
const existing = new Set(scoped.map((row) => taskPath(row.command)));
const expected = new Set(paths);
const obsolete = scoped.filter(
(row) => !expected.has(taskPath(row.command)!),
);
let added = 0,
removed = 0;
if (autoAdd) {
for (const relative of paths) {
if (existing.has(relative)) continue;
const contents = await fs.readFile(
within(context.paths.dir_scripts!, relative, context.env),
'utf8',
);
const fallback =
context.env.DefaultCronRule ||
`${randomInt(0, 59)} ${randomInt(0, 23)} * * *`;
const meta = cronMetadata(contents, relative, fallback);
await api.call('crons', 'POST', {
...meta,
command: `task ${
/^[\w./-]+$/.test(relative) ? relative : quote(relative)
}`,
sub_id: input.subscriptionId ?? null,
});
added++;
}
}
if (autoDelete && obsolete.length) {
await api.call(
'crons',
'DELETE',
obsolete.map((row) => row.id),
);
for (const row of obsolete) {
await fs.rm(
within(context.paths.dir_scripts!, taskPath(row.command)!, context.env),
{
force: true,
},
);
removed++;
}
}
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
});
} catch {
operationOutput(
Buffer.from(
translate(
context.env,
'订阅同步已完成,但通知发送失败,请检查面板通知设置。\n',
),
),
);
}
}
return { added, removed };
}
export async function syncRepository(
context: LocalContext,
input: SubscriptionInput,
): Promise<unknown> {
const name = repositoryName(input.url, input.branch, context.env);
const destination = within(context.paths.dir_scripts!, name, context.env);
const repoDestination = within(context.paths.dir_repo!, name, context.env);
const extensions = (
input.extensions ||
context.env.RepoFileExtensions ||
'js py'
)
.split(/[|\s]+/)
.filter(Boolean);
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
const stage = await fs.mkdtemp(
path.join(context.paths.dir_tmp!, 'subscription-'),
);
const checkout = path.join(stage, 'checkout'),
scripts = path.join(stage, 'scripts');
try {
const env = { ...context.env };
const proxy = input.proxy || env.ProxyUrl;
if (proxy) {
env.http_proxy = proxy;
env.https_proxy = proxy;
}
await checkedProcess(
'git',
[
'clone',
'-q',
'--depth=1',
...(input.branch ? ['--branch', input.branch] : []),
'--',
input.url,
checkout,
],
{ env, cwd: context.root },
);
const candidates = (await regularFiles(checkout)).filter(
(file) =>
!file.split(path.sep).includes('.git') &&
extensions.includes(path.extname(file).slice(1)),
);
const included = await matchSubscriptionPaths(
candidates,
input.include,
stage,
env,
);
const excluded = input.exclude
? await matchSubscriptionPaths(candidates, input.exclude, stage, env)
: new Set<string>();
const dependencies = input.dependencies
? await matchSubscriptionPaths(candidates, input.dependencies, stage, env)
: new Set<string>();
const selected = candidates.filter(
(file) => included.has(file) && !excluded.has(file),
);
await fs.mkdir(scripts);
// Retain previous files unless task reconciliation explicitly removes them.
for (const file of await regularFiles(destination))
await copyRegular(
path.join(destination, file),
within(scripts, file, context.env),
);
for (const key of ['file_notify_js', 'file_notify_py']) {
const source = context.paths[key]!;
if ((await fs.stat(source).catch(() => undefined))?.isFile())
await copyRegular(source, path.join(scripts, path.basename(source)));
}
// Preserve gen_list_repo precedence: notification defaults, repository
// dependencies, local dependency overrides, then selected task scripts.
for (const file of candidates.filter((file) => dependencies.has(file)))
await copyRegular(
path.join(checkout, file),
within(scripts, file, context.env),
);
for (const file of await regularFiles(context.paths.dir_dep!))
await copyRegular(
path.join(context.paths.dir_dep!, file),
within(scripts, file, context.env),
);
for (const file of selected)
await copyRegular(
path.join(checkout, file),
within(scripts, file, context.env),
);
await replaceDirectory(scripts, destination);
await replaceDirectory(checkout, repoDestination);
const relativePaths = selected.map((file) =>
path.posix.join(name, file.split(path.sep).join('/')),
);
const changes = await reconcile(context, input, `${name}/`, relativePaths);
return { repository: name, files: relativePaths, ...changes };
} finally {
await fs.rm(stage, { recursive: true, force: true });
}
}
export async function syncRaw(
context: LocalContext,
input: SubscriptionInput,
): Promise<unknown> {
const url = new URL(input.url);
if (!['http:', 'https:'].includes(url.protocol))
fail(translate(context.env, '单文件订阅需要 HTTP(S) 地址。'), 2);
const name =
repositoryName(input.url, undefined, context.env) +
path.extname(url.pathname);
const raw = within(context.paths.dir_raw!, name, context.env);
const relative = `raw_${name}`;
await fs.mkdir(path.dirname(raw), { recursive: true });
const temporary = `${raw}.${randomUUID()}.tmp`;
try {
const env = { ...context.env };
const proxy = input.proxy || env.ProxyUrl;
if (proxy) {
env.http_proxy = proxy;
env.https_proxy = proxy;
}
await checkedProcess(
'curl',
[
'--fail',
'--netrc-optional',
'--location',
'--silent',
'--show-error',
'--output',
temporary,
'--url',
input.url,
],
{ env, timeoutMs: 120000 },
);
await fs.rename(temporary, raw);
await copyRegular(
raw,
within(context.paths.dir_scripts!, relative, context.env),
);
const changes = await reconcile(
context,
{ ...input, autoDelete: false },
relative,
[relative],
);
return { file: relative, ...changes };
} finally {
await fs.rm(temporary, { force: true });
}
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
import { translate } from '../../shared/i18n/index';
import { standaloneHelp } from '../help/standalone';
import { withCommandCancellation, interruptedCode } from '../runtime/cancellation';
import { localContext } from '../runtime/context';
import {
syncRaw,
syncRepository,
type SubscriptionInput,
} from './subscriptionRunner';
import { CliError, fail } from '../../shared/errors';
import { loggedOperation } from '../runtime/commandLog';
// Compatibility worker for the existing backend SUB_ID=... ql repo/raw contract.
// This is intentionally not registered in the public panel management CLI.
export async function subscriptionWorker(
args = process.argv.slice(2),
): Promise<number> {
return withCommandCancellation(async (signal) => {
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) {
process.stdout.write(standaloneHelp('worker'));
return 0;
}
const [kind, url, ...options] = args;
if (!['repo', 'raw'].includes(kind ?? '') || !url)
fail(
translate(
process.env,
'用法:ql-subscription-worker repo|raw <url> [旧订阅参数]',
),
2,
);
const bool = (value?: string) => {
if (value === undefined || value === '') return undefined;
if (!['true', 'false'].includes(value))
fail(
translate(
process.env,
'订阅布尔参数无效,只能使用 true 或 false。',
),
2,
);
return value === 'true';
};
if (options.length > (kind === 'repo' ? 8 : 3))
fail(translate(process.env, '订阅参数过多。'), 2);
const input: SubscriptionInput =
kind === 'repo'
? {
url,
include: options[0],
exclude: options[1],
dependencies: options[2],
branch: options[3],
extensions: options[4],
proxy: options[5],
autoAdd: bool(options[6]),
autoDelete: bool(options[7]),
}
: {
url,
proxy: options[0],
autoAdd: bool(options[1]),
autoDelete: bool(options[2]),
};
if (process.env.SUB_ID) {
const id = Number(process.env.SUB_ID);
if (!Number.isSafeInteger(id) || id <= 0)
fail(translate(process.env, 'SUB_ID 无效,必须为正整数。'), 2);
input.subscriptionId = id;
}
const context = await localContext(
{ values: {}, positionals: args },
{ signal },
);
const { result, logPath } = await loggedOperation(
context,
kind!,
() =>
kind === 'repo'
? syncRepository(context, input)
: syncRaw(context, input),
signal,
);
process.stdout.write(
JSON.stringify({ code: 200, data: result, logPath }) + '\n',
);
return 0;
} catch (error) {
const code =
interruptedCode(signal) ??
(error instanceof CliError ? error.exitCode : 1);
process.stderr.write(
JSON.stringify({
code,
message: signal.aborted
? translate(process.env, '命令已中断。')
: error instanceof CliError
? error.message
: translate(process.env, '订阅执行器失败,请检查本机日志。'),
}) + '\n',
);
return code;
}
});
}
if (require.main === module)
void subscriptionWorker().then((code) => {
process.exitCode = code;
});
+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 };
+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;
}