diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index 2dc777a92a..e27a04ca0f 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -3,6 +3,7 @@ require "cask/denylist" require "cask/download" +require "cask/installer" require "digest" require "livecheck/livecheck" require "source_location" @@ -631,6 +632,11 @@ module Cask .extract_nestedly(to: @tmpdir, verbose: false) end + # Process rename operations after extraction + # Create a temporary installer to process renames in the audit directory + temp_installer = Installer.new(@cask) + temp_installer.process_rename_operations(target_dir: @tmpdir) + # Set the flag to indicate that extraction has occurred. @artifacts_extracted = T.let(true, T.nilable(TrueClass)) diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index 9b7767cc58..12a530916c 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -19,6 +19,7 @@ require "cask/dsl/container" require "cask/dsl/depends_on" require "cask/dsl/postflight" require "cask/dsl/preflight" +require "cask/dsl/rename" require "cask/dsl/uninstall_postflight" require "cask/dsl/uninstall_preflight" require "cask/dsl/version" @@ -81,6 +82,7 @@ module Cask :language, :name, :os, + :rename, :sha256, :staged_path, :url, @@ -162,6 +164,7 @@ module Cask @on_system_block_min_os = T.let(nil, T.nilable(MacOSVersion)) @os = T.let(nil, T.nilable(String)) @os_set_in_block = T.let(false, T::Boolean) + @rename = T.let([], T::Array[DSL::Rename]) @sha256 = T.let(nil, T.nilable(T.any(Checksum, Symbol))) @sha256_set_in_block = T.let(false, T::Boolean) @staged_path = T.let(nil, T.nilable(Pathname)) @@ -343,6 +346,28 @@ module Cask end end + # Renames files after extraction. + # + # This is useful when the downloaded file has unpredictable names + # that need to be normalized for proper artifact installation. + # + # ### Example + # + # ```ruby + # rename "RØDECaster App*.pkg", "RØDECaster App.pkg" + # ``` + # + # @api public + sig { + params(from: String, + to: String).returns(T::Array[DSL::Rename]) + } + def rename(from = T.unsafe(nil), to = T.unsafe(nil)) + return @rename if from.nil? + + @rename << DSL::Rename.new(T.must(from), T.must(to)) + end + # Sets the cask's version. # # ### Example diff --git a/Library/Homebrew/cask/dsl/rename.rb b/Library/Homebrew/cask/dsl/rename.rb new file mode 100644 index 0000000000..4873503097 --- /dev/null +++ b/Library/Homebrew/cask/dsl/rename.rb @@ -0,0 +1,52 @@ +# typed: strict +# frozen_string_literal: true + +module Cask + class DSL + # Class corresponding to the `rename` stanza. + class Rename + sig { returns(String) } + attr_reader :from, :to + + sig { params(from: String, to: String).void } + def initialize(from, to) + @from = from + @to = to + end + + sig { params(staged_path: Pathname).void } + def perform_rename(staged_path) + return unless staged_path.exist? + + # Find files matching the glob pattern + matching_files = if @from.include?("*") + staged_path.glob(@from) + else + [staged_path.join(@from)].select(&:exist?) + end + + return if matching_files.empty? + + # Rename the first matching file to the target path + source_file = matching_files.first + return if source_file.nil? + + target_file = staged_path.join(@to) + + # Ensure target directory exists + target_file.dirname.mkpath + + # Perform the rename + source_file.rename(target_file.to_s) if source_file.exist? + end + + sig { returns(T::Hash[Symbol, String]) } + def pairs + { from:, to: } + end + + sig { returns(String) } + def to_s = pairs.inspect + end + end +end diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index 967971247c..90893a9f3f 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -125,6 +125,7 @@ module Cask Caskroom.ensure_caskroom_exists extract_primary_container + process_rename_operations save_caskfile rescue => e purge_versioned_files @@ -292,6 +293,19 @@ on_request: true) Quarantine.propagate(from: primary_container.path, to:) end + sig { params(target_dir: T.nilable(Pathname)).void } + def process_rename_operations(target_dir: nil) + return if @cask.rename.empty? + + working_dir = target_dir || @cask.staged_path + odebug "Processing rename operations in #{working_dir}" + + @cask.rename.each do |rename_operation| + odebug "Renaming #{rename_operation.from} to #{rename_operation.to}" + rename_operation.perform_rename(working_dir) + end + end + sig { params(predecessor: T.nilable(Cask)).void } def install_artifacts(predecessor: nil) already_installed_artifacts = [] diff --git a/Library/Homebrew/rubocops/cask/constants/stanza.rb b/Library/Homebrew/rubocops/cask/constants/stanza.rb index d6d2e9a278..9cd042b02f 100644 --- a/Library/Homebrew/rubocops/cask/constants/stanza.rb +++ b/Library/Homebrew/rubocops/cask/constants/stanza.rb @@ -34,6 +34,9 @@ module RuboCop :depends_on, :container, ], + [ + :rename, + ], [ :suite, :app, diff --git a/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi b/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi index 111ddc3b61..1d831fddd1 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi @@ -168,6 +168,9 @@ class Cask::Cask sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } def qlplugin(*args, &block); end + sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } + def rename(*args, &block); end + sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } def screen_saver(*args, &block); end diff --git a/Library/Homebrew/test/cask/dsl/rename_spec.rb b/Library/Homebrew/test/cask/dsl/rename_spec.rb new file mode 100644 index 0000000000..9b979cf2a5 --- /dev/null +++ b/Library/Homebrew/test/cask/dsl/rename_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +RSpec.describe Cask::DSL::Rename do + subject(:rename) { described_class.new(from, to) } + + let(:from) { "Source File*.pkg" } + let(:to) { "Target File.pkg" } + + describe "#initialize" do + it "sets the from and to attributes" do + expect(rename.from).to eq("Source File*.pkg") + expect(rename.to).to eq("Target File.pkg") + end + end + + describe "#pairs" do + it "returns the attributes as a hash" do + expect(rename.pairs).to eq(from: "Source File*.pkg", to: "Target File.pkg") + end + end + + describe "#to_s" do + it "returns the stringified attributes" do + expect(rename.to_s).to eq(rename.pairs.inspect) + end + end + + describe "#perform_rename" do + let(:tmpdir) { mktmpdir } + let(:staged_path) { Pathname(tmpdir) } + + context "when staged_path does not exist" do + let(:staged_path) { Pathname("/nonexistent/path") } + + it "does nothing" do + expect { rename.perform_rename(staged_path) }.not_to raise_error + end + end + + context "when using glob patterns" do + let(:from) { "Test App*.pkg" } + let(:to) { "Test App.pkg" } + + before do + (staged_path / "Test App v1.2.3.pkg").write("test content") + (staged_path / "Test App v2.0.0.pkg").write("other content") + end + + it "renames the first matching file" do + rename.perform_rename(staged_path) + + expect(staged_path / "Test App.pkg").to exist + expect((staged_path / "Test App.pkg").read).to eq("test content") + expect(staged_path / "Test App v1.2.3.pkg").not_to exist + expect(staged_path / "Test App v2.0.0.pkg").to exist + end + end + + context "when using exact filenames" do + let(:from) { "Exact File.dmg" } + let(:to) { "New Name.dmg" } + + before do + (staged_path / "Exact File.dmg").write("dmg content") + end + + it "renames the exact file" do + rename.perform_rename(staged_path) + + expect(staged_path / "New Name.dmg").to exist + expect((staged_path / "New Name.dmg").read).to eq("dmg content") + expect(staged_path / "Exact File.dmg").not_to exist + end + end + + context "when target is in a subdirectory" do + let(:from) { "source.txt" } + let(:to) { "subdir/target.txt" } + + before do + (staged_path / "source.txt").write("content") + end + + it "creates the subdirectory and renames the file" do + rename.perform_rename(staged_path) + + expect(staged_path / "subdir" / "target.txt").to exist + expect((staged_path / "subdir" / "target.txt").read).to eq("content") + expect(staged_path / "source.txt").not_to exist + end + end + + context "when no files match the pattern" do + let(:from) { "nonexistent*.pkg" } + let(:to) { "target.pkg" } + + it "does nothing" do + rename.perform_rename(staged_path) + + expect(staged_path / "target.pkg").not_to exist + end + end + + context "when source file doesn't exist after glob" do + let(:from) { "missing.txt" } + let(:to) { "target.txt" } + + it "does nothing" do + expect { rename.perform_rename(staged_path) }.not_to raise_error + expect(staged_path / "target.txt").not_to exist + end + end + end +end diff --git a/Library/Homebrew/test/cask/dsl_spec.rb b/Library/Homebrew/test/cask/dsl_spec.rb index b93c6a33ff..9b1ef7102f 100644 --- a/Library/Homebrew/test/cask/dsl_spec.rb +++ b/Library/Homebrew/test/cask/dsl_spec.rb @@ -598,4 +598,29 @@ RSpec.describe Cask::DSL, :cask, :no_api do ] end end + + describe "rename stanza" do + it "allows setting single rename operation" do + cask = Cask::Cask.new("rename-cask") do + rename "Source*.pkg", "Target.pkg" + end + + expect(cask.rename.length).to eq(1) + expect(cask.rename.first.from).to eq("Source*.pkg") + expect(cask.rename.first.to).to eq("Target.pkg") + end + + it "allows setting multiple rename operations" do + cask = Cask::Cask.new("multi-rename-cask") do + rename "App*.pkg", "App.pkg" + rename "Doc*.dmg", "Doc.dmg" + end + + expect(cask.rename.length).to eq(2) + expect(cask.rename.first.from).to eq("App*.pkg") + expect(cask.rename.first.to).to eq("App.pkg") + expect(cask.rename.last.from).to eq("Doc*.dmg") + expect(cask.rename.last.to).to eq("Doc.dmg") + end + end end diff --git a/Library/Homebrew/test/cask/installer_spec.rb b/Library/Homebrew/test/cask/installer_spec.rb index 0aceb687a1..4702de3467 100644 --- a/Library/Homebrew/test/cask/installer_spec.rb +++ b/Library/Homebrew/test/cask/installer_spec.rb @@ -423,4 +423,94 @@ RSpec.describe Cask::Installer, :cask do end.to raise_error(Cask::CaskCannotBeInstalledError, /#{dep_name} formula was forbidden/) end end + + describe "rename operations" do + let(:tmpdir) { mktmpdir } + let(:staged_path) { Pathname(tmpdir) } + + after do + FileUtils.rm_rf(tmpdir) if tmpdir && File.exist?(tmpdir) + end + + it "processes rename operations after extraction" do + # Create test files + (staged_path / "Original App.app").mkpath + (staged_path / "Original App.app" / "Contents").mkpath + + cask = Cask::Cask.new("rename-test-cask") do + url "file://#{TEST_FIXTURE_DIR}/cask/caffeine.zip" + rename "Original App.app", "Renamed App.app" + app "Renamed App.app" + end + + # Mock the staged_path to point to our test directory + allow(cask).to receive(:staged_path).and_return(staged_path) + + installer = described_class.new(cask) + installer.send(:process_rename_operations) + + expect(staged_path / "Renamed App.app").to be_a_directory + expect(staged_path / "Original App.app").not_to exist + end + + it "handles multiple rename operations in order" do + # Create test file + (staged_path / "Original.app").mkpath + + cask = Cask::Cask.new("multi-rename-test-cask") do + url "file://#{TEST_FIXTURE_DIR}/cask/caffeine.zip" + rename "Original.app", "First Rename.app" + rename "First Rename.app", "Final Name.app" + app "Final Name.app" + end + + allow(cask).to receive(:staged_path).and_return(staged_path) + + installer = described_class.new(cask) + installer.send(:process_rename_operations) + + expect(staged_path / "Final Name.app").to be_a_directory + expect(staged_path / "Original.app").not_to exist + expect(staged_path / "First Rename.app").not_to exist + end + + it "handles glob patterns in rename operations" do + # Create test file with version + (staged_path / "Test App v1.2.3.pkg").write("test content") + + cask = Cask::Cask.new("glob-rename-test-cask") do + url "file://#{TEST_FIXTURE_DIR}/cask/caffeine.zip" + rename "Test App*.pkg", "Test App.pkg" + pkg "Test App.pkg" + end + + allow(cask).to receive(:staged_path).and_return(staged_path) + + installer = described_class.new(cask) + installer.send(:process_rename_operations) + + expect(staged_path / "Test App.pkg").to be_a_file + expect((staged_path / "Test App.pkg").read).to eq("test content") + expect(staged_path / "Test App v1.2.3.pkg").not_to exist + end + + it "does nothing when no files match rename pattern" do + # Create a different file + (staged_path / "Different.app").mkpath + + cask = Cask::Cask.new("no-match-rename-test-cask") do + url "file://#{TEST_FIXTURE_DIR}/cask/caffeine.zip" + rename "NonExistent*.app", "Target.app" + app "Different.app" + end + + allow(cask).to receive(:staged_path).and_return(staged_path) + + installer = described_class.new(cask) + + expect { installer.send(:process_rename_operations) }.not_to raise_error + expect(staged_path / "Different.app").to be_a_directory + expect(staged_path / "Target.app").not_to exist + end + end end diff --git a/docs/Cask-Cookbook.md b/docs/Cask-Cookbook.md index 4b52704fcf..93257df86a 100644 --- a/docs/Cask-Cookbook.md +++ b/docs/Cask-Cookbook.md @@ -66,6 +66,8 @@ Having a common order for stanzas makes casks easier to update and parse. Below depends_on container + rename + suite app pkg @@ -176,6 +178,7 @@ Each cask must declare one or more [artifacts](https://rubydoc.brew.sh/Cask/Arti | `container type:` | no | Symbol to override container-type autodetect. May be one of: `:air`, `:bz2`, `:cab`, `:dmg`, `:generic_unar`, `:gzip`, `:otf`, `:pkg`, `:rar`, `:seven_zip`, `:sit`, `:tar`, `:ttf`, `:xar`, `:zip`, `:naked`. (Example: [parse.rb](https://github.com/Homebrew/homebrew-cask/blob/aa461148bbb5119af26b82cccf5003e2b4e50d95/Casks/p/parse.rb#L10)) | | `auto_updates` | no | `true`. Asserts that the cask artifacts auto-update. Use if `Check for Updates…` or similar is present in an app menu, but not if it only opens a webpage and does not do the download and installation for you. | | [`no_autobump!`](#stanza-no_autobump) | no | Allowed symbol or a string. Excludes cask from autobumping if set. | +| [`rename`](#stanza-rename) | yes | A pair of strings. | ## Stanza descriptions @@ -252,6 +255,18 @@ binary "#{appdir}/Atom.app/Contents/Resources/app/atom.sh", target: "atom" Behaviour and usage of `target:` is [the same as with `app`](#renaming-the-target). However, for `binary` the select cases don’t apply as rigidly. It’s fine to take extra liberties with `target:` to be consistent with other command-line tools, like [changing case](https://github.com/Homebrew/homebrew-cask/blob/aa461148bbb5119af26b82cccf5003e2b4e50d95/Casks/g/godot.rb#L19), [removing an extension](https://github.com/Homebrew/homebrew-cask/blob/aa461148bbb5119af26b82cccf5003e2b4e50d95/Casks/f/filebot.rb#L19), or [cleaning up the name](https://github.com/Homebrew/homebrew-cask/blob/aa461148bbb5119af26b82cccf5003e2b4e50d95/Casks/f/fig.rb#L21). +### Stanza: `rename` + +The `rename` stanza provides a convenience method to rename files to provide more practical access to them. +This stanza should be used sparingly, and is reserved for scenarios where a the path of a file/directory is impossible to pre-determine. + +The example below can be used when the `pkg` path has a value such as timestamp that can't be detected without extracting the archive it is distributed within. + +```ruby +# Upstream provides a `pkg` - "foobar-.pkg" +rename "foobar-*.pkg", "foobar.pkg" +``` + ### Stanza: `caveats` Sometimes there are particularities with the installation of a piece of software that cannot or should not be handled programmatically by Homebrew Cask. In those instances, `caveats` is the way to inform the user. Information in `caveats` is displayed when a cask is invoked with either `install` or `info`.