shakapacker 9.3.0.beta.0 → 9.3.0.beta.1

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.
@@ -0,0 +1,883 @@
1
+ import { spawn } from "child_process"
2
+ import { existsSync } from "fs"
3
+ import { resolve, relative, sep } from "path"
4
+ import { ResolvedBuildConfig, BuildValidationResult } from "./types"
5
+
6
+ export interface ValidatorOptions {
7
+ verbose: boolean
8
+ timeout?: number // milliseconds
9
+ strictBinaryResolution?: boolean // If true, fail if binaries not found locally (recommended for CI)
10
+ maxConcurrentBuilds?: number // Maximum number of builds to validate concurrently
11
+ }
12
+
13
+ /**
14
+ * Maximum buffer size for stdout/stderr to prevent memory exhaustion
15
+ */
16
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024 // 10MB
17
+
18
+ /**
19
+ * Default timeout for build validation in milliseconds
20
+ */
21
+ const DEFAULT_TIMEOUT_MS = 120000 // 2 minutes
22
+
23
+ /**
24
+ * Safety timeout after SIGTERM before forcing resolution (milliseconds)
25
+ */
26
+ const KILL_SAFETY_TIMEOUT_MS = 5000 // 5 seconds
27
+
28
+ /**
29
+ * Exit code for SIGTERM signal
30
+ */
31
+ const SIGTERM_EXIT_CODE = 143
32
+
33
+ /**
34
+ * TypeScript interface for webpack/rspack JSON output structure
35
+ */
36
+ interface WebpackJsonOutput {
37
+ errors?: Array<string | { message: string }>
38
+ warnings?: Array<string | { message: string }>
39
+ hash?: string
40
+ time?: number
41
+ builtAt?: number
42
+ outputPath?: string
43
+ }
44
+
45
+ /**
46
+ * Whitelisted environment variables that are safe to pass to build processes.
47
+ * This prevents arbitrary environment variable injection from config files.
48
+ *
49
+ * Note: PATH is essential for webpack/rspack to find node and other binaries.
50
+ * HOME is needed for tools that read user config (e.g., .npmrc, .yarnrc).
51
+ */
52
+ const SAFE_ENV_VARS = [
53
+ "PATH",
54
+ "HOME",
55
+ "NODE_ENV",
56
+ "RAILS_ENV",
57
+ "NODE_OPTIONS",
58
+ "BABEL_ENV",
59
+ "WEBPACK_SERVE",
60
+ "HMR",
61
+ "CLIENT_BUNDLE_ONLY",
62
+ "SERVER_BUNDLE_ONLY",
63
+ "PUBLIC_URL",
64
+ "ASSET_HOST",
65
+ "CDN_HOST",
66
+ "TMPDIR",
67
+ "TEMP",
68
+ "TMP"
69
+ ] as const
70
+
71
+ /**
72
+ * Success patterns for detecting successful compilation in webpack/rspack output.
73
+ * These patterns are used to determine when webpack-dev-server has successfully
74
+ * compiled and is ready to serve, or when a static build has completed.
75
+ *
76
+ * Note: Patterns use substring matching, not exact matching, to support version variations.
77
+ * For example, "webpack 5." matches "webpack 5.95.0 compiled successfully"
78
+ *
79
+ * Patterns are checked after excluding lines starting with ERROR: or WARNING:
80
+ * to prevent false positives in error messages.
81
+ */
82
+ const SUCCESS_PATTERNS = [
83
+ "webpack compiled",
84
+ "Compiled successfully",
85
+ "rspack compiled successfully",
86
+ "webpack: Compiled successfully",
87
+ "Compilation completed",
88
+ "wds: Compiled successfully", // webpack-dev-server 4.x
89
+ "webpack-dev-server: Compiled", // webpack-dev-server 5.x
90
+ "[webpack-dev-server] Compiled successfully", // webpack-dev-server 5.x alternative format
91
+ "webpack 5.", // matches "webpack 5.95.0 compiled successfully" (any 5.x.x version)
92
+ "rspack 0.", // matches "rspack 0.7.5 compiled successfully" (any 0.x.x version)
93
+ "rspack-dev-server: Compiled" // rspack-dev-server output
94
+ ]
95
+
96
+ /**
97
+ * Error patterns for detecting compilation errors in webpack/rspack output
98
+ */
99
+ const ERROR_PATTERNS = ["ERROR", "Error:", "Failed to compile"]
100
+
101
+ /**
102
+ * Warning patterns for detecting compilation warnings in webpack/rspack output
103
+ */
104
+ const WARNING_PATTERNS = ["WARNING", "Warning:"]
105
+
106
+ /**
107
+ * Pattern to detect suspicious characters in environment variable values
108
+ * that could indicate command injection attempts
109
+ */
110
+ const SUSPICIOUS_ENV_PATTERN = /[;&|`$()]/
111
+
112
+ /**
113
+ * Validates webpack/rspack builds by running them and checking for errors
114
+ * For HMR builds, starts webpack-dev-server and shuts down after successful start
115
+ */
116
+ export class BuildValidator {
117
+ private options: ValidatorOptions
118
+
119
+ constructor(options: ValidatorOptions) {
120
+ this.options = {
121
+ verbose: options.verbose,
122
+ timeout: options.timeout || DEFAULT_TIMEOUT_MS,
123
+ strictBinaryResolution:
124
+ options.strictBinaryResolution ||
125
+ process.env.CI === "true" ||
126
+ process.env.GITHUB_ACTIONS === "true",
127
+ maxConcurrentBuilds: options.maxConcurrentBuilds || 3
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Filters environment variables to only include whitelisted safe variables.
133
+ * This prevents command injection and limits exposure of sensitive data.
134
+ * Also validates environment variable values for suspicious patterns.
135
+ */
136
+ private filterEnvironment(
137
+ buildEnv: Record<string, string>
138
+ ): Record<string, string> {
139
+ const filtered: Record<string, string> = {}
140
+
141
+ // Start with current process.env but only whitelisted vars
142
+ SAFE_ENV_VARS.forEach((key) => {
143
+ if (process.env[key]) {
144
+ filtered[key] = process.env[key]!
145
+ }
146
+ })
147
+
148
+ // Override with build-specific env vars (also filtered)
149
+ Object.entries(buildEnv).forEach(([key, value]) => {
150
+ if ((SAFE_ENV_VARS as readonly string[]).includes(key)) {
151
+ // Validate for suspicious patterns that could indicate command injection
152
+ if (SUSPICIOUS_ENV_PATTERN.test(value)) {
153
+ if (this.options.verbose) {
154
+ console.warn(
155
+ ` [Security Warning] Suspicious pattern detected in environment variable ${key}: ${value}`
156
+ )
157
+ }
158
+ }
159
+ filtered[key] = value
160
+ }
161
+ })
162
+
163
+ return filtered
164
+ }
165
+
166
+ /**
167
+ * Validates that a config file exists and returns the resolved path.
168
+ * Throws an error if the config file is not found or attempts path traversal.
169
+ *
170
+ * @param configFile - The config file path from the build configuration
171
+ * @param appRoot - The application root directory
172
+ * @param buildName - The name of the build (for error messages)
173
+ * @returns The resolved absolute path to the config file
174
+ * @throws Error if the config file does not exist or is outside appRoot
175
+ */
176
+ private validateConfigPath(
177
+ configFile: string,
178
+ appRoot: string,
179
+ buildName: string
180
+ ): string {
181
+ const configPath = resolve(appRoot, configFile)
182
+
183
+ // Security: Ensure resolved path is within appRoot using path.relative
184
+ // This works cross-platform (Windows/Unix) and prevents path traversal attacks
185
+ const rel = relative(appRoot, configPath)
186
+
187
+ // Path is valid if:
188
+ // 1. rel === "" (same as appRoot) OR
189
+ // 2. rel doesn't start with ".." (not outside appRoot)
190
+ // Note: On Windows, ".." will be used for parent dir regardless of path separator
191
+ if (rel !== "" && rel.startsWith("..")) {
192
+ throw new Error(
193
+ `Invalid config file path for build '${buildName}': Path must be within project directory. ` +
194
+ `Config file: ${configFile}, Resolved path: ${configPath}, Project root: ${appRoot}`
195
+ )
196
+ }
197
+
198
+ if (!existsSync(configPath)) {
199
+ throw new Error(
200
+ `Config file not found for build '${buildName}': ${configPath}. ` +
201
+ `Check the 'config' setting in your build configuration.`
202
+ )
203
+ }
204
+
205
+ return configPath
206
+ }
207
+
208
+ /**
209
+ * Validates a single build configuration by running the appropriate bundler command.
210
+ * For HMR builds, starts webpack-dev-server and validates successful compilation.
211
+ * For static builds, runs a full build and validates the output.
212
+ *
213
+ * @param build - The resolved build configuration to validate
214
+ * @param appRoot - The application root directory
215
+ * @returns A promise that resolves to the build validation result
216
+ */
217
+ async validateBuild(
218
+ build: ResolvedBuildConfig,
219
+ appRoot: string
220
+ ): Promise<BuildValidationResult> {
221
+ // Detect HMR builds by checking for WEBPACK_SERVE or HMR environment variables
222
+ const isHMR =
223
+ build.environment.WEBPACK_SERVE === "true" ||
224
+ build.environment.HMR === "true"
225
+ const bundler = build.bundler
226
+
227
+ if (isHMR) {
228
+ return this.validateHMRBuild(build, appRoot, bundler)
229
+ } else {
230
+ return this.validateStaticBuild(build, appRoot, bundler)
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Validates an HMR build by starting webpack-dev-server
236
+ * Waits for successful compilation, then shuts down
237
+ */
238
+ private async validateHMRBuild(
239
+ build: ResolvedBuildConfig,
240
+ appRoot: string,
241
+ bundler: "webpack" | "rspack"
242
+ ): Promise<BuildValidationResult> {
243
+ const startTime = Date.now()
244
+ const result: BuildValidationResult = {
245
+ buildName: build.name,
246
+ success: false,
247
+ errors: [],
248
+ warnings: [],
249
+ output: [],
250
+ outputs: build.outputs,
251
+ configFile: build.configFile,
252
+ startTime
253
+ }
254
+
255
+ // Determine the dev server command
256
+ const devServerCmd =
257
+ bundler === "rspack" ? "rspack-dev-server" : "webpack-dev-server"
258
+ const devServerBin = this.findBinary(devServerCmd, appRoot)
259
+
260
+ if (!devServerBin) {
261
+ const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
262
+ ? "yarn add"
263
+ : "npm install"
264
+ result.errors.push(
265
+ `Could not find ${devServerCmd} binary. Please install it:\n` +
266
+ ` ${packageManager} -D ${bundler}-dev-server`
267
+ )
268
+ return result
269
+ }
270
+
271
+ // Build arguments
272
+ const args: string[] = []
273
+
274
+ // Add config file if specified
275
+ if (build.configFile) {
276
+ try {
277
+ const configPath = this.validateConfigPath(
278
+ build.configFile,
279
+ appRoot,
280
+ build.name
281
+ )
282
+ args.push("--config", configPath)
283
+ } catch (error) {
284
+ const errorMessage =
285
+ error instanceof Error ? error.message : String(error)
286
+ result.errors.push(errorMessage)
287
+ return result
288
+ }
289
+ } else {
290
+ // Use default config path
291
+ const defaultConfig = resolve(
292
+ appRoot,
293
+ `config/${bundler}/${bundler}.config.js`
294
+ )
295
+ if (existsSync(defaultConfig)) {
296
+ args.push("--config", defaultConfig)
297
+ }
298
+ }
299
+
300
+ // Add bundler env args (--env flags)
301
+ if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
302
+ args.push(...build.bundlerEnvArgs)
303
+ }
304
+
305
+ return new Promise((resolve) => {
306
+ const child = spawn(devServerBin, args, {
307
+ cwd: appRoot,
308
+ env: this.filterEnvironment(build.environment),
309
+ stdio: ["ignore", "pipe", "pipe"]
310
+ })
311
+
312
+ let hasCompiled = false
313
+ let hasError = false
314
+ let resolved = false
315
+ let processKilled = false
316
+
317
+ const resolveOnce = (res: BuildValidationResult) => {
318
+ if (!resolved) {
319
+ resolved = true
320
+ resolve(res)
321
+ }
322
+ }
323
+
324
+ const timeoutId = setTimeout(() => {
325
+ if (!hasCompiled && !resolved && !processKilled) {
326
+ result.errors.push(
327
+ `Timeout: webpack-dev-server did not compile within ${this.options.timeout}ms.`
328
+ )
329
+ processKilled = true
330
+ child.kill("SIGTERM")
331
+ // Remove listeners to prevent further callbacks
332
+ child.stdout?.removeAllListeners()
333
+ child.stderr?.removeAllListeners()
334
+ child.removeAllListeners()
335
+ resolveOnce(result)
336
+ }
337
+ }, this.options.timeout)
338
+
339
+ const processOutput = (data: Buffer) => {
340
+ const lines = data.toString().split("\n")
341
+ lines.forEach((line) => {
342
+ if (!line.trim()) return
343
+
344
+ // Always output in real-time in verbose mode so user sees progress
345
+ if (this.options.verbose) {
346
+ console.log(` ${line}`)
347
+ }
348
+
349
+ // Store all output
350
+ result.output.push(line)
351
+
352
+ // Check for successful compilation
353
+ // Only match success patterns if the line doesn't start with ERROR: or WARNING:
354
+ const isErrorOrWarning =
355
+ line.trim().startsWith("ERROR") || line.trim().startsWith("WARNING")
356
+ if (
357
+ !processKilled &&
358
+ !isErrorOrWarning &&
359
+ SUCCESS_PATTERNS.some((pattern) => line.includes(pattern))
360
+ ) {
361
+ hasCompiled = true
362
+ result.success = true
363
+ // Set processKilled BEFORE clearing timeout to prevent race condition
364
+ // where timeout could fire between clearTimeout and setting the flag
365
+ processKilled = true
366
+ clearTimeout(timeoutId)
367
+ child.kill("SIGTERM")
368
+ // Don't call resolveOnce here - let the exit handler do it
369
+ // This ensures proper cleanup order and avoids race conditions
370
+
371
+ // Safety timeout: if process doesn't exit within 5 seconds, force resolve
372
+ // This prevents hanging if kill() fails or process is unresponsive
373
+ setTimeout(() => {
374
+ if (!resolved) {
375
+ if (this.options.verbose) {
376
+ console.warn(
377
+ ` [Warning] Process did not exit after SIGTERM, forcing resolution.`
378
+ )
379
+ }
380
+ child.stdout?.removeAllListeners()
381
+ child.stderr?.removeAllListeners()
382
+ child.removeAllListeners()
383
+ resolveOnce(result)
384
+ }
385
+ }, KILL_SAFETY_TIMEOUT_MS)
386
+ }
387
+
388
+ // Check for errors
389
+ if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
390
+ hasError = true
391
+ result.errors.push(line)
392
+ }
393
+
394
+ // Check for warnings
395
+ if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
396
+ result.warnings.push(line)
397
+ }
398
+ })
399
+ }
400
+
401
+ child.stdout?.on("data", (data) => processOutput(data))
402
+ child.stderr?.on("data", (data) => processOutput(data))
403
+
404
+ child.on("exit", (code) => {
405
+ clearTimeout(timeoutId)
406
+ // Clean up listeners after exit
407
+ child.stdout?.removeAllListeners()
408
+ child.stderr?.removeAllListeners()
409
+ child.removeAllListeners()
410
+
411
+ // Record timing
412
+ result.endTime = Date.now()
413
+ result.duration = result.endTime - (result.startTime || result.endTime)
414
+
415
+ if (!hasCompiled && !hasError && !resolved) {
416
+ if (code !== 0 && code !== null && code !== SIGTERM_EXIT_CODE) {
417
+ result.errors.push(
418
+ `${devServerCmd} exited with code ${code} before compilation completed.`
419
+ )
420
+ }
421
+ }
422
+ resolveOnce(result)
423
+ })
424
+
425
+ child.on("error", (err) => {
426
+ clearTimeout(timeoutId)
427
+ // Provide more helpful error messages for common spawn failures
428
+ let errorMessage = `Failed to start ${devServerCmd}: ${err.message}`
429
+
430
+ // Check for specific error codes and provide actionable guidance
431
+ if ("code" in err) {
432
+ const code = (err as NodeJS.ErrnoException).code
433
+ if (code === "ENOENT") {
434
+ errorMessage += `. Binary not found. Install with: npm install -D ${devServerCmd}`
435
+ } else if (code === "EMFILE" || code === "ENFILE") {
436
+ errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
437
+ } else if (code === "EACCES") {
438
+ errorMessage += `. Permission denied. Check file permissions for the binary`
439
+ }
440
+ }
441
+
442
+ result.errors.push(errorMessage)
443
+ resolveOnce(result)
444
+ })
445
+ })
446
+ }
447
+
448
+ /**
449
+ * Validates a static build by running webpack/rspack in production mode
450
+ * Uses --json flag to get structured output
451
+ */
452
+ private async validateStaticBuild(
453
+ build: ResolvedBuildConfig,
454
+ appRoot: string,
455
+ bundler: "webpack" | "rspack"
456
+ ): Promise<BuildValidationResult> {
457
+ const startTime = Date.now()
458
+ const result: BuildValidationResult = {
459
+ buildName: build.name,
460
+ success: false,
461
+ errors: [],
462
+ warnings: [],
463
+ output: [],
464
+ outputs: build.outputs,
465
+ configFile: build.configFile,
466
+ startTime
467
+ }
468
+
469
+ const bundlerBin = this.findBinary(bundler, appRoot)
470
+
471
+ if (!bundlerBin) {
472
+ const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
473
+ ? "yarn add"
474
+ : "npm install"
475
+ result.errors.push(
476
+ `Could not find ${bundler} binary. Please install it:\n` +
477
+ ` ${packageManager} -D ${bundler}`
478
+ )
479
+ return result
480
+ }
481
+
482
+ // Build arguments - use --dry-run if available, otherwise just build
483
+ const args: string[] = []
484
+
485
+ // Add config file if specified
486
+ if (build.configFile) {
487
+ try {
488
+ const configPath = this.validateConfigPath(
489
+ build.configFile,
490
+ appRoot,
491
+ build.name
492
+ )
493
+ args.push("--config", configPath)
494
+ } catch (error) {
495
+ const errorMessage =
496
+ error instanceof Error ? error.message : String(error)
497
+ result.errors.push(errorMessage)
498
+ return result
499
+ }
500
+ } else {
501
+ // Use default config path
502
+ const defaultConfig = resolve(
503
+ appRoot,
504
+ `config/${bundler}/${bundler}.config.js`
505
+ )
506
+ if (existsSync(defaultConfig)) {
507
+ args.push("--config", defaultConfig)
508
+ }
509
+ }
510
+
511
+ // Add bundler env args (--env flags)
512
+ if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
513
+ args.push(...build.bundlerEnvArgs)
514
+ }
515
+
516
+ // Add --json for structured output (helps parse errors)
517
+ args.push("--json")
518
+
519
+ return new Promise((resolve) => {
520
+ const child = spawn(bundlerBin, args, {
521
+ cwd: appRoot,
522
+ env: this.filterEnvironment(build.environment),
523
+ stdio: ["ignore", "pipe", "pipe"]
524
+ })
525
+
526
+ const stdoutChunks: Buffer[] = []
527
+ const stderrChunks: Buffer[] = []
528
+
529
+ let stdoutSize = 0
530
+ let stderrSize = 0
531
+ let bufferOverflow = false
532
+
533
+ const timeoutId = setTimeout(() => {
534
+ result.errors.push(
535
+ `Timeout: ${bundler} did not complete within ${this.options.timeout}ms.`
536
+ )
537
+ child.kill("SIGTERM")
538
+ resolve(result)
539
+ }, this.options.timeout)
540
+
541
+ child.stdout?.on("data", (data: Buffer) => {
542
+ // Check buffer size to prevent memory issues
543
+ if (stdoutSize + data.length > MAX_BUFFER_SIZE) {
544
+ if (!bufferOverflow) {
545
+ bufferOverflow = true
546
+ const warning = `Output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build output is too large - data will be truncated.`
547
+ result.warnings.push(warning)
548
+ if (this.options.verbose) {
549
+ console.warn(` [Warning] ${warning}`)
550
+ }
551
+ }
552
+ // Explicitly skip this chunk - don't silently drop
553
+ return
554
+ }
555
+
556
+ stdoutChunks.push(data)
557
+ stdoutSize += data.length
558
+
559
+ // Don't output JSON in verbose mode - it's too large and not useful
560
+ // JSON is for parsing errors, not for human consumption
561
+ })
562
+
563
+ child.stderr?.on("data", (data: Buffer) => {
564
+ // Check buffer size
565
+ if (stderrSize + data.length > MAX_BUFFER_SIZE) {
566
+ if (!bufferOverflow) {
567
+ bufferOverflow = true
568
+ const warning = `Error output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build errors are too large - data will be truncated.`
569
+ result.warnings.push(warning)
570
+ if (this.options.verbose) {
571
+ console.warn(` [Warning] ${warning}`)
572
+ }
573
+ }
574
+ // Explicitly skip this chunk - don't silently drop
575
+ return
576
+ }
577
+
578
+ stderrChunks.push(data)
579
+ stderrSize += data.length
580
+
581
+ // In verbose mode, show useful stderr output (warnings, progress, etc.)
582
+ if (this.options.verbose) {
583
+ const output = data.toString()
584
+ // Only show meaningful output, not just noise
585
+ const lines = output.split("\n")
586
+ lines.forEach((line) => {
587
+ if (line.trim()) {
588
+ console.log(` ${line}`)
589
+ }
590
+ })
591
+ }
592
+ })
593
+
594
+ child.on("exit", (code) => {
595
+ clearTimeout(timeoutId)
596
+
597
+ // Record timing
598
+ result.endTime = Date.now()
599
+ result.duration = result.endTime - (result.startTime || result.endTime)
600
+
601
+ // Combine chunks into strings
602
+ const stdoutData = Buffer.concat(stdoutChunks).toString()
603
+ const stderrData = Buffer.concat(stderrChunks).toString()
604
+
605
+ // Parse JSON output
606
+ try {
607
+ const jsonOutput: WebpackJsonOutput = JSON.parse(stdoutData)
608
+
609
+ // Extract output path if available
610
+ if (jsonOutput.outputPath) {
611
+ result.outputPath = jsonOutput.outputPath
612
+ }
613
+
614
+ // Check for errors in webpack/rspack JSON output
615
+ if (jsonOutput.errors && jsonOutput.errors.length > 0) {
616
+ jsonOutput.errors.forEach((error) => {
617
+ const errorMsg =
618
+ typeof error === "string"
619
+ ? error
620
+ : error.message || String(error)
621
+ result.errors.push(errorMsg)
622
+ // Also add to output for visibility
623
+ if (!this.options.verbose) {
624
+ result.output.push(errorMsg)
625
+ }
626
+ })
627
+ }
628
+
629
+ // Check for warnings
630
+ if (jsonOutput.warnings && jsonOutput.warnings.length > 0) {
631
+ jsonOutput.warnings.forEach((warning) => {
632
+ const warningMsg =
633
+ typeof warning === "string"
634
+ ? warning
635
+ : warning.message || String(warning)
636
+ result.warnings.push(warningMsg)
637
+ })
638
+ }
639
+
640
+ result.success =
641
+ code === 0 && (!jsonOutput.errors || jsonOutput.errors.length === 0)
642
+
643
+ // If build failed but no errors were captured, add helpful message
644
+ if (code !== 0 && result.errors.length === 0) {
645
+ result.errors.push(
646
+ `${bundler} exited with code ${code} but no errors were captured. ` +
647
+ `This may indicate a configuration issue. Run with --verbose for full output.`
648
+ )
649
+ }
650
+ } catch (err) {
651
+ // If JSON parsing fails, log the parsing error in verbose mode
652
+ if (this.options.verbose) {
653
+ const parseError = err instanceof Error ? err.message : String(err)
654
+ console.log(` [Debug] Failed to parse JSON output: ${parseError}`)
655
+ }
656
+
657
+ // Fall back to stderr analysis
658
+ if (stderrData && stderrData.length > 0) {
659
+ const lines = stderrData.split("\n")
660
+ lines.forEach((line) => {
661
+ if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
662
+ result.errors.push(line)
663
+ }
664
+ if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
665
+ result.warnings.push(line)
666
+ }
667
+ })
668
+ }
669
+
670
+ if (code !== 0) {
671
+ result.errors.push(`${bundler} exited with code ${code}.`)
672
+ }
673
+
674
+ result.success = code === 0 && result.errors.length === 0
675
+ }
676
+
677
+ // Add stderr to output if there were errors and not verbose
678
+ if (
679
+ !this.options.verbose &&
680
+ result.errors.length > 0 &&
681
+ stderrData &&
682
+ stderrData.length > 0
683
+ ) {
684
+ result.output.push(stderrData)
685
+ }
686
+
687
+ resolve(result)
688
+ })
689
+
690
+ child.on("error", (err) => {
691
+ clearTimeout(timeoutId)
692
+ // Provide more helpful error messages for common spawn failures
693
+ let errorMessage = `Failed to start ${bundler}: ${err.message}`
694
+
695
+ // Check for specific error codes and provide actionable guidance
696
+ if ("code" in err) {
697
+ const code = (err as NodeJS.ErrnoException).code
698
+ if (code === "ENOENT") {
699
+ errorMessage += `. Binary not found. Install with: npm install -D ${bundler}`
700
+ } else if (code === "EMFILE" || code === "ENFILE") {
701
+ errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
702
+ } else if (code === "EACCES") {
703
+ errorMessage += `. Permission denied. Check file permissions for the binary`
704
+ }
705
+ }
706
+
707
+ result.errors.push(errorMessage)
708
+ resolve(result)
709
+ })
710
+ })
711
+ }
712
+
713
+ /**
714
+ * Finds the binary for webpack, rspack, or dev servers.
715
+ * Prefers local node_modules/.bin installation for security.
716
+ * Falls back to global installation and PATH resolution with a warning in verbose mode.
717
+ *
718
+ * SECURITY NOTE: The PATH fallback allows resolving binaries from the system PATH,
719
+ * which could be a security risk in untrusted environments where an attacker could
720
+ * manipulate the PATH environment variable. This fallback is included for flexibility
721
+ * and backward compatibility with systems that use npx or have binaries installed in
722
+ * non-standard locations. In production CI/CD environments, ensure binaries are
723
+ * installed locally in node_modules to avoid PATH resolution.
724
+ *
725
+ * @param name - The binary name to find (e.g., "webpack", "webpack-dev-server")
726
+ * @param appRoot - The application root directory
727
+ * @returns The path to the binary, or the bare name for PATH resolution
728
+ */
729
+ private findBinary(name: string, appRoot: string): string | null {
730
+ // Try node_modules/.bin (preferred for security)
731
+ const nodeModulesBin = resolve(appRoot, "node_modules", ".bin", name)
732
+ if (existsSync(nodeModulesBin)) {
733
+ return nodeModulesBin
734
+ }
735
+
736
+ // Try global installation
737
+ const globalBin = resolve("/usr/local/bin", name)
738
+ if (existsSync(globalBin)) {
739
+ if (this.options.verbose) {
740
+ console.log(
741
+ ` [Security Warning] Using global ${name} from /usr/local/bin. ` +
742
+ `Consider installing locally: npm install -D ${name}`
743
+ )
744
+ }
745
+ return globalBin
746
+ }
747
+
748
+ // Fall back to PATH resolution (least secure but most flexible)
749
+ // SECURITY: This allows the binary to be found via PATH, which could be
750
+ // exploited if an attacker controls the PATH environment variable.
751
+
752
+ // In strict mode (CI environments), fail instead of falling back to PATH
753
+ if (this.options.strictBinaryResolution) {
754
+ return null // Caller will handle the error
755
+ }
756
+
757
+ if (this.options.verbose) {
758
+ console.log(
759
+ ` [Security Warning] Binary '${name}' not found locally. ` +
760
+ `Falling back to PATH resolution. In production, install locally: npm install -D ${name}`
761
+ )
762
+ }
763
+
764
+ // Return the bare binary name to use PATH resolution
765
+ // This maintains backward compatibility with npx and non-standard installations
766
+ return name
767
+ }
768
+
769
+ /**
770
+ * Formats validation results for display in the terminal.
771
+ * Shows a summary of all builds with success/failure status,
772
+ * error messages, warnings, and optional output logs.
773
+ *
774
+ * @param results - Array of validation results from all builds
775
+ * @returns Formatted string ready for console output
776
+ */
777
+ formatResults(results: BuildValidationResult[]): string {
778
+ const lines: string[] = []
779
+
780
+ lines.push("\n" + "=".repeat(80))
781
+ lines.push("šŸ” Build Validation Results")
782
+ lines.push("=".repeat(80) + "\n")
783
+
784
+ let totalBuilds = results.length
785
+ let successCount = 0
786
+ let failureCount = 0
787
+
788
+ results.forEach((result) => {
789
+ if (result.success) {
790
+ successCount++
791
+ } else {
792
+ failureCount++
793
+ }
794
+
795
+ const icon = result.success ? "āœ…" : "āŒ"
796
+
797
+ // Format timing information
798
+ let timingInfo = ""
799
+ if (result.duration !== undefined) {
800
+ const seconds = (result.duration / 1000).toFixed(2)
801
+ timingInfo = ` (${seconds}s)`
802
+ }
803
+
804
+ lines.push(`${icon} Build: ${result.buildName}${timingInfo}`)
805
+
806
+ // Show outputs (client/server bundles)
807
+ if (result.outputs && result.outputs.length > 0) {
808
+ lines.push(` šŸ“¦ Outputs: ${result.outputs.join(", ")}`)
809
+ }
810
+
811
+ // Show config file if specified
812
+ if (result.configFile) {
813
+ lines.push(` āš™ļø Config: ${result.configFile}`)
814
+ }
815
+
816
+ // Show output directory if available
817
+ if (result.outputPath) {
818
+ lines.push(` šŸ“ Output: ${result.outputPath}`)
819
+ }
820
+
821
+ if (result.warnings.length > 0) {
822
+ lines.push(` āš ļø ${result.warnings.length} warning(s)`)
823
+ }
824
+
825
+ if (result.errors.length > 0) {
826
+ lines.push(` āŒ ${result.errors.length} error(s)`)
827
+ result.errors.forEach((error) => {
828
+ lines.push(` ${error}`)
829
+ })
830
+ }
831
+
832
+ // Always show output if there are errors (unless verbose already showing it)
833
+ if (
834
+ result.output.length > 0 &&
835
+ (this.options.verbose || result.errors.length > 0)
836
+ ) {
837
+ lines.push("\n Full Output:")
838
+ result.output.forEach((line) => {
839
+ lines.push(` ${line}`)
840
+ })
841
+ }
842
+
843
+ lines.push("")
844
+ })
845
+
846
+ lines.push("=".repeat(80))
847
+
848
+ // Calculate total time
849
+ const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0)
850
+ const totalSeconds = (totalDuration / 1000).toFixed(2)
851
+
852
+ lines.push(
853
+ `Summary: ${successCount}/${totalBuilds} builds passed, ${failureCount} failed (Total: ${totalSeconds}s)`
854
+ )
855
+ lines.push("=".repeat(80))
856
+
857
+ // Add debugging guidance if there are failures
858
+ if (failureCount > 0) {
859
+ lines.push("\nšŸ’” Debugging Tips:")
860
+ lines.push(
861
+ " To get more details, run individual builds with --verbose:"
862
+ )
863
+ lines.push("")
864
+
865
+ const failedBuilds = results.filter((r) => !r.success)
866
+ failedBuilds.forEach((result) => {
867
+ lines.push(
868
+ ` bin/export-bundler-config --validate-build ${result.buildName} --verbose`
869
+ )
870
+ })
871
+
872
+ lines.push("")
873
+ lines.push(
874
+ " Or validate all builds with full output: bin/export-bundler-config --validate --verbose"
875
+ )
876
+ lines.push("=".repeat(80))
877
+ }
878
+
879
+ lines.push("")
880
+
881
+ return lines.join("\n")
882
+ }
883
+ }