mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
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:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user