mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-01 05:13:50 +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,170 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
|
||||
test('public CLI excludes local maintenance while the admin entry retains compatibility names', () => {
|
||||
const help = parse(['--help']).help;
|
||||
assert.match(help, /subscription list/);
|
||||
assert.doesNotMatch(help, /resetpwd|local extra|rmlog/);
|
||||
for (const name of ['rmlog', 'resetpwd', 'extra'])
|
||||
assert.throws(() => parse([name, '1']), { exitCode: 2 });
|
||||
assert.equal(parse(['resetpwd', 'example'], 'local').name, 'local resetpwd');
|
||||
assert.doesNotMatch(
|
||||
parse(['--help'], 'local').help,
|
||||
/subscription list|auth login/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parse(['auth', 'status', '--scope', 'all']),
|
||||
{ exitCode: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription management uses panel API, projects credentials out and never invokes local repo/raw', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-subscription-'));
|
||||
const records = [];
|
||||
const row = {
|
||||
id: 7,
|
||||
alias: 'sample',
|
||||
name: 'sample',
|
||||
status: 1,
|
||||
pull_option: { password: 'private-secret' },
|
||||
url: 'https://user:private-secret@example.com/repo.git',
|
||||
command: 'private-secret',
|
||||
sub_before: 'private-secret',
|
||||
};
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
records.push({
|
||||
path: url.pathname,
|
||||
query: url.searchParams,
|
||||
body,
|
||||
method: req.method,
|
||||
});
|
||||
assert.equal(req.headers.authorization, 'Bearer sub-token');
|
||||
const data = url.pathname.endsWith('/log')
|
||||
? 'one\ntwo\nthree\n'
|
||||
: url.pathname.endsWith('/7')
|
||||
? row
|
||||
: [row];
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ code: 200, data }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const config = path.join(root, 'config.json');
|
||||
fs.writeFileSync(
|
||||
config,
|
||||
JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
token: 'sub-token',
|
||||
expiration: Date.now() / 1000 + 10000,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const run = (args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/npm/ql.js'), ...args, '--json'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
QL_CLI_CONFIG: config,
|
||||
QL_DIR: '/not-a-panel',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let out = '',
|
||||
err = '';
|
||||
child.stdout.on('data', (chunk) => (out += chunk));
|
||||
child.stderr.on('data', (chunk) => (err += chunk));
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
assert.equal(code, 0, err);
|
||||
assert.doesNotMatch(out + err, /private-secret/);
|
||||
resolve(JSON.parse(out));
|
||||
});
|
||||
});
|
||||
const status = await run(['auth', 'status', '--scope', 'subscriptions']);
|
||||
assert.equal(status.data.scopeChecked, 'subscriptions');
|
||||
const list = await run(['subscription', 'list', '--search', 'a & b']);
|
||||
assert.deepEqual(list.data, [
|
||||
{ id: 7, name: 'sample', alias: 'sample', status: 1 },
|
||||
]);
|
||||
assert.equal(records.at(-1).query.get('searchValue'), 'a & b');
|
||||
assert.equal((await run(['subscription', 'get', '7'])).data.id, 7);
|
||||
assert.deepEqual(await run(['subscription', 'logs', '7', '--tail', '2']), {
|
||||
code: 200,
|
||||
data: 'two\nthree',
|
||||
truncated: true,
|
||||
});
|
||||
for (const action of ['run', 'stop', 'enable', 'disable']) {
|
||||
const response = await run(['subscription', action, '7']);
|
||||
assert.deepEqual(response.data, {
|
||||
subscriptionId: 7,
|
||||
action,
|
||||
accepted: true,
|
||||
});
|
||||
assert.equal(records.at(-1).method, 'PUT');
|
||||
assert.equal(records.at(-1).body, '[7]');
|
||||
assert.equal(records.at(-1).path, `/open/subscriptions/${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared API errors identify subscription permission and uncertain subscription outcomes', async (t) => {
|
||||
const { request } = require('../../dist/remote/api/client');
|
||||
const http = require('node:http');
|
||||
let mode = 'denied',
|
||||
count = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
count++;
|
||||
if (mode === 'denied') {
|
||||
res.writeHead(403);
|
||||
res.end('forbidden');
|
||||
} else if (mode === 'api-denied') {
|
||||
res.end(JSON.stringify({ code: 403 }));
|
||||
} else {
|
||||
res.writeHead(503);
|
||||
res.end('unavailable');
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const config = {
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
token: 'fixture',
|
||||
clientId: 'fixture',
|
||||
clientSecret: 'fixture',
|
||||
expiration: Date.now() / 1000 + 1000,
|
||||
};
|
||||
for (const failure of ['denied', 'api-denied']) {
|
||||
mode = failure;
|
||||
await assert.rejects(
|
||||
request(config, 'subscriptions'),
|
||||
(error) =>
|
||||
error.exitCode === 3 &&
|
||||
/subscriptions (permission|权限)/.test(error.message) &&
|
||||
!/crons/.test(error.message),
|
||||
);
|
||||
}
|
||||
mode = 'unavailable';
|
||||
await assert.rejects(
|
||||
request(config, 'subscriptions/run', { method: 'PUT', body: [1] }),
|
||||
/check subscription status before retrying|先检查订阅状态再考虑重试/,
|
||||
);
|
||||
assert.equal(count, 3);
|
||||
});
|
||||
Reference in New Issue
Block a user