Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
- **Detected single-dot (`./...`) local-path declarations in `NodePackageVersion#find_version`**. [PR #1106](https://github.com/shakacode/shakapacker/pull/1106) by [justin808](https://github.com/justin808). Extended `LOCAL_PATH_REGEX` to treat `./vendor/shakapacker`-style declarations as local-path installs (alongside the `../` and `file:` patterns added in [#1086](https://github.com/shakacode/shakapacker/pull/1086)), so version checks short-circuit before consulting potentially stale lockfile semvers. Fixes [#1103](https://github.com/shakacode/shakapacker/issues/1103).
- **Fix rspack setup not reusing certain shared webpack-rspack config settings**. [PR #1085](https://github.com/shakacode/shakapacker/pull/1085) by [brunodccarvalho](https://github.com/brunodccarvalho). Default config changes include `optimization.splitChunks.chunks="all"`, `optimization.runtimeChunk="single"`, the webpack compression plugin in production, and the removal of minimization plugins in development. Fixes [#984](https://github.com/shakacode/shakapacker/issues/984).
- **Fixed Rspack Sass rule blocking `sass-embedded` users by requiring the `sass` package**. [PR #1105](https://github.com/shakacode/shakapacker/pull/1105) by [justin808](https://github.com/justin808). Rspack Sass detection now only checks for `sass-loader`; the implementation (`sass`, `sass-embedded`, etc.) is resolved by the loader at build time, matching the webpack code path.
- **Fixed `bin/shakapacker-config` and `bin/diff-bundler-config` in ESM apps**. [PR #1104](https://github.com/shakacode/shakapacker/pull/1104) by [justin808](https://github.com/justin808). Apps with `"type": "module"` in `package.json` failed to run the JavaScript binstubs because Node parsed them as ESM. The binstubs are now Ruby wrappers that locate Node and invoke `.cjs` package scripts shipped inside `node_modules/shakapacker/package/bin/`. Existing apps with the old JavaScript binstubs should re-run `bundle exec rake shakapacker:binstubs` to install the new Ruby wrappers.

## [v10.0.0] - April 8, 2026

Expand Down
109 changes: 45 additions & 64 deletions lib/install/bin/diff-bundler-config
Original file line number Diff line number Diff line change
@@ -1,64 +1,45 @@
#!/usr/bin/env node

const { createRequire } = require("module")

const PACK_CONFIG_DIFF_PACKAGE = "pack-config-diff"

function formatError(error) {
return error instanceof Error ? error.message : String(error)
}

function exitWithCode(exitCode) {
if (!Number.isInteger(exitCode)) {
console.error(
`[Shakapacker] ${PACK_CONFIG_DIFF_PACKAGE} returned a non-integer exit code: ${String(exitCode)}`
)
process.exit(2)
}

// Forward only pack-config-diff's documented exit codes (0 = no diffs, 1 = diffs found).
// Any other code is a tool failure — normalize to 2.
process.exit(exitCode <= 1 ? exitCode : 2)
}

let run

try {
// Resolve pack-config-diff via shakapacker's dependency tree so strict package
// managers (pnpm, Yarn PnP) can find the transitive dependency.
const shakapackerRequire = createRequire(
require.resolve("shakapacker/package.json")
)
const loadedModule = shakapackerRequire("pack-config-diff")
run = loadedModule.run
} catch (error) {
console.error(
`[Shakapacker] Failed to load ${PACK_CONFIG_DIFF_PACKAGE}: ${formatError(error)}`
)
process.exit(2)
}

if (typeof run !== "function") {
console.error(
`[Shakapacker] ${PACK_CONFIG_DIFF_PACKAGE} did not export a run() function`
)
process.exit(2)
}

try {
const runResult = run(process.argv.slice(2))

if (runResult && typeof runResult.then === "function") {
runResult
.then((exitCode) => exitWithCode(exitCode))
.catch((error) => {
console.error(formatError(error))
process.exit(2)
})
} else {
exitWithCode(runResult)
}
} catch (error) {
console.error(formatError(error))
process.exit(2)
}
#!/usr/bin/env ruby
# frozen_string_literal: true

# Keep in sync with lib/install/bin/shakapacker-config and the template in
# package/configExporter/cli.ts (createBinStub).
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"
Comment thread
justin808 marked this conversation as resolved.

app_root = shakapacker_app_root
node_bin = shakapacker_node_binary
script_path = File.join(
app_root,
"node_modules",
"shakapacker",
"package",
"bin",
"diff-bundler-config.cjs"
)

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
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same unhandled exec failure path as in shakapacker-config — if the binary disappears between the system probe and the exec call the user gets a raw Ruby backtrace.

Suggested change
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
end
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
rescue SystemCallError => e
warn "[Shakapacker] Failed to exec #{node_bin.inspect}: #{e.message}"
exit 1
end

52 changes: 43 additions & 9 deletions lib/install/bin/shakapacker-config
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
#!/usr/bin/env node
#!/usr/bin/env ruby
# frozen_string_literal: true

// Minimal shim - all logic is in the TypeScript module
const { run } = require("shakapacker/configExporter")
# Keep in sync with lib/install/bin/diff-bundler-config and the template in
Comment thread
justin808 marked this conversation as resolved.
# package/configExporter/cli.ts (createBinStub).
def shakapacker_app_root
candidate = File.expand_path("..", __dir__)
return candidate if File.exist?(File.join(candidate, "Gemfile"))

run(process.argv.slice(2))
.then((exitCode) => process.exit(exitCode))
.catch((error) => {
console.error(error.message)
process.exit(1)
})
warn "[Shakapacker] No Gemfile found at #{candidate.inspect}; " \
"falling back to the current directory (#{Dir.pwd.inspect})."
Dir.pwd
Comment thread
justin808 marked this conversation as resolved.
end

def shakapacker_node_binary
Comment thread
justin808 marked this conversation as resolved.
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}. " \
Comment on lines +15 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The system call forks a subprocess on every invocation just to confirm Node is reachable. For a low-cost alternative that stays in-process:

Suggested change
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}. " \
def shakapacker_node_binary
node_bin = "node"
found = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
File.executable?(File.join(dir, node_bin))
end
return node_bin if found
warn "[Shakapacker] Could not find Node.js executable #{node_bin.inspect}. " \
"Install Node.js and try again."
exit 1
end

Not a blocker — the system approach is clear and the subprocess cost is tiny at startup — but worth considering if you'd prefer to avoid the fork.

"Install Node.js and try again."
exit 1
end

ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"
Comment thread
justin808 marked this conversation as resolved.

app_root = shakapacker_app_root
node_bin = shakapacker_node_binary
script_path = File.join(
app_root,
"node_modules",
"shakapacker",
"package",
"bin",
"shakapacker-config.cjs"
)

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
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 If exec fails after the node existence check (e.g., the binary is found by system but is then unexecutable due to a race or filesystem error), Ruby raises an unhandled Errno::ENOENT/Errno::EACCES and the user sees a raw backtrace instead of a helpful message. A rescue around the exec block would surface a cleaner error.

Suggested change
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
end
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
rescue SystemCallError => e
warn "[Shakapacker] Failed to exec #{node_bin.inspect}: #{e.message}"
exit 1
end

10 changes: 5 additions & 5 deletions lib/tasks/shakapacker/export_bundler_config.rake
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ namespace :shakapacker do
$stderr.puts ""

Dir.chdir(Rails.root) do
exec("node", gem_bin_path, *ARGV[1..])
exec(RbConfig.ruby, gem_bin_path, *ARGV[1..])
end
else
# Pass through command-line arguments after the task name
# Use exec to replace the rake process with the export script
# This ensures proper exit codes and signal handling
# Pass through command-line arguments after the task name.
# Invoke with RbConfig.ruby so the binstub runs under the same Ruby as Rake
# (avoids version-manager/shebang mismatches and works on Windows).
Dir.chdir(Rails.root) do
exec(bin_path.to_s, *ARGV[1..])
exec(RbConfig.ruby, bin_path.to_s, *ARGV[1..])
Comment thread
justin808 marked this conversation as resolved.
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"./package/config": "./package/config.js",
"./package/*": "./package/*"
},
"bin": {
"shakapacker-config": "package/bin/shakapacker-config.cjs",
"diff-bundler-config": "package/bin/diff-bundler-config.cjs"
},
"files": [
"package",
"lib/install/config/shakapacker.yml"
Expand Down
67 changes: 67 additions & 0 deletions package/bin/diff-bundler-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node

const { createRequire } = require("module")

const PACK_CONFIG_DIFF_PACKAGE = "pack-config-diff"

function formatError(error) {
return error instanceof Error ? error.message : String(error)
}

function exitWithCode(exitCode) {
if (!Number.isInteger(exitCode)) {
console.error(
`[Shakapacker] ${PACK_CONFIG_DIFF_PACKAGE} returned a non-integer exit code: ${String(exitCode)}`
)
process.exit(2)
}

// Forward only pack-config-diff's documented exit codes (0 = no diffs, 1 = diffs found).
// Any other code is a tool failure; normalize to 2.
process.exit(exitCode <= 1 ? exitCode : 2)
}

let run

try {
// Anchor createRequire at __filename: this file lives inside
// node_modules/shakapacker/package/bin/, so resolution starts from
// Shakapacker's own dependency subtree. That is what strict package
// managers (pnpm, Yarn PnP) require to find pack-config-diff as
// Shakapacker's transitive dependency. Do not "simplify" this to
// createRequire(__dirname) or to a path computed by hand — __filename
// is the canonical anchor that keeps virtual-store layouts working.
const shakapackerRequire = createRequire(__filename)
const loadedModule = shakapackerRequire(PACK_CONFIG_DIFF_PACKAGE)
run = loadedModule.run
} catch (error) {
console.error(
`[Shakapacker] Failed to load ${PACK_CONFIG_DIFF_PACKAGE}: ${formatError(error)}`
)
process.exit(2)
}

if (typeof run !== "function") {
console.error(
`[Shakapacker] ${PACK_CONFIG_DIFF_PACKAGE} did not export a run() function`
)
process.exit(2)
}

try {
const runResult = run(process.argv.slice(2))

if (runResult && typeof runResult.then === "function") {
runResult
.then((exitCode) => exitWithCode(exitCode))
.catch((error) => {
console.error(formatError(error))
process.exit(2)
})
} else {
exitWithCode(runResult)
}
} catch (error) {
console.error(formatError(error))
process.exit(2)
}
10 changes: 10 additions & 0 deletions package/bin/shakapacker-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env node

const { run } = require("../configExporter")

run(process.argv.slice(2))
.then((exitCode) => process.exit(exitCode))
.catch((error) => {
console.error(error.message)
Comment thread
justin808 marked this conversation as resolved.
process.exit(1)
})
53 changes: 43 additions & 10 deletions package/configExporter/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ function runInitCommand(options: ExportOptions): number {

function createBinStub(binStubPath: string): void {
const binDir = dirname(binStubPath)
const packageScript = `${basename(binStubPath)}.cjs`
const { mkdirSync, chmodSync } = require("fs")

// Ensure bin directory exists
Expand All @@ -522,28 +523,60 @@ function createBinStub(binStubPath: string): void {
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.
Comment thread
justin808 marked this conversation as resolved.
Comment on lines +526 to +527

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a note here that #{} in the lines below is Ruby interpolation syntax — JavaScript template literals only interpolate ${...}, so these pass through verbatim to the generated file. Without a comment, a future reader may assume #{} is a typo or an unused interpolation and "fix" it.

def shakapacker_app_root
candidate = File.expand_path("..", __dir__)
Comment thread
justin808 marked this conversation as resolved.
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"

require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)

require "bundler/setup"
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

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
exec "node", "./node_modules/.bin/shakapacker-config", *ARGV
Dir.chdir(app_root) do
exec node_bin, script_path, *ARGV
end
Comment on lines 564 to 567

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Generated binstub shares the same unhandled-exec gap. The createBinStub template emits the same Dir.chdir { exec … } pattern without a rescue. Any app that installs the binstub via --init will get the same raw-backtrace failure path. If the rescue is added to lib/install/bin/shakapacker-config, the template here should be updated in the same change to keep the three in sync (as the comment at line 526 already requests).

`

writeFileSync(binStubPath, stubContent, { mode: 0o755 })

// Make executable
// 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) {
// chmod might fail on some systems, but mode in writeFileSync should handle it
// ignore - file was created with executable mode on supporting filesystems
}
}

Expand Down
Loading
Loading