mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
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:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user