mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* 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
543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
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,
|
||
};
|
||
}
|