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
@@ -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 });
}
}
}