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
+77
View File
@@ -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 },
);
}
+55
View File
@@ -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,
},
};
+22
View File
@@ -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;
+124
View File
@@ -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 });
}