@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 +1 -1
- package/src/config/mandu.ts +18 -0
- package/src/config/validate.ts +8 -0
- package/src/content/prebuild.test.ts +322 -0
- package/src/content/prebuild.ts +261 -25
package/package.json
CHANGED
package/src/config/mandu.ts
CHANGED
|
@@ -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;
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/content/prebuild.ts
CHANGED
|
@@ -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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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.
|
|
161
|
-
*
|
|
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 `{
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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<{
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
299
|
-
*
|
|
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
|
|
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: ${
|
|
547
|
+
`Prebuild script '${scriptPath}' failed: ${message}`,
|
|
352
548
|
{
|
|
353
549
|
scriptPath,
|
|
354
550
|
exitCode: null,
|
|
355
551
|
durationMs: 0,
|
|
356
|
-
cause
|
|
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
|
-
|
|
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:
|