runspec-node 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -1
- package/dist/loader.js +4 -0
- package/dist/loader.js.map +1 -1
- package/dist/models.d.ts +4 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +3 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +61 -19
- package/dist/parser.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +44 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/errors.ts +48 -0
- package/src/loader.ts +4 -0
- package/src/models.ts +4 -0
- package/src/parser.ts +70 -20
- package/src/types.ts +51 -3
- package/tests/test_emit_schema.test.ts +32 -0
- package/tests/test_integration.test.ts +20 -0
- package/tests/test_loader.test.ts +33 -0
- package/tests/test_parser.test.ts +175 -1
- package/tests/test_types.test.ts +73 -0
package/src/errors.ts
CHANGED
|
@@ -41,6 +41,54 @@ export function formatOutOfRange(value: number, range: [number, number], name: s
|
|
|
41
41
|
].join('\n');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export function formatInvalidPattern(value: string, pattern: string, name: string): string {
|
|
45
|
+
return [
|
|
46
|
+
`✗ Invalid value for --${name}: ${JSON.stringify(value)}`,
|
|
47
|
+
` Expected: a value matching pattern ${JSON.stringify(pattern)}`,
|
|
48
|
+
` Got: ${JSON.stringify(value)}`,
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatTooShort(value: string, minLength: number, name: string): string {
|
|
53
|
+
return [
|
|
54
|
+
`✗ Value too short for --${name}: ${JSON.stringify(value)}`,
|
|
55
|
+
` Expected: at least ${minLength} character${minLength === 1 ? '' : 's'}`,
|
|
56
|
+
` Got: ${value.length}`,
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatTooLong(value: string, maxLength: number, name: string): string {
|
|
61
|
+
return [
|
|
62
|
+
`✗ Value too long for --${name}: ${JSON.stringify(value)}`,
|
|
63
|
+
` Expected: at most ${maxLength} character${maxLength === 1 ? '' : 's'}`,
|
|
64
|
+
` Got: ${value.length}`,
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatInvalidItems(
|
|
69
|
+
name: string,
|
|
70
|
+
total: number,
|
|
71
|
+
failures: Array<[number, unknown, string]>,
|
|
72
|
+
): string {
|
|
73
|
+
const header = `--${name}: ${failures.length} of ${total} item(s) failed validation:`;
|
|
74
|
+
const blocks = failures.map(([index, value, reason]) => {
|
|
75
|
+
const detail = reason
|
|
76
|
+
.split('\n')
|
|
77
|
+
.map((line) => ' ' + line)
|
|
78
|
+
.join('\n');
|
|
79
|
+
return ` • item ${index} (${JSON.stringify(value)}):\n${detail}`;
|
|
80
|
+
});
|
|
81
|
+
return header + '\n\n' + blocks.join('\n\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatMissingCommand(runnablePath: string, availableCommands: string[]): string {
|
|
85
|
+
return [
|
|
86
|
+
`✗ '${runnablePath}' requires a command.`,
|
|
87
|
+
` Available commands: ${availableCommands.join(', ')}`,
|
|
88
|
+
`\n Run '${runnablePath} <command> --help' for details.`,
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
44
92
|
export function formatUnknownArg(name: string, knownArgs: string[]): string {
|
|
45
93
|
const lines = [
|
|
46
94
|
`✗ Unknown argument: --${name}`,
|
package/src/loader.ts
CHANGED
|
@@ -58,6 +58,7 @@ function normaliseScript(name: string, raw: Record<string, unknown>): ScriptSpec
|
|
|
58
58
|
autonomy: raw['autonomy'] as string | undefined,
|
|
59
59
|
autonomyReason: raw['autonomy-reason'] as string | undefined,
|
|
60
60
|
output: raw['output'] as string | undefined,
|
|
61
|
+
requireCommand: (raw['require-command'] as boolean | undefined) ?? false,
|
|
61
62
|
args: normaliseArgs((raw['args'] ?? {}) as Record<string, unknown>),
|
|
62
63
|
groups: normaliseGroups((raw['groups'] ?? {}) as Record<string, unknown>),
|
|
63
64
|
commands: normaliseCommands((raw['commands'] ?? {}) as Record<string, Record<string, unknown>>),
|
|
@@ -84,6 +85,9 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
|
|
|
84
85
|
description: raw['description'] as string | undefined,
|
|
85
86
|
options: raw['options'] as string[] | undefined,
|
|
86
87
|
range: raw['range'] as [number, number] | undefined,
|
|
88
|
+
pattern: raw['pattern'] as string | undefined,
|
|
89
|
+
minLength: raw['min-length'] as number | undefined,
|
|
90
|
+
maxLength: raw['max-length'] as number | undefined,
|
|
87
91
|
multiple: (raw['multiple'] as boolean | undefined) ?? false,
|
|
88
92
|
delimiter: raw['delimiter'] as string | undefined,
|
|
89
93
|
short: raw['short'] as string | undefined,
|
package/src/models.ts
CHANGED
|
@@ -19,6 +19,9 @@ export interface ArgSpec {
|
|
|
19
19
|
description?: string;
|
|
20
20
|
options?: string[];
|
|
21
21
|
range?: [number, number];
|
|
22
|
+
pattern?: string; // regex the value must fully match (str only)
|
|
23
|
+
minLength?: number; // minimum string length (str only)
|
|
24
|
+
maxLength?: number; // maximum string length (str only)
|
|
22
25
|
multiple?: boolean;
|
|
23
26
|
delimiter?: string;
|
|
24
27
|
short?: string;
|
|
@@ -48,6 +51,7 @@ export interface ScriptSpec {
|
|
|
48
51
|
autonomyReason?: string;
|
|
49
52
|
output?: string;
|
|
50
53
|
serve?: boolean | string[];
|
|
54
|
+
requireCommand?: boolean;
|
|
51
55
|
args: Record<string, ArgSpec>;
|
|
52
56
|
groups: Record<string, GroupSpec>;
|
|
53
57
|
commands: Record<string, ScriptSpec>;
|
package/src/parser.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { loadRaw } from './loader';
|
|
|
4
4
|
import { inferScript, effectiveAutonomy } from './inference';
|
|
5
5
|
import { coerce } from './types';
|
|
6
6
|
import { validateArgs, validateGroups, raiseIfErrors } from './validator';
|
|
7
|
-
import { RunSpecError } from './errors';
|
|
7
|
+
import { RunSpecError, formatMissingCommand } from './errors';
|
|
8
8
|
import { configureLogging } from './logging_setup';
|
|
9
9
|
import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
|
|
10
10
|
|
|
@@ -13,10 +13,12 @@ export interface ParseOptions {
|
|
|
13
13
|
argv?: string[];
|
|
14
14
|
cwd?: string;
|
|
15
15
|
configPath?: string;
|
|
16
|
+
/** Internal: enforce `require-command` (real CLI parsing). loadSpec sets false. */
|
|
17
|
+
_enforceRequiredCommand?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
19
|
-
const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride } = opts;
|
|
21
|
+
const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride, _enforceRequiredCommand = true } = opts;
|
|
20
22
|
|
|
21
23
|
const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
|
|
22
24
|
const raw = loadRaw(configPath);
|
|
@@ -76,18 +78,35 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
76
78
|
let activeScript = rawScript;
|
|
77
79
|
let commandPath: string[] = [];
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Walk into nested subcommands as long as argv[0] matches a declared command.
|
|
82
|
+
while (argv.length > 0) {
|
|
83
|
+
const cmds = activeScript.commands ?? {};
|
|
84
|
+
if (Object.keys(cmds).length === 0 || !(argv[0] in cmds)) break;
|
|
85
|
+
commandPath.push(argv[0]);
|
|
86
|
+
activeScript = cmds[argv[0]];
|
|
83
87
|
argv = argv.slice(1);
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
87
|
-
printHelp(name, activeScript, commandPath
|
|
91
|
+
printHelp(name, activeScript, commandPath);
|
|
88
92
|
process.exit(0);
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
// Enforce require-command on the resolved leaf: if the deepest matched node
|
|
96
|
+
// still requires a command and has commands, none was chosen at that level.
|
|
97
|
+
// Checking the leaf (not commandPath.length) enforces at every nesting depth.
|
|
98
|
+
// Skipped by loadSpec — introspection/emit must not be blocked. Runs after
|
|
99
|
+
// --help so `<name> --help` still lists the commands.
|
|
100
|
+
if (
|
|
101
|
+
_enforceRequiredCommand &&
|
|
102
|
+
activeScript.requireCommand &&
|
|
103
|
+
Object.keys(activeScript.commands ?? {}).length > 0
|
|
104
|
+
) {
|
|
105
|
+
throw new RunSpecError(
|
|
106
|
+
formatMissingCommand([name, ...commandPath].join(' '), Object.keys(activeScript.commands)),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
let parsedValues = parseArgv(argv, activeScript.args ?? {});
|
|
92
111
|
parsedValues = applyEnv(parsedValues, activeScript.args ?? {}, name);
|
|
93
112
|
parsedValues = applyDefaults(parsedValues, activeScript.args ?? {});
|
|
@@ -142,7 +161,7 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
export function loadSpec(opts: ParseOptions = {}): ParsedArgs {
|
|
145
|
-
return parse({ ...opts, argv: [] });
|
|
164
|
+
return parse({ ...opts, argv: [], _enforceRequiredCommand: false });
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
function inferFromArgv(): string {
|
|
@@ -295,28 +314,56 @@ function coerceValues(parsed: Record<string, unknown>, argSpecs: Record<string,
|
|
|
295
314
|
return result;
|
|
296
315
|
}
|
|
297
316
|
|
|
298
|
-
export function printHelp(name: string, script: ScriptSpec,
|
|
299
|
-
const fullName =
|
|
317
|
+
export function printHelp(name: string, script: ScriptSpec, commandPath: string[] = []): void {
|
|
318
|
+
const fullName = [name, ...commandPath].join(' ');
|
|
300
319
|
const args = script.args ?? {};
|
|
320
|
+
const commands = script.commands ?? {};
|
|
301
321
|
|
|
302
|
-
|
|
303
|
-
|
|
322
|
+
// Partition args into flags, positionals, and rest (mirrors Python's _print_help).
|
|
323
|
+
const entries = Object.entries(args);
|
|
324
|
+
const positionalArgs = entries
|
|
325
|
+
.filter(([, s]) => s.position !== undefined)
|
|
326
|
+
.sort(([, a], [, b]) => (a.position ?? 0) - (b.position ?? 0));
|
|
327
|
+
const restArgs = entries.filter(([, s]) => s.type === 'rest');
|
|
328
|
+
const flagArgs = entries.filter(([, s]) => s.position === undefined && s.type !== 'rest');
|
|
329
|
+
|
|
330
|
+
// Choices render their options inline; other types render as <type>.
|
|
331
|
+
const argToken = (spec: typeof args[string]): string =>
|
|
332
|
+
spec.options ? `<${spec.options.join('|')}>` : `<${spec.type ?? 'str'}>`;
|
|
333
|
+
|
|
334
|
+
// Usage line — order: name [flags] [positionals] [<command>] [-- <rest>...]
|
|
335
|
+
// Rest stays last because '--' terminates argument parsing.
|
|
336
|
+
const usageParts: string[] = [fullName];
|
|
337
|
+
for (const [argName, spec] of flagArgs) {
|
|
304
338
|
const flag = `--${argName}`;
|
|
305
|
-
if (spec.type === 'flag') {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
339
|
+
if (spec.type === 'flag') usageParts.push(`[${flag}]`);
|
|
340
|
+
else if (spec.required) usageParts.push(`${flag} ${argToken(spec)}`);
|
|
341
|
+
else usageParts.push(`[${flag} ${argToken(spec)}]`);
|
|
342
|
+
}
|
|
343
|
+
for (const [argName, spec] of positionalArgs) {
|
|
344
|
+
usageParts.push(spec.required ? `<${argName}>` : `[<${argName}>]`);
|
|
345
|
+
}
|
|
346
|
+
if (Object.keys(commands).length > 0) usageParts.push('<command>');
|
|
347
|
+
for (const [argName] of restArgs) {
|
|
348
|
+
usageParts.push(`[-- <${argName}>...]`);
|
|
312
349
|
}
|
|
313
350
|
|
|
314
351
|
console.log(`Usage: ${usageParts.join(' ')}`);
|
|
315
352
|
if (script.description) console.log(`\n${script.description}`);
|
|
316
353
|
|
|
354
|
+
// Commands section
|
|
355
|
+
if (Object.keys(commands).length > 0) {
|
|
356
|
+
console.log(script.requireCommand ? '\nCommands (required):' : '\nCommands:');
|
|
357
|
+
const cmdCol = Math.max(...Object.keys(commands).map((c) => c.length)) + 2;
|
|
358
|
+
for (const [cmdName, cmdSpec] of Object.entries(commands)) {
|
|
359
|
+
const desc = cmdSpec.description ?? '';
|
|
360
|
+
console.log(` ${cmdName.padEnd(cmdCol)} ${desc}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
317
364
|
if (Object.keys(args).length > 0) {
|
|
318
365
|
console.log('\nArguments:');
|
|
319
|
-
for (const [argName, spec] of
|
|
366
|
+
for (const [argName, spec] of entries) {
|
|
320
367
|
const flag = ` --${argName}`;
|
|
321
368
|
const parts: string[] = [];
|
|
322
369
|
if (spec.type === 'flag') parts.push('flag');
|
|
@@ -335,5 +382,8 @@ export function printHelp(name: string, script: ScriptSpec, command?: string): v
|
|
|
335
382
|
if (script.autonomyReason) console.log(` ${script.autonomyReason}`);
|
|
336
383
|
}
|
|
337
384
|
|
|
385
|
+
if (Object.keys(commands).length > 0) {
|
|
386
|
+
console.log(`\nRun '${fullName} <command> --help' for focused help on a command.`);
|
|
387
|
+
}
|
|
338
388
|
console.log('\n -h, --help Show this message and exit');
|
|
339
389
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
2
|
import type { ArgSpec } from './models';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatInvalidChoice,
|
|
5
|
+
formatInvalidPattern,
|
|
6
|
+
formatTooShort,
|
|
7
|
+
formatTooLong,
|
|
8
|
+
formatInvalidItems,
|
|
9
|
+
} from './errors';
|
|
4
10
|
|
|
5
11
|
export type TypeCoercer = (value: unknown, spec: ArgSpec) => unknown;
|
|
6
12
|
|
|
@@ -18,6 +24,25 @@ export function coerce(value: unknown, spec: ArgSpec): unknown {
|
|
|
18
24
|
`Unknown type '${typeName}' for argument '${spec.name}'. Registered types: ${[...registry.keys()].sort().join(', ')}\nRegister custom types with registerType().`,
|
|
19
25
|
);
|
|
20
26
|
}
|
|
27
|
+
// Multiple-valued args coerce and validate each item independently and return
|
|
28
|
+
// a list. The arg's type (and its pattern / length / range / choice checks)
|
|
29
|
+
// applies per item. `rest` manages its own list, so it is excluded.
|
|
30
|
+
if (spec.multiple && typeName !== 'rest') {
|
|
31
|
+
const items = Array.isArray(value) ? value : [value];
|
|
32
|
+
const out: unknown[] = [];
|
|
33
|
+
const failures: Array<[number, unknown, string]> = [];
|
|
34
|
+
items.forEach((item, i) => {
|
|
35
|
+
try {
|
|
36
|
+
out.push(coercer(item, spec));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
failures.push([i + 1, item, (e as Error).message]);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
if (failures.length > 0) {
|
|
42
|
+
throw new Error(formatInvalidItems(spec.name ?? '?', items.length, failures));
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
21
46
|
try {
|
|
22
47
|
return coercer(value, spec);
|
|
23
48
|
} catch (e) {
|
|
@@ -31,8 +56,11 @@ export function listTypes(): string[] {
|
|
|
31
56
|
return [...registry.keys()].sort();
|
|
32
57
|
}
|
|
33
58
|
|
|
34
|
-
function coerceStr(value: unknown): string {
|
|
35
|
-
|
|
59
|
+
function coerceStr(value: unknown, spec: ArgSpec): string {
|
|
60
|
+
const coerced = String(value);
|
|
61
|
+
checkLength(coerced, spec);
|
|
62
|
+
checkPattern(coerced, spec);
|
|
63
|
+
return coerced;
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
function coerceInt(value: unknown, spec: ArgSpec): number {
|
|
@@ -87,6 +115,26 @@ function checkRange(value: number, spec: ArgSpec): void {
|
|
|
87
115
|
}
|
|
88
116
|
}
|
|
89
117
|
|
|
118
|
+
// String-only validation (str coercer). Mirrors Python's _check_length/_check_pattern.
|
|
119
|
+
function checkLength(value: string, spec: ArgSpec): void {
|
|
120
|
+
if (spec.minLength !== undefined && value.length < spec.minLength) {
|
|
121
|
+
throw new Error(formatTooShort(value, spec.minLength, spec.name ?? '?'));
|
|
122
|
+
}
|
|
123
|
+
if (spec.maxLength !== undefined && value.length > spec.maxLength) {
|
|
124
|
+
throw new Error(formatTooLong(value, spec.maxLength, spec.name ?? '?'));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkPattern(value: string, spec: ArgSpec): void {
|
|
129
|
+
if (spec.pattern === undefined) return;
|
|
130
|
+
// (?:…) anchoring reproduces Python's re.fullmatch even when the pattern
|
|
131
|
+
// contains a top-level alternation (plain ^p$ would mis-bind `a|b`).
|
|
132
|
+
const regex = new RegExp(`^(?:${spec.pattern})$`);
|
|
133
|
+
if (!regex.test(value)) {
|
|
134
|
+
throw new Error(formatInvalidPattern(value, spec.pattern, spec.name ?? '?'));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
registerType('str', coerceStr);
|
|
91
139
|
registerType('int', coerceInt);
|
|
92
140
|
registerType('float', coerceFloat);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { loadRaw } from '../src/loader';
|
|
5
|
+
import { inferScript } from '../src/inference';
|
|
6
|
+
import { buildSchema } from '../src/cli';
|
|
7
|
+
|
|
8
|
+
function schemaFor(toml: string, name: string): Record<string, unknown> {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-emit-test-'));
|
|
10
|
+
const file = path.join(dir, 'runspec.toml');
|
|
11
|
+
fs.writeFileSync(file, toml);
|
|
12
|
+
const raw = loadRaw(file);
|
|
13
|
+
const script = inferScript(raw.runnables[name], raw.config.autonomyDefault);
|
|
14
|
+
return buildSchema(name, script, 'mcp');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('string validation fields emit to JSON Schema', () => {
|
|
18
|
+
const schema = schemaFor(
|
|
19
|
+
`
|
|
20
|
+
[greet]
|
|
21
|
+
[greet.args]
|
|
22
|
+
slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
|
|
23
|
+
`,
|
|
24
|
+
'greet',
|
|
25
|
+
);
|
|
26
|
+
const props = (schema['inputSchema'] as Record<string, Record<string, unknown>>)['properties'];
|
|
27
|
+
const slug = (props as Record<string, Record<string, unknown>>)['slug'];
|
|
28
|
+
expect(slug['type']).toBe('string');
|
|
29
|
+
expect(slug['pattern']).toBe('[a-z]+-[0-9]+');
|
|
30
|
+
expect(slug['minLength']).toBe(3);
|
|
31
|
+
expect(slug['maxLength']).toBe(10);
|
|
32
|
+
});
|
|
@@ -171,6 +171,26 @@ describe('complex.toml', () => {
|
|
|
171
171
|
const run = inferred.commands['run'];
|
|
172
172
|
expect(run.autonomyReason).toBe('Writes output files and may call external APIs');
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
test('run subcommand: label has pattern / min-length / max-length', () => {
|
|
176
|
+
const raw = loadRaw(COMPLEX);
|
|
177
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
178
|
+
const label = inferred.commands['run'].args['label'];
|
|
179
|
+
expect(label.type).toBe('str');
|
|
180
|
+
expect(label.pattern).toBe('[a-z][a-z0-9-]+');
|
|
181
|
+
expect(label.minLength).toBe(3);
|
|
182
|
+
expect(label.maxLength).toBe(32);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('db runnable: require-command with subcommands', () => {
|
|
186
|
+
const raw = loadRaw(COMPLEX);
|
|
187
|
+
const db = raw.runnables['db'];
|
|
188
|
+
expect(db.requireCommand).toBe(true);
|
|
189
|
+
expect(db.commands['migrate']).toBeDefined();
|
|
190
|
+
expect(db.commands['seed']).toBeDefined();
|
|
191
|
+
// Defaults to false where unset.
|
|
192
|
+
expect(raw.runnables['pipeline'].requireCommand).toBe(false);
|
|
193
|
+
});
|
|
174
194
|
});
|
|
175
195
|
|
|
176
196
|
// ── cross-fixture: inference rules ────────────────────────────────────────────
|
|
@@ -106,6 +106,39 @@ workers = {default = 4, range = [1, 32]}
|
|
|
106
106
|
expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('normalises pattern / min-length / max-length fields', () => {
|
|
110
|
+
const dir = tmpDir();
|
|
111
|
+
const file = path.join(dir, 'runspec.toml');
|
|
112
|
+
fs.writeFileSync(file, `
|
|
113
|
+
[greet]
|
|
114
|
+
description = "hi"
|
|
115
|
+
|
|
116
|
+
[greet.args]
|
|
117
|
+
slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
|
|
118
|
+
`);
|
|
119
|
+
const raw = loadRaw(file);
|
|
120
|
+
const slug = raw.runnables['greet'].args['slug'];
|
|
121
|
+
expect(slug.pattern).toBe('[a-z]+-[0-9]+');
|
|
122
|
+
expect(slug.minLength).toBe(3);
|
|
123
|
+
expect(slug.maxLength).toBe(10);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('normalises require-command on a runnable', () => {
|
|
127
|
+
const dir = tmpDir();
|
|
128
|
+
const file = path.join(dir, 'runspec.toml');
|
|
129
|
+
fs.writeFileSync(file, `
|
|
130
|
+
[db]
|
|
131
|
+
require-command = true
|
|
132
|
+
|
|
133
|
+
[db.commands.migrate]
|
|
134
|
+
description = "run migrations"
|
|
135
|
+
`);
|
|
136
|
+
const raw = loadRaw(file);
|
|
137
|
+
expect(raw.runnables['db'].requireCommand).toBe(true);
|
|
138
|
+
// Defaults to false when absent.
|
|
139
|
+
expect(raw.runnables['db'].commands['migrate'].requireCommand).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
109
142
|
test('normalises group fields', () => {
|
|
110
143
|
const dir = tmpDir();
|
|
111
144
|
const file = path.join(dir, 'runspec.toml');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { parse } from '../src/parser';
|
|
4
|
+
import { parse, loadSpec } from '../src/parser';
|
|
5
5
|
|
|
6
6
|
function makeTmpConfig(toml: string): string {
|
|
7
7
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
|
|
@@ -65,3 +65,177 @@ describe('env resolution', () => {
|
|
|
65
65
|
expect(args['quality']).toBe(85);
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// ── help display ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('help display', () => {
|
|
72
|
+
let logSpy: jest.SpyInstance;
|
|
73
|
+
let exitSpy: jest.SpyInstance;
|
|
74
|
+
let output: string[];
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
output = [];
|
|
78
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
|
|
79
|
+
output.push(args.join(' '));
|
|
80
|
+
});
|
|
81
|
+
exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
|
82
|
+
throw new Error('__exit__');
|
|
83
|
+
}) as never);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
logSpy.mockRestore();
|
|
88
|
+
exitSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const runHelp = (toml: string, name: string, argv: string[]): string => {
|
|
92
|
+
const configPath = makeTmpConfig(toml);
|
|
93
|
+
try {
|
|
94
|
+
parse({ scriptName: name, argv, configPath });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if ((e as Error).message !== '__exit__') throw e;
|
|
97
|
+
}
|
|
98
|
+
return output.join('\n');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
test('usage line renders <command> after parent args', () => {
|
|
102
|
+
const toml = `
|
|
103
|
+
[pipeline]
|
|
104
|
+
description = "Run a pipeline"
|
|
105
|
+
[pipeline.args]
|
|
106
|
+
verbose = {type = "flag"}
|
|
107
|
+
config = {type = "path"}
|
|
108
|
+
[pipeline.commands.run]
|
|
109
|
+
description = "Run it"
|
|
110
|
+
`;
|
|
111
|
+
const out = runHelp(toml, 'pipeline', ['--help']);
|
|
112
|
+
const usage = out.split('\n').find((l) => l.startsWith('Usage:'))!;
|
|
113
|
+
expect(usage.indexOf('--verbose')).toBeLessThan(usage.indexOf('<command>'));
|
|
114
|
+
expect(usage.indexOf('--config')).toBeLessThan(usage.indexOf('<command>'));
|
|
115
|
+
expect(usage.trimEnd().endsWith('<command>')).toBe(true);
|
|
116
|
+
expect(out).toContain('Commands:');
|
|
117
|
+
expect(out).toContain('run');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('nested subcommands resolve to full path in usage', () => {
|
|
121
|
+
const toml = `
|
|
122
|
+
[outer]
|
|
123
|
+
[outer.commands.inner]
|
|
124
|
+
[outer.commands.inner.commands.deep]
|
|
125
|
+
description = "Deepest"
|
|
126
|
+
[outer.commands.inner.commands.deep.args]
|
|
127
|
+
bar = {type = "str", required = true}
|
|
128
|
+
`;
|
|
129
|
+
const out = runHelp(toml, 'outer', ['inner', 'deep', '--help']);
|
|
130
|
+
expect(out).toContain('Usage: outer inner deep');
|
|
131
|
+
expect(out).toContain('--bar');
|
|
132
|
+
expect(out).toContain('Deepest');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('choice options render inline in usage', () => {
|
|
136
|
+
const toml = `
|
|
137
|
+
[greet]
|
|
138
|
+
[greet.args]
|
|
139
|
+
fmt = {type = "choice", options = ["text", "json", "xml"], default = "text"}
|
|
140
|
+
`;
|
|
141
|
+
const out = runHelp(toml, 'greet', ['--help']);
|
|
142
|
+
expect(out).toContain('[--fmt <text|json|xml>]');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('usage places <command> before -- <rest>', () => {
|
|
146
|
+
const toml = `
|
|
147
|
+
[multi]
|
|
148
|
+
[multi.args]
|
|
149
|
+
host = {type = "str", position = 1}
|
|
150
|
+
extra = {type = "rest"}
|
|
151
|
+
[multi.commands.run]
|
|
152
|
+
description = "Run it"
|
|
153
|
+
`;
|
|
154
|
+
const out = runHelp(toml, 'multi', ['--help']);
|
|
155
|
+
const usage = out.split('\n').find((l) => l.startsWith('Usage:'))!;
|
|
156
|
+
expect(usage.indexOf('<host>')).toBeLessThan(usage.indexOf('<command>'));
|
|
157
|
+
expect(usage.indexOf('<command>')).toBeLessThan(usage.indexOf('-- <extra>'));
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── require-command ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('require-command', () => {
|
|
164
|
+
const DB_TOML = `
|
|
165
|
+
[db]
|
|
166
|
+
require-command = true
|
|
167
|
+
[db.commands.migrate]
|
|
168
|
+
[db.commands.seed]
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
test('no command errors and lists commands', () => {
|
|
172
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
173
|
+
let msg = '';
|
|
174
|
+
try {
|
|
175
|
+
parse({ scriptName: 'db', argv: [], configPath });
|
|
176
|
+
throw new Error('expected parse to throw');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
msg = (e as Error).message;
|
|
179
|
+
}
|
|
180
|
+
expect(msg).toContain('requires a command');
|
|
181
|
+
expect(msg).toContain('migrate');
|
|
182
|
+
expect(msg).toContain('seed');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('valid command passes', () => {
|
|
186
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
187
|
+
const args = parse({ scriptName: 'db', argv: ['migrate'], configPath });
|
|
188
|
+
expect(args.runspec_command_path).toEqual(['migrate']);
|
|
189
|
+
expect(args.runspec_command).toBe('migrate');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('nested requirement enforced at each depth', () => {
|
|
193
|
+
const toml = `
|
|
194
|
+
[app]
|
|
195
|
+
require-command = true
|
|
196
|
+
[app.commands.db]
|
|
197
|
+
require-command = true
|
|
198
|
+
[app.commands.db.commands.migrate]
|
|
199
|
+
`;
|
|
200
|
+
const configPath = makeTmpConfig(toml);
|
|
201
|
+
// Choosing the intermediate command isn't enough — db itself requires one.
|
|
202
|
+
expect(() => parse({ scriptName: 'app', argv: ['db'], configPath })).toThrow(/requires a command/);
|
|
203
|
+
// Going all the way down satisfies it.
|
|
204
|
+
const args = parse({ scriptName: 'app', argv: ['db', 'migrate'], configPath });
|
|
205
|
+
expect(args.runspec_command_path).toEqual(['db', 'migrate']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('backward compatible: commands without require-command run at root', () => {
|
|
209
|
+
const toml = `
|
|
210
|
+
[tool]
|
|
211
|
+
[tool.commands.run]
|
|
212
|
+
`;
|
|
213
|
+
const configPath = makeTmpConfig(toml);
|
|
214
|
+
const args = parse({ scriptName: 'tool', argv: [], configPath });
|
|
215
|
+
expect(args.runspec_command_path).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('loadSpec bypasses enforcement', () => {
|
|
219
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
220
|
+
const spec = loadSpec({ scriptName: 'db', configPath });
|
|
221
|
+
expect(spec.runspec_command_path).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('--help still works at a required level', () => {
|
|
225
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
226
|
+
const output: string[] = [];
|
|
227
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation((...a) => { output.push(a.join(' ')); });
|
|
228
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { throw new Error('__exit__'); }) as never);
|
|
229
|
+
try {
|
|
230
|
+
parse({ scriptName: 'db', argv: ['--help'], configPath });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if ((e as Error).message !== '__exit__') throw e;
|
|
233
|
+
} finally {
|
|
234
|
+
logSpy.mockRestore();
|
|
235
|
+
exitSpy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
const out = output.join('\n');
|
|
238
|
+
expect(out).toContain('Commands (required):');
|
|
239
|
+
expect(out).toContain('migrate');
|
|
240
|
+
});
|
|
241
|
+
});
|