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,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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user