mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* 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
142 lines
4.5 KiB
JavaScript
142 lines
4.5 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { setTimeout: delay } = require('node:timers/promises');
|
|
const { createContext } = require('../../dist/internal/runtime/context');
|
|
const { runContainer } = require('../../dist/internal/runtime/containerRuntime');
|
|
const { launchStartupHook } = require('../../dist/internal/maintenance/bootstrap');
|
|
const { launchBot } = require('../../dist/internal/maintenance/bot');
|
|
|
|
const live = (pid) => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === 'ESRCH') return false;
|
|
throw error;
|
|
}
|
|
};
|
|
async function until(check) {
|
|
for (let i = 0; i < 200; i++) {
|
|
if (await check()) return;
|
|
await delay(25);
|
|
}
|
|
throw new Error('Timed out waiting for fixture process');
|
|
}
|
|
|
|
test(
|
|
'container stops its daemonized bot after installer exits and preserves another installation',
|
|
{ skip: process.platform !== 'linux', timeout: 20000 },
|
|
async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-bot-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
for (const dir of [
|
|
'data',
|
|
'sample',
|
|
'etc',
|
|
'bin',
|
|
'data/repo/dockerbot/.git',
|
|
'data/repo/dockerbot/jbot',
|
|
'data/repo/dockerbot/config',
|
|
'other/data/jbot',
|
|
'other/data/log',
|
|
])
|
|
await fs.mkdir(path.join(root, dir), { recursive: true });
|
|
for (const name of [
|
|
'config.sample.sh',
|
|
'task.sample.sh',
|
|
'extra.sample.sh',
|
|
'notify.py',
|
|
'notify.js',
|
|
'ql_sample.py',
|
|
'ql_sample.js',
|
|
])
|
|
await fs.writeFile(path.join(root, 'sample', name), '\n');
|
|
await fs.writeFile(
|
|
path.join(root, 'sample/config.sample.sh'),
|
|
'AutoStartBot=true\n',
|
|
);
|
|
// Isolate package provisioning; execute the real installer, Python launcher
|
|
// and detached admin entry against a local module that never uses Telegram.
|
|
for (const name of ['apk', 'sudo', 'pip3'])
|
|
await fs.writeFile(path.join(root, 'bin', name), '#!/bin/sh\nexit 0\n', {
|
|
mode: 0o755,
|
|
});
|
|
const python =
|
|
'import os,time\nfrom pathlib import Path\nPath(os.environ["FIXTURE_PID"]).write_text(str(os.getpid()))\nwhile True: time.sleep(1)\n';
|
|
await fs.writeFile(
|
|
path.join(root, 'data/repo/dockerbot/jbot/__main__.py'),
|
|
python,
|
|
);
|
|
await fs.writeFile(
|
|
path.join(root, 'data/repo/dockerbot/jbot/requirements.txt'),
|
|
'',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(root, 'data/repo/dockerbot/config/bot.json'),
|
|
'{}',
|
|
);
|
|
await fs.writeFile(path.join(root, 'other/data/jbot/__main__.py'), python);
|
|
const env = {
|
|
PATH: `${root}/bin:${process.env.PATH}`,
|
|
HOME: root,
|
|
QL_OS_TYPE: 'alpine',
|
|
QL_SCHEDULER: 'node',
|
|
FIXTURE_PID: path.join(root, 'bot.pid'),
|
|
};
|
|
const context = createContext({ root }, env);
|
|
const foreign = createContext(
|
|
{ root: path.join(root, 'other') },
|
|
{ ...env, FIXTURE_PID: path.join(root, 'foreign.pid') },
|
|
);
|
|
const foreignBot = await launchBot(foreign);
|
|
let ownPid, installerPid;
|
|
const controller = new AbortController();
|
|
let running;
|
|
t.after(async () => {
|
|
controller.abort('SIGTERM');
|
|
for (const pid of [ownPid, installerPid, foreignBot.pid])
|
|
if (pid) {
|
|
try {
|
|
process.kill(-pid, 'SIGKILL');
|
|
} catch (error) {
|
|
if (error.code !== 'ESRCH') throw error;
|
|
}
|
|
}
|
|
await running?.catch(() => {});
|
|
});
|
|
running = runContainer(context, controller.signal, {
|
|
systemDirectory: path.join(root, 'etc'),
|
|
services: {
|
|
start: async () => ({ manager: 'node' }),
|
|
stop: async () => {},
|
|
hook: async (runtime, action) => {
|
|
installerPid = await launchStartupHook(runtime, action);
|
|
return installerPid;
|
|
},
|
|
},
|
|
});
|
|
await until(
|
|
async () => !!(await fs.stat(env.FIXTURE_PID).catch(() => false)),
|
|
);
|
|
ownPid = Number(await fs.readFile(env.FIXTURE_PID, 'utf8'));
|
|
assert.ok(ownPid > 1);
|
|
await until(() => installerPid && !live(installerPid));
|
|
assert.equal(
|
|
live(ownPid),
|
|
true,
|
|
'bot must outlive its completed installer',
|
|
);
|
|
controller.abort('SIGTERM');
|
|
assert.equal(await running, 143);
|
|
await until(() => !live(ownPid));
|
|
assert.equal(
|
|
live(foreignBot.pid),
|
|
true,
|
|
'other installation must remain running',
|
|
);
|
|
},
|
|
);
|