Merge pull request #20421 from Homebrew/cask-rename-stanza

cask/dsl/rename: add new `rename` dsl
This commit is contained in:
Mike McQuaid 2025-08-14 08:51:45 +00:00 committed by GitHub
commit 7dd47ca89a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 347 additions and 0 deletions

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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 = []

View File

@ -34,6 +34,9 @@ module RuboCop
:depends_on,
:container,
],
[
:rename,
],
[
:suite,
:app,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 dont apply as rigidly. Its 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-<timestamp>.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`.