shakapacker 9.1.0 → 9.3.0.beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +6 -9
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +6 -8
  4. data/.github/workflows/claude-code-review.yml +4 -5
  5. data/.github/workflows/claude.yml +1 -2
  6. data/.github/workflows/dummy.yml +4 -4
  7. data/.github/workflows/generator.yml +9 -9
  8. data/.github/workflows/node.yml +11 -2
  9. data/.github/workflows/ruby.yml +16 -16
  10. data/.github/workflows/test-bundlers.yml +9 -9
  11. data/.gitignore +7 -0
  12. data/CHANGELOG.md +50 -4
  13. data/CLAUDE.md +6 -1
  14. data/CONTRIBUTING.md +0 -1
  15. data/Gemfile.lock +1 -1
  16. data/README.md +35 -14
  17. data/TODO.md +10 -2
  18. data/TODO_v9.md +13 -3
  19. data/bin/export-bundler-config +11 -0
  20. data/conductor-setup.sh +1 -1
  21. data/conductor.json +1 -1
  22. data/docs/cdn_setup.md +13 -8
  23. data/docs/common-upgrades.md +2 -1
  24. data/docs/configuration.md +630 -0
  25. data/docs/css-modules-export-mode.md +120 -100
  26. data/docs/customizing_babel_config.md +16 -16
  27. data/docs/deployment.md +68 -6
  28. data/docs/developing_shakapacker.md +6 -0
  29. data/docs/optional-peer-dependencies.md +9 -4
  30. data/docs/peer-dependencies.md +17 -6
  31. data/docs/precompile_hook.md +342 -0
  32. data/docs/react.md +57 -47
  33. data/docs/releasing.md +195 -0
  34. data/docs/rspack.md +25 -21
  35. data/docs/rspack_migration_guide.md +363 -8
  36. data/docs/sprockets.md +1 -0
  37. data/docs/style_loader_vs_mini_css.md +12 -12
  38. data/docs/subresource_integrity.md +13 -7
  39. data/docs/transpiler-performance.md +40 -19
  40. data/docs/troubleshooting.md +122 -23
  41. data/docs/typescript-migration.md +48 -39
  42. data/docs/typescript.md +12 -8
  43. data/docs/using_esbuild_loader.md +10 -10
  44. data/docs/v6_upgrade.md +33 -20
  45. data/docs/v7_upgrade.md +8 -6
  46. data/docs/v8_upgrade.md +13 -12
  47. data/docs/v9_upgrade.md +2 -1
  48. data/eslint.config.fast.js +134 -0
  49. data/eslint.config.js +140 -0
  50. data/knip.ts +54 -0
  51. data/lib/install/bin/export-bundler-config +11 -0
  52. data/lib/install/bin/shakapacker +1 -1
  53. data/lib/install/bin/shakapacker-dev-server +1 -1
  54. data/lib/install/config/shakapacker.yml +16 -5
  55. data/lib/shakapacker/bundler_switcher.rb +7 -0
  56. data/lib/shakapacker/compiler.rb +80 -0
  57. data/lib/shakapacker/configuration.rb +56 -2
  58. data/lib/shakapacker/dev_server_runner.rb +140 -1
  59. data/lib/shakapacker/doctor.rb +302 -57
  60. data/lib/shakapacker/instance.rb +8 -3
  61. data/lib/shakapacker/rspack_runner.rb +1 -1
  62. data/lib/shakapacker/runner.rb +245 -9
  63. data/lib/shakapacker/version.rb +1 -1
  64. data/lib/shakapacker/webpack_runner.rb +1 -1
  65. data/lib/shakapacker.rb +10 -0
  66. data/lib/tasks/shakapacker/doctor.rake +42 -2
  67. data/lib/tasks/shakapacker/export_bundler_config.rake +72 -0
  68. data/package/babel/preset.ts +7 -4
  69. data/package/config.ts +42 -30
  70. data/package/configExporter/cli.ts +1274 -0
  71. data/package/configExporter/configDocs.ts +102 -0
  72. data/package/configExporter/configFile.ts +520 -0
  73. data/package/configExporter/fileWriter.ts +96 -0
  74. data/package/configExporter/index.ts +13 -0
  75. data/package/configExporter/types.ts +70 -0
  76. data/package/configExporter/yamlSerializer.ts +280 -0
  77. data/package/dev_server.ts +1 -1
  78. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +11 -5
  79. data/package/environments/base.ts +18 -13
  80. data/package/environments/development.ts +1 -1
  81. data/package/environments/production.ts +4 -1
  82. data/package/index.d.ts +50 -3
  83. data/package/index.d.ts.template +50 -0
  84. data/package/index.ts +7 -7
  85. data/package/loaders.d.ts +2 -2
  86. data/package/optimization/rspack.ts +1 -1
  87. data/package/plugins/rspack.ts +15 -4
  88. data/package/plugins/webpack.ts +7 -3
  89. data/package/rspack/index.ts +10 -2
  90. data/package/rules/raw.ts +3 -2
  91. data/package/rules/sass.ts +1 -1
  92. data/package/types/README.md +15 -13
  93. data/package/types/index.ts +5 -5
  94. data/package/types.ts +0 -1
  95. data/package/utils/defaultConfigPath.ts +4 -1
  96. data/package/utils/errorCodes.ts +129 -100
  97. data/package/utils/errorHelpers.ts +34 -29
  98. data/package/utils/getStyleRule.ts +5 -2
  99. data/package/utils/helpers.ts +21 -11
  100. data/package/utils/pathValidation.ts +43 -35
  101. data/package/utils/requireOrError.ts +1 -1
  102. data/package/utils/snakeToCamelCase.ts +1 -1
  103. data/package/utils/typeGuards.ts +132 -83
  104. data/package/utils/validateDependencies.ts +1 -1
  105. data/package/webpack-types.d.ts +3 -3
  106. data/package/webpackDevServerConfig.ts +22 -10
  107. data/package-lock.json +2 -2
  108. data/package.json +37 -28
  109. data/scripts/type-check-no-emit.js +1 -1
  110. data/test/configExporter/configFile.test.js +392 -0
  111. data/test/configExporter/integration.test.js +275 -0
  112. data/test/helpers.js +1 -1
  113. data/test/package/configExporter.test.js +154 -0
  114. data/test/package/helpers.test.js +2 -2
  115. data/test/package/rules/sass-version-parsing.test.js +71 -0
  116. data/test/package/rules/sass.test.js +2 -4
  117. data/test/package/rules/sass1.test.js +1 -3
  118. data/test/package/rules/sass16.test.js +23 -0
  119. data/tools/README.md +15 -5
  120. data/tsconfig.eslint.json +2 -9
  121. data/yarn.lock +1635 -1442
  122. metadata +29 -3
  123. data/.eslintignore +0 -5
@@ -0,0 +1,1274 @@
1
+ // This will be a substantial file - the main CLI entry point
2
+ // Migrating from bin/export-bundler-config but streamlined for TypeScript
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from "fs"
5
+ import { resolve, dirname, sep, delimiter, basename } from "path"
6
+ import { inspect } from "util"
7
+ import { load as loadYaml } from "js-yaml"
8
+ import yargs from "yargs"
9
+ import { ExportOptions, ConfigMetadata, FileOutput } from "./types"
10
+ import { YamlSerializer } from "./yamlSerializer"
11
+ import { FileWriter } from "./fileWriter"
12
+ import { ConfigFileLoader, generateSampleConfigFile } from "./configFile"
13
+
14
+ // Read version from package.json
15
+ const packageJson = JSON.parse(
16
+ readFileSync(resolve(__dirname, "../../package.json"), "utf8")
17
+ )
18
+ const VERSION = packageJson.version
19
+
20
+ /**
21
+ * Environment variable names that can be set by build configurations
22
+ */
23
+ const BUILD_ENV_VARS = [
24
+ "NODE_ENV",
25
+ "RAILS_ENV",
26
+ "NODE_OPTIONS",
27
+ "BABEL_ENV",
28
+ "WEBPACK_SERVE",
29
+ "CLIENT_BUNDLE_ONLY",
30
+ "SERVER_BUNDLE_ONLY"
31
+ ] as const
32
+
33
+ /**
34
+ * Saves current values of build environment variables for later restoration
35
+ * @returns Object mapping variable names to their current values (or undefined)
36
+ */
37
+ function saveBuildEnvironmentVariables(): Record<string, string | undefined> {
38
+ const saved: Record<string, string | undefined> = {}
39
+ BUILD_ENV_VARS.forEach((varName) => {
40
+ saved[varName] = process.env[varName]
41
+ })
42
+ return saved
43
+ }
44
+
45
+ /**
46
+ * Restores previously saved environment variable values
47
+ * @param saved - Object mapping variable names to their original values
48
+ */
49
+ function restoreBuildEnvironmentVariables(
50
+ saved: Record<string, string | undefined>
51
+ ): void {
52
+ BUILD_ENV_VARS.forEach((varName) => {
53
+ const originalValue = saved[varName]
54
+ if (originalValue === undefined) {
55
+ delete process.env[varName]
56
+ } else {
57
+ process.env[varName] = originalValue
58
+ }
59
+ })
60
+ }
61
+
62
+ /**
63
+ * Clears all whitelisted build environment variables from process.env
64
+ * to prevent environment variable leakage between builds
65
+ */
66
+ function clearBuildEnvironmentVariables(): void {
67
+ BUILD_ENV_VARS.forEach((varName) => {
68
+ delete process.env[varName]
69
+ })
70
+ }
71
+
72
+ // Main CLI entry point
73
+ export async function run(args: string[]): Promise<number> {
74
+ try {
75
+ const options = parseArguments(args)
76
+
77
+ // Handle --init command
78
+ if (options.init) {
79
+ return runInitCommand(options)
80
+ }
81
+
82
+ // Handle --list-builds command
83
+ if (options.listBuilds) {
84
+ return runListBuildsCommand(options)
85
+ }
86
+
87
+ // Handle --all-builds command
88
+ if (options.allBuilds) {
89
+ return runAllBuildsCommand(options)
90
+ }
91
+
92
+ // Set up environment
93
+ const appRoot = findAppRoot()
94
+ process.chdir(appRoot)
95
+ setupNodePath(appRoot)
96
+
97
+ // Apply defaults
98
+ applyDefaults(options)
99
+
100
+ // Validate after defaults are applied
101
+ if (options.annotate && options.format !== "yaml") {
102
+ throw new Error(
103
+ "Annotation requires YAML format. Use --no-annotate or --format=yaml."
104
+ )
105
+ }
106
+
107
+ // Validate --build requires config file
108
+ if (options.build) {
109
+ const loader = new ConfigFileLoader(options.configFile)
110
+ if (!loader.exists()) {
111
+ const configPath = options.configFile || ".bundler-config.yml"
112
+ throw new Error(
113
+ `--build requires a config file but ${configPath} not found. Run --init to create it.`
114
+ )
115
+ }
116
+ }
117
+
118
+ // Execute based on mode
119
+ if (options.doctor) {
120
+ await runDoctorMode(options, appRoot)
121
+ } else if (options.stdout) {
122
+ // Explicit stdout mode
123
+ await runStdoutMode(options, appRoot)
124
+ } else if (options.output) {
125
+ // Save to single file
126
+ await runSingleFileMode(options, appRoot)
127
+ } else {
128
+ // Default: save to directory
129
+ await runSaveMode(options, appRoot)
130
+ }
131
+
132
+ return 0
133
+ } catch (error: any) {
134
+ console.error(`[Config Exporter] Error: ${error.message}`)
135
+ return 1
136
+ }
137
+ }
138
+
139
+ function parseArguments(args: string[]): ExportOptions {
140
+ const argv = yargs(args)
141
+ .version(VERSION)
142
+ .usage(
143
+ `Shakapacker Config Exporter
144
+
145
+ Exports webpack or rspack configuration in a verbose, human-readable format
146
+ for comparison and analysis.
147
+
148
+ QUICK START (for troubleshooting):
149
+ bin/export-bundler-config --doctor
150
+
151
+ Exports annotated YAML configs for both development and production.
152
+ Creates separate files for client and server bundles.
153
+ Best for debugging, AI analysis, and comparing configurations.`
154
+ )
155
+ .option("doctor", {
156
+ type: "boolean",
157
+ default: false,
158
+ description:
159
+ "Export all configs for troubleshooting (dev + prod, annotated YAML)"
160
+ })
161
+ .option("save-dir", {
162
+ type: "string",
163
+ description:
164
+ "Directory for output files (default: shakapacker-config-exports)"
165
+ })
166
+ .option("stdout", {
167
+ type: "boolean",
168
+ default: false,
169
+ description: "Output to stdout instead of saving to files"
170
+ })
171
+ .option("bundler", {
172
+ type: "string",
173
+ choices: ["webpack", "rspack"] as const,
174
+ description: "Specify bundler (auto-detected if not provided)"
175
+ })
176
+ .option("env", {
177
+ type: "string",
178
+ choices: ["development", "production", "test"] as const,
179
+ description:
180
+ "Node environment (default: development, ignored with --doctor or --build)"
181
+ })
182
+ .option("client-only", {
183
+ type: "boolean",
184
+ default: false,
185
+ description: "Generate only client config (sets CLIENT_BUNDLE_ONLY=yes)"
186
+ })
187
+ .option("server-only", {
188
+ type: "boolean",
189
+ default: false,
190
+ description: "Generate only server config (sets SERVER_BUNDLE_ONLY=yes)"
191
+ })
192
+ .option("output", {
193
+ type: "string",
194
+ description: "Output to specific file instead of directory"
195
+ })
196
+ .option("depth", {
197
+ type: "number",
198
+ default: 20,
199
+ coerce: (value: number | string) => {
200
+ if (value === "null" || value === null) return null
201
+ return typeof value === "number" ? value : parseInt(String(value), 10)
202
+ },
203
+ description: "Inspection depth (use 'null' for unlimited)"
204
+ })
205
+ .option("format", {
206
+ type: "string",
207
+ choices: ["yaml", "json", "inspect"] as const,
208
+ description: "Output format (default: yaml for files, inspect for stdout)"
209
+ })
210
+ .option("annotate", {
211
+ type: "boolean",
212
+ description:
213
+ "Enable inline documentation (YAML only, default with --doctor or file output)"
214
+ })
215
+ .option("verbose", {
216
+ type: "boolean",
217
+ default: false,
218
+ description: "Show full output without compact mode"
219
+ })
220
+ .option("init", {
221
+ type: "boolean",
222
+ default: false,
223
+ description: "Generate sample .bundler-config.yml with examples"
224
+ })
225
+ .option("config-file", {
226
+ type: "string",
227
+ description: "Path to config file (default: .bundler-config.yml)"
228
+ })
229
+ .option("build", {
230
+ type: "string",
231
+ description: "Export config for specific build from config file"
232
+ })
233
+ .option("list-builds", {
234
+ type: "boolean",
235
+ default: false,
236
+ description: "List all available builds from config file"
237
+ })
238
+ .option("all-builds", {
239
+ type: "boolean",
240
+ default: false,
241
+ description: "Export all builds from config file"
242
+ })
243
+ .option("webpack", {
244
+ type: "boolean",
245
+ default: false,
246
+ description: "Use webpack (overrides config file)"
247
+ })
248
+ .option("rspack", {
249
+ type: "boolean",
250
+ default: false,
251
+ description: "Use rspack (overrides config file)"
252
+ })
253
+ .check((argv) => {
254
+ if (argv.webpack && argv.rspack) {
255
+ throw new Error(
256
+ "--webpack and --rspack are mutually exclusive. Please specify only one."
257
+ )
258
+ }
259
+ if (argv["client-only"] && argv["server-only"]) {
260
+ throw new Error(
261
+ "--client-only and --server-only are mutually exclusive. Please specify only one."
262
+ )
263
+ }
264
+ if (argv.output && argv["save-dir"]) {
265
+ throw new Error(
266
+ "--output and --save-dir are mutually exclusive. Use one or the other."
267
+ )
268
+ }
269
+ if (argv.stdout && argv["save-dir"]) {
270
+ throw new Error(
271
+ "--stdout and --save-dir are mutually exclusive. Use one or the other."
272
+ )
273
+ }
274
+ if (argv.build && argv["all-builds"]) {
275
+ throw new Error(
276
+ "--build and --all-builds are mutually exclusive. Use one or the other."
277
+ )
278
+ }
279
+ return true
280
+ })
281
+ .help("help")
282
+ .alias("help", "h")
283
+ .epilogue(
284
+ `Examples:
285
+
286
+ # Config File Workflow
287
+ bin/export-bundler-config --init
288
+ bin/export-bundler-config --list-builds
289
+ bin/export-bundler-config --build=dev
290
+ bin/export-bundler-config --all-builds --save-dir=./configs
291
+ bin/export-bundler-config --build=dev --rspack
292
+
293
+ # Traditional Workflow (without config file)
294
+ bin/export-bundler-config --doctor
295
+ # Creates: webpack-development-client-hmr.yaml, webpack-development-client.yaml,
296
+ # webpack-development-server.yaml, webpack-production-client.yaml,
297
+ # webpack-production-server.yaml
298
+
299
+ bin/export-bundler-config --env=production --client-only
300
+ bin/export-bundler-config --save-dir=./debug
301
+ bin/export-bundler-config # Saves to shakapacker-config-exports/
302
+
303
+ # View config in terminal (stdout)
304
+ bin/export-bundler-config --stdout
305
+ bin/export-bundler-config --output=config.yaml # Save to specific file`
306
+ )
307
+ .strict()
308
+ .parseSync()
309
+
310
+ // Type assertions are safe here because yargs validates choices at runtime
311
+ // Handle --webpack and --rspack flags
312
+ let bundler: "webpack" | "rspack" | undefined = argv.bundler as
313
+ | "webpack"
314
+ | "rspack"
315
+ | undefined
316
+ if (argv.webpack) bundler = "webpack"
317
+ if (argv.rspack) bundler = "rspack"
318
+
319
+ return {
320
+ bundler,
321
+ env: argv.env as "development" | "production" | "test" | undefined,
322
+ clientOnly: argv["client-only"],
323
+ serverOnly: argv["server-only"],
324
+ output: argv.output,
325
+ depth: argv.depth as number | null,
326
+ format: argv.format as "yaml" | "json" | "inspect" | undefined,
327
+ help: false, // yargs handles help internally
328
+ verbose: argv.verbose,
329
+ doctor: argv.doctor,
330
+ saveDir: argv["save-dir"],
331
+ stdout: argv.stdout,
332
+ annotate: argv.annotate,
333
+ init: argv.init,
334
+ configFile: argv["config-file"],
335
+ build: argv.build,
336
+ listBuilds: argv["list-builds"],
337
+ allBuilds: argv["all-builds"]
338
+ }
339
+ }
340
+
341
+ function applyDefaults(options: ExportOptions): void {
342
+ if (options.doctor) {
343
+ if (options.format === undefined) options.format = "yaml"
344
+ if (options.annotate === undefined) options.annotate = true
345
+ } else if (!options.stdout && !options.output) {
346
+ // Default mode: save to directory
347
+ if (options.format === undefined) options.format = "yaml"
348
+ if (options.annotate === undefined) options.annotate = true
349
+ } else {
350
+ if (options.format === undefined) options.format = "inspect"
351
+ if (options.annotate === undefined) options.annotate = false
352
+ }
353
+
354
+ // Set default save directory for file output modes
355
+ if (!options.stdout && !options.output && !options.saveDir) {
356
+ options.saveDir = resolve(process.cwd(), "shakapacker-config-exports")
357
+ }
358
+ }
359
+
360
+ function runInitCommand(options: ExportOptions): number {
361
+ const configPath = options.configFile || ".bundler-config.yml"
362
+ const fullPath = resolve(process.cwd(), configPath)
363
+
364
+ if (existsSync(fullPath)) {
365
+ console.error(
366
+ `[Config Exporter] Error: Config file already exists: ${fullPath}`
367
+ )
368
+ console.error(
369
+ `Remove it first or use --config-file=<path> for a different location.`
370
+ )
371
+ return 1
372
+ }
373
+
374
+ const sampleConfig = generateSampleConfigFile()
375
+ writeFileSync(fullPath, sampleConfig, "utf8")
376
+
377
+ console.log(`[Config Exporter] ✅ Created config file: ${fullPath}`)
378
+ console.log(`\nNext steps:`)
379
+ console.log(` 1. Edit the config file to match your build setup`)
380
+ console.log(
381
+ ` 2. List available builds: bin/export-bundler-config --list-builds`
382
+ )
383
+ console.log(
384
+ ` 3. Export a build: bin/export-bundler-config --build=<name> --save\n`
385
+ )
386
+
387
+ return 0
388
+ }
389
+
390
+ function runListBuildsCommand(options: ExportOptions): number {
391
+ try {
392
+ const loader = new ConfigFileLoader(options.configFile)
393
+ loader.listBuilds()
394
+ return 0
395
+ } catch (error: any) {
396
+ console.error(`[Config Exporter] Error: ${error.message}`)
397
+ return 1
398
+ }
399
+ }
400
+
401
+ async function runAllBuildsCommand(options: ExportOptions): Promise<number> {
402
+ // Save original environment to restore after all builds
403
+ const savedEnv = saveBuildEnvironmentVariables()
404
+
405
+ try {
406
+ // Set up environment
407
+ const appRoot = findAppRoot()
408
+ process.chdir(appRoot)
409
+ setupNodePath(appRoot)
410
+
411
+ // Apply defaults
412
+ applyDefaults(options)
413
+
414
+ const loader = new ConfigFileLoader(options.configFile)
415
+ if (!loader.exists()) {
416
+ const configPath = options.configFile || ".bundler-config.yml"
417
+ throw new Error(
418
+ `Config file ${configPath} not found. Run --init to create it.`
419
+ )
420
+ }
421
+
422
+ const config = loader.load()
423
+ const buildNames = Object.keys(config.builds)
424
+
425
+ console.log(
426
+ `\n📦 Exporting ${buildNames.length} builds from config file...\n`
427
+ )
428
+
429
+ const fileWriter = new FileWriter()
430
+ const targetDir = options.saveDir! // Set by applyDefaults
431
+ const createdFiles: string[] = []
432
+
433
+ // Export each build
434
+ for (const buildName of buildNames) {
435
+ console.log(`\n📦 Exporting build: ${buildName}`)
436
+
437
+ // Clear and restore environment to prevent leakage between builds
438
+ clearBuildEnvironmentVariables()
439
+ restoreBuildEnvironmentVariables(savedEnv)
440
+
441
+ // Create a modified options object for this build
442
+ const buildOptions = { ...options, build: buildName }
443
+ const configs = await loadConfigsForEnv(undefined, buildOptions, appRoot)
444
+
445
+ for (const { config: cfg, metadata } of configs) {
446
+ const output = formatConfig(cfg, metadata, options, appRoot)
447
+ const filename = fileWriter.generateFilename(
448
+ metadata.bundler,
449
+ metadata.environment,
450
+ metadata.configType,
451
+ options.format!,
452
+ metadata.buildName
453
+ )
454
+
455
+ const fullPath = resolve(targetDir, filename)
456
+ fileWriter.writeSingleFile(fullPath, output)
457
+ createdFiles.push(fullPath)
458
+ }
459
+ }
460
+
461
+ // Print summary
462
+ console.log("\n" + "=".repeat(80))
463
+ console.log("✅ All Builds Exported!")
464
+ console.log("=".repeat(80))
465
+ console.log(`\nCreated ${createdFiles.length} configuration file(s) in:`)
466
+ console.log(` ${targetDir}\n`)
467
+ console.log("Files:")
468
+ createdFiles.forEach((file) => {
469
+ console.log(` ✓ ${basename(file)}`)
470
+ })
471
+ console.log("\n" + "=".repeat(80) + "\n")
472
+
473
+ return 0
474
+ } catch (error: any) {
475
+ console.error(`[Config Exporter] Error: ${error.message}`)
476
+ return 1
477
+ } finally {
478
+ // Restore original environment
479
+ restoreBuildEnvironmentVariables(savedEnv)
480
+ }
481
+ }
482
+
483
+ async function runDoctorMode(
484
+ options: ExportOptions,
485
+ appRoot: string
486
+ ): Promise<void> {
487
+ // Save original environment to restore after all builds
488
+ const savedEnv = saveBuildEnvironmentVariables()
489
+
490
+ try {
491
+ console.log("\n" + "=".repeat(80))
492
+ console.log("🔍 Config Exporter - Doctor Mode")
493
+ console.log("=".repeat(80))
494
+
495
+ const fileWriter = new FileWriter()
496
+ const targetDir = options.saveDir! // Set by applyDefaults
497
+
498
+ const createdFiles: string[] = []
499
+
500
+ // Check if config file exists with shakapacker_doctor_default_builds_here flag
501
+ const configFilePath = options.configFile || ".bundler-config.yml"
502
+ const loader = new ConfigFileLoader(configFilePath)
503
+
504
+ if (loader.exists()) {
505
+ try {
506
+ const configData = loader.load()
507
+ if (configData.shakapacker_doctor_default_builds_here) {
508
+ console.log(
509
+ "\nUsing builds from config file (shakapacker_doctor_default_builds_here: true)...\n"
510
+ )
511
+ // Use config file builds
512
+ const buildNames = Object.keys(configData.builds)
513
+
514
+ for (const buildName of buildNames) {
515
+ console.log(`\n📦 Loading build: ${buildName}`)
516
+
517
+ // Clear and restore environment to prevent leakage between builds
518
+ clearBuildEnvironmentVariables()
519
+ restoreBuildEnvironmentVariables(savedEnv)
520
+
521
+ const configs = await loadConfigsForEnv(
522
+ undefined,
523
+ { ...options, build: buildName },
524
+ appRoot
525
+ )
526
+
527
+ for (const { config, metadata } of configs) {
528
+ const output = formatConfig(config, metadata, options, appRoot)
529
+ const filename = fileWriter.generateFilename(
530
+ metadata.bundler,
531
+ metadata.environment,
532
+ metadata.configType,
533
+ options.format!,
534
+ metadata.buildName
535
+ )
536
+ const fullPath = resolve(targetDir, filename)
537
+ fileWriter.writeSingleFile(fullPath, output)
538
+ createdFiles.push(fullPath)
539
+ }
540
+ }
541
+
542
+ // Print summary and exit early
543
+ printDoctorSummary(createdFiles, targetDir)
544
+ return
545
+ }
546
+ } catch (error: any) {
547
+ // If config file exists but is invalid, warn and fall through to default behavior
548
+ console.log(`\n⚠️ Config file found but invalid: ${error.message}`)
549
+ console.log("Falling back to default doctor mode...\n")
550
+ }
551
+ }
552
+
553
+ // Default behavior: hardcoded configs
554
+ console.log("\nExporting all development and production configs...")
555
+ console.log("")
556
+
557
+ const configsToExport = [
558
+ { label: "development (HMR)", env: "development" as const, hmr: true },
559
+ { label: "development", env: "development" as const, hmr: false },
560
+ { label: "production", env: "production" as const, hmr: false }
561
+ ]
562
+
563
+ for (const { label, env, hmr } of configsToExport) {
564
+ console.log(`\n📦 Loading ${label} configuration...`)
565
+
566
+ // Clear and restore environment to prevent leakage between builds
567
+ clearBuildEnvironmentVariables()
568
+ restoreBuildEnvironmentVariables(savedEnv)
569
+
570
+ // Set WEBPACK_SERVE for HMR config
571
+ if (hmr) {
572
+ process.env.WEBPACK_SERVE = "true"
573
+ }
574
+
575
+ const configs = await loadConfigsForEnv(env, options, appRoot)
576
+
577
+ for (const { config, metadata } of configs) {
578
+ const output = formatConfig(config, metadata, options, appRoot)
579
+
580
+ // Adjust filename for HMR config
581
+ let filename: string
582
+ if (
583
+ hmr &&
584
+ (metadata.configType === "client" || metadata.configType === "all")
585
+ ) {
586
+ /**
587
+ * HMR Mode Filename Logic:
588
+ * - When WEBPACK_SERVE=true, webpack-dev-server runs and HMR is enabled
589
+ * - HMR only applies to client bundles (server bundles don't use HMR)
590
+ * - If configType is "all", we still only generate client file for HMR
591
+ * because the server bundle is identical to non-HMR development
592
+ * - Filename uses "client" type and "development-hmr" build name to
593
+ * distinguish it from regular development client bundle
594
+ */
595
+ filename = fileWriter.generateFilename(
596
+ metadata.bundler,
597
+ metadata.environment,
598
+ "client",
599
+ options.format!,
600
+ "development-hmr"
601
+ )
602
+ } else {
603
+ filename = fileWriter.generateFilename(
604
+ metadata.bundler,
605
+ metadata.environment,
606
+ metadata.configType,
607
+ options.format!,
608
+ metadata.buildName
609
+ )
610
+ }
611
+
612
+ const fullPath = resolve(targetDir, filename)
613
+ const fileOutput: FileOutput = { filename, content: output, metadata }
614
+ fileWriter.writeSingleFile(fullPath, output)
615
+ createdFiles.push(fullPath)
616
+ }
617
+ }
618
+
619
+ printDoctorSummary(createdFiles, targetDir)
620
+ } finally {
621
+ // Restore original environment
622
+ restoreBuildEnvironmentVariables(savedEnv)
623
+ }
624
+ }
625
+
626
+ function printDoctorSummary(createdFiles: string[], targetDir: string): void {
627
+ // Print summary
628
+ console.log("\n" + "=".repeat(80))
629
+ console.log("✅ Export Complete!")
630
+ console.log("=".repeat(80))
631
+ console.log(`\nCreated ${createdFiles.length} configuration file(s) in:`)
632
+ console.log(` ${targetDir}\n`)
633
+ console.log("Files:")
634
+ createdFiles.forEach((file) => {
635
+ console.log(` ✓ ${basename(file)}`)
636
+ })
637
+
638
+ // Check if directory should be added to .gitignore
639
+ const gitignorePath = resolve(process.cwd(), ".gitignore")
640
+ const dirName = basename(targetDir)
641
+ let shouldSuggestGitignore = false
642
+
643
+ if (existsSync(gitignorePath)) {
644
+ const gitignoreContent = readFileSync(gitignorePath, "utf8")
645
+ if (!gitignoreContent.includes(dirName)) {
646
+ shouldSuggestGitignore = true
647
+ }
648
+ }
649
+
650
+ if (shouldSuggestGitignore) {
651
+ console.log("\n" + "─".repeat(80))
652
+ console.log(
653
+ "💡 Tip: Add the export directory to .gitignore to avoid committing config files:"
654
+ )
655
+ console.log(`\n echo "${dirName}/" >> .gitignore\n`)
656
+ }
657
+
658
+ console.log("\n" + "=".repeat(80) + "\n")
659
+ }
660
+
661
+ async function runSaveMode(
662
+ options: ExportOptions,
663
+ appRoot: string
664
+ ): Promise<void> {
665
+ const env = options.env || "development"
666
+ console.log(`[Config Exporter] Exporting ${env} configs`)
667
+
668
+ const fileWriter = new FileWriter()
669
+ const targetDir = options.saveDir! // Set by applyDefaults
670
+ const configs = await loadConfigsForEnv(options.env, options, appRoot)
671
+ const createdFiles: string[] = []
672
+
673
+ if (options.output) {
674
+ // Single file output
675
+ const combined = configs.map((c) => c.config)
676
+ const metadata = configs[0].metadata
677
+ metadata.configCount = combined.length
678
+
679
+ const output = formatConfig(
680
+ combined.length === 1 ? combined[0] : combined,
681
+ metadata,
682
+ options,
683
+ appRoot
684
+ )
685
+ const fullPath = resolve(options.output)
686
+ fileWriter.writeSingleFile(fullPath, output)
687
+ createdFiles.push(fullPath)
688
+ } else {
689
+ // Multi-file output (one per config)
690
+ for (const { config, metadata } of configs) {
691
+ const output = formatConfig(config, metadata, options, appRoot)
692
+ const filename = fileWriter.generateFilename(
693
+ metadata.bundler,
694
+ metadata.environment,
695
+ metadata.configType,
696
+ options.format!,
697
+ metadata.buildName
698
+ )
699
+ const fullPath = resolve(targetDir, filename)
700
+ fileWriter.writeSingleFile(fullPath, output)
701
+ createdFiles.push(fullPath)
702
+ }
703
+ }
704
+
705
+ // Log all created files
706
+ console.log(`\n[Config Exporter] Created ${createdFiles.length} file(s):`)
707
+ createdFiles.forEach((file) => {
708
+ console.log(` ✓ ${file}`)
709
+ })
710
+ }
711
+
712
+ async function runStdoutMode(
713
+ options: ExportOptions,
714
+ appRoot: string
715
+ ): Promise<void> {
716
+ const configs = await loadConfigsForEnv(options.env!, options, appRoot)
717
+ const combined = configs.map((c) => c.config)
718
+ const metadata = configs[0].metadata
719
+ metadata.configCount = combined.length
720
+
721
+ const config = combined.length === 1 ? combined[0] : combined
722
+ const output = formatConfig(config, metadata, options, appRoot)
723
+
724
+ console.log("\n" + "=".repeat(80) + "\n")
725
+ console.log(output)
726
+ }
727
+
728
+ async function runSingleFileMode(
729
+ options: ExportOptions,
730
+ appRoot: string
731
+ ): Promise<void> {
732
+ const configs = await loadConfigsForEnv(options.env!, options, appRoot)
733
+ const combined = configs.map((c) => c.config)
734
+ const metadata = configs[0].metadata
735
+ metadata.configCount = combined.length
736
+
737
+ const config = combined.length === 1 ? combined[0] : combined
738
+ const output = formatConfig(config, metadata, options, appRoot)
739
+
740
+ const fileWriter = new FileWriter()
741
+ const filePath = resolve(process.cwd(), options.output!)
742
+ fileWriter.writeSingleFile(filePath, output)
743
+ }
744
+
745
+ async function loadConfigsForEnv(
746
+ env: "development" | "production" | "test" | undefined,
747
+ options: ExportOptions,
748
+ appRoot: string
749
+ ): Promise<Array<{ config: any; metadata: ConfigMetadata }>> {
750
+ let bundler: "webpack" | "rspack"
751
+ let buildName: string | undefined
752
+ let buildOutputs: string[] = []
753
+ let customConfigFile: string | undefined
754
+ let bundlerEnvArgs: string[] = []
755
+ let finalEnv: "development" | "production" | "test"
756
+
757
+ // If using config file build
758
+ if (options.build) {
759
+ // Use a temporary env for auto-detection, will be overridden by build config
760
+ const tempEnv = env || "development"
761
+ const loader = new ConfigFileLoader(options.configFile)
762
+ const defaultBundler = await autoDetectBundler(tempEnv, appRoot)
763
+ const resolvedBuild = loader.resolveBuild(
764
+ options.build,
765
+ options,
766
+ defaultBundler
767
+ )
768
+
769
+ bundler = resolvedBuild.bundler
770
+ buildName = resolvedBuild.name
771
+ buildOutputs = resolvedBuild.outputs
772
+ customConfigFile = resolvedBuild.configFile
773
+ bundlerEnvArgs = resolvedBuild.bundlerEnvArgs
774
+
775
+ // Set environment variables from config
776
+ // Security: Only allow specific environment variables to prevent malicious configs
777
+ const DANGEROUS_ENV_VARS = [
778
+ "PATH",
779
+ "HOME",
780
+ "LD_PRELOAD",
781
+ "LD_LIBRARY_PATH",
782
+ "DYLD_LIBRARY_PATH",
783
+ "DYLD_INSERT_LIBRARIES"
784
+ ]
785
+
786
+ for (const [key, value] of Object.entries(resolvedBuild.environment)) {
787
+ if (DANGEROUS_ENV_VARS.includes(key)) {
788
+ console.warn(
789
+ `[Config Exporter] Warning: Skipping dangerous environment variable: ${key}`
790
+ )
791
+ continue
792
+ }
793
+ if (!(BUILD_ENV_VARS as readonly string[]).includes(key)) {
794
+ console.warn(
795
+ `[Config Exporter] Warning: Skipping non-whitelisted environment variable: ${key}. ` +
796
+ `Allowed variables are: ${BUILD_ENV_VARS.join(", ")}`
797
+ )
798
+ continue
799
+ }
800
+ process.env[key] = value
801
+ }
802
+
803
+ // Determine final env: CLI flag > build config NODE_ENV > default
804
+ if (options.env) {
805
+ finalEnv = options.env
806
+ } else if (resolvedBuild.environment.NODE_ENV) {
807
+ const nodeEnv = resolvedBuild.environment.NODE_ENV
808
+ const allowedEnvs = ["development", "production", "test"]
809
+ if (allowedEnvs.includes(nodeEnv)) {
810
+ finalEnv = nodeEnv as "development" | "production" | "test"
811
+ } else {
812
+ throw new Error(
813
+ `Invalid NODE_ENV value in config: "${nodeEnv}". ` +
814
+ `Allowed values are: ${allowedEnvs.join(", ")}.`
815
+ )
816
+ }
817
+ } else {
818
+ finalEnv = "development"
819
+ }
820
+
821
+ // Sync process.env to reflect resolved environment
822
+ process.env.NODE_ENV = finalEnv
823
+ // Determine RAILS_ENV: CLI env option > build config RAILS_ENV > finalEnv
824
+ const railsEnv =
825
+ options.env || resolvedBuild.environment.RAILS_ENV || finalEnv
826
+ process.env.RAILS_ENV = railsEnv
827
+ } else {
828
+ // No build config - use CLI env or default
829
+ finalEnv = env || "development"
830
+
831
+ // Auto-detect bundler if not specified
832
+ bundler = options.bundler || (await autoDetectBundler(finalEnv, appRoot))
833
+
834
+ // Set environment variables
835
+ process.env.NODE_ENV = finalEnv
836
+ process.env.RAILS_ENV = finalEnv
837
+ }
838
+
839
+ if (options.clientOnly) {
840
+ process.env.CLIENT_BUNDLE_ONLY = "yes"
841
+ } else if (options.serverOnly) {
842
+ process.env.SERVER_BUNDLE_ONLY = "yes"
843
+ }
844
+
845
+ // Find and load config file
846
+ const configFile =
847
+ customConfigFile || findConfigFile(bundler, appRoot, finalEnv)
848
+ // Quiet mode for cleaner output - only show if verbose or errors
849
+ if (process.env.VERBOSE) {
850
+ console.log(`[Config Exporter] Loading config: ${configFile}`)
851
+ console.log(`[Config Exporter] Environment: ${finalEnv}`)
852
+ console.log(`[Config Exporter] Bundler: ${bundler}`)
853
+ if (buildName) {
854
+ console.log(`[Config Exporter] Build: ${buildName}`)
855
+ }
856
+ }
857
+
858
+ // Load the config
859
+ // Register ts-node for TypeScript config files
860
+ if (configFile.endsWith(".ts")) {
861
+ try {
862
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
863
+ require("ts-node/register/transpile-only")
864
+ } catch (error) {
865
+ throw new Error(
866
+ "TypeScript config detected but ts-node is not available. " +
867
+ "Install ts-node as a dev dependency: npm install --save-dev ts-node"
868
+ )
869
+ }
870
+ }
871
+
872
+ // Clear require cache for config file and all related modules
873
+ /**
874
+ * AGGRESSIVE REQUIRE CACHE CLEARING
875
+ *
876
+ * Why: This tool can load multiple environments (dev/prod) and builds in a
877
+ * single process. Node's require cache prevents modules from re-evaluating,
878
+ * which causes stale environment values (NODE_ENV, etc.) to persist.
879
+ *
880
+ * What: Clears cache for:
881
+ * - Webpack/rspack config files (they read process.env)
882
+ * - Shakapacker modules (env detection, config loading)
883
+ * - Config directory files (custom helpers that may read env)
884
+ *
885
+ * Trade-offs:
886
+ * - More reliable: Ensures each build gets fresh environment
887
+ * - Potentially brittle: String matching on paths (but comprehensive)
888
+ * - Performance: Minimal impact since this runs per-build, not per-file
889
+ *
890
+ * Maintenance: If adding new shakapacker modules that read env vars,
891
+ * ensure their paths are covered by the patterns below.
892
+ */
893
+ const configDir = dirname(configFile)
894
+ Object.keys(require.cache).forEach((key) => {
895
+ if (
896
+ key.includes("webpack.config") ||
897
+ key.includes("rspack.config") ||
898
+ key.startsWith(configDir) ||
899
+ key.includes("/shakapacker/") || // npm installed shakapacker
900
+ key.includes("\\shakapacker\\") || // Windows path
901
+ key.includes("/package/env") || // shakapacker env module (local dev)
902
+ key.includes("\\package\\env") || // Windows env module
903
+ key.includes("/package/index") || // shakapacker main module
904
+ key.includes("\\package\\index") || // Windows main module
905
+ key === configFile
906
+ ) {
907
+ delete require.cache[key]
908
+ }
909
+ })
910
+
911
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
912
+ let loadedConfig = require(configFile)
913
+
914
+ // Handle ES module default export
915
+ if (typeof loadedConfig === "object" && "default" in loadedConfig) {
916
+ loadedConfig = loadedConfig.default
917
+ }
918
+
919
+ // Handle function exports (webpack config functions)
920
+ if (typeof loadedConfig === "function") {
921
+ // Webpack config functions receive (env, argv) parameters
922
+ // Build env object from bundler_env args if available
923
+ const envObject: Record<string, any> = {}
924
+ if (bundlerEnvArgs && bundlerEnvArgs.length > 0) {
925
+ // Parse --env key=value or --env key into object
926
+ for (let i = 0; i < bundlerEnvArgs.length; i += 2) {
927
+ if (bundlerEnvArgs[i] === "--env") {
928
+ const envArg = bundlerEnvArgs[i + 1]
929
+ if (envArg.includes("=")) {
930
+ const [key, value] = envArg.split("=")
931
+ envObject[key] = value
932
+ } else {
933
+ envObject[envArg] = true
934
+ }
935
+ }
936
+ }
937
+ }
938
+
939
+ const argv = { mode: finalEnv }
940
+ try {
941
+ loadedConfig = loadedConfig(envObject, argv)
942
+ } catch (error: any) {
943
+ throw new Error(
944
+ `Failed to execute config function: ${error.message}\n` +
945
+ `Config file: ${configFile}\n` +
946
+ `Environment: ${JSON.stringify(envObject)}`
947
+ )
948
+ }
949
+ }
950
+
951
+ // Determine config type and split if array
952
+ const configs: any[] = Array.isArray(loadedConfig)
953
+ ? loadedConfig
954
+ : [loadedConfig]
955
+ const results: Array<{ config: any; metadata: ConfigMetadata }> = []
956
+
957
+ configs.forEach((cfg, index) => {
958
+ let configType: "client" | "server" | "all" = "all"
959
+
960
+ // Use outputs from build config if available
961
+ if (
962
+ buildOutputs.length > 0 &&
963
+ index < buildOutputs.length &&
964
+ buildOutputs[index]
965
+ ) {
966
+ const outputValue = buildOutputs[index]
967
+ // Validate the output value is a valid config type
968
+ if (
969
+ outputValue === "client" ||
970
+ outputValue === "server" ||
971
+ outputValue === "all"
972
+ ) {
973
+ configType = outputValue
974
+ } else {
975
+ throw new Error(
976
+ `Invalid output type '${outputValue}' at index ${index} in build '${buildName}'. ` +
977
+ `Allowed values are: client, server, all`
978
+ )
979
+ }
980
+ } else if (configs.length === 2) {
981
+ // Likely client and server configs
982
+ configType = index === 0 ? "client" : "server"
983
+ } else if (options.clientOnly) {
984
+ configType = "client"
985
+ } else if (options.serverOnly) {
986
+ configType = "server"
987
+ }
988
+
989
+ const metadata: ConfigMetadata = {
990
+ exportedAt: new Date().toISOString(),
991
+ bundler,
992
+ environment: finalEnv,
993
+ configFile,
994
+ configType,
995
+ configCount: configs.length,
996
+ buildName,
997
+ environmentVariables: {
998
+ NODE_ENV: process.env.NODE_ENV,
999
+ RAILS_ENV: process.env.RAILS_ENV,
1000
+ CLIENT_BUNDLE_ONLY: process.env.CLIENT_BUNDLE_ONLY,
1001
+ SERVER_BUNDLE_ONLY: process.env.SERVER_BUNDLE_ONLY,
1002
+ WEBPACK_SERVE: process.env.WEBPACK_SERVE
1003
+ }
1004
+ }
1005
+
1006
+ // Clean config if not verbose
1007
+ let cleanedConfig = cfg
1008
+ if (!options.verbose) {
1009
+ cleanedConfig = cleanConfig(cfg, appRoot)
1010
+ }
1011
+
1012
+ results.push({ config: cleanedConfig, metadata })
1013
+ })
1014
+
1015
+ return results
1016
+ }
1017
+
1018
+ function formatConfig(
1019
+ config: any,
1020
+ metadata: ConfigMetadata,
1021
+ options: ExportOptions,
1022
+ appRoot: string
1023
+ ): string {
1024
+ if (options.format === "yaml") {
1025
+ const serializer = new YamlSerializer({
1026
+ annotate: options.annotate!,
1027
+ appRoot
1028
+ })
1029
+ return serializer.serialize(config, metadata)
1030
+ } else if (options.format === "json") {
1031
+ const jsonReplacer = (key: string, value: any): any => {
1032
+ if (typeof value === "function") {
1033
+ return `[Function: ${value.name || "anonymous"}]`
1034
+ }
1035
+ if (value instanceof RegExp) {
1036
+ return `[RegExp: ${value.toString()}]`
1037
+ }
1038
+ if (
1039
+ value &&
1040
+ typeof value === "object" &&
1041
+ value.constructor &&
1042
+ value.constructor.name !== "Object" &&
1043
+ value.constructor.name !== "Array"
1044
+ ) {
1045
+ return `[${value.constructor.name}]`
1046
+ }
1047
+ return value
1048
+ }
1049
+ return JSON.stringify({ metadata, config }, jsonReplacer, 2)
1050
+ } else {
1051
+ // inspect format
1052
+ const inspectOptions = {
1053
+ depth: options.depth,
1054
+ colors: false,
1055
+ maxArrayLength: null,
1056
+ maxStringLength: null,
1057
+ breakLength: 120,
1058
+ compact: false
1059
+ }
1060
+
1061
+ let output =
1062
+ "=== METADATA ===\n\n" + inspect(metadata, inspectOptions) + "\n\n"
1063
+ output += "=== CONFIG ===\n\n"
1064
+
1065
+ if (Array.isArray(config)) {
1066
+ output += `Total configs: ${config.length}\n\n`
1067
+ config.forEach((cfg, index) => {
1068
+ output += `--- Config [${index}] ---\n\n`
1069
+ output += inspect(cfg, inspectOptions) + "\n\n"
1070
+ })
1071
+ } else {
1072
+ output += inspect(config, inspectOptions) + "\n"
1073
+ }
1074
+
1075
+ return output
1076
+ }
1077
+ }
1078
+
1079
+ function cleanConfig(obj: any, rootPath: string): any {
1080
+ const makePathRelative = (str: string): string => {
1081
+ if (typeof str === "string" && str.startsWith(rootPath)) {
1082
+ return "./" + str.substring(rootPath.length + 1)
1083
+ }
1084
+ return str
1085
+ }
1086
+
1087
+ function clean(value: any, key?: string, parent?: any): any {
1088
+ // Remove EnvironmentPlugin keys and defaultValues
1089
+ if (
1090
+ parent &&
1091
+ parent.constructor &&
1092
+ parent.constructor.name === "EnvironmentPlugin"
1093
+ ) {
1094
+ if (key === "keys" || key === "defaultValues") {
1095
+ return undefined
1096
+ }
1097
+ }
1098
+
1099
+ if (typeof value === "function") {
1100
+ // Show function source
1101
+ const source = value.toString()
1102
+ const compacted = source
1103
+ .split("\n")
1104
+ .map((line: string) => line.trim())
1105
+ .filter((line: string) => line.length > 0)
1106
+ .join(" ")
1107
+ return compacted
1108
+ }
1109
+
1110
+ if (typeof value === "string") {
1111
+ return makePathRelative(value)
1112
+ }
1113
+
1114
+ if (Array.isArray(value)) {
1115
+ return value
1116
+ .map((item, i) => clean(item, String(i), value))
1117
+ .filter((v) => v !== undefined)
1118
+ }
1119
+
1120
+ if (value && typeof value === "object") {
1121
+ const cleaned: any = {}
1122
+ for (const k in value) {
1123
+ if (Object.prototype.hasOwnProperty.call(value, k)) {
1124
+ const cleanedValue = clean(value[k], k, value)
1125
+ if (cleanedValue !== undefined) {
1126
+ cleaned[k] = cleanedValue
1127
+ }
1128
+ }
1129
+ }
1130
+ return cleaned
1131
+ }
1132
+
1133
+ return value
1134
+ }
1135
+
1136
+ return clean(obj)
1137
+ }
1138
+
1139
+ /**
1140
+ * Loads and returns shakapacker.yml configuration
1141
+ */
1142
+ function loadShakapackerConfig(
1143
+ env: string,
1144
+ appRoot: string
1145
+ ): { bundler: "webpack" | "rspack"; configPath: string } {
1146
+ try {
1147
+ const configFilePath =
1148
+ process.env.SHAKAPACKER_CONFIG ||
1149
+ resolve(appRoot, "config/shakapacker.yml")
1150
+
1151
+ if (existsSync(configFilePath)) {
1152
+ const config: any = loadYaml(readFileSync(configFilePath, "utf8"))
1153
+ const envConfig = config[env] || config.default || {}
1154
+
1155
+ // Get bundler
1156
+ const bundler = envConfig.assets_bundler || "webpack"
1157
+ if (bundler !== "webpack" && bundler !== "rspack") {
1158
+ console.warn(
1159
+ `[Config Exporter] Invalid bundler '${bundler}' in shakapacker.yml, defaulting to webpack`
1160
+ )
1161
+ return {
1162
+ bundler: "webpack",
1163
+ configPath: bundler === "rspack" ? "config/rspack" : "config/webpack"
1164
+ }
1165
+ }
1166
+
1167
+ // Get config path
1168
+ const customConfigPath = envConfig.assets_bundler_config_path
1169
+ const configPath =
1170
+ customConfigPath ||
1171
+ (bundler === "rspack" ? "config/rspack" : "config/webpack")
1172
+
1173
+ console.log(
1174
+ `[Config Exporter] Auto-detected bundler: ${bundler}, config path: ${configPath}`
1175
+ )
1176
+ return { bundler, configPath }
1177
+ }
1178
+ } catch (error: any) {
1179
+ console.warn(
1180
+ `[Config Exporter] Error loading shakapacker config, defaulting to webpack`
1181
+ )
1182
+ }
1183
+
1184
+ return { bundler: "webpack", configPath: "config/webpack" }
1185
+ }
1186
+
1187
+ /**
1188
+ * Auto-detects bundler from shakapacker.yml
1189
+ *
1190
+ * Error Handling Strategy:
1191
+ * - Invalid bundler → warns and defaults to webpack (graceful fallback)
1192
+ * - Config read errors → warns and defaults to webpack (graceful fallback)
1193
+ *
1194
+ * Rationale for warnings vs errors:
1195
+ * - This reads shakapacker.yml (infrastructure config), not user build config
1196
+ * - Failures here should not block the tool; defaulting to webpack is safe
1197
+ * - Contrast with NODE_ENV validation in build configs, which throws errors
1198
+ * because invalid NODE_ENV would produce incorrect builds
1199
+ */
1200
+ async function autoDetectBundler(
1201
+ env: string,
1202
+ appRoot: string
1203
+ ): Promise<"webpack" | "rspack"> {
1204
+ const { bundler } = loadShakapackerConfig(env, appRoot)
1205
+ return bundler
1206
+ }
1207
+
1208
+ function findConfigFile(
1209
+ bundler: "webpack" | "rspack",
1210
+ appRoot: string,
1211
+ env: string
1212
+ ): string {
1213
+ const { configPath } = loadShakapackerConfig(env, appRoot)
1214
+ const extensions = ["ts", "js"]
1215
+
1216
+ if (bundler === "rspack") {
1217
+ for (const ext of extensions) {
1218
+ const rspackPath = resolve(appRoot, configPath, `rspack.config.${ext}`)
1219
+ if (existsSync(rspackPath)) {
1220
+ return rspackPath
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // Fall back to webpack config
1226
+ for (const ext of extensions) {
1227
+ const webpackPath = resolve(appRoot, configPath, `webpack.config.${ext}`)
1228
+ if (existsSync(webpackPath)) {
1229
+ return webpackPath
1230
+ }
1231
+ }
1232
+
1233
+ throw new Error(
1234
+ `Could not find ${bundler} config file. Expected: ${configPath}/${bundler}.config.{js,ts}`
1235
+ )
1236
+ }
1237
+
1238
+ function findAppRoot(): string {
1239
+ let currentDir = process.cwd()
1240
+ const root = dirname(currentDir).split(sep)[0] + sep
1241
+
1242
+ while (currentDir !== root && currentDir !== dirname(currentDir)) {
1243
+ if (
1244
+ existsSync(resolve(currentDir, "package.json")) ||
1245
+ existsSync(resolve(currentDir, "config/shakapacker.yml"))
1246
+ ) {
1247
+ return currentDir
1248
+ }
1249
+ currentDir = dirname(currentDir)
1250
+ }
1251
+
1252
+ return process.cwd()
1253
+ }
1254
+
1255
+ function setupNodePath(appRoot: string): void {
1256
+ const nodePaths = [
1257
+ resolve(appRoot, "node_modules"),
1258
+ resolve(appRoot, "..", "..", "node_modules"),
1259
+ resolve(appRoot, "..", "..", "package"),
1260
+ ...(appRoot.includes("/spec/dummy")
1261
+ ? [resolve(appRoot, "../../node_modules")]
1262
+ : [])
1263
+ ].filter((p) => existsSync(p))
1264
+
1265
+ if (nodePaths.length > 0) {
1266
+ const existingNodePath = process.env.NODE_PATH || ""
1267
+ process.env.NODE_PATH = existingNodePath
1268
+ ? `${nodePaths.join(delimiter)}${delimiter}${existingNodePath}`
1269
+ : nodePaths.join(delimiter)
1270
+
1271
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1272
+ require("module").Module._initPaths()
1273
+ }
1274
+ }