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
272 lines
8.6 KiB
JavaScript
272 lines
8.6 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 { execFileSync } = require('node:child_process');
|
||
const { createContext } = require('../../dist/internal/runtime/context');
|
||
const {
|
||
botSystemPackages,
|
||
installAndStartBot,
|
||
prepareBot,
|
||
launchBot,
|
||
} = require('../../dist/internal/maintenance/bot');
|
||
const { parse } = require('../helpers/commands.cjs');
|
||
|
||
async function fixture(t) {
|
||
const root = await fs.realpath(
|
||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-')),
|
||
);
|
||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||
const bin = path.join(root, 'bin');
|
||
await fs.mkdir(bin);
|
||
const ctx = createContext(
|
||
{ root },
|
||
{
|
||
PATH: `${bin}:/usr/bin:/bin`,
|
||
QL_OS_TYPE: 'debian',
|
||
CALLS: path.join(root, 'calls'),
|
||
},
|
||
);
|
||
const recorder =
|
||
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify({program:require("node:path").basename(process.argv[1]),args:process.argv.slice(2)})+"\\n");';
|
||
for (const program of ['sudo', 'apt-get', 'apk', 'pip3'])
|
||
await fs.writeFile(
|
||
path.join(bin, program),
|
||
`#!${process.execPath}\n${recorder}`,
|
||
{ mode: 0o755 },
|
||
);
|
||
const repo = path.join(root, 'source');
|
||
await fs.mkdir(path.join(repo, 'jbot'), { recursive: true });
|
||
await fs.mkdir(path.join(repo, 'config'));
|
||
await fs.writeFile(
|
||
path.join(repo, 'jbot/requirements.txt'),
|
||
'example==1.2\n',
|
||
);
|
||
await fs.writeFile(path.join(repo, 'jbot/__main__.py'), 'print("fixture")');
|
||
await fs.writeFile(path.join(repo, 'config/bot.json'), '{"token":"sample"}');
|
||
for (const args of [
|
||
['init', '-q', '-b', 'main'],
|
||
['add', '.'],
|
||
[
|
||
'-c',
|
||
'user.name=Fixture',
|
||
'-c',
|
||
'user.email=fixture@example.invalid',
|
||
'commit',
|
||
'-qm',
|
||
'fixture',
|
||
],
|
||
])
|
||
execFileSync('/usr/bin/git', args, { cwd: repo });
|
||
ctx.env.BotRepoUrl = repo;
|
||
return ctx;
|
||
}
|
||
|
||
test('bot prepares local repository and dependencies while preserving user configuration', async (t) => {
|
||
const ctx = await fixture(t);
|
||
await fs.mkdir(ctx.paths.dir_config, { recursive: true });
|
||
const config = path.join(ctx.paths.dir_config, 'bot.json');
|
||
await fs.writeFile(config, '{"token":"keep"}', { mode: 0o600 });
|
||
const lifecycle = [];
|
||
await installAndStartBot(ctx, {
|
||
stop: async () => {
|
||
lifecycle.push('stop');
|
||
},
|
||
start: async () => {
|
||
lifecycle.push('start');
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.data, 'jbot/__main__.py'), 'utf8'),
|
||
'print("fixture")',
|
||
);
|
||
return { pid: 123 };
|
||
},
|
||
});
|
||
assert.deepEqual(lifecycle, ['stop', 'start']);
|
||
assert.equal(await fs.readFile(config, 'utf8'), '{"token":"keep"}');
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.data, 'requirements.txt'), 'utf8'),
|
||
'example==1.2\n',
|
||
);
|
||
assert.ok(
|
||
!(await fs.readdir(ctx.data)).some((name) =>
|
||
name.startsWith('.bot-stage-'),
|
||
),
|
||
);
|
||
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
|
||
.trim()
|
||
.split('\n')
|
||
.map(JSON.parse);
|
||
const install = calls.find((c) => c.program === 'pip3');
|
||
assert.deepEqual(install.args.slice(0, 3), [
|
||
'--default-timeout=100',
|
||
'install',
|
||
'-r',
|
||
]);
|
||
assert.ok(install.args[3].endsWith('/jbot/requirements.txt'));
|
||
const packages = botSystemPackages('debian');
|
||
assert.deepEqual(
|
||
calls[0].args,
|
||
process.getuid() === 0
|
||
? packages.args
|
||
: [packages.program, ...packages.args],
|
||
);
|
||
assert.equal(botSystemPackages('alpine').program, 'apk');
|
||
assert.throws(
|
||
() => botSystemPackages('darwin', { QL_LANG: 'en' }),
|
||
/does not support/,
|
||
);
|
||
assert.throws(() => parse(['bot'], 'public'));
|
||
assert.equal(parse(['bot'], 'local').name, 'local bot');
|
||
});
|
||
|
||
test('bot startup failure restores old source and requirements before restarting it', async (t) => {
|
||
const ctx = await fixture(t);
|
||
await fs.mkdir(path.join(ctx.data, 'jbot'), { recursive: true });
|
||
await fs.writeFile(path.join(ctx.data, 'jbot/old'), 'old source');
|
||
await fs.writeFile(
|
||
path.join(ctx.data, 'requirements.txt'),
|
||
'old requirements',
|
||
);
|
||
let starts = 0;
|
||
await assert.rejects(
|
||
installAndStartBot(ctx, {
|
||
stop: async () => {},
|
||
start: async () => {
|
||
if (++starts === 1) throw new Error('startup failure');
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.data, 'jbot/old'), 'utf8'),
|
||
'old source',
|
||
);
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.data, 'requirements.txt'), 'utf8'),
|
||
'old requirements',
|
||
);
|
||
return { pid: 123 };
|
||
},
|
||
}),
|
||
/startup failure/,
|
||
);
|
||
assert.equal(starts, 2);
|
||
assert.ok(
|
||
!(await fs.readdir(ctx.data)).some(
|
||
(name) => name.includes('previous-') || name.includes('ql-backup'),
|
||
),
|
||
);
|
||
});
|
||
|
||
test('failed bot clone preserves previous repository and running files', async (t) => {
|
||
const ctx = await fixture(t);
|
||
ctx.env.BotRepoUrl = path.join(ctx.root, 'absent-repository');
|
||
const old = path.join(ctx.paths.dir_repo, 'diybot');
|
||
await fs.mkdir(old, { recursive: true });
|
||
await fs.writeFile(path.join(old, 'old'), 'keep');
|
||
await assert.rejects(
|
||
prepareBot(ctx),
|
||
/Required executable failed|必要的可执行程序失败/,
|
||
);
|
||
assert.equal(await fs.readFile(path.join(old, 'old'), 'utf8'), 'keep');
|
||
assert.ok(
|
||
!(await fs.readdir(ctx.paths.dir_repo)).some((name) =>
|
||
name.startsWith('.bot-clone-'),
|
||
),
|
||
);
|
||
});
|
||
|
||
test('bot launch detects immediate interpreter failure without reporting a running service', async (t) => {
|
||
const ctx = await fixture(t);
|
||
await fs.mkdir(ctx.data, { recursive: true });
|
||
await fs.writeFile(
|
||
path.join(ctx.root, 'bin/python3'),
|
||
`#!${process.execPath}\nprocess.stderr.write('fixture startup failed');process.exit(9);`,
|
||
{ mode: 0o755 },
|
||
);
|
||
ctx.env.QL_LANG = 'en';
|
||
await assert.rejects(launchBot(ctx), /startup \(9\)/);
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
|
||
'fixture startup failed',
|
||
);
|
||
});
|
||
|
||
test('bot installation keeps internal relative links valid after staging cleanup', async (t) => {
|
||
const ctx = await fixture(t);
|
||
const source = ctx.env.BotRepoUrl;
|
||
await fs.symlink('__main__.py', path.join(source, 'jbot/alias.py'));
|
||
execFileSync('/usr/bin/git', ['add', '.'], { cwd: source });
|
||
execFileSync(
|
||
'/usr/bin/git',
|
||
[
|
||
'-c',
|
||
'user.name=Fixture',
|
||
'-c',
|
||
'user.email=fixture@example.invalid',
|
||
'commit',
|
||
'-qm',
|
||
'internal link',
|
||
],
|
||
{ cwd: source },
|
||
);
|
||
await installAndStartBot(ctx, {
|
||
stop: async () => {},
|
||
start: async () => ({ pid: 123 }),
|
||
});
|
||
assert.equal(
|
||
await fs.readlink(path.join(ctx.data, 'jbot/alias.py')),
|
||
'__main__.py',
|
||
);
|
||
assert.equal(
|
||
await fs.readFile(path.join(ctx.data, 'jbot/alias.py'), 'utf8'),
|
||
'print("fixture")',
|
||
);
|
||
});
|
||
|
||
for (const language of ['zh', 'en', 'unsupported']) {
|
||
test(`bot validation and startup failures honor the operation language: ${language}`, async (t) => {
|
||
const ctx = await fixture(t);
|
||
ctx.env.QL_LANG = language;
|
||
const expected = (zh, en) => (language === 'en' ? en : zh);
|
||
assert.throws(
|
||
() => botSystemPackages('unsupported-os', ctx.env),
|
||
expected(
|
||
/不支持此操作系统:unsupported-os/,
|
||
/does not support OS: unsupported-os/,
|
||
),
|
||
);
|
||
const prepared = await prepareBot(ctx);
|
||
await fs.rm(prepared.staged, { recursive: true });
|
||
const userConfig = path.join(ctx.paths.dir_config, 'bot.json');
|
||
await fs.writeFile(userConfig, 'retain-user-config');
|
||
const source = path.join(prepared.repository, 'jbot');
|
||
const outside = path.join(source, 'outside');
|
||
await fs.symlink(userConfig, outside);
|
||
await assert.rejects(
|
||
prepareBot(ctx),
|
||
expected(/外部的符号链接/, /external symlink/),
|
||
);
|
||
await fs.unlink(outside);
|
||
const template = path.join(prepared.repository, 'config/bot.json');
|
||
await fs.unlink(template);
|
||
await fs.symlink(userConfig, template);
|
||
await assert.rejects(
|
||
prepareBot(ctx),
|
||
expected(/模板必须为普通文件/, /template must be a regular file/),
|
||
);
|
||
assert.equal(await fs.readFile(userConfig, 'utf8'), 'retain-user-config');
|
||
assert.ok(
|
||
!(await fs.readdir(ctx.data)).some((name) =>
|
||
name.startsWith('.bot-stage-'),
|
||
),
|
||
);
|
||
await fs.writeFile(
|
||
path.join(ctx.root, 'bin/python3'),
|
||
`#!${process.execPath}\nprocess.exitCode=9;`,
|
||
{ mode: 0o755 },
|
||
);
|
||
await assert.rejects(
|
||
launchBot(ctx),
|
||
expected(/启动期间退出(9)/, /startup \(9\)/),
|
||
);
|
||
});
|
||
}
|