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
258 lines
7.7 KiB
JavaScript
258 lines
7.7 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 { createContext } = require('../../dist/internal/runtime/context');
|
|
const {
|
|
bootstrapPanel,
|
|
bootstrapPackages,
|
|
} = require('../../dist/internal/maintenance/bootstrap');
|
|
const { parse } = require('../helpers/commands.cjs');
|
|
|
|
async function fixture(t) {
|
|
const base = await fs.realpath(
|
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bootstrap-')),
|
|
);
|
|
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
|
const root = path.join(base, 'panel');
|
|
await fs.mkdir(root);
|
|
const bin = path.join(base, 'bin');
|
|
await fs.mkdir(bin);
|
|
const env = {
|
|
PATH: `${bin}:/usr/bin:/bin`,
|
|
QL_OS_TYPE: 'debian',
|
|
CALLS: path.join(base, 'calls'),
|
|
AutoStartBot: 'true',
|
|
EnableExtraShell: 'true',
|
|
};
|
|
const ctx = createContext(
|
|
{ root, 'data-dir': path.join(base, 'external/data') },
|
|
env,
|
|
);
|
|
await fs.mkdir(path.join(root, 'sample'));
|
|
for (const file of [
|
|
'config.sample.sh',
|
|
'task.sample.sh',
|
|
'extra.sample.sh',
|
|
'notify.py',
|
|
'notify.js',
|
|
'ql_sample.js',
|
|
'ql_sample.py',
|
|
])
|
|
await fs.writeFile(path.join(root, 'sample', file), '# fixture');
|
|
await fs.writeFile(path.join(root, '.env.example'), 'SAMPLE=true');
|
|
const program = `
|
|
const fs=require('node:fs'),p=require('node:path');
|
|
const program=p.basename(process.argv[1]),args=process.argv.slice(2);
|
|
fs.appendFileSync(process.env.CALLS,JSON.stringify({program,args,python:process.env.PYTHON_HOME,node:process.env.PNPM_HOME})+'\\n');
|
|
if(program==='python3') process.stdout.write('3.12\\n');
|
|
if(program==='nginx' && args.includes('-s')) process.exit(1);
|
|
`;
|
|
for (const name of [
|
|
'sudo',
|
|
'apt-get',
|
|
'apk',
|
|
'npm',
|
|
'pip3',
|
|
'python3',
|
|
'pm2',
|
|
'nginx',
|
|
])
|
|
await fs.writeFile(
|
|
path.join(bin, name),
|
|
`#!${process.execPath}\n${program}`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const hooks = [];
|
|
const registrations = [];
|
|
const system = {
|
|
registerServices: async (context, os) => {
|
|
registrations.push({ data: context.data, os });
|
|
},
|
|
nginxConfig: path.join(base, 'nginx/nginx.conf'),
|
|
nginxIncludes: path.join(base, 'nginx/conf.d'),
|
|
nginxRun: path.join(base, 'run/nginx'),
|
|
background: async (context, action) => {
|
|
hooks.push({ action, data: context.data });
|
|
return 100 + hooks.length;
|
|
},
|
|
};
|
|
return { ctx, system, hooks, base, registrations };
|
|
}
|
|
|
|
test('bootstrap installs prerequisites, uses the configured data directory and starts services in order', async (t) => {
|
|
const { ctx, system, hooks, registrations } = await fixture(t);
|
|
await fs.writeFile(path.join(ctx.root, '.env'), 'KEEP=true');
|
|
const result = await bootstrapPanel(ctx, false, system);
|
|
assert.equal(result.mode, 'install');
|
|
assert.equal(result.service.manager, 'pm2');
|
|
assert.deepEqual(registrations, [{ data: ctx.data, os: 'debian' }]);
|
|
assert.deepEqual(
|
|
hooks.map((h) => h.action),
|
|
['bot', 'extra'],
|
|
);
|
|
assert.ok(hooks.every((h) => h.data === ctx.data));
|
|
assert.equal(
|
|
await fs.readFile(path.join(ctx.root, '.env'), 'utf8'),
|
|
'KEEP=true',
|
|
);
|
|
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map(JSON.parse);
|
|
assert.deepEqual(
|
|
calls.map((c) => c.program),
|
|
[
|
|
process.getuid() === 0 ? 'apt-get' : 'sudo',
|
|
process.getuid() === 0 ? 'apt-get' : 'sudo',
|
|
'npm',
|
|
'python3',
|
|
'pip3',
|
|
'pm2',
|
|
'nginx',
|
|
'nginx',
|
|
'pm2',
|
|
'pm2',
|
|
'pm2',
|
|
'pm2',
|
|
],
|
|
);
|
|
assert.deepEqual(calls[4].args, [
|
|
'install',
|
|
'--prefix',
|
|
path.join(ctx.data, 'dep_cache/python3'),
|
|
'requests',
|
|
]);
|
|
assert.deepEqual(calls[6].args, ['-c', system.nginxConfig, '-s', 'reload']);
|
|
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig]);
|
|
assert.deepEqual(
|
|
calls.slice(-4).map((c) => c.args),
|
|
[
|
|
['flush'],
|
|
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
|
|
['startup'],
|
|
['save'],
|
|
],
|
|
);
|
|
assert.ok(
|
|
calls
|
|
.slice(4)
|
|
.every(
|
|
(c) =>
|
|
c.python === path.join(ctx.data, 'dep_cache/python3') &&
|
|
c.node === path.join(ctx.data, 'dep_cache/node'),
|
|
),
|
|
);
|
|
assert.equal(bootstrapPackages('alpine').program, 'apk');
|
|
assert.throws(
|
|
() => bootstrapPackages('darwin', { QL_LANG: 'en' }),
|
|
/Unsupported deployment OS/,
|
|
);
|
|
assert.throws(() => parse(['start'], 'public'));
|
|
assert.equal(parse(['start', '--reload'], 'local').values.reload, true);
|
|
});
|
|
|
|
test('startup reload skips package installation, optional hooks and PM2 startup registration', async (t) => {
|
|
const { ctx, system, hooks, registrations } = await fixture(t);
|
|
await bootstrapPanel(ctx, true, system);
|
|
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map(JSON.parse);
|
|
assert.deepEqual(
|
|
calls.map((c) => c.program),
|
|
['python3', 'pm2', 'nginx', 'nginx', 'pm2', 'pm2'],
|
|
);
|
|
assert.deepEqual(hooks, []);
|
|
assert.deepEqual(registrations, []);
|
|
assert.equal(
|
|
await fs.readFile(path.join(ctx.root, '.env'), 'utf8'),
|
|
'SAMPLE=true',
|
|
);
|
|
assert.equal(
|
|
(await fs.stat(path.join(ctx.root, '.env'))).mode & 0o777,
|
|
0o600,
|
|
);
|
|
await assert.rejects(
|
|
bootstrapPanel(
|
|
{
|
|
...ctx,
|
|
env: { ...ctx.env, QL_LANG: 'en' },
|
|
data: path.join(ctx.root, 'wrong'),
|
|
},
|
|
true,
|
|
system,
|
|
),
|
|
/ending in \/data/,
|
|
);
|
|
});
|
|
|
|
test('container startup explicitly skips boot registration but starts services and saves PM2 state', async (t) => {
|
|
const { ctx, system, registrations } = await fixture(t);
|
|
const result = await bootstrapPanel(ctx, false, system, {
|
|
registerStartup: false,
|
|
});
|
|
assert.equal(result.startup, 'skipped');
|
|
assert.deepEqual(registrations, []);
|
|
assert.equal(result.service.manager, 'pm2');
|
|
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map(JSON.parse);
|
|
assert.ok(
|
|
!calls.some((call) => call.program === 'pm2' && call.args[0] === 'startup'),
|
|
);
|
|
assert.ok(
|
|
calls.some((call) => call.program === 'pm2' && call.args[0] === 'save'),
|
|
);
|
|
assert.ok(calls.some((call) => call.program === 'npm'));
|
|
});
|
|
|
|
for (const language of ['zh', 'en', 'unsupported']) {
|
|
test(`bootstrap rejects invalid setup before package or service work: ${language}`, async (t) => {
|
|
const root = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'ql-bootstrap-invalid-'),
|
|
);
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const ctx = createContext(
|
|
{ root },
|
|
{ QL_LANG: language, QL_OS_TYPE: 'unsupported', PATH: '' },
|
|
);
|
|
await assert.rejects(bootstrapPanel(ctx), (error) => {
|
|
assert.equal(error.exitCode, 2);
|
|
assert.match(
|
|
error.message,
|
|
language === 'en'
|
|
? /Unsupported deployment OS: unsupported/
|
|
: /不支持此部署操作系统:unsupported/,
|
|
);
|
|
return true;
|
|
});
|
|
await assert.rejects(
|
|
bootstrapPanel({ ...ctx, data: path.join(root, 'invalid') }),
|
|
(error) => {
|
|
assert.equal(error.exitCode, 2);
|
|
assert.match(
|
|
error.message,
|
|
language === 'en' ? /ending in \/data/ : /以 \/data 结尾/,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
const { runtimeEnvironment } = require('../../dist/internal/maintenance/bootstrap');
|
|
assert.throws(
|
|
() => runtimeEnvironment(ctx, 'bad'),
|
|
(error) => {
|
|
assert.equal(error.exitCode, 1);
|
|
assert.match(
|
|
error.message,
|
|
language === 'en' ? /invalid version/ : /无效版本/,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
assert.deepEqual(await fs.readdir(root), []);
|
|
});
|
|
}
|