runspec-node 0.26.1 → 0.28.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/src/testing.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * runspec-node/testing — test helpers for runspec runnables.
3
+ *
4
+ * The Node port of Python's `runspec.testing`. Import it from the subpath
5
+ * export so it stays out of the main entry:
6
+ *
7
+ * import { ParseHarness } from 'runspec-node/testing';
8
+ *
9
+ * `parse()` already takes `argv` and `configPath`, so testing a runnable never
10
+ * requires monkeypatching `process.argv` or mocking the parser. `ParseHarness`
11
+ * removes the remaining boilerplate — pointing `parse()` at a spec, asserting on
12
+ * clean exits without the process actually exiting, and walking the subcommand
13
+ * tree to auto-cover every command.
14
+ *
15
+ * Node specifics this handles for you: `parse()` calls `process.exit(0)` on
16
+ * `--help` and *throws* `RunSpecError` on validation/require-command errors. The
17
+ * harness intercepts `process.exit` (so `--help` doesn't kill your test runner)
18
+ * and maps a thrown `RunSpecError` to a non-zero "exit", unifying both under one
19
+ * exit-code abstraction that mirrors Python's `SystemExit`-based helpers.
20
+ */
21
+
22
+ import * as fs from 'fs';
23
+ import * as os from 'os';
24
+ import * as path from 'path';
25
+ import { parse } from './parser';
26
+ import { loadRaw } from './loader';
27
+ import { inferScript } from './inference';
28
+ import { RunSpecError } from './errors';
29
+ import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
30
+
31
+ export interface ParseHarnessOptions {
32
+ scriptName: string;
33
+ /** An existing runspec.toml (a fixture, or your package's real spec). */
34
+ configPath?: string;
35
+ /** An inline TOML string written to a throwaway temp file. */
36
+ toml?: string;
37
+ /** Pin the per-run summary off during a parse (default true). */
38
+ suppressSummary?: boolean;
39
+ }
40
+
41
+ export interface ExpectExitOptions {
42
+ /** Expected exit code; `null` accepts any non-zero. Default 1; use 0 for --help. */
43
+ code?: number | null;
44
+ /** Substring(s) the captured output must include. */
45
+ contains?: string | string[];
46
+ }
47
+
48
+ export interface SmokeOptions {
49
+ /** Arg-name → value map used to build valid invocations for each leaf. */
50
+ values?: Record<string, unknown>;
51
+ /** Assert `--help` exits 0 for the runnable and every subcommand (default true). */
52
+ checkHelp?: boolean;
53
+ }
54
+
55
+ /** Thrown in place of `process.exit()` so a parse that exits can be caught. */
56
+ class ExitSignal extends Error {
57
+ constructor(public exitCode: number) {
58
+ super(`process.exit(${exitCode})`);
59
+ }
60
+ }
61
+
62
+ export class ParseHarness {
63
+ readonly scriptName: string;
64
+ readonly configPath: string;
65
+ private readonly suppressSummary: boolean;
66
+ private tmpDir: string | null = null;
67
+ private readonly spec: ScriptSpec;
68
+
69
+ constructor(opts: ParseHarnessOptions) {
70
+ const { scriptName, configPath, toml, suppressSummary = true } = opts;
71
+ if ((configPath == null) === (toml == null)) {
72
+ throw new Error('pass exactly one of configPath or toml');
73
+ }
74
+
75
+ this.scriptName = scriptName;
76
+ this.suppressSummary = suppressSummary;
77
+
78
+ let cfgPath = configPath;
79
+ if (toml != null) {
80
+ this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-test-'));
81
+ cfgPath = path.join(this.tmpDir, 'runspec.toml');
82
+ fs.writeFileSync(cfgPath, toml, 'utf-8');
83
+ }
84
+ this.configPath = path.resolve(cfgPath as string);
85
+
86
+ try {
87
+ const raw = loadRaw(this.configPath);
88
+ if (!(scriptName in raw.runnables)) {
89
+ const have = Object.keys(raw.runnables).sort().join(', ') || '(none)';
90
+ throw new Error(`runnable '${scriptName}' not in ${this.configPath} (have: ${have})`);
91
+ }
92
+ // Infer so `required` is resolved from defaults/types (raw leaves it unset).
93
+ this.spec = inferScript(raw.runnables[scriptName], raw.config.autonomyDefault);
94
+ } catch (e) {
95
+ // Tear down the temp dir synchronously on a failed construction.
96
+ this.close();
97
+ throw e;
98
+ }
99
+ }
100
+
101
+ // ── lifecycle ─────────────────────────────────────────────────────────────
102
+
103
+ close(): void {
104
+ if (this.tmpDir != null) {
105
+ fs.rmSync(this.tmpDir, { recursive: true, force: true });
106
+ this.tmpDir = null;
107
+ }
108
+ }
109
+
110
+ // ── core parse entry points ───────────────────────────────────────────────
111
+
112
+ parse(argv: string[]): ParsedArgs {
113
+ const { code, output, parsed } = this.runParse(argv);
114
+ if (parsed === null) {
115
+ throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to parse, but it exited ${code}:\n${output}`);
116
+ }
117
+ return parsed;
118
+ }
119
+
120
+ expectOk(argv: string[]): ParsedArgs {
121
+ return this.parse(argv);
122
+ }
123
+
124
+ expectExit(argv: string[], opts: ExpectExitOptions = {}): string {
125
+ const { code = 1, contains } = opts;
126
+ const res = this.runParse(argv);
127
+ if (res.code === null) {
128
+ throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to exit, but it parsed cleanly`);
129
+ }
130
+ if (code !== null && res.code !== code) {
131
+ throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: expected exit ${code}, got ${res.code}:\n${res.output}`);
132
+ }
133
+ if (contains != null) {
134
+ const needles = Array.isArray(contains) ? contains : [contains];
135
+ for (const needle of needles) {
136
+ if (!res.output.includes(needle)) {
137
+ throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: ${JSON.stringify(needle)} not found in output:\n${res.output}`);
138
+ }
139
+ }
140
+ }
141
+ return res.output;
142
+ }
143
+
144
+ // ── spec introspection ────────────────────────────────────────────────────
145
+
146
+ commandPaths(): string[][] {
147
+ const paths: string[][] = [];
148
+ const walk = (node: ScriptSpec, prefix: string[]): void => {
149
+ for (const [name, sub] of Object.entries(node.commands ?? {})) {
150
+ const here = [...prefix, name];
151
+ paths.push(here);
152
+ walk(sub, here);
153
+ }
154
+ };
155
+ walk(this.spec, []);
156
+ return paths;
157
+ }
158
+
159
+ leafCommandPaths(): string[][] {
160
+ return this.commandPaths().filter((p) => Object.keys(this.node(p).commands ?? {}).length === 0);
161
+ }
162
+
163
+ argsFor(p: string[] = []): Record<string, ArgSpec> {
164
+ return this.node(p).args ?? {};
165
+ }
166
+
167
+ requiredArgs(p: string[] = []): string[] {
168
+ return Object.entries(this.argsFor(p))
169
+ .filter(([, a]) => a.required)
170
+ .map(([name]) => name);
171
+ }
172
+
173
+ requiresCommand(p: string[] = []): boolean {
174
+ return Boolean(this.node(p).requireCommand);
175
+ }
176
+
177
+ // ── auto coverage ─────────────────────────────────────────────────────────
178
+
179
+ smoke(opts: SmokeOptions = {}): string[] {
180
+ const { values = {}, checkHelp = true } = opts;
181
+ const done: string[] = [];
182
+
183
+ if (checkHelp) {
184
+ this.expectExit(['--help'], { code: 0 });
185
+ done.push('--help');
186
+ }
187
+ if (this.requiresCommand()) {
188
+ this.expectExit([]);
189
+ done.push('(no command) -> require-command');
190
+ }
191
+
192
+ for (const p of this.commandPaths()) {
193
+ const label = p.join(' ');
194
+ const node = this.node(p);
195
+
196
+ if (checkHelp) {
197
+ this.expectExit([...p, '--help'], { code: 0 });
198
+ done.push(`${label} --help`);
199
+ }
200
+
201
+ if (node.requireCommand) {
202
+ this.expectExit(p);
203
+ done.push(`${label} -> require-command`);
204
+ continue;
205
+ }
206
+ if (Object.keys(node.commands ?? {}).length > 0) {
207
+ continue; // intermediate group without require-command
208
+ }
209
+
210
+ if (this.requiredArgs(p).length > 0) {
211
+ this.expectExit(p);
212
+ done.push(`${label} -> missing required`);
213
+ }
214
+
215
+ const built = this.buildValidArgv(p, values);
216
+ if (built !== null) {
217
+ this.expectOk([...p, ...built]);
218
+ done.push(`${label} (valid)`);
219
+ }
220
+ }
221
+
222
+ return done;
223
+ }
224
+
225
+ // ── internals ─────────────────────────────────────────────────────────────
226
+
227
+ private runParse(argv: string[]): { code: number | null; output: string; parsed: ParsedArgs | null } {
228
+ // Capture the *original* (unbound) handles so restoration preserves identity.
229
+ const realExit = process.exit;
230
+ const realOut = process.stdout.write;
231
+ const realErr = process.stderr.write;
232
+ const realLog = console.log;
233
+ const realErrLog = console.error;
234
+ let captured = '';
235
+
236
+ (process as unknown as { exit: (c?: number) => never }).exit = (c?: number): never => {
237
+ throw new ExitSignal(c ?? 0);
238
+ };
239
+ process.stdout.write = ((chunk: unknown): boolean => {
240
+ captured += String(chunk);
241
+ return true;
242
+ }) as typeof process.stdout.write;
243
+ process.stderr.write = ((chunk: unknown): boolean => {
244
+ captured += String(chunk);
245
+ return true;
246
+ }) as typeof process.stderr.write;
247
+ console.log = (...a: unknown[]): void => {
248
+ captured += a.map(String).join(' ') + '\n';
249
+ };
250
+ console.error = (...a: unknown[]): void => {
251
+ captured += a.map(String).join(' ') + '\n';
252
+ };
253
+
254
+ return this.withSummaryPinned(() => {
255
+ try {
256
+ const parsed = parse({
257
+ scriptName: this.scriptName,
258
+ argv,
259
+ configPath: this.configPath,
260
+ _promptSecrets: false,
261
+ });
262
+ return { code: null, output: captured, parsed };
263
+ } catch (e) {
264
+ if (e instanceof ExitSignal) {
265
+ return { code: e.exitCode, output: captured, parsed: null };
266
+ }
267
+ if (e instanceof RunSpecError) {
268
+ // Validation / require-command throw rather than exit; fold the
269
+ // message into the captured output so `contains:` can match on it.
270
+ return { code: 1, output: `${captured}${e.message}\n`, parsed: null };
271
+ }
272
+ throw e;
273
+ } finally {
274
+ process.exit = realExit;
275
+ process.stdout.write = realOut;
276
+ process.stderr.write = realErr;
277
+ console.log = realLog;
278
+ console.error = realErrLog;
279
+ }
280
+ });
281
+ }
282
+
283
+ private node(p: string[]): ScriptSpec {
284
+ let node = this.spec;
285
+ for (const part of p) node = (node.commands ?? {})[part];
286
+ return node;
287
+ }
288
+
289
+ private mergedArgs(p: string[]): Record<string, ArgSpec> {
290
+ const merged: Record<string, ArgSpec> = { ...this.argsFor([]) };
291
+ const acc: string[] = [];
292
+ for (const part of p) {
293
+ acc.push(part);
294
+ Object.assign(merged, this.argsFor(acc));
295
+ }
296
+ return merged;
297
+ }
298
+
299
+ private buildValidArgv(p: string[], values: Record<string, unknown>): string[] | null {
300
+ const argv: string[] = [];
301
+ for (const [name, a] of Object.entries(this.mergedArgs(p))) {
302
+ const supplied = values[name];
303
+ if (supplied === undefined || supplied === null) {
304
+ if (a.required) return null;
305
+ continue;
306
+ }
307
+ const flag = `--${name}`;
308
+ if (a.type === 'flag') {
309
+ if (supplied) argv.push(flag);
310
+ } else {
311
+ argv.push(flag, String(supplied));
312
+ }
313
+ }
314
+ return argv;
315
+ }
316
+
317
+ private withSummaryPinned<T>(fn: () => T): T {
318
+ if (!this.suppressSummary) return fn();
319
+ const prefix = this.scriptName.toUpperCase().replace(/-/g, '_');
320
+ const key = `RUNSPEC_${prefix}_ARG_NO_SUMMARY`;
321
+ const prev = process.env[key];
322
+ process.env[key] = '1';
323
+ try {
324
+ return fn();
325
+ } finally {
326
+ if (prev === undefined) delete process.env[key];
327
+ else process.env[key] = prev;
328
+ }
329
+ }
330
+ }
331
+
332
+ export function harness(scriptName: string, opts: Omit<ParseHarnessOptions, 'scriptName'> = {}): ParseHarness {
333
+ return new ParseHarness({ scriptName, ...opts });
334
+ }
package/src/types.ts CHANGED
@@ -63,6 +63,13 @@ function coerceStr(value: unknown, spec: ArgSpec): string {
63
63
  return coerced;
64
64
  }
65
65
 
66
+ // `password` coerces exactly like `str` (it is a string and honours
67
+ // pattern/min-length/max-length); the type exists to mark the value as secret so
68
+ // it is masked in UIs, refused on the command line, and kept out of logs.
69
+ function coercePassword(value: unknown, spec: ArgSpec): string {
70
+ return coerceStr(value, spec);
71
+ }
72
+
66
73
  function coerceInt(value: unknown, spec: ArgSpec): number {
67
74
  const n = Number(value);
68
75
  if (!Number.isFinite(n) || !Number.isInteger(n)) {
@@ -136,6 +143,7 @@ function checkPattern(value: string, spec: ArgSpec): void {
136
143
  }
137
144
 
138
145
  registerType('str', coerceStr);
146
+ registerType('password', coercePassword);
139
147
  registerType('int', coerceInt);
140
148
  registerType('float', coerceFloat);
141
149
  registerType('bool', coerceBool);
@@ -0,0 +1,134 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { cmdTest, testOne } from '../src/cli';
5
+
6
+ const GOOD_TOML = `
7
+ [config]
8
+ autonomy-default = "autonomous"
9
+
10
+ [greet]
11
+ description = "Greet"
12
+ [greet.args]
13
+ name = {type = "str", required = false, default = "world"}
14
+
15
+ [farewell]
16
+ description = "Bye"
17
+ [farewell.args]
18
+ name = {type = "str", required = false, default = "world"}
19
+ `;
20
+
21
+ const cleanups: string[] = [];
22
+ afterEach(() => {
23
+ while (cleanups.length) fs.rmSync(cleanups.pop()!, { recursive: true, force: true });
24
+ jest.restoreAllMocks();
25
+ });
26
+
27
+ /** Build a venv-shaped folder: runspec.toml + node_modules/.bin/<name> shims
28
+ * exiting with the given code (a shim of `null` means: don't create it). */
29
+ function folder(toml: string, shims: Record<string, number | null>): string {
30
+ const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'runspec-test-'));
31
+ cleanups.push(dir);
32
+ fs.writeFileSync(path.join(dir, 'runspec.toml'), toml);
33
+ const binDir = path.join(dir, 'node_modules', '.bin');
34
+ fs.mkdirSync(binDir, { recursive: true });
35
+ for (const [name, code] of Object.entries(shims)) {
36
+ if (code === null) continue;
37
+ const p = path.join(binDir, name);
38
+ fs.writeFileSync(p, `#!/bin/sh\nexit ${code}\n`);
39
+ fs.chmodSync(p, 0o755);
40
+ }
41
+ return dir;
42
+ }
43
+
44
+ function item(dir: string, runnable: string) {
45
+ return { source: path.join(dir, 'runspec.toml'), runnable, spec: {} as any };
46
+ }
47
+
48
+ // ── testOne (the per-runnable unit) ─────────────────────────────────────────
49
+
50
+ test('passes when spec smoke ok and entry point exits 0', () => {
51
+ const dir = folder(GOOD_TOML, { greet: 0 });
52
+ const r = testOne(item(dir, 'greet'));
53
+ expect(r.ok).toBe(true);
54
+ expect(r.checks.spec_smoke.ok).toBe(true);
55
+ expect(r.checks.entry_point.ok).toBe(true);
56
+ });
57
+
58
+ test('fails when the entry-point binary exits non-zero', () => {
59
+ const dir = folder(GOOD_TOML, { greet: 1 });
60
+ const r = testOne(item(dir, 'greet'));
61
+ expect(r.ok).toBe(false);
62
+ expect(r.checks.spec_smoke.ok).toBe(true); // spec is fine
63
+ expect(r.checks.entry_point.ok).toBe(false);
64
+ expect(r.checks.entry_point.error).toContain('exit 1');
65
+ });
66
+
67
+ test('fails when the entry point is missing', () => {
68
+ const dir = folder(GOOD_TOML, { greet: null });
69
+ const r = testOne(item(dir, 'greet'));
70
+ expect(r.ok).toBe(false);
71
+ expect(r.checks.entry_point.error).toContain('entry point not found');
72
+ });
73
+
74
+ test('fails spec smoke when the runnable is not in the TOML', () => {
75
+ const dir = folder(GOOD_TOML, { ghost: 0 });
76
+ const r = testOne(item(dir, 'ghost'));
77
+ expect(r.ok).toBe(false);
78
+ expect(r.checks.spec_smoke.ok).toBe(false);
79
+ expect(r.checks.spec_smoke.error).toContain('ghost');
80
+ });
81
+
82
+ // ── cmdTest (orchestration: exit code, filter, json) ────────────────────────
83
+
84
+ function runCmd(dir: string, args: string[]): { exitCode: number | undefined; out: string } {
85
+ const cwd = process.cwd();
86
+ let exitCode: number | undefined;
87
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((c?: number) => {
88
+ exitCode = c;
89
+ return undefined as never;
90
+ }) as never);
91
+ let out = '';
92
+ const logSpy = jest.spyOn(console, 'log').mockImplementation((...a: unknown[]) => {
93
+ out += a.map(String).join(' ') + '\n';
94
+ });
95
+ try {
96
+ process.chdir(dir);
97
+ cmdTest(args);
98
+ } finally {
99
+ process.chdir(cwd);
100
+ logSpy.mockRestore();
101
+ exitSpy.mockRestore();
102
+ }
103
+ return { exitCode, out };
104
+ }
105
+
106
+ test('all pass → no exit-1, summary reports passed', () => {
107
+ const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
108
+ const { exitCode, out } = runCmd(dir, []);
109
+ expect(exitCode).toBeUndefined();
110
+ expect(out).toContain('2 passed, 0 failed');
111
+ });
112
+
113
+ test('one broken runnable → exit 1', () => {
114
+ const dir = folder(GOOD_TOML, { greet: 1, farewell: 0 });
115
+ const { exitCode, out } = runCmd(dir, []);
116
+ expect(exitCode).toBe(1);
117
+ expect(out).toContain('1 passed, 1 failed');
118
+ });
119
+
120
+ test('--runnable filters to a single runnable', () => {
121
+ const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
122
+ const { out } = runCmd(dir, ['--runnable', 'greet']);
123
+ expect(out).toContain('1 passed');
124
+ expect(out).not.toContain('farewell');
125
+ });
126
+
127
+ test('--format json emits results + summary', () => {
128
+ const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
129
+ const { out } = runCmd(dir, ['--format', 'json']);
130
+ const payload = JSON.parse(out);
131
+ expect(payload.summary).toEqual({ total: 2, passed: 2, failed: 0 });
132
+ expect(payload.results).toHaveLength(2);
133
+ expect(Object.keys(payload.results[0].checks)).toEqual(['spec_smoke', 'entry_point']);
134
+ });
@@ -0,0 +1,106 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { parse, loadSpec } from '../src/parser';
5
+ import { loadRaw } from '../src/loader';
6
+ import { inferScript } from '../src/inference';
7
+ import { buildSchema } from '../src/cli';
8
+ import { coerce, listTypes } from '../src/types';
9
+ import type { ArgSpec } from '../src/models';
10
+
11
+ function makeTmpConfig(toml: string): string {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-pw-test-'));
13
+ const file = path.join(dir, 'runspec.toml');
14
+ fs.writeFileSync(file, toml);
15
+ return file;
16
+ }
17
+
18
+ const TOML = `
19
+ [deploy]
20
+ description = "Deploy"
21
+ [deploy.args.host]
22
+ type = "str"
23
+ hint = "web-01.example.com"
24
+ required = true
25
+ [deploy.args.secret]
26
+ type = "password"
27
+ description = "DB password"
28
+ `;
29
+
30
+ // ── coercion ──────────────────────────────────────────────────────────────────
31
+
32
+ test('password coerces like str', () => {
33
+ const spec = { name: 'secret', type: 'password' } as ArgSpec;
34
+ expect(coerce('hunter2', spec)).toBe('hunter2');
35
+ });
36
+
37
+ test('password honours min-length', () => {
38
+ const spec = { name: 'secret', type: 'password', minLength: 8 } as ArgSpec;
39
+ expect(() => coerce('ab', spec)).toThrow();
40
+ });
41
+
42
+ test('password is a registered type', () => {
43
+ expect(listTypes()).toContain('password');
44
+ });
45
+
46
+ // ── refused on the command line ─────────────────────────────────────────────────
47
+
48
+ test('password refused as --secret value', () => {
49
+ const configPath = makeTmpConfig(TOML);
50
+ expect(() =>
51
+ parse({ scriptName: 'deploy', argv: ['--host', 'h', '--secret', 'x'], configPath }),
52
+ ).toThrow(/cannot be passed on the command line/);
53
+ });
54
+
55
+ test('password refused as --secret=value', () => {
56
+ const configPath = makeTmpConfig(TOML);
57
+ expect(() =>
58
+ parse({ scriptName: 'deploy', argv: ['--host', 'h', '--secret=x'], configPath }),
59
+ ).toThrow(/cannot be passed on the command line/);
60
+ });
61
+
62
+ // ── resolved from the environment ────────────────────────────────────────────────
63
+
64
+ test('password resolves from RUNSPEC_<RUNNABLE>_ARG_<NAME>', () => {
65
+ const configPath = makeTmpConfig(TOML);
66
+ process.env['RUNSPEC_DEPLOY_ARG_SECRET'] = 'fromenv';
67
+ try {
68
+ const args = parse({ scriptName: 'deploy', argv: ['--host', 'h'], configPath });
69
+ expect(args['secret']).toBe('fromenv');
70
+ } finally {
71
+ delete process.env['RUNSPEC_DEPLOY_ARG_SECRET'];
72
+ }
73
+ });
74
+
75
+ // ── hint plumbing ───────────────────────────────────────────────────────────────
76
+
77
+ test('hint is carried onto the spec', () => {
78
+ const configPath = makeTmpConfig(TOML);
79
+ const raw = loadRaw(configPath);
80
+ expect(raw.runnables['deploy'].args['host'].hint).toBe('web-01.example.com');
81
+ expect(raw.runnables['deploy'].args['secret'].hint).toBeUndefined();
82
+ });
83
+
84
+ // ── omitted from emitted agent schemas ───────────────────────────────────────────
85
+
86
+ test('password arg is excluded from emitted MCP schema', () => {
87
+ const configPath = makeTmpConfig(TOML);
88
+ const raw = loadRaw(configPath);
89
+ const script = inferScript(raw.runnables['deploy'], raw.config.autonomyDefault);
90
+ const schema = buildSchema('deploy', script, 'mcp');
91
+ const input = schema['inputSchema'] as Record<string, unknown>;
92
+ const props = input['properties'] as Record<string, unknown>;
93
+ expect('host' in props).toBe(true);
94
+ expect('secret' in props).toBe(false);
95
+ expect((input['required'] as string[] | undefined) ?? []).not.toContain('secret');
96
+ });
97
+
98
+ // ── loadSpec never prompts ───────────────────────────────────────────────────────
99
+
100
+ test('loadSpec does not prompt for a required password', () => {
101
+ const configPath = makeTmpConfig(TOML);
102
+ // loadSpec disables prompting; required args make it throw at validation, but
103
+ // it must never block on a prompt. (No TTY in tests anyway, but the gate is
104
+ // what guarantees it.)
105
+ expect(() => loadSpec({ scriptName: 'deploy', configPath })).toThrow();
106
+ });