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
+280
View File
@@ -0,0 +1,280 @@
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');
async function fixture(t, mode = 'node') {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-container-runtime-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
for (const name of ['data', 'sample', 'etc', 'bin'])
await fs.mkdir(path.join(root, name));
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');
const context = createContext(
{ root },
{
...process.env,
HOME: root,
PATH: `${root}/bin:${process.env.PATH}`,
QL_SCHEDULER: mode,
},
);
const calls = [];
const services = {
start: async (runtime) => {
calls.push({ action: 'start', env: runtime.env });
return { manager: 'node' };
},
stop: async () => {
calls.push({ action: 'stop' });
},
hook: async () => {
throw new Error('unexpected hook');
},
};
const controller = new AbortController();
return {
root,
context,
calls,
controller,
options: { services, systemDirectory: path.join(root, 'etc') },
};
}
async function waitFile(filename) {
for (let i = 0; i < 100; i++) {
if (await fs.stat(filename).catch(() => false)) return;
await delay(20);
}
throw new Error(`Timed out waiting for ${filename}`);
}
test('container loads repaired configuration and exports ports/scheduler before service start', async (t) => {
const f = await fixture(t);
await fs.writeFile(
path.join(f.root, 'sample/config.sample.sh'),
'QlPort=5799\nQlGrpcPort=5599\n',
);
const events = [];
f.options.event = (event) => {
events.push(event);
if (event.event === 'started') f.controller.abort('SIGTERM');
};
assert.equal(
await runContainer(f.context, f.controller.signal, f.options),
143,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
assert.equal(f.calls[0].env.BACK_PORT, '5799');
assert.equal(f.calls[0].env.GRPC_PORT, '5599');
assert.equal(f.calls[0].env.QL_SCHEDULER, 'node');
assert.equal(f.context.env.BACK_PORT, process.env.BACK_PORT);
assert.deepEqual(events, [
{ event: 'started', scheduler: 'node', manager: 'node' },
]);
});
test('system scheduler is auto-selected, receives termination and exits before service cleanup', async (t) => {
const f = await fixture(t, '');
const started = path.join(f.root, 'scheduler-started');
const stopped = path.join(f.root, 'scheduler-stopped');
f.context.env.QL_TEST_STARTED = started;
f.context.env.QL_TEST_STOPPED = stopped;
await fs.writeFile(
path.join(f.root, 'bin/crond'),
`#!${process.execPath}\nconst fs=require('node:fs'); fs.writeFileSync(process.env.QL_TEST_STARTED, JSON.stringify(process.argv.slice(2))); process.on('SIGTERM',()=>{fs.writeFileSync(process.env.QL_TEST_STOPPED, 'stopped');process.exit(0)}); setInterval(()=>{},1000);\n`,
{ mode: 0o755 },
);
f.options.services.stop = async () => {
assert.equal(await fs.readFile(stopped, 'utf8'), 'stopped');
f.calls.push({ action: 'stop' });
};
const running = runContainer(f.context, f.controller.signal, f.options);
t.after(() => f.controller.abort('SIGTERM'));
await waitFile(started);
f.controller.abort('SIGTERM');
assert.equal(await running, 143);
assert.equal(await fs.readFile(started, 'utf8'), '["-f"]');
assert.equal(f.calls[0].env.QL_SCHEDULER, 'system');
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test('unexpected scheduler exit is a failure and stops the panel', async (t) => {
const f = await fixture(t, 'system');
await fs.writeFile(path.join(f.root, 'bin/crond'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/scheduler exited unexpectedly \(0\)|调度器意外退出(0)/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test('partial startup failure cleans services and invalid scheduler fails before startup', async (t) => {
const f = await fixture(t);
f.options.services.start = async () => {
f.calls.push({ action: 'start' });
throw new Error('partial startup');
};
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/partial startup/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
f.calls.length = 0;
f.context.env.QL_SCHEDULER = 'invalid';
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/must be node or system|必须为 node 或 system/,
);
assert.deepEqual(f.calls, []);
});
test('cancellation during service startup still cleans the partially started panel', async (t) => {
const f = await fixture(t);
f.options.services.start = async () => {
f.calls.push({ action: 'start' });
f.controller.abort('SIGINT');
return { manager: 'node' };
};
assert.equal(
await runContainer(f.context, f.controller.signal, f.options),
130,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test(
'actual extra startup hook finishes subprocess escalation before its supervisor is killed',
{ timeout: 20000 },
async (t) => {
const f = await fixture(t);
const { launchStartupHook } = require('../../dist/internal/maintenance/bootstrap');
const pidFile = path.join(f.root, 'extra-pid');
f.context.env.QL_TEST_EXTRA_PID = pidFile;
await fs.writeFile(
path.join(f.root, 'sample/config.sample.sh'),
'EnableExtraShell=true\n',
);
await fs.writeFile(
path.join(f.root, 'sample/extra.sample.sh'),
'trap "" TERM\nprintf "%s" "$$" > "$QL_TEST_EXTRA_PID"\nwhile :; do sleep 1; done\n',
);
let hookPid;
f.options.services.hook = async (runtime, action) => {
hookPid = await launchStartupHook(runtime, action);
return hookPid;
};
const live = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
};
const running = runContainer(f.context, f.controller.signal, f.options);
let extraPid;
t.after(async () => {
f.controller.abort('SIGTERM');
for (const pid of [extraPid, hookPid])
if (pid) {
try {
process.kill(-pid, 'SIGKILL');
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
}
await running.catch(() => {});
});
await waitFile(pidFile);
extraPid = Number(await fs.readFile(pidFile, 'utf8'));
assert.ok(extraPid > 1 && hookPid > 1);
assert.equal(live(extraPid), true);
f.controller.abort('SIGTERM');
assert.equal(await running, 143);
for (let i = 0; i < 20 && (live(extraPid) || live(hookPid)); i++)
await delay(50);
assert.equal(
live(extraPid),
false,
'extra subprocess must not outlive container cleanup',
);
assert.equal(live(hookPid), false, 'startup supervisor must exit');
const log = await fs.readFile(
path.join(f.root, 'data/log/extra.log'),
'utf8',
);
const result = log
.trim()
.split('\n')
.filter((line) => line.startsWith('{'))
.map((line) => JSON.parse(line))
.at(-1);
assert.equal(result.code, 143);
assert.equal(
result.message,
f.context.env.QL_LANG === 'en'
? 'CLI operation cancelled.'
: 'CLI 操作已取消。',
);
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(`container scheduler errors are localized and preserve cleanup: ${language}`, async (t) => {
const f = await fixture(t, 'invalid');
f.context.env.QL_LANG = language;
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
language === 'en' ? /must be node or system/ : /必须为 node 或 system/,
);
assert.deepEqual(f.calls, []);
f.context.env.QL_SCHEDULER = 'system';
await fs.writeFile(
path.join(f.root, 'bin/crond'),
`#!${process.execPath}\nprocess.exitCode=7;`,
{ mode: 0o755 },
);
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
language === 'en' ? /unexpectedly \(7\)/ : /意外退出(7)/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
}