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
@@ -0,0 +1,60 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const source = path.resolve(__dirname, '../../src');
function files(root) {
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(root, entry.name);
return entry.isDirectory()
? files(file)
: file.endsWith('.ts')
? [file]
: [];
});
}
test('shared, remote and internal dependencies respect their source boundaries', () => {
for (const file of files(source)) {
const owner = path.relative(source, file).split(path.sep)[0];
const allowed = {
shared: ['shared'],
remote: ['remote', 'shared'],
internal: ['internal', 'shared'],
}[owner];
if (!allowed) continue;
const imports = ts.preProcessFile(
fs.readFileSync(file, 'utf8'),
true,
true,
).importedFiles;
for (const imported of imports) {
if (!imported.fileName.startsWith('.')) continue;
const target = path
.relative(source, path.resolve(path.dirname(file), imported.fileName))
.split(path.sep)[0];
assert.ok(
allowed.includes(target),
`${path.relative(source, file)} imports ${imported.fileName}`,
);
}
}
});
test('stable build aliases point to real modules and preserve exported entry functions', () => {
const entries = require('../../scripts/entrypoints.cjs');
for (const [name, entry] of Object.entries(entries)) {
const alias = path.resolve(__dirname, '../../dist', name);
const target = path.resolve(__dirname, '../../dist', entry.module);
assert.ok(fs.statSync(alias).isFile(), name);
assert.ok(fs.statSync(target).isFile(), entry.module);
// Auto-starting entry modules are exercised through child-process integration tests.
if (entry.method)
assert.equal(
require(alias)[entry.method],
require(target)[entry.method],
name,
);
}
});
+570
View File
@@ -0,0 +1,570 @@
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 { execFileSync, spawnSync } = require('node:child_process');
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('unmodified legacy Shell and TS agree on shell hook state and account modes', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.symlink(process.execPath, path.join(bin, 'node'));
await fs.writeFile(path.join(bin, 'pnpm'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
const context = createContext(
{ root },
{ PATH: `${bin}:/usr/local/bin:/usr/bin:/bin`, no_tee: 'true' },
);
for (const directory of [
context.paths.dir_shell,
context.paths.dir_preload,
context.paths.dir_config,
context.paths.dir_scripts,
context.paths.dir_log,
path.join(root, 'static/build'),
])
await fs.mkdir(directory, { recursive: true });
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
await fs.copyFile(
path.resolve(__dirname, '../../../shell', name),
path.join(context.paths.dir_shell, name),
);
await fs.mkdir(path.join(context.paths.dir_shell, 'lang'));
for (const name of ['zh.sh', 'en.sh'])
await fs.copyFile(
path.resolve(__dirname, '../../../shell/lang', name),
path.join(context.paths.dir_shell, 'lang', name),
);
await fs.writeFile(
path.join(root, 'static/build/token.js'),
'process.stdout.write("fixture-local-token")',
);
await fs.writeFile(
context.paths.file_config_user,
'no_tee=true\nconfiguration_function() { printf "function"; }\n',
);
await fs.writeFile(context.paths.file_env, 'export ACCOUNTS="one&two"\n');
await fs.writeFile(context.paths.list_crontab_user, '');
await fs.writeFile(
context.paths.file_task_before,
'state=before\nprintf "before\\n" >> "$TRACE"\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after:%s:%s\\n" "$state" "$_task_exit_code" >> "$TRACE"\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'fixture.sh'),
'printf "run:%s:%s:%s\\n" "$ACCOUNTS" "$state" "$(configuration_function)" >> "$TRACE"\nstate=script\n',
);
for (const mode of ['now', 'desi', 'conc'])
await t.test(mode, async () => {
const legacy = path.join(root, `${mode}-legacy`),
modern = path.join(root, `${mode}-modern`);
const args =
mode === 'now'
? ['fixture.sh', 'now']
: ['fixture.sh', mode, 'ACCOUNTS', '2', '1'];
execFileSync(
'/bin/bash',
[path.join(context.paths.dir_shell, 'task.sh'), ...args],
{
cwd: root,
env: { ...context.env, TRACE: legacy },
timeout: 15000,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const env = await sourceEnvironment({ ...context.env, TRACE: modern }, [
context.paths.file_config_user,
]);
const result = await executeTask(
{ ...context, env },
{
argv: ['fixture.sh'],
mode,
variable: mode === 'now' ? undefined : 'ACCOUNTS',
selection: mode === 'now' ? undefined : '2 1',
},
);
assert.equal(result.exitCode, 0);
const oldLines = (await fs.readFile(legacy, 'utf8')).trim().split('\n');
const newLines = (await fs.readFile(modern, 'utf8')).trim().split('\n');
assert.equal(oldLines[0], 'before');
assert.equal(newLines[0], 'before');
assert.equal(newLines.at(-1), oldLines.at(-1));
assert.deepEqual(
newLines.slice(1, -1).sort(),
oldLines.slice(1, -1).sort(),
);
});
await t.test(
'Shell failure, explicit exit and EXIT traps preserve hook behavior',
async () => {
const cases = [
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
{ name: 'exit', script: 'exit 7', code: 7, legacy: 7, after: false },
{
name: 'errexit',
script: 'set -e; false',
code: 1,
legacy: 1,
after: false,
},
{
name: 'trap-return',
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; return 7',
code: 7,
legacy: 0,
after: true,
trap: true,
},
{
name: 'trap-exit',
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
code: 7,
legacy: 7,
after: false,
trap: true,
},
];
await fs.writeFile(context.paths.file_config_user, 'no_tee=true\n');
await fs.writeFile(
context.paths.file_task_before,
'printf "before\\n" >> "$TRACE"\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after:%s\\n" "$_task_exit_code" >> "$TRACE"\n',
);
for (const item of cases) {
await fs.writeFile(
path.join(context.paths.dir_scripts, 'failure.sh'),
item.script + '\n',
);
const legacyTrace = path.join(root, `${item.name}-old`);
const modernTrace = path.join(root, `${item.name}-new`);
const legacy = spawnSync(
'/bin/bash',
[path.join(context.paths.dir_shell, 'task.sh'), 'failure.sh', 'now'],
{
cwd: root,
env: { ...context.env, TRACE: legacyTrace },
encoding: 'utf8',
timeout: 15000,
},
);
assert.ifError(legacy.error);
assert.equal(legacy.status, item.legacy, item.name);
const env = await sourceEnvironment(
{ ...context.env, TRACE: modernTrace },
[context.paths.file_config_user],
);
const result = await executeTask(
{ ...context, env },
{ argv: ['failure.sh'], mode: 'now' },
);
assert.equal(result.exitCode, item.code, item.name);
const expected = [
'before',
...(item.after ? [`after:${item.code}`] : []),
...(item.trap ? ['exit-trap'] : []),
];
for (const file of [legacyTrace, modernTrace])
assert.deepEqual(
(await fs.readFile(file, 'utf8')).trim().split('\n'),
expected,
`${item.name}: ${file}`,
);
}
},
);
await t.test('/dev/null preserves legacy stream routing', async () => {
await fs.writeFile(
context.paths.file_task_before,
'printf "before-out-marker\\n"; printf "before-err-marker\\n" >&2\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after-out-marker\\n"; printf "after-err-marker\\n" >&2\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'streams.sh'),
'printf "child-out-marker\\n"; printf "child-err-marker\\n" >&2\n',
);
for (const mode of ['now', 'desi', 'conc']) {
for (const realtime of [false, true]) {
await fs.writeFile(
context.paths.file_config_user,
`log_name=/dev/null\nno_tee=true\nreal_time=${realtime}\n`,
);
const legacy = spawnSync(
'/bin/bash',
[
path.join(context.paths.dir_shell, 'task.sh'),
'streams.sh',
mode,
...(mode === 'now' ? [] : ['ACCOUNTS', '2', '1']),
],
{ cwd: root, env: context.env, encoding: 'utf8', timeout: 15000 },
);
assert.ifError(legacy.error);
assert.equal(legacy.status, 0, legacy.stderr);
const env = await sourceEnvironment(context.env, [
context.paths.file_config_user,
]);
let output = '';
const result = await executeTask(
{ ...context, env },
{
argv: ['streams.sh'],
mode,
variable: mode === 'now' ? undefined : 'ACCOUNTS',
selection: mode === 'now' ? undefined : '2 1',
output: (chunk) => {
output += chunk;
},
},
);
assert.equal(result.exitCode, 0);
assert.equal(result.logPath, '/dev/null');
const markers = (text) =>
text.match(/(?:before|after|child)-(?:out|err)-marker/g) ?? [];
assert.deepEqual(markers(legacy.stdout), []);
assert.deepEqual(
markers(output),
markers(legacy.stderr),
`${mode}, realtime=${realtime}`,
);
assert.ok(markers(output).includes('before-err-marker'));
assert.equal(
markers(output).includes('child-err-marker'),
mode !== 'conc',
);
}
}
});
});
test('empty ignored-minute settings match legacy random delay at minute zero', async (t) => {
const source = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const legacyFunction = source.match(/random_delay\(\) \{[\s\S]*?\n\}/)[0];
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-parity-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/scripts/fixture.sh'),
'printf TASK_RAN',
);
const OriginalDate = global.Date;
class MinuteZeroDate extends OriginalDate {
getMinutes() {
return 0;
}
}
global.Date = MinuteZeroDate;
t.after(() => {
global.Date = OriginalDate;
});
for (const ignored of [undefined, '', ' ', '0', ' 30 ']) {
const env = {
PATH: process.env.PATH,
RandomDelay: '1',
RandomDelayFileExtensions: 'sh',
...(ignored === undefined ? {} : { RandomDelayIgnoredMinutes: ignored }),
};
const legacy = execFileSync(
'/bin/bash',
[
'-c',
`${legacyFunction}
date() { printf 0; }
gen_random_num() { printf 0; }
t() { :; }
sleep() { printf DELAYED; }
random_delay fixture.sh
`,
],
{ env, encoding: 'utf8' },
);
let output = '';
const result = await executeTask(createContext({ root }, env), {
argv: ['fixture.sh'],
output: (chunk) => {
output += chunk.toString();
},
});
assert.equal(result.exitCode, 0, output);
assert.match(output, /TASK_RAN/);
assert.equal(
output.includes('任务随机延迟'),
legacy.includes('DELAYED'),
`ignored=${JSON.stringify(ignored)}`,
);
}
});
test('random delay follows legacy dispatch for script arguments and executable commands', async (t) => {
const source = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const functions = ['random_delay', 'run_normal', 'main']
.map(
(name) =>
source.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\}`))[0],
)
.join('\n');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-dispatch-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/scripts/fixture.sh'),
'printf TASK_RAN',
);
const env = {
PATH: process.env.PATH,
RandomDelay: '1',
RandomDelayFileExtensions: '',
RandomDelayIgnoredMinutes: '99',
};
for (const args of [
['fixture.sh'],
['fixture.sh', 'argument'],
['fixture.sh', 'now'],
['true'],
]) {
const legacy = execFileSync(
'/bin/bash',
[
'-c',
`${functions}
date() { printf 0; }
gen_random_num() { printf 0; }
t() { :; }
sleep() { printf DELAYED; }
enter_script_workdir() { :; }
run_else() { :; }
which_program=true
main "$@"
`,
'fixture',
...args,
],
{ env, encoding: 'utf8' },
);
let output = '';
const parsed = require('../../dist/runner').parseExecution(args);
const result = await executeTask(createContext({ root }, env), {
...parsed.execution,
output: (chunk) => {
output += chunk.toString();
},
});
assert.equal(result.exitCode, 0, output);
assert.equal(
output.includes('任务随机延迟'),
legacy.includes('DELAYED'),
JSON.stringify(args),
);
}
});
test('configuration shell options survive the environment bridge for pipeline and glob behavior', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-options-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
const probe =
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; eval "$2"',
'fixture',
config,
probe,
],
{ env: base, encoding: 'utf8' },
);
const env = await sourceEnvironment(base, [config]);
assert.ok(env.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!env.SHELLOPTS.split(':').includes('allexport'));
// Bash 3.x cannot import BASHOPTS itself; exercise the actual CLI bridge.
const execution = await require('../../dist/internal/runtime/process').runProcess(
'bash', ['--noprofile', '--norc', '-c', probe], { env, capture: true },
);
assert.equal(execution.code, 0);
const modern = execution.stdout;
assert.equal(modern, legacy);
assert.match(modern, /pipeline=1/);
const context = createContext({ root }, { ...env, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.writeFile(
path.join(context.paths.dir_scripts, 'pipeline.sh'),
'false | true\n',
);
const result = await executeTask(context, {
argv: ['pipeline.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 1);
});
test('configuration errexit and nounset retain task and after-hook semantics', async (t) => {
for (const option of ['errexit', 'nounset']) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-task-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, `set -o ${option}\n`);
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
assert.ok(env.SHELLOPTS.split(':').includes(option));
const context = createContext({ root }, { ...env, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.mkdir(context.paths.dir_config, { recursive: true });
const marker = path.join(root, 'after');
context.env.AFTER_MARKER = marker;
await fs.writeFile(
context.paths.file_task_after,
'printf "%s" "$-" > "$AFTER_MARKER"\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'options.sh'),
option === 'errexit'
? 'false\nprintf SHOULD_NOT_RUN\n'
: 'printf "%s" "$QL_UNSET_FIXTURE"\n',
);
const result = await executeTask(context, {
argv: ['options.sh'],
mode: 'now',
});
if (option === 'errexit') {
assert.equal(result.exitCode, 1);
await assert.rejects(fs.access(marker));
assert.doesNotMatch(
await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
),
/SHOULD_NOT_RUN/,
);
} else {
assert.equal(result.exitCode, 0);
assert.match(await fs.readFile(marker, 'utf8'), /u/);
}
}
});
test('later hooks can disable previously enabled shell options without mutating the prior snapshot', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-reset-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
const hook = path.join(root, 'before.sh');
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
await fs.writeFile(hook, 'set +o pipefail\nshopt -u nullglob\n');
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
const enabled = await sourceEnvironment(base, [config]);
const disabled = await sourceEnvironment(enabled, [hook]);
assert.ok(enabled.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!disabled.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!disabled.BASHOPTS.split(':').includes('nullglob'));
const probe =
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; . "$2"; eval "$3"',
'fixture',
config,
hook,
probe,
],
{ env: base, encoding: 'utf8' },
);
const modern = execFileSync('bash', ['--noprofile', '--norc', '-c', probe], {
env: disabled,
encoding: 'utf8',
});
assert.equal(modern, legacy);
assert.equal(modern, 'pipeline=0\nmatches=1\n');
const context = createContext({ root }, { ...enabled, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.mkdir(context.paths.dir_config, { recursive: true });
await fs.copyFile(hook, context.paths.file_task_before);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'pipeline.sh'),
'false | true\n',
);
await fs.writeFile(
context.paths.file_task_after,
'false | true; printf "%s" "$?" > "$AFTER_RESULT"\n',
);
context.env.AFTER_RESULT = path.join(root, 'after-result');
const result = await executeTask(context, {
argv: ['pipeline.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
assert.equal(await fs.readFile(context.env.AFTER_RESULT, 'utf8'), '0');
});
test('disabled default shell options stay disabled across configuration and task bridges', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-default-options-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, 'set +o braceexpand\nshopt -u extquote\n');
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
const refreshed = await sourceEnvironment(env, []);
assert.ok(!refreshed.SHELLOPTS.split(':').includes('braceexpand'));
assert.ok(!refreshed.BASHOPTS.split(':').includes('extquote'));
const context = createContext({ root }, { ...refreshed, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
const script =
'printf "%s\\n" {a,b}; shopt -q extquote && printf WRONG; true\n';
await fs.writeFile(
path.join(context.paths.dir_scripts, 'options.sh'),
script,
);
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; eval "$2"',
'fixture',
config,
script,
],
{ env: { PATH: process.env.PATH }, encoding: 'utf8' },
);
assert.equal(legacy, '{a,b}\n');
const result = await executeTask(context, {
argv: ['options.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
const log = await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.ok(log.includes(legacy));
assert.doesNotMatch(log, /WRONG/);
});
@@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
test('generated entries treat quotes, newlines and code-like paths as data', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entry-paths-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const cliRoot = path.join(root, "CLI '中文\n\");throw new Error('injected');//");
const bin = path.join(root, 'bin');
await fs.mkdir(path.join(cliRoot, 'dist'), { recursive: true });
await fs.writeFile(path.join(cliRoot, 'dist/ql.js'), 'exports.qlMain=async()=>{console.log(JSON.stringify(process.argv.slice(2)));return 7;};');
await fs.writeFile(path.join(cliRoot, 'dist/task.js'), 'exports.taskMain=async()=>{throw new Error("secret-value");};');
await installCliEntrypoints(bin, cliRoot);
const ql = spawnSync(process.execPath, [path.join(bin, 'ql'), '--literal', 'value'], { encoding: 'utf8' });
assert.equal(ql.status, 7, ql.stderr);
assert.deepEqual(JSON.parse(ql.stdout), ['--literal', 'value']);
for (const language of ['en', 'zh']) {
const result = spawnSync(process.execPath, [path.join(bin, 'task')], { encoding: 'utf8', env: { QL_LANG: language } });
assert.equal(result.status, 1);
assert.equal(result.stderr, language === 'en' ? 'CLI invocation failed.\n' : 'CLI 调用失败。\n');
assert.doesNotMatch(result.stderr, /secret-value/);
}
});
@@ -0,0 +1,91 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('legacy Shell and TS resolve executable paths after selecting the working directory', t => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ql-executable-')));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts/bin', 'data/scripts/custom', 'data/log', 'static/build', 'bin'])
fs.mkdirSync(path.join(root, dir), { recursive: true });
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
for (const name of ['zh.sh', 'en.sh'])
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
fs.symlinkSync(process.execPath, path.join(root, 'bin/node'));
fs.writeFileSync(path.join(root, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(root, 'static/build/token.js'), 'process.stdout.write("fixture")');
fs.writeFileSync(path.join(root, 'data/config/config.sh'),
'no_tee=true\nmy_task() { printf "%s\\n" "$PWD" "$@" > "$TRACE"; }\n');
fs.writeFileSync(path.join(root, 'data/config/crontab.list'), '');
const script = '#!/bin/sh\nprintf "%s\\n" "$PWD" "$@" > "$TRACE"\n';
for (const file of ['data/scripts/bin/tool', 'data/scripts/bin/tool.exe', 'data/scripts/custom/tool', 'bin/path-tool'])
fs.writeFileSync(path.join(root, file), script, { mode: 0o755 });
const cases = [
['bin/tool', '', 'bin'],
['./bin/tool', '', 'bin'],
['bin/tool.exe', '', 'bin'],
[path.join(root, 'data/scripts/bin/tool'), '', 'bin'],
['bin/tool', 'custom', 'custom'],
['path-tool', '', ''],
['my_task', '', ''],
['my_task', 'custom', 'custom'],
];
for (const [program, workDir, directory] of cases) {
for (const [label, command, prefix] of [
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
]) {
const trace = path.join(root, `${label}.trace`);
fs.rmSync(trace, { force: true });
const result = spawnSync(command, [...prefix, program, 'first', 'two words'], {
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, QL_DIR: root, work_dir: workDir, TRACE: trace },
encoding: 'utf8', timeout: 15000,
});
assert.equal(result.status, 0, `${label}/${program}: ${result.stderr}`);
assert.equal(fs.readFileSync(trace, 'utf8'),
`${path.join(root, 'data/scripts', directory)}\nfirst\ntwo words\n`, `${label}/${program}`);
}
}
// A builtin has no executable file. Function arguments must remain literal.
for (const args of [[':', 'unused'], ['my_task', '$(touch INJECTED)', '; exit 9']]) {
const result = spawnSync(process.execPath,
[path.resolve(__dirname, '../../dist/runner.js'), '--root', root, '--json', ...args], {
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, TRACE: path.join(root, 'literal.trace') },
encoding: 'utf8', timeout: 15000,
});
assert.equal(result.status, 0, result.stderr);
}
assert.equal(fs.readFileSync(path.join(root, 'literal.trace'), 'utf8'),
`${path.join(root, 'data/scripts')}\n$(touch INJECTED)\n; exit 9\n`);
assert.equal(fs.existsSync(path.join(root, 'data/scripts/INJECTED')), false);
fs.writeFileSync(path.join(root, 'data/config/config.sh'),
'no_tee=true\nSTATE=before\nmy_task() { export STATE=after; return 7; }\n');
fs.writeFileSync(path.join(root, 'data/config/task_after.sh'),
'printf "%s:%s" "$STATE" "$_task_exit_code" > "$TRACE.after"\n');
for (const dir of ['', 'custom'])
fs.writeFileSync(path.join(root, 'data/scripts', dir, 'show.sh'),
'printf "%s" "$PWD" > "$TRACE"\n');
for (const scenario of ['function-state', 'hook-directory']) {
fs.writeFileSync(path.join(root, 'data/config/task_before.sh'),
scenario === 'hook-directory' ? 'work_dir=custom\n' : '');
const args = scenario === 'function-state' ? ['my_task'] : ['show.sh', 'now'];
for (const [label, command, prefix] of [
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
]) {
const trace = path.join(root, `${scenario}-${label}`);
const result = spawnSync(command, [...prefix, ...args], {
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, QL_DIR: root, TRACE: trace },
encoding: 'utf8', timeout: 15000,
});
assert.equal(result.status, scenario === 'function-state' && label === 'ts' ? 7 : 0, result.stderr);
if (scenario === 'function-state')
assert.equal(fs.readFileSync(`${trace}.after`, 'utf8'), 'after:7');
else
assert.equal(fs.readFileSync(trace, 'utf8'), path.join(root, 'data/scripts/custom'));
}
}
});
@@ -0,0 +1,73 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('standalone public commands load only public modules and never local operators or backend dependencies', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-loading-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const dist = path.join(root, 'dist');
await fs.cp(path.resolve(__dirname, '../../dist'), dist, { recursive: true });
const script = `
const path = require('node:path');
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = () => true;
process.stderr.write = () => true;
const entry = process.argv[1];
require(entry).main(JSON.parse(process.argv[2])).then(code => {
originalWrite(JSON.stringify({ code, modules: Object.keys(require.cache).map(file => path.relative(path.dirname(entry), file)) }));
});
`;
for (const [args, expected] of [
[['--help'], 0],
[['task', 'list', '--help'], 0],
[['auth', 'logout'], 0],
[['auth', 'status', '--json'], 3],
[['task', 'list', '--json'], 3],
[['subscription', 'list', '--json'], 3],
]) {
const child = spawnSync(
process.execPath,
['-e', script, path.join(dist, 'main.js'), JSON.stringify(args)],
{
cwd: root,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
QL_CLI_CONFIG: path.join(root, 'missing/config.json'),
QL_DIR: '/nonexistent-panel',
},
timeout: 10000,
},
);
assert.equal(child.status, 0, child.stderr);
const result = JSON.parse(child.stdout);
assert.equal(result.code, expected, JSON.stringify(args));
assert.ok(result.modules.length > 0);
for (const module of result.modules) {
assert.ok(!module.startsWith('..'), `External module loaded: ${module}`);
assert.doesNotMatch(
module,
/^(local|internal|back|preload)[/\\]|(^|[/\\])node_modules[/\\]/,
JSON.stringify(args),
);
}
if (args.includes('--help'))
assert.ok(
!result.modules.some((module) =>
/^remote[/\\](commands[/\\](auth|open|task|subscription)\.js|api[/\\](client|download)\.js|auth[/\\])/.test(module),
),
'Help eagerly loaded command implementation',
);
if (args[0] === 'task' && !args.includes('--help'))
assert.ok(result.modules.includes(path.join('remote', 'commands', 'task.js')));
if (args[0] === 'subscription')
assert.ok(
result.modules.includes(path.join('remote', 'commands', 'subscription.js')),
);
}
});
@@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
// Exercise the real dispatcher/parser without performing an actual upgrade,
// service restart or account mutation.
function invoke(args) {
const script = `
require('./cli/dist/internal/commands/dispatch').dispatch = async command => ({code:200,data:command});
require('./cli/dist/ql').qlMain(JSON.parse(process.argv[1])).then(code=>{process.exitCode=code});`;
return spawnSync(process.execPath, ['-e', script, JSON.stringify(args)], {
cwd: path.resolve(__dirname, '../../..'), encoding: 'utf8',
env: { PATH: process.env.PATH, QL_LANG: 'en' }, timeout: 5000,
});
}
test('direct maintenance commands and local aliases accept modern and legacy options identically', () => {
for (const [args, name, values, positionals] of [
[['update', '--mirror', 'gitee', '--download-only', '--root', '/isolated'], 'update', {mirror:'gitee', 'download-only':true, root:'/isolated'}, []],
[['update', 'false', '--root', '/isolated'], 'update', {'download-only':true, root:'/isolated'}, []],
[['update', 'true'], 'update', {mirror:'github'}, []],
[['reload', 'system', '--root', '/isolated'], 'reload', {target:'system', root:'/isolated'}, []],
[['reload', '--target', 'data'], 'reload', {target:'data'}, []],
[['check'], 'check', {}, []],
[['rmlog', '7'], 'rmlog', {}, ['7']],
[['start', '--no-startup'], 'start', {'no-startup':true}, []],
[['repair-config'], 'repair-config', {}, []],
[['resetpwd', '--', '-literal-value'], 'resetpwd', {}, ['-literal-value']],
[['update', '--root', 'false'], 'update', {root:'false'}, []],
]) {
const direct = invoke(['--json', ...args]);
const alias = invoke(['--json', 'local', ...args]);
assert.equal(direct.status, 0, direct.stderr);
assert.equal(alias.status, 0, alias.stderr);
assert.deepEqual(JSON.parse(direct.stdout), JSON.parse(alias.stdout));
const command = JSON.parse(direct.stdout).data;
assert.equal(command.name, `local ${name}`);
assert.deepEqual(command.positionals, positionals);
for (const [key, value] of Object.entries(values)) assert.equal(command.values[key], value);
}
});
test('invalid maintenance options fail before dispatch; help recommends direct commands', () => {
for (const args of [
['update', 'invalid'], ['reload', 'invalid'], ['update', 'false', 'extra'],
['reload', 'system', '--target', 'data'], ['update', '--unknown'],
['update', '--', 'false'], ['rmlog', '-1'],
]) {
const result = invoke(['--json', ...args]);
assert.equal(result.status, 2, result.stderr);
assert.equal(result.stdout, '');
assert.equal(JSON.parse(result.stderr).code, 2);
}
for (const group of [[], ['local']]) {
const result = invoke([...group, 'update', '--help', '--json']);
assert.equal(result.status, 0, result.stderr);
const help = JSON.parse(result.stdout).data.help;
assert.match(help, /Usage: ql update/);
assert.match(help, /--mirror/);
assert.match(help, /--download-only/);
}
});
test('direct and aliased maintenance preserve logs, lifecycle and single configuration evaluation', async t => {
const fs = require('node:fs/promises');
const os = require('node:os');
const http = require('node:http');
const exec = require('node:util').promisify(require('node:child_process').execFile);
for (const prefix of [[], ['local']]) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-direct-extra-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const reports = [];
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
reports.push({ url: req.url, data: JSON.parse(body) });
res.end(JSON.stringify({ code: 200 }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
await fs.writeFile(path.join(root, 'data/config/config.sh'), 'no_tee=true\nprintf x >> "$QL_DIR/loads"\n');
await fs.writeFile(path.join(root, 'data/config/token.json'), JSON.stringify({value:'fixture', expiration:Date.now()/1000+3600}));
await fs.writeFile(path.join(root, 'data/config/extra.sh'), 'printf "logged-extra\\n"\n');
const result = await exec(process.execPath, [path.resolve(__dirname, '../../dist/ql.js'), ...prefix, 'extra', '--root', root, '--json'], {
env: {PATH:process.env.PATH, QlPort:String(server.address().port), ID:'9'}, timeout:10000,
});
assert.equal(await fs.readFile(path.join(root, 'loads'), 'utf8'), 'x');
assert.equal(result.stderr, '');
const response = JSON.parse(result.stdout);
assert.match(response.logPath, /^extra\//);
assert.match(await fs.readFile(path.join(root, 'data/log', response.logPath), 'utf8'), /logged-extra/);
assert.deepEqual(reports.map(report => report.data.status), ['0', '1']);
assert.ok(reports.every(report => report.url === '/open/crons/status'));
} finally {
await new Promise(resolve => server.close(resolve));
}
}
});
+46
View File
@@ -0,0 +1,46 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('legacy Shell and TS preserve task stdin in each execution mode', t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stdin-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts', 'data/log', 'static/build', 'bin'])
fs.mkdirSync(path.join(root, dir), { recursive: true });
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
for (const name of ['zh.sh', 'en.sh'])
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
fs.symlinkSync(process.execPath, path.join(root, 'bin/node'));
fs.writeFileSync(path.join(root, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(root, 'static/build/token.js'), 'process.stdout.write("fixture")');
fs.writeFileSync(path.join(root, 'data/config/config.sh'), 'no_tee=true\n');
fs.writeFileSync(path.join(root, 'data/config/crontab.list'), '');
fs.writeFileSync(path.join(root, 'shell/preload/env.sh'), 'export ACCOUNTS=one\n');
fs.writeFileSync(path.join(root, 'data/scripts/read.sh'),
'IFS= read -r value\nprintf "value=%s\\n" "$value" > "$TRACE"\n');
const env = {
PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`,
QL_DIR: root,
QL_DATA_DIR: path.join(root, 'data'),
no_delay: 'true',
};
for (const mode of ['normal', 'now', 'desi', 'conc']) {
const args = ['read.sh', ...(mode === 'normal' ? [] : [mode]),
...(['desi', 'conc'].includes(mode) ? ['ACCOUNTS', '1'] : [])];
for (const [label, command, prefix] of [
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
]) {
const trace = path.join(root, `${mode}-${label}`);
const result = spawnSync(command, [...prefix, ...args], {
env: { ...env, TRACE: trace }, input: 'hello\n', encoding: 'utf8', timeout: 15000,
});
assert.equal(result.status, 0, `${mode}/${label}: ${result.stderr}`);
assert.equal(fs.readFileSync(trace, 'utf8'), 'value=hello\n', `${mode}/${label}`);
}
}
});
+117
View File
@@ -0,0 +1,117 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
test('unified ql groups and task shortcut preserve help, errors and local execution boundaries', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-unified-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const env = {
PATH: process.env.PATH,
QL_DIR: root,
QL_CLI_CONFIG: path.join(root, 'credentials'),
QL_LANG: 'en',
};
const call = (entry, args) =>
spawnSync(
process.execPath,
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args],
{ env, encoding: 'utf8', timeout: 10000 },
);
for (const group of ['', 'task', 'local']) {
const result = call('ql', [...(group ? [group] : []), '--help', '--json']);
assert.equal(result.status, 0, result.stderr);
assert.match(JSON.parse(result.stdout).data.help, /ql/);
}
for (const kind of ['repo', 'raw']) {
const help = call('ql', ['local', kind, '--help', '--json']);
assert.equal(help.status, 0, help.stderr);
assert.match(JSON.parse(help.stdout).data.help, /ql repo/);
}
for (const args of [
['task', 'list'],
['task', 'run', '12'],
['subscription', 'list'],
['app', 'list'],
['system', 'info'],
['auth', 'status'],
['api', 'routes'],
]) {
const result = call('ql', [...args, '--json']);
assert.equal(result.status, 2, result.stderr);
assert.equal(result.stdout, '');
}
const invalid = call('ql', ['task', 'run', 'demo.sh', '--json']);
assert.equal(invalid.status, 2);
assert.equal(invalid.stdout, '');
const local = call('ql', [
'local',
'repair-config',
'--root',
'relative',
'--json',
]);
assert.equal(local.status, 2);
assert.match(JSON.parse(local.stderr).message, /absolute/);
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/config/config.sh'),
'no_tee=false\n',
);
await fs.writeFile(
path.join(root, 'data/scripts/demo.sh'),
'printf "script:%s\\n" "$1"; return 7\n',
);
for (const [entry, args] of [
['ql', ['task', 'demo.sh', 'now', '--', '--json']],
['ql', ['task', 'exec', 'demo.sh', 'now', '--', '--json']],
['task', ['demo.sh', 'now', '--', '--json']],
['task', ['exec', 'demo.sh', 'now', '--', '--json']],
]) {
const result = call(entry, args);
assert.equal(result.status, 7, result.stderr);
assert.match(result.stdout, /script:--json/);
}
const shortcut = call('task', ['list', '--json']);
assert.equal(shortcut.status, 2, shortcut.stderr);
const legacy = call('ql', ['update', 'invalid']);
assert.equal(legacy.status, 2, legacy.stderr);
assert.ok(legacy.stderr.length > 0);
for (const args of [['raw'], ['local', 'raw'], ['repo'], ['local', 'repo']]) {
const result = call('ql', args);
assert.equal(result.status, 2, result.stderr);
}
const bin = path.join(root, 'bin');
await installCliEntrypoints(bin, path.resolve(__dirname, '../..'));
for (const name of ['ql', 'task']) {
const result = spawnSync(path.join(bin, name), ['--help'], {
env,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /task/);
}
});
test('internal rejection never loads remote credentials or local operators', () => {
const code = `process.stdout.write=()=>true;process.stderr.write=()=>true;
require('./cli/dist/ql').qlMain(['task','list','--json']).then(code=>{
if(code!==2) process.exit(1);
if(Object.keys(require.cache).some(p=>p.includes('/dist/internal/runtime/') || p.includes('/dist/remote/') || p.includes('/dist/remote/commands/auth') || p.includes('/dist/remote/auth/store'))) process.exit(2);
});`;
const result = spawnSync(process.execPath, ['-e', code], {
cwd: path.resolve(__dirname, '../../..'),
env: {
PATH: process.env.PATH,
QL_CLI_CONFIG: '/nonexistent-ql-unified-config',
QL_URL: 'https://must-not-be-contacted.invalid',
QL_ACCESS_TOKEN: 'test-only',
},
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
});