shakapacker 10.1.0 → 10.2.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.
@@ -2,12 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "rbconfig"
5
+ require "yaml"
5
6
 
6
- # Keep helper logic in sync across:
7
- # - lib/install/bin/shakapacker-config
8
- # - lib/install/bin/diff-bundler-config
9
- # - spec/dummy/bin/shakapacker-config
10
- # - package/configExporter/cli.ts (createBinStub).
7
+ # This binstub is managed by Shakapacker. Delete it and rerun the install or init command to regenerate it.
11
8
  def shakapacker_app_root
12
9
  candidate = File.expand_path("..", __dir__)
13
10
  return candidate if File.exist?(File.join(candidate, "Gemfile"))
@@ -28,9 +25,11 @@ def shakapacker_executable_candidates(executable)
28
25
  end
29
26
 
30
27
  def shakapacker_find_executable(executable)
31
- ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
28
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
29
+ search_path = path.empty? ? Dir.pwd : path
30
+
32
31
  shakapacker_executable_candidates(executable).each do |candidate|
33
- executable_path = File.join(path, candidate)
32
+ executable_path = File.join(search_path, candidate)
34
33
  return executable_path if File.file?(executable_path) && File.executable?(executable_path)
35
34
  end
36
35
  end
@@ -51,22 +50,94 @@ def shakapacker_node_env
51
50
  %w[development test].include?(ENV["RAILS_ENV"]) ? "development" : "production"
52
51
  end
53
52
 
53
+ def shakapacker_load_yaml_file(path)
54
+ YAML.load_file(path, aliases: true)
55
+ rescue ArgumentError
56
+ YAML.load_file(path)
57
+ end
58
+
59
+ def shakapacker_source_path(app_root)
60
+ config_path = ENV["SHAKAPACKER_CONFIG"] || File.join("config", "shakapacker.yml")
61
+ config_path = File.expand_path(config_path, app_root)
62
+ return nil unless File.file?(config_path)
63
+
64
+ config = shakapacker_load_yaml_file(config_path)
65
+ return nil unless config.is_a?(Hash)
66
+
67
+ env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
68
+ env_config = config[env] || config["production"]
69
+ return nil unless env_config.is_a?(Hash)
70
+
71
+ source_path = env_config["source_path"]
72
+ source_path if source_path.is_a?(String)
73
+ rescue Psych::Exception, SystemCallError, ArgumentError
74
+ nil
75
+ end
76
+
77
+ def shakapacker_path_within?(path, parent)
78
+ path == parent || path.start_with?("#{parent}#{File::SEPARATOR}")
79
+ end
80
+
81
+ def shakapacker_package_root_marker?(path)
82
+ %w[
83
+ package.json
84
+ bun.lockb
85
+ pnpm-lock.yaml
86
+ yarn.lock
87
+ package-lock.json
88
+ node_modules
89
+ ].any? { |entry| File.exist?(File.join(path, entry)) }
90
+ end
91
+
92
+ def shakapacker_client_root(app_root)
93
+ source_path = shakapacker_source_path(app_root)
94
+ return app_root unless source_path && !source_path.empty?
95
+
96
+ app_root = File.expand_path(app_root)
97
+ current = File.expand_path(source_path, app_root)
98
+ return app_root unless shakapacker_path_within?(current, app_root)
99
+
100
+ loop do
101
+ return current if shakapacker_package_root_marker?(current)
102
+ break if current == app_root
103
+
104
+ parent = File.dirname(current)
105
+ break if parent == current
106
+
107
+ current = parent
108
+ end
109
+
110
+ app_root
111
+ end
112
+
113
+ def shakapacker_package_script_path(package_root, package_script)
114
+ File.join(
115
+ package_root,
116
+ "node_modules",
117
+ "shakapacker",
118
+ "package",
119
+ "bin",
120
+ package_script
121
+ )
122
+ end
123
+
124
+ def shakapacker_package_script_paths(app_root, client_root, package_script)
125
+ [client_root, app_root].uniq.map do |package_root|
126
+ shakapacker_package_script_path(package_root, package_script)
127
+ end
128
+ end
129
+
54
130
  ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
55
131
  ENV["NODE_ENV"] ||= shakapacker_node_env
56
132
 
57
133
  app_root = shakapacker_app_root
134
+ client_root = shakapacker_client_root(app_root)
58
135
  node_bin = shakapacker_node_binary
59
- script_path = File.join(
60
- app_root,
61
- "node_modules",
62
- "shakapacker",
63
- "package",
64
- "bin",
65
- "diff-bundler-config.cjs"
66
- )
67
-
68
- unless File.file?(script_path)
69
- warn "[Shakapacker] Could not find #{script_path}. Run your package manager install command and try again."
136
+ script_paths = shakapacker_package_script_paths(app_root, client_root, "diff-bundler-config.cjs")
137
+ script_path = script_paths.find { |path| File.file?(path) }
138
+
139
+ unless script_path
140
+ warn "[Shakapacker] Could not find #{script_paths.join(' or ')}. Run your package manager install command and try again."
70
141
  exit 1
71
142
  end
72
143
 
@@ -2,12 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "rbconfig"
5
+ require "yaml"
5
6
 
6
- # Keep helper logic in sync across:
7
- # - lib/install/bin/shakapacker-config
8
- # - lib/install/bin/diff-bundler-config
9
- # - spec/dummy/bin/shakapacker-config
10
- # - package/configExporter/cli.ts (createBinStub).
7
+ # This binstub is managed by Shakapacker. Delete it and rerun the install or init command to regenerate it.
11
8
  def shakapacker_app_root
12
9
  candidate = File.expand_path("..", __dir__)
13
10
  return candidate if File.exist?(File.join(candidate, "Gemfile"))
@@ -28,9 +25,11 @@ def shakapacker_executable_candidates(executable)
28
25
  end
29
26
 
30
27
  def shakapacker_find_executable(executable)
31
- ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
28
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
29
+ search_path = path.empty? ? Dir.pwd : path
30
+
32
31
  shakapacker_executable_candidates(executable).each do |candidate|
33
- executable_path = File.join(path, candidate)
32
+ executable_path = File.join(search_path, candidate)
34
33
  return executable_path if File.file?(executable_path) && File.executable?(executable_path)
35
34
  end
36
35
  end
@@ -51,22 +50,94 @@ def shakapacker_node_env
51
50
  %w[development test].include?(ENV["RAILS_ENV"]) ? "development" : "production"
52
51
  end
53
52
 
53
+ def shakapacker_load_yaml_file(path)
54
+ YAML.load_file(path, aliases: true)
55
+ rescue ArgumentError
56
+ YAML.load_file(path)
57
+ end
58
+
59
+ def shakapacker_source_path(app_root)
60
+ config_path = ENV["SHAKAPACKER_CONFIG"] || File.join("config", "shakapacker.yml")
61
+ config_path = File.expand_path(config_path, app_root)
62
+ return nil unless File.file?(config_path)
63
+
64
+ config = shakapacker_load_yaml_file(config_path)
65
+ return nil unless config.is_a?(Hash)
66
+
67
+ env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
68
+ env_config = config[env] || config["production"]
69
+ return nil unless env_config.is_a?(Hash)
70
+
71
+ source_path = env_config["source_path"]
72
+ source_path if source_path.is_a?(String)
73
+ rescue Psych::Exception, SystemCallError, ArgumentError
74
+ nil
75
+ end
76
+
77
+ def shakapacker_path_within?(path, parent)
78
+ path == parent || path.start_with?("#{parent}#{File::SEPARATOR}")
79
+ end
80
+
81
+ def shakapacker_package_root_marker?(path)
82
+ %w[
83
+ package.json
84
+ bun.lockb
85
+ pnpm-lock.yaml
86
+ yarn.lock
87
+ package-lock.json
88
+ node_modules
89
+ ].any? { |entry| File.exist?(File.join(path, entry)) }
90
+ end
91
+
92
+ def shakapacker_client_root(app_root)
93
+ source_path = shakapacker_source_path(app_root)
94
+ return app_root unless source_path && !source_path.empty?
95
+
96
+ app_root = File.expand_path(app_root)
97
+ current = File.expand_path(source_path, app_root)
98
+ return app_root unless shakapacker_path_within?(current, app_root)
99
+
100
+ loop do
101
+ return current if shakapacker_package_root_marker?(current)
102
+ break if current == app_root
103
+
104
+ parent = File.dirname(current)
105
+ break if parent == current
106
+
107
+ current = parent
108
+ end
109
+
110
+ app_root
111
+ end
112
+
113
+ def shakapacker_package_script_path(package_root, package_script)
114
+ File.join(
115
+ package_root,
116
+ "node_modules",
117
+ "shakapacker",
118
+ "package",
119
+ "bin",
120
+ package_script
121
+ )
122
+ end
123
+
124
+ def shakapacker_package_script_paths(app_root, client_root, package_script)
125
+ [client_root, app_root].uniq.map do |package_root|
126
+ shakapacker_package_script_path(package_root, package_script)
127
+ end
128
+ end
129
+
54
130
  ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
55
131
  ENV["NODE_ENV"] ||= shakapacker_node_env
56
132
 
57
133
  app_root = shakapacker_app_root
134
+ client_root = shakapacker_client_root(app_root)
58
135
  node_bin = shakapacker_node_binary
59
- script_path = File.join(
60
- app_root,
61
- "node_modules",
62
- "shakapacker",
63
- "package",
64
- "bin",
65
- "shakapacker-config.cjs"
66
- )
67
-
68
- unless File.file?(script_path)
69
- warn "[Shakapacker] Could not find #{script_path}. Run your package manager install command and try again."
136
+ script_paths = shakapacker_package_script_paths(app_root, client_root, "shakapacker-config.cjs")
137
+ script_path = script_paths.find { |path| File.file?(path) }
138
+
139
+ unless script_path
140
+ warn "[Shakapacker] Could not find #{script_paths.join(' or ')}. Run your package manager install command and try again."
70
141
  exit 1
71
142
  end
72
143
 
@@ -35,6 +35,12 @@ default: &default
35
35
  public_output_path: packs
36
36
  cache_path: tmp/shakapacker
37
37
  webpack_compile_output: true
38
+
39
+ # Extra command-line flags passed to webpack/rspack when compiling.
40
+ # Do not include "--", Shakapacker wrapper flags, help/version flags, watch flags/forms, or managed flags.
41
+ # Example: ["--progress", "--fail-on-warnings"]
42
+ webpack_compile_flags: []
43
+
38
44
  # See https://github.com/shakacode/shakapacker#deployment
39
45
  shakapacker_precompile: true
40
46
 
@@ -59,7 +65,7 @@ default: &default
59
65
  javascript_transpiler: "swc"
60
66
 
61
67
  # Select assets bundler to use
62
- # Available options: 'webpack' (default) or 'rspack'
68
+ # Available options: 'webpack' or 'rspack'
63
69
  assets_bundler: "webpack"
64
70
 
65
71
  # Path to the directory containing webpack/rspack config files
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "rspack": {
3
- "@rspack/cli": "^1.0.0 || ^2.0.0-0",
4
- "@rspack/core": "^1.0.0 || ^2.0.0-0",
5
- "rspack-manifest-plugin": "^5.0.0"
3
+ "@rspack/cli": "^2.0.0",
4
+ "@rspack/core": "^2.0.0",
5
+ "@rspack/dev-server": "^2.0.0",
6
+ "rspack-manifest-plugin": "^5.2.2"
6
7
  },
7
8
  "webpack": {
8
9
  "mini-css-extract-plugin": "^2.0.0",
@@ -17,6 +18,7 @@
17
18
  "common": {
18
19
  "compression-webpack-plugin": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
19
20
  "css-loader": "^6.0.0 || ^7.0.0",
21
+ "sass": "^1.50.0",
20
22
  "sass-loader": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
21
23
  "style-loader": "^3.0.0 || ^4.0.0"
22
24
  },
@@ -15,6 +15,31 @@ require "json"
15
15
  @package_json ||= PackageJson.new
16
16
  install_dir = File.expand_path(File.dirname(__FILE__))
17
17
 
18
+ # Read the existing app's bundler (if any) before copy_file can overwrite it, so a
19
+ # re-install can default to that bundler instead of silently switching it.
20
+ config_path = Rails.root.join("config/shakapacker.yml")
21
+ shakapacker_config_preexisting = config_path.exist?
22
+ existing_assets_bundler =
23
+ File.read(config_path)[/assets_bundler:\s*"([^"]+)"/, 1] if shakapacker_config_preexisting
24
+
25
+ # New installs default to rspack; an existing app keeps its current bundler on a
26
+ # re-install unless overridden by the env var/argument or a FORCE overwrite (see
27
+ # Env.resolve_assets_bundler for the precedence rules). The bundled shakapacker.yml
28
+ # ships "webpack" for backward compatibility and is rewritten below when the chosen
29
+ # bundler differs.
30
+ assets_bundler = Shakapacker::Install::Env.resolve_assets_bundler(
31
+ env_value: ENV["SHAKAPACKER_ASSETS_BUNDLER"],
32
+ existing_bundler: existing_assets_bundler,
33
+ force: @conflict_option[:force]
34
+ )
35
+
36
+ # Fail fast on a misspelled SHAKAPACKER_ASSETS_BUNDLER instead of failing later
37
+ # with a confusing missing-config-directory or peer-lookup error.
38
+ unless Shakapacker::Install::Env::VALID_BUNDLERS.include?(assets_bundler)
39
+ say "❌ Unknown bundler '#{assets_bundler}'. Set SHAKAPACKER_ASSETS_BUNDLER to one of: #{Shakapacker::Install::Env::VALID_BUNDLERS.join(", ")}.", :red
40
+ exit 1
41
+ end
42
+
18
43
  # Installation strategy:
19
44
  # - USE_BABEL_PACKAGES installs both babel AND swc for compatibility
20
45
  # - Otherwise install only the specified transpiler
@@ -35,7 +60,6 @@ else
35
60
  end
36
61
 
37
62
  # Copy config file
38
- shakapacker_config_preexisting = Rails.root.join("config/shakapacker.yml").exist?
39
63
  copy_file "#{install_dir}/config/shakapacker.yml", "config/shakapacker.yml", @conflict_option
40
64
 
41
65
  # Update config to match the selected transpiler
@@ -46,14 +70,59 @@ if Shakapacker::Install::Env.update_transpiler_config?(
46
70
  config_preexisting: shakapacker_config_preexisting
47
71
  )
48
72
  gsub_file "config/shakapacker.yml", 'javascript_transpiler: "swc"', "javascript_transpiler: \"#{@transpiler_to_install}\""
49
- say " 📝 Updated config/shakapacker.yml to use #{@transpiler_to_install} transpiler", :green
73
+ # Unlike the bundler rewrite below (which only runs on installer-owned files and
74
+ # aborts on a miss), this can also run against a pre-existing user config whose
75
+ # transpiler line may legitimately differ from the shipped "swc" literal. A no-op
76
+ # there is not an error, so only claim success when the value actually landed.
77
+ if File.read(config_path).include?("javascript_transpiler: \"#{@transpiler_to_install}\"")
78
+ say " 📝 Updated config/shakapacker.yml to use #{@transpiler_to_install} transpiler", :green
79
+ end
80
+ end
81
+
82
+ # Update config to match the selected bundler (see update_assets_bundler_config?
83
+ # for when the installer rewrites the shipped "webpack" default vs. preserves an
84
+ # existing config).
85
+ if Shakapacker::Install::Env.update_assets_bundler_config?(
86
+ assets_bundler_to_install: assets_bundler,
87
+ conflict_option: @conflict_option,
88
+ config_preexisting: shakapacker_config_preexisting
89
+ )
90
+ gsub_file "config/shakapacker.yml", 'assets_bundler: "webpack"', "assets_bundler: \"#{assets_bundler}\""
91
+ # gsub_file silently no-ops if the shipped literal is ever reformatted, so verify
92
+ # the value landed. Abort rather than continue, since the installer is about to set
93
+ # up the chosen bundler's dependencies and config, and a mismatched bundler value
94
+ # would produce a broken install. This runs before any dependencies are installed.
95
+ if File.read(config_path).include?("assets_bundler: \"#{assets_bundler}\"")
96
+ say " 📝 Updated config/shakapacker.yml to use #{assets_bundler} bundler", :green
97
+ else
98
+ say "❌ Could not set assets_bundler to \"#{assets_bundler}\" in config/shakapacker.yml " \
99
+ "— the expected 'assets_bundler: \"webpack\"' line was not found. Aborting so the " \
100
+ "install doesn't proceed with a mismatched bundler config.", :red
101
+ exit 1
102
+ end
103
+ else
104
+ # The bundler config was left as-is (an existing app's config is preserved unless
105
+ # FORCE overwrites it). Report the value present, and warn if it differs from the
106
+ # bundler whose dependencies/config are being installed — usually when a bundler is
107
+ # requested explicitly (env var or task argument) against a preserved config, but
108
+ # also when a preserved config holds an unrecognized bundler and resolve_assets_bundler
109
+ # falls back to rspack. Either case would otherwise be a silent mismatch. Only act
110
+ # when we can read the value back — never guess, or the message could contradict the file.
111
+ retained_bundler = File.read(config_path)[/assets_bundler:\s*"([^"]+)"/, 1]
112
+ if retained_bundler && retained_bundler != assets_bundler
113
+ say "⚠️ Installing #{assets_bundler} dependencies, but config/shakapacker.yml keeps " \
114
+ "assets_bundler: \"#{retained_bundler}\". To switch an existing app's bundler, run " \
115
+ "`bin/rake shakapacker:switch_bundler #{assets_bundler} -- --install-deps`, " \
116
+ "or re-run the installer with FORCE=true to overwrite the config.", :yellow
117
+ elsif retained_bundler
118
+ say " 📝 Keeping assets_bundler: \"#{retained_bundler}\" in config/shakapacker.yml", :green
119
+ end
50
120
  end
51
121
 
52
122
  # Detect TypeScript usage
53
123
  # Auto-detect from tsconfig.json or explicit via SHAKAPACKER_USE_TYPESCRIPT env var
54
124
  @use_typescript = File.exist?(Rails.root.join("tsconfig.json")) ||
55
125
  Shakapacker::Install::Env.truthy_env?("SHAKAPACKER_USE_TYPESCRIPT")
56
- assets_bundler = ENV["SHAKAPACKER_ASSETS_BUNDLER"] || "webpack"
57
126
  config_extension = @use_typescript ? "ts" : "js"
58
127
 
59
128
  say "Copying #{assets_bundler} core config (#{config_extension.upcase})"
@@ -224,9 +293,12 @@ Dir.chdir(Rails.root) do
224
293
  end
225
294
 
226
295
  # Inline fetch_peer_dependencies and fetch_common_dependencies
227
- peers = PackageJson.read(install_dir).fetch(ENV["SHAKAPACKER_ASSETS_BUNDLER"] || "webpack")
296
+ peers = PackageJson.read(install_dir).fetch(assets_bundler)
228
297
  common_deps = Shakapacker::Install::Env.truthy_env?("SKIP_COMMON_LOADERS") ? {} : PackageJson.read(install_dir).fetch("common")
229
- peers = peers.merge(common_deps)
298
+ peers = common_deps.merge(peers)
299
+ if assets_bundler == "rspack" && common_deps.key?("css-loader")
300
+ peers["css-loader"] = "^7.1.4"
301
+ end
230
302
 
231
303
  # Add transpiler-specific dependencies based on detected/configured transpiler
232
304
  # Inline the logic here since methods can't be called before they're defined in Rails templates
@@ -255,7 +327,9 @@ Dir.chdir(Rails.root) do
255
327
  peers = peers.merge(esbuild_deps)
256
328
  end
257
329
 
258
- dev_dependency_packages = ["webpack-dev-server"]
330
+ # Lists both dev servers for classification only; just the one matching the chosen
331
+ # bundler appears in `peers`, so only that package is actually installed.
332
+ dev_dependency_packages = ["webpack-dev-server", "@rspack/dev-server"]
259
333
 
260
334
  dependencies_to_add = []
261
335
  dev_dependencies_to_add = []
@@ -290,12 +364,17 @@ Dir.chdir(Rails.root) do
290
364
  exit 1
291
365
  end
292
366
 
293
- say "Installing webpack-dev-server for live reloading as a development dependency"
294
- begin
295
- @package_json.manager.add!(dev_dependencies_to_add, type: :dev)
296
- rescue PackageJson::Error
297
- say "Shakapacker installation failed 😭 See above for details.", :red
298
- exit 1
367
+ if dev_dependencies_to_add.any?
368
+ # Strip the trailing @version, keeping the package name; the regex drops only
369
+ # the last @-segment so scoped names (e.g. @rspack/dev-server) survive.
370
+ dev_dependency_names = dev_dependencies_to_add.map { |entry| entry.sub(/@[^@]+\z/, "") }
371
+ say "Installing development dependencies: #{dev_dependency_names.join(", ")}"
372
+ begin
373
+ @package_json.manager.add!(dev_dependencies_to_add, type: :dev)
374
+ rescue PackageJson::Error
375
+ say "Shakapacker installation failed 😭 See above for details.", :red
376
+ exit 1
377
+ end
299
378
  end
300
379
 
301
380
  # Configure babel preset in package.json if babel is being used
@@ -1,7 +1,7 @@
1
1
  module Shakapacker
2
2
  class BaseStrategy
3
- def initialize
4
- @config = Shakapacker.config
3
+ def initialize(instance = Shakapacker.instance)
4
+ @instance = instance
5
5
  end
6
6
 
7
7
  def after_compile_hook
@@ -10,7 +10,13 @@ module Shakapacker
10
10
 
11
11
  private
12
12
 
13
- attr_reader :config
13
+ def config
14
+ @instance.config
15
+ end
16
+
17
+ def env
18
+ @instance.env
19
+ end
14
20
 
15
21
  def default_watched_paths
16
22
  [
@@ -18,7 +24,7 @@ module Shakapacker
18
24
  "#{config.source_path}{,/**/*}",
19
25
  "package.json", "package-lock.json", "yarn.lock",
20
26
  "pnpm-lock.yaml", "bun.lockb",
21
- "config/webpack{,/**/*}"
27
+ "config/{webpack,rspack}{,/**/*}"
22
28
  ].freeze
23
29
  end
24
30
  end
@@ -19,12 +19,13 @@ module Shakapacker
19
19
  prod: %w[webpack-merge]
20
20
  }.freeze
21
21
 
22
- # Default dependencies for each bundler (package names only, no versions)
22
+ # Default dependencies for each bundler. Rspack entries include install-time
23
+ # version ranges; package_names strips them before removal and display.
23
24
  # Note: Excludes independent/optional dependencies like @swc/core, swc-loader (user-configured
24
25
  # transpilers)
25
26
  DEFAULT_RSPACK_DEPS = {
26
- dev: %w[@rspack/cli @rspack/plugin-react-refresh],
27
- prod: %w[@rspack/core rspack-manifest-plugin]
27
+ dev: %w[@rspack/cli@^2.0.0 @rspack/dev-server@^2.0.0 @rspack/plugin-react-refresh@^2.0.0],
28
+ prod: %w[@rspack/core@^2.0.0 rspack-manifest-plugin@^5.2.2]
28
29
  }.freeze
29
30
 
30
31
  DEFAULT_WEBPACK_DEPS = {
@@ -267,8 +268,8 @@ module Shakapacker
267
268
  # Show what will be removed (only when switching and not no_uninstall)
268
269
  if switching && !no_uninstall && (!deps_to_remove[:dev].empty? || !deps_to_remove[:prod].empty?)
269
270
  puts " 🗑️ Removing:"
270
- deps_to_remove[:dev].each { |dep| puts " - #{dep} (dev)" }
271
- deps_to_remove[:prod].each { |dep| puts " - #{dep} (prod)" }
271
+ package_names(deps_to_remove[:dev]).each { |dep| puts " - #{dep} (dev)" }
272
+ package_names(deps_to_remove[:prod]).each { |dep| puts " - #{dep} (prod)" }
272
273
  puts ""
273
274
  elsif switching && no_uninstall
274
275
  puts " ⏭️ Skipping uninstall (--no-uninstall)"
@@ -302,7 +303,7 @@ module Shakapacker
302
303
 
303
304
  # Combine dev and prod dependencies into a single list for removal
304
305
  # Package managers remove packages from both dependencies and devDependencies sections if present
305
- all_deps = deps[:dev] + deps[:prod]
306
+ all_deps = package_names(deps[:dev] + deps[:prod])
306
307
 
307
308
  unless all_deps.empty?
308
309
  unless package_json.manager.remove(all_deps)
@@ -381,6 +382,11 @@ module Shakapacker
381
382
  end
382
383
 
383
384
  def print_uninstall_commands(package_manager, deps)
385
+ deps = {
386
+ dev: package_names(deps[:dev]),
387
+ prod: package_names(deps[:prod])
388
+ }
389
+
384
390
  case package_manager
385
391
  when "yarn"
386
392
  puts " yarn remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
@@ -397,6 +403,16 @@ module Shakapacker
397
403
  end
398
404
  end
399
405
 
406
+ def package_names(dependencies)
407
+ dependencies.map do |dependency|
408
+ if dependency.start_with?("@")
409
+ dependency.count("@") > 1 ? dependency.sub(/@[^@]+\z/, "") : dependency
410
+ else
411
+ dependency.sub(/@[^@]+\z/, "")
412
+ end
413
+ end
414
+ end
415
+
400
416
  # Load YAML config file with Ruby version compatibility
401
417
  # Ruby 3.1+ supports aliases: keyword, older versions need YAML.safe_load
402
418
  def load_yaml_config(path)
@@ -5,6 +5,9 @@ require "shellwords"
5
5
  require_relative "compiler_strategy"
6
6
 
7
7
  class Shakapacker::Compiler
8
+ SpawnFailure = Class.new(StandardError)
9
+ private_constant :SpawnFailure
10
+
8
11
  # Additional environment variables that the compiler is being run with
9
12
  # Shakapacker::Compiler.env['FRONTEND_API_KEY'] = 'your_secret_key'
10
13
  cattr_accessor(:env) { {} }
@@ -36,9 +39,16 @@ class Shakapacker::Compiler
36
39
  else
37
40
  acquire_ipc_lock do
38
41
  run_precompile_hook if should_run_precompile_hook?
39
- run_webpack.tap do |success|
40
- after_compile_hook
42
+ spawn_failed = false
43
+ begin
44
+ success = run_webpack
45
+ rescue SpawnFailure
46
+ spawn_failed = true
47
+ success = false
41
48
  end
49
+
50
+ after_compile_hook unless spawn_failed
51
+ success
42
52
  end
43
53
  end
44
54
  end
@@ -83,8 +93,9 @@ class Shakapacker::Compiler
83
93
  config.root_path.join("tmp/shakapacker.lock")
84
94
  end
85
95
 
96
+ # Returns one executable path for array-form Open3, not a shell command snippet.
86
97
  def optional_ruby_runner
87
- first_line = File.readlines(bin_shakapacker_path).first.chomp
98
+ first_line = File.readlines(bin_shakapacker_path).first&.chomp || ""
88
99
  /ruby/.match?(first_line) ? RbConfig.ruby : ""
89
100
  end
90
101
 
@@ -176,11 +187,21 @@ class Shakapacker::Compiler
176
187
  def run_webpack
177
188
  logger.info "Compiling..."
178
189
 
179
- stdout, stderr, status = Open3.capture3(
180
- webpack_env,
181
- "#{optional_ruby_runner} '#{bin_shakapacker_path}'",
182
- chdir: File.expand_path(config.root_path)
183
- )
190
+ # Fetch compile flags before the spawn rescue so configuration errors stay explicit.
191
+ compile_flags = config.webpack_compile_flags
192
+
193
+ begin
194
+ command = shakapacker_command(compile_flags)
195
+ stdout, stderr, status = Open3.capture3(
196
+ webpack_env,
197
+ *command,
198
+ chdir: File.expand_path(config.root_path)
199
+ )
200
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOEXEC, Errno::EPERM, Errno::ENOTDIR => e
201
+ logger.error "\nCOMPILATION FAILED:\n#{e.class}: #{e.message}"
202
+ show_doctor_hint_once
203
+ raise SpawnFailure
204
+ end
184
205
 
185
206
  if status.success?
186
207
  logger.info "Compiled all packs in #{config.public_output_path}"
@@ -211,6 +232,15 @@ class Shakapacker::Compiler
211
232
  config.root_path.join("bin/shakapacker")
212
233
  end
213
234
 
235
+ def shakapacker_command(compile_flags)
236
+ runner = optional_ruby_runner
237
+ bin_path = bin_shakapacker_path.to_s
238
+ flags_part = compile_flags.any? ? ["--", *compile_flags] : []
239
+
240
+ # Use the [cmd, argv0] form so Open3 never routes a lone binstub path through the shell.
241
+ runner.empty? ? [[bin_path, bin_path], *flags_part] : [runner, bin_path, *flags_part]
242
+ end
243
+
214
244
  # Fires only after a failed compile, so users in a healthy loop never see the tip.
215
245
  def show_doctor_hint_once
216
246
  return if self.class.doctor_hint_shown
@@ -3,14 +3,14 @@ require_relative "digest_strategy"
3
3
 
4
4
  module Shakapacker
5
5
  class CompilerStrategy
6
- def self.from_config
7
- strategy_from_config = Shakapacker.config.compiler_strategy
6
+ def self.from_config(instance = Shakapacker.instance)
7
+ strategy_from_config = instance.config.compiler_strategy
8
8
 
9
9
  case strategy_from_config
10
10
  when "mtime"
11
- Shakapacker::MtimeStrategy.new
11
+ Shakapacker::MtimeStrategy.new(instance)
12
12
  when "digest"
13
- Shakapacker::DigestStrategy.new
13
+ Shakapacker::DigestStrategy.new(instance)
14
14
  else
15
15
  raise "Unknown strategy '#{strategy_from_config}'. " \
16
16
  "Available options are 'mtime' and 'digest'."