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/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "runspec-node",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "description": "Node/TypeScript language pack for runspec",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./testing": {
13
+ "types": "./dist/testing.d.ts",
14
+ "default": "./dist/testing.js"
15
+ }
16
+ },
7
17
  "bin": {
8
18
  "runspec": "./bin/runspec.js"
9
19
  },
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as readline from 'readline';
4
+ import { spawnSync } from 'child_process';
4
5
  import { findConfig } from './finder';
5
6
  import * as logs from './logs';
6
7
  import { loadRaw } from './loader';
@@ -33,6 +34,7 @@ export function main(): void {
33
34
  const commands: Record<string, (args: string[]) => void | Promise<void>> = {
34
35
  init: cmdInit,
35
36
  local: cmdLocal,
37
+ test: cmdTest,
36
38
  bin: cmdBin,
37
39
  logs: cmdLogs,
38
40
  jump: cmdJump,
@@ -359,6 +361,145 @@ function cmdServe(_args: string[]): void {
359
361
  serve();
360
362
  }
361
363
 
364
+ // Hard cap on the entry-point `--help` subprocess so a runnable that hangs on
365
+ // import can't wedge a CI run. A timeout is treated as a failure.
366
+ const TEST_SUBPROCESS_TIMEOUT_MS = 30000;
367
+
368
+ interface TestCheck {
369
+ ok: boolean;
370
+ checks?: string[];
371
+ error?: string;
372
+ stderr?: string;
373
+ }
374
+
375
+ interface TestResult {
376
+ runnable: string;
377
+ source: string;
378
+ ok: boolean;
379
+ checks: { spec_smoke: TestCheck; entry_point: TestCheck };
380
+ }
381
+
382
+ export function cmdTest(args: string[]): void {
383
+ const fmt = getFlag(args, '--format') ?? 'text';
384
+ const runnableFilter = getFlag(args, '--runnable');
385
+
386
+ let discovered = discoverLocal();
387
+ if (runnableFilter) discovered = discovered.filter((d) => d.runnable === runnableFilter);
388
+
389
+ if (!discovered.length) {
390
+ console.log('No runspec-aware runnables found in this environment.');
391
+ console.log("Run from a folder with a runspec.toml, or 'runspec init' to create one.");
392
+ return;
393
+ }
394
+
395
+ const results = discovered.map((d) => testOne(d));
396
+ const failed = results.filter((r) => !r.ok).length;
397
+
398
+ if (fmt === 'json') {
399
+ const summary = { total: results.length, passed: results.length - failed, failed };
400
+ console.log(JSON.stringify({ results, summary }, null, 2));
401
+ } else {
402
+ printTestText(results);
403
+ }
404
+
405
+ if (failed) process.exit(1);
406
+ }
407
+
408
+ // Run both checks against one discovered runnable; never throws. Each phase is
409
+ // isolated so a single broken runnable cannot abort the run.
410
+ export function testOne(item: { source: string; runnable: string; spec: ScriptSpec }): TestResult {
411
+ const { runnable: name, source } = item;
412
+
413
+ // ── phase 1: spec smoke (in-process) ──────────────────────────────────────
414
+ let specSmoke: TestCheck;
415
+ try {
416
+ const { ParseHarness } = require('./testing') as typeof import('./testing');
417
+ const h = new ParseHarness({ scriptName: name, configPath: source, suppressSummary: true });
418
+ try {
419
+ specSmoke = { ok: true, checks: h.smoke({ checkHelp: true }) };
420
+ } finally {
421
+ h.close();
422
+ }
423
+ } catch (e) {
424
+ specSmoke = { ok: false, error: e instanceof Error ? e.message : String(e) };
425
+ }
426
+
427
+ // ── phase 2: entry-point --help (subprocess) ──────────────────────────────
428
+ const { findScript } = require('./serve') as typeof import('./serve');
429
+ const binDir = path.join(path.dirname(source), 'node_modules', '.bin');
430
+ const cmd = findScript(name, binDir);
431
+
432
+ let entryPoint: TestCheck;
433
+ if (cmd === null) {
434
+ entryPoint = { ok: false, error: 'entry point not found — run `runspec bin` or check package.json "bin"' };
435
+ } else {
436
+ const res = spawnSync(cmd, ['--help'], {
437
+ encoding: 'utf-8',
438
+ timeout: TEST_SUBPROCESS_TIMEOUT_MS,
439
+ env: { ...process.env, RUNSPEC_AGENT: '1' },
440
+ });
441
+ if (res.error) {
442
+ const code = (res.error as NodeJS.ErrnoException).code;
443
+ const msg = code === 'ETIMEDOUT' ? `timed out after ${TEST_SUBPROCESS_TIMEOUT_MS / 1000}s` : res.error.message;
444
+ entryPoint = { ok: false, error: msg };
445
+ } else if (res.status !== 0) {
446
+ entryPoint = {
447
+ ok: false,
448
+ error: `exit ${res.status}`,
449
+ stderr: (res.stderr || res.stdout || '').trim().slice(0, 2000),
450
+ };
451
+ } else {
452
+ entryPoint = { ok: true };
453
+ }
454
+ }
455
+
456
+ return {
457
+ runnable: name,
458
+ source,
459
+ ok: specSmoke.ok && entryPoint.ok,
460
+ checks: { spec_smoke: specSmoke, entry_point: entryPoint },
461
+ };
462
+ }
463
+
464
+ // Pick the most informative single line from captured stderr: the last line
465
+ // mentioning an error/exception (the "Error: ..." line for a Node crash, the
466
+ // exception for a Python traceback), falling back to the last non-empty line.
467
+ function summariseStderr(stderr: string): string {
468
+ const lines = stderr
469
+ .split('\n')
470
+ .map((l) => l.trim())
471
+ .filter((l) => l);
472
+ if (!lines.length) return '';
473
+ const errLines = lines.filter((l) => /error|exception/i.test(l));
474
+ return (errLines.length ? errLines : lines)[(errLines.length ? errLines : lines).length - 1];
475
+ }
476
+
477
+ function printTestText(results: TestResult[]): void {
478
+ const total = results.length;
479
+ const passed = results.filter((r) => r.ok).length;
480
+ const failed = total - passed;
481
+
482
+ console.log(`Tested ${total} runnable(s):\n`);
483
+ for (const r of results) {
484
+ const spec = r.checks.spec_smoke;
485
+ const ep = r.checks.entry_point;
486
+ const mark = r.ok ? '✓' : '✗';
487
+ const specNote = spec.ok ? 'spec ok' : 'spec failed';
488
+ const epNote = ep.ok ? '--help exit 0' : '--help failed';
489
+ console.log(` ${mark} ${r.runnable.padEnd(24)} ${specNote}, ${epNote}`);
490
+ if (!spec.ok) console.log(` spec: ${spec.error}`);
491
+ if (!ep.ok) {
492
+ console.log(` entry point: ${ep.error}`);
493
+ if (ep.stderr) {
494
+ const summary = summariseStderr(ep.stderr);
495
+ if (summary) console.log(` stderr: ${summary}`);
496
+ }
497
+ }
498
+ }
499
+ console.log();
500
+ console.log(`Summary: ${passed} passed, ${failed} failed (${total} total)`);
501
+ }
502
+
362
503
  // ── Schema builder ────────────────────────────────────────────────────────────
363
504
 
364
505
  export function buildSchema(name: string, script: ScriptSpec, fmt: string): Record<string, unknown> {
@@ -366,6 +507,10 @@ export function buildSchema(name: string, script: ScriptSpec, fmt: string): Reco
366
507
  const requiredArgs: string[] = [];
367
508
 
368
509
  for (const [argName, arg] of Object.entries(script.args ?? {})) {
510
+ // Secret args are deliberately omitted from agent-facing schemas: a password
511
+ // can't be passed on the command line and must come from the environment /
512
+ // operator, never from an agent's tool call.
513
+ if (arg.type === 'password') continue;
369
514
  properties[argName] = argToJsonSchema(arg);
370
515
  if (arg.required) requiredArgs.push(argName);
371
516
  }
@@ -387,6 +532,7 @@ export function buildSchema(name: string, script: ScriptSpec, fmt: string): Reco
387
532
  function argToJsonSchema(arg: ArgSpec): Record<string, unknown> {
388
533
  const typeMap: Record<string, string> = {
389
534
  str: 'string',
535
+ password: 'string',
390
536
  int: 'integer',
391
537
  float: 'number',
392
538
  bool: 'boolean',
@@ -825,6 +971,7 @@ Usage:
825
971
  Commands:
826
972
  init Create runspec.toml and a code stub
827
973
  local List runnables and emit tool schemas
974
+ test Smoke-test every runnable: spec smoke + entry-point --help (CI gate)
828
975
  bin Generate a venv-shaped bin/ so a controller can run this folder
829
976
  logs View, status, prune, or compact per-invocation audit logs
830
977
  jump Execute a runnable on a remote host via SSH
@@ -837,6 +984,7 @@ Examples:
837
984
  runspec init --example
838
985
  runspec local
839
986
  runspec local --format mcp
987
+ runspec test
840
988
  runspec bin
841
989
  runspec logs deploy
842
990
  runspec serve`);
@@ -869,6 +1017,23 @@ Examples:
869
1017
  runspec local --format mcp --script deploy
870
1018
  runspec local --format json`,
871
1019
 
1020
+ test: `runspec test — Smoke-test every runnable in this folder (CI gate)
1021
+
1022
+ For each runnable in runspec.toml runs two checks: an in-process spec smoke
1023
+ (--help works for every command, require-command is enforced, required args
1024
+ are validated) and a subprocess that executes the entry point with --help
1025
+ (proves the runnable's own code imports and is wired to the right spec).
1026
+ Exits 1 if any runnable fails either check.
1027
+
1028
+ Options:
1029
+ --format Output format: text (default) or json
1030
+ --runnable Filter to a single runnable by name
1031
+
1032
+ Examples:
1033
+ runspec test
1034
+ runspec test --format json
1035
+ runspec test --runnable deploy`,
1036
+
872
1037
  bin: `runspec bin — Generate a venv-shaped bin/ for this folder's runnables
873
1038
 
874
1039
  Writes bin/runspec plus one bin/<runnable> shim per runnable in runspec.toml,
package/src/loader.ts CHANGED
@@ -101,6 +101,7 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
101
101
  deprecated: raw['deprecated'] as string | undefined,
102
102
  autonomy: raw['autonomy'] as string | undefined,
103
103
  ui: raw['ui'] as string | undefined,
104
+ hint: raw['hint'] as string | undefined,
104
105
  meta: raw['meta'] as Record<string, unknown> | undefined,
105
106
  };
106
107
  }
package/src/models.ts CHANGED
@@ -33,6 +33,7 @@ export interface ArgSpec {
33
33
  deprecated?: string;
34
34
  autonomy?: string;
35
35
  ui?: string;
36
+ hint?: string; // UI placeholder/hint text (str/path/password types)
36
37
  meta?: Record<string, unknown>;
37
38
  }
38
39
 
package/src/parser.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import { execSync } from 'child_process';
2
4
  import { findConfig } from './finder';
3
5
  import { loadRaw } from './loader';
4
6
  import { inferScript, effectiveAutonomy } from './inference';
@@ -15,10 +17,12 @@ export interface ParseOptions {
15
17
  configPath?: string;
16
18
  /** Internal: enforce `require-command` (real CLI parsing). loadSpec sets false. */
17
19
  _enforceRequiredCommand?: boolean;
20
+ /** Internal: prompt for missing required password args. loadSpec sets false. */
21
+ _promptSecrets?: boolean;
18
22
  }
19
23
 
20
24
  export function parse(opts: ParseOptions = {}): ParsedArgs {
21
- const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride, _enforceRequiredCommand = true } = opts;
25
+ const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride, _enforceRequiredCommand = true, _promptSecrets = true } = opts;
22
26
 
23
27
  const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
24
28
  const raw = loadRaw(configPath);
@@ -125,6 +129,15 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
125
129
  parsedValues = applyEnv(parsedValues, activeScript.args ?? {}, name);
126
130
  parsedValues = applyDefaults(parsedValues, activeScript.args ?? {});
127
131
 
132
+ // Prompt (no echo) for any still-unresolved required password arg when running
133
+ // interactively. Passwords are refused on the command line, so a terminal user
134
+ // supplies them here; agent / non-interactive runs (and loadSpec) fall through
135
+ // to the env-var value or the required-arg error.
136
+ const agentEarly = ['1', 'true', 'yes'].includes((process.env['RUNSPEC_AGENT'] ?? '').toLowerCase());
137
+ if (_promptSecrets) {
138
+ parsedValues = promptPasswords(parsedValues, activeScript.args ?? {}, agentEarly);
139
+ }
140
+
128
141
  raiseIfErrors(validateArgs(parsedValues, activeScript.args ?? {}));
129
142
  raiseIfErrors(validateGroups(parsedValues, activeScript.groups ?? {}));
130
143
 
@@ -175,7 +188,8 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
175
188
  }
176
189
 
177
190
  export function loadSpec(opts: ParseOptions = {}): ParsedArgs {
178
- return parse({ ...opts, argv: [], _enforceRequiredCommand: false });
191
+ // Secret prompting is disabled introspection must never block on a prompt.
192
+ return parse({ ...opts, argv: [], _enforceRequiredCommand: false, _promptSecrets: false });
179
193
  }
180
194
 
181
195
  function inferFromArgv(): string {
@@ -225,6 +239,7 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
225
239
  if (norm) {
226
240
  const hyphenName = norm.replace(/_/g, '-');
227
241
  const spec = argSpecs[hyphenName] ?? argSpecs[norm] ?? {};
242
+ if ((spec as ArgSpec).type === 'password') refusePasswordOnCli(norm);
228
243
  result[norm] = appendOrSet(result[norm], value, spec);
229
244
  }
230
245
  i++;
@@ -237,6 +252,8 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
237
252
  const spec = argSpecs[hyphenName] ?? argSpecs[norm] ?? {};
238
253
  const argType = spec.type ?? 'str';
239
254
 
255
+ if (argType === 'password') refusePasswordOnCli(norm);
256
+
240
257
  if (argType === 'flag') {
241
258
  result[norm] = true;
242
259
  i++;
@@ -273,6 +290,83 @@ function appendOrSet(current: unknown, value: unknown, spec: ArgSpec): unknown {
273
290
  return value;
274
291
  }
275
292
 
293
+ // A `password` arg must never take its value from argv (it would leak into shell
294
+ // history and `ps`). Resolve it from the environment instead — the automatic
295
+ // RUNSPEC_<RUNNABLE>_ARG_<NAME> variable, a declared `env` alias — or let runspec
296
+ // prompt for it when run interactively in a terminal.
297
+ function refusePasswordOnCli(norm: string): never {
298
+ const flag = norm.replace(/_/g, '-');
299
+ const envHint = `RUNSPEC_<RUNNABLE>_ARG_${norm.toUpperCase()}`;
300
+ throw new RunSpecError(
301
+ `✗ --${flag} is a password and cannot be passed on the command line.\n` +
302
+ ` Set it via the environment (${envHint} or an 'env' alias),\n` +
303
+ ` or run the command interactively to be prompted.`,
304
+ );
305
+ }
306
+
307
+ // Interactively prompt (no echo) for any required password arg still unresolved
308
+ // after env/defaults. Only when attached to a real terminal and not invoked by
309
+ // an agent. POSIX only (uses `stty`); Windows / non-TTY runs fall through to the
310
+ // normal required-arg error.
311
+ function promptPasswords(
312
+ parsed: Record<string, unknown>,
313
+ argSpecs: Record<string, ArgSpec>,
314
+ agent: boolean,
315
+ ): Record<string, unknown> {
316
+ if (agent || process.platform === 'win32' || !(process.stdin.isTTY && process.stderr.isTTY)) {
317
+ return parsed;
318
+ }
319
+ const result = { ...parsed };
320
+ for (const [name, spec] of Object.entries(argSpecs)) {
321
+ if (spec.type !== 'password') continue;
322
+ const norm = name.replace(/-/g, '_');
323
+ if (result[norm] !== undefined && result[norm] !== null) continue;
324
+ if (!spec.required) continue;
325
+ const value = readLineNoEcho(`${name}: `);
326
+ if (value) result[norm] = value;
327
+ }
328
+ return result;
329
+ }
330
+
331
+ // Read one line from stdin with echo suppressed (getpass equivalent). Reads
332
+ // bytes so multibyte UTF-8 secrets decode correctly; restores echo on exit.
333
+ function readLineNoEcho(prompt: string): string {
334
+ process.stderr.write(prompt);
335
+ try {
336
+ try {
337
+ execSync('stty -echo', { stdio: 'inherit' });
338
+ } catch {
339
+ /* no stty available — proceed (input may echo) */
340
+ }
341
+ const bytes: number[] = [];
342
+ const buf = Buffer.alloc(1);
343
+ for (;;) {
344
+ let n: number;
345
+ try {
346
+ n = fs.readSync(0, buf, 0, 1, null);
347
+ } catch (e) {
348
+ if ((e as NodeJS.ErrnoException).code === 'EAGAIN') continue;
349
+ break;
350
+ }
351
+ if (n === 0) break;
352
+ const b = buf[0];
353
+ if (b === 0x0a) break; // \n
354
+ if (b === 0x0d) continue; // \r
355
+ bytes.push(b);
356
+ }
357
+ return Buffer.from(bytes).toString('utf8');
358
+ } catch {
359
+ return '';
360
+ } finally {
361
+ try {
362
+ execSync('stty echo', { stdio: 'inherit' });
363
+ } catch {
364
+ /* ignore */
365
+ }
366
+ process.stderr.write('\n');
367
+ }
368
+ }
369
+
276
370
  function applyEnv(parsed: Record<string, unknown>, argSpecs: Record<string, ArgSpec>, runnableName: string): Record<string, unknown> {
277
371
  const runnablePrefix = runnableName.toUpperCase().replace(/-/g, '_');
278
372
  const result = { ...parsed };
package/src/runspec.toml CHANGED
@@ -53,6 +53,21 @@ examples = [
53
53
  format = {type = "choice", description = "Output format", options = ["text", "json", "mcp", "openai", "anthropic"], short = "-f", default = "text"}
54
54
  runnable = {type = "str", description = "Filter output to a single runnable by name", short = "-r", required = false}
55
55
 
56
+ [runspec.commands.test]
57
+ description = "Smoke-test runnables: spec smoke + entry-point --help"
58
+ autonomy = "autonomous"
59
+ output = "json"
60
+
61
+ examples = [
62
+ {cmd = "runspec test", description = "Test every runnable in this folder (CI gate)"},
63
+ {cmd = "runspec test --format json", description = "Machine-readable results for CI"},
64
+ {cmd = "runspec test --runnable deploy", description = "Test a single runnable"},
65
+ ]
66
+
67
+ [runspec.commands.test.args]
68
+ format = {type = "choice", description = "Output format", options = ["text", "json"], short = "-f", default = "text"}
69
+ runnable = {type = "str", description = "Filter to a single runnable by name", short = "-r", required = false}
70
+
56
71
  [runspec.commands.serve]
57
72
  description = "Start an MCP stdio server exposing all installed runnables as tools"
58
73
 
package/src/serve.ts CHANGED
@@ -258,7 +258,7 @@ function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string
258
258
  return env;
259
259
  }
260
260
 
261
- function findScript(name: string, binDir: string): string | null {
261
+ export function findScript(name: string, binDir: string): string | null {
262
262
  // 1. node_modules/.bin (Node entry points; also .exe on Windows)
263
263
  for (const ext of [...SHELL_EXTS, '.exe']) {
264
264
  const candidate = path.join(binDir, name + ext);