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
+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;
});