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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user