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,162 @@
|
||||
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 {
|
||||
prepareContainerEnvironment,
|
||||
} = require('../../dist/internal/runtime/containerEnvironment');
|
||||
|
||||
async function fixture(t) {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-env-'));
|
||||
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
||||
const root = path.join(base, 'panel');
|
||||
const data = path.join(base, 'external/data');
|
||||
const systemDirectory = path.join(base, 'etc');
|
||||
for (const dir of [root, data, systemDirectory])
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(systemDirectory, 'alpine-release'), '3.20');
|
||||
return {
|
||||
root,
|
||||
data,
|
||||
systemDirectory,
|
||||
env: { PATH: process.env.PATH, HOME: path.join(base, 'absent') },
|
||||
};
|
||||
}
|
||||
|
||||
test('container preparation retains data and existing temporary files, and does not mutate caller environment', async (t) => {
|
||||
const options = await fixture(t);
|
||||
const original = { ...options.env };
|
||||
await fs.mkdir(path.join(options.root, '.tmp'));
|
||||
await fs.writeFile(path.join(options.root, '.tmp/keep'), 'pending upgrade');
|
||||
await fs.writeFile(path.join(options.data, 'keep'), 'user data');
|
||||
const result = await prepareContainerEnvironment(options);
|
||||
assert.equal(result.env.HOME, path.join(options.root, '.tmp'));
|
||||
assert.deepEqual(options.env, original);
|
||||
assert.deepEqual(await fs.readdir(options.data), ['keep']);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(options.root, '.tmp/keep'), 'utf8'),
|
||||
'pending upgrade',
|
||||
);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('network initialization appends complete lines once and matches localhost as a hostname token', async (t) => {
|
||||
const options = await fixture(t);
|
||||
const hosts = path.join(options.systemDirectory, 'hosts');
|
||||
const resolv = path.join(options.systemDirectory, 'resolv.conf');
|
||||
await fs.writeFile(
|
||||
hosts,
|
||||
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost',
|
||||
);
|
||||
await fs.writeFile(resolv, 'nameserver 127.0.0.11');
|
||||
await prepareContainerEnvironment(options);
|
||||
const expectedHosts =
|
||||
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost\n127.0.0.1 localhost\n';
|
||||
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
|
||||
assert.equal(
|
||||
await fs.readFile(resolv, 'utf8'),
|
||||
'nameserver 127.0.0.11\noptions ndots:0\n',
|
||||
);
|
||||
await prepareContainerEnvironment(options);
|
||||
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
|
||||
assert.equal(
|
||||
await fs.readFile(resolv, 'utf8'),
|
||||
'nameserver 127.0.0.11\noptions ndots:0\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('existing HOME, DNS options and non-Alpine DNS are preserved', async (t) => {
|
||||
const options = await fixture(t);
|
||||
options.env.HOME = options.root;
|
||||
const resolv = path.join(options.systemDirectory, 'resolv.conf');
|
||||
await fs.writeFile(resolv, 'options timeout:1 ndots:0 # configured\n');
|
||||
assert.equal(
|
||||
(await prepareContainerEnvironment(options)).env.HOME,
|
||||
options.root,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(resolv, 'utf8'),
|
||||
'options timeout:1 ndots:0 # configured\n',
|
||||
);
|
||||
await fs.rm(path.join(options.systemDirectory, 'alpine-release'));
|
||||
await fs.writeFile(resolv, 'nameserver 1.2.3.4');
|
||||
await prepareContainerEnvironment(options);
|
||||
assert.equal(await fs.readFile(resolv, 'utf8'), 'nameserver 1.2.3.4');
|
||||
});
|
||||
|
||||
test('network failures are reported without hiding permission preflight errors', async (t) => {
|
||||
const options = await fixture(t);
|
||||
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
|
||||
const result = await prepareContainerEnvironment(options);
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.match(result.warnings[0], /hosts[::] ?EISDIR/);
|
||||
const absent = path.join(options.root, 'absent');
|
||||
await assert.rejects(
|
||||
prepareContainerEnvironment({ ...options, data: absent }),
|
||||
/not writable\/searchable|不可写或不可访问/,
|
||||
);
|
||||
await assert.rejects(fs.stat(absent), { code: 'ENOENT' });
|
||||
await assert.rejects(
|
||||
prepareContainerEnvironment({ ...options, root: 'relative' }),
|
||||
/absolute paths|必须为绝对路径/,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'non-root preflight rejects an inaccessible data directory without creating volume files',
|
||||
{ skip: process.getuid?.() === 0 },
|
||||
async (t) => {
|
||||
const options = await fixture(t);
|
||||
await fs.chmod(options.data, 0o400);
|
||||
try {
|
||||
await assert.rejects(
|
||||
prepareContainerEnvironment(options),
|
||||
/not writable\/searchable|不可写或不可访问/,
|
||||
);
|
||||
} finally {
|
||||
await fs.chmod(options.data, 0o700);
|
||||
}
|
||||
assert.deepEqual(await fs.readdir(options.data), []);
|
||||
assert.deepEqual(await fs.readdir(options.systemDirectory), [
|
||||
'alpine-release',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
test(`container preflight and network warnings preserve language and data: ${language}`, async (t) => {
|
||||
const options = await fixture(t);
|
||||
options.env.QL_LANG = language;
|
||||
const expected = (zh, en) => (language === 'en' ? en : zh);
|
||||
await assert.rejects(
|
||||
prepareContainerEnvironment({ ...options, root: 'relative' }),
|
||||
(error) => {
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(
|
||||
error.message,
|
||||
expected(/必须为绝对路径/, /absolute paths/),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const absent = path.join(options.root, 'missing-data');
|
||||
await assert.rejects(
|
||||
prepareContainerEnvironment({ ...options, data: absent }),
|
||||
expected(/不可写或不可访问/, /not writable\/searchable/),
|
||||
);
|
||||
await assert.rejects(fs.access(absent), { code: 'ENOENT' });
|
||||
await fs.writeFile(path.join(options.data, 'retained'), 'keep');
|
||||
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
|
||||
const result = await prepareContainerEnvironment(options);
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.match(
|
||||
result.warnings[0],
|
||||
expected(/无法初始化.*EISDIR/, /Cannot initialize.*EISDIR/),
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(options.data, 'retained'), 'utf8'),
|
||||
'keep',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user