feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)

* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
This commit is contained in:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+117
View File
@@ -0,0 +1,117 @@
import { translate } from '../../shared/i18n/index';
import { extendedLifecycle } from './lifecycle';
import { within } from './files';
import fs from 'node:fs/promises';
import { writeSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { LocalContext } from './context';
import { LocalApi } from './api';
import { CliError } from '../../shared/errors';
import { withOperationOutput } from './output';
import { cancellableOperation, interruptedCode } from './cancellation';
export async function loggedOperation<T>(
context: LocalContext,
action: string,
operation: () => Promise<T>,
signal?: AbortSignal,
): Promise<{ result: T; logPath: string }> {
if (!/^[a-z]+$/.test(action))
throw new Error(translate(context.env, '命令日志目录无效。'));
const start = Date.now(),
started = Math.floor(start / 1000);
const date = new Date(start);
const stamp = [
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
]
.map((value) => String(value).padStart(2, '0'))
.join('-');
const logPath = context.env.real_log_path || `${action}/${stamp}.log`;
const file = within(context.paths.dir_log!, logPath, context.env);
const persist = context.env.real_time !== 'true';
if (persist) await fs.mkdir(path.dirname(file), { recursive: true });
const log = persist ? await fs.open(file, 'a', 0o600) : undefined;
const output = (chunk: Buffer) => {
let offset = 0;
while (log && offset < chunk.length)
offset += writeSync(log.fd, chunk, offset);
if (context.env.real_time === 'true' || context.env.no_tee !== 'true')
process.stderr.write(chunk);
};
const id = Number(context.env.ID);
const extended = await extendedLifecycle(context);
const executionId =
extended && context.env.QL_EXECUTION_ORIGIN === 'scheduled_system'
? `legacy-system:${started}:${randomUUID()}`
: undefined;
const api = new LocalApi(context);
const report = async (final: boolean, code: number) => {
if (!Number.isSafeInteger(id) || id <= 0) return;
try {
await api.call('crons/status', 'PUT', {
ids: [id],
status: final ? '1' : '0',
pid: String(process.pid),
log_path: logPath,
last_execution_time: started,
last_running_time: final ? Math.floor((Date.now() - start) / 1000) : 0,
...(final && extended ? { exit_code: code } : {}),
...(executionId ? { execution_id: executionId } : {}),
});
} catch {
output(
Buffer.from(
translate(context.env, '命令状态上报失败,请检查本机面板。\n'),
),
);
}
};
let code = 1;
try {
await report(false, 0);
if (!['repo', 'raw'].includes(action))
output(
Buffer.from(
translate(context.env, '## 开始执行... %s\n', date.toISOString()),
),
);
const result = await withOperationOutput(output, () =>
cancellableOperation(signal, operation),
);
code = 0;
return { result, logPath };
} catch (error) {
code =
interruptedCode(signal) ??
(error instanceof CliError ? error.exitCode : 1);
output(
Buffer.from(
`Command failed (exit ${code}); inspect the command result.\n`,
),
);
throw error;
} finally {
try {
await report(true, code);
if (!['repo', 'raw'].includes(action))
output(
Buffer.from(
translate(
context.env,
'\n## 执行结束... %s 退出码 %s\n',
new Date().toISOString(),
code,
),
),
);
} finally {
await log?.close();
}
}
}