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,335 @@
|
||||
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 https = require('node:https');
|
||||
const net = require('node:net');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { execFileSync, spawn } = require('node:child_process');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
|
||||
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
|
||||
|
||||
for (const tls of [false, true])
|
||||
test(
|
||||
`authenticated Git ${
|
||||
tls ? 'HTTPS/CONNECT' : 'HTTP/proxy'
|
||||
} transport preserves checkout after access failure`,
|
||||
{ timeout: 30000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-git-http-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const execPath = execFileSync(git, ['--exec-path'], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
await fs.access(path.join(execPath, 'git-http-backend')).catch(() => {
|
||||
throw new Error(
|
||||
'Test prerequisite missing: git-http-backend (Alpine: git-daemon).',
|
||||
);
|
||||
});
|
||||
const source = path.join(root, 'owner/repo.git');
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
const run = (...args) =>
|
||||
execFileSync(git, ['-C', source, ...args], { stdio: 'pipe' });
|
||||
run('init', '-b', 'main');
|
||||
run('config', 'user.name', 'Fixture');
|
||||
run('config', 'user.email', 'fixture@example.invalid');
|
||||
await fs.writeFile(path.join(source, 'wrong.js'), 'wrong branch');
|
||||
run('add', '.');
|
||||
run('commit', '-m', 'main');
|
||||
run('checkout', '-b', 'selected');
|
||||
await fs.rm(path.join(source, 'wrong.js'));
|
||||
await fs.writeFile(path.join(source, 'job.js'), 'version one');
|
||||
await fs.writeFile(path.join(source, 'helper.js'), 'dependency');
|
||||
run('add', '-A');
|
||||
run('commit', '-m', 'selected');
|
||||
const authorization = `Basic ${Buffer.from(
|
||||
`fixture:${randomUUID()}`,
|
||||
).toString('base64')}`;
|
||||
let failure = false;
|
||||
const requests = [];
|
||||
const children = new Set();
|
||||
let certificate;
|
||||
let tlsOptions;
|
||||
if (tls) {
|
||||
certificate = path.join(root, 'certificate.pem');
|
||||
const key = path.join(root, 'key.pem');
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-nodes',
|
||||
'-keyout',
|
||||
key,
|
||||
'-out',
|
||||
certificate,
|
||||
'-subj',
|
||||
'/CN=127.0.0.1',
|
||||
'-addext',
|
||||
'subjectAltName=IP:127.0.0.1',
|
||||
'-days',
|
||||
'1',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
tlsOptions = {
|
||||
key: await fs.readFile(key),
|
||||
cert: await fs.readFile(certificate),
|
||||
};
|
||||
}
|
||||
const serveGit = (req, res) => {
|
||||
const url = new URL(req.url, 'http://fixture.invalid');
|
||||
requests.push({
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
proxy: req.url.startsWith('http://'),
|
||||
});
|
||||
if (failure) {
|
||||
res.writeHead(503).end();
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
if (req.headers.authorization !== authorization) {
|
||||
res
|
||||
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
|
||||
.end();
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
const child = spawn(git, ['http-backend'], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
GIT_PROJECT_ROOT: root,
|
||||
GIT_HTTP_EXPORT_ALL: '1',
|
||||
REQUEST_METHOD: req.method,
|
||||
PATH_INFO: url.pathname,
|
||||
QUERY_STRING: url.search.slice(1),
|
||||
CONTENT_TYPE: req.headers['content-type'] || '',
|
||||
CONTENT_LENGTH: req.headers['content-length'] || '',
|
||||
REMOTE_USER: 'fixture',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
children.add(child);
|
||||
child.on('error', () => res.destroy());
|
||||
child.on('close', () => children.delete(child));
|
||||
child.stderr.resume();
|
||||
child.stdin.on('error', () => {});
|
||||
req.pipe(child.stdin);
|
||||
let pending = Buffer.alloc(0),
|
||||
headersSent = false;
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (headersSent) {
|
||||
res.write(chunk);
|
||||
return;
|
||||
}
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
const boundary = pending.indexOf('\r\n\r\n');
|
||||
if (boundary < 0) return;
|
||||
let status = 200;
|
||||
const headers = {};
|
||||
for (const line of pending
|
||||
.subarray(0, boundary)
|
||||
.toString()
|
||||
.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
const key = line.slice(0, colon),
|
||||
value = line.slice(colon + 1).trim();
|
||||
if (key.toLowerCase() === 'status')
|
||||
status = Number(value.split(' ')[0]);
|
||||
else headers[key] = value;
|
||||
}
|
||||
res.writeHead(status, headers);
|
||||
headersSent = true;
|
||||
res.write(pending.subarray(boundary + 4));
|
||||
});
|
||||
child.stdout.on('end', () => res.end());
|
||||
};
|
||||
const server = tls
|
||||
? https.createServer(tlsOptions, serveGit)
|
||||
: http.createServer(serveGit);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => {
|
||||
for (const child of children) child.kill('SIGKILL');
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const endpoint = `${tls ? 'https' : 'http'}://127.0.0.1:${
|
||||
server.address().port
|
||||
}`;
|
||||
let proxyEndpoint = endpoint;
|
||||
let tunnels = 0;
|
||||
if (tls) {
|
||||
const sockets = new Set();
|
||||
const tunnel = http.createServer((req, res) =>
|
||||
res.writeHead(405).end(),
|
||||
);
|
||||
tunnel.on('connect', (req, client, head) => {
|
||||
if (req.url !== `127.0.0.1:${server.address().port}`) {
|
||||
client.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
tunnels++;
|
||||
const upstream = net.connect(
|
||||
server.address().port,
|
||||
'127.0.0.1',
|
||||
() => {
|
||||
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head.length) upstream.write(head);
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
},
|
||||
);
|
||||
for (const socket of [client, upstream]) {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
}
|
||||
client.on('error', () => upstream.destroy());
|
||||
upstream.on('error', () => client.destroy());
|
||||
client.on('close', () => upstream.destroy());
|
||||
upstream.on('close', () => client.destroy());
|
||||
});
|
||||
await new Promise((resolve) => tunnel.listen(0, '127.0.0.1', resolve));
|
||||
proxyEndpoint = `http://127.0.0.1:${tunnel.address().port}`;
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => tunnel.close(resolve));
|
||||
});
|
||||
}
|
||||
for (const [proxy, credentialMode] of [
|
||||
[false, 'header'],
|
||||
[true, 'header'],
|
||||
[false, 'helper'],
|
||||
[true, 'helper'],
|
||||
]) {
|
||||
failure = false;
|
||||
const panel = path.join(
|
||||
root,
|
||||
`${credentialMode}-${proxy ? 'proxied-panel' : 'direct-panel'}`,
|
||||
);
|
||||
await fs.mkdir(panel);
|
||||
const env = {
|
||||
PATH: `/usr/bin:/bin:${process.env.PATH}`,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
...(tls ? { GIT_SSL_CAINFO: certificate } : {}),
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'http.extraHeader',
|
||||
GIT_CONFIG_VALUE_0: `Authorization: ${authorization}`,
|
||||
http_proxy: '',
|
||||
https_proxy: '',
|
||||
all_proxy: '',
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
NO_PROXY: '',
|
||||
no_proxy: '',
|
||||
};
|
||||
if (credentialMode === 'helper') {
|
||||
const credentialFile = path.join(panel, 'credentials');
|
||||
const credential = new URL(
|
||||
proxy && !tls ? 'http://repository.invalid' : endpoint,
|
||||
);
|
||||
const [username, password] = Buffer.from(
|
||||
authorization.slice(6),
|
||||
'base64',
|
||||
)
|
||||
.toString()
|
||||
.split(':');
|
||||
credential.username = username;
|
||||
credential.password = password;
|
||||
await fs.writeFile(credentialFile, credential.toString() + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
env.GIT_CONFIG_KEY_0 = 'credential.helper';
|
||||
env.GIT_CONFIG_VALUE_0 =
|
||||
"store --file='" + credentialFile.replaceAll("'", "'\"'\"'") + "'";
|
||||
}
|
||||
const context = createContext({ root: panel }, env);
|
||||
const input = {
|
||||
url: `${
|
||||
proxy && !tls ? 'http://repository.invalid' : endpoint
|
||||
}/owner/repo.git`,
|
||||
branch: 'selected',
|
||||
include: '^job',
|
||||
dependencies: '^helper',
|
||||
autoAdd: false,
|
||||
autoDelete: false,
|
||||
...(proxy ? { proxy: proxyEndpoint } : {}),
|
||||
};
|
||||
const start = requests.length;
|
||||
const previousTunnels = tunnels;
|
||||
const result = await syncRepository(context, input);
|
||||
const destination = path.join(
|
||||
context.paths.dir_scripts,
|
||||
result.repository,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
|
||||
'version one',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'helper.js'), 'utf8'),
|
||||
'dependency',
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(destination, 'wrong.js')));
|
||||
assert.ok(
|
||||
requests
|
||||
.slice(start)
|
||||
.some(
|
||||
(row) =>
|
||||
row.method === 'POST' &&
|
||||
row.path.endsWith('/git-upload-pack') &&
|
||||
row.proxy === (proxy && !tls),
|
||||
),
|
||||
);
|
||||
if (tls && proxy)
|
||||
assert.ok(
|
||||
tunnels > previousTunnels,
|
||||
'HTTPS must traverse the CONNECT proxy',
|
||||
);
|
||||
const checkout = path.join(context.paths.dir_repo, result.repository);
|
||||
const head = execFileSync(git, [
|
||||
'-C',
|
||||
checkout,
|
||||
'rev-parse',
|
||||
'HEAD',
|
||||
]).toString();
|
||||
for (const kind of [
|
||||
'unauthorized',
|
||||
'unavailable',
|
||||
...(tls ? ['untrusted'] : []),
|
||||
]) {
|
||||
failure = kind === 'unavailable';
|
||||
const failedContext =
|
||||
kind === 'unauthorized'
|
||||
? createContext(
|
||||
{ root: panel },
|
||||
{ ...env, GIT_CONFIG_COUNT: '0' },
|
||||
)
|
||||
: kind === 'untrusted'
|
||||
? createContext(
|
||||
{ root: panel },
|
||||
{ ...env, GIT_SSL_CAINFO: undefined },
|
||||
)
|
||||
: context;
|
||||
await assert.rejects(syncRepository(failedContext, input));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
|
||||
'version one',
|
||||
);
|
||||
assert.equal(
|
||||
execFileSync(git, ['-C', checkout, 'rev-parse', 'HEAD']).toString(),
|
||||
head,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user