Files
qinglong/cli/src/internal/maintenance/operator.ts
T
whyour 801a71d740 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
2026-09-25 23:24:41 +08:00

205 lines
6.4 KiB
TypeScript

import { translate } from '../../shared/i18n/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { runProcess, checkedProcess } from '../runtime/process';
import { findDirectBackendPids } from '../runtime/backendProcesses';
export async function repairConfiguration(
context: LocalContext,
): Promise<string[]> {
for (const name of [
'tmp',
'static',
'data',
'config',
'log',
'db',
'scripts',
'list_tmp',
'repo',
'raw',
'update_log',
'dep',
])
await fs.mkdir(context.paths[`dir_${name}`]!, { recursive: true });
const restored: string[] = [];
const templates: [string, string, boolean][] = [
['file_config_user', 'file_config_sample', true],
['file_task_before', 'file_task_sample', false],
['file_task_after', 'file_task_sample', false],
['file_extra_shell', 'file_extra_sample', false],
['file_notify_py', 'file_notify_py_sample', true],
['file_notify_js', 'file_notify_js_sample', true],
['file_test_js', 'file_test_js_sample', true],
['file_test_py', 'file_test_py_sample', true],
['dep_notify_js', 'file_notify_js_sample', true],
['dep_notify_py', 'file_notify_py_sample', true],
];
for (const [destination, source, fillEmpty] of templates) {
const target = context.paths[destination]!;
const stat = await fs.stat(target).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return undefined;
});
if (stat && (!fillEmpty || stat.size > 0)) continue;
await atomicWrite(target, await fs.readFile(context.paths[source]!));
restored.push(target);
}
return restored;
}
export async function executableAvailable(
program: string,
env: NodeJS.ProcessEnv,
): Promise<boolean> {
for (const directory of (env.PATH ?? '').split(path.delimiter)) {
if (!directory) continue;
try {
await fs.access(path.join(directory, program), fs.constants.X_OK);
return true;
} catch {
/* Try the next PATH entry. */
}
}
return false;
}
export async function installPanelDependencies(
context: LocalContext,
cwd = context.root,
): Promise<void> {
const termux = context.env.is_termux === '1';
const pnpm = !termux && (await executableAvailable('pnpm', context.env));
await checkedProcess(
pnpm ? 'pnpm' : 'npm',
pnpm
? ['install', '--loglevel', 'error', '--production']
: ['install', '--production', ...(termux ? ['--no-bin-links'] : [])],
{ cwd, env: context.env },
);
}
// Match only this installation's backend; the legacy broad pkill pattern could
// terminate another panel running on the same host.
async function stopDirectBackend(context: LocalContext): Promise<void> {
const entry = path.join(context.paths.dir_static!, 'build/app.js');
if (process.platform === 'linux') {
const stopping = new Set(await findDirectBackendPids(entry));
for (const pid of stopping) {
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
const deadline = Date.now() + 5000;
while (
(await findDirectBackendPids(entry)).some((pid) => stopping.has(pid))
) {
if (Date.now() >= deadline)
throw new Error(
translate(context.env, '后端进程未在 5 秒内停止,已取消重启。'),
);
await delay(50);
}
return;
}
const result = await runProcess('ps', ['-eo', 'pid=,args='], {
capture: true,
env: context.env,
});
if (result.code !== 0)
throw new Error(translate(context.env, '无法检查运行中的后端进程。'));
for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
if (match[3] !== entry) continue;
const pid = Number(match[1]);
if (pid === process.pid) continue;
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
}
export async function stopPanel(context: LocalContext): Promise<void> {
if (await executableAvailable('pm2', context.env))
await runProcess('pm2', ['delete', 'ecosystem.config.js'], {
cwd: context.root,
env: context.env,
});
await stopDirectBackend(context);
}
export async function startPanel(
context: LocalContext,
): Promise<{ manager: 'pm2' | 'node'; pid?: number }> {
// Match container startup even when invoked from a fresh administrative CLI
// process. Legacy dotenv files can still contain BACK_PORT=5600.
const options = {
cwd: context.root,
env: {
...context.env,
BACK_PORT: context.env.QlPort || '5700',
GRPC_PORT: context.env.QlGrpcPort || '5500',
},
};
if (await executableAvailable('pm2', context.env)) {
const flushed = await runProcess('pm2', ['flush'], options);
if (flushed.code === 0) {
const started = await runProcess(
'pm2',
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
options,
);
if (started.code === 0) return { manager: 'pm2' };
}
}
await stopDirectBackend(context);
const entry = path.join(context.paths.dir_static!, 'build/app.js');
await fs.access(entry);
await fs.mkdir(context.paths.dir_log!, { recursive: true });
const log = await fs.open(
path.join(context.paths.dir_log!, 'qinglong.log'),
'a',
0o600,
);
try {
const child = spawn(process.execPath, [entry], {
...options,
detached: true,
stdio: ['ignore', log.fd, log.fd],
});
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
const failed = (code: number | null) => {
clearTimeout(timer);
reject(
new Error(
translate(
context.env,
'直接运行的后端在启动期间退出(%s)。',
code,
),
),
);
};
const timer = setTimeout(() => {
child.removeListener('exit', failed);
resolve();
}, 1000);
child.once('exit', failed);
});
child.unref();
return { manager: 'node', pid: child.pid };
} finally {
await log.close();
}
}