@mandujs/core 0.25.2 → 0.25.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -198,6 +198,24 @@ export interface ManduConfig {
198
198
  * `autoPrebuild === false`. Relative to project root.
199
199
  */
200
200
  contentDir?: string;
201
+ /**
202
+ * Issue #203 — Per-script wall-clock timeout for prebuild scripts
203
+ * (milliseconds). Default: `120_000` (2 minutes), matching the MCP
204
+ * `runCommand()` convention (#136). Override for projects that ship
205
+ * slow seed generators (e.g. large docs indexers, image pipelines).
206
+ *
207
+ * Precedence at runtime, highest first:
208
+ * 1. `MANDU_PREBUILD_TIMEOUT_MS` env var — useful for one-off CI
209
+ * overrides without committing to the config.
210
+ * 2. This field (`dev.prebuildTimeoutMs`).
211
+ * 3. Default 120_000 ms.
212
+ *
213
+ * When the timeout fires, `runPrebuildScripts` throws a
214
+ * `PrebuildTimeoutError` whose message names the failing script path,
215
+ * the limit, AND the two override paths — so the user does not need
216
+ * to re-read this comment to recover.
217
+ */
218
+ prebuildTimeoutMs?: number;
201
219
  };
202
220
  fsRoutes?: {
203
221
  routesDir?: string;
@@ -122,6 +122,14 @@ const DevConfigSchema = z
122
122
  * an empty pattern. Default `"content"`.
123
123
  */
124
124
  contentDir: z.string().min(1).default("content"),
125
+ /**
126
+ * Issue #203 — per-script wall-clock timeout (ms) for
127
+ * `scripts/prebuild-*.ts`. `undefined` = use default (120_000 ms) or
128
+ * the `MANDU_PREBUILD_TIMEOUT_MS` env var if set. Explicit positive
129
+ * integer overrides both. The boundary check mirrors
130
+ * `server.rateLimit.windowMs` style — positive integers only.
131
+ */
132
+ prebuildTimeoutMs: z.number().int().positive().optional(),
125
133
  })
126
134
  .strict();
127
135
 
@@ -24,6 +24,11 @@ import {
24
24
  shouldAutoPrebuild,
25
25
  runPrebuildScripts,
26
26
  PrebuildError,
27
+ PrebuildTimeoutError,
28
+ resolvePrebuildTimeout,
29
+ DEFAULT_PREBUILD_TIMEOUT_MS,
30
+ PREBUILD_TIMEOUT_ENV,
31
+ defaultSpawn,
27
32
  type SpawnHook,
28
33
  } from "./prebuild";
29
34
 
@@ -247,3 +252,320 @@ describe("runPrebuildScripts", () => {
247
252
  expect(spawn.calls[0].timeoutMs).toBe(2 * 60 * 1000);
248
253
  });
249
254
  });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // resolvePrebuildTimeout (Issue #203)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe("resolvePrebuildTimeout", () => {
261
+ const originalEnv = process.env[PREBUILD_TIMEOUT_ENV];
262
+ afterEach(() => {
263
+ if (originalEnv === undefined) delete process.env[PREBUILD_TIMEOUT_ENV];
264
+ else process.env[PREBUILD_TIMEOUT_ENV] = originalEnv;
265
+ });
266
+
267
+ it("returns DEFAULT_PREBUILD_TIMEOUT_MS (120s) when no override", () => {
268
+ delete process.env[PREBUILD_TIMEOUT_ENV];
269
+ expect(resolvePrebuildTimeout()).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
270
+ expect(DEFAULT_PREBUILD_TIMEOUT_MS).toBe(120_000);
271
+ });
272
+
273
+ it("explicit arg wins over env var", () => {
274
+ process.env[PREBUILD_TIMEOUT_ENV] = "5000";
275
+ expect(resolvePrebuildTimeout(1234)).toBe(1234);
276
+ });
277
+
278
+ it("env var is used when no explicit arg", () => {
279
+ process.env[PREBUILD_TIMEOUT_ENV] = "7500";
280
+ expect(resolvePrebuildTimeout()).toBe(7500);
281
+ });
282
+
283
+ it("ignores invalid env var (non-numeric) and falls back to default", () => {
284
+ process.env[PREBUILD_TIMEOUT_ENV] = "not-a-number";
285
+ expect(resolvePrebuildTimeout()).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
286
+ });
287
+
288
+ it("ignores invalid env var (zero / negative) and falls back to default", () => {
289
+ process.env[PREBUILD_TIMEOUT_ENV] = "0";
290
+ expect(resolvePrebuildTimeout()).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
291
+ process.env[PREBUILD_TIMEOUT_ENV] = "-5";
292
+ expect(resolvePrebuildTimeout()).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
293
+ });
294
+
295
+ it("ignores non-positive explicit arg", () => {
296
+ delete process.env[PREBUILD_TIMEOUT_ENV];
297
+ expect(resolvePrebuildTimeout(0)).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
298
+ expect(resolvePrebuildTimeout(-1)).toBe(DEFAULT_PREBUILD_TIMEOUT_MS);
299
+ });
300
+ });
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Issue #203 — Timeout + error surface regressions
304
+ // ---------------------------------------------------------------------------
305
+
306
+ describe("runPrebuildScripts — timeout surface (Issue #203)", () => {
307
+ let dir = "";
308
+ beforeEach(() => { dir = mktmp("timeout-"); });
309
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
310
+
311
+ it("honours MANDU_PREBUILD_TIMEOUT_MS env override when timeoutMs omitted", async () => {
312
+ const originalEnv = process.env[PREBUILD_TIMEOUT_ENV];
313
+ process.env[PREBUILD_TIMEOUT_ENV] = "9999";
314
+ try {
315
+ writeFile(dir, "scripts/prebuild-1.ts");
316
+ const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
317
+ await runPrebuildScripts({ rootDir: dir, spawn });
318
+ expect(spawn.calls[0].timeoutMs).toBe(9999);
319
+ } finally {
320
+ if (originalEnv === undefined) delete process.env[PREBUILD_TIMEOUT_ENV];
321
+ else process.env[PREBUILD_TIMEOUT_ENV] = originalEnv;
322
+ }
323
+ });
324
+
325
+ it("explicit timeoutMs wins over env var", async () => {
326
+ const originalEnv = process.env[PREBUILD_TIMEOUT_ENV];
327
+ process.env[PREBUILD_TIMEOUT_ENV] = "9999";
328
+ try {
329
+ writeFile(dir, "scripts/prebuild-1.ts");
330
+ const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
331
+ await runPrebuildScripts({ rootDir: dir, spawn, timeoutMs: 2222 });
332
+ expect(spawn.calls[0].timeoutMs).toBe(2222);
333
+ } finally {
334
+ if (originalEnv === undefined) delete process.env[PREBUILD_TIMEOUT_ENV];
335
+ else process.env[PREBUILD_TIMEOUT_ENV] = originalEnv;
336
+ }
337
+ });
338
+
339
+ it("defaultSpawn throws PrebuildTimeoutError with script name + limit when the script runs longer than timeout", async () => {
340
+ // Script sleeps 300ms — we set timeout 80ms, so the timer must win.
341
+ // We use `Bun.sleep` + `process.exit(0)` so the script exits cleanly
342
+ // if somehow the kill is skipped (no zombie in test harness).
343
+ const scriptAbs = writeFile(
344
+ dir,
345
+ "scripts/prebuild-slow.ts",
346
+ "await Bun.sleep(300);\nprocess.exit(0);\n",
347
+ );
348
+
349
+ let caught: unknown;
350
+ try {
351
+ await defaultSpawn({ scriptPath: scriptAbs, cwd: dir, timeoutMs: 80 });
352
+ } catch (e) {
353
+ caught = e;
354
+ }
355
+ expect(caught).toBeInstanceOf(PrebuildTimeoutError);
356
+ expect(caught).toBeInstanceOf(PrebuildError); // subclass relationship
357
+ const err = caught as PrebuildTimeoutError;
358
+ expect(err.timeoutMs).toBe(80);
359
+ expect(err.scriptPath).toContain("prebuild-slow.ts");
360
+ // Message contract: includes script name + timeout limit + override hint.
361
+ expect(err.message).toContain("prebuild-slow.ts");
362
+ expect(err.message).toContain("80ms");
363
+ expect(err.message).toContain("dev.prebuildTimeoutMs");
364
+ expect(err.message).toContain(PREBUILD_TIMEOUT_ENV);
365
+ // Regression beacon: message MUST NOT contain the opaque
366
+ // "non-Error thrown" string Issue #203 was reported against.
367
+ expect(err.message).not.toContain("non-Error thrown");
368
+ });
369
+
370
+ it("runPrebuildScripts surfaces PrebuildTimeoutError via injected spawn", async () => {
371
+ writeFile(dir, "scripts/prebuild-slow.ts");
372
+ const spawn: SpawnHook = async (args) => {
373
+ throw new PrebuildTimeoutError({
374
+ scriptPath: args.scriptPath,
375
+ timeoutMs: args.timeoutMs,
376
+ durationMs: args.timeoutMs,
377
+ });
378
+ };
379
+ let caught: unknown;
380
+ try {
381
+ await runPrebuildScripts({ rootDir: dir, spawn, timeoutMs: 100 });
382
+ } catch (e) {
383
+ caught = e;
384
+ }
385
+ expect(caught).toBeInstanceOf(PrebuildTimeoutError);
386
+ const err = caught as PrebuildTimeoutError;
387
+ expect(err.timeoutMs).toBe(100);
388
+ expect(err.scriptPath).toContain("prebuild-slow.ts");
389
+ });
390
+ });
391
+
392
+ describe("runPrebuildScripts — error preservation (Issue #203)", () => {
393
+ let dir = "";
394
+ beforeEach(() => { dir = mktmp("err-surface-"); });
395
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
396
+
397
+ it("preserves the inner Error message ('boom') when a spawn rejection occurs", async () => {
398
+ writeFile(dir, "scripts/prebuild.ts");
399
+ const inner = new Error("boom");
400
+ const spawn: SpawnHook = async () => {
401
+ throw inner;
402
+ };
403
+ let caught: unknown;
404
+ try {
405
+ await runPrebuildScripts({ rootDir: dir, spawn });
406
+ } catch (e) {
407
+ caught = e;
408
+ }
409
+ expect(caught).toBeInstanceOf(PrebuildError);
410
+ const err = caught as PrebuildError & { cause?: unknown };
411
+ // The inner message MUST appear verbatim — the whole point of #203.
412
+ expect(err.message).toContain("boom");
413
+ // Regression beacon.
414
+ expect(err.message).not.toContain("non-Error thrown");
415
+ // The inner error is attached as `.cause` so debug tooling can
416
+ // recover its stack.
417
+ expect(err.cause).toBe(inner);
418
+ });
419
+
420
+ it("preserves stack via .cause when inner Error has a stack", async () => {
421
+ writeFile(dir, "scripts/prebuild.ts");
422
+ const inner = new Error("kaboom");
423
+ const originalStack = inner.stack;
424
+ expect(originalStack).toBeTruthy();
425
+ const spawn: SpawnHook = async () => {
426
+ throw inner;
427
+ };
428
+ let caught: unknown;
429
+ try {
430
+ await runPrebuildScripts({ rootDir: dir, spawn });
431
+ } catch (e) {
432
+ caught = e;
433
+ }
434
+ const err = caught as PrebuildError & { cause?: Error };
435
+ expect(err.cause).toBe(inner);
436
+ expect(err.cause?.stack).toBe(originalStack);
437
+ });
438
+
439
+ it("handles non-Error rejections (string) without producing 'non-Error thrown'", async () => {
440
+ writeFile(dir, "scripts/prebuild.ts");
441
+ const spawn: SpawnHook = async () => {
442
+ // Raw string throw — the pathological case Issue #203 was about.
443
+ throw "spawn blew up";
444
+ };
445
+ let caught: unknown;
446
+ try {
447
+ await runPrebuildScripts({ rootDir: dir, spawn });
448
+ } catch (e) {
449
+ caught = e;
450
+ }
451
+ expect(caught).toBeInstanceOf(PrebuildError);
452
+ const err = caught as PrebuildError & { cause?: unknown };
453
+ // The raw string survives into `.message`.
454
+ expect(err.message).toContain("spawn blew up");
455
+ expect(err.message).not.toContain("non-Error thrown");
456
+ expect(err.cause).toBe("spawn blew up");
457
+ });
458
+
459
+ it("handles non-Error rejections (object) without producing 'non-Error thrown'", async () => {
460
+ writeFile(dir, "scripts/prebuild.ts");
461
+ const raw = { code: "EBADF", info: "fd closed" };
462
+ const spawn: SpawnHook = async () => {
463
+ throw raw;
464
+ };
465
+ let caught: unknown;
466
+ try {
467
+ await runPrebuildScripts({ rootDir: dir, spawn });
468
+ } catch (e) {
469
+ caught = e;
470
+ }
471
+ const err = caught as PrebuildError & { cause?: unknown };
472
+ expect(err.message).toContain("EBADF");
473
+ expect(err.message).not.toContain("non-Error thrown");
474
+ expect(err.cause).toBe(raw);
475
+ });
476
+
477
+ it("includes stdout/stderr tails in PrebuildError.message for non-zero exit", async () => {
478
+ writeFile(dir, "scripts/prebuild.ts");
479
+ const stderrSample = Array.from({ length: 15 }, (_, i) => `err-line-${i + 1}`).join("\n");
480
+ const stdoutSample = Array.from({ length: 12 }, (_, i) => `out-line-${i + 1}`).join("\n");
481
+ const spawn: SpawnHook = async () => ({
482
+ exitCode: 1,
483
+ durationMs: 5,
484
+ stdoutTail: stdoutSample,
485
+ stderrTail: stderrSample,
486
+ });
487
+ let caught: unknown;
488
+ try {
489
+ await runPrebuildScripts({ rootDir: dir, spawn });
490
+ } catch (e) {
491
+ caught = e;
492
+ }
493
+ expect(caught).toBeInstanceOf(PrebuildError);
494
+ const err = caught as PrebuildError;
495
+ // Exactly last 10 stderr lines are kept.
496
+ expect(err.message).toContain("err-line-15");
497
+ expect(err.message).toContain("err-line-6"); // the 10-from-last
498
+ expect(err.message).not.toContain("err-line-5"); // trimmed
499
+ // Stdout tail is also present (10 lines out of 12).
500
+ expect(err.message).toContain("out-line-12");
501
+ expect(err.message).toContain("out-line-3");
502
+ expect(err.message).not.toContain("out-line-2");
503
+ // Structured fields carry the full captured tails.
504
+ expect(err.stderrTail).toBe(stderrSample);
505
+ expect(err.stdoutTail).toBe(stdoutSample);
506
+ });
507
+
508
+ it("omits empty tail sections when the spawn hook didn't capture", async () => {
509
+ writeFile(dir, "scripts/prebuild.ts");
510
+ const spawn: SpawnHook = async () => ({ exitCode: 2, durationMs: 3 });
511
+ let caught: unknown;
512
+ try {
513
+ await runPrebuildScripts({ rootDir: dir, spawn });
514
+ } catch (e) {
515
+ caught = e;
516
+ }
517
+ const err = caught as PrebuildError;
518
+ // The "exited with code 2" prefix is present but no "--- stderr ---" sections.
519
+ expect(err.message).toContain("exited with code 2");
520
+ expect(err.message).not.toContain("--- stderr");
521
+ expect(err.message).not.toContain("--- stdout");
522
+ });
523
+
524
+ it("normalizes Windows-style CRLF line endings in stderr tail", async () => {
525
+ writeFile(dir, "scripts/prebuild.ts");
526
+ const crlf = ["a", "b", "c", "d"].join("\r\n");
527
+ const spawn: SpawnHook = async () => ({
528
+ exitCode: 1,
529
+ durationMs: 1,
530
+ stderrTail: crlf,
531
+ });
532
+ let caught: unknown;
533
+ try {
534
+ await runPrebuildScripts({ rootDir: dir, spawn });
535
+ } catch (e) {
536
+ caught = e;
537
+ }
538
+ const err = caught as PrebuildError;
539
+ // All 4 lines survive (under the 10-line cap).
540
+ expect(err.message).toContain("a\nb\nc\nd");
541
+ // No raw \r\n survived.
542
+ expect(err.message).not.toContain("\r\n");
543
+ });
544
+ });
545
+
546
+ describe("PrebuildTimeoutError shape (Issue #203)", () => {
547
+ it("is a subclass of PrebuildError so existing `instanceof PrebuildError` callers still match", () => {
548
+ const err = new PrebuildTimeoutError({
549
+ scriptPath: "/tmp/foo/scripts/prebuild.ts",
550
+ timeoutMs: 500,
551
+ durationMs: 500,
552
+ });
553
+ expect(err).toBeInstanceOf(PrebuildTimeoutError);
554
+ expect(err).toBeInstanceOf(PrebuildError);
555
+ expect(err).toBeInstanceOf(Error);
556
+ expect(err.name).toBe("PrebuildTimeoutError");
557
+ expect(err.exitCode).toBeNull();
558
+ });
559
+
560
+ it("message names the script + limit + override paths", () => {
561
+ const err = new PrebuildTimeoutError({
562
+ scriptPath: "/repo/scripts/prebuild-seed.ts",
563
+ timeoutMs: 250,
564
+ durationMs: 250,
565
+ });
566
+ expect(err.message).toContain("prebuild-seed.ts");
567
+ expect(err.message).toContain("250ms");
568
+ expect(err.message).toContain("dev.prebuildTimeoutMs");
569
+ expect(err.message).toContain(PREBUILD_TIMEOUT_ENV);
570
+ });
571
+ });
@@ -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: