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
+154
View File
@@ -0,0 +1,154 @@
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 vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const ts = require('typescript');
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
test('2.x startup selection survives repeated linking and can return to original Shell', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entrypoints-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const home = path.join(root, 'home');
const cliRoot = path.join(root, 'CLI with spaces');
const bin = path.join(home, 'bin');
await fs.mkdir(path.join(root, 'shell'), { recursive: true });
for (const name of ['update.sh', 'task.sh'])
await fs.writeFile(path.join(root, 'shell', name), 'original');
await fs.mkdir(path.join(cliRoot, 'dist/local'), { recursive: true });
for (const [file, method] of [
['ql', 'qlMain'],
['task', 'taskMain'],
])
await fs.writeFile(
path.join(cliRoot, 'dist', `${file}.js`),
`exports.${method}=async()=>{console.log(JSON.stringify(process.argv.slice(2)));return 7;};`,
);
await fs.writeFile(
path.join(cliRoot, 'dist/local/entrypoints.js'),
`exports.installCliEntrypoints=require(${JSON.stringify(
path.resolve(__dirname, '../../dist/local/entrypoints'),
)}).installCliEntrypoints;`,
);
await fs.writeFile(
path.join(cliRoot, 'dist/local/cronEntrypoint.js'),
`module.exports=require(${JSON.stringify(
path.resolve(__dirname, '../../dist/local/cronEntrypoint'),
)});`,
);
const code = ts.transpileModule(
await fs.readFile(
path.resolve(__dirname, '../../../back/loaders/deps.ts'),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
esModuleInterop: true,
},
},
).outputText;
const errors = [];
const legacyBin = path.join(root, 'legacy-bin');
await fs.mkdir(legacyBin);
for (const name of ['ql', 'task'])
await fs.writeFile(path.join(legacyBin, name), '#!/bin/sh\nexit 99\n', {
mode: 0o755,
});
await fs.writeFile(path.join(legacyBin, 'crontab'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
const originalPath = [legacyBin, bin, '/usr/bin', '/bin'].join(
path.delimiter,
);
const environment = { PATH: originalPath };
const module = { exports: {} };
vm.runInNewContext(code, {
module,
exports: module.exports,
process: { env: environment },
require: (id) => {
if (id === 'os') return { homedir: () => home };
if (id === '../config/index')
return {
rootPath: root,
dataPath: path.join(root, 'data'),
crontabFile: path.join(root, 'data/config/crontab.list'),
};
if (id === './logger') return { error: (...args) => errors.push(args) };
return require(id);
},
});
await module.exports.default();
assert.equal(
await fs.readlink(path.join(bin, 'ql')),
path.join(root, 'shell/update.sh'),
);
assert.equal(environment.PATH, originalPath);
assert.equal(spawnSync('ql', [], { env: environment }).status, 99);
environment.QL_CLI_ROOT = cliRoot;
for (let i = 0; i < 2; i++) {
await module.exports.default();
assert.equal(environment.PATH.split(path.delimiter)[0], bin);
assert.equal(
environment.PATH.split(path.delimiter).filter((value) => value === bin)
.length,
1,
);
for (const name of ['ql', 'task']) {
const result = spawnSync(name, ['--literal', 'a b', '$(nothing)'], {
encoding: 'utf8',
env: { ...process.env, ...environment },
});
assert.equal(result.status, 7, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), [
'--literal',
'a b',
'$(nothing)',
]);
}
}
assert.match(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
/QingLong CLI crontab bridge/,
);
assert.deepEqual(errors, []);
const before = await fs.readFile(path.join(bin, 'ql'), 'utf8');
environment.QL_CLI_ROOT = 'relative';
await module.exports.default();
assert.equal(errors.length, 1);
assert.equal(await fs.readFile(path.join(bin, 'ql'), 'utf8'), before);
delete environment.QL_CLI_ROOT;
await module.exports.default();
await assert.rejects(fs.lstat(path.join(bin, 'crontab')), { code: 'ENOENT' });
await fs.writeFile(path.join(bin, 'crontab'), '# unrelated user command');
await module.exports.default();
assert.equal(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
'# unrelated user command',
);
assert.equal(
await fs.readlink(path.join(bin, 'task')),
path.join(root, 'shell/task.sh'),
);
assert.equal(
await fs.readFile(path.join(root, 'shell/task.sh'), 'utf8'),
'original',
);
});
test('incomplete CLI installation leaves both existing entrypoints untouched', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entry-incomplete-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.mkdir(path.join(root, 'dist'));
await fs.writeFile(path.join(root, 'dist/ql.js'), '');
for (const name of ['ql', 'task'])
await fs.writeFile(path.join(bin, name), 'keep');
await assert.rejects(installCliEntrypoints(bin, root));
for (const name of ['ql', 'task'])
assert.equal(await fs.readFile(path.join(bin, name), 'utf8'), 'keep');
});