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,77 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
// Deleted commands must not survive incremental builds or npm prepack.
|
||||
fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
require.resolve('typescript/bin/tsc'),
|
||||
'-p',
|
||||
path.join(root, 'tsconfig.json'),
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
legalComments: 'inline',
|
||||
});
|
||||
const license = path.join(
|
||||
path.dirname(require.resolve('commander')),
|
||||
'LICENSE',
|
||||
);
|
||||
fs.mkdirSync(path.join(root, 'dist/licenses'), { recursive: true });
|
||||
fs.copyFileSync(license, path.join(root, 'dist/licenses/commander-LICENSE'));
|
||||
|
||||
// The public npm artifact must not contain or import local operational code.
|
||||
const remote = require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/entrypoints/remote.ts')],
|
||||
outfile: path.join(root, 'dist/npm/ql.js'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
legalComments: 'inline',
|
||||
metafile: true,
|
||||
});
|
||||
for (const input of Object.keys(remote.metafile.inputs)) {
|
||||
if (/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input))
|
||||
throw new Error(
|
||||
'Local implementation leaked into remote npm bundle: ' + input,
|
||||
);
|
||||
}
|
||||
fs.copyFileSync(license, path.join(root, 'dist/npm/commander-LICENSE'));
|
||||
|
||||
// Keep existing panel/cron executable paths stable after source reorganization.
|
||||
for (const [name, entry] of Object.entries(require('./entrypoints.cjs'))) {
|
||||
const target = path.join(root, 'dist', name);
|
||||
let relative = path
|
||||
.relative(path.dirname(target), path.join(root, 'dist', entry.module))
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
if (!relative.startsWith('.')) relative = './' + relative;
|
||||
const run = entry.method
|
||||
? `
|
||||
if (require.main === module) void entry.${entry.method}().then(code => { process.exitCode = code; });`
|
||||
: '';
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
target,
|
||||
'#!/usr/bin/env node\nconst entry = require(' +
|
||||
JSON.stringify(relative) +
|
||||
');\nmodule.exports = entry;' +
|
||||
run +
|
||||
'\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Stable panel integration and executable paths. Source folders may move independently.
|
||||
module.exports = {
|
||||
'ql.js': {
|
||||
module: 'entrypoints/ql.js',
|
||||
method: 'qlMain',
|
||||
},
|
||||
'task.js': {
|
||||
module: 'entrypoints/task.js',
|
||||
method: 'taskMain',
|
||||
},
|
||||
'runner.js': {
|
||||
module: 'internal/execution/runner.js',
|
||||
method: 'runnerMain',
|
||||
},
|
||||
'remote.js': {
|
||||
module: 'entrypoints/remote.js',
|
||||
method: 'remoteMain',
|
||||
},
|
||||
'compat.js': {
|
||||
module: 'compatibility/legacy.js',
|
||||
method: 'compatibilityMain',
|
||||
},
|
||||
'startup.js': {
|
||||
module: 'compatibility/startup.js',
|
||||
method: 'startupMain',
|
||||
},
|
||||
'subscription-worker.js': {
|
||||
module: 'internal/subscription/worker.js',
|
||||
method: 'subscriptionWorker',
|
||||
},
|
||||
'main.js': {
|
||||
module: 'compatibility/main.js',
|
||||
method: null,
|
||||
},
|
||||
'index.js': {
|
||||
module: 'entrypoints/index.js',
|
||||
method: null,
|
||||
},
|
||||
'admin.js': {
|
||||
module: 'entrypoints/admin.js',
|
||||
method: null,
|
||||
},
|
||||
'container.js': {
|
||||
module: 'entrypoints/container.js',
|
||||
method: null,
|
||||
},
|
||||
'local/entrypoints.js': {
|
||||
module: 'internal/integration/entrypoints.js',
|
||||
method: null,
|
||||
},
|
||||
'local/cronEntrypoint.js': {
|
||||
module: 'internal/integration/cronEntrypoint.js',
|
||||
method: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '../test');
|
||||
const files = [
|
||||
'remote',
|
||||
'internal',
|
||||
'shared',
|
||||
'compatibility',
|
||||
'integration',
|
||||
].flatMap((group) =>
|
||||
fs
|
||||
.readdirSync(path.join(root, group))
|
||||
.filter((name) => name.endsWith('.test.cjs'))
|
||||
.sort()
|
||||
.map((name) => path.join(root, group, name)),
|
||||
);
|
||||
const result = spawnSync(process.execPath, ['--test', ...files], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
@@ -0,0 +1,124 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-package-'));
|
||||
const run = (program, args, options = {}) =>
|
||||
execFileSync(program, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 60000,
|
||||
...options,
|
||||
});
|
||||
try {
|
||||
const cache = path.join(temp, 'cache');
|
||||
const manifest = JSON.parse(
|
||||
run(
|
||||
'npm',
|
||||
[
|
||||
'pack',
|
||||
'--json',
|
||||
'--ignore-scripts',
|
||||
'--cache',
|
||||
cache,
|
||||
'--pack-destination',
|
||||
temp,
|
||||
],
|
||||
{ cwd: root },
|
||||
),
|
||||
)[0];
|
||||
for (const name of ['README.md', 'README.en.md', 'LICENSE', 'dist/npm/commander-LICENSE'])
|
||||
assert.ok(manifest.files.some((file) => file.path === name), name);
|
||||
assert.ok(manifest.files.every((file) => /^(dist\/npm\/|skills\/qinglong-cli\/|README(?:\.en)?\.md$|LICENSE$|package\.json$)/.test(file.path)));
|
||||
assert.ok(
|
||||
manifest.files.some((file) => file.path === 'skills/qinglong-cli/SKILL.md'),
|
||||
);
|
||||
assert.ok(
|
||||
!manifest.files.some(
|
||||
(file) => file.path.startsWith('src/') || file.path.startsWith('test/'),
|
||||
),
|
||||
);
|
||||
const prefix = path.join(temp, 'install');
|
||||
run(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--offline',
|
||||
'--ignore-scripts',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--cache',
|
||||
cache,
|
||||
'--prefix',
|
||||
prefix,
|
||||
path.join(temp, manifest.filename),
|
||||
],
|
||||
{ cwd: temp },
|
||||
);
|
||||
const installed = path.join(prefix, 'node_modules/@whyour/qinglong-cli');
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(path.join(installed, 'package.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(Object.keys(metadata.dependencies || {}).length, 0);
|
||||
assert.equal(metadata.bin['ql-dev-cli'], undefined);
|
||||
assert.ok(!manifest.files.some(file => /^dist\/developer(?:\/|\.)/.test(file.path)));
|
||||
assert.deepEqual(metadata.bin, { ql: 'dist/npm/ql.js' });
|
||||
for (const reference of ['panel.md', 'openapi.md'])
|
||||
assert.ok(fs.existsSync(path.join(installed, 'skills/qinglong-cli/references', reference)));
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(installed, 'skills/qinglong-cli/SKILL.md'),
|
||||
'utf8',
|
||||
),
|
||||
fs.readFileSync(path.join(root, 'skills/qinglong-cli/SKILL.md'), 'utf8'),
|
||||
);
|
||||
const entries = [];
|
||||
for (const [name, target] of Object.entries(metadata.bin)) {
|
||||
const entry = path.join(prefix, 'node_modules/.bin', name);
|
||||
assert.equal(
|
||||
fs.realpathSync(entry),
|
||||
fs.realpathSync(path.join(installed, target)),
|
||||
);
|
||||
const output = run(entry, ['--help'], {
|
||||
cwd: temp,
|
||||
env: { ...process.env, QL_DIR: '', QL_DATA_DIR: '' },
|
||||
});
|
||||
assert.match(output, /Usage:|用法|usage/i);
|
||||
entries.push(name);
|
||||
}
|
||||
assert.ok(!manifest.files.some(file => file.path.includes('qinglong-local')));
|
||||
for (const args of [['reload'], ['resetpwd', 'fixture'], ['local', 'check'], ['task', 'exec', 'fixture.js'], ['task', 'fixture.js'], ['repo', 'fixture'], ['dev', 'release']]) {
|
||||
const result = spawnSync(process.execPath, [path.join(installed, metadata.bin.ql), ...args, '--json'], {
|
||||
cwd: temp, encoding: 'utf8', timeout: 10000,
|
||||
env: { PATH: process.env.PATH, QL_CLI_CONFIG: path.join(temp, 'missing.json'), QL_DIR: '/nonexistent-panel' },
|
||||
});
|
||||
assert.equal(result.status, 2, JSON.stringify(args) + result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(JSON.parse(result.stderr).code, 2);
|
||||
}
|
||||
// CI uploads the exact archive whose isolated installation passed above.
|
||||
if (process.env.QL_CLI_PACKAGE_OUTPUT) {
|
||||
const output = path.resolve(process.env.QL_CLI_PACKAGE_OUTPUT);
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.copyFileSync(path.join(temp, manifest.filename), path.join(output, manifest.filename));
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
entries,
|
||||
packedBytes: manifest.size,
|
||||
unpackedBytes: manifest.unpackedSize,
|
||||
runtimeDependencies: 0,
|
||||
standaloneInstall: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user