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
155 lines
5.5 KiB
JavaScript
155 lines
5.5 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 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');
|
|
});
|