shakapacker 9.2.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 (115) 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 +4 -0
  12. data/CHANGELOG.md +19 -4
  13. data/CLAUDE.md +6 -1
  14. data/CONTRIBUTING.md +0 -1
  15. data/Gemfile.lock +1 -1
  16. data/README.md +14 -14
  17. data/TODO.md +10 -2
  18. data/TODO_v9.md +13 -3
  19. data/bin/export-bundler-config +1 -1
  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 +18 -0
  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 +0 -2
  34. data/docs/rspack.md +25 -21
  35. data/docs/rspack_migration_guide.md +335 -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 +0 -2
  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 +1 -1
  52. data/lib/install/config/shakapacker.yml +16 -5
  53. data/lib/shakapacker/compiler.rb +80 -0
  54. data/lib/shakapacker/configuration.rb +33 -5
  55. data/lib/shakapacker/dev_server_runner.rb +140 -1
  56. data/lib/shakapacker/doctor.rb +294 -65
  57. data/lib/shakapacker/instance.rb +8 -3
  58. data/lib/shakapacker/runner.rb +244 -8
  59. data/lib/shakapacker/version.rb +1 -1
  60. data/lib/tasks/shakapacker/doctor.rake +42 -2
  61. data/package/babel/preset.ts +7 -4
  62. data/package/config.ts +42 -30
  63. data/package/configExporter/cli.ts +799 -208
  64. data/package/configExporter/configFile.ts +520 -0
  65. data/package/configExporter/fileWriter.ts +12 -8
  66. data/package/configExporter/index.ts +9 -1
  67. data/package/configExporter/types.ts +36 -2
  68. data/package/configExporter/yamlSerializer.ts +22 -8
  69. data/package/dev_server.ts +1 -1
  70. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +11 -5
  71. data/package/environments/base.ts +18 -13
  72. data/package/environments/development.ts +1 -1
  73. data/package/environments/production.ts +4 -1
  74. data/package/index.d.ts +50 -3
  75. data/package/index.d.ts.template +50 -0
  76. data/package/index.ts +7 -7
  77. data/package/loaders.d.ts +2 -2
  78. data/package/optimization/rspack.ts +1 -1
  79. data/package/plugins/rspack.ts +15 -4
  80. data/package/plugins/webpack.ts +7 -3
  81. data/package/rspack/index.ts +10 -2
  82. data/package/rules/raw.ts +3 -2
  83. data/package/rules/sass.ts +1 -1
  84. data/package/types/README.md +15 -13
  85. data/package/types/index.ts +5 -5
  86. data/package/types.ts +0 -1
  87. data/package/utils/defaultConfigPath.ts +4 -1
  88. data/package/utils/errorCodes.ts +129 -100
  89. data/package/utils/errorHelpers.ts +34 -29
  90. data/package/utils/getStyleRule.ts +5 -2
  91. data/package/utils/helpers.ts +21 -11
  92. data/package/utils/pathValidation.ts +43 -35
  93. data/package/utils/requireOrError.ts +1 -1
  94. data/package/utils/snakeToCamelCase.ts +1 -1
  95. data/package/utils/typeGuards.ts +132 -83
  96. data/package/utils/validateDependencies.ts +1 -1
  97. data/package/webpack-types.d.ts +3 -3
  98. data/package/webpackDevServerConfig.ts +22 -10
  99. data/package-lock.json +2 -2
  100. data/package.json +36 -28
  101. data/scripts/type-check-no-emit.js +1 -1
  102. data/test/configExporter/configFile.test.js +392 -0
  103. data/test/configExporter/integration.test.js +275 -0
  104. data/test/helpers.js +1 -1
  105. data/test/package/configExporter.test.js +154 -0
  106. data/test/package/helpers.test.js +2 -2
  107. data/test/package/rules/sass-version-parsing.test.js +71 -0
  108. data/test/package/rules/sass.test.js +2 -4
  109. data/test/package/rules/sass1.test.js +1 -3
  110. data/test/package/rules/sass16.test.js +23 -0
  111. data/tools/README.md +15 -5
  112. data/tsconfig.eslint.json +2 -9
  113. data/yarn.lock +1894 -1492
  114. metadata +19 -3
  115. data/.eslintignore +0 -5
@@ -1,22 +1,92 @@
1
1
  // This will be a substantial file - the main CLI entry point
2
2
  // Migrating from bin/export-bundler-config but streamlined for TypeScript
3
3
 
4
- import { existsSync, readFileSync } from "fs"
4
+ import { existsSync, readFileSync, writeFileSync } from "fs"
5
5
  import { resolve, dirname, sep, delimiter, basename } from "path"
6
6
  import { inspect } from "util"
7
7
  import { load as loadYaml } from "js-yaml"
8
+ import yargs from "yargs"
8
9
  import { ExportOptions, ConfigMetadata, FileOutput } from "./types"
9
10
  import { YamlSerializer } from "./yamlSerializer"
10
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
+ }
11
71
 
12
72
  // Main CLI entry point
13
73
  export async function run(args: string[]): Promise<number> {
14
74
  try {
15
75
  const options = parseArguments(args)
16
76
 
17
- if (options.help) {
18
- showHelp()
19
- return 0
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)
20
90
  }
21
91
 
22
92
  // Set up environment
@@ -27,16 +97,36 @@ export async function run(args: string[]): Promise<number> {
27
97
  // Apply defaults
28
98
  applyDefaults(options)
29
99
 
30
- // Validate options
31
- validateOptions(options)
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
+ }
32
117
 
33
118
  // Execute based on mode
34
119
  if (options.doctor) {
35
120
  await runDoctorMode(options, appRoot)
36
- } else if (options.save) {
37
- await runSaveMode(options, appRoot)
38
- } else {
121
+ } else if (options.stdout) {
122
+ // Explicit stdout mode
39
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)
40
130
  }
41
131
 
42
132
  return 0
@@ -47,117 +137,346 @@ export async function run(args: string[]): Promise<number> {
47
137
  }
48
138
 
49
139
  function parseArguments(args: string[]): ExportOptions {
50
- const options: ExportOptions = {
51
- bundler: undefined,
52
- env: "development",
53
- clientOnly: false,
54
- serverOnly: false,
55
- output: undefined,
56
- depth: 20,
57
- format: undefined,
58
- help: false,
59
- verbose: false,
60
- doctor: false,
61
- save: false,
62
- saveDir: undefined,
63
- annotate: undefined
64
- }
140
+ const argv = yargs(args)
141
+ .version(VERSION)
142
+ .usage(
143
+ `Shakapacker Config Exporter
65
144
 
66
- const parseValue = (arg: string, prefix: string): string => {
67
- const value = arg.substring(prefix.length)
68
- if (value.length === 0) {
69
- throw new Error(`${prefix} requires a value`)
70
- }
71
- return value
72
- }
145
+ Exports webpack or rspack configuration in a verbose, human-readable format
146
+ for comparison and analysis.
73
147
 
74
- for (const arg of args) {
75
- if (arg === "--help" || arg === "-h") {
76
- options.help = true
77
- } else if (arg === "--doctor") {
78
- options.doctor = true
79
- } else if (arg === "--save") {
80
- options.save = true
81
- } else if (arg.startsWith("--save-dir=")) {
82
- options.saveDir = parseValue(arg, "--save-dir=")
83
- } else if (arg.startsWith("--bundler=")) {
84
- const bundler = parseValue(arg, "--bundler=")
85
- if (bundler !== "webpack" && bundler !== "rspack") {
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) {
86
255
  throw new Error(
87
- `Invalid bundler '${bundler}'. Must be 'webpack' or 'rspack'.`
256
+ "--webpack and --rspack are mutually exclusive. Please specify only one."
88
257
  )
89
258
  }
90
- options.bundler = bundler
91
- } else if (arg.startsWith("--env=")) {
92
- const env = parseValue(arg, "--env=")
93
- if (env !== "development" && env !== "production" && env !== "test") {
259
+ if (argv["client-only"] && argv["server-only"]) {
94
260
  throw new Error(
95
- `Invalid environment '${env}'. Must be 'development', 'production', or 'test'.`
261
+ "--client-only and --server-only are mutually exclusive. Please specify only one."
96
262
  )
97
263
  }
98
- options.env = env
99
- } else if (arg === "--client-only") {
100
- options.clientOnly = true
101
- } else if (arg === "--server-only") {
102
- options.serverOnly = true
103
- } else if (arg.startsWith("--output=")) {
104
- options.output = parseValue(arg, "--output=")
105
- } else if (arg.startsWith("--depth=")) {
106
- const depth = parseValue(arg, "--depth=")
107
- options.depth = depth === "null" ? null : parseInt(depth, 10)
108
- } else if (arg.startsWith("--format=")) {
109
- const format = parseValue(arg, "--format=")
110
- if (format !== "yaml" && format !== "json" && format !== "inspect") {
264
+ if (argv.output && argv["save-dir"]) {
111
265
  throw new Error(
112
- `Invalid format '${format}'. Must be 'yaml', 'json', or 'inspect'.`
266
+ "--output and --save-dir are mutually exclusive. Use one or the other."
113
267
  )
114
268
  }
115
- options.format = format
116
- } else if (arg === "--no-annotate") {
117
- options.annotate = false
118
- } else if (arg === "--verbose") {
119
- options.verbose = true
120
- }
121
- }
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/
122
302
 
123
- return options
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
+ }
124
339
  }
125
340
 
126
341
  function applyDefaults(options: ExportOptions): void {
127
342
  if (options.doctor) {
128
- options.save = true
129
343
  if (options.format === undefined) options.format = "yaml"
130
344
  if (options.annotate === undefined) options.annotate = true
131
- } else if (options.save) {
345
+ } else if (!options.stdout && !options.output) {
346
+ // Default mode: save to directory
132
347
  if (options.format === undefined) options.format = "yaml"
133
348
  if (options.annotate === undefined) options.annotate = true
134
349
  } else {
135
350
  if (options.format === undefined) options.format = "inspect"
136
351
  if (options.annotate === undefined) options.annotate = false
137
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
+ }
138
358
  }
139
359
 
140
- function validateOptions(options: ExportOptions): void {
141
- if (options.clientOnly && options.serverOnly) {
142
- throw new Error(
143
- "--client-only and --server-only are mutually exclusive. Please specify only one."
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.`
144
370
  )
371
+ return 1
145
372
  }
146
373
 
147
- if (options.saveDir && !options.save && !options.doctor) {
148
- throw new Error("--save-dir requires --save or --doctor flag.")
149
- }
374
+ const sampleConfig = generateSampleConfigFile()
375
+ writeFileSync(fullPath, sampleConfig, "utf8")
150
376
 
151
- if (options.output && options.saveDir) {
152
- throw new Error(
153
- "--output and --save-dir are mutually exclusive. Use one or the other."
154
- )
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
155
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
+ }
156
421
 
157
- if (options.annotate && options.format !== "yaml") {
158
- throw new Error(
159
- "--annotate (or default with --save/--doctor) requires --format=yaml. Use --no-annotate or --format=inspect/json."
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`
160
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)
161
480
  }
162
481
  }
163
482
 
@@ -165,42 +484,146 @@ async function runDoctorMode(
165
484
  options: ExportOptions,
166
485
  appRoot: string
167
486
  ): Promise<void> {
168
- console.log("\n" + "=".repeat(80))
169
- console.log("🔍 Config Exporter - Doctor Mode")
170
- console.log("=".repeat(80))
171
- console.log("\nExporting development AND production configs...")
172
- console.log("")
487
+ // Save original environment to restore after all builds
488
+ const savedEnv = saveBuildEnvironmentVariables()
173
489
 
174
- const environments: Array<"development" | "production"> = [
175
- "development",
176
- "production"
177
- ]
178
- const fileWriter = new FileWriter()
179
- const defaultDir = resolve(process.cwd(), "shakapacker-config-exports")
180
- const targetDir = options.saveDir || defaultDir
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
+ }
181
541
 
182
- const createdFiles: string[] = []
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
+ }
183
552
 
184
- for (const env of environments) {
185
- console.log(`\n📦 Loading ${env} configuration...`)
186
- const configs = await loadConfigsForEnv(env, options, appRoot)
553
+ // Default behavior: hardcoded configs
554
+ console.log("\nExporting all development and production configs...")
555
+ console.log("")
187
556
 
188
- for (const { config, metadata } of configs) {
189
- const output = formatConfig(config, metadata, options, appRoot)
190
- const filename = fileWriter.generateFilename(
191
- metadata.bundler,
192
- metadata.environment,
193
- metadata.configType,
194
- options.format!
195
- )
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
+ ]
196
562
 
197
- const fullPath = resolve(targetDir, filename)
198
- const fileOutput: FileOutput = { filename, content: output, metadata }
199
- fileWriter.writeSingleFile(fullPath, output, true) // quiet mode
200
- createdFiles.push(fullPath)
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
+ }
201
617
  }
618
+
619
+ printDoctorSummary(createdFiles, targetDir)
620
+ } finally {
621
+ // Restore original environment
622
+ restoreBuildEnvironmentVariables(savedEnv)
202
623
  }
624
+ }
203
625
 
626
+ function printDoctorSummary(createdFiles: string[], targetDir: string): void {
204
627
  // Print summary
205
628
  console.log("\n" + "=".repeat(80))
206
629
  console.log("✅ Export Complete!")
@@ -239,11 +662,13 @@ async function runSaveMode(
239
662
  options: ExportOptions,
240
663
  appRoot: string
241
664
  ): Promise<void> {
242
- console.log(`[Config Exporter] Save mode: Exporting ${options.env} configs`)
665
+ const env = options.env || "development"
666
+ console.log(`[Config Exporter] Exporting ${env} configs`)
243
667
 
244
668
  const fileWriter = new FileWriter()
245
- const targetDir = options.saveDir || process.cwd()
246
- const configs = await loadConfigsForEnv(options.env!, options, appRoot)
669
+ const targetDir = options.saveDir! // Set by applyDefaults
670
+ const configs = await loadConfigsForEnv(options.env, options, appRoot)
671
+ const createdFiles: string[] = []
247
672
 
248
673
  if (options.output) {
249
674
  // Single file output
@@ -257,7 +682,9 @@ async function runSaveMode(
257
682
  options,
258
683
  appRoot
259
684
  )
260
- fileWriter.writeSingleFile(resolve(options.output), output)
685
+ const fullPath = resolve(options.output)
686
+ fileWriter.writeSingleFile(fullPath, output)
687
+ createdFiles.push(fullPath)
261
688
  } else {
262
689
  // Multi-file output (one per config)
263
690
  for (const { config, metadata } of configs) {
@@ -266,11 +693,20 @@ async function runSaveMode(
266
693
  metadata.bundler,
267
694
  metadata.environment,
268
695
  metadata.configType,
269
- options.format!
696
+ options.format!,
697
+ metadata.buildName
270
698
  )
271
- fileWriter.writeSingleFile(resolve(targetDir, filename), output)
699
+ const fullPath = resolve(targetDir, filename)
700
+ fileWriter.writeSingleFile(fullPath, output)
701
+ createdFiles.push(fullPath)
272
702
  }
273
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
+ })
274
710
  }
275
711
 
276
712
  async function runStdoutMode(
@@ -289,17 +725,116 @@ async function runStdoutMode(
289
725
  console.log(output)
290
726
  }
291
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
+
292
745
  async function loadConfigsForEnv(
293
- env: "development" | "production" | "test",
746
+ env: "development" | "production" | "test" | undefined,
294
747
  options: ExportOptions,
295
748
  appRoot: string
296
749
  ): Promise<Array<{ config: any; metadata: ConfigMetadata }>> {
297
- // Auto-detect bundler if not specified
298
- const bundler = options.bundler || (await autoDetectBundler(env, appRoot))
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"
299
830
 
300
- // Set environment variables
301
- process.env.NODE_ENV = env
302
- process.env.RAILS_ENV = env
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
+ }
303
838
 
304
839
  if (options.clientOnly) {
305
840
  process.env.CLIENT_BUNDLE_ONLY = "yes"
@@ -308,12 +843,16 @@ async function loadConfigsForEnv(
308
843
  }
309
844
 
310
845
  // Find and load config file
311
- const configFile = findConfigFile(bundler, appRoot)
846
+ const configFile =
847
+ customConfigFile || findConfigFile(bundler, appRoot, finalEnv)
312
848
  // Quiet mode for cleaner output - only show if verbose or errors
313
849
  if (process.env.VERBOSE) {
314
850
  console.log(`[Config Exporter] Loading config: ${configFile}`)
315
- console.log(`[Config Exporter] Environment: ${env}`)
851
+ console.log(`[Config Exporter] Environment: ${finalEnv}`)
316
852
  console.log(`[Config Exporter] Bundler: ${bundler}`)
853
+ if (buildName) {
854
+ console.log(`[Config Exporter] Build: ${buildName}`)
855
+ }
317
856
  }
318
857
 
319
858
  // Load the config
@@ -331,8 +870,26 @@ async function loadConfigsForEnv(
331
870
  }
332
871
 
333
872
  // Clear require cache for config file and all related modules
334
- // This is critical for loading different environments in the same process
335
- // MUST clear shakapacker env module cache so env.nodeEnv is re-read!
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
+ */
336
893
  const configDir = dirname(configFile)
337
894
  Object.keys(require.cache).forEach((key) => {
338
895
  if (
@@ -359,6 +916,38 @@ async function loadConfigsForEnv(
359
916
  loadedConfig = loadedConfig.default
360
917
  }
361
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
+
362
951
  // Determine config type and split if array
363
952
  const configs: any[] = Array.isArray(loadedConfig)
364
953
  ? loadedConfig
@@ -368,8 +957,27 @@ async function loadConfigsForEnv(
368
957
  configs.forEach((cfg, index) => {
369
958
  let configType: "client" | "server" | "all" = "all"
370
959
 
371
- // Try to infer config type from the config itself
372
- if (configs.length === 2) {
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) {
373
981
  // Likely client and server configs
374
982
  configType = index === 0 ? "client" : "server"
375
983
  } else if (options.clientOnly) {
@@ -381,15 +989,17 @@ async function loadConfigsForEnv(
381
989
  const metadata: ConfigMetadata = {
382
990
  exportedAt: new Date().toISOString(),
383
991
  bundler,
384
- environment: env,
992
+ environment: finalEnv,
385
993
  configFile,
386
994
  configType,
387
995
  configCount: configs.length,
996
+ buildName,
388
997
  environmentVariables: {
389
998
  NODE_ENV: process.env.NODE_ENV,
390
999
  RAILS_ENV: process.env.RAILS_ENV,
391
1000
  CLIENT_BUNDLE_ONLY: process.env.CLIENT_BUNDLE_ONLY,
392
- SERVER_BUNDLE_ONLY: process.env.SERVER_BUNDLE_ONLY
1001
+ SERVER_BUNDLE_ONLY: process.env.SERVER_BUNDLE_ONLY,
1002
+ WEBPACK_SERVE: process.env.WEBPACK_SERVE
393
1003
  }
394
1004
  }
395
1005
 
@@ -526,46 +1136,86 @@ function cleanConfig(obj: any, rootPath: string): any {
526
1136
  return clean(obj)
527
1137
  }
528
1138
 
529
- async function autoDetectBundler(
1139
+ /**
1140
+ * Loads and returns shakapacker.yml configuration
1141
+ */
1142
+ function loadShakapackerConfig(
530
1143
  env: string,
531
1144
  appRoot: string
532
- ): Promise<"webpack" | "rspack"> {
1145
+ ): { bundler: "webpack" | "rspack"; configPath: string } {
533
1146
  try {
534
- const configPath =
1147
+ const configFilePath =
535
1148
  process.env.SHAKAPACKER_CONFIG ||
536
1149
  resolve(appRoot, "config/shakapacker.yml")
537
1150
 
538
- if (existsSync(configPath)) {
539
- const config: any = loadYaml(readFileSync(configPath, "utf8"))
1151
+ if (existsSync(configFilePath)) {
1152
+ const config: any = loadYaml(readFileSync(configFilePath, "utf8"))
540
1153
  const envConfig = config[env] || config.default || {}
1154
+
1155
+ // Get bundler
541
1156
  const bundler = envConfig.assets_bundler || "webpack"
542
1157
  if (bundler !== "webpack" && bundler !== "rspack") {
543
1158
  console.warn(
544
1159
  `[Config Exporter] Invalid bundler '${bundler}' in shakapacker.yml, defaulting to webpack`
545
1160
  )
546
- return "webpack"
1161
+ return {
1162
+ bundler: "webpack",
1163
+ configPath: bundler === "rspack" ? "config/rspack" : "config/webpack"
1164
+ }
547
1165
  }
548
- console.log(`[Config Exporter] Auto-detected bundler: ${bundler}`)
549
- return bundler
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 }
550
1177
  }
551
1178
  } catch (error: any) {
552
1179
  console.warn(
553
- `[Config Exporter] Error detecting bundler, defaulting to webpack`
1180
+ `[Config Exporter] Error loading shakapacker config, defaulting to webpack`
554
1181
  )
555
1182
  }
556
1183
 
557
- return "webpack"
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
558
1206
  }
559
1207
 
560
1208
  function findConfigFile(
561
1209
  bundler: "webpack" | "rspack",
562
- appRoot: string
1210
+ appRoot: string,
1211
+ env: string
563
1212
  ): string {
1213
+ const { configPath } = loadShakapackerConfig(env, appRoot)
564
1214
  const extensions = ["ts", "js"]
565
1215
 
566
1216
  if (bundler === "rspack") {
567
1217
  for (const ext of extensions) {
568
- const rspackPath = resolve(appRoot, `config/rspack/rspack.config.${ext}`)
1218
+ const rspackPath = resolve(appRoot, configPath, `rspack.config.${ext}`)
569
1219
  if (existsSync(rspackPath)) {
570
1220
  return rspackPath
571
1221
  }
@@ -574,14 +1224,14 @@ function findConfigFile(
574
1224
 
575
1225
  // Fall back to webpack config
576
1226
  for (const ext of extensions) {
577
- const webpackPath = resolve(appRoot, `config/webpack/webpack.config.${ext}`)
1227
+ const webpackPath = resolve(appRoot, configPath, `webpack.config.${ext}`)
578
1228
  if (existsSync(webpackPath)) {
579
1229
  return webpackPath
580
1230
  }
581
1231
  }
582
1232
 
583
1233
  throw new Error(
584
- `Could not find ${bundler} config file. Expected: config/${bundler}/${bundler}.config.{js,ts}`
1234
+ `Could not find ${bundler} config file. Expected: ${configPath}/${bundler}.config.{js,ts}`
585
1235
  )
586
1236
  }
587
1237
 
@@ -622,62 +1272,3 @@ function setupNodePath(appRoot: string): void {
622
1272
  require("module").Module._initPaths()
623
1273
  }
624
1274
  }
625
-
626
- function showHelp(): void {
627
- console.log(`
628
- Shakapacker Config Exporter
629
-
630
- Exports webpack or rspack configuration in a verbose, human-readable format
631
- for comparison and analysis.
632
-
633
- QUICK START (for troubleshooting):
634
- bin/export-bundler-config --doctor
635
-
636
- Exports annotated YAML configs for both development and production.
637
- Creates separate files for client and server bundles.
638
- Best for debugging, AI analysis, and comparing configurations.
639
-
640
- Usage:
641
- bin/export-bundler-config [options]
642
-
643
- Options:
644
- --doctor Export all configs for troubleshooting (dev + prod, annotated YAML)
645
- --save Save to auto-generated file(s) (default: YAML format)
646
- --save-dir=<directory> Directory for output files (requires --save)
647
- --bundler=webpack|rspack Specify bundler (auto-detected if not provided)
648
- --env=development|production|test Node environment (default: development, ignored with --doctor)
649
- --client-only Generate only client config (sets CLIENT_BUNDLE_ONLY=yes)
650
- --server-only Generate only server config (sets SERVER_BUNDLE_ONLY=yes)
651
- --output=<filename> Output to specific file (default: stdout)
652
- --depth=<number> Inspection depth (default: 20, use 'null' for unlimited)
653
- --format=yaml|json|inspect Output format (default: inspect for stdout, yaml for --save/--doctor)
654
- --no-annotate Disable inline documentation (YAML only)
655
- --verbose Show full output without compact mode
656
- --help, -h Show this help message
657
-
658
- Note: --client-only and --server-only are mutually exclusive.
659
- --save-dir requires --save.
660
- --output and --save-dir are mutually exclusive.
661
- If neither --client-only nor --server-only specified, both configs are generated.
662
-
663
- Examples:
664
- # RECOMMENDED: Export everything for troubleshooting
665
- bin/export-bundler-config --doctor
666
- # Creates: webpack-development-client.yaml, webpack-development-server.yaml,
667
- # webpack-production-client.yaml, webpack-production-server.yaml
668
-
669
- # Save current environment configs
670
- bin/export-bundler-config --save
671
- # Creates: webpack-development-client.yaml, webpack-development-server.yaml
672
-
673
- # Save to specific directory
674
- bin/export-bundler-config --save --save-dir=./debug
675
-
676
- # Export only client config for production
677
- bin/export-bundler-config --save --env=production --client-only
678
- # Creates: webpack-production-client.yaml
679
-
680
- # View config in terminal (stdout)
681
- bin/export-bundler-config
682
- `)
683
- }