mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:20:51 +08:00
feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants
This commit is contained in:
@@ -0,0 +1,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) };
|
||||
}
|
||||
@@ -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!),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user