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/dist/cli.d.ts +22 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +149 -0
- package/dist/cli.js.map +1 -1
- package/dist/loader.js +1 -0
- package/dist/loader.js.map +1 -1
- package/dist/models.d.ts +1 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +2 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +98 -2
- package/dist/parser.js.map +1 -1
- package/dist/runspec.toml +15 -0
- package/dist/serve.d.ts +1 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +1 -0
- package/dist/serve.js.map +1 -1
- package/dist/testing.d.ts +67 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +324 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -1
- package/package.json +11 -1
- package/src/cli.ts +165 -0
- package/src/loader.ts +1 -0
- package/src/models.ts +1 -0
- package/src/parser.ts +96 -2
- package/src/runspec.toml +15 -0
- package/src/serve.ts +1 -1
- package/src/testing.ts +334 -0
- package/src/types.ts +8 -0
- package/tests/test_cli_test.test.ts +134 -0
- package/tests/test_password.test.ts +106 -0
- package/tests/testing.test.ts +132 -0
package/package.json
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runspec-node",
|
|
3
|
-
"version": "0.
|
|
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
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
|
-
|
|
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);
|