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,289 @@
|
||||
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 http = require('node:http');
|
||||
const { LocalApi } = require('../../dist/internal/runtime/api');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
|
||||
async function fixture(t, handler) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-local-api-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const server = http.createServer(handler);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ PATH: process.env.PATH, QlPort: String(server.address().port) },
|
||||
);
|
||||
await fs.mkdir(context.paths.dir_config, { recursive: true });
|
||||
return context;
|
||||
}
|
||||
|
||||
async function token(
|
||||
context,
|
||||
value = 'fixture-token',
|
||||
expiration = Date.now() / 1000 + 3600,
|
||||
) {
|
||||
await fs.writeFile(
|
||||
context.paths.file_auth_token,
|
||||
JSON.stringify({ value, expiration }),
|
||||
);
|
||||
}
|
||||
|
||||
test('local API preserves every legacy request shape and JSON special characters', async (t) => {
|
||||
const requests = [];
|
||||
const context = await fixture(t, async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
requests.push({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
authorization: req.headers.authorization,
|
||||
body: body ? JSON.parse(body) : undefined,
|
||||
});
|
||||
res.end(JSON.stringify({ code: 200, data: null }));
|
||||
});
|
||||
await token(context);
|
||||
const api = new LocalApi(context);
|
||||
const name = '任务 "quoted" \\ newline\n';
|
||||
const command = 'task "owner/repo name.js" -- "a:b"';
|
||||
const cases = [
|
||||
['crons', 'POST', { name, command, schedule: '0 1 * * *', sub_id: 4 }],
|
||||
['crons', 'PUT', { name, command, schedule: '0 2 * * *', id: '7' }],
|
||||
['crons', 'PUT', { command, id: '7' }],
|
||||
['crons', 'DELETE', [7, 8]],
|
||||
[
|
||||
'crons/status',
|
||||
'PUT',
|
||||
{
|
||||
ids: [7],
|
||||
status: '1',
|
||||
pid: '12',
|
||||
log_path: 'a/1.log',
|
||||
last_execution_time: 123,
|
||||
last_running_time: 5,
|
||||
exit_code: 7,
|
||||
execution_id: 'legacy-system:123:fixture',
|
||||
},
|
||||
],
|
||||
['system/notify', 'PUT', { title: name, content: 'line1\nline2\\"' }],
|
||||
[
|
||||
`crons/detail?${new URLSearchParams({ log_path: 'a space/日志.log' })}`,
|
||||
'GET',
|
||||
undefined,
|
||||
],
|
||||
['system/auth/reset', 'PUT', { retries: 0 }],
|
||||
['system/auth/reset', 'PUT', { twoFactorActivated: false }],
|
||||
['system/auth/reset', 'PUT', { password: name }],
|
||||
['system/auth/reset', 'PUT', { username: name }],
|
||||
['dashboard/record', 'POST', { ref_id: 7, code: 7, elapsed: 5 }],
|
||||
];
|
||||
for (const [endpoint, method, body] of cases)
|
||||
assert.deepEqual(await api.call(endpoint, method, body), {
|
||||
code: 200,
|
||||
data: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
cases.map(([endpoint, method, body]) => ({
|
||||
url: `/open/${endpoint}`,
|
||||
method,
|
||||
body,
|
||||
authorization: 'Bearer fixture-token',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test('local credentials reuse valid cache and regenerate missing, expired or malformed files', async (t) => {
|
||||
for (const state of ['valid', 'missing', 'expired', 'malformed']) {
|
||||
await t.test(state, async (t) => {
|
||||
const auth = [];
|
||||
const context = await fixture(t, (req, res) => {
|
||||
auth.push(req.headers.authorization);
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
const marker = path.join(context.root, 'generated');
|
||||
context.env.TOKEN_FILE = context.paths.file_auth_token;
|
||||
context.env.GENERATOR_MARKER = marker;
|
||||
await fs.mkdir(path.join(context.root, 'static/build'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(context.root, 'static/build/token.js'),
|
||||
'const fs=require("fs");fs.appendFileSync(process.env.GENERATOR_MARKER,"generated\\n");' +
|
||||
'fs.writeFileSync(process.env.TOKEN_FILE,JSON.stringify({value:"generated-token",expiration:Date.now()/1000+3600}));' +
|
||||
'console.log("generated-token")',
|
||||
);
|
||||
if (state === 'valid') await token(context);
|
||||
if (state === 'expired') await token(context, 'expired-token', 1);
|
||||
if (state === 'malformed')
|
||||
await fs.writeFile(context.paths.file_auth_token, 'invalid-json');
|
||||
const api = new LocalApi(context);
|
||||
await api.call('crons');
|
||||
await api.call('crons');
|
||||
assert.deepEqual(
|
||||
auth,
|
||||
Array(2).fill(
|
||||
`Bearer ${state === 'valid' ? 'fixture-token' : 'generated-token'}`,
|
||||
),
|
||||
);
|
||||
if (state === 'valid')
|
||||
await assert.rejects(fs.stat(marker), { code: 'ENOENT' });
|
||||
else assert.equal(await fs.readFile(marker, 'utf8'), 'generated\n');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('local API rejects bad responses and redirects without replaying mutations', async (t) => {
|
||||
const seen = [];
|
||||
const context = await fixture(t, (req, res) => {
|
||||
seen.push(req.url);
|
||||
if (req.url === '/open/redirect') {
|
||||
res.writeHead(307, { location: '/open/redirect-target' });
|
||||
res.end();
|
||||
} else if (req.url === '/open/http-error') {
|
||||
res.writeHead(500);
|
||||
res.end('fixture-token');
|
||||
} else if (req.url === '/open/api-error') {
|
||||
res.end(JSON.stringify({ code: 403, message: 'fixture-token' }));
|
||||
} else res.end('not-json fixture-token');
|
||||
});
|
||||
await token(context);
|
||||
const api = new LocalApi(context);
|
||||
for (const endpoint of [
|
||||
'redirect',
|
||||
'http-error',
|
||||
'api-error',
|
||||
'invalid-json',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
api.call(endpoint, 'PUT', { value: 'secret-input' }),
|
||||
(error) => {
|
||||
assert.doesNotMatch(error.message, /fixture-token|secret-input/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
assert.deepEqual(seen, [
|
||||
'/open/redirect',
|
||||
'/open/http-error',
|
||||
'/open/api-error',
|
||||
'/open/invalid-json',
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'cancelled local API requests do not replay mutations and final lifecycle still reports',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const { cancellableOperation } = require('../../dist/internal/runtime/cancellation');
|
||||
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
|
||||
for (const partialBody of [false, true]) {
|
||||
const controller = new AbortController();
|
||||
const calls = [];
|
||||
const context = await fixture(t, async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
calls.push({ url: req.url, body: JSON.parse(body) });
|
||||
if (req.url === '/open/crons') {
|
||||
if (partialBody) {
|
||||
res.writeHead(200);
|
||||
res.write('{"code":');
|
||||
}
|
||||
controller.abort('SIGTERM');
|
||||
return;
|
||||
}
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
await token(context);
|
||||
context.env.QL_CLI_LIFECYCLE = 'extended';
|
||||
context.env.ID = '7';
|
||||
context.env.no_tee = 'true';
|
||||
await assert.rejects(
|
||||
loggedOperation(
|
||||
context,
|
||||
'raw',
|
||||
() => new LocalApi(context).call('crons', 'POST', { name: 'once' }),
|
||||
controller.signal,
|
||||
),
|
||||
/do not retry a mutation|不要直接重试写入/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.url),
|
||||
['/open/crons/status', '/open/crons', '/open/crons/status'],
|
||||
);
|
||||
assert.equal(calls[2].body.exit_code, 143);
|
||||
assert.equal(calls[2].body.status, '1');
|
||||
await assert.rejects(
|
||||
cancellableOperation(controller.signal, () =>
|
||||
new LocalApi(context).call('crons', 'POST', {}),
|
||||
),
|
||||
);
|
||||
assert.equal(calls.length, 3);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
test(`local API errors preserve language, status and no-replay behavior: ${language}`, async (t) => {
|
||||
const seen = [];
|
||||
const context = await fixture(t, (req, res) => {
|
||||
seen.push(req.url);
|
||||
if (req.url.endsWith('/http')) {
|
||||
res.statusCode = 503;
|
||||
res.end('private-server-body');
|
||||
} else
|
||||
res.end(JSON.stringify({ code: 403, message: 'private-server-body' }));
|
||||
});
|
||||
context.env.QL_LANG = language;
|
||||
const expected = (zh, en) => (language === 'en' ? en : zh);
|
||||
const check = (code, zh, en) => (error) => {
|
||||
assert.equal(error.exitCode, code);
|
||||
assert.match(error.message, expected(zh, en));
|
||||
assert.doesNotMatch(
|
||||
error.message,
|
||||
/fixture-token|private-server-body|private-input/,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
await fs.mkdir(path.join(context.root, 'static/build'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(path.join(context.root, 'static/build/token.js'), '');
|
||||
await assert.rejects(
|
||||
new LocalApi(context).call('http'),
|
||||
check(3, /无法获取本机系统令牌/, /Cannot obtain a local system token/),
|
||||
);
|
||||
assert.deepEqual(seen, []);
|
||||
await token(context);
|
||||
const port = context.env.QlPort;
|
||||
context.env.QlPort = 'invalid';
|
||||
await assert.rejects(
|
||||
new LocalApi(context).call('http'),
|
||||
check(2, /端口无效/, /Invalid local panel port/),
|
||||
);
|
||||
assert.deepEqual(seen, []);
|
||||
context.env.QlPort = port;
|
||||
await assert.rejects(
|
||||
new LocalApi(context).call('http', 'PUT', { value: 'private-input' }),
|
||||
check(1, /不要直接重试写入/, /do not retry a mutation/),
|
||||
);
|
||||
await assert.rejects(
|
||||
new LocalApi(context).call('rejected', 'PUT', { value: 'private-input' }),
|
||||
check(
|
||||
1,
|
||||
/拒绝请求.*面板日志和权限/,
|
||||
/rejected request.*logs and permissions/,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(seen, ['/open/http', '/open/rejected']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user