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
+417
View File
@@ -0,0 +1,417 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const { compatibilityRoute } = require('../../dist/compat');
const { parse } = require('../helpers/commands.cjs');
test('legacy adapter maps positional switches and preserves subscription arguments', () => {
for (const [input, expected] of [
[['update'], ['update']],
[['update', 'true'], ['update']],
[
['-l', 'update', 'false'],
['update', '--download-only'],
],
[['reload'], ['reload', '--target', 'services']],
[
['reload', 'system'],
['reload', '--target', 'system'],
],
[
['reload', 'data'],
['reload', '--target', 'data'],
],
]) {
const route = compatibilityRoute(input);
assert.deepEqual(route, { surface: 'local', args: expected });
assert.doesNotThrow(() => parse(route.args, 'local'));
}
for (const action of ['repo', 'raw']) {
const args = [
action,
'https://example.invalid/owner/repo.git',
'',
'a b',
'',
'feature/test',
];
assert.deepEqual(compatibilityRoute(args), {
surface: 'subscription',
args,
});
}
const reset = compatibilityRoute(['resetpwd', '--literal-password']);
assert.deepEqual(parse(reset.args, 'local').positionals, [
'--literal-password',
]);
for (const input of [
['update', 'maybe'],
['reload', 'wrong'],
['login'],
['task', 'run', '1'],
])
assert.throws(() => compatibilityRoute(input));
});
test('legacy adapter help runs without panel setup and invalid commands fail before execution', () => {
const entry = path.resolve(__dirname, '../../dist/compat.js');
const env = {
PATH: process.env.PATH,
QL_DIR: '/nonexistent-panel',
QL_LANG: 'en',
};
const help = spawnSync(process.execPath, [entry, '--help'], {
env,
encoding: 'utf8',
});
assert.equal(help.status, 0);
assert.match(help.stdout, /Usage: ql-compat/);
const invalid = spawnSync(process.execPath, [entry, 'update', 'maybe'], {
env,
encoding: 'utf8',
});
assert.equal(invalid.status, 2);
assert.equal(invalid.stdout, '');
assert.equal(JSON.parse(invalid.stderr).code, 2);
});
test('legacy maintenance logs output and reports lifecycle without sourcing config twice', async (t) => {
const fs = require('node:fs/promises');
const os = require('node:os');
const http = require('node:http');
const { promisify } = require('node:util');
const exec = promisify(require('node:child_process').execFile);
for (const flags of ['no_tee=true', 'real_time=true\nno_tee=true']) {
for (const failed of [false, true]) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-compat-log-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const calls = [];
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
calls.push({ url: req.url, body: JSON.parse(body) });
res.end(JSON.stringify({ code: 200 }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/config/config.sh'),
`${flags}\nprintf x >> "$QL_DIR/config-loads"\n`,
);
await fs.writeFile(
path.join(root, 'data/config/token.json'),
JSON.stringify({
value: 'fixture',
expiration: Date.now() / 1000 + 3600,
}),
);
await fs.writeFile(
path.join(root, 'data/config/extra.sh'),
`printf 'fixture-output\\n'\n${failed ? 'exit 7' : ':'}\n`,
);
const result = await exec(
process.execPath,
[path.resolve(__dirname, '../../dist/compat.js'), 'extra'],
{
env: {
PATH: process.env.PATH,
QL_DIR: root,
QlPort: String(server.address().port),
QL_CLI_LIFECYCLE: 'extended',
ID: '9',
QL_EXECUTION_ORIGIN: 'scheduled_system',
},
timeout: 15000,
},
).then(
(value) => ({ ...value, code: 0 }),
(error) => error,
);
assert.equal(result.code, failed ? 1 : 0);
assert.equal(
await fs.readFile(path.join(root, 'config-loads'), 'utf8'),
'x',
);
assert.equal(calls.length, 2);
assert.ok(calls.every((call) => call.url === '/open/crons/status'));
assert.deepEqual(
calls.map((call) => call.body.status),
['0', '1'],
);
assert.equal(calls[1].body.exit_code, failed ? 1 : 0);
assert.deepEqual(calls[0].body.ids, [9]);
assert.match(
calls[0].body.execution_id,
/^legacy-system:\d+:[0-9a-f-]+$/,
);
assert.equal(calls[0].body.execution_id, calls[1].body.execution_id);
const logPath = calls[0].body.log_path;
assert.match(logPath, /^extra\/.*\.log$/);
if (flags.startsWith('real_time')) {
await assert.rejects(fs.stat(path.join(root, 'data/log', logPath)), {
code: 'ENOENT',
});
assert.match(result.stderr, /fixture-output/);
} else {
const log = await fs.readFile(
path.join(root, 'data/log', logPath),
'utf8',
);
assert.match(log, /fixture-output/);
assert.match(log, /执行结束/);
assert.doesNotMatch(result.stderr, /fixture-output/);
}
if (!failed) assert.equal(JSON.parse(result.stdout).logPath, logPath);
else assert.equal(result.stdout, '');
} finally {
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
}
}
}
});
test('raw worker and compatibility route share subscription logs and lifecycle', async (t) => {
const fs = require('node:fs/promises');
const os = require('node:os');
const http = require('node:http');
const exec = require('node:util').promisify(
require('node:child_process').execFile,
);
for (const entry of ['compat.js', 'subscription-worker.js']) {
for (const failed of [false, true]) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-worker-log-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const calls = [];
const server = http.createServer(async (req, res) => {
if (req.url === '/fixture.js') {
res.writeHead(failed ? 503 : 200);
res.end('console.log("downloaded");');
return;
}
let body = '';
for await (const chunk of req) body += chunk;
calls.push(JSON.parse(body));
res.end(JSON.stringify({ code: 200 }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/config/config.sh'),
'no_tee=true\nprintf x >> "$QL_DIR/config-loads"\n',
);
await fs.writeFile(
path.join(root, 'data/config/token.json'),
JSON.stringify({
value: 'fixture',
expiration: Date.now() / 1000 + 3600,
}),
);
const port = String(server.address().port);
const result = await exec(
process.execPath,
[
path.resolve(__dirname, '../../dist', entry),
'raw',
`http://127.0.0.1:${port}/fixture.js`,
'',
'false',
'false',
],
{
env: {
PATH: process.env.PATH,
QL_DIR: root,
QlPort: port,
QL_CLI_LIFECYCLE: 'extended',
ID: '12',
SUB_ID: '3',
},
timeout: 15000,
},
).then(
(value) => ({ ...value, code: 0 }),
(error) => error,
);
assert.equal(result.code, failed ? 1 : 0, result.stderr);
assert.equal(
await fs.readFile(path.join(root, 'config-loads'), 'utf8'),
'x',
);
assert.equal(calls.length, 2);
assert.deepEqual(
calls.map((call) => call.status),
['0', '1'],
);
assert.deepEqual(calls[0].ids, [12]);
assert.equal(calls[1].exit_code, failed ? 1 : 0);
const logPath = calls[0].log_path;
assert.match(logPath, /^raw\/.*\.log$/);
const log = await fs.readFile(
path.join(root, 'data/log', logPath),
'utf8',
);
assert.doesNotMatch(log, /开始执行|执行结束/);
if (failed) {
assert.match(log, /503/);
assert.doesNotMatch(result.stderr, /curl:.*503/);
} else {
const payload = JSON.parse(result.stdout);
assert.equal(payload.logPath, logPath);
assert.match(
await fs.readFile(
path.join(root, 'data/scripts', payload.data.file),
'utf8',
),
/downloaded/,
);
}
} finally {
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
}
}
}
});
test(
'compatibility commands finish lifecycle after signals and stop active children',
{ timeout: 90000 },
async (t) => {
const fs = require('node:fs/promises');
const os = require('node:os');
const http = require('node:http');
const { spawn } = require('node:child_process');
for (const mode of ['config', 'extra', 'raw', 'worker']) {
for (const [signal, code] of [
['SIGINT', 130],
['SIGTERM', 143],
['SIGHUP', 129],
['SIGQUIT', 128 + os.constants.signals.SIGQUIT],
['SIGALRM', 128 + os.constants.signals.SIGALRM],
['SIGTSTP', 128 + os.constants.signals.SIGTSTP],
]) {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-compat-signal-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const calls = [];
let ready;
const started = new Promise((resolve) => {
ready = resolve;
});
const server = http.createServer(async (req, res) => {
if (req.url === '/waiting.js') {
ready();
return; // curl remains active until the command receives a signal.
}
let body = '';
for await (const chunk of req) body += chunk;
calls.push(JSON.parse(body));
res.end(JSON.stringify({ code: 200 }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
let child;
try {
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
const wait =
'echo $$ > "$QL_DIR/child.pid"\nprintf "READY\\n" >&2\nexec sleep 60\n';
await fs.writeFile(
path.join(root, 'data/config/config.sh'),
mode === 'config' ? wait : '',
);
await fs.writeFile(path.join(root, 'data/config/extra.sh'), wait);
await fs.writeFile(
path.join(root, 'data/config/token.json'),
JSON.stringify({
value: 'fixture',
expiration: Date.now() / 1000 + 3600,
}),
);
const port = String(server.address().port);
const args = [
'raw',
`http://127.0.0.1:${port}/waiting.js`,
'',
'false',
'false',
];
child = spawn(
process.execPath,
[
path.resolve(
__dirname,
'../../dist',
mode === 'worker' ? 'subscription-worker.js' : 'compat.js',
),
...(mode === 'config' || mode === 'extra' ? ['extra'] : args),
],
{
env: {
PATH: process.env.PATH,
QL_DIR: root,
QlPort: port,
QL_CLI_LIFECYCLE: 'extended',
ID: '13',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '',
stderr = '';
child.stdout.on('data', (chunk) => (stdout += chunk));
child.stderr.on('data', (chunk) => {
stderr += chunk;
if (stderr.includes('READY')) ready();
});
const closed = new Promise((resolve) =>
child.on('close', (code, signal) => resolve({ code, signal })),
);
await Promise.race([
started,
closed.then(() => {
throw new Error(`Exited before readiness: ${stderr}`);
}),
]);
child.kill(signal);
const result = await closed;
assert.deepEqual(
result,
{ code, signal: null },
`${mode} ${signal}: ${stderr}`,
);
assert.equal(stdout, '');
assert.equal(JSON.parse(stderr.trim().split('\n').at(-1)).code, code);
if (mode === 'config') assert.equal(calls.length, 0);
else {
assert.deepEqual(
calls.map((call) => call.status),
['0', '1'],
);
assert.equal(calls[1].exit_code, code);
}
if (mode === 'extra' || mode === 'config') {
const pid = Number(
await fs.readFile(path.join(root, 'child.pid'), 'utf8'),
);
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
}
} finally {
if (child?.exitCode === null && child?.signalCode === null)
child.kill('SIGKILL');
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
}
}
}
},
);
+76
View File
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { startupMain } = require('../../dist/startup');
test('startup compatibility uses the shared host-start handler with exact mode and directory arguments', async (t) => {
const main = require('../../dist/main');
const calls = [];
t.mock.method(main, 'main', async (args, surface, signal) => {
calls.push({ args, surface, signal });
return 7;
});
assert.equal(await startupMain([]), 7);
assert.equal(
await startupMain([
'reload',
'--root',
'/a b',
'--data-dir',
'/storage/data',
'--json',
]),
7,
);
assert.equal(await startupMain(['--root', '/a b', '--no-startup']), 7);
assert.deepEqual(
calls.map(({ args }) => args),
[
['start'],
[
'start',
'--reload',
'--root',
'/a b',
'--data-dir',
'/storage/data',
'--json',
],
['start', '--root', '/a b', '--no-startup'],
],
);
assert.ok(
calls.every(
({ surface, signal }) =>
surface === 'local' && signal instanceof AbortSignal,
),
);
});
test('installed startup entry has bilingual JSON help and validates before executing host operations', () => {
const entry = path.resolve(__dirname, '../../dist/startup.js');
const run = (args, language = 'zh') =>
spawnSync(process.execPath, [entry, ...args], {
env: { ...process.env, QL_DIR: '', QL_DATA_DIR: '', QL_LANG: language },
encoding: 'utf8',
timeout: 5000,
});
for (const [language, pattern] of [
['zh', /用法/],
['en', /Usage:/],
]) {
const result = run(['--help', '--json'], language);
assert.equal(result.status, 0, result.stderr);
const help = JSON.parse(result.stdout).data.help;
assert.match(help, pattern);
assert.match(help, /qinglong-cli/);
assert.match(help, /reload/);
}
for (const args of [[], ['reload'], ['unexpected'], ['reload', 'extra']]) {
const result = run([...args, '--json']);
assert.equal(result.status, 2, result.stderr);
assert.equal(result.stdout, '');
assert.equal(JSON.parse(result.stderr).code, 2);
}
});