-
-
Notifications
You must be signed in to change notification settings - Fork 108
Expand file tree
/
Copy pathcli.ts
More file actions
1781 lines (1580 loc) · 56.8 KB
/
Copy pathcli.ts
File metadata and controls
1781 lines (1580 loc) · 56.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// This will be a substantial file - the main CLI entry point
// Originally migrated from bin/export-bundler-config, now bin/shakapacker-config
import { existsSync, readFileSync, writeFileSync } from "fs"
import { resolve, dirname, sep, delimiter, basename } from "path"
import { inspect } from "util"
import { load as loadYaml } from "js-yaml"
import yargs from "yargs"
import {
ExportOptions,
ConfigMetadata,
FileOutput,
BUILD_ENV_VARS,
isBuildEnvVar,
isDangerousEnvVar,
DEFAULT_EXPORT_DIR,
DEFAULT_CONFIG_FILE
} from "./types"
import { YamlSerializer } from "./yamlSerializer"
import { FileWriter } from "./fileWriter"
import { ConfigFileLoader, generateSampleConfigFile } from "./configFile"
import { BuildValidator } from "./buildValidator"
import { safeResolvePath } from "../utils/pathValidation"
// Read version from package.json
let VERSION = "unknown"
try {
const packageJson = JSON.parse(
readFileSync(resolve(__dirname, "../../package.json"), "utf8")
) as { version?: string }
VERSION = packageJson.version || "unknown"
} catch (error) {
console.warn(
"Could not read version from package.json:",
error instanceof Error ? error.message : String(error)
)
}
/**
* Saves current values of build environment variables for later restoration
* @returns Object mapping variable names to their current values (or undefined)
*/
function saveBuildEnvironmentVariables(): Record<string, string | undefined> {
const saved: Record<string, string | undefined> = {}
BUILD_ENV_VARS.forEach((varName) => {
saved[varName] = process.env[varName]
})
return saved
}
/**
* Restores previously saved environment variable values
* @param saved - Object mapping variable names to their original values
*/
function restoreBuildEnvironmentVariables(
saved: Record<string, string | undefined>
): void {
BUILD_ENV_VARS.forEach((varName) => {
const originalValue = saved[varName]
if (originalValue === undefined) {
delete process.env[varName]
} else {
process.env[varName] = originalValue
}
})
}
/**
* Clears all whitelisted build environment variables from process.env
* to prevent environment variable leakage between builds
*/
function clearBuildEnvironmentVariables(): void {
BUILD_ENV_VARS.forEach((varName) => {
delete process.env[varName]
})
}
// Main CLI entry point
export async function run(args: string[]): Promise<number> {
try {
const options = parseArguments(args)
// Handle --init command
if (options.init) {
return runInitCommand(options)
}
// Handle --list-builds command
if (options.listBuilds) {
return runListBuildsCommand(options)
}
// Handle --validate or --validate-build command
if (options.validate || options.validateBuild) {
return await runValidateCommand(options)
}
// Handle --all-builds command
if (options.allBuilds) {
return runAllBuildsCommand(options)
}
// Set up environment
const appRoot = findAppRoot()
process.chdir(appRoot)
setupNodePath(appRoot)
// Apply defaults
const resolvedOptions = applyDefaults(options)
// Validate paths for security AFTER defaults are applied
// Use safeResolvePath which validates and resolves symlinks
if (resolvedOptions.output) {
safeResolvePath(appRoot, resolvedOptions.output)
}
if (resolvedOptions.saveDir) {
safeResolvePath(appRoot, resolvedOptions.saveDir)
}
// Validate after defaults are applied
if (resolvedOptions.annotate && resolvedOptions.format !== "yaml") {
throw new Error(
"Annotation requires YAML format. Use --no-annotate or --format=yaml."
)
}
// Validate --build requires config file
if (resolvedOptions.build) {
const loader = new ConfigFileLoader(resolvedOptions.configFile)
if (!loader.exists()) {
const configPath = resolvedOptions.configFile || DEFAULT_CONFIG_FILE
throw new Error(
`--build requires a config file but ${configPath} not found. Run --init to create it.`
)
}
}
// Execute based on mode
if (resolvedOptions.doctor) {
await runDoctorMode(resolvedOptions, appRoot)
} else if (resolvedOptions.stdout) {
// Explicit stdout mode
await runStdoutMode(resolvedOptions, appRoot)
} else if (resolvedOptions.output) {
// Save to single file
await runSingleFileMode(resolvedOptions, appRoot)
} else {
// Default: save to directory
await runSaveMode(resolvedOptions, appRoot)
}
return 0
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[Config Exporter] Error: ${errorMessage}`)
return 1
}
}
export function parseArguments(args: string[]): ExportOptions {
const argv = yargs(args)
.version(VERSION)
.usage(
`Shakapacker Config Exporter
Exports webpack or rspack configuration in a verbose, human-readable format
for comparison and analysis.
QUICK START (for troubleshooting):
bin/shakapacker-config --doctor
Exports annotated YAML configs for both development and production.
Creates separate files for client and server bundles.
Best for debugging, AI analysis, and comparing configurations.`
)
// Build Configuration Options (most important - users interact with these most)
.option("init", {
type: "boolean",
default: false,
description: `Generate ${DEFAULT_CONFIG_FILE} (use with --ssr for SSR builds)`
})
.option("ssr", {
type: "boolean",
default: false,
description: "Include SSR builds when using --init"
})
.option("list-builds", {
type: "boolean",
default: false,
description: "List all available builds from config file"
})
.option("build", {
type: "string",
description: "Export config for specific build from config file"
})
.option("all-builds", {
type: "boolean",
default: false,
description: "Export all builds from config file"
})
.option("config-file", {
type: "string",
description: `Path to config file (default: ${DEFAULT_CONFIG_FILE})`
})
// Validation Options
.option("validate", {
type: "boolean",
default: false,
description:
"Validate all builds by running webpack/rspack (requires config file)"
})
.option("validate-build", {
type: "string",
description: "Validate specific build from config file"
})
// Troubleshooting
.option("doctor", {
type: "boolean",
default: false,
description:
"Export all configs for troubleshooting (uses config file builds if available)"
})
// Output Options
.option("save-dir", {
type: "string",
description:
"Directory for output files (default: shakapacker-config-exports)"
})
.option("output", {
type: "string",
description: "Output to specific file instead of directory"
})
.option("stdout", {
type: "boolean",
default: false,
description: "Output to stdout instead of saving to files"
})
.option("format", {
type: "string",
choices: ["yaml", "json", "inspect"] as const,
description: "Output format (default: yaml for files, inspect for stdout)"
})
.option("annotate", {
type: "boolean",
description:
"Enable inline documentation (YAML only, default with --doctor or file output)"
})
.option("depth", {
// Note: type omitted to allow string "null" (yargs would reject it).
// Coerce function handles validation for both numbers and "null".
default: 20,
coerce: (value: number | string) => {
// Handle "null" string for unlimited depth
if (value === "null" || value === null) return null
// Reject non-numeric types (arrays, objects, etc.)
if (typeof value !== "number" && typeof value !== "string") {
throw new Error(
`--depth must be a number or 'null', got: ${typeof value}`
)
}
const parsed =
typeof value === "number" ? value : parseInt(String(value), 10)
if (Number.isNaN(parsed)) {
throw new Error(`--depth must be a number or 'null', got: ${value}`)
}
return parsed
},
description: "Inspection depth (use 'null' for unlimited)"
})
.option("verbose", {
type: "boolean",
default: false,
description: "Show full output without compact mode"
})
// Bundler Options
.option("bundler", {
type: "string",
choices: ["webpack", "rspack"] as const,
description: "Specify bundler (auto-detected if not provided)"
})
.option("webpack", {
type: "boolean",
default: false,
description: "Use webpack (overrides config file)"
})
.option("rspack", {
type: "boolean",
default: false,
description: "Use rspack (overrides config file)"
})
// Legacy/Fallback Options (when no config file exists)
.option("env", {
type: "string",
choices: ["development", "production", "test"] as const,
description:
"Node environment (fallback when no config file exists, ignored with --doctor or --build)"
})
.option("client-only", {
type: "boolean",
default: false,
description:
"Generate only client config (fallback when no config file exists)"
})
.option("server-only", {
type: "boolean",
default: false,
description:
"Generate only server config (fallback when no config file exists)"
})
.check((parsedArgs) => {
if (parsedArgs.webpack && parsedArgs.rspack) {
throw new Error(
"--webpack and --rspack are mutually exclusive. Please specify only one."
)
}
if (parsedArgs["client-only"] && parsedArgs["server-only"]) {
throw new Error(
"--client-only and --server-only are mutually exclusive. Please specify only one."
)
}
if (parsedArgs.output && parsedArgs["save-dir"]) {
throw new Error(
"--output and --save-dir are mutually exclusive. Use one or the other."
)
}
if (parsedArgs.stdout && parsedArgs["save-dir"]) {
throw new Error(
"--stdout and --save-dir are mutually exclusive. Use one or the other."
)
}
if (parsedArgs.build && parsedArgs["all-builds"]) {
throw new Error(
"--build and --all-builds are mutually exclusive. Use one or the other."
)
}
if (parsedArgs.validate && parsedArgs["validate-build"]) {
throw new Error(
"--validate and --validate-build are mutually exclusive. Use one or the other."
)
}
if (
parsedArgs.validate &&
(parsedArgs.build || parsedArgs["all-builds"])
) {
throw new Error(
"--validate cannot be used with --build or --all-builds."
)
}
if (parsedArgs["all-builds"] && parsedArgs.output) {
throw new Error(
"--all-builds and --output are mutually exclusive. Use --save-dir instead."
)
}
if (parsedArgs["all-builds"] && parsedArgs.stdout) {
throw new Error(
"--all-builds and --stdout are mutually exclusive. Use --save-dir instead."
)
}
if (parsedArgs.stdout && parsedArgs.output) {
throw new Error(
"--stdout and --output are mutually exclusive. Use one or the other."
)
}
if (parsedArgs.ssr && !parsedArgs.init) {
throw new Error(
"--ssr can only be used with --init. Use: bin/shakapacker-config --init --ssr"
)
}
return true
})
.help("help")
.alias("help", "h")
.epilogue(
`Examples:
# Config File Workflow (recommended)
bin/shakapacker-config --init # Create config file
bin/shakapacker-config --init --ssr # Create config with SSR builds
bin/shakapacker-config --list-builds # List available builds
bin/shakapacker-config --build=dev # Export specific build
bin/shakapacker-config --all-builds --save-dir=./configs
bin/shakapacker-config --build=dev --rspack # Override bundler
# Troubleshooting
bin/shakapacker-config --doctor # Export all configs for debugging
# If config file exists: exports all builds from config
# If no config file: exports dev/prod client/server configs
# Validate builds (requires config file)
bin/shakapacker-config --validate # Validate all builds
bin/shakapacker-config --validate-build=dev # Validate specific build
bin/shakapacker-config --validate --verbose # Validate with full logs
# Advanced output options
bin/shakapacker-config --build=dev --stdout # View in terminal
bin/shakapacker-config --build=dev --output=config.yml # Save to specific file`
)
.strict()
.parseSync()
// Type assertions are safe here because yargs validates choices at runtime
// Handle --webpack and --rspack flags
let { bundler } = argv
if (argv.webpack) bundler = "webpack"
if (argv.rspack) bundler = "rspack"
return {
bundler,
env: argv.env,
clientOnly: argv["client-only"],
serverOnly: argv["server-only"],
output: argv.output,
depth: argv.depth,
format: argv.format,
help: false, // yargs handles help internally
verbose: argv.verbose,
doctor: argv.doctor,
saveDir: argv["save-dir"],
stdout: argv.stdout,
annotate: argv.annotate,
init: argv.init,
ssr: argv.ssr,
configFile: argv["config-file"],
build: argv.build,
listBuilds: argv["list-builds"],
allBuilds: argv["all-builds"],
validate: argv.validate,
validateBuild: argv["validate-build"]
}
}
function applyDefaults(options: ExportOptions): ExportOptions {
const updatedOptions = { ...options }
if (updatedOptions.doctor) {
if (updatedOptions.format === undefined) updatedOptions.format = "yaml"
if (updatedOptions.annotate === undefined) updatedOptions.annotate = true
} else if (!updatedOptions.stdout && !updatedOptions.output) {
// Default mode: save to directory
if (updatedOptions.format === undefined) updatedOptions.format = "yaml"
if (updatedOptions.annotate === undefined) updatedOptions.annotate = true
} else {
if (updatedOptions.format === undefined) updatedOptions.format = "inspect"
if (updatedOptions.annotate === undefined) updatedOptions.annotate = false
}
// Set default save directory for file output modes
if (
!updatedOptions.stdout &&
!updatedOptions.output &&
!updatedOptions.saveDir
) {
updatedOptions.saveDir = resolve(process.cwd(), DEFAULT_EXPORT_DIR)
}
return updatedOptions
}
function runInitCommand(options: ExportOptions): number {
const configPath = options.configFile || DEFAULT_CONFIG_FILE
const fullPath = resolve(process.cwd(), configPath)
// Check if SSR variant is requested via --ssr flag
const ssrMode = options.ssr || false
if (existsSync(fullPath)) {
console.error(
`[Config Exporter] Error: Config file already exists: ${fullPath}`
)
console.error(
`Remove it first or use --config-file=<path> for a different location.`
)
return 1
}
// Create bin stub if it doesn't exist
const binStubPath = resolve(process.cwd(), "bin/shakapacker-config")
const createdStub = !existsSync(binStubPath)
if (createdStub) {
createBinStub(binStubPath)
}
const sampleConfig = generateSampleConfigFile(ssrMode)
writeFileSync(fullPath, sampleConfig, "utf8")
console.log(`[Config Exporter] ✅ Created config file: ${fullPath}`)
if (ssrMode) {
console.log(
`[Config Exporter] ℹ️ Generated SSR build configuration (5 builds)`
)
} else {
console.log(
`[Config Exporter] ℹ️ Generated standard build configuration (3 builds)`
)
console.log(
`[Config Exporter] 💡 Uncomment SSR builds in the file if needed, or regenerate with: bin/shakapacker-config --init --ssr`
)
}
if (createdStub) {
console.log(`[Config Exporter] ✅ Created bin stub: ${binStubPath}`)
}
console.log(`\nNext steps:`)
console.log(` 1. List available builds: bin/shakapacker --list-builds`)
console.log(` 2. Run a build: bin/shakapacker --build <name>\n`)
return 0
}
function createBinStub(binStubPath: string): void {
const binDir = dirname(binStubPath)
const packageScript = `${basename(binStubPath)}.cjs`
const { mkdirSync, chmodSync } = require("fs")
// Ensure bin directory exists
if (!existsSync(binDir)) {
mkdirSync(binDir, { recursive: true })
}
const stubContent = `#!/usr/bin/env ruby
# frozen_string_literal: true
# Keep in sync with lib/install/bin/shakapacker-config and
# lib/install/bin/diff-bundler-config; update all three when changing helpers.
def shakapacker_app_root
candidate = File.expand_path("..", __dir__)
return candidate if File.exist?(File.join(candidate, "Gemfile"))
warn "[Shakapacker] No Gemfile found at #{candidate.inspect}; " \\
"falling back to the current directory (#{Dir.pwd.inspect})."
Dir.pwd
end
def shakapacker_node_binary
node_bin = "node"
return node_bin if system(node_bin, "--version", out: File::NULL, err: File::NULL)
warn "[Shakapacker] Could not find Node.js executable #{node_bin.inspect}. " \\
"Install Node.js and try again."
exit 1
end
ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"
app_root = shakapacker_app_root
node_bin = shakapacker_node_binary
script_path = File.join(
app_root,
"node_modules",
"shakapacker",
"package",
"bin",
"${packageScript}"
)
unless File.file?(script_path)
warn "[Shakapacker] Could not find #{script_path}. Run your package manager install command and try again."
exit 1
end
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
end
`
writeFileSync(binStubPath, stubContent, { mode: 0o755 })
// writeFileSync's mode is filtered by the process umask (e.g. umask 077
// strips the execute bit). chmodSync ensures the file is actually 0o755
// regardless of umask. It can throw on filesystems that don't support
// permission bits (Windows/FAT), so the try/catch is intentional.
try {
chmodSync(binStubPath, 0o755)
} catch (_e) {
// ignore - file was created with executable mode on supporting filesystems
}
}
function runListBuildsCommand(options: ExportOptions): number {
try {
const loader = new ConfigFileLoader(options.configFile)
loader.listBuilds()
return 0
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[Config Exporter] Error: ${errorMessage}`)
return 1
}
}
async function runValidateCommand(options: ExportOptions): Promise<number> {
const savedEnv = saveBuildEnvironmentVariables()
try {
// Validate that config file exists
const loader = new ConfigFileLoader(options.configFile)
if (!loader.exists()) {
const configPath = options.configFile || DEFAULT_CONFIG_FILE
throw new Error(
`Config file ${configPath} not found. Run --init to create it.`
)
}
// Set up environment
const appRoot = findAppRoot()
process.chdir(appRoot)
setupNodePath(appRoot)
const config = loader.load()
const validator = new BuildValidator({ verbose: options.verbose || false })
// Determine which builds to validate
let buildsToValidate: string[]
if (options.validateBuild) {
// Validate specific build
if (!config.builds[options.validateBuild]) {
const available = Object.keys(config.builds).join(", ")
throw new Error(
`Build '${options.validateBuild}' not found in config file.\n` +
`Available builds: ${available}`
)
}
buildsToValidate = [options.validateBuild]
} else {
// Validate all builds
buildsToValidate = Object.keys(config.builds)
// Handle empty builds edge case
if (buildsToValidate.length === 0) {
throw new Error(
`No builds found in config file. Add at least one build to ${DEFAULT_CONFIG_FILE} or run --init to see examples.`
)
}
}
console.log(`\n${"=".repeat(80)}`)
console.log("🔍 Validating Builds")
console.log("=".repeat(80))
console.log(`\nValidating ${buildsToValidate.length} build(s)...\n`)
if (options.verbose) {
console.log("⚡ VERBOSE MODE ENABLED - Full build output will be shown")
console.log(
" This includes all webpack/rspack compilation logs, warnings, and progress messages"
)
console.log(" Use without --verbose to see only errors and summaries\n")
console.log(`${"=".repeat(80)}\n`)
}
const results = []
// Validate each build
for (const buildName of buildsToValidate) {
if (options.verbose) {
console.log(`\n${"=".repeat(80)}`)
console.log(`📦 VALIDATING BUILD: ${buildName}`)
console.log("=".repeat(80))
} else {
console.log(`\n📦 Validating build: ${buildName}`)
}
// Clear and restore environment to prevent leakage between builds
clearBuildEnvironmentVariables()
restoreBuildEnvironmentVariables(savedEnv)
// Clear shakapacker config cache between builds
shakapackerConfigCache = null
// Get the build's environment to use for auto-detection
const buildConfig = config.builds[buildName]
const buildEnv =
buildConfig.environment?.NODE_ENV ||
(buildConfig.environment?.RAILS_ENV as
| "development"
| "production"
| "test"
| undefined) ||
"development"
// Auto-detect bundler using the build's environment
// eslint-disable-next-line no-await-in-loop -- Sequential execution required: each build modifies shared global state (env vars, config cache) that must be cleared/restored between iterations
const defaultBundler = await autoDetectBundler(
buildEnv,
appRoot,
options.verbose
)
// Resolve build config with the correct default bundler
const resolvedBuild = loader.resolveBuild(
buildName,
options,
defaultBundler
)
// Validate the build
// eslint-disable-next-line no-await-in-loop -- Sequential execution required: each build modifies shared global state (env vars, config cache) that must be cleared/restored between iterations
const result = await validator.validateBuild(resolvedBuild, appRoot)
results.push(result)
// Show immediate feedback
if (options.verbose) {
console.log("=".repeat(80))
}
if (result.success) {
console.log(` ✅ Build passed`)
} else {
console.log(` ❌ Build failed with ${result.errors.length} error(s)`)
}
if (options.verbose) {
console.log("")
}
}
// Print formatted results
const formattedResults = validator.formatResults(results)
console.log(formattedResults)
// Return exit code based on results
const hasFailures = results.some((r) => !r.success)
return hasFailures ? 1 : 0
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[Config Exporter] Error: ${errorMessage}`)
return 1
} finally {
// Restore original environment
restoreBuildEnvironmentVariables(savedEnv)
}
}
async function runAllBuildsCommand(options: ExportOptions): Promise<number> {
// Save original environment to restore after all builds
const savedEnv = saveBuildEnvironmentVariables()
try {
// Set up environment
const appRoot = findAppRoot()
process.chdir(appRoot)
setupNodePath(appRoot)
// Apply defaults
const resolvedOptions = applyDefaults(options)
// Validate paths for security in all-builds mode.
// saveDir is always set by applyDefaults(); --output is not used in --all-builds mode.
safeResolvePath(appRoot, resolvedOptions.saveDir!)
// Keep in sync with validation in run()
if (resolvedOptions.annotate && resolvedOptions.format !== "yaml") {
throw new Error(
"Annotation requires YAML format. Use --no-annotate or --format=yaml."
)
}
const loader = new ConfigFileLoader(resolvedOptions.configFile)
if (!loader.exists()) {
const configPath = resolvedOptions.configFile || DEFAULT_CONFIG_FILE
throw new Error(
`Config file ${configPath} not found. Run --init to create it.`
)
}
const config = loader.load()
const buildNames = Object.keys(config.builds)
console.log(
`\n📦 Exporting ${buildNames.length} builds from config file...\n`
)
const targetDir = resolvedOptions.saveDir! // Set by applyDefaults
const createdFiles: string[] = []
// Export each build
for (const buildName of buildNames) {
console.log(`\n📦 Exporting build: ${buildName}`)
// Clear and restore environment to prevent leakage between builds
clearBuildEnvironmentVariables()
restoreBuildEnvironmentVariables(savedEnv)
// Clear shakapacker config cache between builds
shakapackerConfigCache = null
// Create a modified options object for this build
const buildOptions = { ...resolvedOptions, build: buildName }
// eslint-disable-next-line no-await-in-loop -- Sequential execution required: each build modifies shared global state (env vars, config cache) that must be cleared/restored between iterations
const configs = await loadConfigsForEnv(undefined, buildOptions, appRoot)
for (const { config: cfg, metadata } of configs) {
const output = formatConfig(cfg, metadata, resolvedOptions, appRoot)
const filename = FileWriter.generateFilename(
metadata.bundler,
metadata.environment,
metadata.configType,
resolvedOptions.format!,
metadata.buildName
)
const fullPath = resolve(targetDir, filename)
FileWriter.writeSingleFile(fullPath, output)
createdFiles.push(fullPath)
}
}
// Print summary
console.log(`\n${"=".repeat(80)}`)
console.log("✅ All Builds Exported!")
console.log("=".repeat(80))
console.log(`\nCreated ${createdFiles.length} configuration file(s) in:`)
console.log(` ${targetDir}\n`)
console.log("Files:")
createdFiles.forEach((file) => {
console.log(` ✓ ${basename(file)}`)
})
console.log(`\n${"=".repeat(80)}\n`)
return 0
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[Config Exporter] Error: ${errorMessage}`)
return 1
} finally {
// Restore original environment
restoreBuildEnvironmentVariables(savedEnv)
}
}
async function runDoctorMode(
options: ExportOptions,
appRoot: string
): Promise<void> {
// Save original environment to restore after all builds
const savedEnv = saveBuildEnvironmentVariables()
try {
console.log(`\n${"=".repeat(80)}`)
console.log("🔍 Config Exporter - Doctor Mode")
console.log("=".repeat(80))
const targetDir = options.saveDir! // Set by applyDefaults
const createdFiles: string[] = []
// Check if config file exists - always use it for doctor mode
const configFilePath = options.configFile || DEFAULT_CONFIG_FILE
const loader = new ConfigFileLoader(configFilePath)
if (loader.exists()) {
try {
const configData = loader.load()
console.log(`\nUsing builds from ${configFilePath}...\n`)
// Use config file builds
const buildNames = Object.keys(configData.builds)
for (const buildName of buildNames) {
console.log(`\n📦 Loading build: ${buildName}`)
// Clear and restore environment to prevent leakage between builds
clearBuildEnvironmentVariables()
restoreBuildEnvironmentVariables(savedEnv)
// Clear shakapacker config cache between builds
shakapackerConfigCache = null
// eslint-disable-next-line no-await-in-loop -- Sequential execution required: each build modifies shared global state (env vars, config cache) that must be cleared/restored between iterations
const configs = await loadConfigsForEnv(
undefined,
{ ...options, build: buildName },
appRoot
)
for (const { config, metadata } of configs) {
const output = formatConfig(config, metadata, options, appRoot)
const filename = FileWriter.generateFilename(
metadata.bundler,
metadata.environment,
metadata.configType,
options.format!,
metadata.buildName
)
const fullPath = resolve(targetDir, filename)
FileWriter.writeSingleFile(fullPath, output)
createdFiles.push(fullPath)
}
}
// Print summary and exit early
printDoctorSummary(createdFiles, targetDir)
return
} catch (error: unknown) {
// If config file exists but is invalid, show error and exit
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error(`\n❌ Error loading build configuration:`)
console.error(`\n${errorMessage}`)
console.error(
`\n💡 To fix this issue, check your build config in ${configFilePath}`
)
console.error(
` or run: bin/shakapacker-config --init to regenerate it.\n`
)
throw error
}
}
// No config file found - suggest creating one
console.log(`\n⚠️ No build config file found at ${configFilePath}`)
console.log(`Run: bin/shakapacker-config --init to create one.\n`)
console.log("Exporting default development and production configs...")
console.log("")
const configsToExport = [
{ label: "development (HMR)", env: "development" as const, hmr: true },
{ label: "development", env: "development" as const, hmr: false },
{ label: "production", env: "production" as const, hmr: false }
]
for (const { label, env, hmr } of configsToExport) {
console.log(`\n📦 Loading ${label} configuration...`)
// Clear and restore environment to prevent leakage between builds
clearBuildEnvironmentVariables()
restoreBuildEnvironmentVariables(savedEnv)
// Clear shakapacker config cache between builds
shakapackerConfigCache = null
// Set WEBPACK_SERVE for HMR config
if (hmr) {
process.env.WEBPACK_SERVE = "true"
}
// eslint-disable-next-line no-await-in-loop -- Sequential execution required: each config modifies shared global state (env vars, config cache) that must be cleared/restored between iterations
const configs = await loadConfigsForEnv(env, options, appRoot)
for (const { config, metadata } of configs) {
const output = formatConfig(config, metadata, options, appRoot)
// Adjust filename for HMR config
let filename: string
if (
hmr &&
(metadata.configType === "client" || metadata.configType === "all")
) {
/**
* HMR Mode Filename Logic:
* - When WEBPACK_SERVE=true, webpack-dev-server runs and HMR is enabled
* - HMR only applies to client bundles (server bundles don't use HMR)
* - If configType is "all", we still only generate client file for HMR
* because the server bundle is identical to non-HMR development
* - Filename uses "client" type and "development-hmr" build name to
* distinguish it from regular development client bundle
*/
filename = FileWriter.generateFilename(
metadata.bundler,
metadata.environment,
"client",
options.format!,
"development-hmr"
)
} else {
filename = FileWriter.generateFilename(
metadata.bundler,
metadata.environment,
metadata.configType,
options.format!,
metadata.buildName
)
}
const fullPath = resolve(targetDir, filename)
FileWriter.writeSingleFile(fullPath, output)
createdFiles.push(fullPath)
}
}
printDoctorSummary(createdFiles, targetDir)
} finally {
// Restore original environment
restoreBuildEnvironmentVariables(savedEnv)
}
}
function printDoctorSummary(createdFiles: string[], targetDir: string): void {
// Print summary
console.log(`\n${"=".repeat(80)}`)
console.log("✅ Export Complete!")
console.log("=".repeat(80))
console.log(`\nCreated ${createdFiles.length} configuration file(s) in:`)
console.log(` ${targetDir}\n`)
console.log("Files:")
createdFiles.forEach((file) => {
console.log(` ✓ ${basename(file)}`)
})