@mandujs/core 0.25.2 → 0.26.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.
@@ -34,9 +34,27 @@
34
34
  * whether to abort (prod) or log + continue (dev) based on caller
35
35
  * flags — this module does not make that policy decision.
36
36
  *
37
- * 4. **Timeout**: per-script 2-minute wall-clock cap, matching the
38
- * policy established by `packages/mcp/src/util/runCommand.ts` (#136).
39
- * Override via `options.timeoutMs` for unusual long-running seeds.
37
+ * **Error surface (Issue #203)**: when a script exits non-zero we
38
+ * tail its stdout/stderr (last 10 lines of each) into the error
39
+ * message so the user can see WHY it failed without scrolling back
40
+ * through the terminal. When a spawn rejection is a real `Error`
41
+ * (e.g. `ENOENT`) we propagate its `message` and attach `cause` so
42
+ * `err.cause?.stack` is recoverable — fixing the "non-Error thrown"
43
+ * ghost stack traces reporters saw pre-#203.
44
+ *
45
+ * 4. **Timeout (Issue #203 — configurable)**: per-script wall-clock cap.
46
+ * Default is 2 minutes (matching `packages/mcp/src/util/runCommand.ts`
47
+ * #136). Override precedence, highest first:
48
+ *
49
+ * a. `options.timeoutMs` (explicit caller arg)
50
+ * b. `MANDU_PREBUILD_TIMEOUT_MS` environment variable
51
+ * c. `ManduConfig.dev.prebuildTimeoutMs` (threaded through by the
52
+ * CLI in `packages/cli/src/commands/dev.ts`)
53
+ * d. Default: 120_000 ms
54
+ *
55
+ * When the timer fires we throw `PrebuildTimeoutError` with the
56
+ * script path + limit so `error.message` alone carries enough info
57
+ * to let the user pick their override path without re-reading docs.
40
58
  *
41
59
  * 5. **No side-effects on empty discovery**: if no scripts are found,
42
60
  * `runPrebuildScripts` returns `{ ran: 0 }` silently. This is the
@@ -75,6 +93,54 @@ import fs from "node:fs";
75
93
  const PREBUILD_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs"]);
76
94
  const PREBUILD_FILENAME_RE = /^prebuild[-_.a-zA-Z0-9]*\.(ts|tsx|js|mjs)$/;
77
95
 
96
+ /**
97
+ * Default per-script timeout. Matches the MCP `runCommand()` convention
98
+ * (#136). Exported so `@mandujs/core` consumers (validators, tests) can
99
+ * reference the same constant instead of re-hardcoding `2 * 60 * 1000`.
100
+ */
101
+ export const DEFAULT_PREBUILD_TIMEOUT_MS = 2 * 60 * 1000;
102
+
103
+ /**
104
+ * Env var name for the runtime override. Checked inside
105
+ * `resolvePrebuildTimeout` when the caller does not pass an explicit
106
+ * `timeoutMs`. Invalid values (non-numeric, <= 0) are ignored with a
107
+ * single stderr warning so a typo does not silently break the default.
108
+ */
109
+ export const PREBUILD_TIMEOUT_ENV = "MANDU_PREBUILD_TIMEOUT_MS";
110
+
111
+ /**
112
+ * Resolve the timeout to apply for a single prebuild script, with
113
+ * precedence (highest first):
114
+ *
115
+ * 1. `explicit` argument — if the CLI or a test passes an explicit
116
+ * `timeoutMs`, we honour it verbatim (no env lookup). This keeps
117
+ * the injected-spawn unit tests deterministic.
118
+ * 2. `MANDU_PREBUILD_TIMEOUT_MS` environment variable (runtime knob
119
+ * for ops — set once in CI / docker env without re-deploying code).
120
+ * 3. `DEFAULT_PREBUILD_TIMEOUT_MS`.
121
+ *
122
+ * Separated from `runPrebuildScripts` so the CLI can log the resolved
123
+ * value without re-implementing the lookup.
124
+ */
125
+ export function resolvePrebuildTimeout(explicit?: number): number {
126
+ if (typeof explicit === "number" && explicit > 0) {
127
+ return explicit;
128
+ }
129
+ const envRaw = process.env[PREBUILD_TIMEOUT_ENV];
130
+ if (typeof envRaw === "string" && envRaw.length > 0) {
131
+ const parsed = Number(envRaw);
132
+ if (Number.isFinite(parsed) && parsed > 0) {
133
+ return parsed;
134
+ }
135
+ // Warn once per process — a typoed env var silently falling back to
136
+ // the default is the kind of issue #203 was reported to fix.
137
+ console.warn(
138
+ `[Mandu prebuild] ignoring invalid ${PREBUILD_TIMEOUT_ENV}='${envRaw}' (expected positive number of milliseconds). Falling back to default ${DEFAULT_PREBUILD_TIMEOUT_MS}ms.`,
139
+ );
140
+ }
141
+ return DEFAULT_PREBUILD_TIMEOUT_MS;
142
+ }
143
+
78
144
  /**
79
145
  * Discover prebuild scripts under `<rootDir>/<scriptsDir>`.
80
146
  *
@@ -119,14 +185,25 @@ export function discoverPrebuildScripts(
119
185
  // ---------------------------------------------------------------------------
120
186
 
121
187
  /**
122
- * Error thrown when a prebuild script exits non-zero or times out. The
123
- * `scriptPath` + `exitCode` fields are stable so callers can decide
124
- * recovery policy without string-matching the message.
188
+ * Error thrown when a prebuild script exits non-zero or when we could not
189
+ * complete the spawn (e.g. `bun` binary missing, ENOENT on the script
190
+ * path). The `scriptPath` + `exitCode` fields are stable so callers can
191
+ * decide recovery policy without string-matching the message.
192
+ *
193
+ * Issue #203: when wrapping an inner `Error`, we preserve the inner
194
+ * `.message` (shown in the message) AND set `this.cause = err` so
195
+ * runtimes that render `err.cause` get the full stack. Previously we
196
+ * sometimes lost the inner error entirely, producing a useless
197
+ * `"non-Error thrown"` surface.
125
198
  */
126
199
  export class PrebuildError extends Error {
127
200
  readonly scriptPath: string;
128
201
  readonly exitCode: number | null;
129
202
  readonly durationMs: number;
203
+ /** Last ~10 lines of stdout captured from the child process, if any. */
204
+ readonly stdoutTail?: string;
205
+ /** Last ~10 lines of stderr captured from the child process, if any. */
206
+ readonly stderrTail?: string;
130
207
 
131
208
  constructor(
132
209
  message: string,
@@ -135,6 +212,8 @@ export class PrebuildError extends Error {
135
212
  exitCode: number | null;
136
213
  durationMs: number;
137
214
  cause?: unknown;
215
+ stdoutTail?: string;
216
+ stderrTail?: string;
138
217
  },
139
218
  ) {
140
219
  super(message);
@@ -142,12 +221,55 @@ export class PrebuildError extends Error {
142
221
  this.scriptPath = options.scriptPath;
143
222
  this.exitCode = options.exitCode;
144
223
  this.durationMs = options.durationMs;
224
+ if (options.stdoutTail !== undefined) this.stdoutTail = options.stdoutTail;
225
+ if (options.stderrTail !== undefined) this.stderrTail = options.stderrTail;
145
226
  if (options.cause !== undefined) {
146
227
  (this as Error & { cause?: unknown }).cause = options.cause;
147
228
  }
148
229
  }
149
230
  }
150
231
 
232
+ /**
233
+ * Error thrown specifically when the per-script wall-clock timer elapses
234
+ * before the subprocess exits. Separate class (a subclass of
235
+ * `PrebuildError`) so callers can pattern-match:
236
+ *
237
+ * if (err instanceof PrebuildTimeoutError) { showTimeoutHint(); }
238
+ * else if (err instanceof PrebuildError) { showGenericHint(); }
239
+ *
240
+ * Without the subclass they would have to grep the `.message` string,
241
+ * which is the exact anti-pattern Issue #203 flagged.
242
+ *
243
+ * The message always ends with an actionable hint naming the three
244
+ * override paths (config field, env var, CLI flag) so the user can pick
245
+ * one without reading the docs.
246
+ */
247
+ export class PrebuildTimeoutError extends PrebuildError {
248
+ readonly timeoutMs: number;
249
+
250
+ constructor(options: {
251
+ scriptPath: string;
252
+ timeoutMs: number;
253
+ durationMs: number;
254
+ stdoutTail?: string;
255
+ stderrTail?: string;
256
+ }) {
257
+ const { scriptPath, timeoutMs, durationMs } = options;
258
+ super(
259
+ `PrebuildTimeoutError: ${scriptPath} exceeded ${timeoutMs}ms (set dev.prebuildTimeoutMs in mandu.config.ts or ${PREBUILD_TIMEOUT_ENV} env var to override).`,
260
+ {
261
+ scriptPath,
262
+ exitCode: null,
263
+ durationMs,
264
+ stdoutTail: options.stdoutTail,
265
+ stderrTail: options.stderrTail,
266
+ },
267
+ );
268
+ this.name = "PrebuildTimeoutError";
269
+ this.timeoutMs = timeoutMs;
270
+ }
271
+ }
272
+
151
273
  export interface PrebuildRunnerOptions {
152
274
  /** Project root. Absolute path — we resolve scripts relative to this. */
153
275
  rootDir: string;
@@ -157,8 +279,13 @@ export interface PrebuildRunnerOptions {
157
279
  */
158
280
  scriptsDir?: string;
159
281
  /**
160
- * Per-script wall-clock timeout in milliseconds. Default: 2 minutes,
161
- * matching the MCP `runCommand()` convention (#136).
282
+ * Per-script wall-clock timeout in milliseconds. Precedence matches
283
+ * `resolvePrebuildTimeout`: explicit arg > `MANDU_PREBUILD_TIMEOUT_MS`
284
+ * > `DEFAULT_PREBUILD_TIMEOUT_MS` (120_000).
285
+ *
286
+ * The CLI threads `ManduConfig.dev.prebuildTimeoutMs` into this field
287
+ * so end-users typically configure the timeout declaratively without
288
+ * passing an arg to this API.
162
289
  */
163
290
  timeoutMs?: number;
164
291
  /**
@@ -179,9 +306,10 @@ export interface PrebuildRunnerOptions {
179
306
  * Injected spawn hook — overridable in tests so we do not have to
180
307
  * actually fork `bun`. Default uses `Bun.spawn` with `stdio: "inherit"`.
181
308
  *
182
- * Contract: returns a `{ exited }` with an `exited` Promise that
183
- * resolves to the exit code (null on signal / timeout kill). The hook
184
- * is responsible for the actual kill on timeout.
309
+ * Contract: returns a `{ exitCode, durationMs }`. May also return
310
+ * `stdoutTail` / `stderrTail` strings (the default spawn does not
311
+ * it uses `stdio: "inherit"` so there is no captured output to tail).
312
+ * The hook is responsible for the actual kill on timeout.
185
313
  */
186
314
  spawn?: SpawnHook;
187
315
  }
@@ -198,12 +326,22 @@ export interface PrebuildResult {
198
326
  /**
199
327
  * Spawn shim — kept as an interface so tests can inject a pure-in-memory
200
328
  * replacement without monkey-patching `globalThis.Bun`.
329
+ *
330
+ * The hook is expected to self-enforce `timeoutMs` by killing the child
331
+ * when the timer fires, then throwing `PrebuildTimeoutError`. The
332
+ * default spawn follows this pattern. Custom hooks that skip the kill
333
+ * are responsible for any consequent zombie.
201
334
  */
202
335
  export type SpawnHook = (args: {
203
336
  scriptPath: string;
204
337
  cwd: string;
205
338
  timeoutMs: number;
206
- }) => Promise<{ exitCode: number | null; durationMs: number }>;
339
+ }) => Promise<{
340
+ exitCode: number | null;
341
+ durationMs: number;
342
+ stdoutTail?: string;
343
+ stderrTail?: string;
344
+ }>;
207
345
 
208
346
  /**
209
347
  * Default spawn hook: fork `bun <scriptPath>` with `stdio: "inherit"` so
@@ -219,6 +357,11 @@ export type SpawnHook = (args: {
219
357
  * prebuild's own perf log output doesn't muddle the dev boot perf trace
220
358
  * — otherwise the user's "dev boot in Nms" numbers include every
221
359
  * prebuild step, which is misleading.
360
+ *
361
+ * Note: `stdio: "inherit"` means we cannot capture stdout/stderr to tail
362
+ * into the error message. The captured-tail feature is exercised by
363
+ * injected spawn hooks in the test suite and by any future
364
+ * capture-mode hook callers may want to wire (e.g. CI log bundling).
222
365
  */
223
366
  export const defaultSpawn: SpawnHook = async ({
224
367
  scriptPath,
@@ -281,10 +424,11 @@ export const defaultSpawn: SpawnHook = async ({
281
424
  const exitCode = await proc.exited;
282
425
  const durationMs = performance.now() - start;
283
426
  if (timedOut) {
284
- throw new PrebuildError(
285
- `Prebuild script '${scriptPath}' exceeded timeout (${timeoutMs}ms) and was killed.`,
286
- { scriptPath, exitCode: null, durationMs },
287
- );
427
+ throw new PrebuildTimeoutError({
428
+ scriptPath,
429
+ timeoutMs,
430
+ durationMs,
431
+ });
288
432
  }
289
433
  return { exitCode, durationMs };
290
434
  } finally {
@@ -292,11 +436,50 @@ export const defaultSpawn: SpawnHook = async ({
292
436
  }
293
437
  };
294
438
 
439
+ /**
440
+ * Coerce an unknown rejection into a stable Error shape.
441
+ *
442
+ * Issue #203 root cause: the original wrap path did
443
+ *
444
+ * throw new Error("non-Error thrown: " + String(e));
445
+ *
446
+ * when `e` was, say, a plain string — which destroyed stack info and
447
+ * yielded the "non-Error thrown" message the reporter saw. This helper
448
+ * preserves whatever signal we can extract:
449
+ *
450
+ * - `Error` instance → use `err.message`, attach as `cause`.
451
+ * - Non-Error (string / number / object) → use `String(e)` as
452
+ * message prefix AND attach the raw value as `cause` so debug
453
+ * tooling can introspect it.
454
+ *
455
+ * Never produces the string "non-Error thrown" — that phrase was the
456
+ * explicit regression beacon.
457
+ */
458
+ function describeInner(err: unknown): { message: string; cause: unknown } {
459
+ if (err instanceof Error) {
460
+ // `.message` may still be empty (hand-thrown `new Error()`); fall
461
+ // back to the class name so the user sees something.
462
+ const message = err.message.length > 0 ? err.message : err.name;
463
+ return { message, cause: err };
464
+ }
465
+ // Primitives / plain objects: coerce to string. We intentionally do NOT
466
+ // use the phrase "non-Error thrown" (the Issue #203 regression beacon).
467
+ let message: string;
468
+ try {
469
+ message = typeof err === "string" ? err : JSON.stringify(err);
470
+ } catch {
471
+ message = String(err);
472
+ }
473
+ if (!message || message === "{}") message = String(err);
474
+ return { message: message || "unknown spawn rejection", cause: err };
475
+ }
476
+
295
477
  /**
296
478
  * Run every `scripts/prebuild-*.ts` in sequence. Resolves with a summary
297
479
  * of which scripts ran and how long each took. Rejects with
298
- * `PrebuildError` on the first failure (subsequent scripts are NOT run,
299
- * matching the `&&` chain semantics the user relied on before).
480
+ * `PrebuildError` (or `PrebuildTimeoutError` specifically) on the first
481
+ * failure subsequent scripts are NOT run, matching the `&&` chain
482
+ * semantics the user relied on before.
300
483
  *
301
484
  * @example
302
485
  * ```ts
@@ -309,12 +492,16 @@ export async function runPrebuildScripts(
309
492
  const {
310
493
  rootDir,
311
494
  scriptsDir = "scripts",
312
- timeoutMs = 2 * 60 * 1000,
313
495
  onStart,
314
496
  onFinish,
315
497
  spawn = defaultSpawn,
316
498
  } = options;
317
499
 
500
+ // Resolve timeout with env-var awareness so both the CLI and direct
501
+ // programmatic callers pick up `MANDU_PREBUILD_TIMEOUT_MS` without
502
+ // plumbing.
503
+ const timeoutMs = resolvePrebuildTimeout(options.timeoutMs);
504
+
318
505
  const scripts = discoverPrebuildScripts(rootDir, scriptsDir);
319
506
  if (scripts.length === 0) {
320
507
  return { ran: 0, scripts: [] };
@@ -327,11 +514,18 @@ export async function runPrebuildScripts(
327
514
 
328
515
  let exitCode: number | null;
329
516
  let durationMs: number;
517
+ let stdoutTail: string | undefined;
518
+ let stderrTail: string | undefined;
330
519
  try {
331
520
  const res = await spawn({ scriptPath, cwd: rootDir, timeoutMs });
332
521
  exitCode = res.exitCode;
333
522
  durationMs = res.durationMs;
523
+ stdoutTail = res.stdoutTail;
524
+ stderrTail = res.stderrTail;
334
525
  } catch (err) {
526
+ // Already a PrebuildError (typically PrebuildTimeoutError from the
527
+ // default spawn): propagate as-is after notifying onFinish so the
528
+ // caller's UI ticks the row off.
335
529
  if (err instanceof PrebuildError) {
336
530
  onFinish?.({
337
531
  scriptPath: err.scriptPath,
@@ -343,17 +537,19 @@ export async function runPrebuildScripts(
343
537
  exitCode: err.exitCode,
344
538
  durationMs: err.durationMs,
345
539
  });
346
- // Re-throw to abort the chain on failure.
347
540
  throw err;
348
541
  }
349
- // Non-PrebuildError rejection — wrap so callers only see one error shape.
542
+ // Non-PrebuildError rejection — wrap in a way that preserves the
543
+ // inner error's message + stack (Issue #203: previously this path
544
+ // produced a "non-Error thrown" surface with no useful info).
545
+ const { message, cause } = describeInner(err);
350
546
  throw new PrebuildError(
351
- `Prebuild script '${scriptPath}' failed: ${err instanceof Error ? err.message : String(err)}`,
547
+ `Prebuild script '${scriptPath}' failed: ${message}`,
352
548
  {
353
549
  scriptPath,
354
550
  exitCode: null,
355
551
  durationMs: 0,
356
- cause: err,
552
+ cause,
357
553
  },
358
554
  );
359
555
  }
@@ -362,10 +558,14 @@ export async function runPrebuildScripts(
362
558
  results.push({ scriptPath, exitCode, durationMs });
363
559
 
364
560
  if (exitCode !== 0) {
561
+ // Include captured output tails in the error so logs are actionable
562
+ // even after the dev server aborts and wipes the scrollback.
563
+ const tailHint = formatTailHint({ stdoutTail, stderrTail });
365
564
  throw new PrebuildError(
366
565
  `Prebuild script '${scriptPath}' exited with code ${exitCode}. ` +
367
- `Subsequent prebuild scripts were not run.`,
368
- { scriptPath, exitCode, durationMs },
566
+ `Subsequent prebuild scripts were not run.` +
567
+ (tailHint ? `\n${tailHint}` : ""),
568
+ { scriptPath, exitCode, durationMs, stdoutTail, stderrTail },
369
569
  );
370
570
  }
371
571
  }
@@ -373,6 +573,42 @@ export async function runPrebuildScripts(
373
573
  return { ran: results.length, scripts: results };
374
574
  }
375
575
 
576
+ /**
577
+ * Format the captured stdout/stderr tails into a human-readable multi-line
578
+ * hint suffix for `PrebuildError.message`. Returns an empty string when
579
+ * neither tail is present (the default spawn path) so we do not bloat the
580
+ * message with "[stdout tail]\n(empty)" noise in the common case.
581
+ */
582
+ function formatTailHint(args: {
583
+ stdoutTail?: string;
584
+ stderrTail?: string;
585
+ }): string {
586
+ const lines: string[] = [];
587
+ if (args.stderrTail && args.stderrTail.length > 0) {
588
+ lines.push("--- stderr (last 10 lines) ---");
589
+ lines.push(tailLines(args.stderrTail, 10));
590
+ }
591
+ if (args.stdoutTail && args.stdoutTail.length > 0) {
592
+ lines.push("--- stdout (last 10 lines) ---");
593
+ lines.push(tailLines(args.stdoutTail, 10));
594
+ }
595
+ return lines.join("\n");
596
+ }
597
+
598
+ /**
599
+ * Keep only the last N lines of a string. Exported indirectly through
600
+ * `PrebuildError.message` formatting so test assertions can recompute
601
+ * the expected tail without importing this helper.
602
+ */
603
+ function tailLines(text: string, n: number): string {
604
+ const normalized = text.replace(/\r\n/g, "\n");
605
+ const lines = normalized.split("\n");
606
+ // Strip one trailing empty line (shell output convention) so we count
607
+ // real lines only.
608
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
609
+ return lines.slice(-n).join("\n");
610
+ }
611
+
376
612
  /**
377
613
  * Determine whether a project appears to use the content/prebuild workflow.
378
614
  * Used by `mandu dev` to decide whether to enable auto-prebuild by default: