runspec-node 0.23.0 → 0.25.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/cli.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as readline from 'readline';
4
4
  import { findConfig } from './finder';
5
+ import * as logs from './logs';
5
6
  import { loadRaw } from './loader';
6
7
  import { inferScript } from './inference';
7
8
  import { parse } from './parser';
@@ -32,6 +33,8 @@ export function main(): void {
32
33
  const commands: Record<string, (args: string[]) => void | Promise<void>> = {
33
34
  init: cmdInit,
34
35
  local: cmdLocal,
36
+ bin: cmdBin,
37
+ logs: cmdLogs,
35
38
  jump: cmdJump,
36
39
  serve: cmdServe,
37
40
  };
@@ -112,6 +115,186 @@ function cmdLocal(args: string[]): void {
112
115
  }
113
116
  }
114
117
 
118
+ function cmdBin(_args: string[]): void {
119
+ let configPath: string;
120
+ try {
121
+ configPath = findConfig(process.cwd()).configPath;
122
+ } catch {
123
+ console.log("✗ No runspec.toml found. Run 'runspec init' first.");
124
+ process.exit(1);
125
+ }
126
+ const projectRoot = path.dirname(configPath);
127
+ const result = scaffoldBin(projectRoot);
128
+
129
+ console.log(`Wrote ${result.written.length} shim(s) to ${path.join(projectRoot, 'bin')}/:\n`);
130
+ for (const w of result.written) console.log(` ✓ bin/${w.name}${w.target ? ` → ${w.target}` : ''}`);
131
+ if (result.warnings.length) {
132
+ console.log('\nIssues:\n');
133
+ for (const msg of result.warnings) console.log(` ✗ ${msg}`);
134
+ }
135
+ console.log('\nThis folder is now venv-shaped — a controller can invoke bin/<runnable> and');
136
+ console.log('discover/manage it via bin/runspec, from any working directory.');
137
+ if (result.warnings.length) process.exit(1);
138
+ }
139
+
140
+ /**
141
+ * Generate a venv-shaped `bin/` for the runnables declared in `{projectRoot}/
142
+ * runspec.toml`: a `bin/runspec` shim plus one `bin/<runnable>` per runnable.
143
+ * The shims resolve their own location, so the folder stays relocatable and
144
+ * works from any cwd (paired with caller-relative findConfig). Returns a
145
+ * summary; performs the filesystem writes. Exported for testing.
146
+ */
147
+ export function scaffoldBin(projectRoot: string): { written: Array<{ name: string; target: string }>; warnings: string[] } {
148
+ const raw = loadRaw(configPathOf(projectRoot));
149
+ const runnables = Object.keys(raw.runnables);
150
+ const binMap = readBinMap(projectRoot);
151
+ const binDir = path.join(projectRoot, 'bin');
152
+ fs.mkdirSync(binDir, { recursive: true });
153
+ // The venv shape includes a logs/ dir (created lazily on first run anyway).
154
+ fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
155
+
156
+ const written: Array<{ name: string; target: string }> = [];
157
+ const warnings: string[] = [];
158
+
159
+ // bin/runspec — the CLI a controller shells for discovery + log management.
160
+ const cliTarget = resolveRunspecCli(projectRoot);
161
+ if (cliTarget) {
162
+ writeShim(path.join(binDir, 'runspec'), toPosix(path.relative(projectRoot, cliTarget)));
163
+ written.push({ name: 'runspec', target: toPosix(path.relative(projectRoot, cliTarget)) });
164
+ } else {
165
+ warnings.push("runspec-node is not installed — run 'npm install runspec-node', then 'runspec bin' again (bin/runspec skipped)");
166
+ }
167
+
168
+ // bin/<runnable> — one per declared runnable.
169
+ for (const name of runnables) {
170
+ const script = resolveScript(projectRoot, name, binMap);
171
+ if (!script) {
172
+ warnings.push(`${name}: no script found — add package.json bin["${name}"] or create ./${name}.js`);
173
+ continue;
174
+ }
175
+ const rel = toPosix(path.relative(projectRoot, script));
176
+ writeShim(path.join(binDir, name), rel);
177
+ written.push({ name, target: rel });
178
+ }
179
+ return { written, warnings };
180
+ }
181
+
182
+ const SCRIPT_EXTS = ['.js', '.cjs', '.mjs'];
183
+
184
+ function configPathOf(projectRoot: string): string {
185
+ return path.join(projectRoot, 'runspec.toml');
186
+ }
187
+
188
+ /** Resolve a runnable's script: package.json bin first, then ./<name>.{js,cjs,mjs}. */
189
+ export function resolveScript(projectRoot: string, name: string, binMap: Record<string, string>): string | null {
190
+ if (binMap[name]) {
191
+ const p = path.resolve(projectRoot, binMap[name]);
192
+ return fs.existsSync(p) ? p : null;
193
+ }
194
+ for (const ext of SCRIPT_EXTS) {
195
+ const p = path.join(projectRoot, name + ext);
196
+ if (fs.existsSync(p)) return p;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /** package.json `bin` as a {name: relPath} map. A string bin maps the package name. */
202
+ export function readBinMap(projectRoot: string): Record<string, string> {
203
+ try {
204
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
205
+ if (typeof pkg.bin === 'string') return pkg.name ? { [pkg.name]: pkg.bin } : {};
206
+ if (pkg.bin && typeof pkg.bin === 'object') return pkg.bin as Record<string, string>;
207
+ } catch {
208
+ // no package.json / unparseable — fall back to the file-name convention
209
+ }
210
+ return {};
211
+ }
212
+
213
+ function resolveRunspecCli(projectRoot: string): string | null {
214
+ const candidates = [
215
+ path.join(projectRoot, 'node_modules', 'runspec-node', 'bin', 'runspec.js'),
216
+ path.join(projectRoot, 'node_modules', '.bin', 'runspec'),
217
+ ];
218
+ return candidates.find((c) => fs.existsSync(c)) ?? null;
219
+ }
220
+
221
+ /** POSIX shim that resolves the project root relative to itself, then execs node. */
222
+ export function shimContent(relPath: string): string {
223
+ return ['#!/bin/sh', '# Generated by `runspec bin` — do not edit.', 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)', `exec node "$DIR/${relPath}" "$@"`, ''].join('\n');
224
+ }
225
+
226
+ function writeShim(shimPath: string, relPath: string): void {
227
+ fs.writeFileSync(shimPath, shimContent(relPath), 'utf-8');
228
+ fs.chmodSync(shimPath, 0o755);
229
+ }
230
+
231
+ function toPosix(p: string): string {
232
+ return p.split(path.sep).join('/');
233
+ }
234
+
235
+ function cmdLogs(args: string[]): void {
236
+ const has = (name: string): boolean => args.includes(name);
237
+ const valueFlags = new Set(['--since', '--user', '--run', '--older-than', '--max-files', '--max-total-size']);
238
+ const val = (name: string): string | undefined => {
239
+ const i = args.indexOf(name);
240
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
241
+ };
242
+ // Positionals are tokens that aren't flags or flag-values.
243
+ const positionals: string[] = [];
244
+ for (let i = 0; i < args.length; i++) {
245
+ const a = args[i];
246
+ if (a.startsWith('-')) {
247
+ if (valueFlags.has(a)) i++; // skip its value
248
+ continue;
249
+ }
250
+ positionals.push(a);
251
+ }
252
+
253
+ // `runspec logs status|prune|compact [runnable]` vs `runspec logs <runnable>`.
254
+ const target = positionals[0];
255
+ const verb = target === 'status' || target === 'prune' || target === 'compact' ? target : 'view';
256
+ const runnable = verb === 'view' ? target : positionals[1] ?? null;
257
+
258
+ try {
259
+ if (verb === 'view') {
260
+ if (!runnable) {
261
+ console.log('✗ A runnable is required: runspec logs <runnable>');
262
+ process.exit(1);
263
+ }
264
+ logs.view(runnable, {
265
+ since: val('--since') ? logs.parseDuration(val('--since')!) : null,
266
+ run: val('--run') ?? null,
267
+ user: val('--user') ?? null,
268
+ asJson: has('--json'),
269
+ follow: has('--follow'),
270
+ });
271
+ } else if (verb === 'status') {
272
+ logs.status(runnable, { asJson: has('--json') });
273
+ } else if (verb === 'prune') {
274
+ logs.prune(runnable, {
275
+ olderThan: val('--older-than') ? logs.parseDuration(val('--older-than')!) : null,
276
+ maxFiles: val('--max-files') ? parseInt(val('--max-files')!, 10) : null,
277
+ maxTotalSize: val('--max-total-size') ? logs.parseSize(val('--max-total-size')!) : null,
278
+ dryRun: has('--dry-run'),
279
+ asJson: has('--json'),
280
+ });
281
+ } else if (verb === 'compact') {
282
+ if (!val('--older-than')) {
283
+ console.log('✗ compact requires --older-than (e.g. --older-than 7d)');
284
+ process.exit(1);
285
+ }
286
+ logs.compact(runnable, logs.parseDuration(val('--older-than')!), {
287
+ gzip: has('--gzip'),
288
+ dryRun: has('--dry-run'),
289
+ asJson: has('--json'),
290
+ });
291
+ }
292
+ } catch (err) {
293
+ console.log(`✗ ${(err as Error).message}`);
294
+ process.exit(1);
295
+ }
296
+ }
297
+
115
298
  async function cmdJump(args: string[]): Promise<void> {
116
299
  const parsed = parse({ scriptName: 'runspec', argv: ['jump', ...args], configPath: _CLI_CONFIG });
117
300
  const fmt = String(parsed['format'] ?? 'text');
@@ -632,6 +815,8 @@ Usage:
632
815
  Commands:
633
816
  init Create runspec.toml and a code stub
634
817
  local List runnables and emit tool schemas
818
+ bin Generate a venv-shaped bin/ so a controller can run this folder
819
+ logs View, status, prune, or compact per-invocation audit logs
635
820
  jump Execute a runnable on a remote host via SSH
636
821
  serve Start the MCP stdio server for local runnables
637
822
 
@@ -642,6 +827,8 @@ Examples:
642
827
  runspec init --example
643
828
  runspec local
644
829
  runspec local --format mcp
830
+ runspec bin
831
+ runspec logs deploy
645
832
  runspec serve`);
646
833
  }
647
834
 
@@ -672,6 +859,39 @@ Examples:
672
859
  runspec local --format mcp --script deploy
673
860
  runspec local --format json`,
674
861
 
862
+ bin: `runspec bin — Generate a venv-shaped bin/ for this folder's runnables
863
+
864
+ Writes bin/runspec plus one bin/<runnable> shim per runnable in runspec.toml,
865
+ so a controller (e.g. runspec-console) can invoke bin/<runnable> and discover
866
+ the folder via bin/runspec — from any working directory, including over SSH.
867
+
868
+ Each runnable's script is resolved from package.json "bin", falling back to
869
+ ./<runnable>.js | .cjs | .mjs next to runspec.toml. Re-run after adding a
870
+ runnable or 'npm install'. (POSIX shims; Windows support is a follow-up.)
871
+
872
+ Examples:
873
+ npm install runspec-node
874
+ runspec bin`,
875
+
876
+ logs: `runspec logs — View, status, prune, or compact per-invocation audit logs
877
+
878
+ For runnables using [config.logging] store = "per-run" (one file per
879
+ invocation, no in-process rotation). Reads/maintains the project's logs/.
880
+
881
+ View (default):
882
+ runspec logs <runnable> merged, timestamp-sorted stream
883
+ runspec logs <runnable> --follow live tail across invocations
884
+ runspec logs <runnable> --since 1h --user alice --run <id>
885
+ runspec logs <runnable> --json raw JSON lines
886
+
887
+ Status / retention (default to all runnables):
888
+ runspec logs status [runnable] [--json] per-runnable file + disk inventory
889
+ runspec logs compact [runnable] --older-than 7d [--gzip] [--dry-run]
890
+ runspec logs prune [runnable] --older-than 90d | --max-files N | --max-total-size 5GB [--dry-run]
891
+
892
+ prune/compact never touch a single-mode {runnable}.log — only per-run files
893
+ and archives. Add --json to any verb for machine-readable output.`,
894
+
675
895
  jump: `runspec jump — Execute a runnable on a remote host via SSH
676
896
 
677
897
  Not yet implemented in the Node package.
@@ -22,6 +22,16 @@ const _handlers: Handler[] = [];
22
22
  let _runId: string | null = null;
23
23
 
24
24
  const RUN_SUMMARY_LOGGER = 'runspec.runsummary';
25
+ // Captured-stdout records are logged under this name (mirrors Python's
26
+ // `runspec.print`). Used only for clarity in the audit file.
27
+ const PRINT_LOGGER = 'runspec.print';
28
+
29
+ // The real `process.stdout.write`, captured before we tee it. The stdout
30
+ // handler writes through this so its own output isn't re-captured (mirrors
31
+ // Python capturing the real sys.stdout reference before the tee swap).
32
+ let _rawStdoutWrite: typeof process.stdout.write | null = null;
33
+ let _stdoutBuf = '';
34
+ let _captureInstalled = false;
25
35
  // Uncaught exceptions are emitted on this dedicated logger so the file handler
26
36
  // records them while the console handlers drop them by name — console display is
27
37
  // handled explicitly in _handleUncaught (debug-gated).
@@ -114,6 +124,10 @@ interface LogRecord {
114
124
  error?: Error;
115
125
  extra?: Record<string, unknown>;
116
126
  excStructured?: Record<string, unknown>;
127
+ // Set on records synthesised from captured stdout (print-capture). Console +
128
+ // counter handlers skip these (the real stdout write already happened, and
129
+ // printed lines aren't log "events"); only file handlers persist them.
130
+ fromPrint?: boolean;
117
131
  }
118
132
 
119
133
  interface Handler {
@@ -220,8 +234,11 @@ class StdoutHandler implements Handler {
220
234
  emit(record: LogRecord): void {
221
235
  if (record.levelNum >= 30) return; // WARNING+ belongs on stderr
222
236
  if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
237
+ if (record.fromPrint) return; // the real stdout write already emitted this
223
238
  try {
224
- process.stdout.write(formatConsole(record, this.showTracebacks) + '\n');
239
+ // Write through the *raw* stream so this output isn't re-captured by the
240
+ // print-capture tee (would double it in the audit file).
241
+ (_rawStdoutWrite ?? process.stdout.write.bind(process.stdout))(formatConsole(record, this.showTracebacks) + '\n');
225
242
  } catch {
226
243
  // never disrupt
227
244
  }
@@ -262,8 +279,9 @@ class RunSummaryCounter implements Handler {
262
279
  };
263
280
 
264
281
  emit(record: LogRecord): void {
265
- // Don't count runspec's own bookkeeping records (summary + uncaught-exception).
266
- if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
282
+ // Don't count runspec's own bookkeeping records (summary + uncaught-exception)
283
+ // or captured stdout lines only genuine logger events.
284
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER || record.fromPrint) return;
267
285
  const label = LEVEL_LABEL[record.levelNum];
268
286
  if (label && label in this.counts) {
269
287
  this.counts[label]++;
@@ -617,6 +635,61 @@ export function _handleUncaught(err: Error): void {
617
635
  }
618
636
  }
619
637
 
638
+ // ── stdout capture (print → audit log) ─────────────────────────────────────────
639
+
640
+ /** Emit one captured stdout line as a file-only record (skipped by console + counter). */
641
+ function emitPrintLine(line: string): void {
642
+ if (_handlers.length === 0) return;
643
+ const record: LogRecord = { ts: new Date(), levelNum: 20, loggerName: PRINT_LOGGER, message: redact(line), fromPrint: true };
644
+ for (const h of _handlers) {
645
+ try {
646
+ if (record.levelNum >= h.level) h.emit(record);
647
+ } catch {
648
+ // never disrupt
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Tee `process.stdout.write`: output still reaches the real stream unchanged
655
+ * (so pipes / `runspec serve` capture are untouched), and each complete line is
656
+ * also written to the audit file as a `fromPrint` record. Mirrors Python's
657
+ * `_StdoutTee`, so a runnable's `console.log` output is preserved in the
658
+ * per-invocation log — not just the run summary.
659
+ */
660
+ function installStdoutCapture(): void {
661
+ if (_captureInstalled) return;
662
+ _captureInstalled = true;
663
+ _rawStdoutWrite = process.stdout.write.bind(process.stdout);
664
+ const patched = function (chunk: unknown, encoding?: unknown, cb?: unknown): boolean {
665
+ const result = (_rawStdoutWrite as (...a: unknown[]) => boolean)(chunk, encoding, cb);
666
+ try {
667
+ const s = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? (encoding as BufferEncoding) : 'utf8') : '';
668
+ if (s) {
669
+ _stdoutBuf += s;
670
+ let idx: number;
671
+ while ((idx = _stdoutBuf.indexOf('\n')) !== -1) {
672
+ const line = _stdoutBuf.slice(0, idx);
673
+ _stdoutBuf = _stdoutBuf.slice(idx + 1);
674
+ if (line) emitPrintLine(line);
675
+ }
676
+ }
677
+ } catch {
678
+ // never disrupt
679
+ }
680
+ return result;
681
+ };
682
+ process.stdout.write = patched as typeof process.stdout.write;
683
+ // Flush a trailing partial line at exit. Registered before installExitHooks so
684
+ // it runs before the run-summary record (FIFO exit handlers).
685
+ process.on('exit', () => {
686
+ if (_stdoutBuf) {
687
+ emitPrintLine(_stdoutBuf);
688
+ _stdoutBuf = '';
689
+ }
690
+ });
691
+ }
692
+
620
693
  function installExitHooks(): void {
621
694
  if (_exitHooksInstalled) return;
622
695
  _exitHooksInstalled = true;
@@ -740,6 +813,10 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
740
813
  };
741
814
  }
742
815
 
816
+ // Tee stdout into the audit log so printed output (not just the run summary)
817
+ // is preserved. Installed before the exit hooks so its flush runs first.
818
+ installStdoutCapture();
819
+
743
820
  // Uncaught-exception handling is always installed (independent of the summary
744
821
  // toggle) so the structured exception record reaches the audit file even when
745
822
  // summary is off. The exit hook only flushes a summary when _summaryState is set.
@@ -757,8 +834,15 @@ export function _resetForTest(): void {
757
834
  _loggers.clear();
758
835
  _handlers.length = 0;
759
836
  _summaryState = null;
837
+ // Un-tee stdout so the patch doesn't leak across tests.
838
+ if (_rawStdoutWrite) {
839
+ process.stdout.write = _rawStdoutWrite;
840
+ _rawStdoutWrite = null;
841
+ }
842
+ _stdoutBuf = '';
843
+ _captureInstalled = false;
760
844
  // Note: process event listeners installed by installExitHooks() stay —
761
845
  // they no-op when _summaryState is null, which is the test-time state.
762
846
  }
763
847
 
764
- export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace };
848
+ export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace, findProjectRoot };