shakapacker 9.2.0 → 9.3.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +4 -0
  12. data/CHANGELOG.md +74 -5
  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 +141 -3
  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/jest.config.js +8 -1
  51. data/knip.ts +54 -0
  52. data/lib/install/bin/export-bundler-config +1 -1
  53. data/lib/install/config/shakapacker.yml +16 -5
  54. data/lib/shakapacker/compiler.rb +80 -0
  55. data/lib/shakapacker/configuration.rb +33 -5
  56. data/lib/shakapacker/dev_server_runner.rb +140 -1
  57. data/lib/shakapacker/doctor.rb +294 -65
  58. data/lib/shakapacker/instance.rb +8 -3
  59. data/lib/shakapacker/runner.rb +244 -8
  60. data/lib/shakapacker/version.rb +1 -1
  61. data/lib/tasks/shakapacker/doctor.rake +42 -2
  62. data/package/babel/preset.ts +7 -4
  63. data/package/config.ts +42 -30
  64. data/package/configExporter/buildValidator.ts +883 -0
  65. data/package/configExporter/cli.ts +972 -210
  66. data/package/configExporter/configFile.ts +520 -0
  67. data/package/configExporter/fileWriter.ts +12 -8
  68. data/package/configExporter/index.ts +11 -1
  69. data/package/configExporter/types.ts +54 -2
  70. data/package/configExporter/yamlSerializer.ts +22 -8
  71. data/package/dev_server.ts +1 -1
  72. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +11 -5
  73. data/package/environments/base.ts +18 -13
  74. data/package/environments/development.ts +1 -1
  75. data/package/environments/production.ts +4 -1
  76. data/package/index.d.ts +50 -3
  77. data/package/index.d.ts.template +50 -0
  78. data/package/index.ts +7 -7
  79. data/package/loaders.d.ts +2 -2
  80. data/package/optimization/rspack.ts +1 -1
  81. data/package/plugins/rspack.ts +15 -4
  82. data/package/plugins/webpack.ts +7 -3
  83. data/package/rspack/index.ts +10 -2
  84. data/package/rules/raw.ts +3 -2
  85. data/package/rules/sass.ts +1 -1
  86. data/package/types/README.md +15 -13
  87. data/package/types/index.ts +5 -5
  88. data/package/types.ts +0 -1
  89. data/package/utils/defaultConfigPath.ts +4 -1
  90. data/package/utils/errorCodes.ts +129 -100
  91. data/package/utils/errorHelpers.ts +34 -29
  92. data/package/utils/getStyleRule.ts +5 -2
  93. data/package/utils/helpers.ts +21 -11
  94. data/package/utils/pathValidation.ts +43 -35
  95. data/package/utils/requireOrError.ts +1 -1
  96. data/package/utils/snakeToCamelCase.ts +1 -1
  97. data/package/utils/typeGuards.ts +132 -83
  98. data/package/utils/validateDependencies.ts +1 -1
  99. data/package/webpack-types.d.ts +3 -3
  100. data/package/webpackDevServerConfig.ts +22 -10
  101. data/package-lock.json +2 -2
  102. data/package.json +25 -16
  103. data/scripts/type-check-no-emit.js +1 -1
  104. data/test/configExporter/buildValidator.test.js +1292 -0
  105. data/test/configExporter/configFile.test.js +392 -0
  106. data/test/configExporter/integration.test.js +275 -0
  107. data/test/helpers.js +1 -1
  108. data/test/package/configExporter.test.js +154 -0
  109. data/test/package/environments/base.test.js +6 -3
  110. data/test/package/helpers.test.js +2 -2
  111. data/test/package/rules/babel.test.js +61 -51
  112. data/test/package/rules/esbuild.test.js +12 -3
  113. data/test/package/rules/file.test.js +3 -1
  114. data/test/package/rules/sass-version-parsing.test.js +71 -0
  115. data/test/package/rules/sass.test.js +11 -6
  116. data/test/package/rules/sass1.test.js +4 -5
  117. data/test/package/rules/sass16.test.js +24 -0
  118. data/test/package/rules/swc.test.js +48 -38
  119. data/tools/README.md +15 -5
  120. data/tsconfig.eslint.json +2 -9
  121. data/yarn.lock +1954 -1493
  122. metadata +22 -3
  123. data/.eslintignore +0 -5
@@ -1,22 +1,98 @@
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
+ import { BuildValidator } from "./buildValidator"
14
+
15
+ // Read version from package.json
16
+ const packageJson = JSON.parse(
17
+ readFileSync(resolve(__dirname, "../../package.json"), "utf8")
18
+ )
19
+ const VERSION = packageJson.version
20
+
21
+ /**
22
+ * Environment variable names that can be set by build configurations
23
+ */
24
+ const BUILD_ENV_VARS = [
25
+ "NODE_ENV",
26
+ "RAILS_ENV",
27
+ "NODE_OPTIONS",
28
+ "BABEL_ENV",
29
+ "WEBPACK_SERVE",
30
+ "CLIENT_BUNDLE_ONLY",
31
+ "SERVER_BUNDLE_ONLY"
32
+ ] as const
33
+
34
+ /**
35
+ * Saves current values of build environment variables for later restoration
36
+ * @returns Object mapping variable names to their current values (or undefined)
37
+ */
38
+ function saveBuildEnvironmentVariables(): Record<string, string | undefined> {
39
+ const saved: Record<string, string | undefined> = {}
40
+ BUILD_ENV_VARS.forEach((varName) => {
41
+ saved[varName] = process.env[varName]
42
+ })
43
+ return saved
44
+ }
45
+
46
+ /**
47
+ * Restores previously saved environment variable values
48
+ * @param saved - Object mapping variable names to their original values
49
+ */
50
+ function restoreBuildEnvironmentVariables(
51
+ saved: Record<string, string | undefined>
52
+ ): void {
53
+ BUILD_ENV_VARS.forEach((varName) => {
54
+ const originalValue = saved[varName]
55
+ if (originalValue === undefined) {
56
+ delete process.env[varName]
57
+ } else {
58
+ process.env[varName] = originalValue
59
+ }
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Clears all whitelisted build environment variables from process.env
65
+ * to prevent environment variable leakage between builds
66
+ */
67
+ function clearBuildEnvironmentVariables(): void {
68
+ BUILD_ENV_VARS.forEach((varName) => {
69
+ delete process.env[varName]
70
+ })
71
+ }
11
72
 
12
73
  // Main CLI entry point
13
74
  export async function run(args: string[]): Promise<number> {
14
75
  try {
15
76
  const options = parseArguments(args)
16
77
 
17
- if (options.help) {
18
- showHelp()
19
- return 0
78
+ // Handle --init command
79
+ if (options.init) {
80
+ return runInitCommand(options)
81
+ }
82
+
83
+ // Handle --list-builds command
84
+ if (options.listBuilds) {
85
+ return runListBuildsCommand(options)
86
+ }
87
+
88
+ // Handle --validate or --validate-build command
89
+ if (options.validate || options.validateBuild) {
90
+ return await runValidateCommand(options)
91
+ }
92
+
93
+ // Handle --all-builds command
94
+ if (options.allBuilds) {
95
+ return runAllBuildsCommand(options)
20
96
  }
21
97
 
22
98
  // Set up environment
@@ -27,137 +103,547 @@ export async function run(args: string[]): Promise<number> {
27
103
  // Apply defaults
28
104
  applyDefaults(options)
29
105
 
30
- // Validate options
31
- validateOptions(options)
106
+ // Validate after defaults are applied
107
+ if (options.annotate && options.format !== "yaml") {
108
+ throw new Error(
109
+ "Annotation requires YAML format. Use --no-annotate or --format=yaml."
110
+ )
111
+ }
112
+
113
+ // Validate --build requires config file
114
+ if (options.build) {
115
+ const loader = new ConfigFileLoader(options.configFile)
116
+ if (!loader.exists()) {
117
+ const configPath = options.configFile || ".bundler-config.yml"
118
+ throw new Error(
119
+ `--build requires a config file but ${configPath} not found. Run --init to create it.`
120
+ )
121
+ }
122
+ }
32
123
 
33
124
  // Execute based on mode
34
125
  if (options.doctor) {
35
126
  await runDoctorMode(options, appRoot)
36
- } else if (options.save) {
37
- await runSaveMode(options, appRoot)
38
- } else {
127
+ } else if (options.stdout) {
128
+ // Explicit stdout mode
39
129
  await runStdoutMode(options, appRoot)
130
+ } else if (options.output) {
131
+ // Save to single file
132
+ await runSingleFileMode(options, appRoot)
133
+ } else {
134
+ // Default: save to directory
135
+ await runSaveMode(options, appRoot)
40
136
  }
41
137
 
42
138
  return 0
43
- } catch (error: any) {
44
- console.error(`[Config Exporter] Error: ${error.message}`)
139
+ } catch (error: unknown) {
140
+ const errorMessage = error instanceof Error ? error.message : String(error)
141
+ console.error(`[Config Exporter] Error: ${errorMessage}`)
45
142
  return 1
46
143
  }
47
144
  }
48
145
 
49
146
  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
- }
147
+ const argv = yargs(args)
148
+ .version(VERSION)
149
+ .usage(
150
+ `Shakapacker Config Exporter
65
151
 
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
- }
152
+ Exports webpack or rspack configuration in a verbose, human-readable format
153
+ for comparison and analysis.
73
154
 
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") {
155
+ QUICK START (for troubleshooting):
156
+ bin/export-bundler-config --doctor
157
+
158
+ Exports annotated YAML configs for both development and production.
159
+ Creates separate files for client and server bundles.
160
+ Best for debugging, AI analysis, and comparing configurations.`
161
+ )
162
+ .option("doctor", {
163
+ type: "boolean",
164
+ default: false,
165
+ description:
166
+ "Export all configs for troubleshooting (dev + prod, annotated YAML)"
167
+ })
168
+ .option("save-dir", {
169
+ type: "string",
170
+ description:
171
+ "Directory for output files (default: shakapacker-config-exports)"
172
+ })
173
+ .option("stdout", {
174
+ type: "boolean",
175
+ default: false,
176
+ description: "Output to stdout instead of saving to files"
177
+ })
178
+ .option("bundler", {
179
+ type: "string",
180
+ choices: ["webpack", "rspack"] as const,
181
+ description: "Specify bundler (auto-detected if not provided)"
182
+ })
183
+ .option("env", {
184
+ type: "string",
185
+ choices: ["development", "production", "test"] as const,
186
+ description:
187
+ "Node environment (default: development, ignored with --doctor or --build)"
188
+ })
189
+ .option("client-only", {
190
+ type: "boolean",
191
+ default: false,
192
+ description: "Generate only client config (sets CLIENT_BUNDLE_ONLY=yes)"
193
+ })
194
+ .option("server-only", {
195
+ type: "boolean",
196
+ default: false,
197
+ description: "Generate only server config (sets SERVER_BUNDLE_ONLY=yes)"
198
+ })
199
+ .option("output", {
200
+ type: "string",
201
+ description: "Output to specific file instead of directory"
202
+ })
203
+ .option("depth", {
204
+ type: "number",
205
+ default: 20,
206
+ coerce: (value: number | string) => {
207
+ if (value === "null" || value === null) return null
208
+ return typeof value === "number" ? value : parseInt(String(value), 10)
209
+ },
210
+ description: "Inspection depth (use 'null' for unlimited)"
211
+ })
212
+ .option("format", {
213
+ type: "string",
214
+ choices: ["yaml", "json", "inspect"] as const,
215
+ description: "Output format (default: yaml for files, inspect for stdout)"
216
+ })
217
+ .option("annotate", {
218
+ type: "boolean",
219
+ description:
220
+ "Enable inline documentation (YAML only, default with --doctor or file output)"
221
+ })
222
+ .option("verbose", {
223
+ type: "boolean",
224
+ default: false,
225
+ description: "Show full output without compact mode"
226
+ })
227
+ .option("init", {
228
+ type: "boolean",
229
+ default: false,
230
+ description: "Generate sample .bundler-config.yml with examples"
231
+ })
232
+ .option("config-file", {
233
+ type: "string",
234
+ description: "Path to config file (default: .bundler-config.yml)"
235
+ })
236
+ .option("build", {
237
+ type: "string",
238
+ description: "Export config for specific build from config file"
239
+ })
240
+ .option("list-builds", {
241
+ type: "boolean",
242
+ default: false,
243
+ description: "List all available builds from config file"
244
+ })
245
+ .option("all-builds", {
246
+ type: "boolean",
247
+ default: false,
248
+ description: "Export all builds from config file"
249
+ })
250
+ .option("validate", {
251
+ type: "boolean",
252
+ default: false,
253
+ description:
254
+ "Validate all builds by running webpack/rspack (requires config file)"
255
+ })
256
+ .option("validate-build", {
257
+ type: "string",
258
+ description: "Validate specific build from config file"
259
+ })
260
+ .option("webpack", {
261
+ type: "boolean",
262
+ default: false,
263
+ description: "Use webpack (overrides config file)"
264
+ })
265
+ .option("rspack", {
266
+ type: "boolean",
267
+ default: false,
268
+ description: "Use rspack (overrides config file)"
269
+ })
270
+ .check((argv) => {
271
+ if (argv.webpack && argv.rspack) {
86
272
  throw new Error(
87
- `Invalid bundler '${bundler}'. Must be 'webpack' or 'rspack'.`
273
+ "--webpack and --rspack are mutually exclusive. Please specify only one."
88
274
  )
89
275
  }
90
- options.bundler = bundler
91
- } else if (arg.startsWith("--env=")) {
92
- const env = parseValue(arg, "--env=")
93
- if (env !== "development" && env !== "production" && env !== "test") {
276
+ if (argv["client-only"] && argv["server-only"]) {
94
277
  throw new Error(
95
- `Invalid environment '${env}'. Must be 'development', 'production', or 'test'.`
278
+ "--client-only and --server-only are mutually exclusive. Please specify only one."
96
279
  )
97
280
  }
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") {
281
+ if (argv.output && argv["save-dir"]) {
111
282
  throw new Error(
112
- `Invalid format '${format}'. Must be 'yaml', 'json', or 'inspect'.`
283
+ "--output and --save-dir are mutually exclusive. Use one or the other."
113
284
  )
114
285
  }
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
- }
286
+ if (argv.stdout && argv["save-dir"]) {
287
+ throw new Error(
288
+ "--stdout and --save-dir are mutually exclusive. Use one or the other."
289
+ )
290
+ }
291
+ if (argv.build && argv["all-builds"]) {
292
+ throw new Error(
293
+ "--build and --all-builds are mutually exclusive. Use one or the other."
294
+ )
295
+ }
296
+ if (argv.validate && argv["validate-build"]) {
297
+ throw new Error(
298
+ "--validate and --validate-build are mutually exclusive. Use one or the other."
299
+ )
300
+ }
301
+ if (argv.validate && (argv.build || argv["all-builds"])) {
302
+ throw new Error(
303
+ "--validate cannot be used with --build or --all-builds."
304
+ )
305
+ }
306
+ return true
307
+ })
308
+ .help("help")
309
+ .alias("help", "h")
310
+ .epilogue(
311
+ `Examples:
312
+
313
+ # Config File Workflow
314
+ bin/export-bundler-config --init
315
+ bin/export-bundler-config --list-builds
316
+ bin/export-bundler-config --build=dev
317
+ bin/export-bundler-config --all-builds --save-dir=./configs
318
+ bin/export-bundler-config --build=dev --rspack
319
+
320
+ # Traditional Workflow (without config file)
321
+ bin/export-bundler-config --doctor
322
+ # Creates: webpack-development-client-hmr.yaml, webpack-development-client.yaml,
323
+ # webpack-development-server.yaml, webpack-production-client.yaml,
324
+ # webpack-production-server.yaml
325
+
326
+ bin/export-bundler-config --env=production --client-only
327
+ bin/export-bundler-config --save-dir=./debug
328
+ bin/export-bundler-config # Saves to shakapacker-config-exports/
329
+
330
+ # Validate builds
331
+ bin/export-bundler-config --validate # Validate all builds
332
+ bin/export-bundler-config --validate-build=dev # Validate specific build
333
+ bin/export-bundler-config --validate --verbose # Validate with full logs
122
334
 
123
- return options
335
+ # View config in terminal (stdout)
336
+ bin/export-bundler-config --stdout
337
+ bin/export-bundler-config --output=config.yaml # Save to specific file`
338
+ )
339
+ .strict()
340
+ .parseSync()
341
+
342
+ // Type assertions are safe here because yargs validates choices at runtime
343
+ // Handle --webpack and --rspack flags
344
+ let bundler: "webpack" | "rspack" | undefined = argv.bundler as
345
+ | "webpack"
346
+ | "rspack"
347
+ | undefined
348
+ if (argv.webpack) bundler = "webpack"
349
+ if (argv.rspack) bundler = "rspack"
350
+
351
+ return {
352
+ bundler,
353
+ env: argv.env as "development" | "production" | "test" | undefined,
354
+ clientOnly: argv["client-only"],
355
+ serverOnly: argv["server-only"],
356
+ output: argv.output,
357
+ depth: argv.depth as number | null,
358
+ format: argv.format as "yaml" | "json" | "inspect" | undefined,
359
+ help: false, // yargs handles help internally
360
+ verbose: argv.verbose,
361
+ doctor: argv.doctor,
362
+ saveDir: argv["save-dir"],
363
+ stdout: argv.stdout,
364
+ annotate: argv.annotate,
365
+ init: argv.init,
366
+ configFile: argv["config-file"],
367
+ build: argv.build,
368
+ listBuilds: argv["list-builds"],
369
+ allBuilds: argv["all-builds"],
370
+ validate: argv.validate,
371
+ validateBuild: argv["validate-build"]
372
+ }
124
373
  }
125
374
 
126
375
  function applyDefaults(options: ExportOptions): void {
127
376
  if (options.doctor) {
128
- options.save = true
129
377
  if (options.format === undefined) options.format = "yaml"
130
378
  if (options.annotate === undefined) options.annotate = true
131
- } else if (options.save) {
379
+ } else if (!options.stdout && !options.output) {
380
+ // Default mode: save to directory
132
381
  if (options.format === undefined) options.format = "yaml"
133
382
  if (options.annotate === undefined) options.annotate = true
134
383
  } else {
135
384
  if (options.format === undefined) options.format = "inspect"
136
385
  if (options.annotate === undefined) options.annotate = false
137
386
  }
387
+
388
+ // Set default save directory for file output modes
389
+ if (!options.stdout && !options.output && !options.saveDir) {
390
+ options.saveDir = resolve(process.cwd(), "shakapacker-config-exports")
391
+ }
138
392
  }
139
393
 
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."
394
+ function runInitCommand(options: ExportOptions): number {
395
+ const configPath = options.configFile || ".bundler-config.yml"
396
+ const fullPath = resolve(process.cwd(), configPath)
397
+
398
+ if (existsSync(fullPath)) {
399
+ console.error(
400
+ `[Config Exporter] Error: Config file already exists: ${fullPath}`
401
+ )
402
+ console.error(
403
+ `Remove it first or use --config-file=<path> for a different location.`
144
404
  )
405
+ return 1
145
406
  }
146
407
 
147
- if (options.saveDir && !options.save && !options.doctor) {
148
- throw new Error("--save-dir requires --save or --doctor flag.")
408
+ const sampleConfig = generateSampleConfigFile()
409
+ writeFileSync(fullPath, sampleConfig, "utf8")
410
+
411
+ console.log(`[Config Exporter] ✅ Created config file: ${fullPath}`)
412
+ console.log(`\nNext steps:`)
413
+ console.log(` 1. Edit the config file to match your build setup`)
414
+ console.log(
415
+ ` 2. List available builds: bin/export-bundler-config --list-builds`
416
+ )
417
+ console.log(
418
+ ` 3. Export a build: bin/export-bundler-config --build=<name> --save\n`
419
+ )
420
+
421
+ return 0
422
+ }
423
+
424
+ function runListBuildsCommand(options: ExportOptions): number {
425
+ try {
426
+ const loader = new ConfigFileLoader(options.configFile)
427
+ loader.listBuilds()
428
+ return 0
429
+ } catch (error: unknown) {
430
+ const errorMessage = error instanceof Error ? error.message : String(error)
431
+ console.error(`[Config Exporter] Error: ${errorMessage}`)
432
+ return 1
149
433
  }
434
+ }
150
435
 
151
- if (options.output && options.saveDir) {
152
- throw new Error(
153
- "--output and --save-dir are mutually exclusive. Use one or the other."
154
- )
436
+ async function runValidateCommand(options: ExportOptions): Promise<number> {
437
+ const savedEnv = saveBuildEnvironmentVariables()
438
+
439
+ try {
440
+ // Validate that config file exists
441
+ const loader = new ConfigFileLoader(options.configFile)
442
+ if (!loader.exists()) {
443
+ const configPath = options.configFile || ".bundler-config.yml"
444
+ throw new Error(
445
+ `Config file ${configPath} not found. Run --init to create it.`
446
+ )
447
+ }
448
+
449
+ // Set up environment
450
+ const appRoot = findAppRoot()
451
+ process.chdir(appRoot)
452
+ setupNodePath(appRoot)
453
+
454
+ const config = loader.load()
455
+ const validator = new BuildValidator({ verbose: options.verbose || false })
456
+
457
+ // Determine which builds to validate
458
+ let buildsToValidate: string[]
459
+ if (options.validateBuild) {
460
+ // Validate specific build
461
+ if (!config.builds[options.validateBuild]) {
462
+ const available = Object.keys(config.builds).join(", ")
463
+ throw new Error(
464
+ `Build '${options.validateBuild}' not found in config file.\n` +
465
+ `Available builds: ${available}`
466
+ )
467
+ }
468
+ buildsToValidate = [options.validateBuild]
469
+ } else {
470
+ // Validate all builds
471
+ buildsToValidate = Object.keys(config.builds)
472
+
473
+ // Handle empty builds edge case
474
+ if (buildsToValidate.length === 0) {
475
+ throw new Error(
476
+ `No builds found in config file. Add at least one build to .bundler-config.yml or run --init to see examples.`
477
+ )
478
+ }
479
+ }
480
+
481
+ console.log("\n" + "=".repeat(80))
482
+ console.log("🔍 Validating Builds")
483
+ console.log("=".repeat(80))
484
+ console.log(`\nValidating ${buildsToValidate.length} build(s)...\n`)
485
+
486
+ if (options.verbose) {
487
+ console.log("⚡ VERBOSE MODE ENABLED - Full build output will be shown")
488
+ console.log(
489
+ " This includes all webpack/rspack compilation logs, warnings, and progress messages"
490
+ )
491
+ console.log(" Use without --verbose to see only errors and summaries\n")
492
+ console.log("=".repeat(80) + "\n")
493
+ }
494
+
495
+ const results = []
496
+
497
+ // Validate each build
498
+ for (const buildName of buildsToValidate) {
499
+ if (options.verbose) {
500
+ console.log("\n" + "=".repeat(80))
501
+ console.log(`📦 VALIDATING BUILD: ${buildName}`)
502
+ console.log("=".repeat(80))
503
+ } else {
504
+ console.log(`\n📦 Validating build: ${buildName}`)
505
+ }
506
+
507
+ // Clear and restore environment to prevent leakage between builds
508
+ clearBuildEnvironmentVariables()
509
+ restoreBuildEnvironmentVariables(savedEnv)
510
+
511
+ // Get the build's environment to use for auto-detection
512
+ const buildConfig = config.builds[buildName]
513
+ const buildEnv =
514
+ buildConfig.environment?.NODE_ENV ||
515
+ (buildConfig.environment?.RAILS_ENV as
516
+ | "development"
517
+ | "production"
518
+ | "test"
519
+ | undefined) ||
520
+ "development"
521
+
522
+ // Auto-detect bundler using the build's environment
523
+ const defaultBundler = await autoDetectBundler(buildEnv, appRoot)
524
+
525
+ // Resolve build config with the correct default bundler
526
+ const resolvedBuild = loader.resolveBuild(
527
+ buildName,
528
+ options,
529
+ defaultBundler
530
+ )
531
+
532
+ // Validate the build
533
+ const result = await validator.validateBuild(resolvedBuild, appRoot)
534
+ results.push(result)
535
+
536
+ // Show immediate feedback
537
+ if (options.verbose) {
538
+ console.log("=".repeat(80))
539
+ }
540
+ if (result.success) {
541
+ console.log(` ✅ Build passed`)
542
+ } else {
543
+ console.log(` ❌ Build failed with ${result.errors.length} error(s)`)
544
+ }
545
+ if (options.verbose) {
546
+ console.log("")
547
+ }
548
+ }
549
+
550
+ // Print formatted results
551
+ const formattedResults = validator.formatResults(results)
552
+ console.log(formattedResults)
553
+
554
+ // Return exit code based on results
555
+ const hasFailures = results.some((r) => !r.success)
556
+ return hasFailures ? 1 : 0
557
+ } catch (error: unknown) {
558
+ const errorMessage = error instanceof Error ? error.message : String(error)
559
+ console.error(`[Config Exporter] Error: ${errorMessage}`)
560
+ return 1
561
+ } finally {
562
+ // Restore original environment
563
+ restoreBuildEnvironmentVariables(savedEnv)
155
564
  }
565
+ }
566
+
567
+ async function runAllBuildsCommand(options: ExportOptions): Promise<number> {
568
+ // Save original environment to restore after all builds
569
+ const savedEnv = saveBuildEnvironmentVariables()
156
570
 
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."
571
+ try {
572
+ // Set up environment
573
+ const appRoot = findAppRoot()
574
+ process.chdir(appRoot)
575
+ setupNodePath(appRoot)
576
+
577
+ // Apply defaults
578
+ applyDefaults(options)
579
+
580
+ const loader = new ConfigFileLoader(options.configFile)
581
+ if (!loader.exists()) {
582
+ const configPath = options.configFile || ".bundler-config.yml"
583
+ throw new Error(
584
+ `Config file ${configPath} not found. Run --init to create it.`
585
+ )
586
+ }
587
+
588
+ const config = loader.load()
589
+ const buildNames = Object.keys(config.builds)
590
+
591
+ console.log(
592
+ `\n📦 Exporting ${buildNames.length} builds from config file...\n`
160
593
  )
594
+
595
+ const fileWriter = new FileWriter()
596
+ const targetDir = options.saveDir! // Set by applyDefaults
597
+ const createdFiles: string[] = []
598
+
599
+ // Export each build
600
+ for (const buildName of buildNames) {
601
+ console.log(`\n📦 Exporting build: ${buildName}`)
602
+
603
+ // Clear and restore environment to prevent leakage between builds
604
+ clearBuildEnvironmentVariables()
605
+ restoreBuildEnvironmentVariables(savedEnv)
606
+
607
+ // Create a modified options object for this build
608
+ const buildOptions = { ...options, build: buildName }
609
+ const configs = await loadConfigsForEnv(undefined, buildOptions, appRoot)
610
+
611
+ for (const { config: cfg, metadata } of configs) {
612
+ const output = formatConfig(cfg, metadata, options, appRoot)
613
+ const filename = fileWriter.generateFilename(
614
+ metadata.bundler,
615
+ metadata.environment,
616
+ metadata.configType,
617
+ options.format!,
618
+ metadata.buildName
619
+ )
620
+
621
+ const fullPath = resolve(targetDir, filename)
622
+ fileWriter.writeSingleFile(fullPath, output)
623
+ createdFiles.push(fullPath)
624
+ }
625
+ }
626
+
627
+ // Print summary
628
+ console.log("\n" + "=".repeat(80))
629
+ console.log("✅ All Builds Exported!")
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
+ console.log("\n" + "=".repeat(80) + "\n")
638
+
639
+ return 0
640
+ } catch (error: unknown) {
641
+ const errorMessage = error instanceof Error ? error.message : String(error)
642
+ console.error(`[Config Exporter] Error: ${errorMessage}`)
643
+ return 1
644
+ } finally {
645
+ // Restore original environment
646
+ restoreBuildEnvironmentVariables(savedEnv)
161
647
  }
162
648
  }
163
649
 
@@ -165,42 +651,148 @@ async function runDoctorMode(
165
651
  options: ExportOptions,
166
652
  appRoot: string
167
653
  ): 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("")
654
+ // Save original environment to restore after all builds
655
+ const savedEnv = saveBuildEnvironmentVariables()
173
656
 
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
657
+ try {
658
+ console.log("\n" + "=".repeat(80))
659
+ console.log("🔍 Config Exporter - Doctor Mode")
660
+ console.log("=".repeat(80))
661
+
662
+ const fileWriter = new FileWriter()
663
+ const targetDir = options.saveDir! // Set by applyDefaults
664
+
665
+ const createdFiles: string[] = []
666
+
667
+ // Check if config file exists with shakapacker_doctor_default_builds_here flag
668
+ const configFilePath = options.configFile || ".bundler-config.yml"
669
+ const loader = new ConfigFileLoader(configFilePath)
670
+
671
+ if (loader.exists()) {
672
+ try {
673
+ const configData = loader.load()
674
+ if (configData.shakapacker_doctor_default_builds_here) {
675
+ console.log(
676
+ "\nUsing builds from config file (shakapacker_doctor_default_builds_here: true)...\n"
677
+ )
678
+ // Use config file builds
679
+ const buildNames = Object.keys(configData.builds)
680
+
681
+ for (const buildName of buildNames) {
682
+ console.log(`\n📦 Loading build: ${buildName}`)
683
+
684
+ // Clear and restore environment to prevent leakage between builds
685
+ clearBuildEnvironmentVariables()
686
+ restoreBuildEnvironmentVariables(savedEnv)
687
+
688
+ const configs = await loadConfigsForEnv(
689
+ undefined,
690
+ { ...options, build: buildName },
691
+ appRoot
692
+ )
693
+
694
+ for (const { config, metadata } of configs) {
695
+ const output = formatConfig(config, metadata, options, appRoot)
696
+ const filename = fileWriter.generateFilename(
697
+ metadata.bundler,
698
+ metadata.environment,
699
+ metadata.configType,
700
+ options.format!,
701
+ metadata.buildName
702
+ )
703
+ const fullPath = resolve(targetDir, filename)
704
+ fileWriter.writeSingleFile(fullPath, output)
705
+ createdFiles.push(fullPath)
706
+ }
707
+ }
181
708
 
182
- const createdFiles: string[] = []
709
+ // Print summary and exit early
710
+ printDoctorSummary(createdFiles, targetDir)
711
+ return
712
+ }
713
+ } catch (error: unknown) {
714
+ // If config file exists but is invalid, warn and fall through to default behavior
715
+ const errorMessage =
716
+ error instanceof Error ? error.message : String(error)
717
+ console.log(`\n⚠️ Config file found but invalid: ${errorMessage}`)
718
+ console.log("Falling back to default doctor mode...\n")
719
+ }
720
+ }
183
721
 
184
- for (const env of environments) {
185
- console.log(`\n📦 Loading ${env} configuration...`)
186
- const configs = await loadConfigsForEnv(env, options, appRoot)
722
+ // Default behavior: hardcoded configs
723
+ console.log("\nExporting all development and production configs...")
724
+ console.log("")
187
725
 
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
- )
726
+ const configsToExport = [
727
+ { label: "development (HMR)", env: "development" as const, hmr: true },
728
+ { label: "development", env: "development" as const, hmr: false },
729
+ { label: "production", env: "production" as const, hmr: false }
730
+ ]
196
731
 
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)
732
+ for (const { label, env, hmr } of configsToExport) {
733
+ console.log(`\n📦 Loading ${label} configuration...`)
734
+
735
+ // Clear and restore environment to prevent leakage between builds
736
+ clearBuildEnvironmentVariables()
737
+ restoreBuildEnvironmentVariables(savedEnv)
738
+
739
+ // Set WEBPACK_SERVE for HMR config
740
+ if (hmr) {
741
+ process.env.WEBPACK_SERVE = "true"
742
+ }
743
+
744
+ const configs = await loadConfigsForEnv(env, options, appRoot)
745
+
746
+ for (const { config, metadata } of configs) {
747
+ const output = formatConfig(config, metadata, options, appRoot)
748
+
749
+ // Adjust filename for HMR config
750
+ let filename: string
751
+ if (
752
+ hmr &&
753
+ (metadata.configType === "client" || metadata.configType === "all")
754
+ ) {
755
+ /**
756
+ * HMR Mode Filename Logic:
757
+ * - When WEBPACK_SERVE=true, webpack-dev-server runs and HMR is enabled
758
+ * - HMR only applies to client bundles (server bundles don't use HMR)
759
+ * - If configType is "all", we still only generate client file for HMR
760
+ * because the server bundle is identical to non-HMR development
761
+ * - Filename uses "client" type and "development-hmr" build name to
762
+ * distinguish it from regular development client bundle
763
+ */
764
+ filename = fileWriter.generateFilename(
765
+ metadata.bundler,
766
+ metadata.environment,
767
+ "client",
768
+ options.format!,
769
+ "development-hmr"
770
+ )
771
+ } else {
772
+ filename = fileWriter.generateFilename(
773
+ metadata.bundler,
774
+ metadata.environment,
775
+ metadata.configType,
776
+ options.format!,
777
+ metadata.buildName
778
+ )
779
+ }
780
+
781
+ const fullPath = resolve(targetDir, filename)
782
+ const fileOutput: FileOutput = { filename, content: output, metadata }
783
+ fileWriter.writeSingleFile(fullPath, output)
784
+ createdFiles.push(fullPath)
785
+ }
201
786
  }
787
+
788
+ printDoctorSummary(createdFiles, targetDir)
789
+ } finally {
790
+ // Restore original environment
791
+ restoreBuildEnvironmentVariables(savedEnv)
202
792
  }
793
+ }
203
794
 
795
+ function printDoctorSummary(createdFiles: string[], targetDir: string): void {
204
796
  // Print summary
205
797
  console.log("\n" + "=".repeat(80))
206
798
  console.log("✅ Export Complete!")
@@ -239,11 +831,13 @@ async function runSaveMode(
239
831
  options: ExportOptions,
240
832
  appRoot: string
241
833
  ): Promise<void> {
242
- console.log(`[Config Exporter] Save mode: Exporting ${options.env} configs`)
834
+ const env = options.env || "development"
835
+ console.log(`[Config Exporter] Exporting ${env} configs`)
243
836
 
244
837
  const fileWriter = new FileWriter()
245
- const targetDir = options.saveDir || process.cwd()
246
- const configs = await loadConfigsForEnv(options.env!, options, appRoot)
838
+ const targetDir = options.saveDir! // Set by applyDefaults
839
+ const configs = await loadConfigsForEnv(options.env, options, appRoot)
840
+ const createdFiles: string[] = []
247
841
 
248
842
  if (options.output) {
249
843
  // Single file output
@@ -257,7 +851,9 @@ async function runSaveMode(
257
851
  options,
258
852
  appRoot
259
853
  )
260
- fileWriter.writeSingleFile(resolve(options.output), output)
854
+ const fullPath = resolve(options.output)
855
+ fileWriter.writeSingleFile(fullPath, output)
856
+ createdFiles.push(fullPath)
261
857
  } else {
262
858
  // Multi-file output (one per config)
263
859
  for (const { config, metadata } of configs) {
@@ -266,11 +862,20 @@ async function runSaveMode(
266
862
  metadata.bundler,
267
863
  metadata.environment,
268
864
  metadata.configType,
269
- options.format!
865
+ options.format!,
866
+ metadata.buildName
270
867
  )
271
- fileWriter.writeSingleFile(resolve(targetDir, filename), output)
868
+ const fullPath = resolve(targetDir, filename)
869
+ fileWriter.writeSingleFile(fullPath, output)
870
+ createdFiles.push(fullPath)
272
871
  }
273
872
  }
873
+
874
+ // Log all created files
875
+ console.log(`\n[Config Exporter] Created ${createdFiles.length} file(s):`)
876
+ createdFiles.forEach((file) => {
877
+ console.log(` ✓ ${file}`)
878
+ })
274
879
  }
275
880
 
276
881
  async function runStdoutMode(
@@ -289,17 +894,116 @@ async function runStdoutMode(
289
894
  console.log(output)
290
895
  }
291
896
 
897
+ async function runSingleFileMode(
898
+ options: ExportOptions,
899
+ appRoot: string
900
+ ): Promise<void> {
901
+ const configs = await loadConfigsForEnv(options.env!, options, appRoot)
902
+ const combined = configs.map((c) => c.config)
903
+ const metadata = configs[0].metadata
904
+ metadata.configCount = combined.length
905
+
906
+ const config = combined.length === 1 ? combined[0] : combined
907
+ const output = formatConfig(config, metadata, options, appRoot)
908
+
909
+ const fileWriter = new FileWriter()
910
+ const filePath = resolve(process.cwd(), options.output!)
911
+ fileWriter.writeSingleFile(filePath, output)
912
+ }
913
+
292
914
  async function loadConfigsForEnv(
293
- env: "development" | "production" | "test",
915
+ env: "development" | "production" | "test" | undefined,
294
916
  options: ExportOptions,
295
917
  appRoot: string
296
918
  ): Promise<Array<{ config: any; metadata: ConfigMetadata }>> {
297
- // Auto-detect bundler if not specified
298
- const bundler = options.bundler || (await autoDetectBundler(env, appRoot))
919
+ let bundler: "webpack" | "rspack"
920
+ let buildName: string | undefined
921
+ let buildOutputs: string[] = []
922
+ let customConfigFile: string | undefined
923
+ let bundlerEnvArgs: string[] = []
924
+ let finalEnv: "development" | "production" | "test"
925
+
926
+ // If using config file build
927
+ if (options.build) {
928
+ // Use a temporary env for auto-detection, will be overridden by build config
929
+ const tempEnv = env || "development"
930
+ const loader = new ConfigFileLoader(options.configFile)
931
+ const defaultBundler = await autoDetectBundler(tempEnv, appRoot)
932
+ const resolvedBuild = loader.resolveBuild(
933
+ options.build,
934
+ options,
935
+ defaultBundler
936
+ )
937
+
938
+ bundler = resolvedBuild.bundler
939
+ buildName = resolvedBuild.name
940
+ buildOutputs = resolvedBuild.outputs
941
+ customConfigFile = resolvedBuild.configFile
942
+ bundlerEnvArgs = resolvedBuild.bundlerEnvArgs
943
+
944
+ // Set environment variables from config
945
+ // Security: Only allow specific environment variables to prevent malicious configs
946
+ const DANGEROUS_ENV_VARS = [
947
+ "PATH",
948
+ "HOME",
949
+ "LD_PRELOAD",
950
+ "LD_LIBRARY_PATH",
951
+ "DYLD_LIBRARY_PATH",
952
+ "DYLD_INSERT_LIBRARIES"
953
+ ]
954
+
955
+ for (const [key, value] of Object.entries(resolvedBuild.environment)) {
956
+ if (DANGEROUS_ENV_VARS.includes(key)) {
957
+ console.warn(
958
+ `[Config Exporter] Warning: Skipping dangerous environment variable: ${key}`
959
+ )
960
+ continue
961
+ }
962
+ if (!(BUILD_ENV_VARS as readonly string[]).includes(key)) {
963
+ console.warn(
964
+ `[Config Exporter] Warning: Skipping non-whitelisted environment variable: ${key}. ` +
965
+ `Allowed variables are: ${BUILD_ENV_VARS.join(", ")}`
966
+ )
967
+ continue
968
+ }
969
+ process.env[key] = value
970
+ }
971
+
972
+ // Determine final env: CLI flag > build config NODE_ENV > default
973
+ if (options.env) {
974
+ finalEnv = options.env
975
+ } else if (resolvedBuild.environment.NODE_ENV) {
976
+ const nodeEnv = resolvedBuild.environment.NODE_ENV
977
+ const allowedEnvs = ["development", "production", "test"]
978
+ if (allowedEnvs.includes(nodeEnv)) {
979
+ finalEnv = nodeEnv as "development" | "production" | "test"
980
+ } else {
981
+ throw new Error(
982
+ `Invalid NODE_ENV value in config: "${nodeEnv}". ` +
983
+ `Allowed values are: ${allowedEnvs.join(", ")}.`
984
+ )
985
+ }
986
+ } else {
987
+ finalEnv = "development"
988
+ }
989
+
990
+ // Sync process.env to reflect resolved environment
991
+ process.env.NODE_ENV = finalEnv
992
+ // Determine RAILS_ENV: CLI env option > build config RAILS_ENV > finalEnv
993
+ const railsEnv =
994
+ options.env || resolvedBuild.environment.RAILS_ENV || finalEnv
995
+ process.env.RAILS_ENV = railsEnv
996
+ } else {
997
+ // No build config - use CLI env or default
998
+ finalEnv = env || "development"
299
999
 
300
- // Set environment variables
301
- process.env.NODE_ENV = env
302
- process.env.RAILS_ENV = env
1000
+ // Auto-detect bundler if not specified
1001
+ bundler = options.bundler || (await autoDetectBundler(finalEnv, appRoot))
1002
+
1003
+ // Set environment variables
1004
+ process.env.NODE_ENV = finalEnv
1005
+ process.env.RAILS_ENV = finalEnv
1006
+ }
303
1007
 
304
1008
  if (options.clientOnly) {
305
1009
  process.env.CLIENT_BUNDLE_ONLY = "yes"
@@ -308,12 +1012,16 @@ async function loadConfigsForEnv(
308
1012
  }
309
1013
 
310
1014
  // Find and load config file
311
- const configFile = findConfigFile(bundler, appRoot)
1015
+ const configFile =
1016
+ customConfigFile || findConfigFile(bundler, appRoot, finalEnv)
312
1017
  // Quiet mode for cleaner output - only show if verbose or errors
313
1018
  if (process.env.VERBOSE) {
314
1019
  console.log(`[Config Exporter] Loading config: ${configFile}`)
315
- console.log(`[Config Exporter] Environment: ${env}`)
1020
+ console.log(`[Config Exporter] Environment: ${finalEnv}`)
316
1021
  console.log(`[Config Exporter] Bundler: ${bundler}`)
1022
+ if (buildName) {
1023
+ console.log(`[Config Exporter] Build: ${buildName}`)
1024
+ }
317
1025
  }
318
1026
 
319
1027
  // Load the config
@@ -331,8 +1039,26 @@ async function loadConfigsForEnv(
331
1039
  }
332
1040
 
333
1041
  // 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!
1042
+ /**
1043
+ * AGGRESSIVE REQUIRE CACHE CLEARING
1044
+ *
1045
+ * Why: This tool can load multiple environments (dev/prod) and builds in a
1046
+ * single process. Node's require cache prevents modules from re-evaluating,
1047
+ * which causes stale environment values (NODE_ENV, etc.) to persist.
1048
+ *
1049
+ * What: Clears cache for:
1050
+ * - Webpack/rspack config files (they read process.env)
1051
+ * - Shakapacker modules (env detection, config loading)
1052
+ * - Config directory files (custom helpers that may read env)
1053
+ *
1054
+ * Trade-offs:
1055
+ * - More reliable: Ensures each build gets fresh environment
1056
+ * - Potentially brittle: String matching on paths (but comprehensive)
1057
+ * - Performance: Minimal impact since this runs per-build, not per-file
1058
+ *
1059
+ * Maintenance: If adding new shakapacker modules that read env vars,
1060
+ * ensure their paths are covered by the patterns below.
1061
+ */
336
1062
  const configDir = dirname(configFile)
337
1063
  Object.keys(require.cache).forEach((key) => {
338
1064
  if (
@@ -359,6 +1085,40 @@ async function loadConfigsForEnv(
359
1085
  loadedConfig = loadedConfig.default
360
1086
  }
361
1087
 
1088
+ // Handle function exports (webpack config functions)
1089
+ if (typeof loadedConfig === "function") {
1090
+ // Webpack config functions receive (env, argv) parameters
1091
+ // Build env object from bundler_env args if available
1092
+ const envObject: Record<string, any> = {}
1093
+ if (bundlerEnvArgs && bundlerEnvArgs.length > 0) {
1094
+ // Parse --env key=value or --env key into object
1095
+ for (let i = 0; i < bundlerEnvArgs.length; i += 2) {
1096
+ if (bundlerEnvArgs[i] === "--env") {
1097
+ const envArg = bundlerEnvArgs[i + 1]
1098
+ if (envArg.includes("=")) {
1099
+ const [key, value] = envArg.split("=")
1100
+ envObject[key] = value
1101
+ } else {
1102
+ envObject[envArg] = true
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ const argv = { mode: finalEnv }
1109
+ try {
1110
+ loadedConfig = loadedConfig(envObject, argv)
1111
+ } catch (error: unknown) {
1112
+ const errorMessage =
1113
+ error instanceof Error ? error.message : String(error)
1114
+ throw new Error(
1115
+ `Failed to execute config function: ${errorMessage}\n` +
1116
+ `Config file: ${configFile}\n` +
1117
+ `Environment: ${JSON.stringify(envObject)}`
1118
+ )
1119
+ }
1120
+ }
1121
+
362
1122
  // Determine config type and split if array
363
1123
  const configs: any[] = Array.isArray(loadedConfig)
364
1124
  ? loadedConfig
@@ -368,8 +1128,27 @@ async function loadConfigsForEnv(
368
1128
  configs.forEach((cfg, index) => {
369
1129
  let configType: "client" | "server" | "all" = "all"
370
1130
 
371
- // Try to infer config type from the config itself
372
- if (configs.length === 2) {
1131
+ // Use outputs from build config if available
1132
+ if (
1133
+ buildOutputs.length > 0 &&
1134
+ index < buildOutputs.length &&
1135
+ buildOutputs[index]
1136
+ ) {
1137
+ const outputValue = buildOutputs[index]
1138
+ // Validate the output value is a valid config type
1139
+ if (
1140
+ outputValue === "client" ||
1141
+ outputValue === "server" ||
1142
+ outputValue === "all"
1143
+ ) {
1144
+ configType = outputValue
1145
+ } else {
1146
+ throw new Error(
1147
+ `Invalid output type '${outputValue}' at index ${index} in build '${buildName}'. ` +
1148
+ `Allowed values are: client, server, all`
1149
+ )
1150
+ }
1151
+ } else if (configs.length === 2) {
373
1152
  // Likely client and server configs
374
1153
  configType = index === 0 ? "client" : "server"
375
1154
  } else if (options.clientOnly) {
@@ -381,15 +1160,17 @@ async function loadConfigsForEnv(
381
1160
  const metadata: ConfigMetadata = {
382
1161
  exportedAt: new Date().toISOString(),
383
1162
  bundler,
384
- environment: env,
1163
+ environment: finalEnv,
385
1164
  configFile,
386
1165
  configType,
387
1166
  configCount: configs.length,
1167
+ buildName,
388
1168
  environmentVariables: {
389
1169
  NODE_ENV: process.env.NODE_ENV,
390
1170
  RAILS_ENV: process.env.RAILS_ENV,
391
1171
  CLIENT_BUNDLE_ONLY: process.env.CLIENT_BUNDLE_ONLY,
392
- SERVER_BUNDLE_ONLY: process.env.SERVER_BUNDLE_ONLY
1172
+ SERVER_BUNDLE_ONLY: process.env.SERVER_BUNDLE_ONLY,
1173
+ WEBPACK_SERVE: process.env.WEBPACK_SERVE
393
1174
  }
394
1175
  }
395
1176
 
@@ -526,46 +1307,86 @@ function cleanConfig(obj: any, rootPath: string): any {
526
1307
  return clean(obj)
527
1308
  }
528
1309
 
529
- async function autoDetectBundler(
1310
+ /**
1311
+ * Loads and returns shakapacker.yml configuration
1312
+ */
1313
+ function loadShakapackerConfig(
530
1314
  env: string,
531
1315
  appRoot: string
532
- ): Promise<"webpack" | "rspack"> {
1316
+ ): { bundler: "webpack" | "rspack"; configPath: string } {
533
1317
  try {
534
- const configPath =
1318
+ const configFilePath =
535
1319
  process.env.SHAKAPACKER_CONFIG ||
536
1320
  resolve(appRoot, "config/shakapacker.yml")
537
1321
 
538
- if (existsSync(configPath)) {
539
- const config: any = loadYaml(readFileSync(configPath, "utf8"))
1322
+ if (existsSync(configFilePath)) {
1323
+ const config: any = loadYaml(readFileSync(configFilePath, "utf8"))
540
1324
  const envConfig = config[env] || config.default || {}
1325
+
1326
+ // Get bundler
541
1327
  const bundler = envConfig.assets_bundler || "webpack"
542
1328
  if (bundler !== "webpack" && bundler !== "rspack") {
543
1329
  console.warn(
544
1330
  `[Config Exporter] Invalid bundler '${bundler}' in shakapacker.yml, defaulting to webpack`
545
1331
  )
546
- return "webpack"
1332
+ return {
1333
+ bundler: "webpack",
1334
+ configPath: bundler === "rspack" ? "config/rspack" : "config/webpack"
1335
+ }
547
1336
  }
548
- console.log(`[Config Exporter] Auto-detected bundler: ${bundler}`)
549
- return bundler
1337
+
1338
+ // Get config path
1339
+ const customConfigPath = envConfig.assets_bundler_config_path
1340
+ const configPath =
1341
+ customConfigPath ||
1342
+ (bundler === "rspack" ? "config/rspack" : "config/webpack")
1343
+
1344
+ console.log(
1345
+ `[Config Exporter] Auto-detected bundler: ${bundler}, config path: ${configPath}`
1346
+ )
1347
+ return { bundler, configPath }
550
1348
  }
551
- } catch (error: any) {
1349
+ } catch (error: unknown) {
552
1350
  console.warn(
553
- `[Config Exporter] Error detecting bundler, defaulting to webpack`
1351
+ `[Config Exporter] Error loading shakapacker config, defaulting to webpack`
554
1352
  )
555
1353
  }
556
1354
 
557
- return "webpack"
1355
+ return { bundler: "webpack", configPath: "config/webpack" }
1356
+ }
1357
+
1358
+ /**
1359
+ * Auto-detects bundler from shakapacker.yml
1360
+ *
1361
+ * Error Handling Strategy:
1362
+ * - Invalid bundler → warns and defaults to webpack (graceful fallback)
1363
+ * - Config read errors → warns and defaults to webpack (graceful fallback)
1364
+ *
1365
+ * Rationale for warnings vs errors:
1366
+ * - This reads shakapacker.yml (infrastructure config), not user build config
1367
+ * - Failures here should not block the tool; defaulting to webpack is safe
1368
+ * - Contrast with NODE_ENV validation in build configs, which throws errors
1369
+ * because invalid NODE_ENV would produce incorrect builds
1370
+ */
1371
+ async function autoDetectBundler(
1372
+ env: string,
1373
+ appRoot: string
1374
+ ): Promise<"webpack" | "rspack"> {
1375
+ const { bundler } = loadShakapackerConfig(env, appRoot)
1376
+ return bundler
558
1377
  }
559
1378
 
560
1379
  function findConfigFile(
561
1380
  bundler: "webpack" | "rspack",
562
- appRoot: string
1381
+ appRoot: string,
1382
+ env: string
563
1383
  ): string {
1384
+ const { configPath } = loadShakapackerConfig(env, appRoot)
564
1385
  const extensions = ["ts", "js"]
565
1386
 
566
1387
  if (bundler === "rspack") {
567
1388
  for (const ext of extensions) {
568
- const rspackPath = resolve(appRoot, `config/rspack/rspack.config.${ext}`)
1389
+ const rspackPath = resolve(appRoot, configPath, `rspack.config.${ext}`)
569
1390
  if (existsSync(rspackPath)) {
570
1391
  return rspackPath
571
1392
  }
@@ -574,14 +1395,14 @@ function findConfigFile(
574
1395
 
575
1396
  // Fall back to webpack config
576
1397
  for (const ext of extensions) {
577
- const webpackPath = resolve(appRoot, `config/webpack/webpack.config.${ext}`)
1398
+ const webpackPath = resolve(appRoot, configPath, `webpack.config.${ext}`)
578
1399
  if (existsSync(webpackPath)) {
579
1400
  return webpackPath
580
1401
  }
581
1402
  }
582
1403
 
583
1404
  throw new Error(
584
- `Could not find ${bundler} config file. Expected: config/${bundler}/${bundler}.config.{js,ts}`
1405
+ `Could not find ${bundler} config file. Expected: ${configPath}/${bundler}.config.{js,ts}`
585
1406
  )
586
1407
  }
587
1408
 
@@ -622,62 +1443,3 @@ function setupNodePath(appRoot: string): void {
622
1443
  require("module").Module._initPaths()
623
1444
  }
624
1445
  }
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
- }