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:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+271
View File
@@ -0,0 +1,271 @@
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\)/),
);
});
}