diff --git a/lib/install/bin/diff-bundler-config b/lib/install/bin/diff-bundler-config index 4b0087caa..667066565 100755 --- a/lib/install/bin/diff-bundler-config +++ b/lib/install/bin/diff-bundler-config @@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable) end def shakapacker_find_executable(executable) - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path| + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path| + search_path = path.empty? ? Dir.pwd : path + shakapacker_executable_candidates(executable).each do |candidate| - executable_path = File.join(path, candidate) + executable_path = File.join(search_path, candidate) return executable_path if File.file?(executable_path) && File.executable?(executable_path) end end diff --git a/lib/install/bin/shakapacker-config b/lib/install/bin/shakapacker-config index 93382abbd..9f6c5fcfb 100755 --- a/lib/install/bin/shakapacker-config +++ b/lib/install/bin/shakapacker-config @@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable) end def shakapacker_find_executable(executable) - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path| + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path| + search_path = path.empty? ? Dir.pwd : path + shakapacker_executable_candidates(executable).each do |candidate| - executable_path = File.join(path, candidate) + executable_path = File.join(search_path, candidate) return executable_path if File.file?(executable_path) && File.executable?(executable_path) end end diff --git a/package/configExporter/cli.ts b/package/configExporter/cli.ts index 7f3623bb4..7e94802d0 100644 --- a/package/configExporter/cli.ts +++ b/package/configExporter/cli.ts @@ -1,7 +1,13 @@ // 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 { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync +} from "fs" import { resolve, dirname, sep, delimiter, basename } from "path" import { inspect } from "util" import { load as loadYaml } from "js-yaml" @@ -510,10 +516,15 @@ function runInitCommand(options: ExportOptions): number { return 0 } -function createBinStub(binStubPath: string): void { +/** + * Exported for test use only: verifies generated content matches + * lib/install/bin/* binstubs. Not part of the public API. + * + * @internal + */ +export 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)) { @@ -550,9 +561,11 @@ def shakapacker_executable_candidates(executable) end def shakapacker_find_executable(executable) - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path| + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path| + search_path = path.empty? ? Dir.pwd : path + shakapacker_executable_candidates(executable).each do |candidate| - executable_path = File.join(path, candidate) + executable_path = File.join(search_path, candidate) return executable_path if File.file?(executable_path) && File.executable?(executable_path) end end diff --git a/spec/dummy/bin/shakapacker-config b/spec/dummy/bin/shakapacker-config index 93382abbd..9f6c5fcfb 100755 --- a/spec/dummy/bin/shakapacker-config +++ b/spec/dummy/bin/shakapacker-config @@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable) end def shakapacker_find_executable(executable) - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path| + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path| + search_path = path.empty? ? Dir.pwd : path + shakapacker_executable_candidates(executable).each do |candidate| - executable_path = File.join(path, candidate) + executable_path = File.join(search_path, candidate) return executable_path if File.file?(executable_path) && File.executable?(executable_path) end end diff --git a/spec/shakapacker/binstub_sync_spec.rb b/spec/shakapacker/binstub_sync_spec.rb index 0aa768732..8bfa67612 100644 --- a/spec/shakapacker/binstub_sync_spec.rb +++ b/spec/shakapacker/binstub_sync_spec.rb @@ -55,7 +55,9 @@ expect(dummy_content).to eq(install_content), "spec/dummy/bin/shakapacker-config and lib/install/bin/shakapacker-config have diverged. " \ - "Update both files to keep them in sync." + "All four copies must stay byte-for-byte identical — update each one: " \ + "lib/install/bin/shakapacker-config, lib/install/bin/diff-bundler-config, " \ + "spec/dummy/bin/shakapacker-config, and the createBinStub template in package/configExporter/cli.ts." end # lib/install/bin/diff-bundler-config and lib/install/bin/shakapacker-config share diff --git a/spec/shakapacker/helper_binstubs_spec.rb b/spec/shakapacker/helper_binstubs_spec.rb index fc9ece20c..7c3b7190e 100644 --- a/spec/shakapacker/helper_binstubs_spec.rb +++ b/spec/shakapacker/helper_binstubs_spec.rb @@ -38,6 +38,12 @@ def install_fake_node_script(app_path, command) FileUtils.chmod(0o755, script_path) end + def real_node_path + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |dir| File.join(dir, "node") } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + end + %w[shakapacker-config diff-bundler-config].each do |command| it "runs #{command} through a CommonJS package script when the app is ESM" do Dir.mktmpdir("shakapacker-binstub-") do |app_path| @@ -160,11 +166,9 @@ def install_fake_node_script(app_path, command) FileUtils.mkdir_p(File.join(app_path, "bin")) install_fake_node_script(app_path, command) - real_node_path = ENV.fetch("PATH").split(File::PATH_SEPARATOR).map { |path| File.join(path, "node") }.find do |path| - File.file?(path) && File.executable?(path) - end + node_path = real_node_path - skip "node not found in PATH" unless real_node_path + skip "node not found in PATH" unless node_path fake_bin_path = File.join(app_path, "fake-bin") FileUtils.mkdir_p(fake_bin_path) @@ -176,7 +180,7 @@ def install_fake_node_script(app_path, command) echo probed >> "$SHAKAPACKER_NODE_PROBE_OUTPUT" exit 0 fi - exec #{real_node_path.shellescape} "$@" + exec #{node_path.shellescape} "$@" SH FileUtils.chmod(0o755, fake_node_path) @@ -200,6 +204,55 @@ def install_fake_node_script(app_path, command) end end + { + "leading" => "#{File::PATH_SEPARATOR}/nonexistent", + "trailing" => "/nonexistent#{File::PATH_SEPARATOR}" + }.each do |position, path_value| + it "honors a #{position} empty PATH entry as the current directory for #{command}" do + Dir.mktmpdir("shakapacker-binstub-") do |app_path| + File.write(File.join(app_path, "Gemfile"), "") + FileUtils.mkdir_p(File.join(app_path, "bin")) + install_fake_node_script(app_path, command) + + node_path = real_node_path + skip "node not found in PATH" unless node_path + + launch_path = File.join(app_path, "launch") + FileUtils.mkdir_p(launch_path) + fake_node_path = File.join(launch_path, "node") + File.write(fake_node_path, <<~SH) + #!/bin/sh + exec #{node_path.shellescape} "$@" + SH + FileUtils.chmod(0o755, fake_node_path) + + binstub_path = File.join(app_path, "bin", command) + FileUtils.cp(File.join(gem_root, "lib", "install", "bin", command), binstub_path) + FileUtils.chmod(0o755, binstub_path) + + output_path = File.join(app_path, "binstub-output.json") + _stdout, stderr, status = Bundler.with_unbundled_env do + Open3.capture3( + { + "BUNDLE_GEMFILE" => nil, + "PATH" => path_value, + "RUBYOPT" => nil, + "SHAKAPACKER_BINSTUB_OUTPUT" => output_path + }, + RbConfig.ruby, + binstub_path, + chdir: launch_path + ) + end + + expect(status).to be_success, stderr + expect(JSON.parse(File.read(output_path))).to include( + "cwd" => File.realpath(app_path) + ) + end + end + end + it "exits with an error when #{command}'s package script is missing" do Dir.mktmpdir("shakapacker-binstub-") do |app_path| File.write(File.join(app_path, "Gemfile"), "") @@ -232,12 +285,14 @@ def install_fake_node_script(app_path, command) FileUtils.cp(File.join(gem_root, "lib", "install", "bin", command), binstub_path) FileUtils.chmod(0o755, binstub_path) - _stdout, stderr, status = Open3.capture3( - { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "PATH" => "/nonexistent" }, - RbConfig.ruby, - binstub_path, - chdir: app_path - ) + _stdout, stderr, status = Bundler.with_unbundled_env do + Open3.capture3( + { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "PATH" => "/nonexistent" }, + RbConfig.ruby, + binstub_path, + chdir: app_path + ) + end expect(status.exitstatus).to eq(1) expect(stderr).to include('[Shakapacker] Could not find Node.js executable "node"') diff --git a/test/configExporter/createBinStub.test.js b/test/configExporter/createBinStub.test.js new file mode 100644 index 000000000..aff17a343 --- /dev/null +++ b/test/configExporter/createBinStub.test.js @@ -0,0 +1,51 @@ +const { + accessSync, + constants, + mkdtempSync, + readFileSync, + rmSync +} = require("fs") +const { join, resolve } = require("path") +const { tmpdir } = require("os") +const { createBinStub } = require("../../package/configExporter/cli") + +const gemRoot = resolve(__dirname, "../..") + +// The Ruby logic in lib/install/bin/shakapacker-config and +// lib/install/bin/diff-bundler-config is also duplicated inside the +// `createBinStub` template in package/configExporter/cli.ts. The Ruby spec +// (spec/shakapacker/binstub_sync_spec.rb) keeps the three checked-in copies +// (install template, install diff template, and the dummy app's binstub) +// honest, but it cannot reach into the JS template. This test closes that +// gap by invoking createBinStub for both helper names and asserting the +// generated content matches the corresponding lib/install/bin/* file. +describe("createBinStub template parity", () => { + let tmp + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "shakapacker-createBinStub-")) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + // diff-bundler-config is a white-box parity case; production init only + // generates shakapacker-config. + test.each(["shakapacker-config", "diff-bundler-config"])( + "generates lib/install/bin/%s byte-for-byte", + (binstubName) => { + const generatedPath = join(tmp, "bin", binstubName) + createBinStub(generatedPath) + + const generated = readFileSync(generatedPath, "utf8") + const installed = readFileSync( + join(gemRoot, "lib", "install", "bin", binstubName), + "utf8" + ) + + expect(generated).toBe(installed) + expect(() => accessSync(generatedPath, constants.X_OK)).not.toThrow() + } + ) +})