mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user