mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* 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
163 lines
6.1 KiB
JavaScript
163 lines
6.1 KiB
JavaScript
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',
|
||
);
|
||
});
|
||
}
|