diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000000..75c947cdca1ca --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "./bin/brew lgtm", + "timeout": 360 + } + ] + } + ] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000000000..8baaa5a8e2387 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": ".codex/hooks/brew-lgtm-stop.sh", + "timeout": 360 + } + ] + } + ] + } +} diff --git a/.codex/hooks/brew-lgtm-stop.sh b/.codex/hooks/brew-lgtm-stop.sh new file mode 100755 index 0000000000000..9bc852248446d --- /dev/null +++ b/.codex/hooks/brew-lgtm-stop.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +cd "$(dirname "$0")/../.." || { + printf '%s\n' '{"decision":"block","reason":"Failed to cd to the repository root before running ./bin/brew lgtm."}' + exit 0 +} + +if ./bin/brew lgtm >&2 +then + printf '%s\n' '{"continue":true}' +else + printf '%s\n' '{"decision":"block","reason":"./bin/brew lgtm failed; review the output above and fix it before stopping."}' +fi diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000000000..7e283598601ef --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "./bin/brew style --changed --fix" + }, + { + "command": "./bin/brew typecheck" + } + ], + "stop": [ + { + "command": "./bin/brew tests --changed" + } + ] + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..990dabc1d90c3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "Homebrew/brew", + "image": "ghcr.io/homebrew/brew:main", + "workspaceFolder": "/home/linuxbrew/.linuxbrew/Homebrew", + "workspaceMount": "source=${localWorkspaceFolder},target=/home/linuxbrew/.linuxbrew/Homebrew,type=bind,consistency=cached", + "onCreateCommand": ".devcontainer/on-create-command.sh", + "customizations": { + "vscode": { + // Installing all necessary extensions for vscode + // Taken from: .vscode/extensions.json + "extensions": [ + "Shopify.ruby-lsp", + "sorbet.sorbet-vscode-extension", + "github.vscode-github-actions", + "anykeyh.simplecov-vscode", + "ms-azuretools.vscode-docker", + "github.vscode-pull-request-github", + "davidanson.vscode-markdownlint", + "foxundermoon.shell-format", + "timonwong.shellcheck", + "ban.spellright", + "redhat.vscode-yaml", + "koichisasada.vscode-rdbg", + "editorconfig.editorconfig" + ] + } + }, + "remoteEnv": { + "HOMEBREW_GITHUB_API_TOKEN": "${localEnv:GITHUB_TOKEN}" + } +} diff --git a/.devcontainer/on-create-command.sh b/.devcontainer/on-create-command.sh new file mode 100755 index 0000000000000..68d4fe2350359 --- /dev/null +++ b/.devcontainer/on-create-command.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +# fix permissions so Homebrew and Bundler don't complain +sudo chmod -R g-w,o-w /home/linuxbrew +sudo chmod +t -R /home/linuxbrew/ + +# everything below is too slow to do unless prebuilding so skip it +CODESPACES_ACTION_NAME="$(jq --raw-output '.ACTION_NAME' /workspaces/.codespaces/shared/environment-variables.json)" +if [[ "${CODESPACES_ACTION_NAME}" != "createPrebuildTemplate" ]] +then + echo "Skipping slow items, not prebuilding." + exit 0 +fi + +# install Homebrew's development gems +brew install-bundler-gems --groups=all + +# install Homebrew formulae we might need +brew install shellcheck shfmt gh gnu-tar + +# cleanup any mess +brew cleanup + +# actually tap homebrew/core, no longer done by default +brew tap --force homebrew/core + +# install some useful development things +sudo apt-get update +sudo apt-get install -y \ + -o Dpkg::Options::=--force-confdef \ + -o Dpkg::Options::=--force-confnew \ + bash-completion \ + openssh-server \ + zsh + +# Start the SSH server so that `gh cs ssh` works. +sudo service ssh start diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000..51422c966d366 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +Library/Homebrew/vendor/portable-ruby +Library/Homebrew/vendor/bundle/ruby/.homebrew_gem_groups +Library/Homebrew/vendor/bundle/ruby/*/.homebrew_vendor_version +Library/Homebrew/vendor/bundle/ruby/*/bin +Library/Homebrew/vendor/bundle/ruby/*/build_info +Library/Homebrew/vendor/bundle/ruby/*/bundler.lock +Library/Homebrew/vendor/bundle/ruby/*/cache +Library/Homebrew/vendor/bundle/ruby/*/extensions +Library/Homebrew/vendor/bundle/ruby/*/plugins +Library/Homebrew/vendor/bundle/ruby/*/specifications +Library/Taps diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000..ea0c7184ecefe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +.licenses/**/*.yml linguist-generated=true +Library/Homebrew/sorbet/rbi/annotations/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/annotations/**/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/dsl/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/dsl/**/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/gems/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/gems/**/*.rbi linguist-generated=true +Library/Homebrew/sorbet/rbi/parser@*.rbi linguist-generated=true +Library/Homebrew/vendor/bundle/** linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 09af8ebef84d7..ff2c5e3577385 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1 +1 @@ -# Please fill out one of the templates on: https://github.com/Homebrew/brew/issues/new/choose or we will close it without comment. +Please fill out one of the templates on https://github.com/Homebrew/brew/issues/new/choose or we will close your issue without comment. diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 67d0a3161ce28..0000000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: New issue for Reproducible Bug -about: "If you're sure it's reproducible and not just your machine: submit an issue so we can investigate." - ---- - -# Please note we will close your issue without comment if you delete, do not read or do not fill out the issue checklist below and provide ALL the requested information. If you repeatedly fail to use the issue template, we will block you from ever submitting issues to Homebrew again. - -- [ ] ran `brew update` and can still reproduce the problem? -- [ ] ran `brew doctor`, fixed all issues and can still reproduce the problem? -- [ ] ran `brew config` and `brew doctor` and included their output with your issue? - - - -## What you were trying to do (and why) - - - -## What happened (include command output) - - - -
- Command output -
-
-  
-
-  
-
- -## What you expected to happen - - - -## Step-by-step reproduction instructions (by running `brew` commands) - - - -## Output of `brew config` and `brew doctor` commands - -
-
-
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000000..efdb38d190256 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,51 @@ +name: New issue for Reproducible Bug +description: "If you're sure it's reproducible and not just your machine: submit an issue so we can investigate." +type: "Bug" +body: + - type: markdown + attributes: + value: We may close your issue without comment if you don't fill out the checklist below and provide ALL requested information (even if you think it's irrelevant). If you won't use the issue template, we may block you from submitting future issues to Homebrew. + - type: textarea + attributes: + render: shell + label: "`brew doctor` output" + validations: + required: true + - type: checkboxes + attributes: + label: Verification + description: Verify you've followed these steps. If you can't truthfully check these boxes, open a discussion at https://github.com/orgs/Homebrew/discussions instead. Don't delete these checkboxes or your issue will be closed automatically. + options: + - label: I ran `brew update` twice and am still able to reproduce my issue. + required: true + - label: My "`brew doctor` output" above says `Your system is ready to brew` or a definitely unrelated `Tier` message. + required: true + - label: This issue's title and/or description do not reference a single formula e.g. `brew install wget`. If they do, open an issue at https://github.com/Homebrew/homebrew-core/issues/new/choose instead. + required: true + - type: textarea + attributes: + render: shell + label: "`brew config` output" + validations: + required: true + - type: textarea + attributes: + label: What were you trying to do (and why)? + validations: + required: true + - type: textarea + attributes: + label: What happened (include all command output)? + validations: + required: true + - type: textarea + attributes: + label: What did you expect to happen? + validations: + required: true + - type: textarea + attributes: + render: shell + label: Step-by-step reproduction instructions (running `brew` commands) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index eb2c4408187dd..4736e05e93cff 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,15 +1,18 @@ blank_issues_enabled: false contact_links: -- name: Get help on Discourse - url: https://discourse.brew.sh - about: Have a question? Not sure if your issue affects everyone reproducibly? The quickest way to get help is on Homebrew's Discourse! -- name: New issue on Homebrew/homebrew-core - url: https://github.com/Homebrew/homebrew-core/issues/new/choose - about: On macOS/Mac OS X? Having a `brew` problem with a `brew install` or `brew upgrade` of a single formula/package? Report it to Homebrew/homebrew-core (the macOS core tap/repository). -- name: New issue on Homebrew/homebrew-cask - url: https://github.com/Homebrew/homebrew-cask/issues/new/choose - about: Having a `brew cask` problem? Report it to Homebrew/homebrew-cask (the cask tap/repository). -- name: New issue on Homebrew/linuxbrew-core - url: https://github.com/Homebrew/linuxbrew-core/issues/new/choose - about: On Linux? Having a `brew` problem with a `brew install` or `brew upgrade` of a single formula/package? Report it to Homebrew/linuxbrew-core (the Linux core tap/repository). + - name: Get help in GitHub Discussions + url: https://github.com/orgs/Homebrew/discussions + about: Have a question, or unsure whether your issue is reproducible for everyone? The quickest way to get help is on Homebrew's GitHub Discussions! + - name: New issue on Homebrew/homebrew-core + url: https://github.com/Homebrew/homebrew-core/issues/new/choose + about: Having a `brew install` or `brew upgrade` problem with a single formula/package? Report it to Homebrew/homebrew-core (the core tap/repository). + - name: New issue on Homebrew/homebrew-cask + url: https://github.com/Homebrew/homebrew-cask/issues/new/choose + about: Having a `brew --cask` problem? Report it to Homebrew/homebrew-cask (the cask tap/repository). + - name: New issue on Homebrew/install + url: https://github.com/Homebrew/install/issues/new/choose + about: Having a problem installing Homebrew? Report it to Homebrew/install (the installer repository). + - name: Get help from Homebrew mirror maintainers + url: https://github.com/orgs/Homebrew/discussions/1917 + about: Slow downloads or a mirror not working as expected? Check the mirror list and contact the respective mirror maintainers. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 9033c7b4d70f0..0000000000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: New issue for Feature Suggestion -about: Request our thoughts on your suggestion for a new feature for Homebrew. - ---- - - - -# A detailed description of the proposed feature - - - -# The motivation for the feature - - - -# How the feature would be relevant to at least 90% of Homebrew users - - - -# What alternatives to the feature have been considered - - - - diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000000000..d8a4b36fca442 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,37 @@ +name: New issue for Feature Suggestion +description: Request our thoughts on your suggestion for a new feature for Homebrew. +type: "Feature" +body: + - type: markdown + attributes: + value: We will close your issue without comment if you don't fill out the checklist below and provide ALL requested information. If you repeatedly fail to use the issue template, we will block you from submitting issues to Homebrew again. + - type: checkboxes + attributes: + label: Verification + description: Verify you've followed these steps. Don't delete these checkboxes or your issue will be closed automatically. + options: + - label: This issue's title and/or description do not reference a single formula e.g. `brew install wget`. If they do, open an issue at https://github.com/Homebrew/homebrew-core/issues/new/choose instead. + required: true + - type: textarea + attributes: + label: Provide a detailed description of the proposed feature + validations: + required: true + - type: textarea + attributes: + label: What is the motivation for the feature? + validations: + required: true + - type: textarea + attributes: + label: How will the feature be relevant to at least 90% of Homebrew users? + validations: + required: true + - type: textarea + attributes: + label: What alternatives to the feature have been considered? + validations: + required: true + - type: markdown + attributes: + value: We will close this issue or ask you to open a pull request if it's something the maintainers aren't actively planning to work on. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 399ede2172ce0..c85e6752ad13e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,21 @@ -- [ ] Have you followed the guidelines in our [Contributing](https://github.com/Homebrew/brew/blob/master/CONTRIBUTING.md) document? -- [ ] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/Homebrew/brew/pulls) for the same change? -- [ ] Have you added an explanation of what your changes do and why you'd like us to include them? -- [ ] Have you written new tests for your changes? [Here's an example](https://github.com/Homebrew/brew/blob/master/Library/Homebrew/test/PATH_spec.rb). -- [ ] Have you successfully run `brew style` with your changes locally? -- [ ] Have you successfully run `brew tests` with your changes locally? +----- + + + + + +- [ ] Have you followed our [Contributing](https://github.com/Homebrew/brew/blob/HEAD/CONTRIBUTING.md) guidelines? +- [ ] Have you checked for other open [Pull Requests](https://github.com/Homebrew/brew/pulls) for the same change? +- [ ] Have you explained what your changes do? Performance claims (e.g. "this is faster") must include [Hyperfine](https://github.com/sharkdp/hyperfine) benchmarks. +- [ ] Have you explained why you'd like these changes included, not just what they do? +- [ ] For bug fixes, have you given step-by-step `brew` commands to reproduce the bug? +- [ ] Have you written new tests (excluding integration tests)? [Here's an example](https://github.com/Homebrew/brew/blob/HEAD/Library/Homebrew/test/PATH_spec.rb). +- [ ] Have you successfully run `brew lgtm` (style, typechecking and tests) locally? + +----- + +- [ ] AI was used to generate or assist with generating this PR. + + ----- diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000000000..4613e1617bfe2 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000000..6d92a0088fc2c --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,12 @@ +self-hosted-runner: + # Labels of self-hosted or not-yet-known-upstream runner in array of strings. + labels: [] +# Configuration variables in array of strings defined in your repository or +# organization. `null` means disabling configuration variables check. +# Empty array means no configuration variable is allowed. +config-variables: + - BREW_COMMIT_CLIENT_ID +paths: + "**/.github/workflows/tests.yml": + ignore: + - unknown permission scope "code-quality" diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000000000..fa828ea69cbd6 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,10 @@ +coverage: + round: nearest + status: + project: + default: + informational: true + threshold: 0.05% + patch: + default: + informational: true diff --git a/.github/codeql/extensions/homebrew-actions.yml b/.github/codeql/extensions/homebrew-actions.yml new file mode 100644 index 0000000000000..8d4091650bff2 --- /dev/null +++ b/.github/codeql/extensions/homebrew-actions.yml @@ -0,0 +1,7 @@ +# This file is synced from the `.github` repository, do not modify it directly. +extensions: + - addsTo: + pack: codeql/actions-all + extensible: trustedActionsOwnerDataModel + data: + - ["Homebrew"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..6ed05dc9d58ec --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,84 @@ +# This file is synced from the `.github` repository, do not modify it directly. +--- +version: 2 +updates: +- package-ecosystem: bundler + directories: + - "/docs" + - "/Library/Homebrew" + schedule: + interval: weekly + day: friday + time: '08:00' + timezone: Etc/UTC + groups: + bundler: + patterns: + - "*" + allow: + - dependency-type: all + cooldown: + default-days: 7 +- package-ecosystem: devcontainers + directory: "/" + schedule: + interval: weekly + day: friday + time: '08:00' + timezone: Etc/UTC + groups: + devcontainers: + patterns: + - "*" + allow: + - dependency-type: all + cooldown: + default-days: 7 +- package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: friday + time: '08:00' + timezone: Etc/UTC + groups: + docker: + patterns: + - "*" + allow: + - dependency-type: all + cooldown: + default-days: 7 +- package-ecosystem: github-actions + directories: + - "/" + schedule: + interval: weekly + day: friday + time: '08:00' + timezone: Etc/UTC + groups: + github-actions: + patterns: + - "*" + allow: + - dependency-type: all + cooldown: + default-days: 7 +- package-ecosystem: pip + directories: + - "/Library/Homebrew/formula-analytics/" + schedule: + interval: weekly + day: friday + time: '08:00' + timezone: Etc/UTC + groups: + pip: + patterns: + - "*" + allow: + - dependency-type: all + cooldown: + default-days: 7 + diff --git a/.github/licensed.yml b/.github/licensed.yml new file mode 100644 index 0000000000000..4608e952cef0d --- /dev/null +++ b/.github/licensed.yml @@ -0,0 +1,40 @@ +sources: + bundler: true + +source_path: Library/Homebrew + +bundler: + # Licensed treats an empty value as its default development/test exclusion. + without: __licensed_none__ + +stale_records_action: error + +allowed: + - apache-2.0 + - bsd-2-clause + - bsd-3-clause + - isc + - mit + - ruby + - wtfpl + +reviewed: + bundler: + - base64 + - benchmark + - bigdecimal + - coderay + - concurrent-ruby + - diff-lcs + - drb + - io-console + - json + - kramdown + - logger + - ostruct + - parser + - racc + - rbs + - reline + - ruby-progressbar + - tsort diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000000..afd9480f6bab9 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot[bot] + - BrewTestBot diff --git a/.github/scripts/approve_stale_lead_maintainer_prs.rb b/.github/scripts/approve_stale_lead_maintainer_prs.rb new file mode 100644 index 0000000000000..b883693c7bb3e --- /dev/null +++ b/.github/scripts/approve_stale_lead_maintainer_prs.rb @@ -0,0 +1,556 @@ +# typed: strict +# frozen_string_literal: true + +require "date" +require "time" +require "uri" +require "utils/formatter" +require "utils/github" +require "utils/output" + +# Approves stale lead maintainer PRs from GitHub Actions. +class StaleLeadMaintainerPrApproval + include Utils::Output::Mixin + + REPOSITORY = "Homebrew/brew" + GITHUB_ACTIONS_URL = "https://github.com/apps/github-actions" + APPROVABLE_CHECK_RUN_CONCLUSIONS = ["success", "neutral", "skipped"].freeze + HUMAN_REVIEW_WINDOW_HOURS = 48 + SENSITIVE_PATH_PREFIXES = [".github/"].freeze + SENSITIVE_PATHS = [ + "Library/Homebrew/utils/github.rb", + "README.md", + ].freeze + REPORT_BRANCH = "approve-stale-lead-maintainer-prs" + GitHubPayload = T.type_alias { T::Hash[String, BasicObject] } + GitHubPayloads = T.type_alias { T::Array[GitHubPayload] } + GitHubPage = T.type_alias { T.any(GitHubPayload, GitHubPayloads) } + GitHubResult = T.type_alias { T.any(GitHubPayload, GitHubPayloads) } + RequestData = T.type_alias { T::Hash[Symbol, String] } + + # Recent qualifying approval for another Homebrew/brew PR. + class RecentApproval < T::Struct + const :number, Integer + const :url, String + end + + # Decision facts for a stale lead maintainer PR. + class PullRequestFacts < T::Struct + const :number, Integer + const :title, String + const :author, String + const :created_at, Time + const :head_sha, String + const :pr_url, String + const :checks_url, String + const :author_url, String + const :not_from_fork, T::Boolean + const :draft, T::Boolean + const :weekday_approval_window, T::Boolean + const :old_enough_for_approval, T::Boolean + prop :lead_maintainer, T::Boolean, default: false + prop :lead_maintainer_checked, T::Boolean, default: false + prop :approved_another_pr, T::Boolean, default: false + prop :approved_another_pr_checked, T::Boolean, default: false + prop :approved_pr_number, String, default: "" + prop :approved_pr_url, String, default: "" + prop :no_human_review_since_creation, T::Boolean, default: false + prop :reviews_checked, T::Boolean, default: false + prop :human_reviews_since_creation, T::Array[String], default: [] + prop :copilot_reviewed, T::Boolean, default: false + prop :already_approved, T::Boolean, default: false + prop :sensitive_files_unchanged, T::Boolean, default: false + prop :sensitive_files_checked, T::Boolean, default: false + prop :changed_sensitive_files, T::Array[String], default: [] + prop :ci_passing, T::Boolean, default: false + prop :ci_checked, T::Boolean, default: false + prop :failing_ci_jobs, T::Array[String], default: [] + prop :requirements_met, T::Boolean, default: false + prop :should_approve, T::Boolean, default: false + prop :failure_messages, T::Array[String], default: [] + end + + sig { void } + def initialize + @repository = T.let(ENV.fetch("GITHUB_REPOSITORY"), String) + raise "This workflow must only run in #{REPOSITORY}." if @repository != REPOSITORY + + @server_url = T.let(ENV.fetch("GITHUB_SERVER_URL", "https://github.com"), String) + @event_name = T.let(ENV.fetch("GITHUB_EVENT_NAME"), String) + @pull_request_number = T.let(ENV.fetch("PR_NUMBER", ""), String) + lead_maintainers_line = File.read("README.md").each_line.find do |line| + line.start_with?("Homebrew's [Lead Maintainers]") + end + raise "Could not find lead maintainers in README.md." if lead_maintainers_line.blank? + + lead_maintainers = T.let({}, T::Hash[String, T::Boolean]) + lead_maintainers_line.scan(%r{https://github\.com/([A-Za-z0-9-]+)}) do |login| + lead_maintainers[T.cast(login.fetch(0), String)] = true + end + @lead_maintainers = T.let(lead_maintainers, T::Hash[String, T::Boolean]) + @recent_approval_issues = T.let({}, T::Hash[String, GitHubPayloads]) + @recent_approval_search_complete = T.let({}, T::Hash[String, T::Boolean]) + @recent_approval_search_pages = T.let({}, T::Hash[String, Integer]) + @recent_approval_checks = T.let({}, T::Hash[[String, Integer], T.any(RecentApproval, FalseClass)]) + @recent_approval_results = T.let({}, T::Hash[String, T::Array[RecentApproval]]) + @reviews = T.let({}, T::Hash[Integer, GitHubPayloads]) + @printed_pull_request_summary = T.let(false, T::Boolean) + @weekday_approval_window = T.let((1..5).cover?(Time.now.utc.wday), T::Boolean) + end + + sig { void } + def run + if @event_name == "push" + report + else + approve + end + end + + private + + sig { void } + def approve + if @event_name == "workflow_dispatch" && @pull_request_number.empty? + raise "PR_NUMBER must be set for workflow_dispatch." + end + + pull_requests = if @pull_request_number.empty? + paginated_rest("#{GitHub::API_URL}/repos/#{@repository}/pulls", "state=open&per_page=#{GitHub::MAX_PER_PAGE}") + else + [T.cast(rest("#{GitHub::API_URL}/repos/#{@repository}/pulls/#{Integer(@pull_request_number)}"), GitHubPayload)] + end + puts "Evaluating #{pull_requests.length} pull request(s)." + facts = pull_requests.map { |pull_request| evaluate(pull_request, exhaustive: false) } + + if @event_name == "workflow_dispatch" + requested = facts.fetch(0) + unless requested.should_approve + requested.failure_messages.each { |failure| puts "::error::#{failure}" } + exit 1 + end + end + + approval_facts = facts.select(&:should_approve) + puts "Approving #{approval_facts.length} pull request(s)." + approval_facts.each do |data| + puts "Approving pull request ##{data.number}." + rest( + "#{GitHub::API_URL}/repos/#{@repository}/pulls/#{data.number}/reviews", + data: { + event: "APPROVE", + body: <<~MARKDOWN, + Automated approval by [github-actions\\[bot\\]](#{GITHUB_ACTIONS_URL}) for [##{data.number}](#{data.pr_url}) because all requirements are met: + + - [##{data.number}](#{data.pr_url}) is not from a fork. + - [##{data.number}](#{data.pr_url}) is not a draft. + - The approval workflow is running on a weekday. + - [@#{data.author}](#{data.author_url}) is listed as a lead maintainer in [README.md](#{@server_url}/#{@repository}/blob/HEAD/README.md). + - [@#{data.author}](#{data.author_url}) approved Homebrew/brew PR [##{data.approved_pr_number}](#{data.approved_pr_url}) in the last 7 days. + - [##{data.number}](#{data.pr_url}) was created at least #{HUMAN_REVIEW_WINDOW_HOURS} hours ago and has had no human review since creation. + - Copilot has already reviewed [##{data.number}](#{data.pr_url}). + - [##{data.number}](#{data.pr_url}) does not modify `.github/` or other sensitive files. + - All [CI jobs](#{data.checks_url}) are passing, including non-required jobs. + MARKDOWN + }, + request_method: :POST, + ) + puts "Approved pull request ##{data.number}." + end + summarise(facts) + end + + sig { void } + def report + branch = ENV.fetch("GITHUB_REF_NAME", REPORT_BRANCH) + query = URI.encode_www_form(state: "open", head: "Homebrew:#{branch}", per_page: 1) + pull_requests = T.cast(rest("#{GitHub::API_URL}/repos/#{@repository}/pulls?#{query}"), GitHubPayloads) + raise "No open pull request found for branch #{branch}." if pull_requests.empty? + + data = evaluate(pull_requests.fetch(0), exhaustive: true) + + puts "Reported stale lead maintainer PR approval facts for pull request ##{data.number}." + end + + sig { params(pull_request: GitHubPayload, exhaustive: T::Boolean).returns(PullRequestFacts) } + def evaluate(pull_request, exhaustive:) + number = T.cast(pull_request.fetch("number"), Integer) + title = T.cast(pull_request.fetch("title"), String) + author = T.cast(T.cast(pull_request.fetch("user"), GitHubPayload).fetch("login"), String) + created_at = Time.parse(T.cast(pull_request.fetch("created_at"), String)) + draft = T.cast(pull_request.fetch("draft"), T::Boolean) + head = T.cast(pull_request.fetch("head"), GitHubPayload) + head_sha = T.cast(head.fetch("sha"), String) + head_repo = T.cast(head.fetch("repo"), GitHubPayload) + data = PullRequestFacts.new( + number:, + title:, + author:, + created_at:, + head_sha:, + pr_url: "#{@server_url}/#{@repository}/pull/#{number}", + checks_url: "#{@server_url}/#{@repository}/commit/#{head_sha}/checks", + author_url: "#{@server_url}/#{author}", + not_from_fork: T.cast(head_repo.fetch("full_name"), String) == @repository && + !T.cast(head_repo.fetch("fork"), T::Boolean), + draft:, + weekday_approval_window: @weekday_approval_window, + old_enough_for_approval: created_at <= Time.now.utc - (HUMAN_REVIEW_WINDOW_HOURS * 60 * 60), + ) + raw_failures = [] + raw_failures << "Pull request ##{data.number} is from a fork." unless data.not_from_fork + raw_failures << "Pull request ##{data.number} is a draft." if data.draft + return finish(data, raw_failures) if raw_failures.any? + + if !exhaustive && !data.weekday_approval_window + return finish(data, ["Stale lead maintainer PR approvals do not run on Saturdays or Sundays."]) + end + + data.lead_maintainer_checked = true + data.lead_maintainer = @lead_maintainers.fetch(data.author, false) + if !exhaustive && !data.lead_maintainer + return finish(data, ["@#{data.author} is not listed as a lead maintainer in README.md."]) + end + + approved_pr = T.let( + @recent_approval_results[data.author]&.find { |approval| approval.number != data.number }, + T.nilable(RecentApproval), + ) + unless approved_pr + @recent_approval_results[data.author] ||= [] + @recent_approval_issues[data.author] ||= [] + + loop do + @recent_approval_issues.fetch(data.author).each do |issue| + number = T.cast(issue.fetch("number"), Integer) + next if number == data.number + + unless @recent_approval_checks.key?([data.author, number]) + cutoff = Time.now.utc - (7 * 24 * 60 * 60) + @recent_approval_checks[[data.author, number]] = if reviews_for(number).any? do |review| + review_user = T.cast(review.fetch("user"), + GitHubPayload) + T.cast(review_user.fetch("login"), String) == + data.author && + T.cast(review.fetch("state"), String) == "APPROVED" && + Time.parse( + T.cast(review.fetch("submitted_at"), String), + ) >= cutoff + end + RecentApproval.new( + number:, + url: "#{@server_url}/#{@repository}/pull/#{number}", + ) + else + false + end + end + + approval = @recent_approval_checks.fetch([data.author, number]) + next unless approval + + approval_results = @recent_approval_results.fetch(data.author) + approval_results << approval unless approval_results.include?(approval) + approved_pr = approval + break + end + break if approved_pr || @recent_approval_search_complete[data.author] + + page = @recent_approval_search_pages.fetch(data.author, 0) + 1 + cutoff_date = (Time.now.utc - (7 * 24 * 60 * 60)).to_date.iso8601 + query = "repo:#{@repository} is:pr reviewed-by:#{data.author} review:approved updated:>=#{cutoff_date}" + issues = T.cast( + T.cast( + rest( + "#{GitHub::API_URL}/search/issues?#{URI.encode_www_form(q: query, per_page: GitHub::MAX_PER_PAGE, + page:)}", + ), + GitHubPayload, + ).fetch("items"), + GitHubPayloads, + ) + @recent_approval_search_pages[data.author] = page + @recent_approval_issues.fetch(data.author).concat(issues) + @recent_approval_search_complete[data.author] = true if issues.length < GitHub::MAX_PER_PAGE + end + end + data.approved_another_pr_checked = true + data.approved_another_pr = !approved_pr.nil? + data.approved_pr_number = approved_pr&.number.to_s + data.approved_pr_url = approved_pr&.url.to_s + if !exhaustive && !data.approved_another_pr + return finish(data, [ + "@#{data.author} has not approved another Homebrew/brew PR in the last 7 days.", + ]) + end + + if !exhaustive && !data.old_enough_for_approval + return finish(data, [ + "Pull request ##{data.number} was created less than #{HUMAN_REVIEW_WINDOW_HOURS} hours ago.", + ]) + end + + reviews = reviews_for(data.number) + data.reviews_checked = true + data.human_reviews_since_creation = reviews.filter_map do |review| + review_user = T.cast(review.fetch("user"), GitHubPayload) + submitted_at = Time.parse(T.cast(review.fetch("submitted_at"), String)) + next if submitted_at < data.created_at + next if T.cast(review_user.fetch("type"), String) == "Bot" + + "@#{T.cast(review_user.fetch("login"), String)} #{T.cast(review.fetch("state"), String).downcase} " \ + "at #{submitted_at.utc.iso8601}" + end + data.no_human_review_since_creation = data.human_reviews_since_creation.empty? + data.copilot_reviewed = reviews.any? do |review| + review_user = T.cast(review.fetch("user"), GitHubPayload) + T.cast(review_user.fetch("type"), String) == "Bot" && + T.cast(review_user.fetch("login"), String).downcase.include?("copilot") + end + data.already_approved = reviews.any? do |review| + review_user = T.cast(review.fetch("user"), GitHubPayload) + T.cast(review_user.fetch("login"), String) == "github-actions[bot]" && + T.cast(review.fetch("state"), String) == "APPROVED" && + T.cast(review.fetch("commit_id"), String) == data.head_sha + end + if !exhaustive && + (!data.old_enough_for_approval || !data.no_human_review_since_creation || !data.copilot_reviewed || + data.already_approved) + return finish(data, failures_for(data, include_ci: false)) + end + + changed_files = paginated_rest("#{GitHub::API_URL}/repos/#{@repository}/pulls/#{data.number}/files") + data.sensitive_files_checked = true + data.changed_sensitive_files = changed_files.filter_map do |file| + filename = T.cast(file.fetch("filename"), String) + next if SENSITIVE_PATH_PREFIXES.none? { |prefix| filename.start_with?(prefix) } && + SENSITIVE_PATHS.exclude?(filename) + + filename + end + data.sensitive_files_unchanged = data.changed_sensitive_files.empty? + if !exhaustive && !data.sensitive_files_unchanged + return finish(data, + failures_for(data, include_ci: false)) + end + + check_runs = paginated_rest("#{GitHub::API_URL}/repos/#{@repository}/commits/#{data.head_sha}/check-runs") + .flat_map do |page| + T.cast(page.fetch("check_runs"), GitHubPayloads) + end + commit_status = T.cast(rest("#{GitHub::API_URL}/repos/#{@repository}/commits/#{data.head_sha}/status"), + GitHubPayload) + data.ci_checked = true + data.failing_ci_jobs = check_runs.filter_map do |check_run| + status = T.cast(check_run.fetch("status"), String) + conclusion = T.cast(check_run.fetch("conclusion", nil), T.nilable(String)) + next if status == "completed" && !conclusion.nil? && APPROVABLE_CHECK_RUN_CONCLUSIONS.include?(conclusion) + + name = T.cast(check_run.fetch("name"), String) + url = T.cast(check_run.fetch("html_url", nil), T.nilable(String)) + "#{name}: #{status}#{"/#{conclusion}" unless conclusion.nil?}" \ + "#{" (#{url})" unless url.nil?}" + end + data.failing_ci_jobs << "No check runs found." if check_runs.empty? + T.cast(commit_status.fetch("statuses"), GitHubPayloads).each do |status| + next if T.cast(status.fetch("state"), String) == "success" + + context = T.cast(status.fetch("context"), String) + url = T.cast(status.fetch("target_url", nil), T.nilable(String)) + data.failing_ci_jobs << "#{context}: #{T.cast(status.fetch("state"), String)}" \ + "#{" (#{url})" unless url.nil?}" + end + data.ci_passing = data.failing_ci_jobs.empty? + + finish(data, failures_for(data)) + end + + sig { params(data: PullRequestFacts, failure_messages: T::Array[String]).returns(PullRequestFacts) } + def finish(data, failure_messages) + data.requirements_met = data.not_from_fork && + !data.draft && + data.weekday_approval_window && + data.lead_maintainer && + data.approved_another_pr && + data.old_enough_for_approval && + data.no_human_review_since_creation && + data.copilot_reviewed && + data.sensitive_files_unchanged && + data.ci_passing + data.should_approve = data.requirements_met && !data.already_approved + data.failure_messages = failure_messages + puts if @printed_pull_request_summary + @printed_pull_request_summary = true + result = if @event_name == "push" + data.should_approve ? Formatter.success("would approve") : Formatter.error("would not approve") + elsif data.should_approve + Formatter.success("will approve") + else + Formatter.error("will not approve") + end + oh1 "Pull request ##{data.number}: #{data.title}" + puts "- Result: #{result}" + puts "- Author: #{Formatter.identifier("@#{data.author}")}" + puts "- Not from a fork: #{status_label(data.not_from_fork)}" + puts "- Not a draft: #{status_label(!data.draft)}" + puts "- Weekday approval window: #{status_label(data.weekday_approval_window)}" + puts "- Created at: #{data.created_at.utc.iso8601}" + puts "- Created at least #{HUMAN_REVIEW_WINDOW_HOURS} hours ago: #{status_label(data.old_enough_for_approval)}" + lead_maintainer = data.lead_maintainer_checked ? data.lead_maintainer : nil + puts "- Author listed as a lead maintainer in README.md: #{status_label(lead_maintainer)}" + approved_another_pr = data.approved_another_pr_checked ? data.approved_another_pr : nil + puts "- Author approved another Homebrew/brew PR in the last 7 days: #{status_label(approved_another_pr)}" + no_human_review_since_creation = data.reviews_checked ? data.no_human_review_since_creation : nil + puts "- No human review since creation: #{status_label(no_human_review_since_creation)}" + if data.reviews_checked + puts "- Human reviews since creation:" + if data.human_reviews_since_creation.empty? + puts " - #{Formatter.success("none")}" + else + data.human_reviews_since_creation.each { |review| puts " - #{Formatter.warning(review)}" } + end + else + puts "- Human reviews since creation: #{Formatter.warning("not checked")}" + end + puts "- Copilot reviewed: #{status_label(data.reviews_checked ? data.copilot_reviewed : nil)}" + sensitive_files_unchanged = data.sensitive_files_checked ? data.sensitive_files_unchanged : nil + puts "- .github/ and sensitive files unchanged: #{status_label(sensitive_files_unchanged)}" + if data.sensitive_files_checked && !data.changed_sensitive_files.empty? + puts "- Changed .github/ or sensitive files:" + data.changed_sensitive_files.each { |file| puts " - #{Formatter.error(file)}" } + end + puts "- CI passing: #{status_label(data.ci_checked ? data.ci_passing : nil)}" + if data.ci_checked + puts "- Failing CI jobs:" + if data.failing_ci_jobs.empty? + puts " - #{Formatter.success("none")}" + else + data.failing_ci_jobs.each { |job| puts " - #{Formatter.error(job)}" } + end + else + puts "- Failing CI jobs: #{Formatter.warning("not checked")}" + end + already_approved = data.reviews_checked ? data.already_approved : nil + puts "- Already approved by github-actions[bot] for this commit: #{status_label(already_approved)}" + data.failure_messages.each { |failure| puts "- Failure: #{failure}" } + data + end + + sig { params(value: T.nilable(T::Boolean)).returns(String) } + def status_label(value) + return Formatter.warning("not checked") if value.nil? + + value ? Formatter.success("true") : Formatter.error("false") + end + + sig { params(data: PullRequestFacts, include_ci: T::Boolean).returns(T::Array[String]) } + def failures_for(data, include_ci: true) + failures = [] + failures << "Pull request ##{data.number} is from a fork." unless data.not_from_fork + failures << "Pull request ##{data.number} is a draft." if data.draft + unless data.weekday_approval_window + failures << "Stale lead maintainer PR approvals do not run on Saturdays or Sundays." + end + failures << "@#{data.author} is not listed as a lead maintainer in README.md." unless data.lead_maintainer + unless data.approved_another_pr + failures << "@#{data.author} has not approved another Homebrew/brew PR in the last 7 days." + end + unless data.old_enough_for_approval + failures << "Pull request ##{data.number} was created less than #{HUMAN_REVIEW_WINDOW_HOURS} hours ago." + end + unless data.no_human_review_since_creation + failures << "Pull request ##{data.number} has a human review since creation." + end + failures << "Copilot has not reviewed pull request ##{data.number}." unless data.copilot_reviewed + unless data.sensitive_files_unchanged + failures << "Pull request ##{data.number} changes .github/ or other sensitive files: " \ + "#{data.changed_sensitive_files.join(", ")}." + end + failures << "Not all CI jobs are passing for pull request ##{data.number}." if include_ci && !data.ci_passing + if data.already_approved + failures << "github-actions[bot] has already approved pull request ##{data.number} for this commit." + end + failures + end + + sig { params(facts: T::Array[PullRequestFacts]).void } + def summarise(facts) + summary_path = ENV.fetch("GITHUB_STEP_SUMMARY", nil) + return if summary_path.blank? + + File.open(summary_path, "a") do |summary| + summary.puts "## Stale lead maintainer PR approval" + summary.puts + facts.each do |data| + summary.puts "### [##{data.number}](#{data.pr_url})" + summary.puts + summary.puts "- Pull request: [##{data.number}](#{data.pr_url})" + summary.puts "- Author: [@#{data.author}](#{data.author_url})" + summary.puts "- Not from a fork: #{data.not_from_fork}" + summary.puts "- Not a draft: #{!data.draft}" + summary.puts "- Weekday approval window: #{data.weekday_approval_window}" + summary.puts "- Created at: #{data.created_at.utc.iso8601}" + summary.puts "- Created at least #{HUMAN_REVIEW_WINDOW_HOURS} hours ago: " \ + "#{data.old_enough_for_approval}" + summary.puts "- [@#{data.author}](#{data.author_url}) is listed as a lead maintainer in " \ + "[README.md](#{@server_url}/#{@repository}/blob/HEAD/README.md): #{data.lead_maintainer}" + summary.puts "- [@#{data.author}](#{data.author_url}) approved another " \ + "[Homebrew/brew PR](#{@server_url}/#{@repository}/pulls) in the last 7 days: " \ + "#{data.approved_another_pr} (#{approved_pr_link(data)})" + summary.puts "- No human review on [##{data.number}](#{data.pr_url}) since creation: " \ + "#{data.no_human_review_since_creation}" + summary.puts "- Copilot has reviewed [##{data.number}](#{data.pr_url}): " \ + "#{data.copilot_reviewed}" + summary.puts "- `.github/` and sensitive files are unchanged in " \ + "[##{data.number}](#{data.pr_url}): " \ + "#{data.sensitive_files_unchanged}" + summary.puts "- All [CI jobs](#{data.checks_url}) are passing: #{data.ci_passing}" + summary.puts "- Requirements met: #{data.requirements_met}" + summary.puts "- Already approved by [github-actions\\[bot\\]](#{GITHUB_ACTIONS_URL}) for " \ + "`#{data.head_sha}`: #{data.already_approved}" + summary.puts "- Eligible for approval: #{data.should_approve}" + summary.puts "- Approved by this run: #{data.should_approve}" + summary.puts + end + end + end + + sig { params(data: PullRequestFacts).returns(String) } + def approved_pr_link(data) + return "none" if data.approved_pr_number.empty? + + "[##{data.approved_pr_number}](#{data.approved_pr_url})" + end + + sig { params(number: Integer).returns(GitHubPayloads) } + def reviews_for(number) + @reviews[number] ||= paginated_rest("#{GitHub::API_URL}/repos/#{@repository}/pulls/#{number}/reviews") + end + + sig { params(url: T.any(String, URI::Generic), additional_query_params: String).returns(GitHubPayloads) } + def paginated_rest(url, additional_query_params = "") + results = T.let([], GitHubPayloads) + GitHub::API.paginate_rest(url, additional_query_params:) do |result| + page = T.cast(result, GitHubPage) + if page.is_a?(Array) + results.concat(page) + else + results << page + end + end + results + end + + sig { + params( + url: T.any(String, URI::Generic), + data: RequestData, + request_method: Symbol, + ).returns(GitHubResult) + } + def rest(url, data: {}, request_method: :GET) + GitHub::API.open_rest(url, data:, request_method:) + end +end + +StaleLeadMaintainerPrApproval.new.run diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 0d0b1c994dd62..0000000000000 --- a/.github/stale.yml +++ /dev/null @@ -1 +0,0 @@ -_extends: .github diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000000000..b0352df88e439 --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,107 @@ +# This file is synced from the `.github` repository, do not modify it directly. +name: Actionlint + +on: + push: + branches: + - main + - master + pull_request: + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: "actionlint-${{ github.ref }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + +permissions: {} + +jobs: + workflow_syntax: + if: github.repository_owner == 'Homebrew' + runs-on: ubuntu-latest + permissions: + contents: read + container: + image: ghcr.io/homebrew/brew:main + steps: + - name: Set up Homebrew + id: setup-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - uses: Homebrew/actions/cache-homebrew-prefix@main + # Don't cache the prefix for the formulae.brew.sh repository as it won't download its own JSON API files + if: github.repository != 'Homebrew/formulae.brew.sh' + with: + install: actionlint shellcheck zizmor + # Not needed in .github but required for other repositories with multiple uses of cache-homebrew-prefix. + workflow-key: actionlint + + - name: Install tools + if: github.repository == 'Homebrew/formulae.brew.sh' + run: brew install actionlint shellcheck zizmor + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - run: zizmor --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload SARIF file + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + # We can't use the SARIF file when triggered by `merge_group` so we don't upload it. + if: always() && github.event_name != 'merge_group' + with: + name: results.sarif + path: results.sarif + + - name: Set up actionlint + run: | + # In homebrew-core, setting `shell: /bin/bash` prevents shellcheck from running on + # those steps, so let's change them to `shell: bash` temporarily for better linting. + sed -i 's|shell: /bin/bash -x|shell: bash -x|' .github/workflows/*.y*ml + + # In homebrew-core, the JSON matcher needs to be accessible to the container host. + cp "$(brew --repository)/.github/actionlint-matcher.json" "$HOME" + + echo "::add-matcher::$HOME/actionlint-matcher.json" + + - run: actionlint + + upload_sarif: + needs: workflow_syntax + # We want to always upload this even if `actionlint` failed. + # This is only available on public repositories. + if: > + always() && + !contains(fromJSON('["cancelled", "skipped"]'), needs.workflow_syntax.result) && + !github.event.repository.private && + github.event_name != 'merge_group' + runs-on: ubuntu-slim + permissions: + contents: read + security-events: write + steps: + - name: Download SARIF file + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: results.sarif + path: results.sarif + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: zizmor diff --git a/.github/workflows/apidoc.yml b/.github/workflows/apidoc.yml deleted file mode 100644 index 4ed55573ffa1d..0000000000000 --- a/.github/workflows/apidoc.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Generate rubydoc.brew.sh -on: - push: - branches: master -jobs: - apidoc: - if: github.repository == 'Homebrew/brew' - runs-on: ubuntu-latest - steps: - - uses: Homebrew/actions/git-ssh@master - with: - git_user: BrewTestBot - git_email: homebrew-test-bot@lists.sfconservancy.org - key: ${{ secrets.RUBYDOC_DEPLOY_KEY }} - - - name: Set up Ruby - uses: actions/setup-ruby@v1 - with: - version: '>=2.3' - - - name: Build and push API docs - run: | - # clone rubydoc.brew.sh with SSH so we can push back - git clone git@github.com:Homebrew/rubydoc.brew.sh - cd rubydoc.brew.sh - - # clone latest Homebrew/brew - git clone --depth=1 https://github.com/Homebrew/brew - - # run rake to build documentation - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec rake - - # commit and push generated files - git add docs - - if ! git diff --exit-code HEAD -- docs; then - git commit -m 'docs: update from Homebrew/brew push' docs - git fetch - git rebase origin/master - git push - fi diff --git a/.github/workflows/approve-stale-lead-maintainer-prs.yml b/.github/workflows/approve-stale-lead-maintainer-prs.yml new file mode 100644 index 0000000000000..48d0584d829aa --- /dev/null +++ b/.github/workflows/approve-stale-lead-maintainer-prs.yml @@ -0,0 +1,45 @@ +name: Approve stale lead maintainer PRs + +on: + push: + branches: + - approve-stale-lead-maintainer-prs + schedule: + # Run every six hours on weekdays to find stale lead maintainer PRs. + - cron: "17 0,6,12,18 * * 1-5" + workflow_dispatch: + inputs: + pull_request: + description: Pull request number to review + required: true + type: number + +permissions: {} + +env: + HOMEBREW_DEVELOPER: 1 + +defaults: + run: + shell: bash -euo pipefail {0} + +jobs: + approve: + if: github.repository == 'Homebrew/brew' + runs-on: ubuntu-latest + permissions: + checks: read + contents: read + issues: read + pull-requests: write + statuses: read + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.inputs.pull_request || '' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Evaluate stale lead maintainer PRs + run: ./bin/brew ruby -- .github/scripts/approve_stale_lead_maintainer_prs.rb diff --git a/.github/workflows/autogenerated-files.yml b/.github/workflows/autogenerated-files.yml new file mode 100644 index 0000000000000..37f4ccf42d29c --- /dev/null +++ b/.github/workflows/autogenerated-files.yml @@ -0,0 +1,53 @@ +name: Autogenerated files check + +on: + pull_request: + paths: + - .github/workflows/autogenerated-files.yml + - README.md + - completions/** + - docs/Manpage.md + - manpages/brew.1 + +permissions: + contents: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + autogenerated: + runs-on: ubuntu-latest + if: github.repository == 'Homebrew/brew' + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Cache Bundler RubyGems + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ runner.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ runner.os }}-rubygems- + + - name: Check for changes to autogenerated files + id: check + run: | + if brew generate-man-completions + then + echo "This PR modifies autogenerated files!" >&2 + echo "Please ensure their source files are updated and then run the following: + brew generate-man-completions" >&2 + exit 1 + else + exit 0 + fi diff --git a/.github/workflows/check-issues.yml b/.github/workflows/check-issues.yml new file mode 100644 index 0000000000000..3fa6deb7ac8a9 --- /dev/null +++ b/.github/workflows/check-issues.yml @@ -0,0 +1,163 @@ +# This file is synced from the `.github` repository, do not modify it directly. +name: Check issues + +on: + issues: + types: + - opened + - edited + - reopened + +permissions: {} + +defaults: + run: + shell: bash -euo pipefail {0} + +concurrency: + group: "check-issue-${{ github.event.issue.number }}" + cancel-in-progress: true + +jobs: + manage: + # Restrict this write-token workflow to repositories with supported templates. + # The first step also fails if a repository checkout has occurred. + if: >- + contains(fromJSON('["Homebrew/brew", "Homebrew/homebrew-core", "Homebrew/homebrew-cask"]'), github.repository) && + github.event.issue.user.login != 'BrewTestBot' && + github.event.issue.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + # Read the trusted base-branch issue templates and checker through the API; + # write only the issue state needed here. + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + steps: + - name: Verify no checkout + run: | + if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null + then + echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2 + exit 1 + fi + + - name: Write issue body + env: + # Bind issue-controlled strings as environment variables instead of + # interpolating them into shell code. + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + mkdir -p "${RUNNER_TEMP:?}/check-issues/templates" + printf "%s" "${ISSUE_BODY}" >"${RUNNER_TEMP}/check-issues/body" + + - name: Fetch issue templates + run: | + # Validate against the brew, homebrew-core and homebrew-cask templates so + # issues transferred between these repositories are not closed for using a + # sibling repository's (valid) template. + for repo in Homebrew/brew Homebrew/homebrew-core Homebrew/homebrew-cask + do + gh api "repos/${repo}/contents/.github/ISSUE_TEMPLATE?ref=main" \ + --jq '.[] | select(.type == "file" and (.name | test("\\.ya?ml$")) and .name != "config.yml") | .path' | + while IFS= read -r template_path + do + gh api "repos/${repo}/contents/${template_path}?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP}/check-issues/templates/${repo//\//-}-${template_path##*/}" + done + done + + - name: Fetch template checker + run: | + gh api "repos/Homebrew/.github/contents/.github/scripts/check_template.rb?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check_template.rb" + + - name: Check issue template + id: template + run: | + complete_template="$( + ruby "${RUNNER_TEMP:?}/check_template.rb" issue \ + "${RUNNER_TEMP}/check-issues/body" \ + "${RUNNER_TEMP}/check-issues/templates" \ + 2>"${RUNNER_TEMP}/check-issues/missing-checkboxes" + )" + case "${complete_template}" in + true | false) ;; + *) + echo "Unexpected template completion result: ${complete_template}" >&2 + exit 1 + ;; + esac + + echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}" + + - name: Find incomplete template comment + id: comments + if: >- + (github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true') || + (github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false') + run: | + comment_ids="$( + gh api --paginate "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' + )" + if [[ -n "${comment_ids}" ]] + then + echo "has_incomplete_template_comment=true" >>"${GITHUB_OUTPUT:?}" + else + echo "has_incomplete_template_comment=false" >>"${GITHUB_OUTPUT:?}" + fi + + - name: Find issue closer + id: closer + if: >- + github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true' + run: | + closed_by="$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" --jq ".closed_by.login // \"\"")" + echo "closed_by=${closed_by}" >>"${GITHUB_OUTPUT:?}" + + - name: Reopen completed issue + if: >- + github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true' && + steps.closer.outputs.closed_by == 'github-actions[bot]' && + steps.comments.outputs.has_incomplete_template_comment == 'true' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \ + -f state=open + + - name: Comment on incomplete issue + if: >- + github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false' && + steps.comments.outputs.has_incomplete_template_comment != 'true' + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \ + --raw-field body="$( + cat < + Thanks for your issue. This has been closed because it appears to use an incomplete or outdated issue template. + + Please edit this issue to restore the following sections and checkboxes from the current issue template (you do not need to tick the checkboxes): + + $(cat "${RUNNER_TEMP:?}/check-issues/missing-checkboxes") + + This workflow will reopen this issue automatically once they are present. **Do not create a new issue for this.** + COMMENT + )" + + - name: Close incomplete issue + if: >- + github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \ + -f state=closed \ + -f state_reason=not_planned diff --git a/.github/workflows/check-prs.yml b/.github/workflows/check-prs.yml new file mode 100644 index 0000000000000..d301aa62f43d3 --- /dev/null +++ b/.github/workflows/check-prs.yml @@ -0,0 +1,167 @@ +# This file is synced from the `.github` repository, do not modify it directly. +name: Check pull requests + +on: + # `pull_request_target` has a write token, so this workflow must only ever run trusted + # base-branch code and must never checkout or execute pull request head code. + pull_request_target: + types: + - opened + - edited + - reopened + +permissions: {} + +defaults: + run: + shell: bash -euo pipefail {0} + +concurrency: + group: "check-pr-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + manage: + # Restrict this write-token workflow to repositories with supported templates. + # The first step also fails if a repository checkout has occurred. + if: >- + contains(fromJSON('["Homebrew/brew", "Homebrew/homebrew-core", "Homebrew/homebrew-cask"]'), github.repository) && + github.event.pull_request.user.login != 'BrewTestBot' && + github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + # Read the trusted base-branch pull request template and checker through + # the API; write only the issue comment and pull request state needed here. + contents: read + issues: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TEMPLATE_URL: ${{ github.server_url }}/${{ github.repository }}/blob/main/.github/PULL_REQUEST_TEMPLATE.md + steps: + - name: Verify no checkout + run: | + if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null + then + echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2 + exit 1 + fi + + - name: Write pull request body + # Do not add a checkout here. `pull_request_target` has a write token, so this + # step must only use inline trusted code and API responses from `main`. + env: + # Bind PR-controlled strings as environment variables instead of interpolating + # them into shell code. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # This workflow intentionally uses `pull_request_target` so it can close and + # reopen forked pull requests. Keep this self-contained and never execute + # pull request code from this step. + + mkdir -p "${RUNNER_TEMP:?}/check-prs" + printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/check-prs/body" + + - name: Fetch pull request template + run: | + gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check-prs/template" + + - name: Fetch template checker + run: | + gh api "repos/Homebrew/.github/contents/.github/scripts/check_template.rb?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check_template.rb" + + - name: Check pull request template + id: template + run: | + # homebrew-core and homebrew-cask allow a condensed PR body when opened by brew bump. + if [[ "${GITHUB_REPOSITORY}" == "Homebrew/homebrew-core" || + "${GITHUB_REPOSITORY}" == "Homebrew/homebrew-cask" ]] + then + if grep -qE "Created (with|by) \`brew bump(-cask-pr|-formula-pr)?\`" "${RUNNER_TEMP}/check-prs/body" + then + echo "complete_template=true" >>"${GITHUB_OUTPUT:?}" + exit 0 + fi + fi + + complete_template="$( + ruby "${RUNNER_TEMP:?}/check_template.rb" pull-request \ + "${RUNNER_TEMP}/check-prs/body" \ + "${RUNNER_TEMP}/check-prs/template" + )" + case "${complete_template}" in + true | false) ;; + *) + echo "Unexpected template completion result: ${complete_template}" >&2 + exit 1 + ;; + esac + + echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}" + + - name: Find incomplete template comment + id: comments + if: >- + (github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true') || + (github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false') + run: | + comment_ids="$( + gh api --paginate "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' + )" + if [[ -n "${comment_ids}" ]] + then + echo "has_incomplete_template_comment=true" >>"${GITHUB_OUTPUT:?}" + else + echo "has_incomplete_template_comment=false" >>"${GITHUB_OUTPUT:?}" + fi + + - name: Find pull request closer + id: closer + if: >- + github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true' + run: | + closed_by="$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}" --jq ".closed_by.login // \"\"")" + echo "closed_by=${closed_by}" >>"${GITHUB_OUTPUT:?}" + + - name: Reopen completed pull request + if: >- + github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true' && + steps.closer.outputs.closed_by == 'github-actions[bot]' && + steps.comments.outputs.has_incomplete_template_comment == 'true' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/pulls/${PR_NUMBER:?}" \ + -f state=open + + - name: Comment on incomplete pull request + if: >- + github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false' && + steps.comments.outputs.has_incomplete_template_comment != 'true' + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}/comments" \ + --raw-field body="$( + cat < + Thanks for your pull request. This has been closed because it appears to use an incomplete or outdated pull request template. + + Please edit this pull request to fill in the current [pull request template](${PR_TEMPLATE_URL:?}). This workflow will reopen this pull request automatically once the template is complete. **Do not open a new pull request for this.** + COMMENT + )" + + - name: Close incomplete pull request + if: >- + github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/pulls/${PR_NUMBER:?}" \ + -f state=closed diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000000..1779966a5c044 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,38 @@ +name: "CodeQL" + +on: + push: + branches: + - main + - master + pull_request: + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ruby + config: | + paths-ignore: + - Library/Homebrew/vendor + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000000000..068670f49f414 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,34 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: true + cask: true + + - run: brew install-bundler-gems --groups=all + + # install Homebrew formulae we might need + - uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: shellcheck shfmt gh gnu-tar subversion curl + workflow-key: copilot-setup-steps + + # brew tests doesn't like world writable directories + - run: sudo chmod -R g-w,o-w /home/linuxbrew/.linuxbrew/Homebrew diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000000..5682146ae2171 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,300 @@ +name: Docker + +on: + pull_request: + push: + branches: + - main + merge_group: + release: + types: + - published + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +env: + # odeprecated: remove 22.04 image in Homebrew >=6.1 + VERSIONS: '["22.04", "24.04", "26.04"]' + +jobs: + generate-tags: + if: github.repository_owner == 'Homebrew' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.attributes.outputs.matrix }} + tags: ${{ steps.attributes.outputs.tags }} + labels: ${{ steps.attributes.outputs.labels }} + push: ${{ steps.attributes.outputs.push }} + merge: ${{ steps.attributes.outputs.merge }} + core_revision: ${{ steps.attributes.outputs.core_revision }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch origin/HEAD from Git + run: git fetch origin HEAD + + - name: Determine build attributes + id: attributes + run: | + date="$(date --rfc-3339=seconds --utc)" + brew_version="$(git describe --tags --dirty --abbrev=7)" + core_revision="$(git ls-remote https://github.com/Homebrew/homebrew-core refs/heads/main | cut -f1)" + + DELIMITER="END_LABELS_$(uuidgen)" + cat <>"${GITHUB_OUTPUT}" + + { + DELIMITER="END_TAGS_$(uuidgen)" + has_previous= + echo "tags<<${DELIMITER}" + printf '{' + for version in "${!tag_hash[@]}"; do + [[ -n "${has_previous:-}" ]] && printf ',' + printf '"%s": %s' "${version}" "${tag_hash[$version]}" + has_previous=1 + done + echo '}' + echo "${DELIMITER}" + } | tee -a "${GITHUB_OUTPUT}" + + { + DELIMITER="END_PUSH_$(uuidgen)" + has_previous= + echo "push<<${DELIMITER}" + printf '{' + for version in "${!push_hash[@]}"; do + [[ -n "${has_previous:-}" ]] && printf ',' + printf '"%s": %s' "${version}" "${push_hash[$version]}" + has_previous=1 + done + echo '}' + echo "${DELIMITER}" + } | tee -a "${GITHUB_OUTPUT}" + + build: + needs: generate-tags + if: github.repository_owner == 'Homebrew' + name: docker (${{ matrix.arch }} Ubuntu ${{ matrix.version }}) + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + # odeprecated: remove 22.04 image in Homebrew >=5.4 + version: ["22.04", "24.04", "26.04"] + arch: ["x86_64", "arm64"] + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch origin/HEAD from Git + run: git fetch origin HEAD + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + cache-binary: false + + - name: Retrieve build attributes + id: attributes + env: + VERSION: ${{ matrix.version }} + PUSH: ${{ needs.generate-tags.outputs.push }} + run: | + # odeprecated: remove 22.04 image in Homebrew >=6.1 + if [[ "${VERSION}" == "22.04" ]]; then + echo "The homebrew/ubuntu22.04 image is deprecated and will soon be retired. Use homebrew/brew." > .docker-deprecate + fi + + filter="$(printf '.["%s"]' "${VERSION}")" + echo "push=$(jq --raw-output "${filter}" <<<"${PUSH}")" >>"${GITHUB_OUTPUT}" + + - name: Log in to GitHub Packages (github-actions[bot]) + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: github-actions[bot] + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Docker image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + load: true + tags: brew + cache-from: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache-${{ matrix.arch }} + build-args: | + version=${{ matrix.version }} + HOMEBREW_CORE_REVISION=${{ needs.generate-tags.outputs.core_revision }} + labels: ${{ needs.generate-tags.outputs.labels }} + + - name: Set environment variables + if: matrix.version == '22.04' + # odeprecated: remove 22.04 in Homebrew >=6.1 + run: echo "HOMEBREW_GLIBC_TESTING=1" >> "$GITHUB_ENV" + + - name: Run brew test-bot --only-setup + run: docker run --env HOMEBREW_GLIBC_TESTING --rm brew brew test-bot --only-setup + + - name: Log in to GitHub Packages (BrewTestBot) + if: fromJSON(steps.attributes.outputs.push) + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: BrewTestBot + password: ${{ secrets.HOMEBREW_BREW_GITHUB_PACKAGES_TOKEN }} + + - name: Deploy the Docker image by digest + id: digest + if: fromJSON(steps.attributes.outputs.push) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + cache-from: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache-${{ matrix.arch }} + cache-to: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache-${{ matrix.arch }},mode=max + build-args: | + version=${{ matrix.version }} + HOMEBREW_CORE_REVISION=${{ needs.generate-tags.outputs.core_revision }} + labels: ${{ needs.generate-tags.outputs.labels }} + outputs: type=image,name=ghcr.io/homebrew/ubuntu${{ matrix.version }},name-canonical=true,push=true,push-by-digest=true + + - name: Export the Docker image digest + if: fromJSON(steps.attributes.outputs.push) + run: | + mkdir -p "${RUNNER_TEMP}"/digests + echo "${DIGEST#sha256:}" >"${RUNNER_TEMP}/digests/${VERSION}-${ARCH}" + env: + DIGEST: ${{ steps.digest.outputs.digest }} + VERSION: ${{ matrix.version }} + ARCH: ${{ matrix.arch }} + + - name: Upload the Docker image digest + if: fromJSON(steps.attributes.outputs.push) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digest-${{ matrix.version }}-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + + merge: + needs: [generate-tags, build] + if: github.repository_owner == 'Homebrew' && fromJSON(needs.generate-tags.outputs.merge) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.generate-tags.outputs.matrix) }} + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + cache-binary: false + + - name: Download Docker image digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ runner.temp }}/digests + pattern: digest-${{ matrix.version }}-* + merge-multiple: true + + - name: Log in to GitHub Packages (BrewTestBot) + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: BrewTestBot + password: ${{ secrets.HOMEBREW_BREW_GITHUB_PACKAGES_TOKEN }} + + - name: Merge and push Docker image + env: + TAGS: ${{ needs.generate-tags.outputs.tags }} + VERSION: ${{ matrix.version }} + run: | + filter="$(printf '.["%s"].[]' "${VERSION}")" + tag_args=() + while IFS=$'\n' read -r tag; do + [[ -n "${tag}" ]] || continue + tag_args+=("--tag=${tag}") + done <<<"$(jq --raw-output "${filter}" <<<"${TAGS}")" + + image_args=("ghcr.io/homebrew/ubuntu${VERSION}@sha256:$(<"${RUNNER_TEMP}/digests/${VERSION}-x86_64")") + image_args+=("ghcr.io/homebrew/ubuntu${VERSION}@sha256:$(<"${RUNNER_TEMP}/digests/${VERSION}-arm64")") + + attempts=0 + until docker buildx imagetools create "${tag_args[@]}" "${image_args[@]}"; do + attempts=$((attempts + 1)) + if [[ $attempts -ge 3 ]]; then + echo "[$(date -u)] ERROR: Failed after 3 attempts." >&2 + exit 1 + fi + delay=$((2 ** attempts)) + if [[ $delay -gt 15 ]]; then delay=15; fi + echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..." + sleep ${delay} + done diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000000..1733f1d2fe1ee --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,141 @@ +name: Documentation + +on: + push: + branches: + - main + pull_request: + merge_group: + +permissions: + contents: read + pages: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + stable: false + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Vale + run: brew install vale + + - name: Cleanup Homebrew/brew docs + if: github.repository == 'Homebrew/brew' + run: | + # No ignore support (https://github.com/errata-ai/vale/issues/131). + rm -r Library/Homebrew/vendor + + # Generate latest manpage + brew generate-man-completions --no-exit-code + + - name: Run Vale + run: vale docs/ + + - name: Setup Ruby + uses: Homebrew/actions/setup-ruby@main + with: + bundler-cache: true + portable-ruby: true + working-directory: docs + + - name: Check Markdown syntax + working-directory: docs + run: bundle exec rake lint + + - name: Check code blocks conform to our Ruby style guide + run: brew style docs + + - name: Generate formulae.brew.sh API samples + if: github.repository == 'Homebrew/formulae.brew.sh' + working-directory: docs + run: ../script/generate-api-samples.rb --template + + - name: Cache HTML Proofer + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: tmp/.htmlproofer + key: ${{ runner.os }}-htmlproofer + + - name: Build the site + working-directory: docs + run: bundle exec rake build + + - name: Check for broken links + if: github.event_name == 'pull_request' + working-directory: docs + run: bundle exec rake test || bundle exec rake test + + - name: Rebuild the site with YARD + if: github.repository == 'Homebrew/brew' + working-directory: docs + run: bundle exec rake yard build + + - name: Upload pages artifact + if: github.repository == 'Homebrew/brew' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs/_site/ + + deploy: + needs: docs + if: ${{ github.repository == 'Homebrew/brew' && github.ref_name == 'main' }} + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + outputs: + deploy_url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-slim + timeout-minutes: 10 + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + + deploy-issue: + name: Open/close deploy issue + needs: [docs, deploy] + if: ${{ github.repository == 'Homebrew/brew' && always() && github.ref_name == 'main' }} + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + issues: write # for Homebrew/actions/create-or-update-issue + steps: + - name: Open, update, or close deploy issue + uses: Homebrew/actions/create-or-update-issue@main + with: + token: ${{ github.token }} + repository: ${{ github.repository }} + title: docs.brew.sh deployment failed! + body: The most recent [docs.brew.sh deployment failed](${{ env.RUN_URL }}). + labels: deploy failure + update-existing: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + close-existing: ${{ needs.deploy.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: The most recent [docs.brew.sh deployment succeeded](${{ env.RUN_URL }}). Closing issue. diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml new file mode 100644 index 0000000000000..1d4d9ae0afc45 --- /dev/null +++ b/.github/workflows/doctor.yml @@ -0,0 +1,68 @@ +name: brew doctor +on: + pull_request: + paths: + - .github/workflows/doctor.yml + - Library/Homebrew/cmd/doctor.rb + - Library/Homebrew/diagnostic.rb + - Library/Homebrew/extend/os/diagnostic.rb + - Library/Homebrew/extend/os/mac/diagnostic.rb + - Library/Homebrew/os/mac/xcode.rb + +permissions: + contents: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + determine-runners: + runs-on: ubuntu-latest + outputs: + runners: ${{ steps.determine-runners.outputs.runners }} + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Determine runners to use for this job + id: determine-runners + env: + HOMEBREW_MACOS_TIMEOUT: 30 + run: brew determine-test-runners --all-supported + + tests: + needs: determine-runners + strategy: + matrix: + include: ${{ fromJson(needs.determine-runners.outputs.runners) }} + fail-fast: false + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} + defaults: + run: + working-directory: /tmp + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - run: brew test-bot --only-cleanup-before + if: matrix.cleanup + + - run: brew doctor + + - run: brew test-bot --only-cleanup-after + if: always() && matrix.cleanup diff --git a/.github/workflows/reject-conventional-commits.yml b/.github/workflows/reject-conventional-commits.yml new file mode 100644 index 0000000000000..b82a464a53c92 --- /dev/null +++ b/.github/workflows/reject-conventional-commits.yml @@ -0,0 +1,39 @@ +name: Commit Style + +on: + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + reject-conventional-commits: + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 25 + persist-credentials: false + + - name: Check commit titles + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + pattern='^(feat|fix|chore|refactor|perf|ci)(\([^)]*\))?!?:' + violations=() + while IFS= read -r title; do + if [[ "$title" =~ $pattern ]]; then + violations+=("$title") + fi + done < <(git log --format=%s "${BASE_SHA}..${HEAD_SHA}") + if (( ${#violations[@]} > 0 )); then + echo "Conventional commit prefixes are not allowed. Found:" + printf ' - %s\n' "${violations[@]}" + exit 1 + fi + echo "OK — no conventional commit prefixes found." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000..a76711c20d6dd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,323 @@ +name: Release +on: + push: + branches: + - "**" + tags-ignore: + - "**" + paths: + - .github/workflows/release.yml + - Library/Homebrew/utils/macos_user.sh + - package/**/* + workflow_dispatch: + inputs: + tag: + description: "Git tag for the release" + required: true + type: string +env: + PKG_APPLE_DEVELOPER_TEAM_ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }} + HOMEBREW_NO_ANALYTICS_THIS_RUN: 1 + HOMEBREW_NO_ANALYTICS_MESSAGE_OUTPUT: 1 + +permissions: {} + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + build: + if: github.repository_owner == 'Homebrew' && github.actor != 'dependabot[bot]' + runs-on: macos-26 + outputs: + installer_path: "Homebrew-${{ steps.homebrew-version.outputs.version }}.pkg" + env: + TEMPORARY_CERTIFICATE_FILE: "homebrew_developer_id_installer_certificate.p12" + TEMPORARY_KEYCHAIN_FILE: "homebrew_installer_signing.keychain-db" + # Set to the oldest supported version of macOS + HOMEBREW_MACOS_OLDEST_SUPPORTED: "14.0" + permissions: + contents: write # for creating tags and releases + attestations: write # for actions/attest + id-token: write # for actions/attest + steps: + - name: Remove existing API cache (to force update) + run: rm -rvf ~/Library/Caches/Homebrew/api + + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Install Pandoc + uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: pandoc + workflow-key: release + uninstall: true + + - name: Create and unlock temporary macOS keychain + run: | + TEMPORARY_KEYCHAIN_PASSWORD="$(openssl rand -base64 20)" + TEMPORARY_KEYCHAIN_PATH="${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + security create-keychain -p "${TEMPORARY_KEYCHAIN_PASSWORD}" "${TEMPORARY_KEYCHAIN_PATH}" + security set-keychain-settings -l -u -t 21600 "${TEMPORARY_KEYCHAIN_PATH}" + security unlock-keychain -p "${TEMPORARY_KEYCHAIN_PASSWORD}" "${TEMPORARY_KEYCHAIN_PATH}" + + - name: Create temporary certificate file + env: + PKG_APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_BASE64 }} + run: echo -n "${PKG_APPLE_SIGNING_CERTIFICATE_BASE64}" | + base64 --decode --output="${RUNNER_TEMP}/${TEMPORARY_CERTIFICATE_FILE}" + + - name: Import certificate file into macOS keychain + env: + PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD }} + run: security import "${RUNNER_TEMP}/${TEMPORARY_CERTIFICATE_FILE}" + -k "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + -P "${PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD}" + -t cert + -f pkcs12 + -A + + - name: Clean up temporary certificate file + if: ${{ always() }} + run: rm -f "${RUNNER_TEMP}/${TEMPORARY_CERTIFICATE_FILE}" + + - name: Checkout another Homebrew to brew subdirectory + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: brew + fetch-depth: 0 + persist-credentials: false + + - name: Create tag if needed, check and get Homebrew version from Git + id: homebrew-version + env: + TAG: ${{ inputs.tag }} + run: | + if [ -n "${TAG}" ]; then + git -C brew tag "${TAG}" + fi + + homebrew_version=$(git -C brew describe --tags --always) + + if [ -n "${TAG}" ] && [ "${homebrew_version}" != "${TAG}" ]; then + echo "Homebrew version ${homebrew_version} does not match tag ${TAG}" + exit 1 + fi + + echo "version=${homebrew_version}" >> "${GITHUB_OUTPUT}" + + - name: Prepare Homebrew API cache for package + run: | + package_api_cache="${RUNNER_TEMP}/homebrew-api-cache" + rm -rvf ~/Library/Caches/Homebrew/api "${package_api_cache}" + + HOMEBREW_DEVELOPER=1 HOMEBREW_UPDATE_SKIP_BREW=1 brew update --auto-update --verbose + cp -vR ~/Library/Caches/Homebrew/api brew/cache_api + + - name: Check installer Git safety + run: | + # Keep any future privileged repository Git calls aligned with the hardening above. + if grep -nE 'git .* (checkout|reset|clean)\b|git .* tag --list\b' \ + brew/package/scripts/postinstall + then + echo "Privileged Git repository operations must use \`git_no_hooks\`." + exit 1 + fi + + - name: Open macOS keychain + run: security list-keychain -d user -s "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + + - name: Prepare Homebrew installer scripts + run: | + installer_scripts="${RUNNER_TEMP}/homebrew-installer-scripts" + rm -rvf "${installer_scripts}" + mkdir -vp "${installer_scripts}" + cp -vp brew/package/scripts/preinstall \ + brew/package/scripts/postinstall \ + brew/Library/Homebrew/utils/macos_user.sh \ + "${installer_scripts}/" + echo "HOMEBREW_INSTALLER_SCRIPTS=${installer_scripts}" >> "${GITHUB_ENV}" + + - name: Build Homebrew installer component package + env: + HOMEBREW_VERSION: ${{ steps.homebrew-version.outputs.version }} + # Note: `Library/Homebrew/test/support/fixtures/` contains unsigned + # binaries so it needs to be excluded from notarization. + run: pkgbuild --root brew + --scripts "${HOMEBREW_INSTALLER_SCRIPTS}" + --identifier sh.brew.homebrew + --version "${HOMEBREW_VERSION}" + --install-location /opt/homebrew + --filter .DS_Store + --filter "(.*)/Library/Homebrew/test/support/fixtures/" + --min-os-version "${HOMEBREW_MACOS_OLDEST_SUPPORTED}" + --sign "${PKG_APPLE_DEVELOPER_TEAM_ID}" + Homebrew.pkg + + - name: Convert Homebrew license file to RTF + run: (printf "### " && cat brew/LICENSE.txt) | + pandoc --from markdown --standalone --output brew/package/resources/LICENSE.rtf + + - name: Build Homebrew installer product package + env: + HOMEBREW_VERSION: ${{ steps.homebrew-version.outputs.version }} + run: productbuild --resources brew/package/resources + --distribution brew/package/Distribution.xml + --package-path Homebrew.pkg + --sign "${PKG_APPLE_DEVELOPER_TEAM_ID}" + "Homebrew-${HOMEBREW_VERSION}.pkg" + + - name: Clean up temporary macOS keychain + if: ${{ always() }} + run: | + if [[ -f "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" ]] + then + security delete-keychain "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + fi + + - name: Generate build provenance + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: Homebrew-${{ steps.homebrew-version.outputs.version }}.pkg + + - name: Upload installer to GitHub Actions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Homebrew-${{ steps.homebrew-version.outputs.version }}.pkg + path: Homebrew-${{ steps.homebrew-version.outputs.version }}.pkg + test: + needs: build + name: "test (${{matrix.name}})" + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + # Intel + - runner: macos-15-intel + name: macos-15-x86_64 + # Apple Silicon + - runner: macos-15 + name: macos-15-arm64 + - runner: macos-26 + name: macos-26-arm64 + steps: + - name: Download installer from GitHub Actions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "${{ needs.build.outputs.installer_path }}" + + - name: Unset global Git safe directory setting + run: git config --global --unset-all safe.directory + + - name: Remove existing Homebrew installations + run: | + sudo rm -rf brew /{usr/local,opt/homebrew}/{Cellar,Caskroom,Homebrew/Library/Taps} + brew cleanup --prune-prefix + sudo rm -rf /usr/local/{bin/brew,Homebrew} /opt/homebrew /home/linuxbrew + + - name: Zero existing installer logs + run: echo | sudo tee /var/log/install.log + + - name: Install Homebrew from installer package + env: + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: sudo installer -verbose -pkg "${INSTALLER_PATH}" -target / + + - name: Output installer logs + if: ${{ always() }} + run: sudo cat /var/log/install.log + + - name: Check Homebrew version matches tag + env: + TAG: ${{ inputs.tag }} + run: | + homebrew_version=$(brew --version) + if [ -n "${TAG}" ] && [ "${homebrew_version}" != "Homebrew ${TAG}" ]; then + echo "Homebrew version ${homebrew_version} does not match tag ${TAG}" + exit 1 + fi + + - run: brew config + + - run: brew doctor + + - name: Zero existing installer logs (again) + run: echo | sudo tee /var/log/install.log + + - name: Reinstall Homebrew from installer package + env: + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: sudo installer -verbose -pkg "${INSTALLER_PATH}" -target / + + - name: Output installer logs (again) + if: ${{ always() }} + run: sudo cat /var/log/install.log + + - run: brew config + + - run: brew doctor + + upload: + needs: [build, test] + runs-on: macos-26 + permissions: + # To write assets to GitHub release + contents: write + steps: + - name: Checkout Homebrew/brew + if: github.event_name == 'workflow_dispatch' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download installer from GitHub Actions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "${{ needs.build.outputs.installer_path }}" + + - name: Notarize Homebrew installer package + env: + PKG_APPLE_ID_EMAIL: ${{ secrets.PKG_APPLE_ID_EMAIL }} + PKG_APPLE_ID_APP_SPECIFIC_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }} + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: xcrun notarytool submit "${INSTALLER_PATH}" + --team-id "${PKG_APPLE_DEVELOPER_TEAM_ID}" + --apple-id "${PKG_APPLE_ID_EMAIL}" + --password "${PKG_APPLE_ID_APP_SPECIFIC_PASSWORD}" + --wait + + - name: Create draft release + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} + run: | + gh release create "${TAG}" \ + --repo Homebrew/brew \ + --title "${TAG}" \ + --draft \ + --generate-notes \ + --fail-on-no-commits + + - name: Rename installer to unversioned path + env: + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: mv "${INSTALLER_PATH}" Homebrew.pkg + + - name: Upload installer to GitHub release + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: | + VERSION="${INSTALLER_PATH#Homebrew-}" + VERSION="${VERSION%.pkg}" + gh release upload --repo Homebrew/brew "${VERSION}" Homebrew.pkg diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 0000000000000..8fe81e5af81d8 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,118 @@ +name: Update SBOM schema +on: + push: + paths: + - .github/workflows/sbom.yml + branches-ignore: + - main + - master + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + sbom: + if: github.repository == 'Homebrew/brew' + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + - name: Set up commit signing + uses: Homebrew/actions/setup-commit-signing@main + with: + signing_key: ${{ secrets.BREWTESTBOT_SSH_SIGNING_KEY }} + + - name: Update schema data + id: update + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + git fetch origin + + BRANCH="schema-update" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --heads origin "${BRANCH}" + then + git checkout "${BRANCH}" + git checkout "Library/Homebrew/data/schemas" + else + git checkout --no-track -B "${BRANCH}" origin/HEAD + fi + + # Intentionally tracking 2.3.x to match what we output in sbom.rb. 3.0 also doesn't have a JSON Schema. + # Note: this is a 2.3.1 development branch - not a 2.3.1 tag. It contains bugfixes compared to 2.3.0. + curl --location --output Library/Homebrew/data/schemas/sbom.json https://raw.githubusercontent.com/spdx/spdx-spec/support/v2.3.1/schemas/spdx-schema.json + # https://github.com/spdx/spdx-spec/pull/1029 + sed -i -e 's|\(2019-09/schema\)#|\1|' Library/Homebrew/data/schemas/sbom.json + + if ! git diff --exit-code Library/Homebrew/data/schemas + then + git add "Library/Homebrew/data/schemas" + git commit -m "data/schemas: update schema data." -m "Autogenerated by [a scheduled GitHub Action](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sbom.yml)." + + echo "committed=true" >> "$GITHUB_OUTPUT" + PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state" || true)" + if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] + then + echo "pull_request=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Push commits + if: steps.update.outputs.committed == 'true' + uses: Homebrew/actions/git-try-push@main + with: + token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + branch: ${{ steps.update.outputs.branch }} + force: true + origin_branch: "HEAD" + + - name: Open a pull request + if: steps.update.outputs.pull_request == 'true' + run: gh pr create --fill + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + issue: + needs: sbom + if: always() && github.event_name == 'schedule' && github.repository == 'Homebrew/brew' + runs-on: ubuntu-slim + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + permissions: + # To create or update issues + issues: write + steps: + - name: Open, update, or close schema issue + uses: Homebrew/actions/create-or-update-issue@main + with: + title: Failed to update SBOM schema + body: > + The SBOM schema workflow [failed](${{ env.RUN_URL }}). No SBOM schema was updated. + labels: bug + update-existing: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + close-existing: ${{ needs.sbom.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: > + The SBOM schema workflow [succeeded](${{ env.RUN_URL }}). Closing this issue. diff --git a/.github/workflows/sorbet.yml b/.github/workflows/sorbet.yml new file mode 100644 index 0000000000000..78427b0f54d7d --- /dev/null +++ b/.github/workflows/sorbet.yml @@ -0,0 +1,138 @@ +name: Update Sorbet files + +on: + pull_request: + paths: + - Library/Homebrew/dev-cmd/typecheck.rb + - Library/Homebrew/sorbet/** + - "!Library/Homebrew/sorbet/rbi/**" + push: + paths: + - .github/workflows/sorbet.yml + branches-ignore: + - main + - master + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + tapioca: + if: github.repository == 'Homebrew/brew' + runs-on: macos-26 + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + if: github.event_name != 'pull_request' + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + - name: Set up commit signing + if: github.event_name != 'pull_request' + uses: Homebrew/actions/setup-commit-signing@main + with: + signing_key: ${{ secrets.BREWTESTBOT_SSH_SIGNING_KEY }} + + - name: Update RBI files + id: update + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]] + then + git fetch origin + + BRANCH="sorbet-files-update" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --heads origin "${BRANCH}" + then + git checkout "${BRANCH}" + git checkout "Library/Homebrew/sorbet" + else + git checkout --no-track -B "${BRANCH}" origin/HEAD + fi + fi + + brew typecheck --update --suggest-typed + + - name: Commit changes + id: commit + if: github.event_name != 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + if ! git diff --stat --exit-code "Library/Homebrew/sorbet" + then + git add "Library/Homebrew/sorbet" + git commit -m "sorbet: Update RBI files." \ + -m "Autogenerated by the [sorbet](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sorbet.yml) workflow." + + if ! git diff --stat --exit-code "Library/Homebrew" + then + git add "Library/Homebrew/" + git commit -m "sorbet: Autobump sigils via Spoom" \ + -m "Autogenerated by the [sorbet](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sorbet.yml) workflow." + fi + + echo "committed=true" >> "$GITHUB_OUTPUT" + PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state" || true)" + if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] + then + echo "pull_request=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Push commits + if: steps.commit.outputs.committed == 'true' + uses: Homebrew/actions/git-try-push@main + with: + token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + branch: ${{ steps.update.outputs.branch }} + force: true + origin_branch: "HEAD" + + - name: Open a pull request + if: steps.commit.outputs.pull_request == 'true' + run: gh pr create --fill + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + issue: + needs: tapioca + if: always() && github.event_name == 'schedule' && github.repository == 'Homebrew/brew' + runs-on: ubuntu-slim + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + permissions: + # To create or update issues + issues: write + steps: + - name: Open, update, or close Sorbet issue + uses: Homebrew/actions/create-or-update-issue@main + with: + title: Failed to update RBI files + body: > + The Sorbet workflow [failed](${{ env.RUN_URL }}). No RBI files were updated. + labels: bug + update-existing: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + close-existing: ${{ needs.tapioca.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: > + The Sorbet workflow [succeeded](${{ env.RUN_URL }}). Closing this issue. diff --git a/.github/workflows/spdx.yml b/.github/workflows/spdx.yml new file mode 100644 index 0000000000000..df5e0153541d8 --- /dev/null +++ b/.github/workflows/spdx.yml @@ -0,0 +1,112 @@ +name: Update SPDX license data +on: + push: + paths: + - .github/workflows/spdx.yml + branches-ignore: + - main + - master + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + spdx: + if: github.repository == 'Homebrew/brew' + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + - name: Set up commit signing + uses: Homebrew/actions/setup-commit-signing@main + with: + signing_key: ${{ secrets.BREWTESTBOT_SSH_SIGNING_KEY }} + + - name: Update SPDX license data + id: update + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + git fetch origin + + BRANCH="spdx-update" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --heads origin "${BRANCH}" + then + git checkout "${BRANCH}" + git checkout "Library/Homebrew/data/spdx" + else + git checkout --no-track -B "${BRANCH}" origin/HEAD + fi + + if brew update-license-data + then + git add "Library/Homebrew/data/spdx" + git commit -m "spdx: update license data." -m "Autogenerated by [a scheduled GitHub Action](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/spdx.yml)." + + echo "committed=true" >> "$GITHUB_OUTPUT" + PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state" || true)" + if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] + then + echo "pull_request=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Push commits + if: steps.update.outputs.committed == 'true' + uses: Homebrew/actions/git-try-push@main + with: + token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + branch: ${{ steps.update.outputs.branch }} + force: true + origin_branch: "HEAD" + + - name: Open a pull request + if: steps.update.outputs.pull_request == 'true' + run: gh pr create --fill + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + issue: + needs: spdx + if: always() && github.event_name == 'schedule' && github.repository == 'Homebrew/brew' + runs-on: ubuntu-slim + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + permissions: + # To create or update issues + issues: write + steps: + - name: Open, update, or close SPDX issue + uses: Homebrew/actions/create-or-update-issue@main + with: + title: Failed to update SPDX license data + body: > + The SPDX license data workflow [failed](${{ env.RUN_URL }}). No SPDX license data was updated. + labels: bug + update-existing: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + close-existing: ${{ needs.spdx.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: > + The SPDX license data workflow [succeeded](${{ env.RUN_URL }}). Closing this issue. diff --git a/.github/workflows/sponsors-maintainers-man-completions.yml b/.github/workflows/sponsors-maintainers-man-completions.yml new file mode 100644 index 0000000000000..872e26cfcbf79 --- /dev/null +++ b/.github/workflows/sponsors-maintainers-man-completions.yml @@ -0,0 +1,162 @@ +name: Update sponsors, maintainers, manpage and completions + +on: + push: + branches: + - main + - master + paths: + - .github/workflows/sponsors-maintainers-man-completions.yml + - README.md + - Library/Homebrew/cmd/** + - Library/Homebrew/dev-cmd/** + - Library/Homebrew/completions/** + - Library/Homebrew/manpages/** + - Library/Homebrew/cli/parser.rb + - Library/Homebrew/completions.rb + - Library/Homebrew/env_config.rb + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + updates: + runs-on: ubuntu-latest + if: github.repository == 'Homebrew/brew' + steps: + - name: Setup Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + - name: Set up commit signing + uses: Homebrew/actions/setup-commit-signing@main + with: + signing_key: ${{ secrets.BREWTESTBOT_SSH_SIGNING_KEY }} + + - name: Cache Bundler RubyGems + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ runner.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ runner.os }}-rubygems- + + - name: Update sponsors, maintainers, manpage and completions + id: update + run: | + git fetch origin + + if [[ -n "$GITHUB_REF_NAME" && "$GITHUB_REF_NAME" != "master" && "$GITHUB_REF_NAME" != "main" ]] + then + BRANCH="$GITHUB_REF_NAME" + else + BRANCH=sponsors-maintainers-man-completions + fi + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --heads origin "${BRANCH}" + then + git checkout --force "${BRANCH}" + git checkout "README.md" \ + "docs/Manpage.md" \ + "manpages/brew.1" \ + "completions" + else + git checkout --force --no-track -B "${BRANCH}" origin/HEAD + fi + + if brew update-sponsors + then + git add "README.md" + git commit -m "Update sponsors." \ + -m "Autogenerated by the [sponsors-maintainers-man-completions](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sponsors-maintainers-man-completions.yml) workflow." + COMMITTED=true + fi + + if brew update-maintainers + then + git add "README.md" \ + "docs/Manpage.md" \ + "manpages/brew.1" + git commit -m "Update maintainers." \ + -m "Autogenerated by the [sponsors-maintainers-man-completions](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sponsors-maintainers-man-completions.yml) workflow." + COMMITTED=true + fi + + if brew generate-man-completions + then + git add "README.md" \ + "docs/Manpage.md" \ + "manpages/brew.1" \ + "completions" + git commit -m "Update manpage and completions." \ + -m "Autogenerated by the [sponsors-maintainers-man-completions](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/sponsors-maintainers-man-completions.yml) workflow." + COMMITTED=true + fi + + if [[ -n "${COMMITTED-}" ]] + then + echo "committed=true" >> "$GITHUB_OUTPUT" + PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state" || true)" + if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] + then + echo "pull_request=true" >> "$GITHUB_OUTPUT" + fi + fi + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_BREW_UPDATE_SPONSORS_MAINTAINERS_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + - name: Push commits + if: steps.update.outputs.committed == 'true' + uses: Homebrew/actions/git-try-push@main + with: + token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + branch: ${{ steps.update.outputs.branch }} + force: true + + - name: Open a pull request + if: steps.update.outputs.pull_request == 'true' + run: gh pr create --fill + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + issue: + needs: updates + if: always() && github.event_name == 'schedule' && github.repository == 'Homebrew/brew' + runs-on: ubuntu-slim + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + permissions: + # To create or update issues + issues: write + steps: + - name: Open, update, or close sponsors, maintainers, manpage and completions issue + uses: Homebrew/actions/create-or-update-issue@main + with: + title: Failed to update sponsors, maintainers, manpage and completions + body: > + The sponsors, maintainers, manpage and completions workflow [failed](${{ env.RUN_URL }}). No sponsors, maintainers, manpage and completions were updated. + labels: bug + update-existing: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + close-existing: ${{ needs.updates.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: > + The sponsors, maintainers, manpage and completions workflow [succeeded](${{ env.RUN_URL }}). Closing this issue. diff --git a/.github/workflows/stale-issues-and-prs.yml b/.github/workflows/stale-issues-and-prs.yml new file mode 100644 index 0000000000000..b03313ba1e97d --- /dev/null +++ b/.github/workflows/stale-issues-and-prs.yml @@ -0,0 +1,81 @@ +# This file is synced from the `.github` repository, do not modify it directly. +name: Manage stale issues and pull requests + +on: + push: + paths: + - .github/workflows/stale-issues-and-prs.yml + branches-ignore: + - dependabot/** + schedule: + # Once every day at midnight UTC + - cron: "0 0 * * *" + issue_comment: + +permissions: {} + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: stale-issues-and-prs + cancel-in-progress: ${{ github.event_name != 'issue_comment' }} + +jobs: + stale: + if: > + github.repository_owner == 'Homebrew' && ( + github.event_name != 'issue_comment' || ( + contains(github.event.issue.labels.*.name, 'stale') || + contains(github.event.pull_request.labels.*.name, 'stale') + ) + ) + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale Issues and Pull Requests + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 21 + days-before-close: 7 + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + exempt-issue-labels: "help wanted" + delete-branch: true + + bump-pr-stale: + if: > + github.repository_owner == 'Homebrew' && ( + github.event_name != 'issue_comment' || ( + contains(github.event.issue.labels.*.name, 'stale') || + contains(github.event.pull_request.labels.*.name, 'stale') + ) + ) + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale `bump-formula-pr` and `bump-cask-pr` Pull Requests + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 2 + days-before-close: 1 + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + any-of-labels: "bump-formula-pr,bump-cask-pr" + delete-branch: true diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 0000000000000..e52f63b86fb16 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,84 @@ +# This file is synced from the `.github` repository, do not modify it directly. +name: Manage stale issues + +on: + push: + paths: + - .github/workflows/stale-issues.yml + branches-ignore: + - dependabot/** + schedule: + # Once every day at midnight UTC + - cron: "0 0 * * *" + issue_comment: + +permissions: {} + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: stale-issues + cancel-in-progress: ${{ github.event_name != 'issue_comment' }} + +jobs: + stale: + if: > + github.repository_owner == 'Homebrew' && ( + github.event_name != 'issue_comment' || ( + contains(github.event.issue.labels.*.name, 'stale') || + contains(github.event.pull_request.labels.*.name, 'stale') + ) + ) + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale Issues and Pull Requests + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 21 + days-before-close: 7 + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + exempt-issue-labels: "gsoc-outreachy,help wanted,in progress" + exempt-pr-labels: "gsoc-outreachy,help wanted,in progress" + delete-branch: true + + bump-pr-stale: + if: > + github.repository_owner == 'Homebrew' && ( + github.event_name != 'issue_comment' || ( + contains(github.event.issue.labels.*.name, 'stale') || + contains(github.event.pull_request.labels.*.name, 'stale') + ) + ) + runs-on: ubuntu-slim + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale `bump-formula-pr` and `bump-cask-pr` Pull Requests + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: 2 + days-before-close: 1 + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. To keep this + pull request open, add a `help wanted` or `in progress` label. + exempt-pr-labels: "help wanted,in progress" + any-of-labels: "bump-formula-pr,bump-cask-pr" + delete-branch: true diff --git a/.github/workflows/sync-default-branches.yml b/.github/workflows/sync-default-branches.yml new file mode 100644 index 0000000000000..8a792f76806ff --- /dev/null +++ b/.github/workflows/sync-default-branches.yml @@ -0,0 +1,64 @@ +name: Sync default branches + +on: + push: + branches: + - main + - master + pull_request: + paths: + - .github/workflows/sync-default-branches.yml + +permissions: {} + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: "sync-default-branches-${{ github.ref }}" + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-slim + permissions: + contents: write + steps: + - name: Configure Git user + uses: Homebrew/actions/git-user-config@main + with: + username: github-actions[bot] + + - name: Determine source and target branches + id: branches + run: | + if [[ "${GITHUB_REF_NAME}" == "main" ]]; then + target="master" + source="main" + else + target="main" + source="master" + fi + echo "target=${target}" >> "$GITHUB_OUTPUT" + echo "source=${source}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + persist-credentials: true + + - name: Get target SHA + id: sha + run: | + TARGET_SHA=$(git ls-remote origin "refs/heads/${SOURCE_BRANCH}" | cut -f1) + echo "target=${TARGET_SHA}" >> "$GITHUB_OUTPUT" + env: + SOURCE_BRANCH: ${{ steps.branches.outputs.source }} + + - name: Push target branch + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + run: git push origin "${TARGET_SHA}:refs/heads/${TARGET_BRANCH}" --force + env: + TARGET_SHA: ${{ steps.sha.outputs.target }} + TARGET_BRANCH: ${{ steps.branches.outputs.target }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 511700200c878..d4d8771db4327 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,151 +1,513 @@ -name: GitHub Actions CI +name: CI + on: push: - branches: master - pull_request: [] + branches: + - main + - master + pull_request: + merge_group: + +permissions: + contents: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_SORBET_RECURSIVE: 1 + HOMEBREW_VERIFY_ATTESTATIONS: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: "${{ github.ref }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + syntax: + if: github.repository_owner == 'Homebrew' + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Cache Bundler RubyGems + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ runner.os }}-rubygems-syntax-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ runner.os }}-rubygems-syntax- + + - name: Install Bundler RubyGems + run: brew install-bundler-gems --groups=style,typecheck + + - name: Install actionlint, shellcheck and shfmt + uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: actionlint shellcheck shfmt + workflow-key: tests-syntax + + - name: Cache style cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/Homebrew/style + key: syntax-style-cache-${{ github.sha }} + restore-keys: syntax-style-cache- + + - run: brew style + + - run: brew typecheck + + tap-syntax: + name: tap syntax + needs: syntax + if: github.repository_owner == 'Homebrew' + runs-on: macos-26 + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: true + cask: true + + - name: Cache Bundler RubyGems + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ runner.os }}-rubygems-tap-syntax-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ runner.os }}-rubygems-tap-syntax- + + - name: Install Bundler RubyGems + run: brew install-bundler-gems --groups=style,typecheck + + - name: Cache style cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Caches/Homebrew/style + key: tap-syntax-style-cache-${{ github.sha }} + restore-keys: tap-syntax-style-cache- + + - name: Run brew style on official taps + run: brew style homebrew/core homebrew/cask + + - name: Run brew typecheck on official taps + run: brew typecheck homebrew/core homebrew/cask + + formula-audit: + name: formula audit + needs: syntax + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + runs-on: ubuntu-latest + container: + image: ghcr.io/homebrew/brew:main + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: true + cask: false + + - name: Run brew readall on homebrew/core + run: brew readall --os=all --arch=all --aliases homebrew/core + + - name: Run brew audit --skip-style on homebrew/core + run: brew audit --skip-style --except=version --tap=homebrew/core + + - name: Generate formula API + run: brew generate-formula-api --dry-run + + cask-audit: + name: cask audit + needs: syntax + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + runs-on: macos-26 + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: true + cask: true + + - name: Run brew readall on all casks + run: brew readall --os=all --arch=all homebrew/cask + + - name: Run brew audit --skip-style on homebrew/cask + run: | + brew audit --skip-style --except=version --tap=homebrew/cask + + - name: Generate cask API + run: brew generate-cask-api --dry-run + + vendored-gems: + name: vendored gems + needs: syntax + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + # Can't cache this because we need to check that it doesn't fail the + # "uncommitted RubyGems" step with a cold cache. + - name: Install Bundler RubyGems + run: brew install-bundler-gems --groups=all + + - name: Check for uncommitted RubyGems + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + git diff --stat --exit-code Library/Homebrew/vendor/bundle + if [ -n "$(git status --porcelain -- Library/Homebrew/vendor/bundle)" ]; then + echo "Untracked or unstaged files found:" + git status --porcelain -- Library/Homebrew/vendor/bundle + exit 1 + fi + + update-test: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} + needs: syntax + if: github.event_name != 'push' + strategy: + fail-fast: false + matrix: + include: + - name: update-test (Linux) + runs-on: ubuntu-latest + - name: update-test (macOS) + runs-on: macos-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Run brew update-tests + run: | + brew update-test + brew update-test --to-tag + brew update-test --commit=HEAD + tests: - runs-on: ${{ matrix.os }} + name: ${{ matrix.name }} + needs: syntax + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 30 + permissions: + contents: read + code-quality: write + pull-requests: read strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macOS-latest] + include: + - name: tests (online) + test-flags: --online --coverage --profile 3 + runs-on: ubuntu-latest + - name: tests (generic OS) + test-flags: --generic --coverage --profile 3 + runs-on: ubuntu-latest + - name: tests (Linux) + test-flags: --coverage --profile 3 + runs-on: ubuntu-24.04 + - name: tests (macOS) + test-flags: --coverage --profile 3 + runs-on: macos-26 steps: - - name: Set up Git repository - uses: actions/checkout@v1 - - - name: Set up Homebrew - run: | - if [ "$RUNNER_OS" = "Linux" ]; then - HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew - sudo mkdir -p /home/linuxbrew/.linuxbrew - cd .. - sudo mv "brew" "$HOMEBREW_REPOSITORY" - sudo ln -s "$HOMEBREW_REPOSITORY" "brew" - cd /home/linuxbrew/.linuxbrew - sudo mkdir -p bin etc include lib opt sbin share var/homebrew/linked Cellar - sudo ln -s ../Homebrew/bin/brew /home/linuxbrew/.linuxbrew/bin/ - sudo chown -R "$USER" /home/linuxbrew - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - - # Install taps needed for 'brew tests' - export HOMEBREW_NO_AUTO_UPDATE=1 - brew tap homebrew/bundle - else - HOMEBREW_REPOSITORY="$(brew --repo)" - mv "$HOMEBREW_REPOSITORY/Library/Taps" "$PWD/Library" - sudo rm -rf "$HOMEBREW_REPOSITORY" - sudo ln -s "$PWD" "$HOMEBREW_REPOSITORY" - brew update-reset Library/Taps/homebrew/homebrew-core - - # Install taps needed for 'brew tests' - export HOMEBREW_NO_AUTO_UPDATE=1 - brew tap homebrew/cask - brew tap homebrew/bundle - brew tap homebrew/services - fi - - - name: Run brew config - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew config - - - name: Run brew doctor - run: | - if [ "$RUNNER_OS" = "Linux" ]; then - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin" - - # Cleanup some Linux `brew doctor` failures - sudo rm -rf /usr/local/include/node/ - else - # Allow Xcode to be outdated - export HOMEBREW_GITHUB_ACTIONS=1 - fi - brew doctor - - - name: Install Bundler RubyGems - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew install-bundler-gems - - # Check for uncommitted gems - git -C $(brew --repo) diff --stat --exit-code Library/Homebrew/vendor/bundle/ruby - - if [ "$RUNNER_OS" = "Linux" ]; then - # Fix permissions for 'brew tests' - sudo chmod -R g-w,o-w /home/linuxbrew /home/runner /opt - fi - - - name: Run brew tests - run: | + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + # We only test needs_homebrew_core tests on macOS because + # homebrew/core is not available by default on GitHub-hosted Ubuntu + # runners, and it's expensive to tap it. + core: ${{ runner.os == 'macOS' }} + cask: false + + - name: Cache Bundler RubyGems + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ matrix.runs-on }}-tests-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ matrix.runs-on }}-tests-rubygems- + + - run: brew config + + - name: Install Bundler RubyGems + run: brew install-bundler-gems --groups=all + + - name: Create parallel test log directory + run: mkdir tests + + - name: Cache parallel tests log + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: tests + key: ${{ runner.os }}-${{ matrix.test-flags }}-parallel_runtime_rspec-${{ github.sha }} + restore-keys: ${{ runner.os }}-${{ matrix.test-flags }}-parallel_runtime_rspec- + + - name: Install brew tests --online dependencies + if: matrix.name == 'tests (online)' + uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: curl subversion + workflow-key: tests-tests-online + + - name: Install brew tests macOS dependencies + if: runner.os != 'Linux' + uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: subversion gnupg + workflow-key: tests-tests-macos + uninstall: true + # brew tests doesn't like world writable directories - umask 022 - - # set variables for coverage reporting - export HOMEBREW_CI_BUILD_NUMBER="$GITHUB_REF" - export HOMEBREW_CI_BRANCH="$HEAD_GITHUB_REF" - export HOMEBREW_GITHUB_REPOSITORY="$GITHUB_REPOSITORY" - - # don't bother running all tests on both platforms (for speed) - if [ "$RUNNER_OS" = "Linux" ]; then - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin" - brew tests --no-compat --online - brew tests --generic --online - brew tests --online - else - brew tests --online --coverage - fi - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # set variables for coverage reporting - HOMEBREW_GITHUB_ACTIONS: 1 - HOMEBREW_CI_NAME: github-actions - HOMEBREW_COVERALLS_REPO_TOKEN: 3F6U6ZqctoNJwKyREremsqMgpU3qYgxFk - - # These cannot be queried at the macOS level on GitHub Actions. - HOMEBREW_LANGUAGES: en-GB - - - name: Run brew style - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew style --display-cop-names - - - name: Run brew man - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew man --fail-if-changed - - - name: Run brew update-tests - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - git config --global user.name "BrewTestBot" - git config --global user.email "homebrew-test-bot@lists.sfconservancy.org" - brew update-test - brew update-test --to-tag - brew update-test --commit=HEAD - if: github.event_name == 'pull_request' - - - name: Run brew readall - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew readall --aliases - - - name: Run vale for docs linting - run: | - export PATH="/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - brew install vale - vale $(brew --repo)/docs/ - - - name: Build Docker image - run: | - docker pull homebrew/brew - docker-compose -f Dockerfile.yml build sut - if: matrix.os == 'ubuntu-latest' - - - name: Run brew test-bot - run: | - if [ "$RUNNER_OS" = "Linux" ]; then - docker-compose -f Dockerfile.yml run --rm -v $GITHUB_WORKSPACE:/tmp/test-bot sut - else - brew test-bot - fi + - name: Cleanup permissions + if: runner.os == 'Linux' + run: sudo chmod -R g-w,o-w /home/linuxbrew/.linuxbrew/Homebrew + + - name: Run brew tests + if: github.event_name == 'pull_request' || matrix.name != 'tests (online)' + run: brew tests ${{ matrix.test-flags }} + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # These cannot be queried at the macOS level on GitHub Actions. + HOMEBREW_LANGUAGES: en-GB + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Get RSpec JUnit XML filenames + id: junit_xml + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + mkdir -p Library/Homebrew/test/junit + filenames=$(find Library/Homebrew/test/junit -name 'rspec*.xml' -print | tr '\n' ',') + echo "filenames=${filenames%,}" >> "$GITHUB_OUTPUT" + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + if: github.event_name == 'pull_request' || matrix.name != 'tests (online)' + with: + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + files: ${{ steps.junit_xml.outputs.filenames }} + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + if: github.event_name == 'pull_request' || matrix.name != 'tests (online)' + with: + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + files: Library/Homebrew/test/coverage/coverage.xml + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + + test-bot: + name: ${{ matrix.name }} + needs: syntax + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + runs-on: ${{ matrix.runs-on }} + container: + image: ${{ matrix.container }} + options: ${{ matrix.container != '' && '--privileged' }} + strategy: + fail-fast: false + matrix: + include: + - name: test-bot (Linux arm64) + runs-on: ubuntu-24.04-arm + container: ghcr.io/homebrew/ubuntu24.04:latest + - name: test-bot (Linux x86_64) + runs-on: ubuntu-latest + container: ghcr.io/homebrew/ubuntu24.04:main + - name: test-bot (Linux containerless) + runs-on: ubuntu-latest + # Use older Ubuntu for testing Homebrew's glibc and gcc support. + - name: test-bot (Linux Homebrew glibc) + runs-on: ubuntu-latest + container: ubuntu:22.04 + - name: test-bot (macOS x86_64) + runs-on: macos-15-intel + - name: test-bot (macOS arm64) + runs-on: macos-26 + env: + HOMEBREW_TEST_BOT_ANALYTICS: 1 + steps: + - name: Install Homebrew and Homebrew's dependencies + # All other images are built from our Homebrew Dockerfile. + # This is the only one that needs to be set up manually. + if: matrix.container == 'ubuntu:22.04' + env: + DEBIAN_FRONTEND: noninteractive + run: | + # Slimmed down version from the Homebrew Dockerfile + apt-get update + apt-get install -y --no-install-recommends \ + bzip2 \ + ca-certificates \ + curl \ + file \ + g++ \ + git-core \ + jq \ + less \ + locales \ + make \ + netbase \ + patch \ + procps \ + sudo \ + uuid-runtime \ + tzdata + + # Install Homebrew + mkdir -p /home/linuxbrew/.linuxbrew/bin + # Don't do shallow clone or it's unshallowed by "Set up Homebrew" + git clone https://github.com/Homebrew/brew.git /home/linuxbrew/.linuxbrew/Homebrew + cd /home/linuxbrew/.linuxbrew/bin + ln -s ../Homebrew/bin/brew brew + echo "/home/linuxbrew/.linuxbrew/bin" >>"$GITHUB_PATH" + + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: true + cask: false + + - uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: gnu-tar + workflow-key: test-bot + uninstall: true + + - name: Setup environment variables + if: matrix.name == 'test-bot (Linux Homebrew glibc)' + run: | + # Set environment variables to bypass `brew doctor` failures and disable + # recursive Sorbet runtime checks for speed on Tier >=2 configurations + echo "HOMEBREW_GLIBC_TESTING=1" >> "$GITHUB_ENV" + echo "HOMEBREW_SORBET_RECURSIVE=0" >> "$GITHUB_ENV" + + - run: brew test-bot --only-cleanup-before + + - run: brew test-bot --only-setup + + - run: brew test-bot --only-formulae --only-json-tab --test-default-formula + + bundle-and-services: + name: ${{ matrix.name }} + needs: syntax + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - name: bundle and services (Linux) + runs-on: ubuntu-latest + - name: bundle and services (macOS) + runs-on: macos-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - run: brew test-bot --only-cleanup-before + + - name: Run brew bundle and brew services integration tests + run: | + cat <> Brewfile + brew "git" + brew "memcached", restart_service: true + brew "postgresql@15", restart_service: true + cask "rectangle" + cask "1password-cli" + cask "firefox" + # VSCode cask is not available on Linux. + vscode "shopify.ruby-lsp" if OS.mac? + EOS + + brew bundle check && exit 1 + brew bundle check --verbose && exit 1 + brew bundle list + brew bundle --verbose + brew bundle --upgrade-formulae=memcached,postgresql@15 + brew bundle upgrade + brew bundle env | grep PATH | grep git + brew bundle env --install + brew bundle exec --services true + brew bundle dump --force + brew services list + brew services info memcached postgresql@15 + brew services info --json memcached postgresql@15 + brew services cleanup + brew bundle cleanup --force + + analytics: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - name: analytics (Linux) + runs-on: ubuntu-latest + - name: analytics (macOS) + runs-on: macos-latest + needs: syntax + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: ${{ steps.set-up-homebrew.outputs.repository-path }}/Library/Homebrew/formula-analytics/.python-version + + - name: Cache Homebrew Bundler RubyGems + id: cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.set-up-homebrew.outputs.gems-path }} + key: ${{ runner.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} + restore-keys: ${{ runner.os }}-rubygems- + + - name: Install Homebrew Bundler RubyGems + if: steps.cache.outputs.cache-hit != 'true' + run: brew install-bundler-gems + + - run: brew formula-analytics --setup + + - run: brew formula-analytics --install --json --days-ago=2 + if: github.event.pull_request.head.repo.fork == false && (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + env: + HOMEBREW_INFLUXDB_TOKEN: ${{ secrets.HOMEBREW_INFLUXDB_READ_TOKEN }} + + - run: brew generate-analytics-api + if: github.event.pull_request.head.repo.fork == false && (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + env: + HOMEBREW_INFLUXDB_TOKEN: ${{ secrets.HOMEBREW_INFLUXDB_READ_TOKEN }} diff --git a/.github/workflows/vendor-gems.yml b/.github/workflows/vendor-gems.yml new file mode 100644 index 0000000000000..42cfe12f7cc81 --- /dev/null +++ b/.github/workflows/vendor-gems.yml @@ -0,0 +1,157 @@ +name: Vendor Gems + +on: + pull_request: + paths: + - .github/licensed.yml + - .github/workflows/vendor-gems.yml + - .licenses/** + - Library/Homebrew/dev-cmd/vendor-gems.rb + - Library/Homebrew/Gemfile* + push: + paths: + - .github/workflows/vendor-gems.yml + branches-ignore: + - main + - master + workflow_dispatch: + inputs: + pull_request: + description: Pull request number + required: true + +permissions: + contents: read + pull-requests: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + vendor-gems: + if: github.repository_owner == 'Homebrew' + runs-on: macos-26 + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Configure Git user + if: github.event_name == 'workflow_dispatch' + uses: Homebrew/actions/git-user-config@main + with: + username: BrewTestBot + + - name: Set up commit signing + if: github.event_name == 'workflow_dispatch' + uses: Homebrew/actions/setup-commit-signing@main + with: + signing_key: ${{ secrets.BREWTESTBOT_SSH_SIGNING_KEY }} + + - name: Check out pull request + id: checkout + if: github.event_name == 'workflow_dispatch' + run: | + gh pr checkout "${PR}" + + branch="$(git branch --show-current)" + echo "branch=${branch}" >> "$GITHUB_OUTPUT" + + gem_name="$(echo "${branch}" | sed -E 's|.*/||;s|(.*)-.*$|\1|')" + echo "gem_name=${gem_name}" >> "$GITHUB_OUTPUT" + env: + PR: ${{ github.event.pull_request.number || github.event.inputs.pull_request }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + - name: Vendor Gems + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] + then + brew vendor-gems --non-bundler-gems + else + brew vendor-gems --non-bundler-gems --no-commit + fi + + - name: Install CMake and Licensed + uses: Homebrew/actions/cache-homebrew-prefix@main + with: + install: cmake licensed + workflow-key: vendor-gems-licensed + uninstall: true + + - name: Update RubyGems licence metadata + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + BUNDLE_WITH="$(brew ruby -- -e 'require "utils/gem_setup"; puts Homebrew.valid_gem_groups.join(":")')" + export BUNDLE_WITH + licensed cache --config .github/licensed.yml + licensed status --config .github/licensed.yml + + - name: Commit licence metadata changes + if: github.event_name == 'workflow_dispatch' + env: + GEM_NAME: ${{ steps.checkout.outputs.gem_name }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + if [[ -n "$(git status --porcelain -- ".licenses")" ]] + then + git add ".licenses" + git commit -m "Update RubyGems licence metadata for ${GEM_NAME}." \ + -m "Autogenerated by the [vendor-gems](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/vendor-gems.yml) workflow." + fi + + - name: Update RBI files + run: brew typecheck --update + + - name: Commit RBI changes + if: github.event_name == 'workflow_dispatch' + env: + GEM_NAME: ${{ steps.checkout.outputs.gem_name }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + if [[ -n "$(git status --porcelain -- "Library/Homebrew/sorbet")" ]] + then + git add "Library/Homebrew/sorbet" + git commit -m "Update RBI files for ${GEM_NAME}." \ + -m "Autogenerated by the [vendor-gems](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/vendor-gems.yml) workflow." + fi + + - name: Generate push token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + if: github.event_name == 'workflow_dispatch' + with: + client-id: ${{ vars.BREW_COMMIT_CLIENT_ID }} + private-key: ${{ secrets.BREW_COMMIT_APP_KEY }} + permission-contents: write + + - name: Push to pull request + if: github.event_name == 'workflow_dispatch' + uses: Homebrew/actions/git-try-push@main + with: + token: ${{ steps.app-token.outputs.token }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + branch: ${{ steps.checkout.outputs.branch }} + force: true + + - name: Check vendor and RBI files are in sync + if: github.event_name == 'pull_request' + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + status="$(git status --porcelain -- .licenses Library/Homebrew/vendor/bundle Library/Homebrew/sorbet)" + if [ -n "${status}" ] + then + { + echo "::error::Vendored gems, license metadata and/or RBI files are out of date." + echo "Run 'brew vendor-gems && brew typecheck --update' (or 'brew update-portable-ruby' for portable-ruby bumps) and commit the changes." + echo "RubyGems licence metadata is refreshed by this workflow." + echo + echo "${status}" + } >&2 + exit 1 + fi diff --git a/.github/workflows/vendor-version.yml b/.github/workflows/vendor-version.yml new file mode 100644 index 0000000000000..f0bffcfcd963c --- /dev/null +++ b/.github/workflows/vendor-version.yml @@ -0,0 +1,72 @@ +name: Check Vendor Version + +on: + pull_request: + paths: + - .github/workflows/vendor-version.yml + - Library/Homebrew/vendor/bundle/ruby/** + +permissions: + contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + check-vendor-version: + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@main + with: + core: false + cask: false + + - name: Install Bundler RubyGems + run: brew install-bundler-gems --groups=all + + - name: Get Ruby ABI version + id: ruby-abi + run: echo "version=$(brew ruby -e "puts Gem.ruby_api_version")" >> "${GITHUB_OUTPUT}" + + - name: Get gem info + id: gem-info + working-directory: ${{ steps.set-up-homebrew.outputs.gems-path }}/${{ steps.ruby-abi.outputs.version }}/gems + run: | + { + echo "vendor-version=$(<../.homebrew_vendor_version)" + echo "ignored<> "${GITHUB_OUTPUT}" + + - name: Compare to base ref + working-directory: ${{ steps.set-up-homebrew.outputs.gems-path }}/${{ steps.ruby-abi.outputs.version }} + env: + ABI_VERSION: ${{ steps.ruby-abi.outputs.version }} + VENDOR_VERSION: ${{ steps.gem-info.outputs.vendor-version }} + IGNORED_GEMS: ${{ steps.gem-info.outputs.ignored }} + run: | + git checkout "origin/${GITHUB_BASE_REF}" + rm .homebrew_vendor_version + brew install-bundler-gems --groups=all + if [[ "$(brew ruby -e "puts Gem.ruby_api_version")" == "${ABI_VERSION}" && \ + "$(<.homebrew_vendor_version)" == "${VENDOR_VERSION}" ]] + then + while IFS= read -r gem; do + gem_dir="./gems/${gem}" + [[ -d "${gem_dir}" ]] || continue + exit_code=0 + git check-ignore --quiet "${gem_dir}" || exit_code=$? + if (( exit_code != 0 )); then + if (( exit_code == 1 )); then + echo "::error::VENDOR_VERSION needs bumping in utils/gem_setup.rb" >&2 + else + echo "::error::git check-ignore failed" >&2 + fi + exit "${exit_code}" + fi + done <<< "${IGNORED_GEMS}" + fi diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000000..465fbd2c2e052 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,6 @@ +# This file is synced from the `.github` repository, do not modify it directly. +rules: + unpinned-uses: + config: + policies: + Homebrew/actions/*: ref-pin diff --git a/.gitignore b/.gitignore index 94bfb9b2c93e1..e13398d906928 100644 --- a/.gitignore +++ b/.gitignore @@ -5,132 +5,166 @@ .DS_Store # Unignore the contents of `Library` as that's where our code lives. -!/Library/ +!/Library # Ignore files within `Library` (again). /Library/Homebrew/.npmignore /Library/Homebrew/bin /Library/Homebrew/doc +/Library/Homebrew/prof /Library/Homebrew/test/.gem +/Library/Homebrew/test/.cargo/ +/Library/Homebrew/test/.homebrew/ /Library/Homebrew/test/.subversion /Library/Homebrew/test/coverage +/Library/Homebrew/test/junit /Library/Homebrew/test/fs_leak_log /Library/Homebrew/vendor/portable-ruby +/Library/Homebrew/vendor/brew-rs/ +/Library/Homebrew/rust/brew-rs /Library/Taps /Library/PinnedTaps +/Library/Homebrew/.byebug_history +/Library/Homebrew/test/.npm/ +/Library/Homebrew/test/.rdbg_history # Ignore Bundler files **/.bundle/bin **/.bundle/cache +**/vendor/bundle/ruby/.homebrew_gem_groups +**/vendor/bundle/ruby/*/.homebrew_vendor_version +**/vendor/bundle/ruby/*/bundler.lock **/vendor/bundle/ruby/*/bin **/vendor/bundle/ruby/*/build_info/ **/vendor/bundle/ruby/*/cache **/vendor/bundle/ruby/*/extensions **/vendor/bundle/ruby/*/gems/*/* +**/vendor/bundle/ruby/*/plugins **/vendor/bundle/ruby/*/specifications +# Ignore Ruby gems for versions other than we explicitly vendor. +# Keep this in sync with the list in standalone/init.rb. +**/vendor/bundle/ruby/*/ +!**/vendor/bundle/ruby/4.0.0/ + +# Ignore Bundler binary files +**/vendor/bundle/ruby/*/gems/**/*.bundle + +# Ignore YARD files +**/.yardoc + # Unignore vendored gems +!**/vendor/bundle/ruby/*/gems/*/*COPYING* +!**/vendor/bundle/ruby/*/gems/*/*LICENSE* !**/vendor/bundle/ruby/*/gems/*/lib -!**/vendor/bundle/ruby/*/gems/rubocop-performance-*/config -!**/vendor/bundle/ruby/*/gems/rubocop-rspec-*/config +!**/vendor/bundle/ruby/*/gems/addressable-*/data +!**/vendor/bundle/ruby/*/gems/public_suffix-*/data # Ignore partially included gems where we don't need all files -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/all.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/cache.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/cache/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/concurrency/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/dependencies.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/dependencies/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/duration/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/json.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/json/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/log_subscriber.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/log_subscriber/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/messages/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/multibyte/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/number_helper.rb -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/number_helper/ -**/vendor/bundle/ruby/*/gems/activesupport-*/lib/active_support/testing/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/atomic/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/atomic_reference/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/collection/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/concern/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/executor/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/synchronization/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/thread_safe/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/utility/ -**/vendor/bundle/ruby/*/gems/concurrent-ruby-*/lib/*/*.jar -**/vendor/bundle/ruby/*/gems/i18n-*/lib/i18n/tests* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/*.rb -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/http/agent.rb -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/http/*auth*.rb -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/c* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/d* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/e* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/f* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/h* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/i* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/p* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/r* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/t* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/u* -**/vendor/bundle/ruby/*/gems/mechanize-*/lib/mechanize/x* -**/vendor/bundle/ruby/*/gems/thread_safe-*/lib/thread_safe/util +**/vendor/gems/mechanize-*/.* +**/vendor/gems/mechanize-*/*.md +**/vendor/gems/mechanize-*/*.rdoc +**/vendor/gems/mechanize-*/*.gemspec +**/vendor/gems/mechanize-*/Gemfile +**/vendor/gems/mechanize-*/Rakefile +**/vendor/gems/mechanize-*/examples/ +**/vendor/gems/mechanize-*/lib/**/* +!**/vendor/gems/mechanize-*/lib/mechanize/ +!**/vendor/gems/mechanize-*/lib/mechanize/http/ +!**/vendor/gems/mechanize-*/lib/mechanize/http/content_disposition_parser.rb +!**/vendor/gems/mechanize-*/lib/mechanize/version.rb +**/vendor/gems/mechanize-*/test/ # Ignore dependencies we don't wish to vendor **/vendor/bundle/ruby/*/gems/ast-*/ +**/vendor/bundle/ruby/*/gems/benchmark-*/ +**/vendor/bundle/ruby/*/gems/bigdecimal-*/ +**/vendor/bundle/ruby/*/gems/bootsnap-*/ **/vendor/bundle/ruby/*/gems/bundler-*/ +**/vendor/bundle/ruby/*/gems/byebug-*/ **/vendor/bundle/ruby/*/gems/coderay-*/ -**/vendor/bundle/ruby/*/gems/connection_pool-*/ -**/vendor/bundle/ruby/*/gems/coveralls-*/ +**/vendor/bundle/ruby/*/gems/colorize-*/ +**/vendor/bundle/ruby/*/gems/commander-*/ +**/vendor/bundle/ruby/*/gems/csv-*/ **/vendor/bundle/ruby/*/gems/diff-lcs-*/ **/vendor/bundle/ruby/*/gems/docile-*/ -**/vendor/bundle/ruby/*/gems/domain_name-*/ -**/vendor/bundle/ruby/*/gems/http-cookie-*/ -**/vendor/bundle/ruby/*/gems/hpricot-*/ +**/vendor/bundle/ruby/*/gems/drb-*/ +**/vendor/bundle/ruby/*/gems/ecma-re-validator-*/ +**/vendor/bundle/ruby/*/gems/erubi-*/ +**/vendor/bundle/ruby/*/gems/hana-*/ +**/vendor/bundle/ruby/*/gems/highline-*/ +**/vendor/bundle/ruby/*/gems/io-console-*/ **/vendor/bundle/ruby/*/gems/jaro_winkler-*/ **/vendor/bundle/ruby/*/gems/json-*/ +**/vendor/bundle/ruby/*/gems/json_schemer-*/ +**/vendor/bundle/ruby/*/gems/kramdown-*/ +**/vendor/bundle/ruby/*/gems/language_server-protocol-*/ +**/vendor/bundle/ruby/*/gems/lint_roller-*/ +**/vendor/bundle/ruby/*/gems/mcp-*/ **/vendor/bundle/ruby/*/gems/method_source-*/ -**/vendor/bundle/ruby/*/gems/mime-types-data-*/ -**/vendor/bundle/ruby/*/gems/mime-types-*/ **/vendor/bundle/ruby/*/gems/mini_portile2-*/ **/vendor/bundle/ruby/*/gems/minitest-*/ -**/vendor/bundle/ruby/*/gems/mustache-*/ -**/vendor/bundle/ruby/*/gems/net-http-digest_auth-*/ -**/vendor/bundle/ruby/*/gems/net-http-persistent-*/ -**/vendor/bundle/ruby/*/gems/nokogiri-*/ +**/vendor/bundle/ruby/*/gems/msgpack-*/ +**/vendor/bundle/ruby/*/gems/netrc-*/ **/vendor/bundle/ruby/*/gems/ntlm-http-*/ +**/vendor/bundle/ruby/*/gems/ostruct-*/ **/vendor/bundle/ruby/*/gems/parallel-*/ **/vendor/bundle/ruby/*/gems/parallel_tests-*/ +**/vendor/bundle/ruby/*/gems/parlour-*/ **/vendor/bundle/ruby/*/gems/parser-*/ **/vendor/bundle/ruby/*/gems/powerpack-*/ +**/vendor/bundle/ruby/*/gems/prettier_print-*/ +**/vendor/bundle/ruby/*/gems/prism-*/ **/vendor/bundle/ruby/*/gems/psych-*/ **/vendor/bundle/ruby/*/gems/pry-*/ +**/vendor/bundle/ruby/*/gems/pycall-*/ +**/vendor/bundle/ruby/*/gems/racc-*/ **/vendor/bundle/ruby/*/gems/rainbow-*/ -**/vendor/bundle/ruby/*/gems/rdiscount-*/ -**/vendor/bundle/ruby/*/gems/ronn-*/ +**/vendor/bundle/ruby/*/gems/rbi-*/ +**/vendor/bundle/ruby/*/gems/rbs-*/ +**/vendor/bundle/ruby/*/gems/rdoc-*/ +**/vendor/bundle/ruby/*/gems/redcarpet-*/ +**/vendor/bundle/ruby/*/gems/regexp_parser-*/ +**/vendor/bundle/ruby/*/gems/reline-*/ +**/vendor/bundle/ruby/*/gems/require-hooks-*/ +**/vendor/bundle/ruby/*/gems/rexml-*/ **/vendor/bundle/ruby/*/gems/rspec-*/ **/vendor/bundle/ruby/*/gems/rspec-core-*/ **/vendor/bundle/ruby/*/gems/rspec-expectations-*/ -**/vendor/bundle/ruby/*/gems/rspec-its-*/ +**/vendor/bundle/ruby/*/gems/rspec_junit_formatter-*/ **/vendor/bundle/ruby/*/gems/rspec-mocks-*/ **/vendor/bundle/ruby/*/gems/rspec-retry-*/ **/vendor/bundle/ruby/*/gems/rspec-support-*/ -**/vendor/bundle/ruby/*/gems/rspec-wait-*/ -**/vendor/bundle/ruby/*/gems/rubocop-0*/ +**/vendor/bundle/ruby/*/gems/rspec-sorbet-*/ +**/vendor/bundle/ruby/*/gems/rubocop-*/ +**/vendor/bundle/ruby/*/gems/rubydex-*/ +**/vendor/bundle/ruby/*/gems/ruby-lsp-*/ **/vendor/bundle/ruby/*/gems/ruby-prof-*/ **/vendor/bundle/ruby/*/gems/ruby-progressbar-*/ **/vendor/bundle/ruby/*/gems/simplecov-*/ -**/vendor/bundle/ruby/*/gems/simplecov-cobertura-*/ **/vendor/bundle/ruby/*/gems/simplecov-html-*/ -**/vendor/bundle/ruby/*/gems/term-ansicolor-*/ +**/vendor/bundle/ruby/*/gems/simplecov_json_formatter-*/ +**/vendor/bundle/ruby/*/gems/simpleidn-*/ +**/vendor/bundle/ruby/*/gems/sorbet-*/ +!**/vendor/bundle/ruby/*/gems/sorbet-runtime-*/ +**/vendor/bundle/ruby/*/gems/spoom-*/ +**/vendor/bundle/ruby/*/gems/stackprof-*/ +**/vendor/bundle/ruby/*/gems/strscan-*/ +**/vendor/bundle/ruby/*/gems/syntax_tree-*/ +**/vendor/bundle/ruby/*/gems/tapioca-*/ +**/vendor/bundle/ruby/*/gems/test-prof-*/ **/vendor/bundle/ruby/*/gems/thor-*/ -**/vendor/bundle/ruby/*/gems/tins-*/ -**/vendor/bundle/ruby/*/gems/unf_ext-*/ -**/vendor/bundle/ruby/*/gems/unf-*/ +**/vendor/bundle/ruby/*/gems/tsort-*/ **/vendor/bundle/ruby/*/gems/unicode-display_width-*/ +**/vendor/bundle/ruby/*/gems/unicode-emoji-*/ +**/vendor/bundle/ruby/*/gems/unparser-*/ +**/vendor/bundle/ruby/*/gems/uri_template-*/ +**/vendor/bundle/ruby/*/gems/vernier-*/ **/vendor/bundle/ruby/*/gems/webrobots-*/ +**/vendor/bundle/ruby/*/gems/yard-*/ +**/vendor/bundle/ruby/*/gems/yard-sorbet-*/ +**/vendor/cache/ +**/vendor/specifications/ # Ignore `bin` contents (again). /bin @@ -138,23 +172,35 @@ # Unignore our `brew` script. !/bin/brew -# Unignore our documentation/completions. +# Unignore our configuration/completions/documentation. +!/.devcontainer !/.github !/completions !/docs !/manpages +!/CODEOWNERS + +# Unignore our packaging files +!/package +/package/resources/LICENSE.rtf # Ignore generated documentation site /docs/_site -/docs/bin /docs/.jekyll-metadata /docs/vendor +/docs/rubydoc # Unignore our root-level metadata files. +!/.dockerignore !/.editorconfig !/.gitignore -!/.yardopts +!/.irb_config +!/.licenses +!/.licenses/** +!/.ruby-version +!/.shellcheckrc !/.vale.ini +!/.yardopts !/CHANGELOG.md !/CONTRIBUTING.md !/Dockerfile @@ -164,3 +210,19 @@ # Unignore tests' bundle config !/Library/Homebrew/test/.bundle/config + +# Unignore editor configuration +!/.sublime +/.sublime/homebrew.sublime-workspace +!/.vscode + +# Unignore AI configuration +!/.cursor +!/.claude +!/.codex + +# Ignore local AI configuration +/.claude/settings.local.json + +# Ignore tmp files +**/tmp/ diff --git a/.licenses/bundler/addressable.dep.yml b/.licenses/bundler/addressable.dep.yml new file mode 100644 index 0000000000000..0da599d725edf --- /dev/null +++ b/.licenses/bundler/addressable.dep.yml @@ -0,0 +1,213 @@ +--- +name: addressable +version: 2.9.0 +type: bundler +summary: URI Implementation +homepage: https://github.com/sporkmonger/addressable +license: apache-2.0 +licenses: +- sources: LICENSE.txt + text: |2 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/ast.dep.yml b/.licenses/bundler/ast.dep.yml new file mode 100644 index 0000000000000..b4e740214d109 --- /dev/null +++ b/.licenses/bundler/ast.dep.yml @@ -0,0 +1,31 @@ +--- +name: ast +version: 2.4.3 +type: bundler +summary: A library for working with Abstract Syntax Trees. +homepage: https://whitequark.github.io/ast/ +license: mit +licenses: +- sources: LICENSE.MIT + text: | + Copyright (c) 2011-2013 Peter Zotov + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/base64.dep.yml b/.licenses/bundler/base64.dep.yml new file mode 100644 index 0000000000000..949ed6447978c --- /dev/null +++ b/.licenses/bundler/base64.dep.yml @@ -0,0 +1,129 @@ +--- +name: base64 +version: 0.3.0 +type: bundler +summary: Support for encoding and decoding binary data using a Base64 representation. +homepage: https://github.com/ruby/base64 +license: other +licenses: +- sources: COPYING + text: | + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the + 2-clause BSDL (see the file BSDL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. +notices: +- sources: LEGAL + text: |- + # -*- rdoc -*- + + = LEGAL NOTICE INFORMATION + -------------------------- + + All the files in this distribution are covered under either the Ruby's + license (see the file COPYING) or public-domain except some files + mentioned below. + + == MIT License + >>> + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + == Old-style BSD license + >>> + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + + IMPORTANT NOTE:: + + From ftp://ftp.cs.berkeley.edu/pub/4bsd/README.Impt.License.Change + paragraph 3 above is now null and void. diff --git a/.licenses/bundler/benchmark.dep.yml b/.licenses/bundler/benchmark.dep.yml new file mode 100644 index 0000000000000..c719fcea71f8c --- /dev/null +++ b/.licenses/bundler/benchmark.dep.yml @@ -0,0 +1,67 @@ +--- +name: benchmark +version: 0.5.0 +type: bundler +summary: a performance benchmarking library +homepage: https://github.com/ruby/benchmark +license: other +licenses: +- sources: COPYING + text: | + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the + 2-clause BSDL (see the file BSDL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. +notices: [] diff --git a/.licenses/bundler/bindata.dep.yml b/.licenses/bundler/bindata.dep.yml new file mode 100644 index 0000000000000..65fc10fd89ac0 --- /dev/null +++ b/.licenses/bundler/bindata.dep.yml @@ -0,0 +1,36 @@ +--- +name: bindata +version: 2.5.1 +type: bundler +summary: A declarative way to read and write binary file formats +homepage: https://github.com/dmendel/bindata +license: bsd-2-clause +licenses: +- sources: LICENSE + text: | + BSD 2-Clause License + + Copyright (c) 2007-2022, Dion Mendel + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +notices: [] diff --git a/.licenses/bundler/bundler.dep.yml b/.licenses/bundler/bundler.dep.yml new file mode 100644 index 0000000000000..09455a8f1f28e --- /dev/null +++ b/.licenses/bundler/bundler.dep.yml @@ -0,0 +1,30 @@ +--- +name: bundler +version: 4.0.11 +type: bundler +summary: The best way to manage your application's dependencies +homepage: https://bundler.io +license: mit +licenses: +- sources: Auto-generated MIT license text + text: | + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/coderay.dep.yml b/.licenses/bundler/coderay.dep.yml new file mode 100644 index 0000000000000..968c9a1168633 --- /dev/null +++ b/.licenses/bundler/coderay.dep.yml @@ -0,0 +1,33 @@ +--- +name: coderay +version: 1.1.3 +type: bundler +summary: Fast syntax highlighting for selected languages. +homepage: http://coderay.rubychan.de +license: other +licenses: +- sources: MIT-LICENSE + text: |- + Copyright (C) 2005-2012 Kornelius Kalnbach (@murphy_karasu) + + http://coderay.rubychan.de/ + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/concurrent-ruby.dep.yml b/.licenses/bundler/concurrent-ruby.dep.yml new file mode 100644 index 0000000000000..5fb7d22ad3594 --- /dev/null +++ b/.licenses/bundler/concurrent-ruby.dep.yml @@ -0,0 +1,33 @@ +--- +name: concurrent-ruby +version: 1.3.7 +type: bundler +summary: Modern concurrency tools for Ruby. Inspired by Erlang, Clojure, Scala, Haskell, + F#, C#, Java, and classic concurrency patterns. +homepage: http://www.concurrent-ruby.com +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) Jerry D'Antonio -- released under the MIT license. + + http://www.opensource.org/licenses/mit-license.php + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/csv.dep.yml b/.licenses/bundler/csv.dep.yml new file mode 100644 index 0000000000000..f0de9a1609671 --- /dev/null +++ b/.licenses/bundler/csv.dep.yml @@ -0,0 +1,49 @@ +--- +name: csv +version: 3.3.5 +type: bundler +summary: CSV Reading and Writing +homepage: https://github.com/ruby/csv +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (C) 2005-2016 James Edward Gray II. All rights reserved. + Copyright (C) 2007-2017 Yukihiro Matsumoto. All rights reserved. + Copyright (C) 2017 SHIBATA Hiroshi. All rights reserved. + Copyright (C) 2017 Olivier Lacan. All rights reserved. + Copyright (C) 2017 Espartaco Palma. All rights reserved. + Copyright (C) 2017 Marcus Stollsteimer. All rights reserved. + Copyright (C) 2017 pavel. All rights reserved. + Copyright (C) 2017-2018 Steven Daniels. All rights reserved. + Copyright (C) 2018 Tomohiro Ogoke. All rights reserved. + Copyright (C) 2018 Kouhei Sutou. All rights reserved. + Copyright (C) 2018 Mitsutaka Mimura. All rights reserved. + Copyright (C) 2018 Vladislav. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. +- sources: README.md + text: |- + The gem is available as open source under the terms of the [2-Clause BSD License](https://opensource.org/licenses/BSD-2-Clause). + + See LICENSE.txt for details. +notices: [] diff --git a/.licenses/bundler/diff-lcs.dep.yml b/.licenses/bundler/diff-lcs.dep.yml new file mode 100644 index 0000000000000..97bc7a60fd1f2 --- /dev/null +++ b/.licenses/bundler/diff-lcs.dep.yml @@ -0,0 +1,52 @@ +--- +name: diff-lcs +version: 1.6.2 +type: bundler +summary: Diff::LCS computes the difference between two Enumerable sequences using + the McIlroy-Hunt longest common subsequence (LCS) algorithm +homepage: https://github.com/halostatue/diff-lcs +license: other +licenses: +- sources: LICENCE.md + text: | + # Licence + + This software is available under three licenses: the GNU GPL version 2 (or at + your option, a later version), the Perl Artistic license, or the MIT license. + Note that my preference for licensing is the MIT license, but Algorithm::Diff + was dually originally licensed with the Perl Artistic and the GNU GPL ("the same + terms as Perl itself") and given that the Ruby implementation originally hewed + pretty closely to the Perl version, I must maintain the additional licensing + terms. + + - Copyright 2004–2025 Austin Ziegler and contributors. + - Adapted from Algorithm::Diff (Perl) by Ned Konz and a Smalltalk version by + Mario I. Wolczko. + + ## MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ## Perl Artistic License + + See the file docs/artistic.txt in the main distribution. + + ## GNU GPL version 2 + + See the file docs/COPYING.txt in the main distribution. +notices: [] diff --git a/.licenses/bundler/docile.dep.yml b/.licenses/bundler/docile.dep.yml new file mode 100644 index 0000000000000..a867d1da4d7b5 --- /dev/null +++ b/.licenses/bundler/docile.dep.yml @@ -0,0 +1,32 @@ +--- +name: docile +version: 1.4.1 +type: bundler +summary: Docile keeps your Ruby DSLs tame and well-behaved. +homepage: https://ms-ati.github.io/docile/ +license: mit +licenses: +- sources: LICENSE + text: | + The MIT License (MIT) + + Copyright (c) 2012-2024 Marc Siegel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/drb.dep.yml b/.licenses/bundler/drb.dep.yml new file mode 100644 index 0000000000000..16caf990796ba --- /dev/null +++ b/.licenses/bundler/drb.dep.yml @@ -0,0 +1,33 @@ +--- +name: drb +version: 2.2.3 +type: bundler +summary: Distributed object system for Ruby +homepage: https://github.com/ruby/drb +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. +notices: [] diff --git a/.licenses/bundler/elftools.dep.yml b/.licenses/bundler/elftools.dep.yml new file mode 100644 index 0000000000000..cee60ee2e5c8e --- /dev/null +++ b/.licenses/bundler/elftools.dep.yml @@ -0,0 +1,11 @@ +--- +name: elftools +version: 1.3.1 +type: bundler +summary: ELFTools - Pure ruby library for parsing and patching ELF files +homepage: https://github.com/david942j/rbelftools +license: mit +licenses: +- sources: README.md + text: MIT License +notices: [] diff --git a/.licenses/bundler/erubi.dep.yml b/.licenses/bundler/erubi.dep.yml new file mode 100644 index 0000000000000..50ab745b759da --- /dev/null +++ b/.licenses/bundler/erubi.dep.yml @@ -0,0 +1,34 @@ +--- +name: erubi +version: 1.13.1 +type: bundler +summary: Small ERB Implementation +homepage: https://github.com/jeremyevans/erubi +license: mit +licenses: +- sources: MIT-LICENSE + text: | + copyright(c) 2006-2011 kuwata-lab.com all rights reserved. + copyright(c) 2016-2021 Jeremy Evans + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.rdoc + text: MIT +notices: [] diff --git a/.licenses/bundler/hana.dep.yml b/.licenses/bundler/hana.dep.yml new file mode 100644 index 0000000000000..e619a8bf9de6a --- /dev/null +++ b/.licenses/bundler/hana.dep.yml @@ -0,0 +1,36 @@ +--- +name: hana +version: 1.3.7 +type: bundler +summary: Implementation of [JSON Patch][1] and [JSON Pointer][2] RFC. +homepage: http://github.com/tenderlove/hana +license: mit +licenses: +- sources: README.md + text: |- + (The MIT License) + + Copyright (c) 2012-2020 Aaron Patterson + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + 'Software'), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + [1]: https://datatracker.ietf.org/doc/rfc6902/ + [2]: http://tools.ietf.org/html/rfc6901 +notices: [] diff --git a/.licenses/bundler/io-console.dep.yml b/.licenses/bundler/io-console.dep.yml new file mode 100644 index 0000000000000..456cea80507ac --- /dev/null +++ b/.licenses/bundler/io-console.dep.yml @@ -0,0 +1,69 @@ +--- +name: io-console +version: 0.8.2 +type: bundler +summary: Console interface +homepage: https://github.com/ruby/io-console +license: other +licenses: +- sources: COPYING + text: | + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the + 2-clause BSDL (see the file BSDL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. +- sources: README.md + text: The gem is available as open source under the terms of the [2-Clause BSD License](https://opensource.org/licenses/BSD-2-Clause). +notices: [] diff --git a/.licenses/bundler/json_schemer.dep.yml b/.licenses/bundler/json_schemer.dep.yml new file mode 100644 index 0000000000000..818732c8d968b --- /dev/null +++ b/.licenses/bundler/json_schemer.dep.yml @@ -0,0 +1,35 @@ +--- +name: json_schemer +version: 2.5.0 +type: bundler +summary: JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, 2020-12, OpenAPI + 3.0, and OpenAPI 3.1. +homepage: https://github.com/davishmcclurg/json_schemer +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2018 David Harsha + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +notices: [] diff --git a/.licenses/bundler/kramdown.dep.yml b/.licenses/bundler/kramdown.dep.yml new file mode 100644 index 0000000000000..878b9a943dd62 --- /dev/null +++ b/.licenses/bundler/kramdown.dep.yml @@ -0,0 +1,46 @@ +--- +name: kramdown +version: 2.5.2 +type: bundler +summary: kramdown is a fast, pure-Ruby Markdown-superset converter. +homepage: http://kramdown.gettalong.org +license: other +licenses: +- sources: COPYING + text: |+ + kramdown - fast, pure-Ruby Markdown-superset converter + Copyright (C) 2009-2013 Thomas Leitner + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + Some test cases and the benchmark files are based on test cases from + the MDTest test suite: + + MDTest + Copyright (c) 2007 Michel Fortin + + +- sources: README.md + text: MIT - see the **COPYING** file. +notices: +- sources: AUTHORS + text: The author of kramdown is Thomas Leitner . +... diff --git a/.licenses/bundler/language_server-protocol.dep.yml b/.licenses/bundler/language_server-protocol.dep.yml new file mode 100644 index 0000000000000..e035939e27125 --- /dev/null +++ b/.licenses/bundler/language_server-protocol.dep.yml @@ -0,0 +1,34 @@ +--- +name: language_server-protocol +version: 3.17.0.5 +type: bundler +summary: A Language Server Protocol SDK +homepage: https://github.com/mtsmfm/language_server-protocol-ruby +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2017 Fumiaki MATSUSHIMA + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). +notices: [] diff --git a/.licenses/bundler/lint_roller.dep.yml b/.licenses/bundler/lint_roller.dep.yml new file mode 100644 index 0000000000000..12428b07b3bc0 --- /dev/null +++ b/.licenses/bundler/lint_roller.dep.yml @@ -0,0 +1,32 @@ +--- +name: lint_roller +version: 1.1.0 +type: bundler +summary: A plugin specification for linter and formatter rulesets +homepage: https://github.com/standardrb/lint_roller +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2023 Test Double, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/logger.dep.yml b/.licenses/bundler/logger.dep.yml new file mode 100644 index 0000000000000..cc783bd0b4c3e --- /dev/null +++ b/.licenses/bundler/logger.dep.yml @@ -0,0 +1,69 @@ +--- +name: logger +version: 1.7.0 +type: bundler +summary: Provides a simple logging utility for outputting messages. +homepage: https://github.com/ruby/logger +license: other +licenses: +- sources: COPYING + text: | + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the + 2-clause BSDL (see the file BSDL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. +- sources: README.md + text: The gem is available as open source under the terms of the [BSD-2-Clause](BSDL). +notices: [] diff --git a/.licenses/bundler/method_source.dep.yml b/.licenses/bundler/method_source.dep.yml new file mode 100644 index 0000000000000..18a50549dde00 --- /dev/null +++ b/.licenses/bundler/method_source.dep.yml @@ -0,0 +1,57 @@ +--- +name: method_source +version: 1.1.0 +type: bundler +summary: retrieve the sourcecode for a method +homepage: http://banisterfiend.wordpress.com +license: mit +licenses: +- sources: LICENSE + text: | + MIT License + + Copyright (c) 2011 John Mair (banisterfiend) + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + 'Software'), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.markdown + text: |- + (The MIT License) + + Copyright (c) 2011 John Mair (banisterfiend) + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + 'Software'), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/minitest.dep.yml b/.licenses/bundler/minitest.dep.yml new file mode 100644 index 0000000000000..db0a9b700183a --- /dev/null +++ b/.licenses/bundler/minitest.dep.yml @@ -0,0 +1,34 @@ +--- +name: minitest +version: 6.0.6 +type: bundler +summary: minitest provides a complete suite of testing facilities supporting TDD, + BDD, and benchmarking +homepage: https://minite.st/ +license: mit +licenses: +- sources: README.rdoc + text: |- + (The MIT License) + + Copyright (c) Ryan Davis, seattle.rb + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + 'Software'), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/netrc.dep.yml b/.licenses/bundler/netrc.dep.yml new file mode 100644 index 0000000000000..02fea77e20255 --- /dev/null +++ b/.licenses/bundler/netrc.dep.yml @@ -0,0 +1,31 @@ +--- +name: netrc +version: 0.11.0 +type: bundler +summary: Library to read and write netrc files. +homepage: https://github.com/geemus/netrc +license: mit +licenses: +- sources: LICENSE.md + text: | + The MIT License (MIT) + + Copyright (c) 2011-2014 [CONTRIBUTORS.md](https://github.com/geemus/netrc/blob/master/CONTRIBUTORS.md) + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/ostruct.dep.yml b/.licenses/bundler/ostruct.dep.yml new file mode 100644 index 0000000000000..c388c04a115f2 --- /dev/null +++ b/.licenses/bundler/ostruct.dep.yml @@ -0,0 +1,69 @@ +--- +name: ostruct +version: 0.6.3 +type: bundler +summary: Class to build custom data structures, similar to a Hash. +homepage: https://github.com/ruby/ostruct +license: other +licenses: +- sources: COPYING + text: | + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the + 2-clause BSDL (see the file BSDL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a. place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b. use the modified software only within your corporation or + organization. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a. distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b. accompany the distribution with the machine-readable source of + the software. + + c. give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d. make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. +- sources: README.md + text: The gem is available as open source under the terms of the [2-Clause BSD License](https://opensource.org/licenses/BSD-2-Clause). +notices: [] diff --git a/.licenses/bundler/parallel.dep.yml b/.licenses/bundler/parallel.dep.yml new file mode 100644 index 0000000000000..a98a7b570e0ee --- /dev/null +++ b/.licenses/bundler/parallel.dep.yml @@ -0,0 +1,31 @@ +--- +name: parallel +version: 2.1.0 +type: bundler +summary: Run any kind of code in parallel processes +homepage: https://github.com/grosser/parallel +license: mit +licenses: +- sources: MIT-LICENSE.txt + text: | + Copyright (C) 2013 Michael Grosser + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/parallel_tests.dep.yml b/.licenses/bundler/parallel_tests.dep.yml new file mode 100644 index 0000000000000..25be60b22db40 --- /dev/null +++ b/.licenses/bundler/parallel_tests.dep.yml @@ -0,0 +1,30 @@ +--- +name: parallel_tests +version: 5.7.0 +type: bundler +summary: Run Test::Unit / RSpec / Cucumber / Spinach in parallel +homepage: https://github.com/grosser/parallel_tests +license: mit +licenses: +- sources: Auto-generated MIT license text + text: | + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/parser.dep.yml b/.licenses/bundler/parser.dep.yml new file mode 100644 index 0000000000000..8f931a24e68a8 --- /dev/null +++ b/.licenses/bundler/parser.dep.yml @@ -0,0 +1,37 @@ +--- +name: parser +version: 3.3.11.1 +type: bundler +summary: A Ruby parser written in pure Ruby. +homepage: https://github.com/whitequark/parser +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2013-2024 parser project contributors + Copyright (c) 2013-2016 Catherine + + Parts of the source are derived from ruby_parser: + Copyright (c) Ryan Davis, seattle.rb + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/patchelf.dep.yml b/.licenses/bundler/patchelf.dep.yml new file mode 100644 index 0000000000000..c56e65f899a88 --- /dev/null +++ b/.licenses/bundler/patchelf.dep.yml @@ -0,0 +1,32 @@ +--- +name: patchelf +version: 1.5.2 +type: bundler +summary: patchelf +homepage: https://github.com/david942j/patchelf.rb +license: mit +licenses: +- sources: LICENSE + text: | + MIT License + + Copyright (c) 2019 david942j + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/plist.dep.yml b/.licenses/bundler/plist.dep.yml new file mode 100644 index 0000000000000..7fbb0e5dd44d0 --- /dev/null +++ b/.licenses/bundler/plist.dep.yml @@ -0,0 +1,31 @@ +--- +name: plist +version: 3.7.2 +type: bundler +summary: All-purpose Property List manipulation library +homepage: https://github.com/patsplat/plist +license: mit +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2006-2010, Ben Bleything and Patrick May + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/pry.dep.yml b/.licenses/bundler/pry.dep.yml new file mode 100644 index 0000000000000..fd908acaab294 --- /dev/null +++ b/.licenses/bundler/pry.dep.yml @@ -0,0 +1,39 @@ +--- +name: pry +version: 0.16.0 +type: bundler +summary: A runtime developer console and IRB alternative with powerful introspection + capabilities. +homepage: http://pry.github.io +license: other +licenses: +- sources: LICENSE + text: | + License + ------- + + (The MIT License) + + Copyright (c) 2010 John Mair (banisterfiend) + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + 'Software'), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: The project uses the MIT License. See LICENSE.md for details. +notices: [] diff --git a/.licenses/bundler/public_suffix.dep.yml b/.licenses/bundler/public_suffix.dep.yml new file mode 100644 index 0000000000000..a56227004dfb3 --- /dev/null +++ b/.licenses/bundler/public_suffix.dep.yml @@ -0,0 +1,38 @@ +--- +name: public_suffix +version: 7.0.5 +type: bundler +summary: Domain name parser based on the Public Suffix List. +homepage: https://simonecarletti.com/code/publicsuffix-ruby +license: mit +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2009-2026 Simone Carletti + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: |- + Copyright (c) 2009-2026 Simone Carletti. [MIT License](LICENSE.txt). + + The [Public Suffix List source](https://publicsuffix.org/list/) is subject to the terms of the Mozilla Public License, v. 2.0. +notices: [] diff --git a/.licenses/bundler/rainbow.dep.yml b/.licenses/bundler/rainbow.dep.yml new file mode 100644 index 0000000000000..f8bfbe09f1392 --- /dev/null +++ b/.licenses/bundler/rainbow.dep.yml @@ -0,0 +1,31 @@ +--- +name: rainbow +version: 3.1.1 +type: bundler +summary: Colorize printed text on ANSI terminals +homepage: https://github.com/sickill/rainbow +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) Marcin Kulik + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rbi.dep.yml b/.licenses/bundler/rbi.dep.yml new file mode 100644 index 0000000000000..c91e8e3e51061 --- /dev/null +++ b/.licenses/bundler/rbi.dep.yml @@ -0,0 +1,11 @@ +--- +name: rbi +version: 0.3.14 +type: bundler +summary: RBI generation framework +homepage: https://github.com/Shopify/rbi +license: mit +licenses: +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +notices: [] diff --git a/.licenses/bundler/regexp_parser.dep.yml b/.licenses/bundler/regexp_parser.dep.yml new file mode 100644 index 0000000000000..d9a58c263ffa2 --- /dev/null +++ b/.licenses/bundler/regexp_parser.dep.yml @@ -0,0 +1,33 @@ +--- +name: regexp_parser +version: 2.12.0 +type: bundler +summary: Scanner, lexer, parser for ruby's regular expressions +homepage: https://github.com/ammar/regexp_parser +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) 2010, 2012-2025, Ammar Ali + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/reline.dep.yml b/.licenses/bundler/reline.dep.yml new file mode 100644 index 0000000000000..d37d7a85e316a --- /dev/null +++ b/.licenses/bundler/reline.dep.yml @@ -0,0 +1,63 @@ +--- +name: reline +version: 0.6.3 +type: bundler +summary: Alternative GNU Readline or Editline implementation by pure Ruby. +homepage: https://github.com/ruby/reline +license: other +licenses: +- sources: COPYING + text: "Ruby is copyrighted free software by Yukihiro Matsumoto .\nYou + can redistribute it and/or modify it under either the terms of the\n2-clause BSDL + (see the file BSDL), or the conditions below:\n\n 1. You may make and give away + verbatim copies of the source form of the\n software without restriction, + provided that you duplicate all of the\n original copyright notices and associated + disclaimers.\n\n 2. You may modify your copy of the software in any way, provided + that\n you do at least ONE of the following:\n\n a) place your modifications + in the Public Domain or otherwise\n make them Freely Available, such + as by posting said\n\t modifications to Usenet or an equivalent medium, or by + allowing\n\t the author to include your modifications in the software.\n\n b) + use the modified software only within your corporation or\n organization.\n\n + \ c) give non-standard binaries non-standard names, with\n instructions + on where to get the original software distribution.\n\n d) make other distribution + arrangements with the author.\n\n 3. You may distribute the software in object + code or binary form,\n provided that you do at least ONE of the following:\n\n + \ a) distribute the binaries and library files of the software,\n\t together + with instructions (in the manual page or equivalent)\n\t on where to get the + original distribution.\n\n b) accompany the distribution with the machine-readable + source of\n\t the software.\n\n c) give non-standard binaries non-standard + names, with\n instructions on where to get the original software distribution.\n\n + \ d) make other distribution arrangements with the author.\n\n 4. You may + modify and include the part of the software into any other\n software (possibly + commercial). But some files in the distribution\n are not written by the + author, so that they are not under these terms.\n\n For the list of those + files and their copying conditions, see the\n file LEGAL.\n\n 5. The scripts + and library files supplied as input to or produced as\n output from the software + do not automatically fall under the\n copyright of the software, but belong + to whomever generated them,\n and may be sold commercially, and may be aggregated + with this\n software.\n\n 6. THIS SOFTWARE IS PROVIDED \"AS IS\" AND WITHOUT + ANY EXPRESS OR\n IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED\n + \ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\n PURPOSE.\n" +- sources: license_of_rb-readline + text: "Copyright (c) 2009, Park Heesob\nAll rights reserved.\n\nRedistribution and + use in source and binary forms, with or without \nmodification, are permitted + provided that the following conditions are met:\n\n* Redistributions of source + code must retain the above copyright notice, this\n list of conditions and the + following disclaimer.\n* Redistributions in binary form must reproduce the above + copyright notice\n this list of conditions and the following disclaimer in the + documentation\n and/or other materials provided with the distribution.\n* Neither + the name of Park Heesob nor the names of its contributors \n may be used to endorse + or promote products derived from this software \n without specific prior written + permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + \"AS IS\" \nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + TO, THE \nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE \nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + \nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL \nDAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR \nSERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER \nCAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, \nOR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE \nOF THIS SOFTWARE, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" +- sources: README.md + text: The gem is available as open source under the terms of the [Ruby License](https://www.ruby-lang.org/en/about/license.txt). +notices: [] diff --git a/.licenses/bundler/require-hooks.dep.yml b/.licenses/bundler/require-hooks.dep.yml new file mode 100644 index 0000000000000..fa05d7aca24a9 --- /dev/null +++ b/.licenses/bundler/require-hooks.dep.yml @@ -0,0 +1,41 @@ +--- +name: require-hooks +version: 0.4.0 +type: bundler +summary: Require Hooks provide infrastructure for intercepting require/load calls + in Ruby. +homepage: https://github.com/ruby-next/require-hooks +license: mit +licenses: +- sources: LICENSE.txt + text: |+ + Copyright (c) 2023 Vladimir Dementyev + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +- sources: README.md + text: |- + The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). + + [Bootsnap]: https://github.com/Shopify/bootsnap + [ruby-next]: https://github.com/ruby-next/ruby-next +notices: [] diff --git a/.licenses/bundler/rexml.dep.yml b/.licenses/bundler/rexml.dep.yml new file mode 100644 index 0000000000000..5ea308ce0b48c --- /dev/null +++ b/.licenses/bundler/rexml.dep.yml @@ -0,0 +1,35 @@ +--- +name: rexml +version: 3.4.4 +type: bundler +summary: An XML toolkit for Ruby +homepage: https://github.com/ruby/rexml +license: bsd-2-clause +licenses: +- sources: LICENSE.txt + text: | + Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. +- sources: README.md + text: The gem is available as open source under the terms of the [BSD-2-Clause](LICENSE.txt). +notices: [] diff --git a/.licenses/bundler/rspec-core.dep.yml b/.licenses/bundler/rspec-core.dep.yml new file mode 100644 index 0000000000000..eeed5473aef71 --- /dev/null +++ b/.licenses/bundler/rspec-core.dep.yml @@ -0,0 +1,37 @@ +--- +name: rspec-core +version: 3.13.6 +type: bundler +summary: rspec-core-3.13.6 +homepage: https://rspec.info +license: mit +licenses: +- sources: LICENSE.md + text: | + The MIT License (MIT) + ===================== + + * Copyright © 2012 Chad Humphries, David Chelimsky, Myron Marston + * Copyright © 2009 Chad Humphries, David Chelimsky + * Copyright © 2006 David Chelimsky, The RSpec Development Team + * Copyright © 2005 Steven Baker + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-expectations.dep.yml b/.licenses/bundler/rspec-expectations.dep.yml new file mode 100644 index 0000000000000..e3bf228247e7b --- /dev/null +++ b/.licenses/bundler/rspec-expectations.dep.yml @@ -0,0 +1,36 @@ +--- +name: rspec-expectations +version: 3.13.5 +type: bundler +summary: rspec-expectations-3.13.5 +homepage: https://rspec.info +license: mit +licenses: +- sources: LICENSE.md + text: | + The MIT License (MIT) + ===================== + + * Copyright © 2012 David Chelimsky, Myron Marston + * Copyright © 2006 David Chelimsky, The RSpec Development Team + * Copyright © 2005 Steven Baker + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-github.dep.yml b/.licenses/bundler/rspec-github.dep.yml new file mode 100644 index 0000000000000..9656699f2177e --- /dev/null +++ b/.licenses/bundler/rspec-github.dep.yml @@ -0,0 +1,30 @@ +--- +name: rspec-github +version: 3.0.0 +type: bundler +summary: Formatter for RSpec to show errors in GitHub action annotations +homepage: https://drieam.github.io/rspec-github +license: mit +licenses: +- sources: Auto-generated MIT license text + text: | + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-mocks.dep.yml b/.licenses/bundler/rspec-mocks.dep.yml new file mode 100644 index 0000000000000..b8edd838ae4ef --- /dev/null +++ b/.licenses/bundler/rspec-mocks.dep.yml @@ -0,0 +1,36 @@ +--- +name: rspec-mocks +version: 3.13.8 +type: bundler +summary: rspec-mocks-3.13.8 +homepage: https://rspec.info +license: mit +licenses: +- sources: LICENSE.md + text: | + The MIT License (MIT) + ===================== + + * Copyright © 2012 David Chelimsky, Myron Marston + * Copyright © 2006 David Chelimsky, The RSpec Development Team + * Copyright © 2005 Steven Baker + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-retry.dep.yml b/.licenses/bundler/rspec-retry.dep.yml new file mode 100644 index 0000000000000..2b5e949e9c3fc --- /dev/null +++ b/.licenses/bundler/rspec-retry.dep.yml @@ -0,0 +1,33 @@ +--- +name: rspec-retry +version: 0.6.2 +type: bundler +summary: retry intermittently failing rspec examples +homepage: http://github.com/NoRedInk/rspec-retry +license: mit +licenses: +- sources: LICENSE + text: |- + Copyright (c) 2012 Yusuke Mito + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-sorbet.dep.yml b/.licenses/bundler/rspec-sorbet.dep.yml new file mode 100644 index 0000000000000..973c2d101aafd --- /dev/null +++ b/.licenses/bundler/rspec-sorbet.dep.yml @@ -0,0 +1,32 @@ +--- +name: rspec-sorbet +version: 1.9.2 +type: bundler +summary: A small gem consisting of helpers for using Sorbet & RSpec together. +homepage: https://github.com/samuelgiles/rspec-sorbet +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2019 Samuel Giles + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec-support.dep.yml b/.licenses/bundler/rspec-support.dep.yml new file mode 100644 index 0000000000000..cdeed5931bf20 --- /dev/null +++ b/.licenses/bundler/rspec-support.dep.yml @@ -0,0 +1,34 @@ +--- +name: rspec-support +version: 3.13.7 +type: bundler +summary: rspec-support-3.13.7 +homepage: https://rspec.info +license: mit +licenses: +- sources: LICENSE.md + text: | + The MIT License (MIT) + ==================== + + * Copyright © 2013 David Chelimsky, Myron Marston, Jon Rowe, Sam Phippen, Xavier Shay, Bradley Schaefer + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/rspec.dep.yml b/.licenses/bundler/rspec.dep.yml new file mode 100644 index 0000000000000..c184a5e6c16e1 --- /dev/null +++ b/.licenses/bundler/rspec.dep.yml @@ -0,0 +1,26 @@ +--- +name: rspec +version: 3.13.2 +type: bundler +summary: rspec-3.13.2 +homepage: https://rspec.info +license: mit +licenses: +- sources: LICENSE.md + text: "The MIT License (MIT)\n=====================\n\nCopyright © 2009 Chad Humphries, + David Chelimsky \nCopyright © 2006 David Chelimsky, The RSpec Development Team + \ \nCopyright © 2005 Steven Baker\n\nPermission is hereby granted, free of charge, + to any person\nobtaining a copy of this software and associated documentation\nfiles + (the “Software”), to deal in the Software without\nrestriction, including without + limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, + and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware + is furnished to do so, subject to the following\nconditions:\n\nThe above copyright + notice and this permission notice shall be\nincluded in all copies or substantial + portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY + OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO + EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES + OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE + SOFTWARE.\n" +notices: [] diff --git a/.licenses/bundler/rspec_junit_formatter.dep.yml b/.licenses/bundler/rspec_junit_formatter.dep.yml new file mode 100644 index 0000000000000..7b3c4ad1af725 --- /dev/null +++ b/.licenses/bundler/rspec_junit_formatter.dep.yml @@ -0,0 +1,33 @@ +--- +name: rspec_junit_formatter +version: 0.6.0 +type: bundler +summary: RSpec JUnit XML formatter +homepage: https://github.com/sj26/rspec_junit_formatter +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) 2011-2022 Samuel Cochran + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: The MIT License, see [LICENSE](./LICENSE). +notices: [] diff --git a/.licenses/bundler/rubocop-ast.dep.yml b/.licenses/bundler/rubocop-ast.dep.yml new file mode 100644 index 0000000000000..8b41d22a93b0f --- /dev/null +++ b/.licenses/bundler/rubocop-ast.dep.yml @@ -0,0 +1,35 @@ +--- +name: rubocop-ast +version: 1.49.1 +type: bundler +summary: RuboCop tools to deal with Ruby code AST. +homepage: https://github.com/rubocop/rubocop-ast +license: mit +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2012-20 Bozhidar Batsov + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: |- + `rubocop-ast` is MIT licensed. [See the accompanying file](LICENSE.txt) for + the full text. +notices: [] diff --git a/.licenses/bundler/rubocop-md.dep.yml b/.licenses/bundler/rubocop-md.dep.yml new file mode 100644 index 0000000000000..c67c918d43450 --- /dev/null +++ b/.licenses/bundler/rubocop-md.dep.yml @@ -0,0 +1,35 @@ +--- +name: rubocop-md +version: 2.0.4 +type: bundler +summary: Run RuboCop against your Markdown files to make sure that code examples follow + style guidelines. +homepage: https://github.com/rubocop/rubocop-md +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2017-2023 Vladimir Dementyev + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). +notices: [] diff --git a/.licenses/bundler/rubocop-performance.dep.yml b/.licenses/bundler/rubocop-performance.dep.yml new file mode 100644 index 0000000000000..3f3db3f4842d9 --- /dev/null +++ b/.licenses/bundler/rubocop-performance.dep.yml @@ -0,0 +1,35 @@ +--- +name: rubocop-performance +version: 1.26.1 +type: bundler +summary: Automatic performance checking tool for Ruby code. +homepage: https://github.com/rubocop/rubocop-performance +license: mit +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2012-25 Bozhidar Batsov + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: |- + `rubocop-performance` is MIT licensed. [See the accompanying file](LICENSE.txt) for + the full text. +notices: [] diff --git a/.licenses/bundler/rubocop-rspec.dep.yml b/.licenses/bundler/rubocop-rspec.dep.yml new file mode 100644 index 0000000000000..5434141f5bb51 --- /dev/null +++ b/.licenses/bundler/rubocop-rspec.dep.yml @@ -0,0 +1,36 @@ +--- +name: rubocop-rspec +version: 3.10.2 +type: bundler +summary: Code style checking for RSpec files +homepage: https://github.com/rubocop/rubocop-rspec +license: mit +licenses: +- sources: MIT-LICENSE.md + text: | + # The MIT License (MIT) + + Copyright (c) 2014 Ian MacLeod + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + of the Software, and to permit persons to whom the Software is furnished to do + so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- sources: README.md + text: |- + `rubocop-rspec` is MIT licensed. [See the accompanying file](MIT-LICENSE.md) for + the full text. +notices: [] diff --git a/.licenses/bundler/rubocop-sorbet.dep.yml b/.licenses/bundler/rubocop-sorbet.dep.yml new file mode 100644 index 0000000000000..12cf3fa03f028 --- /dev/null +++ b/.licenses/bundler/rubocop-sorbet.dep.yml @@ -0,0 +1,34 @@ +--- +name: rubocop-sorbet +version: 0.12.0 +type: bundler +summary: Automatic Sorbet code style checking tool. +homepage: https://github.com/shopify/rubocop-sorbet +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2019-present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](https://github.com/Shopify/rubocop-sorbet/blob/main/LICENSE.txt). +notices: [] diff --git a/.licenses/bundler/rubocop.dep.yml b/.licenses/bundler/rubocop.dep.yml new file mode 100644 index 0000000000000..df50883b80f63 --- /dev/null +++ b/.licenses/bundler/rubocop.dep.yml @@ -0,0 +1,31 @@ +--- +name: rubocop +version: 1.88.0 +type: bundler +summary: Automatic Ruby code style checking tool. +homepage: https://github.com/rubocop/rubocop +license: mit +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2012-26 Bozhidar Batsov + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/ruby-lsp.dep.yml b/.licenses/bundler/ruby-lsp.dep.yml new file mode 100644 index 0000000000000..59a96967ab9c8 --- /dev/null +++ b/.licenses/bundler/ruby-lsp.dep.yml @@ -0,0 +1,34 @@ +--- +name: ruby-lsp +version: 0.26.9 +type: bundler +summary: An opinionated language server for Ruby +homepage: https://github.com/Shopify/ruby-lsp +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2022-present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](LICENSE.txt). +notices: [] diff --git a/.licenses/bundler/ruby-macho.dep.yml b/.licenses/bundler/ruby-macho.dep.yml new file mode 100644 index 0000000000000..baefa23311f49 --- /dev/null +++ b/.licenses/bundler/ruby-macho.dep.yml @@ -0,0 +1,37 @@ +--- +name: ruby-macho +version: 5.0.0 +type: bundler +summary: ruby-macho - Mach-O file analyzer. +homepage: https://github.com/Homebrew/ruby-macho +license: mit +licenses: +- sources: LICENSE + text: | + The MIT License (MIT) + + Copyright (c) 2015, 2016, 2017, 2018 William Woodruff + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: |- + `ruby-macho` is licensed under the MIT License. + + For the exact terms, see the [license](LICENSE) file. +notices: [] diff --git a/.licenses/bundler/ruby-progressbar.dep.yml b/.licenses/bundler/ruby-progressbar.dep.yml new file mode 100644 index 0000000000000..4f90dee5204ec --- /dev/null +++ b/.licenses/bundler/ruby-progressbar.dep.yml @@ -0,0 +1,46 @@ +--- +name: ruby-progressbar +version: 1.13.0 +type: bundler +summary: Ruby/ProgressBar is a flexible text progress bar library for Ruby. +homepage: https://github.com/jfelchner/ruby-progressbar +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (c) 2010-2019 The Kompanee, Ltd + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: |- + ruby-progressbar 1.0 is Copyright © 2011-2021 The Kompanee. It is free + software, and may be redistributed under the terms specified in the LICENSE + file. + ruby-progressbar 0.9.0 is Copyright © 2008 [Satoru Takabayashi][satoru] + + [contributors]: https://github.com/jfelchner/ruby-progressbar/graphs/contributors + [dependencies]: https://github.com/jfelchner/ruby-progressbar/network/dependents + [gemspec]: https://github.com/jfelchner/ruby-progressbar/blob/master/ruby-progressbar.gemspec + [history]: https://github.com/jfelchner/ruby-progressbar/wiki/History + [issues]: https://github.com/jfelchner/ruby-progressbar/issues + [kompanee-logo]: https://kompanee-public-assets.s3.amazonaws.com/readmes/kompanee-horizontal-black.png + [kompanee-site]: http://www.thekompanee.com + [satoru]: http://0xcc.net + [wiki]: https://github.com/jfelchner/ruby-progressbar/wiki +notices: [] diff --git a/.licenses/bundler/rubydex.dep.yml b/.licenses/bundler/rubydex.dep.yml new file mode 100644 index 0000000000000..716615fcbf94f --- /dev/null +++ b/.licenses/bundler/rubydex.dep.yml @@ -0,0 +1,3875 @@ +--- +name: rubydex +version: 0.2.7 +type: bundler +summary: A high-performance static analysis suite for Ruby +homepage: https://github.com/Shopify/rubydex +license: other +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2025-present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + See the generated THIRD_PARTY_LICENSES.html file for third party licenses. +- sources: THIRD_PARTY_LICENSES.html + text: "\n\n\n \n\n\n\n
\n
\n

Third Party Licenses

\n

This + page lists the licenses of the projects used in Rubydex.

\n
\n\n + \

Overview of licenses:

\n \n\n

All license text:

\n
    \n + \
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • nohash-hasher\n 0.2.0
    • \n
    • utf8_iter\n + \ 1.0.4
    • \n
    \n
    \n                                 Apache License\n                           Version
    +    2.0, January 2004\n                        http://www.apache.org/licenses/\n\n
    +    \  TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n
    +    \     "License" shall mean the terms and conditions for use, reproduction,\n
    +    \     and distribution as defined by Sections 1 through 9 of this document.\n\n
    +    \     "Licensor" shall mean the copyright owner or entity authorized
    +    by\n      the copyright owner that is granting the License.\n\n      "Legal
    +    Entity" shall mean the union of the acting entity and all\n      other entities
    +    that control, are controlled by, or are under common\n      control with that
    +    entity. For the purposes of this definition,\n      "control" means
    +    (i) the power, direct or indirect, to cause the\n      direction or management
    +    of such entity, whether by contract or\n      otherwise, or (ii) ownership of
    +    fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial
    +    ownership of such entity.\n\n      "You" (or "Your") shall
    +    mean an individual or Legal Entity\n      exercising permissions granted by this
    +    License.\n\n      "Source" form shall mean the preferred form for making
    +    modifications,\n      including but not limited to software source code, documentation\n
    +    \     source, and configuration files.\n\n      "Object" form shall
    +    mean any form resulting from mechanical\n      transformation or translation of
    +    a Source form, including but\n      not limited to compiled object code, generated
    +    documentation,\n      and conversions to other media types.\n\n      "Work"
    +    shall mean the work of authorship, whether in Source or\n      Object form, made
    +    available under the License, as indicated by a\n      copyright notice that is
    +    included in or attached to the work\n      (an example is provided in the Appendix
    +    below).\n\n      "Derivative Works" shall mean any work, whether in
    +    Source or Object\n      form, that is based on (or derived from) the Work and
    +    for which the\n      editorial revisions, annotations, elaborations, or other
    +    modifications\n      represent, as a whole, an original work of authorship. For
    +    the purposes\n      of this License, Derivative Works shall not include works
    +    that remain\n      separable from, or merely link (or bind by name) to the interfaces
    +    of,\n      the Work and Derivative Works thereof.\n\n      "Contribution"
    +    shall mean any work of authorship, including\n      the original version of the
    +    Work and any modifications or additions\n      to that Work or Derivative Works
    +    thereof, that is intentionally\n      submitted to Licensor for inclusion in the
    +    Work by the copyright owner\n      or by an individual or Legal Entity authorized
    +    to submit on behalf of\n      the copyright owner. For the purposes of this definition,
    +    "submitted"\n      means any form of electronic, verbal, or written
    +    communication sent\n      to the Licensor or its representatives, including but
    +    not limited to\n      communication on electronic mailing lists, source code control
    +    systems,\n      and issue tracking systems that are managed by, or on behalf of,
    +    the\n      Licensor for the purpose of discussing and improving the Work, but\n
    +    \     excluding communication that is conspicuously marked or otherwise\n      designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n      "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n      on behalf of whom
    +    a Contribution has been received by Licensor and\n      subsequently incorporated
    +    within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and
    +    conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n
    +    \     worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright
    +    license to reproduce, prepare Derivative Works of,\n      publicly display, publicly
    +    perform, sublicense, and distribute the\n      Work and such Derivative Works
    +    in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms
    +    and conditions of\n      this License, each Contributor hereby grants to You a
    +    perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n
    +    \     (except as stated in this section) patent license to make, have made,\n
    +    \     use, offer to sell, sell, import, and otherwise transfer the Work,\n      where
    +    such license applies only to those patent claims licensable\n      by such Contributor
    +    that are necessarily infringed by their\n      Contribution(s) alone or by combination
    +    of their Contribution(s)\n      with the Work to which such Contribution(s) was
    +    submitted. If You\n      institute patent litigation against any entity (including
    +    a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or
    +    a Contribution incorporated within the Work constitutes direct\n      or contributory
    +    patent infringement, then any patent licenses\n      granted to You under this
    +    License for that Work shall terminate\n      as of the date such litigation is
    +    filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n
    +    \     Work or Derivative Works thereof in any medium, with or without\n      modifications,
    +    and in Source or Object form, provided that You\n      meet the following conditions:\n\n
    +    \     (a) You must give any other recipients of the Work or\n          Derivative
    +    Works a copy of this License; and\n\n      (b) You must cause any modified files
    +    to carry prominent notices\n          stating that You changed the files; and\n\n
    +    \     (c) You must retain, in the Source form of any Derivative Works\n          that
    +    You distribute, all copyright, patent, trademark, and\n          attribution notices
    +    from the Source form of the Work,\n          excluding those notices that do not
    +    pertain to any part of\n          the Derivative Works; and\n\n      (d) If the
    +    Work includes a "NOTICE" text file as part of its\n          distribution,
    +    then any Derivative Works that You distribute must\n          include a readable
    +    copy of the attribution notices contained\n          within such NOTICE file,
    +    excluding those notices that do not\n          pertain to any part of the Derivative
    +    Works, in at least one\n          of the following places: within a NOTICE text
    +    file distributed\n          as part of the Derivative Works; within the Source
    +    form or\n          documentation, if provided along with the Derivative Works;
    +    or,\n          within a display generated by the Derivative Works, if and\n          wherever
    +    such third-party notices normally appear. The contents\n          of the NOTICE
    +    file are for informational purposes only and\n          do not modify the License.
    +    You may add Your own attribution\n          notices within Derivative Works that
    +    You distribute, alongside\n          or as an addendum to the NOTICE text from
    +    the Work, provided\n          that such additional attribution notices cannot
    +    be construed\n          as modifying the License.\n\n      You may add Your own
    +    copyright statement to Your modifications and\n      may provide additional or
    +    different license terms and conditions\n      for use, reproduction, or distribution
    +    of Your modifications, or\n      for any such Derivative Works as a whole, provided
    +    Your use,\n      reproduction, and distribution of the Work otherwise complies
    +    with\n      the conditions stated in this License.\n\n   5. Submission of Contributions.
    +    Unless You explicitly state otherwise,\n      any Contribution intentionally submitted
    +    for inclusion in the Work\n      by You to the Licensor shall be under the terms
    +    and conditions of\n      this License, without any additional terms or conditions.\n
    +    \     Notwithstanding the above, nothing herein shall supersede or modify\n      the
    +    terms of any separate license agreement you may have executed\n      with Licensor
    +    regarding such Contributions.\n\n   6. Trademarks. This License does not grant
    +    permission to use the trade\n      names, trademarks, service marks, or product
    +    names of the Licensor,\n      except as required for reasonable and customary
    +    use in describing the\n      origin of the Work and reproducing the content of
    +    the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable
    +    law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor
    +    provides its Contributions) on an "AS IS" BASIS,\n      WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without
    +    limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY,
    +    or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining
    +    the\n      appropriateness of using or redistributing the Work and assume any\n
    +    \     risks associated with Your exercise of permissions under this License.\n\n
    +    \  8. Limitation of Liability. In no event and under no legal theory,\n      whether
    +    in tort (including negligence), contract, or otherwise,\n      unless required
    +    by applicable law (such as deliberate and grossly\n      negligent acts) or agreed
    +    to in writing, shall any Contributor be\n      liable to You for damages, including
    +    any direct, indirect, special,\n      incidental, or consequential damages of
    +    any character arising as a\n      result of this License or out of the use or
    +    inability to use the\n      Work (including but not limited to damages for loss
    +    of goodwill,\n      work stoppage, computer failure or malfunction, or any and
    +    all\n      other commercial damages or losses), even if such Contributor\n      has
    +    been advised of the possibility of such damages.\n\n   9. Accepting Warranty or
    +    Additional Liability. While redistributing\n      the Work or Derivative Works
    +    thereof, You may choose to offer,\n      and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n      or other liability obligations and/or rights consistent
    +    with this\n      License. However, in accepting such obligations, You may act
    +    only\n      on Your own behalf and on Your sole responsibility, not on behalf\n
    +    \     of any other Contributor, and only if You agree to indemnify,\n      defend,
    +    and hold each Contributor harmless for any liability\n      incurred by, or claims
    +    asserted against, such Contributor by reason\n      of your accepting any such
    +    warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX:
    +    How to apply the Apache License to your work.\n\n      To apply the Apache License
    +    to your work, attach the following\n      boilerplate notice, with the fields
    +    enclosed by brackets "[]"\n      replaced with your own identifying
    +    information. (Don't include\n      the brackets!)  The text should be enclosed
    +    in the appropriate\n      comment syntax for the file format. We also recommend
    +    that a\n      file or class name and description of purpose be included on the\n
    +    \     same "printed page" as the copyright notice for easier\n      identification
    +    within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n
    +    \  Licensed under the Apache License, Version 2.0 (the "License");\n
    +    \  you may not use this file except in compliance with the License.\n   You may
    +    obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n
    +    \  Unless required by applicable law or agreed to in writing, software\n   distributed
    +    under the License is distributed on an "AS IS" BASIS,\n   WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for
    +    the specific language governing permissions and\n   limitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • clang-sys\n 1.8.1
    • \n
    \n + \
    \n                                 Apache
    +    License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n
    +    \  TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n
    +    \     "License" shall mean the terms and conditions for use, reproduction,\n
    +    \     and distribution as defined by Sections 1 through 9 of this document.\n\n
    +    \     "Licensor" shall mean the copyright owner or entity authorized
    +    by\n      the copyright owner that is granting the License.\n\n      "Legal
    +    Entity" shall mean the union of the acting entity and all\n      other entities
    +    that control, are controlled by, or are under common\n      control with that
    +    entity. For the purposes of this definition,\n      "control" means
    +    (i) the power, direct or indirect, to cause the\n      direction or management
    +    of such entity, whether by contract or\n      otherwise, or (ii) ownership of
    +    fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial
    +    ownership of such entity.\n\n      "You" (or "Your") shall
    +    mean an individual or Legal Entity\n      exercising permissions granted by this
    +    License.\n\n      "Source" form shall mean the preferred form for making
    +    modifications,\n      including but not limited to software source code, documentation\n
    +    \     source, and configuration files.\n\n      "Object" form shall
    +    mean any form resulting from mechanical\n      transformation or translation of
    +    a Source form, including but\n      not limited to compiled object code, generated
    +    documentation,\n      and conversions to other media types.\n\n      "Work"
    +    shall mean the work of authorship, whether in Source or\n      Object form, made
    +    available under the License, as indicated by a\n      copyright notice that is
    +    included in or attached to the work\n      (an example is provided in the Appendix
    +    below).\n\n      "Derivative Works" shall mean any work, whether in
    +    Source or Object\n      form, that is based on (or derived from) the Work and
    +    for which the\n      editorial revisions, annotations, elaborations, or other
    +    modifications\n      represent, as a whole, an original work of authorship. For
    +    the purposes\n      of this License, Derivative Works shall not include works
    +    that remain\n      separable from, or merely link (or bind by name) to the interfaces
    +    of,\n      the Work and Derivative Works thereof.\n\n      "Contribution"
    +    shall mean any work of authorship, including\n      the original version of the
    +    Work and any modifications or additions\n      to that Work or Derivative Works
    +    thereof, that is intentionally\n      submitted to Licensor for inclusion in the
    +    Work by the copyright owner\n      or by an individual or Legal Entity authorized
    +    to submit on behalf of\n      the copyright owner. For the purposes of this definition,
    +    "submitted"\n      means any form of electronic, verbal, or written
    +    communication sent\n      to the Licensor or its representatives, including but
    +    not limited to\n      communication on electronic mailing lists, source code control
    +    systems,\n      and issue tracking systems that are managed by, or on behalf of,
    +    the\n      Licensor for the purpose of discussing and improving the Work, but\n
    +    \     excluding communication that is conspicuously marked or otherwise\n      designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n      "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n      on behalf of whom
    +    a Contribution has been received by Licensor and\n      subsequently incorporated
    +    within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and
    +    conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n
    +    \     worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright
    +    license to reproduce, prepare Derivative Works of,\n      publicly display, publicly
    +    perform, sublicense, and distribute the\n      Work and such Derivative Works
    +    in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms
    +    and conditions of\n      this License, each Contributor hereby grants to You a
    +    perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n
    +    \     (except as stated in this section) patent license to make, have made,\n
    +    \     use, offer to sell, sell, import, and otherwise transfer the Work,\n      where
    +    such license applies only to those patent claims licensable\n      by such Contributor
    +    that are necessarily infringed by their\n      Contribution(s) alone or by combination
    +    of their Contribution(s)\n      with the Work to which such Contribution(s) was
    +    submitted. If You\n      institute patent litigation against any entity (including
    +    a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or
    +    a Contribution incorporated within the Work constitutes direct\n      or contributory
    +    patent infringement, then any patent licenses\n      granted to You under this
    +    License for that Work shall terminate\n      as of the date such litigation is
    +    filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n
    +    \     Work or Derivative Works thereof in any medium, with or without\n      modifications,
    +    and in Source or Object form, provided that You\n      meet the following conditions:\n\n
    +    \     (a) You must give any other recipients of the Work or\n          Derivative
    +    Works a copy of this License; and\n\n      (b) You must cause any modified files
    +    to carry prominent notices\n          stating that You changed the files; and\n\n
    +    \     (c) You must retain, in the Source form of any Derivative Works\n          that
    +    You distribute, all copyright, patent, trademark, and\n          attribution notices
    +    from the Source form of the Work,\n          excluding those notices that do not
    +    pertain to any part of\n          the Derivative Works; and\n\n      (d) If the
    +    Work includes a "NOTICE" text file as part of its\n          distribution,
    +    then any Derivative Works that You distribute must\n          include a readable
    +    copy of the attribution notices contained\n          within such NOTICE file,
    +    excluding those notices that do not\n          pertain to any part of the Derivative
    +    Works, in at least one\n          of the following places: within a NOTICE text
    +    file distributed\n          as part of the Derivative Works; within the Source
    +    form or\n          documentation, if provided along with the Derivative Works;
    +    or,\n          within a display generated by the Derivative Works, if and\n          wherever
    +    such third-party notices normally appear. The contents\n          of the NOTICE
    +    file are for informational purposes only and\n          do not modify the License.
    +    You may add Your own attribution\n          notices within Derivative Works that
    +    You distribute, alongside\n          or as an addendum to the NOTICE text from
    +    the Work, provided\n          that such additional attribution notices cannot
    +    be construed\n          as modifying the License.\n\n      You may add Your own
    +    copyright statement to Your modifications and\n      may provide additional or
    +    different license terms and conditions\n      for use, reproduction, or distribution
    +    of Your modifications, or\n      for any such Derivative Works as a whole, provided
    +    Your use,\n      reproduction, and distribution of the Work otherwise complies
    +    with\n      the conditions stated in this License.\n\n   5. Submission of Contributions.
    +    Unless You explicitly state otherwise,\n      any Contribution intentionally submitted
    +    for inclusion in the Work\n      by You to the Licensor shall be under the terms
    +    and conditions of\n      this License, without any additional terms or conditions.\n
    +    \     Notwithstanding the above, nothing herein shall supersede or modify\n      the
    +    terms of any separate license agreement you may have executed\n      with Licensor
    +    regarding such Contributions.\n\n   6. Trademarks. This License does not grant
    +    permission to use the trade\n      names, trademarks, service marks, or product
    +    names of the Licensor,\n      except as required for reasonable and customary
    +    use in describing the\n      origin of the Work and reproducing the content of
    +    the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable
    +    law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor
    +    provides its Contributions) on an "AS IS" BASIS,\n      WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without
    +    limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY,
    +    or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining
    +    the\n      appropriateness of using or redistributing the Work and assume any\n
    +    \     risks associated with Your exercise of permissions under this License.\n\n
    +    \  8. Limitation of Liability. In no event and under no legal theory,\n      whether
    +    in tort (including negligence), contract, or otherwise,\n      unless required
    +    by applicable law (such as deliberate and grossly\n      negligent acts) or agreed
    +    to in writing, shall any Contributor be\n      liable to You for damages, including
    +    any direct, indirect, special,\n      incidental, or consequential damages of
    +    any character arising as a\n      result of this License or out of the use or
    +    inability to use the\n      Work (including but not limited to damages for loss
    +    of goodwill,\n      work stoppage, computer failure or malfunction, or any and
    +    all\n      other commercial damages or losses), even if such Contributor\n      has
    +    been advised of the possibility of such damages.\n\n   9. Accepting Warranty or
    +    Additional Liability. While redistributing\n      the Work or Derivative Works
    +    thereof, You may choose to offer,\n      and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n      or other liability obligations and/or rights consistent
    +    with this\n      License. However, in accepting such obligations, You may act
    +    only\n      on Your own behalf and on Your sole responsibility, not on behalf\n
    +    \     of any other Contributor, and only if You agree to indemnify,\n      defend,
    +    and hold each Contributor harmless for any liability\n      incurred by, or claims
    +    asserted against, such Contributor by reason\n      of your accepting any such
    +    warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX:
    +    How to apply the Apache License to your work.\n\n      To apply the Apache License
    +    to your work, attach the following\n      boilerplate notice, with the fields
    +    enclosed by brackets "[]"\n      replaced with your own identifying
    +    information. (Don't include\n      the brackets!)  The text should be enclosed
    +    in the appropriate\n      comment syntax for the file format. We also recommend
    +    that a\n      file or class name and description of purpose be included on the\n
    +    \     same "printed page" as the copyright notice for easier\n      identification
    +    within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n
    +    \  Licensed under the Apache License, Version 2.0 (the "License");\n
    +    \  you may not use this file except in compliance with the License.\n   You may
    +    obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n
    +    \  Unless required by applicable law or agreed to in writing, software\n   distributed
    +    under the License is distributed on an "AS IS" BASIS,\n   WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for
    +    the specific language governing permissions and\n   limitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • windows-core\n 0.62.2
    • \n
    • windows-implement\n + \ 0.60.2
    • \n
    • windows-interface\n 0.59.3
    • \n + \
    • windows-link\n 0.1.3
    • \n
    • windows-link\n + \ 0.2.1
    • \n
    • windows-result\n 0.4.1
    • \n + \
    • windows-strings\n 0.5.1
    • \n
    • windows-sys\n + \ 0.59.0
    • \n
    • windows-sys\n 0.60.2
    • \n + \
    • windows-targets\n 0.52.6
    • \n
    • windows-targets\n + \ 0.53.3
    • \n
    • windows_aarch64_gnullvm\n 0.52.6
    • \n + \
    • windows_aarch64_gnullvm\n 0.53.0
    • \n
    • windows_aarch64_msvc\n + \ 0.52.6
    • \n
    • windows_aarch64_msvc\n 0.53.0
    • \n + \
    • windows_i686_gnu\n 0.52.6
    • \n
    • windows_i686_gnu\n + \ 0.53.0
    • \n
    • windows_i686_gnullvm\n 0.52.6
    • \n + \
    • windows_i686_gnullvm\n 0.53.0
    • \n
    • windows_i686_msvc\n + \ 0.52.6
    • \n
    • windows_i686_msvc\n 0.53.0
    • \n + \
    • windows_x86_64_gnu\n 0.52.6
    • \n
    • windows_x86_64_gnu\n + \ 0.53.0
    • \n
    • windows_x86_64_gnullvm\n 0.52.6
    • \n + \
    • windows_x86_64_gnullvm\n 0.53.0
    • \n
    • windows_x86_64_msvc\n + \ 0.52.6
    • \n
    • windows_x86_64_msvc\n 0.53.0
    • \n + \
    \n
                                     Apache
    +    License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n
    +    \  TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n
    +    \     "License" shall mean the terms and conditions for use, reproduction,\n
    +    \     and distribution as defined by Sections 1 through 9 of this document.\n\n
    +    \     "Licensor" shall mean the copyright owner or entity authorized
    +    by\n      the copyright owner that is granting the License.\n\n      "Legal
    +    Entity" shall mean the union of the acting entity and all\n      other entities
    +    that control, are controlled by, or are under common\n      control with that
    +    entity. For the purposes of this definition,\n      "control" means
    +    (i) the power, direct or indirect, to cause the\n      direction or management
    +    of such entity, whether by contract or\n      otherwise, or (ii) ownership of
    +    fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial
    +    ownership of such entity.\n\n      "You" (or "Your") shall
    +    mean an individual or Legal Entity\n      exercising permissions granted by this
    +    License.\n\n      "Source" form shall mean the preferred form for making
    +    modifications,\n      including but not limited to software source code, documentation\n
    +    \     source, and configuration files.\n\n      "Object" form shall
    +    mean any form resulting from mechanical\n      transformation or translation of
    +    a Source form, including but\n      not limited to compiled object code, generated
    +    documentation,\n      and conversions to other media types.\n\n      "Work"
    +    shall mean the work of authorship, whether in Source or\n      Object form, made
    +    available under the License, as indicated by a\n      copyright notice that is
    +    included in or attached to the work\n      (an example is provided in the Appendix
    +    below).\n\n      "Derivative Works" shall mean any work, whether in
    +    Source or Object\n      form, that is based on (or derived from) the Work and
    +    for which the\n      editorial revisions, annotations, elaborations, or other
    +    modifications\n      represent, as a whole, an original work of authorship. For
    +    the purposes\n      of this License, Derivative Works shall not include works
    +    that remain\n      separable from, or merely link (or bind by name) to the interfaces
    +    of,\n      the Work and Derivative Works thereof.\n\n      "Contribution"
    +    shall mean any work of authorship, including\n      the original version of the
    +    Work and any modifications or additions\n      to that Work or Derivative Works
    +    thereof, that is intentionally\n      submitted to Licensor for inclusion in the
    +    Work by the copyright owner\n      or by an individual or Legal Entity authorized
    +    to submit on behalf of\n      the copyright owner. For the purposes of this definition,
    +    "submitted"\n      means any form of electronic, verbal, or written
    +    communication sent\n      to the Licensor or its representatives, including but
    +    not limited to\n      communication on electronic mailing lists, source code control
    +    systems,\n      and issue tracking systems that are managed by, or on behalf of,
    +    the\n      Licensor for the purpose of discussing and improving the Work, but\n
    +    \     excluding communication that is conspicuously marked or otherwise\n      designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n      "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n      on behalf of whom
    +    a Contribution has been received by Licensor and\n      subsequently incorporated
    +    within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and
    +    conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n
    +    \     worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright
    +    license to reproduce, prepare Derivative Works of,\n      publicly display, publicly
    +    perform, sublicense, and distribute the\n      Work and such Derivative Works
    +    in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms
    +    and conditions of\n      this License, each Contributor hereby grants to You a
    +    perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n
    +    \     (except as stated in this section) patent license to make, have made,\n
    +    \     use, offer to sell, sell, import, and otherwise transfer the Work,\n      where
    +    such license applies only to those patent claims licensable\n      by such Contributor
    +    that are necessarily infringed by their\n      Contribution(s) alone or by combination
    +    of their Contribution(s)\n      with the Work to which such Contribution(s) was
    +    submitted. If You\n      institute patent litigation against any entity (including
    +    a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or
    +    a Contribution incorporated within the Work constitutes direct\n      or contributory
    +    patent infringement, then any patent licenses\n      granted to You under this
    +    License for that Work shall terminate\n      as of the date such litigation is
    +    filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n
    +    \     Work or Derivative Works thereof in any medium, with or without\n      modifications,
    +    and in Source or Object form, provided that You\n      meet the following conditions:\n\n
    +    \     (a) You must give any other recipients of the Work or\n          Derivative
    +    Works a copy of this License; and\n\n      (b) You must cause any modified files
    +    to carry prominent notices\n          stating that You changed the files; and\n\n
    +    \     (c) You must retain, in the Source form of any Derivative Works\n          that
    +    You distribute, all copyright, patent, trademark, and\n          attribution notices
    +    from the Source form of the Work,\n          excluding those notices that do not
    +    pertain to any part of\n          the Derivative Works; and\n\n      (d) If the
    +    Work includes a "NOTICE" text file as part of its\n          distribution,
    +    then any Derivative Works that You distribute must\n          include a readable
    +    copy of the attribution notices contained\n          within such NOTICE file,
    +    excluding those notices that do not\n          pertain to any part of the Derivative
    +    Works, in at least one\n          of the following places: within a NOTICE text
    +    file distributed\n          as part of the Derivative Works; within the Source
    +    form or\n          documentation, if provided along with the Derivative Works;
    +    or,\n          within a display generated by the Derivative Works, if and\n          wherever
    +    such third-party notices normally appear. The contents\n          of the NOTICE
    +    file are for informational purposes only and\n          do not modify the License.
    +    You may add Your own attribution\n          notices within Derivative Works that
    +    You distribute, alongside\n          or as an addendum to the NOTICE text from
    +    the Work, provided\n          that such additional attribution notices cannot
    +    be construed\n          as modifying the License.\n\n      You may add Your own
    +    copyright statement to Your modifications and\n      may provide additional or
    +    different license terms and conditions\n      for use, reproduction, or distribution
    +    of Your modifications, or\n      for any such Derivative Works as a whole, provided
    +    Your use,\n      reproduction, and distribution of the Work otherwise complies
    +    with\n      the conditions stated in this License.\n\n   5. Submission of Contributions.
    +    Unless You explicitly state otherwise,\n      any Contribution intentionally submitted
    +    for inclusion in the Work\n      by You to the Licensor shall be under the terms
    +    and conditions of\n      this License, without any additional terms or conditions.\n
    +    \     Notwithstanding the above, nothing herein shall supersede or modify\n      the
    +    terms of any separate license agreement you may have executed\n      with Licensor
    +    regarding such Contributions.\n\n   6. Trademarks. This License does not grant
    +    permission to use the trade\n      names, trademarks, service marks, or product
    +    names of the Licensor,\n      except as required for reasonable and customary
    +    use in describing the\n      origin of the Work and reproducing the content of
    +    the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable
    +    law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor
    +    provides its Contributions) on an "AS IS" BASIS,\n      WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without
    +    limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY,
    +    or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining
    +    the\n      appropriateness of using or redistributing the Work and assume any\n
    +    \     risks associated with Your exercise of permissions under this License.\n\n
    +    \  8. Limitation of Liability. In no event and under no legal theory,\n      whether
    +    in tort (including negligence), contract, or otherwise,\n      unless required
    +    by applicable law (such as deliberate and grossly\n      negligent acts) or agreed
    +    to in writing, shall any Contributor be\n      liable to You for damages, including
    +    any direct, indirect, special,\n      incidental, or consequential damages of
    +    any character arising as a\n      result of this License or out of the use or
    +    inability to use the\n      Work (including but not limited to damages for loss
    +    of goodwill,\n      work stoppage, computer failure or malfunction, or any and
    +    all\n      other commercial damages or losses), even if such Contributor\n      has
    +    been advised of the possibility of such damages.\n\n   9. Accepting Warranty or
    +    Additional Liability. While redistributing\n      the Work or Derivative Works
    +    thereof, You may choose to offer,\n      and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n      or other liability obligations and/or rights consistent
    +    with this\n      License. However, in accepting such obligations, You may act
    +    only\n      on Your own behalf and on Your sole responsibility, not on behalf\n
    +    \     of any other Contributor, and only if You agree to indemnify,\n      defend,
    +    and hold each Contributor harmless for any liability\n      incurred by, or claims
    +    asserted against, such Contributor by reason\n      of your accepting any such
    +    warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX:
    +    How to apply the Apache License to your work.\n\n      To apply the Apache License
    +    to your work, attach the following\n      boilerplate notice, with the fields
    +    enclosed by brackets "[]"\n      replaced with your own identifying
    +    information. (Don't include\n      the brackets!)  The text should be enclosed
    +    in the appropriate\n      comment syntax for the file format. We also recommend
    +    that a\n      file or class name and description of purpose be included on the\n
    +    \     same "printed page" as the copyright notice for easier\n      identification
    +    within third-party archives.\n\n   Copyright (c) Microsoft Corporation.\n\n   Licensed
    +    under the Apache License, Version 2.0 (the "License");\n   you may not
    +    use this file except in compliance with the License.\n   You may obtain a copy
    +    of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless
    +    required by applicable law or agreed to in writing, software\n   distributed under
    +    the License is distributed on an "AS IS" BASIS,\n   WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for
    +    the specific language governing permissions and\n   limitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • bytecount\n 0.6.9
    • \n
    • normalize-line-endings\n 0.3.0
    • \n
    • predicates-core\n 1.0.9
    • \n
    • predicates-tree\n 1.0.12
    • \n
    • predicates\n 3.1.3
    • \n
    \n + \
                                     Apache
    +    License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n
    +    \  TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n
    +    \     "License" shall mean the terms and conditions for use, reproduction,\n
    +    \     and distribution as defined by Sections 1 through 9 of this document.\n\n
    +    \     "Licensor" shall mean the copyright owner or entity authorized
    +    by\n      the copyright owner that is granting the License.\n\n      "Legal
    +    Entity" shall mean the union of the acting entity and all\n      other entities
    +    that control, are controlled by, or are under common\n      control with that
    +    entity. For the purposes of this definition,\n      "control" means
    +    (i) the power, direct or indirect, to cause the\n      direction or management
    +    of such entity, whether by contract or\n      otherwise, or (ii) ownership of
    +    fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial
    +    ownership of such entity.\n\n      "You" (or "Your") shall
    +    mean an individual or Legal Entity\n      exercising permissions granted by this
    +    License.\n\n      "Source" form shall mean the preferred form for making
    +    modifications,\n      including but not limited to software source code, documentation\n
    +    \     source, and configuration files.\n\n      "Object" form shall
    +    mean any form resulting from mechanical\n      transformation or translation of
    +    a Source form, including but\n      not limited to compiled object code, generated
    +    documentation,\n      and conversions to other media types.\n\n      "Work"
    +    shall mean the work of authorship, whether in Source or\n      Object form, made
    +    available under the License, as indicated by a\n      copyright notice that is
    +    included in or attached to the work\n      (an example is provided in the Appendix
    +    below).\n\n      "Derivative Works" shall mean any work, whether in
    +    Source or Object\n      form, that is based on (or derived from) the Work and
    +    for which the\n      editorial revisions, annotations, elaborations, or other
    +    modifications\n      represent, as a whole, an original work of authorship. For
    +    the purposes\n      of this License, Derivative Works shall not include works
    +    that remain\n      separable from, or merely link (or bind by name) to the interfaces
    +    of,\n      the Work and Derivative Works thereof.\n\n      "Contribution"
    +    shall mean any work of authorship, including\n      the original version of the
    +    Work and any modifications or additions\n      to that Work or Derivative Works
    +    thereof, that is intentionally\n      submitted to Licensor for inclusion in the
    +    Work by the copyright owner\n      or by an individual or Legal Entity authorized
    +    to submit on behalf of\n      the copyright owner. For the purposes of this definition,
    +    "submitted"\n      means any form of electronic, verbal, or written
    +    communication sent\n      to the Licensor or its representatives, including but
    +    not limited to\n      communication on electronic mailing lists, source code control
    +    systems,\n      and issue tracking systems that are managed by, or on behalf of,
    +    the\n      Licensor for the purpose of discussing and improving the Work, but\n
    +    \     excluding communication that is conspicuously marked or otherwise\n      designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n      "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n      on behalf of whom
    +    a Contribution has been received by Licensor and\n      subsequently incorporated
    +    within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and
    +    conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n
    +    \     worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright
    +    license to reproduce, prepare Derivative Works of,\n      publicly display, publicly
    +    perform, sublicense, and distribute the\n      Work and such Derivative Works
    +    in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms
    +    and conditions of\n      this License, each Contributor hereby grants to You a
    +    perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n
    +    \     (except as stated in this section) patent license to make, have made,\n
    +    \     use, offer to sell, sell, import, and otherwise transfer the Work,\n      where
    +    such license applies only to those patent claims licensable\n      by such Contributor
    +    that are necessarily infringed by their\n      Contribution(s) alone or by combination
    +    of their Contribution(s)\n      with the Work to which such Contribution(s) was
    +    submitted. If You\n      institute patent litigation against any entity (including
    +    a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or
    +    a Contribution incorporated within the Work constitutes direct\n      or contributory
    +    patent infringement, then any patent licenses\n      granted to You under this
    +    License for that Work shall terminate\n      as of the date such litigation is
    +    filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n
    +    \     Work or Derivative Works thereof in any medium, with or without\n      modifications,
    +    and in Source or Object form, provided that You\n      meet the following conditions:\n\n
    +    \     (a) You must give any other recipients of the Work or\n          Derivative
    +    Works a copy of this License; and\n\n      (b) You must cause any modified files
    +    to carry prominent notices\n          stating that You changed the files; and\n\n
    +    \     (c) You must retain, in the Source form of any Derivative Works\n          that
    +    You distribute, all copyright, patent, trademark, and\n          attribution notices
    +    from the Source form of the Work,\n          excluding those notices that do not
    +    pertain to any part of\n          the Derivative Works; and\n\n      (d) If the
    +    Work includes a "NOTICE" text file as part of its\n          distribution,
    +    then any Derivative Works that You distribute must\n          include a readable
    +    copy of the attribution notices contained\n          within such NOTICE file,
    +    excluding those notices that do not\n          pertain to any part of the Derivative
    +    Works, in at least one\n          of the following places: within a NOTICE text
    +    file distributed\n          as part of the Derivative Works; within the Source
    +    form or\n          documentation, if provided along with the Derivative Works;
    +    or,\n          within a display generated by the Derivative Works, if and\n          wherever
    +    such third-party notices normally appear. The contents\n          of the NOTICE
    +    file are for informational purposes only and\n          do not modify the License.
    +    You may add Your own attribution\n          notices within Derivative Works that
    +    You distribute, alongside\n          or as an addendum to the NOTICE text from
    +    the Work, provided\n          that such additional attribution notices cannot
    +    be construed\n          as modifying the License.\n\n      You may add Your own
    +    copyright statement to Your modifications and\n      may provide additional or
    +    different license terms and conditions\n      for use, reproduction, or distribution
    +    of Your modifications, or\n      for any such Derivative Works as a whole, provided
    +    Your use,\n      reproduction, and distribution of the Work otherwise complies
    +    with\n      the conditions stated in this License.\n\n   5. Submission of Contributions.
    +    Unless You explicitly state otherwise,\n      any Contribution intentionally submitted
    +    for inclusion in the Work\n      by You to the Licensor shall be under the terms
    +    and conditions of\n      this License, without any additional terms or conditions.\n
    +    \     Notwithstanding the above, nothing herein shall supersede or modify\n      the
    +    terms of any separate license agreement you may have executed\n      with Licensor
    +    regarding such Contributions.\n\n   6. Trademarks. This License does not grant
    +    permission to use the trade\n      names, trademarks, service marks, or product
    +    names of the Licensor,\n      except as required for reasonable and customary
    +    use in describing the\n      origin of the Work and reproducing the content of
    +    the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable
    +    law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor
    +    provides its Contributions) on an "AS IS" BASIS,\n      WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without
    +    limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY,
    +    or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining
    +    the\n      appropriateness of using or redistributing the Work and assume any\n
    +    \     risks associated with Your exercise of permissions under this License.\n\n
    +    \  8. Limitation of Liability. In no event and under no legal theory,\n      whether
    +    in tort (including negligence), contract, or otherwise,\n      unless required
    +    by applicable law (such as deliberate and grossly\n      negligent acts) or agreed
    +    to in writing, shall any Contributor be\n      liable to You for damages, including
    +    any direct, indirect, special,\n      incidental, or consequential damages of
    +    any character arising as a\n      result of this License or out of the use or
    +    inability to use the\n      Work (including but not limited to damages for loss
    +    of goodwill,\n      work stoppage, computer failure or malfunction, or any and
    +    all\n      other commercial damages or losses), even if such Contributor\n      has
    +    been advised of the possibility of such damages.\n\n   9. Accepting Warranty or
    +    Additional Liability. While redistributing\n      the Work or Derivative Works
    +    thereof, You may choose to offer,\n      and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n      or other liability obligations and/or rights consistent
    +    with this\n      License. However, in accepting such obligations, You may act
    +    only\n      on Your own behalf and on Your sole responsibility, not on behalf\n
    +    \     of any other Contributor, and only if You agree to indemnify,\n      defend,
    +    and hold each Contributor harmless for any liability\n      incurred by, or claims
    +    asserted against, such Contributor by reason\n      of your accepting any such
    +    warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX:
    +    How to apply the Apache License to your work.\n\n      To apply the Apache License
    +    to your work, attach the following\n      boilerplate notice, with the fields
    +    enclosed by brackets "{}"\n      replaced with your own identifying
    +    information. (Don't include\n      the brackets!)  The text should be enclosed
    +    in the appropriate\n      comment syntax for the file format. We also recommend
    +    that a\n      file or class name and description of purpose be included on the\n
    +    \     same "printed page" as the copyright notice for easier\n      identification
    +    within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n
    +    \  Licensed under the Apache License, Version 2.0 (the "License");\n
    +    \  you may not use this file except in compliance with the License.\n   You may
    +    obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n
    +    \  Unless required by applicable law or agreed to in writing, software\n   distributed
    +    under the License is distributed on an "AS IS" BASIS,\n   WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for
    +    the specific language governing permissions and\n   limitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • anstream\n 0.6.19
    • \n
    • anstyle-parse\n + \ 0.2.7
    • \n
    • anstyle-query\n 1.1.3
    • \n + \
    • anstyle-wincon\n 3.0.9
    • \n
    • anstyle\n + \ 1.0.11
    • \n
    • assert_cmd\n 2.0.17
    • \n + \
    • clap\n 4.5.41
    • \n
    • clap_builder\n + \ 4.5.41
    • \n
    • clap_derive\n 4.5.41
    • \n + \
    • clap_lex\n 0.7.5
    • \n
    • colorchoice\n + \ 1.0.4
    • \n
    • is_terminal_polyfill\n + \ 1.70.1
    • \n
    • once_cell_polyfill\n 1.70.1
    • \n + \
    • serde_spanned\n 0.6.9
    • \n
    • serde_spanned\n + \ 1.1.1
    • \n
    • tikv-jemalloc-ctl\n 0.7.0
    • \n + \
    • toml\n 0.8.23
    • \n
    • toml\n + \ 1.1.2+spec-1.1.0
    • \n
    • toml_datetime\n + \ 0.6.11
    • \n
    • toml_datetime\n 1.1.1+spec-1.1.0
    • \n + \
    • toml_edit\n 0.22.27
    • \n
    • toml_parser\n + \ 1.1.2+spec-1.1.0
    • \n
    • toml_write\n + \ 0.1.2
    • \n
    • toml_writer\n 1.1.1+spec-1.1.0
    • \n + \
    \n
                                     Apache
    +    License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n
    +    \  TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n
    +    \     "License" shall mean the terms and conditions for use, reproduction,\n
    +    \     and distribution as defined by Sections 1 through 9 of this document.\n\n
    +    \     "Licensor" shall mean the copyright owner or entity authorized
    +    by\n      the copyright owner that is granting the License.\n\n      "Legal
    +    Entity" shall mean the union of the acting entity and all\n      other entities
    +    that control, are controlled by, or are under common\n      control with that
    +    entity. For the purposes of this definition,\n      "control" means
    +    (i) the power, direct or indirect, to cause the\n      direction or management
    +    of such entity, whether by contract or\n      otherwise, or (ii) ownership of
    +    fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial
    +    ownership of such entity.\n\n      "You" (or "Your") shall
    +    mean an individual or Legal Entity\n      exercising permissions granted by this
    +    License.\n\n      "Source" form shall mean the preferred form for making
    +    modifications,\n      including but not limited to software source code, documentation\n
    +    \     source, and configuration files.\n\n      "Object" form shall
    +    mean any form resulting from mechanical\n      transformation or translation of
    +    a Source form, including but\n      not limited to compiled object code, generated
    +    documentation,\n      and conversions to other media types.\n\n      "Work"
    +    shall mean the work of authorship, whether in Source or\n      Object form, made
    +    available under the License, as indicated by a\n      copyright notice that is
    +    included in or attached to the work\n      (an example is provided in the Appendix
    +    below).\n\n      "Derivative Works" shall mean any work, whether in
    +    Source or Object\n      form, that is based on (or derived from) the Work and
    +    for which the\n      editorial revisions, annotations, elaborations, or other
    +    modifications\n      represent, as a whole, an original work of authorship. For
    +    the purposes\n      of this License, Derivative Works shall not include works
    +    that remain\n      separable from, or merely link (or bind by name) to the interfaces
    +    of,\n      the Work and Derivative Works thereof.\n\n      "Contribution"
    +    shall mean any work of authorship, including\n      the original version of the
    +    Work and any modifications or additions\n      to that Work or Derivative Works
    +    thereof, that is intentionally\n      submitted to Licensor for inclusion in the
    +    Work by the copyright owner\n      or by an individual or Legal Entity authorized
    +    to submit on behalf of\n      the copyright owner. For the purposes of this definition,
    +    "submitted"\n      means any form of electronic, verbal, or written
    +    communication sent\n      to the Licensor or its representatives, including but
    +    not limited to\n      communication on electronic mailing lists, source code control
    +    systems,\n      and issue tracking systems that are managed by, or on behalf of,
    +    the\n      Licensor for the purpose of discussing and improving the Work, but\n
    +    \     excluding communication that is conspicuously marked or otherwise\n      designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n      "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n      on behalf of whom
    +    a Contribution has been received by Licensor and\n      subsequently incorporated
    +    within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and
    +    conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n
    +    \     worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright
    +    license to reproduce, prepare Derivative Works of,\n      publicly display, publicly
    +    perform, sublicense, and distribute the\n      Work and such Derivative Works
    +    in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms
    +    and conditions of\n      this License, each Contributor hereby grants to You a
    +    perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n
    +    \     (except as stated in this section) patent license to make, have made,\n
    +    \     use, offer to sell, sell, import, and otherwise transfer the Work,\n      where
    +    such license applies only to those patent claims licensable\n      by such Contributor
    +    that are necessarily infringed by their\n      Contribution(s) alone or by combination
    +    of their Contribution(s)\n      with the Work to which such Contribution(s) was
    +    submitted. If You\n      institute patent litigation against any entity (including
    +    a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or
    +    a Contribution incorporated within the Work constitutes direct\n      or contributory
    +    patent infringement, then any patent licenses\n      granted to You under this
    +    License for that Work shall terminate\n      as of the date such litigation is
    +    filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n
    +    \     Work or Derivative Works thereof in any medium, with or without\n      modifications,
    +    and in Source or Object form, provided that You\n      meet the following conditions:\n\n
    +    \     (a) You must give any other recipients of the Work or\n          Derivative
    +    Works a copy of this License; and\n\n      (b) You must cause any modified files
    +    to carry prominent notices\n          stating that You changed the files; and\n\n
    +    \     (c) You must retain, in the Source form of any Derivative Works\n          that
    +    You distribute, all copyright, patent, trademark, and\n          attribution notices
    +    from the Source form of the Work,\n          excluding those notices that do not
    +    pertain to any part of\n          the Derivative Works; and\n\n      (d) If the
    +    Work includes a "NOTICE" text file as part of its\n          distribution,
    +    then any Derivative Works that You distribute must\n          include a readable
    +    copy of the attribution notices contained\n          within such NOTICE file,
    +    excluding those notices that do not\n          pertain to any part of the Derivative
    +    Works, in at least one\n          of the following places: within a NOTICE text
    +    file distributed\n          as part of the Derivative Works; within the Source
    +    form or\n          documentation, if provided along with the Derivative Works;
    +    or,\n          within a display generated by the Derivative Works, if and\n          wherever
    +    such third-party notices normally appear. The contents\n          of the NOTICE
    +    file are for informational purposes only and\n          do not modify the License.
    +    You may add Your own attribution\n          notices within Derivative Works that
    +    You distribute, alongside\n          or as an addendum to the NOTICE text from
    +    the Work, provided\n          that such additional attribution notices cannot
    +    be construed\n          as modifying the License.\n\n      You may add Your own
    +    copyright statement to Your modifications and\n      may provide additional or
    +    different license terms and conditions\n      for use, reproduction, or distribution
    +    of Your modifications, or\n      for any such Derivative Works as a whole, provided
    +    Your use,\n      reproduction, and distribution of the Work otherwise complies
    +    with\n      the conditions stated in this License.\n\n   5. Submission of Contributions.
    +    Unless You explicitly state otherwise,\n      any Contribution intentionally submitted
    +    for inclusion in the Work\n      by You to the Licensor shall be under the terms
    +    and conditions of\n      this License, without any additional terms or conditions.\n
    +    \     Notwithstanding the above, nothing herein shall supersede or modify\n      the
    +    terms of any separate license agreement you may have executed\n      with Licensor
    +    regarding such Contributions.\n\n   6. Trademarks. This License does not grant
    +    permission to use the trade\n      names, trademarks, service marks, or product
    +    names of the Licensor,\n      except as required for reasonable and customary
    +    use in describing the\n      origin of the Work and reproducing the content of
    +    the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable
    +    law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor
    +    provides its Contributions) on an "AS IS" BASIS,\n      WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without
    +    limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY,
    +    or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining
    +    the\n      appropriateness of using or redistributing the Work and assume any\n
    +    \     risks associated with Your exercise of permissions under this License.\n\n
    +    \  8. Limitation of Liability. In no event and under no legal theory,\n      whether
    +    in tort (including negligence), contract, or otherwise,\n      unless required
    +    by applicable law (such as deliberate and grossly\n      negligent acts) or agreed
    +    to in writing, shall any Contributor be\n      liable to You for damages, including
    +    any direct, indirect, special,\n      incidental, or consequential damages of
    +    any character arising as a\n      result of this License or out of the use or
    +    inability to use the\n      Work (including but not limited to damages for loss
    +    of goodwill,\n      work stoppage, computer failure or malfunction, or any and
    +    all\n      other commercial damages or losses), even if such Contributor\n      has
    +    been advised of the possibility of such damages.\n\n   9. Accepting Warranty or
    +    Additional Liability. While redistributing\n      the Work or Derivative Works
    +    thereof, You may choose to offer,\n      and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n      or other liability obligations and/or rights consistent
    +    with this\n      License. However, in accepting such obligations, You may act
    +    only\n      on Your own behalf and on Your sole responsibility, not on behalf\n
    +    \     of any other Contributor, and only if You agree to indemnify,\n      defend,
    +    and hold each Contributor harmless for any liability\n      incurred by, or claims
    +    asserted against, such Contributor by reason\n      of your accepting any such
    +    warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX:
    +    How to apply the Apache License to your work.\n\n      To apply the Apache License
    +    to your work, attach the following\n      boilerplate notice, with the fields
    +    enclosed by brackets "{}"\n      replaced with your own identifying
    +    information. (Don't include\n      the brackets!)  The text should be enclosed
    +    in the appropriate\n      comment syntax for the file format. We also recommend
    +    that a\n      file or class name and description of purpose be included on the\n
    +    \     same "printed page" as the copyright notice for easier\n      identification
    +    within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n
    +    \  Licensed under the Apache License, Version 2.0 (the "License");\n
    +    \  you may not use this file except in compliance with the License.\n   You may
    +    obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n
    +    \  Unless required by applicable law or agreed to in writing, software\n   distributed
    +    under the License is distributed on an "AS IS" BASIS,\n   WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for
    +    the specific language governing permissions and\n   limitations under the License.\n\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • futures-channel\n 0.3.31
    • \n
    • futures-core\n + \ 0.3.31
    • \n
    • futures-executor\n 0.3.31
    • \n + \
    • futures-io\n 0.3.31
    • \n
    • futures-macro\n + \ 0.3.31
    • \n
    • futures-sink\n 0.3.31
    • \n + \
    • futures-task\n 0.3.31
    • \n
    • futures-util\n + \ 0.3.31
    • \n
    • futures\n 0.3.31
    • \n + \
    \n
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nLicensed under
    +    the Apache License, Version 2.0 (the "License");\nyou may not use this
    +    file except in compliance with the License.\nYou may obtain a copy of the License
    +    at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable
    +    law or agreed to in writing, software\ndistributed under the License is distributed
    +    on an "AS IS" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or implied.\nSee the License for the specific language governing
    +    permissions and\nlimitations under the License.\n
    \n
  • \n
  • \n

    Apache License 2.0

    \n + \

    Used by:

    \n
      \n + \
    • pin-utils\n 0.1.0
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    2018 The pin-utils authors\n\nLicensed under the Apache License, Version 2.0 (the
    +    "License");\nyou may not use this file except in compliance with the
    +    License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • iana-time-zone-haiku\n 0.1.2
    • \n
    • iana-time-zone\n 0.1.65
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    2020 Andrew Straw\n\nLicensed under the Apache License, Version 2.0 (the "License");\nyou
    +    may not use this file except in compliance with the License.\nYou may obtain a
    +    copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • autocfg\n 1.5.0
    • \n
    • base64\n 0.22.1
    • \n
    • bitflags\n + \ 2.9.1
    • \n
    • bstr\n 1.12.0
    • \n + \
    • bumpalo\n 3.19.1
    • \n
    • cc\n + \ 1.2.30
    • \n
    • cexpr\n 0.6.0
    • \n + \
    • cfg-if\n 1.0.1
    • \n
    • core-foundation-sys\n 0.8.7
    • \n
    • crossbeam-channel\n 0.5.15
    • \n
    • crossbeam-deque\n 0.8.6
    • \n
    • crossbeam-epoch\n 0.9.18
    • \n
    • crossbeam-utils\n 0.8.21
    • \n
    • displaydoc\n + \ 0.2.5
    • \n
    • either\n 1.15.0
    • \n + \
    • equivalent\n 1.0.2
    • \n
    • errno\n 0.3.13
    • \n
    • fastrand\n + \ 2.3.0
    • \n
    • form_urlencoded\n 1.2.1
    • \n + \
    • glob\n 0.3.2
    • \n
    • hashbrown\n + \ 0.17.1
    • \n
    • heck\n 0.5.0
    • \n + \
    • idna\n 1.0.3
    • \n
    • idna_adapter\n + \ 1.2.1
    • \n
    • indexmap\n 2.14.0
    • \n + \
    • itertools\n 0.13.0
    • \n
    • js-sys\n 0.3.85
    • \n
    • linux-raw-sys\n 0.9.4
    • \n
    • log\n + \ 0.4.27
    • \n
    • num-traits\n 0.2.19
    • \n + \
    • once_cell\n 1.21.3
    • \n
    • percent-encoding\n + \ 2.3.1
    • \n
    • regex-automata\n + \ 0.4.9
    • \n
    • regex-syntax\n + \ 0.8.5
    • \n
    • regex\n 1.11.1
    • \n + \
    • rustix\n 1.0.8
    • \n
    • smallvec\n + \ 1.15.1
    • \n
    • stable_deref_trait\n 1.2.0
    • \n + \
    • tempfile\n 3.20.0
    • \n
    • tikv-jemalloc-sys\n + \ 0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8
    • \n + \
    • tikv-jemallocator\n 0.7.0
    • \n
    • url\n + \ 2.5.4
    • \n
    • wait-timeout\n 0.2.1
    • \n + \
    • wasi\n 0.14.2+wasi-0.2.4
    • \n
    • wasm-bindgen-macro-support\n 0.2.108
    • \n + \
    • wasm-bindgen-macro\n 0.2.108
    • \n
    • wasm-bindgen-shared\n 0.2.108
    • \n
    • wasm-bindgen\n 0.2.108
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version
    +    2.0 (the "License");\nyou may not use this file except in compliance
    +    with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • minimal-lexical\n 0.2.1
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version
    +    2.0 (the "License");\nyou may not use this file except in compliance
    +    with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • getrandom\n 0.3.3
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     https://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version
    +    2.0 (the "License");\nyou may not use this file except in compliance
    +    with the License.\nYou may obtain a copy of the License at\n\n\thttps://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • text-size\n 1.1.1
    • \n
    \n + \
                                  Apache
    +    License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version
    +    2.0 (the "License");\nyou may not use this file except in compliance
    +    with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n
    \n + \
  • \n
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • android_system_properties\n 0.1.5
    • \n
    • async-trait\n + \ 0.1.89
    • \n
    • dyn-clone\n 1.0.20
    • \n + \
    • ident_case\n 1.0.1
    • \n
    • itoa\n + \ 1.0.15
    • \n
    • libc\n 0.2.174
    • \n + \
    • line-index\n 0.1.2
    • \n
    • paste\n + \ 1.0.15
    • \n
    • pastey\n 0.2.1
    • \n + \
    • pin-project-lite\n 0.2.16
    • \n
    • prettyplease\n + \ 0.2.36
    • \n
    • proc-macro2\n 1.0.95
    • \n + \
    • quote\n 1.0.40
    • \n
    • r-efi\n + \ 5.3.0
    • \n
    • ref-cast-impl\n 1.0.25
    • \n + \
    • ref-cast\n 1.0.25
    • \n
    • rmcp-macros\n 1.6.0
    • \n
    • rmcp\n 1.4.0
    • \n
    • rustc-hash\n + \ 2.1.1
    • \n
    • rustversion\n 1.0.22
    • \n + \
    • ryu\n 1.0.20
    • \n
    • serde\n + \ 1.0.228
    • \n
    • serde_core\n 1.0.228
    • \n + \
    • serde_derive\n 1.0.228
    • \n
    • serde_derive_internals\n + \ 0.29.1
    • \n
    • serde_json\n 1.0.141
    • \n + \
    • serde_yaml\n 0.9.34+deprecated
    • \n
    • shlex\n + \ 1.3.0
    • \n
    • syn\n 2.0.104
    • \n + \
    • thiserror-impl\n 2.0.18
    • \n
    • thiserror\n + \ 2.0.18
    • \n
    • unicode-ident\n 1.0.18
    • \n + \
    • utf8parse\n 0.2.2
    • \n
    • wit-bindgen-rt\n 0.39.0
    • \n
    \n + \
    Apache License\nVersion 2.0, January
    +    2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION,
    +    AND DISTRIBUTION\n\n1. Definitions.\n\n"License" shall mean the terms
    +    and conditions for use, reproduction, and distribution as defined by Sections
    +    1 through 9 of this document.\n\n"Licensor" shall mean the copyright
    +    owner or entity authorized by the copyright owner that is granting the License.\n\n"Legal
    +    Entity" shall mean the union of the acting entity and all other entities
    +    that control, are controlled by, or are under common control with that entity.
    +    For the purposes of this definition, "control" means (i) the power,
    +    direct or indirect, to cause the direction or management of such entity, whether
    +    by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of
    +    the outstanding shares, or (iii) beneficial ownership of such entity.\n\n"You"
    +    (or "Your") shall mean an individual or Legal Entity exercising permissions
    +    granted by this License.\n\n"Source" form shall mean the preferred form
    +    for making modifications, including but not limited to software source code, documentation
    +    source, and configuration files.\n\n"Object" form shall mean any form
    +    resulting from mechanical transformation or translation of a Source form, including
    +    but not limited to compiled object code, generated documentation, and conversions
    +    to other media types.\n\n"Work" shall mean the work of authorship, whether
    +    in Source or Object form, made available under the License, as indicated by a
    +    copyright notice that is included in or attached to the work (an example is provided
    +    in the Appendix below).\n\n"Derivative Works" shall mean any work, whether
    +    in Source or Object form, that is based on (or derived from) the Work and for
    +    which the editorial revisions, annotations, elaborations, or other modifications
    +    represent, as a whole, an original work of authorship. For the purposes of this
    +    License, Derivative Works shall not include works that remain separable from,
    +    or merely link (or bind by name) to the interfaces of, the Work and Derivative
    +    Works thereof.\n\n"Contribution" shall mean any work of authorship,
    +    including the original version of the Work and any modifications or additions
    +    to that Work or Derivative Works thereof, that is intentionally submitted to Licensor
    +    for inclusion in the Work by the copyright owner or by an individual or Legal
    +    Entity authorized to submit on behalf of the copyright owner. For the purposes
    +    of this definition, "submitted" means any form of electronic, verbal,
    +    or written communication sent to the Licensor or its representatives, including
    +    but not limited to communication on electronic mailing lists, source code control
    +    systems, and issue tracking systems that are managed by, or on behalf of, the
    +    Licensor for the purpose of discussing and improving the Work, but excluding communication
    +    that is conspicuously marked or otherwise designated in writing by the copyright
    +    owner as "Not a Contribution."\n\n"Contributor" shall mean
    +    Licensor and any individual or Legal Entity on behalf of whom a Contribution has
    +    been received by Licensor and subsequently incorporated within the Work.\n\n2.
    +    Grant of Copyright License. Subject to the terms and conditions of this License,
    +    each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge,
    +    royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works
    +    of, publicly display, publicly perform, sublicense, and distribute the Work and
    +    such Derivative Works in Source or Object form.\n\n3. Grant of Patent License.
    +    Subject to the terms and conditions of this License, each Contributor hereby grants
    +    to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +    (except as stated in this section) patent license to make, have made, use, offer
    +    to sell, sell, import, and otherwise transfer the Work, where such license applies
    +    only to those patent claims licensable by such Contributor that are necessarily
    +    infringed by their Contribution(s) alone or by combination of their Contribution(s)
    +    with the Work to which such Contribution(s) was submitted. If You institute patent
    +    litigation against any entity (including a cross-claim or counterclaim in a lawsuit)
    +    alleging that the Work or a Contribution incorporated within the Work constitutes
    +    direct or contributory patent infringement, then any patent licenses granted to
    +    You under this License for that Work shall terminate as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the
    +    Work or Derivative Works thereof in any medium, with or without modifications,
    +    and in Source or Object form, provided that You meet the following conditions:\n\n
    +    \    (a) You must give any other recipients of the Work or Derivative Works a
    +    copy of this License; and\n\n     (b) You must cause any modified files to carry
    +    prominent notices stating that You changed the files; and\n\n     (c) You must
    +    retain, in the Source form of any Derivative Works that You distribute, all copyright,
    +    patent, trademark, and attribution notices from the Source form of the Work, excluding
    +    those notices that do not pertain to any part of the Derivative Works; and\n\n
    +    \    (d) If the Work includes a "NOTICE" text file as part of its distribution,
    +    then any Derivative Works that You distribute must include a readable copy of
    +    the attribution notices contained within such NOTICE file, excluding those notices
    +    that do not pertain to any part of the Derivative Works, in at least one of the
    +    following places: within a NOTICE text file distributed as part of the Derivative
    +    Works; within the Source form or documentation, if provided along with the Derivative
    +    Works; or, within a display generated by the Derivative Works, if and wherever
    +    such third-party notices normally appear. The contents of the NOTICE file are
    +    for informational purposes only and do not modify the License. You may add Your
    +    own attribution notices within Derivative Works that You distribute, alongside
    +    or as an addendum to the NOTICE text from the Work, provided that such additional
    +    attribution notices cannot be construed as modifying the License.\n\n     You
    +    may add Your own copyright statement to Your modifications and may provide additional
    +    or different license terms and conditions for use, reproduction, or distribution
    +    of Your modifications, or for any such Derivative Works as a whole, provided Your
    +    use, reproduction, and distribution of the Work otherwise complies with the conditions
    +    stated in this License.\n\n5. Submission of Contributions. Unless You explicitly
    +    state otherwise, any Contribution intentionally submitted for inclusion in the
    +    Work by You to the Licensor shall be under the terms and conditions of this License,
    +    without any additional terms or conditions. Notwithstanding the above, nothing
    +    herein shall supersede or modify the terms of any separate license agreement you
    +    may have executed with Licensor regarding such Contributions.\n\n6. Trademarks.
    +    This License does not grant permission to use the trade names, trademarks, service
    +    marks, or product names of the Licensor, except as required for reasonable and
    +    customary use in describing the origin of the Work and reproducing the content
    +    of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable
    +    law or agreed to in writing, Licensor provides the Work (and each Contributor
    +    provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation,
    +    any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS
    +    FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
    +    of using or redistributing the Work and assume any risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory, whether in tort (including negligence), contract,
    +    or otherwise, unless required by applicable law (such as deliberate and grossly
    +    negligent acts) or agreed to in writing, shall any Contributor be liable to You
    +    for damages, including any direct, indirect, special, incidental, or consequential
    +    damages of any character arising as a result of this License or out of the use
    +    or inability to use the Work (including but not limited to damages for loss of
    +    goodwill, work stoppage, computer failure or malfunction, or any and all other
    +    commercial damages or losses), even if such Contributor has been advised of the
    +    possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.
    +    While redistributing the Work or Derivative Works thereof, You may choose to offer,
    +    and charge a fee for, acceptance of support, warranty, indemnity, or other liability
    +    obligations and/or rights consistent with this License. However, in accepting
    +    such obligations, You may act only on Your own behalf and on Your sole responsibility,
    +    not on behalf of any other Contributor, and only if You agree to indemnify, defend,
    +    and hold each Contributor harmless for any liability incurred by, or claims asserted
    +    against, such Contributor by reason of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\nTo apply the Apache License to your work, attach the
    +    following boilerplate notice, with the fields enclosed by brackets "[]"
    +    replaced with your own identifying information. (Don't include the brackets!)
    +    \ The text should be enclosed in the appropriate comment syntax for the file format.
    +    We also recommend that a file or class name and description of purpose be included
    +    on the same "printed page" as the copyright notice for easier identification
    +    within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed
    +    under the Apache License, Version 2.0 (the "License");\nyou may not
    +    use this file except in compliance with the License.\nYou may obtain a copy of
    +    the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required
    +    by applicable law or agreed to in writing, software\ndistributed under the License
    +    is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS
    +    OF ANY KIND, either express or implied.\nSee the License for the specific language
    +    governing permissions and\nlimitations under the License.\n
    \n
  • \n + \
  • \n

    Apache + License 2.0

    \n

    Used by:

    \n
      \n + \
    • chrono\n 0.4.43
    • \n
    \n + \
    Rust-chrono is dual-licensed under
    +    The MIT License [1] and\nApache 2.0 License [2]. Copyright (c) 2014--2026, Kang
    +    Seonghoon and\ncontributors.\n\nNota Bene: This is same as the Rust Project's
    +    own license.\n\n\n[1]: <http://opensource.org/licenses/MIT>, which is reproduced
    +    below:\n\n~~~~\nThe MIT License (MIT)\n\nCopyright (c) 2014, Kang Seonghoon.\n\nPermission
    +    is hereby granted, free of charge, to any person obtaining a copy\nof this software
    +    and associated documentation files (the "Software"), to deal\nin the
    +    Software without restriction, including without limitation the rights\nto use,
    +    copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the
    +    Software, and to permit persons to whom the Software is\nfurnished to do so, subject
    +    to the following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
    +    OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR
    +    COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER
    +    IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION
    +    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n~~~~\n\n\n[2]:
    +    <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below:\n\n~~~~\n
    +    \                             Apache License\n                        Version
    +    2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS
    +    AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n
    +    \  "License" shall mean the terms and conditions for use, reproduction,\n
    +    \  and distribution as defined by Sections 1 through 9 of this document.\n\n   "Licensor"
    +    shall mean the copyright owner or entity authorized by\n   the copyright owner
    +    that is granting the License.\n\n   "Legal Entity" shall mean the union
    +    of the acting entity and all\n   other entities that control, are controlled by,
    +    or are under common\n   control with that entity. For the purposes of this definition,\n
    +    \  "control" means (i) the power, direct or indirect, to cause the\n
    +    \  direction or management of such entity, whether by contract or\n   otherwise,
    +    or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares,
    +    or (iii) beneficial ownership of such entity.\n\n   "You" (or "Your")
    +    shall mean an individual or Legal Entity\n   exercising permissions granted by
    +    this License.\n\n   "Source" form shall mean the preferred form for
    +    making modifications,\n   including but not limited to software source code, documentation\n
    +    \  source, and configuration files.\n\n   "Object" form shall mean any
    +    form resulting from mechanical\n   transformation or translation of a Source form,
    +    including but\n   not limited to compiled object code, generated documentation,\n
    +    \  and conversions to other media types.\n\n   "Work" shall mean the
    +    work of authorship, whether in Source or\n   Object form, made available under
    +    the License, as indicated by a\n   copyright notice that is included in or attached
    +    to the work\n   (an example is provided in the Appendix below).\n\n   "Derivative
    +    Works" shall mean any work, whether in Source or Object\n   form, that is
    +    based on (or derived from) the Work and for which the\n   editorial revisions,
    +    annotations, elaborations, or other modifications\n   represent, as a whole, an
    +    original work of authorship. For the purposes\n   of this License, Derivative
    +    Works shall not include works that remain\n   separable from, or merely link (or
    +    bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n
    +    \  "Contribution" shall mean any work of authorship, including\n   the
    +    original version of the Work and any modifications or additions\n   to that Work
    +    or Derivative Works thereof, that is intentionally\n   submitted to Licensor for
    +    inclusion in the Work by the copyright owner\n   or by an individual or Legal
    +    Entity authorized to submit on behalf of\n   the copyright owner. For the purposes
    +    of this definition, "submitted"\n   means any form of electronic, verbal,
    +    or written communication sent\n   to the Licensor or its representatives, including
    +    but not limited to\n   communication on electronic mailing lists, source code
    +    control systems,\n   and issue tracking systems that are managed by, or on behalf
    +    of, the\n   Licensor for the purpose of discussing and improving the Work, but\n
    +    \  excluding communication that is conspicuously marked or otherwise\n   designated
    +    in writing by the copyright owner as "Not a Contribution."\n\n   "Contributor"
    +    shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a
    +    Contribution has been received by Licensor and\n   subsequently incorporated within
    +    the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions
    +    of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide,
    +    non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce,
    +    prepare Derivative Works of,\n   publicly display, publicly perform, sublicense,
    +    and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3.
    +    Grant of Patent License. Subject to the terms and conditions of\n   this License,
    +    each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive,
    +    no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent
    +    license to make, have made,\n   use, offer to sell, sell, import, and otherwise
    +    transfer the Work,\n   where such license applies only to those patent claims
    +    licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s)
    +    alone or by combination of their Contribution(s)\n   with the Work to which such
    +    Contribution(s) was submitted. If You\n   institute patent litigation against
    +    any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging
    +    that the Work\n   or a Contribution incorporated within the Work constitutes direct\n
    +    \  or contributory patent infringement, then any patent licenses\n   granted to
    +    You under this License for that Work shall terminate\n   as of the date such litigation
    +    is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n
    +    \  Work or Derivative Works thereof in any medium, with or without\n   modifications,
    +    and in Source or Object form, provided that You\n   meet the following conditions:\n\n
    +    \  (a) You must give any other recipients of the Work or\n       Derivative Works
    +    a copy of this License; and\n\n   (b) You must cause any modified files to carry
    +    prominent notices\n       stating that You changed the files; and\n\n   (c) You
    +    must retain, in the Source form of any Derivative Works\n       that You distribute,
    +    all copyright, patent, trademark, and\n       attribution notices from the Source
    +    form of the Work,\n       excluding those notices that do not pertain to any part
    +    of\n       the Derivative Works; and\n\n   (d) If the Work includes a "NOTICE"
    +    text file as part of its\n       distribution, then any Derivative Works that
    +    You distribute must\n       include a readable copy of the attribution notices
    +    contained\n       within such NOTICE file, excluding those notices that do not\n
    +    \      pertain to any part of the Derivative Works, in at least one\n       of
    +    the following places: within a NOTICE text file distributed\n       as part of
    +    the Derivative Works; within the Source form or\n       documentation, if provided
    +    along with the Derivative Works; or,\n       within a display generated by the
    +    Derivative Works, if and\n       wherever such third-party notices normally appear.
    +    The contents\n       of the NOTICE file are for informational purposes only and\n
    +    \      do not modify the License. You may add Your own attribution\n       notices
    +    within Derivative Works that You distribute, alongside\n       or as an addendum
    +    to the NOTICE text from the Work, provided\n       that such additional attribution
    +    notices cannot be construed\n       as modifying the License.\n\n   You may add
    +    Your own copyright statement to Your modifications and\n   may provide additional
    +    or different license terms and conditions\n   for use, reproduction, or distribution
    +    of Your modifications, or\n   for any such Derivative Works as a whole, provided
    +    Your use,\n   reproduction, and distribution of the Work otherwise complies with\n
    +    \  the conditions stated in this License.\n\n5. Submission of Contributions. Unless
    +    You explicitly state otherwise,\n   any Contribution intentionally submitted for
    +    inclusion in the Work\n   by You to the Licensor shall be under the terms and
    +    conditions of\n   this License, without any additional terms or conditions.\n
    +    \  Notwithstanding the above, nothing herein shall supersede or modify\n   the
    +    terms of any separate license agreement you may have executed\n   with Licensor
    +    regarding such Contributions.\n\n6. Trademarks. This License does not grant permission
    +    to use the trade\n   names, trademarks, service marks, or product names of the
    +    Licensor,\n   except as required for reasonable and customary use in describing
    +    the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7.
    +    Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in
    +    writing, Licensor provides the Work (and each\n   Contributor provides its Contributions)
    +    on an "AS IS" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    +    either express or\n   implied, including, without limitation, any warranties or
    +    conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n
    +    \  PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness
    +    of using or redistributing the Work and assume any\n   risks associated with Your
    +    exercise of permissions under this License.\n\n8. Limitation of Liability. In
    +    no event and under no legal theory,\n   whether in tort (including negligence),
    +    contract, or otherwise,\n   unless required by applicable law (such as deliberate
    +    and grossly\n   negligent acts) or agreed to in writing, shall any Contributor
    +    be\n   liable to You for damages, including any direct, indirect, special,\n   incidental,
    +    or consequential damages of any character arising as a\n   result of this License
    +    or out of the use or inability to use the\n   Work (including but not limited
    +    to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction,
    +    or any and all\n   other commercial damages or losses), even if such Contributor\n
    +    \  has been advised of the possibility of such damages.\n\n9. Accepting Warranty
    +    or Additional Liability. While redistributing\n   the Work or Derivative Works
    +    thereof, You may choose to offer,\n   and charge a fee for, acceptance of support,
    +    warranty, indemnity,\n   or other liability obligations and/or rights consistent
    +    with this\n   License. However, in accepting such obligations, You may act only\n
    +    \  on Your own behalf and on Your sole responsibility, not on behalf\n   of any
    +    other Contributor, and only if You agree to indemnify,\n   defend, and hold each
    +    Contributor harmless for any liability\n   incurred by, or claims asserted against,
    +    such Contributor by reason\n   of your accepting any such warranty or additional
    +    liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache
    +    License to your work.\n\n   To apply the Apache License to your work, attach the
    +    following\n   boilerplate notice, with the fields enclosed by brackets "[]"\n
    +    \  replaced with your own identifying information. (Don't include\n   the
    +    brackets!)  The text should be enclosed in the appropriate\n   comment syntax
    +    for the file format. We also recommend that a\n   file or class name and description
    +    of purpose be included on the\n   same "printed page" as the copyright
    +    notice for easier\n   identification within third-party archives.\n\nCopyright
    +    [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version
    +    2.0 (the "License");\nyou may not use this file except in compliance
    +    with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless
    +    required by applicable law or agreed to in writing, software\ndistributed under
    +    the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES
    +    OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the
    +    specific language governing permissions and\nlimitations under the License.\n~~~~\n\n
    \n + \
  • \n
  • \n

    BSD + 2-Clause "Simplified" License

    \n

    Used by:

    \n + \
      \n
    • ruby-rbs-sys\n 0.3.0
    • \n + \
    • ruby-rbs\n 0.3.0
    • \n
    \n + \
    Copyright (c) <year> <owner>
    +    \n\nRedistribution and use in source and binary forms, with or without modification,
    +    are permitted provided that the following conditions are met:\n\n1. Redistributions
    +    of source code must retain the above copyright notice, this list of conditions
    +    and the following disclaimer.\n\n2. Redistributions in binary form must reproduce
    +    the above copyright notice, this list of conditions and the following disclaimer
    +    in the documentation and/or other materials provided with the distribution.\n\nTHIS
    +    SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    +    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
    +    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
    +    IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
    +    INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
    +    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
    +    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
    +    LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
    +    OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
    +    OF THE POSSIBILITY OF SUCH DAMAGE.\n
    \n
  • \n
  • \n

    BSD 3-Clause "New" + or "Revised" License

    \n

    Used by:

    \n
      \n
    • bindgen\n 0.72.1
    • \n + \
    \n
    BSD 3-Clause
    +    License\n\nCopyright (c) 2013, Jyun-Yan You\nAll rights reserved.\n\nRedistribution
    +    and use in source and binary forms, with or without\nmodification, are permitted
    +    provided that the following conditions are met:\n\n* Redistributions of source
    +    code must retain the above copyright notice, this\n  list of conditions and the
    +    following disclaimer.\n\n* Redistributions in binary form must reproduce the above
    +    copyright notice,\n  this list of conditions and the following disclaimer in the
    +    documentation\n  and/or other materials provided with the distribution.\n\n* Neither
    +    the name of the copyright holder nor the names of its\n  contributors may be used
    +    to endorse or promote products derived from\n  this software without specific
    +    prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS
    +    AND CONTRIBUTORS "AS IS"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
    +    BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
    +    A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
    +    CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
    +    OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
    +    GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED
    +    AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT
    +    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS
    +    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n
    \n
  • \n + \
  • \n

    Boost Software + License 1.0

    \n

    Used by:

    \n
      \n + \
    • xxhash-rust\n 0.8.15
    • \n
    \n + \
    Boost Software License - Version 1.0
    +    - August 17th, 2003\n\nPermission is hereby granted, free of charge, to any person
    +    or organization\nobtaining a copy of the software and accompanying documentation
    +    covered by\nthis license (the "Software") to use, reproduce, display,
    +    distribute,\nexecute, and transmit the Software, and to prepare derivative works
    +    of the\nSoftware, and to permit third-parties to whom the Software is furnished
    +    to\ndo so, all subject to the following:\n\nThe copyright notices in the Software
    +    and this entire statement, including\nthe above license grant, this restriction
    +    and the following disclaimer,\nmust be included in all copies of the Software,
    +    in whole or in part, and\nall derivative works of the Software, unless such copies
    +    or derivative\nworks are solely in the form of machine-executable object code
    +    generated by\na source language processor.\n\nTHE SOFTWARE IS PROVIDED "AS
    +    IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT
    +    LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE,
    +    TITLE AND NON-INFRINGEMENT. IN NO EVENT\nSHALL THE COPYRIGHT HOLDERS OR ANYONE
    +    DISTRIBUTING THE SOFTWARE BE LIABLE\nFOR ANY DAMAGES OR OTHER LIABILITY, WHETHER
    +    IN CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE
    +    SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n
    \n
  • \n + \
  • \n

    ISC License

    \n + \

    Used by:

    \n
      \n + \
    • libloading\n 0.8.8
    • \n
    \n + \
    Copyright © 2015, Simonas Kazlauskas\n\nPermission
    +    to use, copy, modify, and/or distribute this software for any purpose with or
    +    without\nfee is hereby granted, provided that the above copyright notice and this
    +    permission notice appear\nin all copies.\n\nTHE SOFTWARE IS PROVIDED "AS
    +    IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS\nSOFTWARE
    +    INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
    +    THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES
    +    OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER
    +    IN AN ACTION OF CONTRACT,\nNEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
    +    OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • nom\n 7.1.3
    • \n
    \n
    Copyright (c) 2014-2019 Geoffroy Couprie\n\nPermission
    +    is hereby granted, free of charge, to any person obtaining\na copy of this software
    +    and associated documentation files (the\n"Software"), to deal in the
    +    Software without restriction, including\nwithout limitation the rights to use,
    +    copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the
    +    Software, and to\npermit persons to whom the Software is furnished to do so, subject
    +    to\nthe following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS
    +    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS
    +    FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +    COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +    IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH
    +    THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • float-cmp\n 0.10.0
    • \n
    \n + \
    Copyright (c) 2014-2020 Optimal Computing
    +    (NZ) Ltd\n\nPermission is hereby granted, free of charge, to any person obtaining
    +    a copy of\nthis software and associated documentation files (the "Software"),
    +    to deal in\nthe Software without restriction, including without limitation the
    +    rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +    copies\nof the Software, and to permit persons to whom the Software is furnished
    +    to do\nso, subject to the following conditions:\n\nThe above copyright notice
    +    and this permission notice shall be included in all\ncopies or substantial portions
    +    of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    +    OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
    +    OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM,
    +    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE
    +    SOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used by:

    \n
      \n
    • bytes\n 1.11.1
    • \n + \
    \n
    Copyright (c)
    +    2018 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson
    +    obtaining a copy of this software and associated\ndocumentation files (the "Software"),
    +    to deal in the\nSoftware without restriction, including without\nlimitation the
    +    rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell
    +    copies of\nthe Software, and to permit persons to whom the Software\nis furnished
    +    to do so, subject to the following\nconditions:\n\nThe above copyright notice
    +    and this permission notice\nshall be included in all copies or substantial portions\nof
    +    the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES
    +    OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES
    +    OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING
    +    FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS
    +    IN THE SOFTWARE.\n
    \n
  • \n
  • \n + \

    MIT License

    \n

    Used by:

    \n + \
      \n
    • slab\n 0.4.12
    • \n + \
    \n
    Copyright (c)
    +    2019 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson
    +    obtaining a copy of this software and associated\ndocumentation files (the "Software"),
    +    to deal in the\nSoftware without restriction, including without\nlimitation the
    +    rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell
    +    copies of\nthe Software, and to permit persons to whom the Software\nis furnished
    +    to do so, subject to the following\nconditions:\n\nThe above copyright notice
    +    and this permission notice\nshall be included in all copies or substantial portions\nof
    +    the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES
    +    OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES
    +    OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING
    +    FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS
    +    IN THE SOFTWARE.\n
    \n
  • \n
  • \n + \

    MIT License

    \n

    Used by:

    \n + \
      \n
    • tracing-attributes\n 0.1.31
    • \n + \
    • tracing-core\n 0.1.36
    • \n
    • tracing\n + \ 0.1.44
    • \n
    \n
    Copyright (c) 2019 Tokio Contributors\n\nPermission is
    +    hereby granted, free of charge, to any\nperson obtaining a copy of this software
    +    and associated\ndocumentation files (the "Software"), to deal in the\nSoftware
    +    without restriction, including without\nlimitation the rights to use, copy, modify,
    +    merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software,
    +    and to permit persons to whom the Software\nis furnished to do so, subject to
    +    the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall
    +    be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE
    +    IS PROVIDED "AS IS", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED,
    +    INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR
    +    PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS
    +    BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF
    +    CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE
    +    OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used + by:

    \n
      \n
    • termtree\n + \ 0.5.1
    • \n
    \n
    Copyright (c) Individual contributors\n\nPermission is
    +    hereby granted, free of charge, to any person obtaining a copy\nof this software
    +    and associated documentation files (the "Software"), to deal\nin the
    +    Software without restriction, including without limitation the rights\nto use,
    +    copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the
    +    Software, and to permit persons to whom the Software is\nfurnished to do so, subject
    +    to the following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
    +    OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR
    +    COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER
    +    IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION
    +    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • synstructure\n 0.13.2
    • \n
    \n + \
    Copyright 2016 Nika Layzell\n\nPermission
    +    is hereby granted, free of charge, to any person obtaining a copy of this software
    +    and associated documentation files (the "Software"), to deal in the
    +    Software without restriction, including without limitation the rights to use,
    +    copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
    +    Software, and to permit persons to whom the Software is furnished to do so, subject
    +    to the following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be included in all copies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
    +    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +    COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +    IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • ruby-prism-sys\n 1.9.0
    • \n
    • ruby-prism\n + \ 1.9.0
    • \n
    \n
    Copyright 2022-present, Shopify Inc.\n\nPermission is hereby
    +    granted, free of charge, to any person obtaining a copy of this software and associated
    +    documentation files (the "Software"), to deal in the Software without
    +    restriction, including without limitation the rights to use, copy, modify, merge,
    +    publish, distribute, sublicense, and/or sell copies of the Software, and to permit
    +    persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe
    +    above copyright notice and this permission notice shall be included in all copies
    +    or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS
    +    IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
    +    LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
    +    AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
    +    FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
    +    OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
    +    OR OTHER DEALINGS IN THE SOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used + by:

    \n
      \n
    • darling\n + \ 0.23.0
    • \n
    • darling_core\n 0.23.0
    • \n + \
    • darling_macro\n 0.23.0
    • \n
    \n + \
    MIT License\n\nCopyright (c) 2017
    +    Ted Driggs\n\nPermission is hereby granted, free of charge, to any person obtaining
    +    a copy\nof this software and associated documentation files (the "Software"),
    +    to deal\nin the Software without restriction, including without limitation the
    +    rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies
    +    of the Software, and to permit persons to whom the Software is\nfurnished to do
    +    so, subject to the following conditions:\n\nThe above copyright notice and this
    +    permission notice shall be included in all\ncopies or substantial portions of
    +    the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    +    OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
    +    OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +    FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +    IN THE\nSOFTWARE.\n
    \n
  • \n
  • \n + \

    MIT License

    \n

    Used by:

    \n + \
      \n
    • doc-comment\n 0.3.3
    • \n + \
    \n
    MIT License\n\nCopyright
    +    (c) 2018 Guillaume Gomez\n\nPermission is hereby granted, free of charge, to any
    +    person obtaining a copy\nof this software and associated documentation files (the
    +    "Software"), to deal\nin the Software without restriction, including
    +    without limitation the rights\nto use, copy, modify, merge, publish, distribute,
    +    sublicense, and/or sell\ncopies of the Software, and to permit persons to whom
    +    the Software is\nfurnished to do so, subject to the following conditions:\n\nThe
    +    above copyright notice and this permission notice shall be included in all\ncopies
    +    or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS
    +    IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT
    +    LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE
    +    AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE
    +    FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    +    TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR
    +    THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used + by:

    \n
      \n
    • schemars\n + \ 1.2.1
    • \n
    • schemars_derive\n 1.2.1
    • \n + \
    \n
    MIT License\n\nCopyright
    +    (c) 2019 Graham Esau\n\nPermission is hereby granted, free of charge, to any person
    +    obtaining a copy\nof this software and associated documentation files (the "Software"),
    +    to deal\nin the Software without restriction, including without limitation the
    +    rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies
    +    of the Software, and to permit persons to whom the Software is\nfurnished to do
    +    so, subject to the following conditions:\n\nThe above copyright notice and this
    +    permission notice shall be included in all\ncopies or substantial portions of
    +    the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    +    OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
    +    OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +    FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +    IN THE\nSOFTWARE.\n
    \n
  • \n
  • \n + \

    MIT License

    \n

    Used by:

    \n + \
      \n
    • tokio-macros\n 2.6.0
    • \n + \
    \n
    MIT License\n\nCopyright
    +    (c) 2019 Yoshua Wuyts\nCopyright (c) Tokio Contributors\n\nPermission is hereby
    +    granted, free of charge, to any person obtaining a copy\nof this software and
    +    associated documentation files (the "Software"), to deal\nin the Software
    +    without restriction, including without limitation the rights\nto use, copy, modify,
    +    merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and
    +    to permit persons to whom the Software is\nfurnished to do so, subject to the
    +    following conditions:\n\nThe above copyright notice and this permission notice
    +    shall be included in all\ncopies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
    +    OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR
    +    COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER
    +    IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION
    +    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • rubydex\n 0.2.6
    • \n
    • rubydex-mcp\n + \ 0.2.6
    • \n
    • rubydex-sys\n 0.2.6
    • \n + \
    • difflib\n 0.4.0
    • \n
    \n + \
    MIT License\n\nCopyright (c) <year>
    +    <copyright holders>\n\nPermission is hereby granted, free of charge, to
    +    any person obtaining a copy of this software and\nassociated documentation files
    +    (the "Software"), to deal in the Software without restriction, including\nwithout
    +    limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
    +    and/or sell\ncopies of the Software, and to permit persons to whom the Software
    +    is furnished to do so, subject to the\nfollowing conditions:\n\nThe above copyright
    +    notice and this permission notice shall be included in all copies or substantial\nportions
    +    of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT\nLIMITED TO THE WARRANTIES
    +    OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO\nEVENT
    +    SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +    LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\nUSE OR OTHER DEALINGS IN THE
    +    SOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used by:

    \n
      \n
    • tokio-util\n 0.7.18
    • \n + \
    • tokio\n 1.49.0
    • \n
    \n + \
    MIT License\n\nCopyright (c) Tokio
    +    Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining
    +    a copy\nof this software and associated documentation files (the "Software"),
    +    to deal\nin the Software without restriction, including without limitation the
    +    rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies
    +    of the Software, and to permit persons to whom the Software is\nfurnished to do
    +    so, subject to the following conditions:\n\nThe above copyright notice and this
    +    permission notice shall be included in all\ncopies or substantial portions of
    +    the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY
    +    OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    +    OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
    +    NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
    +    OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +    FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +    IN THE\nSOFTWARE.\n
    \n
  • \n
  • \n + \

    MIT License

    \n

    Used by:

    \n + \
      \n
    • unsafe-libyaml\n 0.2.11
    • \n + \
    \n
    Permission
    +    is hereby granted, free of charge, to any\nperson obtaining a copy of this software
    +    and associated\ndocumentation files (the "Software"), to deal in the\nSoftware
    +    without restriction, including without\nlimitation the rights to use, copy, modify,
    +    merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software,
    +    and to permit persons to whom the Software\nis furnished to do so, subject to
    +    the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall
    +    be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE
    +    IS PROVIDED "AS IS", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED,
    +    INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR
    +    PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS
    +    BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF
    +    CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE
    +    OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n
    \n
  • \n
  • \n

    MIT License

    \n

    Used + by:

    \n
      \n
    • winnow\n + \ 0.7.12
    • \n
    • winnow\n 1.0.3
    • \n + \
    \n
    Permission
    +    is hereby granted, free of charge, to any person obtaining\na copy of this software
    +    and associated documentation files (the\n"Software"), to deal in the
    +    Software without restriction, including\nwithout limitation the rights to use,
    +    copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the
    +    Software, and to\npermit persons to whom the Software is furnished to do so, subject
    +    to\nthe following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS
    +    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS
    +    FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +    COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +    IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH
    +    THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • aho-corasick\n 1.1.3
    • \n
    • memchr\n + \ 2.7.5
    • \n
    \n
    The MIT License (MIT)\n\nCopyright (c) 2015 Andrew Gallant\n\nPermission
    +    is hereby granted, free of charge, to any person obtaining a copy\nof this software
    +    and associated documentation files (the "Software"), to deal\nin the
    +    Software without restriction, including without limitation the rights\nto use,
    +    copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the
    +    Software, and to permit persons to whom the Software is\nfurnished to do so, subject
    +    to the following conditions:\n\nThe above copyright notice and this permission
    +    notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE
    +    SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
    +    OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR
    +    COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER
    +    IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION
    +    WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n
    \n
  • \n + \
  • \n

    MIT License

    \n + \

    Used by:

    \n
      \n + \
    • strsim\n 0.11.1
    • \n
    \n + \
    The MIT License (MIT)\n\nCopyright
    +    (c) 2015 Danny Guo\nCopyright (c) 2016 Titus Wormer <tituswormer@gmail.com>\nCopyright
    +    (c) 2018 Akash Kurdekar\n\nPermission is hereby granted, free of charge, to any
    +    person obtaining a copy\nof this software and associated documentation files (the
    +    "Software"), to deal\nin the Software without restriction, including
    +    without limitation the rights\nto use, copy, modify, merge, publish, distribute,
    +    sublicense, and/or sell\ncopies of the Software, and to permit persons to whom
    +    the Software is\nfurnished to do so, subject to the following conditions:\n\nThe
    +    above copyright notice and this permission notice shall be included in all\ncopies
    +    or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS
    +    IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT
    +    LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE
    +    AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE
    +    FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    +    TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR
    +    THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n
    \n
  • \n
  • \n

    Mozilla Public License + 2.0

    \n

    Used by:

    \n
      \n + \
    • cbindgen\n 0.29.0
    • \n
    \n + \
    Mozilla Public License Version 2.0\n==================================\n\n1.
    +    Definitions\n--------------\n\n1.1. "Contributor"\n    means each individual
    +    or legal entity that creates, contributes to\n    the creation of, or owns Covered
    +    Software.\n\n1.2. "Contributor Version"\n    means the combination of
    +    the Contributions of others (if any) used\n    by a Contributor and that particular
    +    Contributor's Contribution.\n\n1.3. "Contribution"\n    means Covered
    +    Software of a particular Contributor.\n\n1.4. "Covered Software"\n    means
    +    Source Code Form to which the initial Contributor has attached\n    the notice
    +    in Exhibit A, the Executable Form of such Source Code\n    Form, and Modifications
    +    of such Source Code Form, in each case\n    including portions thereof.\n\n1.5.
    +    "Incompatible With Secondary Licenses"\n    means\n\n    (a) that the
    +    initial Contributor has attached the notice described\n        in Exhibit B to
    +    the Covered Software; or\n\n    (b) that the Covered Software was made available
    +    under the terms of\n        version 1.1 or earlier of the License, but not also
    +    under the\n        terms of a Secondary License.\n\n1.6. "Executable Form"\n
    +    \   means any form of the work other than Source Code Form.\n\n1.7. "Larger
    +    Work"\n    means a work that combines Covered Software with other material,
    +    in\n    a separate file or files, that is not Covered Software.\n\n1.8. "License"\n
    +    \   means this document.\n\n1.9. "Licensable"\n    means having the
    +    right to grant, to the maximum extent possible,\n    whether at the time of the
    +    initial grant or subsequently, any and\n    all of the rights conveyed by this
    +    License.\n\n1.10. "Modifications"\n    means any of the following:\n\n
    +    \   (a) any file in Source Code Form that results from an addition to,\n        deletion
    +    from, or modification of the contents of Covered\n        Software; or\n\n    (b)
    +    any new file in Source Code Form that contains any Covered\n        Software.\n\n1.11.
    +    "Patent Claims" of a Contributor\n    means any patent claim(s), including
    +    without limitation, method,\n    process, and apparatus claims, in any patent
    +    Licensable by such\n    Contributor that would be infringed, but for the grant
    +    of the\n    License, by the making, using, selling, offering for sale, having\n
    +    \   made, import, or transfer of either its Contributions or its\n    Contributor
    +    Version.\n\n1.12. "Secondary License"\n    means either the GNU General
    +    Public License, Version 2.0, the GNU\n    Lesser General Public License, Version
    +    2.1, the GNU Affero General\n    Public License, Version 3.0, or any later versions
    +    of those\n    licenses.\n\n1.13. "Source Code Form"\n    means the form
    +    of the work preferred for making modifications.\n\n1.14. "You" (or "Your")\n
    +    \   means an individual or a legal entity exercising rights under this\n    License.
    +    For legal entities, "You" includes any entity that\n    controls, is
    +    controlled by, or is under common control with You. For\n    purposes of this
    +    definition, "control" means (a) the power, direct\n    or indirect,
    +    to cause the direction or management of such entity,\n    whether by contract
    +    or otherwise, or (b) ownership of more than\n    fifty percent (50%) of the outstanding
    +    shares or beneficial\n    ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1.
    +    Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive
    +    license:\n\n(a) under intellectual property rights (other than patent or trademark)\n
    +    \   Licensable by such Contributor to use, reproduce, make available,\n    modify,
    +    display, perform, distribute, and otherwise exploit its\n    Contributions, either
    +    on an unmodified basis, with Modifications, or\n    as part of a Larger Work;
    +    and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n
    +    \   for sale, have made, import, and otherwise transfer either its\n    Contributions
    +    or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in
    +    Section 2.1 with respect to any Contribution\nbecome effective for each Contribution
    +    on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations
    +    on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted
    +    under\nthis License. No additional rights or licenses will be implied from the\ndistribution
    +    or licensing of Covered Software under this License.\nNotwithstanding Section
    +    2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code
    +    that a Contributor has removed from Covered Software;\n    or\n\n(b) for infringements
    +    caused by: (i) Your and any other third party's\n    modifications of Covered
    +    Software, or (ii) the combination of its\n    Contributions with other software
    +    (except as part of its Contributor\n    Version); or\n\n(c) under Patent Claims
    +    infringed by Covered Software in the absence of\n    its Contributions.\n\nThis
    +    License does not grant any rights in the trademarks, service marks,\nor logos
    +    of any Contributor (except as may be necessary to comply with\nthe notice requirements
    +    in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional
    +    grants as a result of Your choice to\ndistribute the Covered Software under a
    +    subsequent version of this\nLicense (see Section 10.2) or under the terms of a
    +    Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach
    +    Contributor represents that the Contributor believes its\nContributions are its
    +    original creation(s) or it has sufficient rights\nto grant the rights to its Contributions
    +    conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to
    +    limit any rights You have under\napplicable copyright doctrines of fair use, fair
    +    dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3,
    +    and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1.
    +    Distribution of Source Form\n\nAll distribution of Covered Software in Source
    +    Code Form, including any\nModifications that You create or to which You contribute,
    +    must be under\nthe terms of this License. You must inform recipients that the
    +    Source\nCode Form of the Covered Software is governed by the terms of this\nLicense,
    +    and how they can obtain a copy of this License. You may not\nattempt to alter
    +    or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution
    +    of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a)
    +    such Covered Software must also be made available in Source Code\n    Form, as
    +    described in Section 3.1, and You must inform recipients of\n    the Executable
    +    Form how they can obtain a copy of such Source Code\n    Form by reasonable means
    +    in a timely manner, at a charge no more\n    than the cost of distribution to
    +    the recipient; and\n\n(b) You may distribute such Executable Form under the terms
    +    of this\n    License, or sublicense it under different terms, provided that the\n
    +    \   license for the Executable Form does not attempt to limit or alter\n    the
    +    recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution
    +    of a Larger Work\n\nYou may create and distribute a Larger Work under terms of
    +    Your choice,\nprovided that You also comply with the requirements of this License
    +    for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware
    +    with a work governed by one or more Secondary Licenses, and the\nCovered Software
    +    is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally
    +    distribute such Covered Software\nunder the terms of such Secondary License(s),
    +    so that the recipient of\nthe Larger Work may, at their option, further distribute
    +    the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4.
    +    Notices\n\nYou may not remove or alter the substance of any license notices\n(including
    +    copyright notices, patent notices, disclaimers of warranty,\nor limitations of
    +    liability) contained within the Source Code Form of\nthe Covered Software, except
    +    that You may alter any license notices to\nthe extent required to remedy known
    +    factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose
    +    to offer, and to charge a fee for, warranty, support,\nindemnity or liability
    +    obligations to one or more recipients of Covered\nSoftware. However, You may do
    +    so only on Your own behalf, and not on\nbehalf of any Contributor. You must make
    +    it absolutely clear that any\nsuch warranty, support, indemnity, or liability
    +    obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor
    +    for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity
    +    or liability terms You offer. You may include additional\ndisclaimers of warranty
    +    and limitations of liability specific to any\njurisdiction.\n\n4. Inability to
    +    Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf
    +    it is impossible for You to comply with any of the terms of this\nLicense with
    +    respect to some or all of the Covered Software due to\nstatute, judicial order,
    +    or regulation then You must: (a) comply with\nthe terms of this License to the
    +    maximum extent possible; and (b)\ndescribe the limitations and the code they affect.
    +    Such description must\nbe placed in a text file included with all distributions
    +    of the Covered\nSoftware under this License. Except to the extent prohibited by
    +    statute\nor regulation, such description must be sufficiently detailed for a\nrecipient
    +    of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1.
    +    The rights granted under this License will terminate automatically\nif You fail
    +    to comply with any of its terms. However, if You become\ncompliant, then the rights
    +    granted under this License from a particular\nContributor are reinstated (a) provisionally,
    +    unless and until such\nContributor explicitly and finally terminates Your grants,
    +    and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance
    +    by some reasonable means prior to 60 days after You have\ncome back into compliance.
    +    Moreover, Your grants from a particular\nContributor are reinstated on an ongoing
    +    basis if such Contributor\nnotifies You of the non-compliance by some reasonable
    +    means, this is the\nfirst time You have received notice of non-compliance with
    +    this License\nfrom such Contributor, and You become compliant prior to 30 days
    +    after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against
    +    any entity by asserting a patent\ninfringement claim (excluding declaratory judgment
    +    actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly
    +    or indirectly infringes any patent, then the rights granted to\nYou by any and
    +    all Contributors for the Covered Software under Section\n2.1 of this License shall
    +    terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above,
    +    all\nend user license agreements (excluding distributors and resellers) which\nhave
    +    been validly granted by You or Your distributors under this License\nprior to
    +    termination shall survive termination.\n\n************************************************************************\n*
    +    \                                                                     *\n*  6.
    +    Disclaimer of Warranty                                           *\n*  -------------------------
    +    \                                          *\n*                                                                      *\n*
    +    \ Covered Software is provided under this License on an "as is"       *\n*
    +    \ basis, without warranty of any kind, either expressed, implied, or  *\n*  statutory,
    +    including, without limitation, warranties that the       *\n*  Covered Software
    +    is free of defects, merchantable, fit for a        *\n*  particular purpose or
    +    non-infringing. The entire risk as to the     *\n*  quality and performance of
    +    the Covered Software is with You.        *\n*  Should any Covered Software prove
    +    defective in any respect, You     *\n*  (not any Contributor) assume the cost
    +    of any necessary servicing,   *\n*  repair, or correction. This disclaimer of
    +    warranty constitutes an   *\n*  essential part of this License. No use of any
    +    Covered Software is   *\n*  authorized under this License except under this disclaimer.
    +    \        *\n*                                                                      *\n************************************************************************\n\n************************************************************************\n*
    +    \                                                                     *\n*  7.
    +    Limitation of Liability                                          *\n*  --------------------------
    +    \                                         *\n*                                                                      *\n*
    +    \ Under no circumstances and under no legal theory, whether tort      *\n*  (including
    +    negligence), contract, or otherwise, shall any           *\n*  Contributor, or
    +    anyone who distributes Covered Software as          *\n*  permitted above, be
    +    liable to You for any direct, indirect,         *\n*  special, incidental, or
    +    consequential damages of any character      *\n*  including, without limitation,
    +    damages for lost profits, loss of    *\n*  goodwill, work stoppage, computer failure
    +    or malfunction, or any    *\n*  and all other commercial damages or losses, even
    +    if such party      *\n*  shall have been informed of the possibility of such damages.
    +    This   *\n*  limitation of liability shall not apply to liability for death or
    +    \  *\n*  personal injury resulting from such party's negligence to the       *\n*
    +    \ extent applicable law prohibits such limitation. Some               *\n*  jurisdictions
    +    do not allow the exclusion or limitation of           *\n*  incidental or consequential
    +    damages, so this exclusion and          *\n*  limitation may not apply to You.
    +    \                                   *\n*                                                                      *\n************************************************************************\n\n8.
    +    Litigation\n-------------\n\nAny litigation relating to this License may be brought
    +    only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace
    +    of business and such litigation shall be governed by laws of that\njurisdiction,
    +    without reference to its conflict-of-law provisions.\nNothing in this Section
    +    shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9.
    +    Miscellaneous\n----------------\n\nThis License represents the complete agreement
    +    concerning the subject\nmatter hereof. If any provision of this License is held
    +    to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary
    +    to make it enforceable. Any law or regulation which provides\nthat the language
    +    of a contract shall be construed against the drafter\nshall not be used to construe
    +    this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1.
    +    New Versions\n\nMozilla Foundation is the license steward. Except as provided
    +    in Section\n10.3, no one other than the license steward has the right to modify
    +    or\npublish new versions of this License. Each version will be given a\ndistinguishing
    +    version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered
    +    Software under the terms of the version\nof the License under which You originally
    +    received the Covered Software,\nor under the terms of any subsequent version published
    +    by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software
    +    not governed by this License, and you want to\ncreate a new license for such software,
    +    you may create and use a\nmodified version of this License if you rename the license
    +    and remove\nany references to the name of the license steward (except to note
    +    that\nsuch modified license differs from this License).\n\n10.4. Distributing
    +    Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose
    +    to distribute Source Code Form that is Incompatible With\nSecondary Licenses under
    +    the terms of this version of the License, the\nnotice described in Exhibit B of
    +    this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n
    +    \ This Source Code Form is subject to the terms of the Mozilla Public\n  License,
    +    v. 2.0. If a copy of the MPL was not distributed with this\n  file, You can obtain
    +    one at http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put
    +    the notice in a particular\nfile, then You may include the notice in a location
    +    (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely
    +    to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright
    +    ownership.\n\nExhibit B - "Incompatible With Secondary Licenses" Notice\n---------------------------------------------------------\n\n
    +    \ This Source Code Form is "Incompatible With Secondary Licenses", as\n
    +    \ defined by the Mozilla Public License, v. 2.0.\n
    \n
  • \n + \
  • \n

    Unicode + License v3

    \n

    Used by:

    \n
      \n + \
    • unicode-ident\n 1.0.18
    • \n
    \n + \
    UNICODE LICENSE V3\n\nCOPYRIGHT AND
    +    PERMISSION NOTICE\n\nCopyright © 1991-2023 Unicode, Inc.\n\nNOTICE TO USER: Carefully
    +    read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE
    +    USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE
    +    BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE,
    +    DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission
    +    is hereby granted, free of charge, to any person obtaining a\ncopy of data files
    +    and any associated documentation (the "Data Files") or\nsoftware and
    +    any associated documentation (the "Software") to deal in the\nData Files
    +    or Software without restriction, including without limitation\nthe rights to use,
    +    copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files
    +    or Software, and to permit persons to whom the\nData Files or Software are furnished
    +    to do so, provided that either (a)\nthis copyright and permission notice appear
    +    with all copies of the Data\nFiles or Software, or (b) this copyright and permission
    +    notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE
    +    PROVIDED "AS IS", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED,
    +    INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A
    +    PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT
    +    SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY
    +    CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER
    +    RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT,
    +    NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE
    +    USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this
    +    notice, the name of a copyright holder shall\nnot be used in advertising or otherwise
    +    to promote the sale, use or other\ndealings in these Data Files or Software without
    +    prior written\nauthorization of the copyright holder.\n
    \n
  • \n + \
  • \n

    Unicode + License v3

    \n

    Used by:

    \n
      \n + \
    • icu_collections\n 2.0.0
    • \n
    • icu_locale_core\n + \ 2.0.0
    • \n
    • icu_normalizer\n 2.0.0
    • \n + \
    • icu_normalizer_data\n 2.0.0
    • \n
    • icu_properties\n + \ 2.0.1
    • \n
    • icu_properties_data\n 2.0.1
    • \n + \
    • icu_provider\n 2.0.0
    • \n
    • litemap\n + \ 0.8.0
    • \n
    • potential_utf\n 0.1.2
    • \n + \
    • tinystr\n 0.8.1
    • \n
    • writeable\n + \ 0.6.1
    • \n
    • yoke-derive\n 0.8.0
    • \n + \
    • yoke\n 0.8.0
    • \n
    • zerofrom-derive\n + \ 0.1.6
    • \n
    • zerofrom\n 0.1.6
    • \n + \
    • zerotrie\n 0.2.2
    • \n
    • zerovec-derive\n + \ 0.11.1
    • \n
    • zerovec\n 0.11.2
    • \n + \
    \n
    UNICODE LICENSE
    +    V3\n\nCOPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 2020-2024 Unicode, Inc.\n\nNOTICE
    +    TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING,
    +    COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT,
    +    AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT.
    +    IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA
    +    FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person
    +    obtaining a\ncopy of data files and any associated documentation (the "Data
    +    Files") or\nsoftware and any associated documentation (the "Software")
    +    to deal in the\nData Files or Software without restriction, including without
    +    limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or
    +    sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData
    +    Files or Software are furnished to do so, provided that either (a)\nthis copyright
    +    and permission notice appear with all copies of the Data\nFiles or Software, or
    +    (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE
    +    DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY\nKIND,
    +    EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY,
    +    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN
    +    NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE
    +    FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES
    +    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION
    +    OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION
    +    WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained
    +    in this notice, the name of a copyright holder shall\nnot be used in advertising
    +    or otherwise to promote the sale, use or other\ndealings in these Data Files or
    +    Software without prior written\nauthorization of the copyright holder.\n\nSPDX-License-Identifier:
    +    Unicode-3.0\n\n—\n\nPortions of ICU4X may have been adapted from ICU4C and/or
    +    ICU4J.\nICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation
    +    and others.\n
    \n
  • \n
  • \n

    MinGW-w64 toolchain and related libraries for Windows + precompiled builds

    \n

    Used by:

    \n
      \n
    • libgcc (with GCC Runtime Library + Exception), mingw-w64 runtime, winpthreads
    • \n
    \n
    \n            The following 3rd-party software packages
    +    may be used by or distributed with Rubydex on Windows. Any information\n            relevant
    +    to third-party vendors listed below are collected using common, reasonable means.\n
    +    \           \n            https://github.com/mingw-w64/mingw-w64\n            MinGW-w64
    +    runtime licensing\n            ***************************\n            \n            This
    +    program or library was built using MinGW-w64 and statically\n            linked
    +    against the MinGW-w64 runtime. Some parts of the runtime\n            are under
    +    licenses which require that the copyright and license\n            notices are
    +    included when distributing the code in binary form.\n            These notices
    +    are listed below.\n            \n            \n            ========================\n
    +    \           Overall copyright notice\n            ========================\n            \n
    +    \           Copyright (c) 2009, 2010, 2011, 2012, 2013 by the mingw-w64 project\n
    +    \           \n            This license has been certified as open source. It has
    +    also been designated\n            as GPL compatible by the Free Software Foundation
    +    (FSF).\n            \n            Redistribution and use in source and binary
    +    forms, with or without\n            modification, are permitted provided that
    +    the following conditions are met:\n            \n            1. Redistributions
    +    in source code must retain the accompanying copyright\n            notice, this
    +    list of conditions, and the following disclaimer.\n            2. Redistributions
    +    in binary form must reproduce the accompanying\n            copyright notice,
    +    this list of conditions, and the following disclaimer\n            in the documentation
    +    and/or other materials provided with the\n            distribution.\n            3.
    +    Names of the copyright holders must not be used to endorse or promote\n            products
    +    derived from this software without prior written permission\n            from
    +    the copyright holders.\n            4. The right to distribute this software or
    +    to use it for any purpose does\n            not give you the right to use Servicemarks
    +    (sm) or Trademarks (tm) of\n            the copyright holders. Use of them is
    +    covered by separate agreement\n            with the copyright holders.\n            5.
    +    If any files are modified, you must cause the modified files to carry\n            prominent
    +    notices stating that you changed the files and the date of\n            any change.\n
    +    \           \n            Disclaimer\n            \n            THIS SOFTWARE
    +    IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED\n            OR
    +    IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\n            OF
    +    MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO\n            EVENT
    +    SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,\n            INCIDENTAL,
    +    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\n            LIMITED
    +    TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,\n            OR
    +    PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\n            LIABILITY,
    +    WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\n            NEGLIGENCE
    +    OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,\n            EVEN
    +    IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n            \n            ========================================\n
    +    \           getopt, getopt_long, and getop_long_only\n            ========================================\n
    +    \           \n            Copyright (c) 2002 Todd C. Miller <Todd.Miller@courtesan.com>\n
    +    \           \n                Permission to use, copy, modify, and distribute
    +    this software for any\n                purpose with or without fee is hereby granted,
    +    provided that the above\n                copyright notice and this permission
    +    notice appear in all copies.\n            \n                THE SOFTWARE IS PROVIDED
    +    \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\n                WITH REGARD
    +    TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n                MERCHANTABILITY
    +    AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\n                ANY SPECIAL,
    +    DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n                WHATSOEVER
    +    RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n                ACTION
    +    OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n                OR
    +    IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n            \n                Sponsored
    +    in part by the Defense Advanced Research Projects\n                Agency (DARPA)
    +    and Air Force Research Laboratory, Air Force\n                Materiel Command,
    +    USAF, under agreement number F39502-99-1-0512.\n            \n                *
    +    * * * * * *\n            \n                Copyright (c) 2000 The NetBSD Foundation,
    +    Inc.\n                All rights reserved.\n            \n                This
    +    code is derived from software contributed to The NetBSD Foundation\n                by
    +    Dieter Baron and Thomas Klausner.\n            \n                Redistribution
    +    and use in source and binary forms, with or without\n                modification,
    +    are permitted provided that the following conditions\n                are met:\n
    +    \               1. Redistributions of source code must retain the above copyright\n
    +    \               notice, this list of conditions and the following disclaimer.\n
    +    \               2. Redistributions in binary form must reproduce the above copyright\n
    +    \               notice, this list of conditions and the following disclaimer in
    +    the\n                documentation and/or other materials provided with the distribution.\n
    +    \           \n                THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION,
    +    INC. AND CONTRIBUTORS\n                ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
    +    INCLUDING, BUT NOT LIMITED\n                TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
    +    AND FITNESS FOR A PARTICULAR\n                PURPOSE ARE DISCLAIMED. IN NO EVENT
    +    SHALL THE FOUNDATION OR CONTRIBUTORS\n                BE LIABLE FOR ANY DIRECT,
    +    INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n                CONSEQUENTIAL DAMAGES
    +    (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n                SUBSTITUTE GOODS
    +    OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n                INTERRUPTION)
    +    HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n                CONTRACT,
    +    STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n                ARISING
    +    IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n                POSSIBILITY
    +    OF SUCH DAMAGE.\n            \n            \n                ===============================================================\n
    +    \               gdtoa: Converting between IEEE floating point numbers and ASCII\n
    +    \               ===============================================================\n
    +    \           \n                The author of this software is David M. Gay.\n            \n
    +    \               Copyright (C) 1997, 1998, 1999, 2000, 2001 by Lucent Technologies\n
    +    \               All Rights Reserved\n            \n                Permission
    +    to use, copy, modify, and distribute this software and\n                its documentation
    +    for any purpose and without fee is hereby\n                granted, provided that
    +    the above copyright notice appear in all\n                copies and that both
    +    that the copyright notice and this\n                permission notice and warranty
    +    disclaimer appear in supporting\n                documentation, and that the name
    +    of Lucent or any of its entities\n                not be used in advertising or
    +    publicity pertaining to\n                distribution of the software without
    +    specific, written prior\n                permission.\n            \n                LUCENT
    +    DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,\n                INCLUDING
    +    ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS.\n                IN NO
    +    EVENT SHALL LUCENT OR ANY OF ITS ENTITIES BE LIABLE FOR ANY\n                SPECIAL,
    +    INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n                WHATSOEVER RESULTING
    +    FROM LOSS OF USE, DATA OR PROFITS, WHETHER\n                IN AN ACTION OF CONTRACT,
    +    NEGLIGENCE OR OTHER TORTIOUS ACTION,\n                ARISING OUT OF OR IN CONNECTION
    +    WITH THE USE OR PERFORMANCE OF\n                THIS SOFTWARE.\n            \n
    +    \               * * * * * * *\n            \n                The author of this
    +    software is David M. Gay.\n            \n                Copyright (C) 2005 by
    +    David M. Gay\n                All Rights Reserved\n            \n                Permission
    +    to use, copy, modify, and distribute this software and its\n                documentation
    +    for any purpose and without fee is hereby granted,\n                provided that
    +    the above copyright notice appear in all copies and that\n                both
    +    that the copyright notice and this permission notice and warranty\n                disclaimer
    +    appear in supporting documentation, and that the name of\n                the
    +    author or any of his current or former employers not be used in\n                advertising
    +    or publicity pertaining to distribution of the software\n                without
    +    specific, written prior permission.\n            \n                THE AUTHOR
    +    DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,\n                INCLUDING
    +    ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN\n                NO
    +    EVENT SHALL THE AUTHOR OR ANY OF HIS CURRENT OR FORMER EMPLOYERS BE\n                LIABLE
    +    FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY\n                DAMAGES
    +    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\n                WHETHER
    +    IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\n                ARISING
    +    OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS\n                SOFTWARE.\n
    +    \           \n                * * * * * * *\n            \n                The
    +    author of this software is David M. Gay.\n            \n                Copyright
    +    (C) 2004 by David M. Gay.\n                All Rights Reserved\n                Based
    +    on material in the rest of /netlib/fp/gdota.tar.gz,\n                which is
    +    copyright (C) 1998, 2000 by Lucent Technologies.\n            \n                Permission
    +    to use, copy, modify, and distribute this software and\n                its documentation
    +    for any purpose and without fee is hereby\n                granted, provided that
    +    the above copyright notice appear in all\n                copies and that both
    +    that the copyright notice and this\n                permission notice and warranty
    +    disclaimer appear in supporting\n                documentation, and that the name
    +    of Lucent or any of its entities\n                not be used in advertising or
    +    publicity pertaining to\n                distribution of the software without
    +    specific, written prior\n                permission.\n            \n                LUCENT
    +    DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,\n                INCLUDING
    +    ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS.\n                IN NO
    +    EVENT SHALL LUCENT OR ANY OF ITS ENTITIES BE LIABLE FOR ANY\n                SPECIAL,
    +    INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n                WHATSOEVER RESULTING
    +    FROM LOSS OF USE, DATA OR PROFITS, WHETHER\n                IN AN ACTION OF CONTRACT,
    +    NEGLIGENCE OR OTHER TORTIOUS ACTION,\n                ARISING OUT OF OR IN CONNECTION
    +    WITH THE USE OR PERFORMANCE OF\n                THIS SOFTWARE.\n            \n
    +    \           \n                =========================\n                Parts
    +    of the math library\n                =========================\n            \n
    +    \               Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.\n
    +    \           \n                Developed at SunSoft, a Sun Microsystems, Inc. business.\n
    +    \               Permission to use, copy, modify, and distribute this\n                software
    +    is freely granted, provided that this notice\n                is preserved.\n
    +    \           \n                * * * * * * *\n            \n                Copyright
    +    (C) 1993 by Sun Microsystems, Inc. All rights reserved.\n            \n                Developed
    +    at SunPro, a Sun Microsystems, Inc. business.\n                Permission to use,
    +    copy, modify, and distribute this\n                software is freely granted,
    +    provided that this notice\n                is preserved.\n            \n                *
    +    * * * * * *\n            \n                FIXME: Cephes math lib\n                Copyright
    +    (C) 1984-1998 Stephen L. Moshier\n            \n                It sounds vague,
    +    but as to be found at\n                <http: //lists.debian.org/debian-legal/2004/12/msg00295.html>,
    +    it gives an\n                    impression that the author could be willing to
    +    give an explicit\n                    permission to distribute those files e.g.
    +    under a BSD style license. So\n                    probably there is no problem
    +    here, although it could be good to get a\n                    permission from
    +    the author and then add a license into the Cephes files\n                    in
    +    MinGW runtime. At least on follow-up it is marked that debian sees the\n                    version
    +    a-like BSD one. As MinGW.org (where those cephes parts are coming\n                    from)
    +    distributes them now over 6 years, it should be fine.\n            \n                    =================================================\n
    +    \                   Some string, memory and time conversion functions\n                    =================================================\n
    +    \           \n                    Copyright © 2005-2020 Rich Felker, et al.\n
    +    \           \n                    Permission is hereby granted, free of charge,
    +    to any person obtaining\n                    a copy of this software and associated
    +    documentation files (the\n                    \"Software\"), to deal in the Software
    +    without restriction, including\n                    without limitation the rights
    +    to use, copy, modify, merge, publish,\n                    distribute, sublicense,
    +    and/or sell copies of the Software, and to\n                    permit persons
    +    to whom the Software is furnished to do so, subject to\n                    the
    +    following conditions:\n            \n                    The above copyright notice
    +    and this permission notice shall be\n                    included in all copies
    +    or substantial portions of the Software.\n            \n                    THE
    +    SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\n                    EXPRESS
    +    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n                    MERCHANTABILITY,
    +    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\n                    IN
    +    NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\n                    CLAIM,
    +    DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\n                    TORT
    +    OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n                    SOFTWARE
    +    OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n            \n                    ===================================\n
    +    \                   Headers and IDLs imported from Wine\n                    ===================================\n
    +    \           \n                    Some header and IDL files were imported from
    +    the Wine project. These files\n                    are prominent maked in source.
    +    Their copyright belongs to contributors and\n                    they are distributed
    +    under LGPL license.\n            \n                    Disclaimer\n            \n
    +    \                   This library is free software; you can redistribute it and/or\n
    +    \                   modify it under the terms of the GNU Lesser General Public\n
    +    \                   License as published by the Free Software Foundation; either\n
    +    \                   version 2.1 of the License, or (at your option) any later
    +    version.\n            \n                    This library is distributed in the
    +    hope that it will be useful,\n                    but WITHOUT ANY WARRANTY; without
    +    even the implied warranty of\n                    MERCHANTABILITY or FITNESS FOR
    +    A PARTICULAR PURPOSE. See the GNU\n                    Lesser General Public License
    +    for more details.\n            \n            ==========================================================================\n
    +    \           \n            winpthreads\n            \n            Copyright (c)
    +    2011 mingw-w64 project\n            \n            Permission is hereby granted,
    +    free of charge, to any person obtaining a\n            copy of this software and
    +    associated documentation files (the \"Software\"),\n            to deal in the
    +    Software without restriction, including without limitation\n            the rights
    +    to use, copy, modify, merge, publish, distribute, sublicense,\n            and/or
    +    sell copies of the Software, and to permit persons to whom the\n            Software
    +    is furnished to do so, subject to the following conditions:\n            \n            The
    +    above copyright notice and this permission notice shall be included in\n            all
    +    copies or substantial portions of the Software.\n            \n            THE
    +    SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n            IMPLIED,
    +    INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n            FITNESS
    +    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n            AUTHORS
    +    OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n            LIABILITY,
    +    WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n            FROM,
    +    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\n            DEALINGS
    +    IN THE SOFTWARE.\n            \n            \n            /*\n            * Parts
    +    of this library are derived by:\n            *\n            * Posix Threads library
    +    for Microsoft Windows\n            *\n            * Use at own risk, there is
    +    no implied warranty to this code.\n            * It uses undocumented features
    +    of Microsoft Windows that can change\n            * at any time in the future.\n
    +    \           *\n            * (C) 2010 Lockless Inc.\n            * All rights
    +    reserved.\n            *\n            * Redistribution and use in source and binary
    +    forms, with or without modification,\n            * are permitted provided that
    +    the following conditions are met:\n            *\n            *\n            *
    +    * Redistributions of source code must retain the above copyright notice,\n            *
    +    this list of conditions and the following disclaimer.\n            * * Redistributions
    +    in binary form must reproduce the above copyright notice,\n            * this
    +    list of conditions and the following disclaimer in the documentation\n            *
    +    and/or other materials provided with the distribution.\n            * * Neither
    +    the name of Lockless Inc. nor the names of its contributors may be\n            *
    +    used to endorse or promote products derived from this software without\n            *
    +    specific prior written permission.\n            *\n            * THIS SOFTWARE
    +    IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AN\n            *
    +    ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n
    +    \           * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    +    ARE DISCLAIMED.\n            * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
    +    BE LIABLE FOR ANY DIRECT,\n            * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
    +    OR CONSEQUENTIAL DAMAGES (INCLUDING,\n            * BUT NOT LIMITED TO, PROCUREMENT
    +    OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n            * DATA, OR PROFITS;
    +    OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\n            * LIABILITY,
    +    WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE\n            *
    +    OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED\n
    +    \           * OF THE POSSIBILITY OF SUCH DAMAGE.\n            */\n            \n
    +    \           ==========================================================================\n
    +    \           \n            libgcc\n            \n                                        GNU
    +    GENERAL PUBLIC LICENSE\n                                Version 3, 29 June 2007\n
    +    \           \n            Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n
    +    \           Everyone is permitted to copy and distribute verbatim copies\n            of
    +    this license document, but changing it is not allowed.\n            \n                                        Preamble\n
    +    \           \n            The GNU General Public License is a free, copyleft license
    +    for\n            software and other kinds of works.\n            \n            The
    +    licenses for most software and other practical works are designed\n            to
    +    take away your freedom to share and change the works.  By contrast,\n            the
    +    GNU General Public License is intended to guarantee your freedom to\n            share
    +    and change all versions of a program--to make sure it remains free\n            software
    +    for all its users.  We, the Free Software Foundation, use the\n            GNU
    +    General Public License for most of our software; it applies also to\n            any
    +    other work released this way by its authors.  You can apply it to\n            your
    +    programs, too.\n            \n            When we speak of free software, we are
    +    referring to freedom, not\n            price.  Our General Public Licenses are
    +    designed to make sure that you\n            have the freedom to distribute copies
    +    of free software (and charge for\n            them if you wish), that you receive
    +    source code or can get it if you\n            want it, that you can change the
    +    software or use pieces of it in new\n            free programs, and that you know
    +    you can do these things.\n            \n            To protect your rights, we
    +    need to prevent others from denying you\n            these rights or asking you
    +    to surrender the rights.  Therefore, you have\n            certain responsibilities
    +    if you distribute copies of the software, or if\n            you modify it: responsibilities
    +    to respect the freedom of others.\n            \n            For example, if you
    +    distribute copies of such a program, whether\n            gratis or for a fee,
    +    you must pass on to the recipients the same\n            freedoms that you received.
    +    \ You must make sure that they, too, receive\n            or can get the source
    +    code.  And you must show them these terms so they\n            know their rights.\n
    +    \           \n            Developers that use the GNU GPL protect your rights
    +    with two steps:\n            (1) assert copyright on the software, and (2) offer
    +    you this License\n            giving you legal permission to copy, distribute
    +    and/or modify it.\n            \n            For the developers' and authors'
    +    protection, the GPL clearly explains\n            that there is no warranty for
    +    this free software.  For both users' and\n            authors' sake, the GPL requires
    +    that modified versions be marked as\n            changed, so that their problems
    +    will not be attributed erroneously to\n            authors of previous versions.\n
    +    \           \n            Some devices are designed to deny users access to install
    +    or run\n            modified versions of the software inside them, although the
    +    manufacturer\n            can do so.  This is fundamentally incompatible with
    +    the aim of\n            protecting users' freedom to change the software.  The
    +    systematic\n            pattern of such abuse occurs in the area of products for
    +    individuals to\n            use, which is precisely where it is most unacceptable.
    +    \ Therefore, we\n            have designed this version of the GPL to prohibit
    +    the practice for those\n            products.  If such problems arise substantially
    +    in other domains, we\n            stand ready to extend this provision to those
    +    domains in future versions\n            of the GPL, as needed to protect the freedom
    +    of users.\n            \n            Finally, every program is threatened constantly
    +    by software patents.\n            States should not allow patents to restrict
    +    development and use of\n            software on general-purpose computers, but
    +    in those that do, we wish to\n            avoid the special danger that patents
    +    applied to a free program could\n            make it effectively proprietary.
    +    \ To prevent this, the GPL assures that\n            patents cannot be used to
    +    render the program non-free.\n            \n            The precise terms and
    +    conditions for copying, distribution and\n            modification follow.\n            \n
    +    \                               TERMS AND CONDITIONS\n            \n            0.
    +    Definitions.\n            \n            \"This License\" refers to version 3 of
    +    the GNU General Public License.\n            \n            \"Copyright\" also
    +    means copyright-like laws that apply to other kinds of\n            works, such
    +    as semiconductor masks.\n            \n            \"The Program\" refers to any
    +    copyrightable work licensed under this\n            License.  Each licensee is
    +    addressed as \"you\".  \"Licensees\" and\n            \"recipients\" may be individuals
    +    or organizations.\n            \n            To \"modify\" a work means to copy
    +    from or adapt all or part of the work\n            in a fashion requiring copyright
    +    permission, other than the making of an\n            exact copy.  The resulting
    +    work is called a \"modified version\" of the\n            earlier work or a work
    +    \"based on\" the earlier work.\n            \n            A \"covered work\" means
    +    either the unmodified Program or a work based\n            on the Program.\n            \n
    +    \           To \"propagate\" a work means to do anything with it that, without\n
    +    \           permission, would make you directly or secondarily liable for\n            infringement
    +    under applicable copyright law, except executing it on a\n            computer
    +    or modifying a private copy.  Propagation includes copying,\n            distribution
    +    (with or without modification), making available to the\n            public, and
    +    in some countries other activities as well.\n            \n            To \"convey\"
    +    a work means any kind of propagation that enables other\n            parties to
    +    make or receive copies.  Mere interaction with a user through\n            a computer
    +    network, with no transfer of a copy, is not conveying.\n            \n            An
    +    interactive user interface displays \"Appropriate Legal Notices\"\n            to
    +    the extent that it includes a convenient and prominently visible\n            feature
    +    that (1) displays an appropriate copyright notice, and (2)\n            tells
    +    the user that there is no warranty for the work (except to the\n            extent
    +    that warranties are provided), that licensees may convey the\n            work
    +    under this License, and how to view a copy of this License.  If\n            the
    +    interface presents a list of user commands or options, such as a\n            menu,
    +    a prominent item in the list meets this criterion.\n            \n            1.
    +    Source Code.\n            \n            The \"source code\" for a work means the
    +    preferred form of the work\n            for making modifications to it.  \"Object
    +    code\" means any non-source\n            form of a work.\n            \n            A
    +    \"Standard Interface\" means an interface that either is an official\n            standard
    +    defined by a recognized standards body, or, in the case of\n            interfaces
    +    specified for a particular programming language, one that\n            is widely
    +    used among developers working in that language.\n            \n            The
    +    \"System Libraries\" of an executable work include anything, other\n            than
    +    the work as a whole, that (a) is included in the normal form of\n            packaging
    +    a Major Component, but which is not part of that Major\n            Component,
    +    and (b) serves only to enable use of the work with that\n            Major Component,
    +    or to implement a Standard Interface for which an\n            implementation
    +    is available to the public in source code form.  A\n            \"Major Component\",
    +    in this context, means a major essential component\n            (kernel, window
    +    system, and so on) of the specific operating system\n            (if any) on which
    +    the executable work runs, or a compiler used to\n            produce the work,
    +    or an object code interpreter used to run it.\n            \n            The \"Corresponding
    +    Source\" for a work in object code form means all\n            the source code
    +    needed to generate, install, and (for an executable\n            work) run the
    +    object code and to modify the work, including scripts to\n            control
    +    those activities.  However, it does not include the work's\n            System
    +    Libraries, or general-purpose tools or generally available free\n            programs
    +    which are used unmodified in performing those activities but\n            which
    +    are not part of the work.  For example, Corresponding Source\n            includes
    +    interface definition files associated with source files for\n            the work,
    +    and the source code for shared libraries and dynamically\n            linked subprograms
    +    that the work is specifically designed to require,\n            such as by intimate
    +    data communication or control flow between those\n            subprograms and
    +    other parts of the work.\n            \n            The Corresponding Source need
    +    not include anything that users\n            can regenerate automatically from
    +    other parts of the Corresponding\n            Source.\n            \n            The
    +    Corresponding Source for a work in source code form is that\n            same
    +    work.\n            \n            2. Basic Permissions.\n            \n            All
    +    rights granted under this License are granted for the term of\n            copyright
    +    on the Program, and are irrevocable provided the stated\n            conditions
    +    are met.  This License explicitly affirms your unlimited\n            permission
    +    to run the unmodified Program.  The output from running a\n            covered
    +    work is covered by this License only if the output, given its\n            content,
    +    constitutes a covered work.  This License acknowledges your\n            rights
    +    of fair use or other equivalent, as provided by copyright law.\n            \n
    +    \           You may make, run and propagate covered works that you do not\n            convey,
    +    without conditions so long as your license otherwise remains\n            in force.
    +    \ You may convey covered works to others for the sole purpose\n            of
    +    having them make modifications exclusively for you, or provide you\n            with
    +    facilities for running those works, provided that you comply with\n            the
    +    terms of this License in conveying all material for which you do\n            not
    +    control copyright.  Those thus making or running the covered works\n            for
    +    you must do so exclusively on your behalf, under your direction\n            and
    +    control, on terms that prohibit them from making any copies of\n            your
    +    copyrighted material outside their relationship with you.\n            \n            Conveying
    +    under any other circumstances is permitted solely under\n            the conditions
    +    stated below.  Sublicensing is not allowed; section 10\n            makes it unnecessary.\n
    +    \           \n            3. Protecting Users' Legal Rights From Anti-Circumvention
    +    Law.\n            \n            No covered work shall be deemed part of an effective
    +    technological\n            measure under any applicable law fulfilling obligations
    +    under article\n            11 of the WIPO copyright treaty adopted on 20 December
    +    1996, or\n            similar laws prohibiting or restricting circumvention of
    +    such\n            measures.\n            \n            When you convey a covered
    +    work, you waive any legal power to forbid\n            circumvention of technological
    +    measures to the extent such circumvention\n            is effected by exercising
    +    rights under this License with respect to\n            the covered work, and you
    +    disclaim any intention to limit operation or\n            modification of the
    +    work as a means of enforcing, against the work's\n            users, your or third
    +    parties' legal rights to forbid circumvention of\n            technological measures.\n
    +    \           \n            4. Conveying Verbatim Copies.\n            \n            You
    +    may convey verbatim copies of the Program's source code as you\n            receive
    +    it, in any medium, provided that you conspicuously and\n            appropriately
    +    publish on each copy an appropriate copyright notice;\n            keep intact
    +    all notices stating that this License and any\n            non-permissive terms
    +    added in accord with section 7 apply to the code;\n            keep intact all
    +    notices of the absence of any warranty; and give all\n            recipients a
    +    copy of this License along with the Program.\n            \n            You may
    +    charge any price or no price for each copy that you convey,\n            and you
    +    may offer support or warranty protection for a fee.\n            \n            5.
    +    Conveying Modified Source Versions.\n            \n            You may convey
    +    a work based on the Program, or the modifications to\n            produce it from
    +    the Program, in the form of source code under the\n            terms of section
    +    4, provided that you also meet all of these conditions:\n            \n                a)
    +    The work must carry prominent notices stating that you modified\n                it,
    +    and giving a relevant date.\n            \n                b) The work must carry
    +    prominent notices stating that it is\n                released under this License
    +    and any conditions added under section\n                7.  This requirement modifies
    +    the requirement in section 4 to\n                \"keep intact all notices\".\n
    +    \           \n                c) You must license the entire work, as a whole,
    +    under this\n                License to anyone who comes into possession of a copy.
    +    \ This\n                License will therefore apply, along with any applicable
    +    section 7\n                additional terms, to the whole of the work, and all
    +    its parts,\n                regardless of how they are packaged.  This License
    +    gives no\n                permission to license the work in any other way, but
    +    it does not\n                invalidate such permission if you have separately
    +    received it.\n            \n                d) If the work has interactive user
    +    interfaces, each must display\n                Appropriate Legal Notices; however,
    +    if the Program has interactive\n                interfaces that do not display
    +    Appropriate Legal Notices, your\n                work need not make them do so.\n
    +    \           \n            A compilation of a covered work with other separate
    +    and independent\n            works, which are not by their nature extensions of
    +    the covered work,\n            and which are not combined with it such as to form
    +    a larger program,\n            in or on a volume of a storage or distribution
    +    medium, is called an\n            \"aggregate\" if the compilation and its resulting
    +    copyright are not\n            used to limit the access or legal rights of the
    +    compilation's users\n            beyond what the individual works permit.  Inclusion
    +    of a covered work\n            in an aggregate does not cause this License to
    +    apply to the other\n            parts of the aggregate.\n            \n            6.
    +    Conveying Non-Source Forms.\n            \n            You may convey a covered
    +    work in object code form under the terms\n            of sections 4 and 5, provided
    +    that you also convey the\n            machine-readable Corresponding Source under
    +    the terms of this License,\n            in one of these ways:\n            \n
    +    \               a) Convey the object code in, or embodied in, a physical product\n
    +    \               (including a physical distribution medium), accompanied by the\n
    +    \               Corresponding Source fixed on a durable physical medium\n                customarily
    +    used for software interchange.\n            \n                b) Convey the object
    +    code in, or embodied in, a physical product\n                (including a physical
    +    distribution medium), accompanied by a\n                written offer, valid for
    +    at least three years and valid for as\n                long as you offer spare
    +    parts or customer support for that product\n                model, to give anyone
    +    who possesses the object code either (1) a\n                copy of the Corresponding
    +    Source for all the software in the\n                product that is covered by
    +    this License, on a durable physical\n                medium customarily used for
    +    software interchange, for a price no\n                more than your reasonable
    +    cost of physically performing this\n                conveying of source, or (2)
    +    access to copy the\n                Corresponding Source from a network server
    +    at no charge.\n            \n                c) Convey individual copies of the
    +    object code with a copy of the\n                written offer to provide the Corresponding
    +    Source.  This\n                alternative is allowed only occasionally and noncommercially,
    +    and\n                only if you received the object code with such an offer,
    +    in accord\n                with subsection 6b.\n            \n                d)
    +    Convey the object code by offering access from a designated\n                place
    +    (gratis or for a charge), and offer equivalent access to the\n                Corresponding
    +    Source in the same way through the same place at no\n                further charge.
    +    \ You need not require recipients to copy the\n                Corresponding Source
    +    along with the object code.  If the place to\n                copy the object
    +    code is a network server, the Corresponding Source\n                may be on
    +    a different server (operated by you or a third party)\n                that supports
    +    equivalent copying facilities, provided you maintain\n                clear directions
    +    next to the object code saying where to find the\n                Corresponding
    +    Source.  Regardless of what server hosts the\n                Corresponding Source,
    +    you remain obligated to ensure that it is\n                available for as long
    +    as needed to satisfy these requirements.\n            \n                e) Convey
    +    the object code using peer-to-peer transmission, provided\n                you
    +    inform other peers where the object code and Corresponding\n                Source
    +    of the work are being offered to the general public at no\n                charge
    +    under subsection 6d.\n            \n            A separable portion of the object
    +    code, whose source code is excluded\n            from the Corresponding Source
    +    as a System Library, need not be\n            included in conveying the object
    +    code work.\n            \n            A \"User Product\" is either (1) a \"consumer
    +    product\", which means any\n            tangible personal property which is normally
    +    used for personal, family,\n            or household purposes, or (2) anything
    +    designed or sold for incorporation\n            into a dwelling.  In determining
    +    whether a product is a consumer product,\n            doubtful cases shall be
    +    resolved in favor of coverage.  For a particular\n            product received
    +    by a particular user, \"normally used\" refers to a\n            typical or common
    +    use of that class of product, regardless of the status\n            of the particular
    +    user or of the way in which the particular user\n            actually uses, or
    +    expects or is expected to use, the product.  A product\n            is a consumer
    +    product regardless of whether the product has substantial\n            commercial,
    +    industrial or non-consumer uses, unless such uses represent\n            the only
    +    significant mode of use of the product.\n            \n            \"Installation
    +    Information\" for a User Product means any methods,\n            procedures, authorization
    +    keys, or other information required to install\n            and execute modified
    +    versions of a covered work in that User Product from\n            a modified version
    +    of its Corresponding Source.  The information must\n            suffice to ensure
    +    that the continued functioning of the modified object\n            code is in
    +    no case prevented or interfered with solely because\n            modification
    +    has been made.\n            \n            If you convey an object code work under
    +    this section in, or with, or\n            specifically for use in, a User Product,
    +    and the conveying occurs as\n            part of a transaction in which the right
    +    of possession and use of the\n            User Product is transferred to the recipient
    +    in perpetuity or for a\n            fixed term (regardless of how the transaction
    +    is characterized), the\n            Corresponding Source conveyed under this section
    +    must be accompanied\n            by the Installation Information.  But this requirement
    +    does not apply\n            if neither you nor any third party retains the ability
    +    to install\n            modified object code on the User Product (for example,
    +    the work has\n            been installed in ROM).\n            \n            The
    +    requirement to provide Installation Information does not include a\n            requirement
    +    to continue to provide support service, warranty, or updates\n            for
    +    a work that has been modified or installed by the recipient, or for\n            the
    +    User Product in which it has been modified or installed.  Access to a\n            network
    +    may be denied when the modification itself materially and\n            adversely
    +    affects the operation of the network or violates the rules and\n            protocols
    +    for communication across the network.\n            \n            Corresponding
    +    Source conveyed, and Installation Information provided,\n            in accord
    +    with this section must be in a format that is publicly\n            documented
    +    (and with an implementation available to the public in\n            source code
    +    form), and must require no special password or key for\n            unpacking,
    +    reading or copying.\n            \n            7. Additional Terms.\n            \n
    +    \           \"Additional permissions\" are terms that supplement the terms of
    +    this\n            License by making exceptions from one or more of its conditions.\n
    +    \           Additional permissions that are applicable to the entire Program shall\n
    +    \           be treated as though they were included in this License, to the extent\n
    +    \           that they are valid under applicable law.  If additional permissions\n
    +    \           apply only to part of the Program, that part may be used separately\n
    +    \           under those permissions, but the entire Program remains governed by\n
    +    \           this License without regard to the additional permissions.\n            \n
    +    \           When you convey a copy of a covered work, you may at your option\n
    +    \           remove any additional permissions from that copy, or from any part
    +    of\n            it.  (Additional permissions may be written to require their own\n
    +    \           removal in certain cases when you modify the work.)  You may place\n
    +    \           additional permissions on material, added by you to a covered work,\n
    +    \           for which you have or can give appropriate copyright permission.\n
    +    \           \n            Notwithstanding any other provision of this License,
    +    for material you\n            add to a covered work, you may (if authorized by
    +    the copyright holders of\n            that material) supplement the terms of this
    +    License with terms:\n            \n                a) Disclaiming warranty or
    +    limiting liability differently from the\n                terms of sections 15
    +    and 16 of this License; or\n            \n                b) Requiring preservation
    +    of specified reasonable legal notices or\n                author attributions
    +    in that material or in the Appropriate Legal\n                Notices displayed
    +    by works containing it; or\n            \n                c) Prohibiting misrepresentation
    +    of the origin of that material, or\n                requiring that modified versions
    +    of such material be marked in\n                reasonable ways as different from
    +    the original version; or\n            \n                d) Limiting the use for
    +    publicity purposes of names of licensors or\n                authors of the material;
    +    or\n            \n                e) Declining to grant rights under trademark
    +    law for use of some\n                trade names, trademarks, or service marks;
    +    or\n            \n                f) Requiring indemnification of licensors and
    +    authors of that\n                material by anyone who conveys the material (or
    +    modified versions of\n                it) with contractual assumptions of liability
    +    to the recipient, for\n                any liability that these contractual assumptions
    +    directly impose on\n                those licensors and authors.\n            \n
    +    \           All other non-permissive additional terms are considered \"further\n
    +    \           restrictions\" within the meaning of section 10.  If the Program as
    +    you\n            received it, or any part of it, contains a notice stating that
    +    it is\n            governed by this License along with a term that is a further\n
    +    \           restriction, you may remove that term.  If a license document contains\n
    +    \           a further restriction but permits relicensing or conveying under this\n
    +    \           License, you may add to a covered work material governed by the terms\n
    +    \           of that license document, provided that the further restriction does\n
    +    \           not survive such relicensing or conveying.\n            \n            If
    +    you add terms to a covered work in accord with this section, you\n            must
    +    place, in the relevant source files, a statement of the\n            additional
    +    terms that apply to those files, or a notice indicating\n            where to
    +    find the applicable terms.\n            \n            Additional terms, permissive
    +    or non-permissive, may be stated in the\n            form of a separately written
    +    license, or stated as exceptions;\n            the above requirements apply either
    +    way.\n            \n            8. Termination.\n            \n            You
    +    may not propagate or modify a covered work except as expressly\n            provided
    +    under this License.  Any attempt otherwise to propagate or\n            modify
    +    it is void, and will automatically terminate your rights under\n            this
    +    License (including any patent licenses granted under the third\n            paragraph
    +    of section 11).\n            \n            However, if you cease all violation
    +    of this License, then your\n            license from a particular copyright holder
    +    is reinstated (a)\n            provisionally, unless and until the copyright holder
    +    explicitly and\n            finally terminates your license, and (b) permanently,
    +    if the copyright\n            holder fails to notify you of the violation by some
    +    reasonable means\n            prior to 60 days after the cessation.\n            \n
    +    \           Moreover, your license from a particular copyright holder is\n            reinstated
    +    permanently if the copyright holder notifies you of the\n            violation
    +    by some reasonable means, this is the first time you have\n            received
    +    notice of violation of this License (for any work) from that\n            copyright
    +    holder, and you cure the violation prior to 30 days after\n            your receipt
    +    of the notice.\n            \n            Termination of your rights under this
    +    section does not terminate the\n            licenses of parties who have received
    +    copies or rights from you under\n            this License.  If your rights have
    +    been terminated and not permanently\n            reinstated, you do not qualify
    +    to receive new licenses for the same\n            material under section 10.\n
    +    \           \n            9. Acceptance Not Required for Having Copies.\n            \n
    +    \           You are not required to accept this License in order to receive or\n
    +    \           run a copy of the Program.  Ancillary propagation of a covered work\n
    +    \           occurring solely as a consequence of using peer-to-peer transmission\n
    +    \           to receive a copy likewise does not require acceptance.  However,\n
    +    \           nothing other than this License grants you permission to propagate
    +    or\n            modify any covered work.  These actions infringe copyright if
    +    you do\n            not accept this License.  Therefore, by modifying or propagating
    +    a\n            covered work, you indicate your acceptance of this License to do
    +    so.\n            \n            10. Automatic Licensing of Downstream Recipients.\n
    +    \           \n            Each time you convey a covered work, the recipient automatically\n
    +    \           receives a license from the original licensors, to run, modify and\n
    +    \           propagate that work, subject to this License.  You are not responsible\n
    +    \           for enforcing compliance by third parties with this License.\n            \n
    +    \           An \"entity transaction\" is a transaction transferring control of
    +    an\n            organization, or substantially all assets of one, or subdividing
    +    an\n            organization, or merging organizations.  If propagation of a covered\n
    +    \           work results from an entity transaction, each party to that\n            transaction
    +    who receives a copy of the work also receives whatever\n            licenses to
    +    the work the party's predecessor in interest had or could\n            give under
    +    the previous paragraph, plus a right to possession of the\n            Corresponding
    +    Source of the work from the predecessor in interest, if\n            the predecessor
    +    has it or can get it with reasonable efforts.\n            \n            You may
    +    not impose any further restrictions on the exercise of the\n            rights
    +    granted or affirmed under this License.  For example, you may\n            not
    +    impose a license fee, royalty, or other charge for exercise of\n            rights
    +    granted under this License, and you may not initiate litigation\n            (including
    +    a cross-claim or counterclaim in a lawsuit) alleging that\n            any patent
    +    claim is infringed by making, using, selling, offering for\n            sale,
    +    or importing the Program or any portion of it.\n            \n            11.
    +    Patents.\n            \n            A \"contributor\" is a copyright holder who
    +    authorizes use under this\n            License of the Program or a work on which
    +    the Program is based.  The\n            work thus licensed is called the contributor's
    +    \"contributor version\".\n            \n            A contributor's \"essential
    +    patent claims\" are all patent claims\n            owned or controlled by the
    +    contributor, whether already acquired or\n            hereafter acquired, that
    +    would be infringed by some manner, permitted\n            by this License, of
    +    making, using, or selling its contributor version,\n            but do not include
    +    claims that would be infringed only as a\n            consequence of further modification
    +    of the contributor version.  For\n            purposes of this definition, \"control\"
    +    includes the right to grant\n            patent sublicenses in a manner consistent
    +    with the requirements of\n            this License.\n            \n            Each
    +    contributor grants you a non-exclusive, worldwide, royalty-free\n            patent
    +    license under the contributor's essential patent claims, to\n            make,
    +    use, sell, offer for sale, import and otherwise run, modify and\n            propagate
    +    the contents of its contributor version.\n            \n            In the following
    +    three paragraphs, a \"patent license\" is any express\n            agreement or
    +    commitment, however denominated, not to enforce a patent\n            (such as
    +    an express permission to practice a patent or covenant not to\n            sue
    +    for patent infringement).  To \"grant\" such a patent license to a\n            party
    +    means to make such an agreement or commitment not to enforce a\n            patent
    +    against the party.\n            \n            If you convey a covered work, knowingly
    +    relying on a patent license,\n            and the Corresponding Source of the
    +    work is not available for anyone\n            to copy, free of charge and under
    +    the terms of this License, through a\n            publicly available network server
    +    or other readily accessible means,\n            then you must either (1) cause
    +    the Corresponding Source to be so\n            available, or (2) arrange to deprive
    +    yourself of the benefit of the\n            patent license for this particular
    +    work, or (3) arrange, in a manner\n            consistent with the requirements
    +    of this License, to extend the patent\n            license to downstream recipients.
    +    \ \"Knowingly relying\" means you have\n            actual knowledge that, but
    +    for the patent license, your conveying the\n            covered work in a country,
    +    or your recipient's use of the covered work\n            in a country, would infringe
    +    one or more identifiable patents in that\n            country that you have reason
    +    to believe are valid.\n            \n            If, pursuant to or in connection
    +    with a single transaction or\n            arrangement, you convey, or propagate
    +    by procuring conveyance of, a\n            covered work, and grant a patent license
    +    to some of the parties\n            receiving the covered work authorizing them
    +    to use, propagate, modify\n            or convey a specific copy of the covered
    +    work, then the patent license\n            you grant is automatically extended
    +    to all recipients of the covered\n            work and works based on it.\n            \n
    +    \           A patent license is \"discriminatory\" if it does not include within\n
    +    \           the scope of its coverage, prohibits the exercise of, or is\n            conditioned
    +    on the non-exercise of one or more of the rights that are\n            specifically
    +    granted under this License.  You may not convey a covered\n            work if
    +    you are a party to an arrangement with a third party that is\n            in the
    +    business of distributing software, under which you make payment\n            to
    +    the third party based on the extent of your activity of conveying\n            the
    +    work, and under which the third party grants, to any of the\n            parties
    +    who would receive the covered work from you, a discriminatory\n            patent
    +    license (a) in connection with copies of the covered work\n            conveyed
    +    by you (or copies made from those copies), or (b) primarily\n            for and
    +    in connection with specific products or compilations that\n            contain
    +    the covered work, unless you entered into that arrangement,\n            or that
    +    patent license was granted, prior to 28 March 2007.\n            \n            Nothing
    +    in this License shall be construed as excluding or limiting\n            any implied
    +    license or other defenses to infringement that may\n            otherwise be available
    +    to you under applicable patent law.\n            \n            12. No Surrender
    +    of Others' Freedom.\n            \n            If conditions are imposed on you
    +    (whether by court order, agreement or\n            otherwise) that contradict
    +    the conditions of this License, they do not\n            excuse you from the conditions
    +    of this License.  If you cannot convey a\n            covered work so as to satisfy
    +    simultaneously your obligations under this\n            License and any other
    +    pertinent obligations, then as a consequence you may\n            not convey it
    +    at all.  For example, if you agree to terms that obligate you\n            to
    +    collect a royalty for further conveying from those to whom you convey\n            the
    +    Program, the only way you could satisfy both those terms and this\n            License
    +    would be to refrain entirely from conveying the Program.\n            \n            13.
    +    Use with the GNU Affero General Public License.\n            \n            Notwithstanding
    +    any other provision of this License, you have\n            permission to link
    +    or combine any covered work with a work licensed\n            under version 3
    +    of the GNU Affero General Public License into a single\n            combined work,
    +    and to convey the resulting work.  The terms of this\n            License will
    +    continue to apply to the part which is the covered work,\n            but the
    +    special requirements of the GNU Affero General Public License,\n            section
    +    13, concerning interaction through a network will apply to the\n            combination
    +    as such.\n            \n            14. Revised Versions of this License.\n            \n
    +    \           The Free Software Foundation may publish revised and/or new versions
    +    of\n            the GNU General Public License from time to time.  Such new versions
    +    will\n            be similar in spirit to the present version, but may differ
    +    in detail to\n            address new problems or concerns.\n            \n            Each
    +    version is given a distinguishing version number.  If the\n            Program
    +    specifies that a certain numbered version of the GNU General\n            Public
    +    License \"or any later version\" applies to it, you have the\n            option
    +    of following the terms and conditions either of that numbered\n            version
    +    or of any later version published by the Free Software\n            Foundation.
    +    \ If the Program does not specify a version number of the\n            GNU General
    +    Public License, you may choose any version ever published\n            by the
    +    Free Software Foundation.\n            \n            If the Program specifies
    +    that a proxy can decide which future\n            versions of the GNU General
    +    Public License can be used, that proxy's\n            public statement of acceptance
    +    of a version permanently authorizes you\n            to choose that version for
    +    the Program.\n            \n            Later license versions may give you additional
    +    or different\n            permissions.  However, no additional obligations are
    +    imposed on any\n            author or copyright holder as a result of your choosing
    +    to follow a\n            later version.\n            \n            15. Disclaimer
    +    of Warranty.\n            \n            THERE IS NO WARRANTY FOR THE PROGRAM,
    +    TO THE EXTENT PERMITTED BY\n            APPLICABLE LAW.  EXCEPT WHEN OTHERWISE
    +    STATED IN WRITING THE COPYRIGHT\n            HOLDERS AND/OR OTHER PARTIES PROVIDE
    +    THE PROGRAM \"AS IS\" WITHOUT WARRANTY\n            OF ANY KIND, EITHER EXPRESSED
    +    OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\n            THE IMPLIED WARRANTIES
    +    OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\n            PURPOSE.  THE ENTIRE
    +    RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\n            IS WITH YOU.
    +    \ SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\n            ALL
    +    NECESSARY SERVICING, REPAIR OR CORRECTION.\n            \n            16. Limitation
    +    of Liability.\n            \n            IN NO EVENT UNLESS REQUIRED BY APPLICABLE
    +    LAW OR AGREED TO IN WRITING\n            WILL ANY COPYRIGHT HOLDER, OR ANY OTHER
    +    PARTY WHO MODIFIES AND/OR CONVEYS\n            THE PROGRAM AS PERMITTED ABOVE,
    +    BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\n            GENERAL, SPECIAL, INCIDENTAL
    +    OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\n            USE OR INABILITY TO USE
    +    THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\n            DATA OR DATA BEING
    +    RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\n            PARTIES OR
    +    A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\n            EVEN
    +    IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\n            SUCH
    +    DAMAGES.\n            \n            17. Interpretation of Sections 15 and 16.\n
    +    \           \n            If the disclaimer of warranty and limitation of liability
    +    provided\n            above cannot be given local legal effect according to their
    +    terms,\n            reviewing courts shall apply local law that most closely approximates\n
    +    \           an absolute waiver of all civil liability in connection with the\n
    +    \           Program, unless a warranty or assumption of liability accompanies
    +    a\n            copy of the Program in return for a fee.\n            \n                                END
    +    OF TERMS AND CONDITIONS\n            \n                        How to Apply These
    +    Terms to Your New Programs\n            \n            If you develop a new program,
    +    and you want it to be of the greatest\n            possible use to the public,
    +    the best way to achieve this is to make it\n            free software which everyone
    +    can redistribute and change under these terms.\n            \n            To do
    +    so, attach the following notices to the program.  It is safest\n            to
    +    attach them to the start of each source file to most effectively\n            state
    +    the exclusion of warranty; and each file should have at least\n            the
    +    \"copyright\" line and a pointer to where the full notice is found.\n            \n
    +    \               <one line to give the program's name and a brief idea of what
    +    it does.>\n                Copyright (C) <year>  <name of author>\n
    +    \           \n                This program is free software: you can redistribute
    +    it and/or modify\n                it under the terms of the GNU General Public
    +    License as published by\n                the Free Software Foundation, either
    +    version 3 of the License, or\n                (at your option) any later version.\n
    +    \           \n                This program is distributed in the hope that it
    +    will be useful,\n                but WITHOUT ANY WARRANTY; without even the implied
    +    warranty of\n                MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
    +    \ See the\n                GNU General Public License for more details.\n            \n
    +    \               You should have received a copy of the GNU General Public License\n
    +    \               along with this program.  If not, see <https://www.gnu.org/licenses/>.\n
    +    \           \n            Also add information on how to contact you by electronic
    +    and paper mail.\n            \n            If the program does terminal interaction,
    +    make it output a short\n            notice like this when it starts in an interactive
    +    mode:\n            \n                <program>  Copyright (C) <year>  <name
    +    of author>\n                This program comes with ABSOLUTELY NO WARRANTY;
    +    for details type `show w'.\n                This is free software, and you are
    +    welcome to redistribute it\n                under certain conditions; type `show
    +    c' for details.\n            \n            The hypothetical commands `show w'
    +    and `show c' should show the appropriate\n            parts of the General Public
    +    License.  Of course, your program's commands\n            might be different;
    +    for a GUI interface, you would use an \"about box\".\n            \n            You
    +    should also get your employer (if you work as a programmer) or school,\n            if
    +    any, to sign a \"copyright disclaimer\" for the program, if necessary.\n            For
    +    more information on this, and how to apply and follow the GNU GPL, see\n            <https://www.gnu.org/licenses/>.\n
    +    \           \n            The GNU General Public License does not permit incorporating
    +    your program\n            into proprietary programs.  If your program is a subroutine
    +    library, you\n            may consider it more useful to permit linking proprietary
    +    applications with\n            the library.  If this is what you want to do, use
    +    the GNU Lesser General\n            Public License instead of this License.  But
    +    first, please read\n            <https://www.gnu.org/licenses/why-not-lgpl.html>.\n
    +    \           \n            \n            GCC RUNTIME LIBRARY EXCEPTION\n            Version
    +    3.1, 31 March 2009\n            \n            Copyright © 2009 Free Software Foundation,
    +    Inc.\n            <https://fsf.org/>\n            \n            Everyone is
    +    permitted to copy and distribute verbatim copies of this license document, but
    +    changing it is not allowed.\n            \n            This GCC Runtime Library
    +    Exception (\"Exception\") is an additional permission under section 7 of the GNU
    +    General Public\n            License, version 3 (\"GPLv3\"). It applies to a given
    +    file (the \"Runtime Library\") that bears a notice placed by the\n            copyright
    +    holder of the file stating that the file is governed by GPLv3 along with this
    +    Exception.\n            \n            When you use GCC to compile a program, GCC
    +    may combine portions of certain GCC header files and runtime libraries with\n
    +    \           the compiled program. The purpose of this Exception is to allow compilation
    +    of non-GPL (including proprietary) programs\n            to use, in this way,
    +    the header files and runtime libraries covered by this Exception.\n            \n
    +    \           0. Definitions.\n            A file is an \"Independent Module\" if
    +    it either requires the Runtime Library for execution after a Compilation Process,\n
    +    \           or makes use of an interface provided by the Runtime Library, but
    +    is not otherwise based on the Runtime Library.\n            \n            \"GCC\"
    +    means a version of the GNU Compiler Collection, with or without modifications,
    +    governed by version 3 (or a\n            specified later version) of the GNU General
    +    Public License (GPL) with the option of using any subsequent versions\n            published
    +    by the FSF.\n            \n            \"GPL-compatible Software\" is software
    +    whose conditions of propagation, modification and use would permit combination\n
    +    \           with GCC in accord with the license of GCC.\n            \n            \"Target
    +    Code\" refers to output from any compiler for a real or virtual target processor
    +    architecture, in executable form\n            or suitable for input to an assembler,
    +    loader, linker and/or execution phase. Notwithstanding that, Target Code does
    +    not\n            include data in any format that is used as a compiler intermediate
    +    representation, or used for producing a compiler\n            intermediate representation.\n
    +    \           \n            The \"Compilation Process\" transforms code entirely
    +    represented in non-intermediate languages designed for human-written\n            code,
    +    and/or in Java Virtual Machine byte code, into Target Code. Thus, for example,
    +    use of source code generators and\n            preprocessors need not be considered
    +    part of the Compilation Process, since the Compilation Process can be understood
    +    as\n            starting with the output of the generators or preprocessors.\n
    +    \           \n            A Compilation Process is \"Eligible\" if it is done
    +    using GCC, alone or with other GPL-compatible software, or if it is\n            done
    +    without using any work based on GCC. For example, using non-GPL-compatible Software
    +    to optimize any GCC\n            intermediate representations would not qualify
    +    as an Eligible Compilation Process.\n            \n            1. Grant of Additional
    +    Permission.\n            You have permission to propagate a work of Target Code
    +    formed by combining the Runtime Library with Independent Modules,\n            even
    +    if such propagation would otherwise violate the terms of GPLv3, provided that
    +    all Target Code was generated by\n            Eligible Compilation Processes.
    +    You may then convey such a combination under terms of your choice, consistent
    +    with the\n            licensing of the Independent Modules.\n            \n            2.
    +    No Weakening of GCC Copyleft.\n            The availability of this Exception
    +    does not imply any general presumption that third-party software is unaffected
    +    by the\n            copyleft requirements of the license of GCC.\n                
    \n + \
  • \n
\n
\n\n\n\n\n" +notices: [] diff --git a/.licenses/bundler/simplecov-cobertura.dep.yml b/.licenses/bundler/simplecov-cobertura.dep.yml new file mode 100644 index 0000000000000..284aa47a50f96 --- /dev/null +++ b/.licenses/bundler/simplecov-cobertura.dep.yml @@ -0,0 +1,228 @@ +--- +name: simplecov-cobertura +version: 3.2.0 +type: bundler +summary: SimpleCov Cobertura Formatter +homepage: https://github.com/jessebs/simplecov-cobertura +license: apache-2.0 +licenses: +- sources: LICENSE + text: |+ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +- sources: README.md + text: |- + Copyright 2025 Jesse Bowes + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/simplecov-html.dep.yml b/.licenses/bundler/simplecov-html.dep.yml new file mode 100644 index 0000000000000..42526252846b8 --- /dev/null +++ b/.licenses/bundler/simplecov-html.dep.yml @@ -0,0 +1,31 @@ +--- +name: simplecov-html +version: 0.13.2 +type: bundler +summary: Default HTML formatter for SimpleCov code coverage tool for ruby 2.4+ +homepage: https://github.com/simplecov-ruby/simplecov-html +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) 2010-2013 Christoph Olszowka + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/simplecov.dep.yml b/.licenses/bundler/simplecov.dep.yml new file mode 100644 index 0000000000000..a7ce909139cfe --- /dev/null +++ b/.licenses/bundler/simplecov.dep.yml @@ -0,0 +1,31 @@ +--- +name: simplecov +version: 0.22.0 +type: bundler +summary: Code coverage for Ruby +homepage: https://github.com/simplecov-ruby/simplecov +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) 2010-2017 Christoph Olszowka + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/simplecov_json_formatter.dep.yml b/.licenses/bundler/simplecov_json_formatter.dep.yml new file mode 100644 index 0000000000000..6b370206f928e --- /dev/null +++ b/.licenses/bundler/simplecov_json_formatter.dep.yml @@ -0,0 +1,30 @@ +--- +name: simplecov_json_formatter +version: 0.1.4 +type: bundler +summary: JSON formatter for SimpleCov +homepage: https://github.com/fede-moya/simplecov_json_formatter +license: mit +licenses: +- sources: Auto-generated MIT license text + text: | + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +notices: [] diff --git a/.licenses/bundler/simpleidn.dep.yml b/.licenses/bundler/simpleidn.dep.yml new file mode 100644 index 0000000000000..9e32ca5b429b6 --- /dev/null +++ b/.licenses/bundler/simpleidn.dep.yml @@ -0,0 +1,32 @@ +--- +name: simpleidn +version: 0.2.3 +type: bundler +summary: Punycode ACE to unicode UTF-8 (and vice-versa) string conversion. +homepage: https://github.com/mmriis/simpleidn +license: mit +licenses: +- sources: LICENCE + text: | + The MIT License + + Copyright (c) 2011-2024 Morten Møller Riis + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/sorbet-runtime.dep.yml b/.licenses/bundler/sorbet-runtime.dep.yml new file mode 100644 index 0000000000000..7b8b5a7d3f3b6 --- /dev/null +++ b/.licenses/bundler/sorbet-runtime.dep.yml @@ -0,0 +1,207 @@ +--- +name: sorbet-runtime +version: 0.6.13316 +type: bundler +summary: Sorbet runtime +homepage: https://sorbet.org +license: apache-2.0 +licenses: +- sources: Auto-generated Apache-2.0 license text + text: |2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/sorbet-static-and-runtime.dep.yml b/.licenses/bundler/sorbet-static-and-runtime.dep.yml new file mode 100644 index 0000000000000..911244a59f4c0 --- /dev/null +++ b/.licenses/bundler/sorbet-static-and-runtime.dep.yml @@ -0,0 +1,207 @@ +--- +name: sorbet-static-and-runtime +version: 0.6.13316 +type: bundler +summary: A Typechecker for Ruby +homepage: https://sorbet.org +license: apache-2.0 +licenses: +- sources: Auto-generated Apache-2.0 license text + text: |2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/sorbet-static.dep.yml b/.licenses/bundler/sorbet-static.dep.yml new file mode 100644 index 0000000000000..4e301953bcd59 --- /dev/null +++ b/.licenses/bundler/sorbet-static.dep.yml @@ -0,0 +1,207 @@ +--- +name: sorbet-static +version: 0.6.13316 +type: bundler +summary: A Typechecker for Ruby +homepage: https://sorbet.org +license: apache-2.0 +licenses: +- sources: Auto-generated Apache-2.0 license text + text: |2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/sorbet.dep.yml b/.licenses/bundler/sorbet.dep.yml new file mode 100644 index 0000000000000..a6b22da8a4567 --- /dev/null +++ b/.licenses/bundler/sorbet.dep.yml @@ -0,0 +1,207 @@ +--- +name: sorbet +version: 0.6.13316 +type: bundler +summary: A Typechecker for Ruby +homepage: https://sorbet.org +license: apache-2.0 +licenses: +- sources: Auto-generated Apache-2.0 license text + text: |2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/spoom.dep.yml b/.licenses/bundler/spoom.dep.yml new file mode 100644 index 0000000000000..8d31060d6d0f7 --- /dev/null +++ b/.licenses/bundler/spoom.dep.yml @@ -0,0 +1,11 @@ +--- +name: spoom +version: 1.8.2 +type: bundler +summary: Useful tools for Sorbet enthusiasts. +homepage: https://github.com/Shopify/spoom +license: mit +licenses: +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +notices: [] diff --git a/.licenses/bundler/tapioca.dep.yml b/.licenses/bundler/tapioca.dep.yml new file mode 100644 index 0000000000000..746ca63989d3c --- /dev/null +++ b/.licenses/bundler/tapioca.dep.yml @@ -0,0 +1,35 @@ +--- +name: tapioca +version: 0.19.2 +type: bundler +summary: A Ruby Interface file generator for gems, core types and the Ruby standard + library +homepage: https://github.com/Shopify/tapioca +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2019-present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: The gem is available as open source under the terms of the [MIT License](https://github.com/Shopify/tapioca/blob/main/LICENSE.txt). +notices: [] diff --git a/.licenses/bundler/test-prof.dep.yml b/.licenses/bundler/test-prof.dep.yml new file mode 100644 index 0000000000000..c5288c1aa877e --- /dev/null +++ b/.licenses/bundler/test-prof.dep.yml @@ -0,0 +1,37 @@ +--- +name: test-prof +version: 1.6.1 +type: bundler +summary: Ruby applications tests profiling tools +homepage: http://github.com/test-prof/test-prof +license: mit +licenses: +- sources: LICENSE.txt + text: | + The MIT License (MIT) + + Copyright (c) 2017-2020 Vladimir Dementyev + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +- sources: README.md + text: |- + The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). + + [docs]: https://test-prof.evilmartians.io +notices: [] diff --git a/.licenses/bundler/thor.dep.yml b/.licenses/bundler/thor.dep.yml new file mode 100644 index 0000000000000..1ee31ef7bb5bf --- /dev/null +++ b/.licenses/bundler/thor.dep.yml @@ -0,0 +1,36 @@ +--- +name: thor +version: 1.5.0 +type: bundler +summary: Thor is a toolkit for building powerful command-line interfaces. +homepage: https://github.com/rails/thor +license: mit +licenses: +- sources: LICENSE.md + text: | + Copyright (c) 2008 Yehuda Katz, Eric Hodel, et al. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: |- + Released under the MIT License. See the [LICENSE][] file for further details. + + [license]: LICENSE.md +notices: [] diff --git a/.licenses/bundler/tsort.dep.yml b/.licenses/bundler/tsort.dep.yml new file mode 100644 index 0000000000000..bf32c0c76cb7a --- /dev/null +++ b/.licenses/bundler/tsort.dep.yml @@ -0,0 +1,33 @@ +--- +name: tsort +version: 0.2.0 +type: bundler +summary: Topological sorting using Tarjan's algorithm +homepage: https://github.com/ruby/tsort +license: other +licenses: +- sources: LICENSE.txt + text: | + Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. +notices: [] diff --git a/.licenses/bundler/unicode-display_width.dep.yml b/.licenses/bundler/unicode-display_width.dep.yml new file mode 100644 index 0000000000000..d454e30e26881 --- /dev/null +++ b/.licenses/bundler/unicode-display_width.dep.yml @@ -0,0 +1,33 @@ +--- +name: unicode-display_width +version: 3.2.0 +type: bundler +summary: Determines the monospace display width of a string in Ruby. +homepage: https://github.com/janlelis/unicode-display_width +license: mit +licenses: +- sources: MIT-LICENSE.txt + text: | + The MIT LICENSE + + Copyright (c) 2011, 2015-2024 Jan Lelis + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/unicode-emoji.dep.yml b/.licenses/bundler/unicode-emoji.dep.yml new file mode 100644 index 0000000000000..b5218b1f2d891 --- /dev/null +++ b/.licenses/bundler/unicode-emoji.dep.yml @@ -0,0 +1,31 @@ +--- +name: unicode-emoji +version: 4.2.0 +type: bundler +summary: Emoji data and regex +homepage: https://github.com/janlelis/unicode-emoji +license: mit +licenses: +- sources: MIT-LICENSE.txt + text: | + Copyright (c) 2017-2024 Jan Lelis, https://janlelis.com + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +notices: [] diff --git a/.licenses/bundler/warning.dep.yml b/.licenses/bundler/warning.dep.yml new file mode 100644 index 0000000000000..3bb0dfb759414 --- /dev/null +++ b/.licenses/bundler/warning.dep.yml @@ -0,0 +1,26 @@ +--- +name: warning +version: 1.6.0 +type: bundler +summary: Add custom processing for warnings +homepage: https://github.com/jeremyevans/ruby-warning +license: mit +licenses: +- sources: MIT-LICENSE + text: "Copyright (c) 2016-2026 Jeremy Evans and contributors\n\nPermission is hereby + granted, free of charge, to any person obtaining a copy\nof this software and + associated documentation files (the \"Software\"), to\ndeal in the Software without + restriction, including without limitation the\nrights to use, copy, modify, merge, + publish, distribute, sublicense, and/or\nsell copies of the Software, and to permit + persons to whom the Software is\nfurnished to do so, subject to the following + conditions:\n \nThe above copyright notice and this permission notice shall be + included in\nall copies or substantial portions of the Software.\n \nTHE SOFTWARE + IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING + BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR + PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL\nTHE AUTHORS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER \nIN AN ACTION OF CONTRACT, TORT OR + OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE + OR OTHER DEALINGS IN THE SOFTWARE.\n" +- sources: README.rdoc + text: MIT +notices: [] diff --git a/.licenses/bundler/yard-sorbet.dep.yml b/.licenses/bundler/yard-sorbet.dep.yml new file mode 100644 index 0000000000000..d9aaca56597b9 --- /dev/null +++ b/.licenses/bundler/yard-sorbet.dep.yml @@ -0,0 +1,212 @@ +--- +name: yard-sorbet +version: 0.9.0 +type: bundler +summary: Create YARD docs from Sorbet type signatures +homepage: https://github.com/dduugg/yard-sorbet +license: apache-2.0 +licenses: +- sources: LICENSE + text: |2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +notices: [] diff --git a/.licenses/bundler/yard.dep.yml b/.licenses/bundler/yard.dep.yml new file mode 100644 index 0000000000000..42c6b1a75c3a9 --- /dev/null +++ b/.licenses/bundler/yard.dep.yml @@ -0,0 +1,135 @@ +--- +name: yard +version: 0.9.44 +type: bundler +summary: Documentation tool for consistent and usable documentation in Ruby. +homepage: https://yardoc.org +license: mit +licenses: +- sources: LICENSE + text: | + Copyright (c) 2007-2026 Loren Segal + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. +- sources: README.md + text: |- + YARD © by [Loren Segal](mailto:lsegal@soen.ca). YARD is licensed + under the MIT license except for some files which come from the RDoc/Ruby + distributions. Please see the {file:LICENSE} and {file:LEGAL} documents for more + information. +notices: +- sources: LEGAL + text: |- + LEGAL NOTICE INFORMATION + ------------------------ + + All the files in this distribution are covered under either the MIT + license (see the file LICENSE) except some files mentioned below. + + lib/yard/parser/ruby/legacy/ruby_lex.rb: + + This file is under the Ruby license. YARD uses a modified version of it. + + Ruby is copyrighted free software by Yukihiro Matsumoto . + You can redistribute it and/or modify it under either the terms of the GPL + version 2 (see the file GPL), or the conditions below: + + 1. You may make and give away verbatim copies of the source form of the + software without restriction, provided that you duplicate all of the + original copyright notices and associated disclaimers. + + 2. You may modify your copy of the software in any way, provided that + you do at least ONE of the following: + + a) place your modifications in the Public Domain or otherwise + make them Freely Available, such as by posting said + modifications to Usenet or an equivalent medium, or by allowing + the author to include your modifications in the software. + + b) use the modified software only within your corporation or + organization. + + c) give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d) make other distribution arrangements with the author. + + 3. You may distribute the software in object code or binary form, + provided that you do at least ONE of the following: + + a) distribute the binaries and library files of the software, + together with instructions (in the manual page or equivalent) + on where to get the original distribution. + + b) accompany the distribution with the machine-readable source of + the software. + + c) give non-standard binaries non-standard names, with + instructions on where to get the original software distribution. + + d) make other distribution arrangements with the author. + + 4. You may modify and include the part of the software into any other + software (possibly commercial). But some files in the distribution + are not written by the author, so that they are not under these terms. + + For the list of those files and their copying conditions, see the + file LEGAL. + + 5. The scripts and library files supplied as input to or produced as + output from the software do not automatically fall under the + copyright of the software, but belong to whomever generated them, + and may be sold commercially, and may be aggregated with this + software. + + 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. + + lib/yard/server/http_utils.rb: + + This file is vendored and slightly modified from WEBrick because it was + removed from Ruby core in Ruby 3.x. + + Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000000000..5fb9c0db2f9af --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,32 @@ +# Look for 'source'd files relative to the current working directory where the +# shellcheck command is invoked. +source-path=Library + +# Allow opening any 'source'd file, even if not specified as input +external-sources=true + +# SC2310: This function is invoked in an 'if' / ! condition so set -e will be +# disabled. Invoke separately if failures should cause the script to exit. +# See: https://github.com/koalaman/shellcheck/wiki/SC2310 +# +# We don't use the `set -e` feature in Bash scripts yet. +# We do need the return status as condition for if switches. +# Allow `if command` and `if ! command`. +disable=SC2310 + +# SC2311: Bash implicitly disabled set -e for this function invocation because +# it's inside a command substitution. Add set -e; before it or enable inherit_errexit. +# See: https://github.com/koalaman/shellcheck/wiki/SC2311 +# +# We don't use the `set -e` feature in Bash scripts yet. +# We don't need return codes for "$(command)", only stdout is needed. +# Allow `var="$(command)"`, etc. +disable=SC2311 + +# SC2312: Consider invoking this command separately to avoid masking its return +# value (or use '|| true' to ignore). +# See: https://github.com/koalaman/shellcheck/wiki/SC2312 +# +# We don't need return codes for "$(command)", only stdout is needed. +# Allow `[[ -n "$(command)" ]]`, `func "$(command)"`, pipes, etc. +disable=SC2312 diff --git a/.sublime/homebrew.sublime-project b/.sublime/homebrew.sublime-project new file mode 100644 index 0000000000000..6a212a4dd638d --- /dev/null +++ b/.sublime/homebrew.sublime-project @@ -0,0 +1,28 @@ +{ + "folders": + [ + { + "//": "Generated based on the contents of our .gitignore", + "path": "..", + "folder_exclude_patterns": [ + "Caskroom", + "Cellar", + "Frameworks", + "bin", + "etc", + "include", + "lib", + "opt", + "sbin", + "share", + "var", + "Library/Homebrew/LinkedKegs", + "Library/Homebrew/Aliases" + ], + "follow_symlinks": true + } + ], + "settings": { + "tab_size": 2 + } +} diff --git a/.vale.ini b/.vale.ini index 8aeb07510139e..13f035651a317 100644 --- a/.vale.ini +++ b/.vale.ini @@ -1,4 +1,7 @@ StylesPath = ./docs/vale-styles -[*.md] +[formats] +rb = md + +[*.{md,rb}] BasedOnStyles = Homebrew diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000..33251fe7a3813 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "Shopify.ruby-lsp", + "sorbet.sorbet-vscode-extension", + "github.vscode-github-actions", + "anykeyh.simplecov-vscode", + "ms-azuretools.vscode-docker", + "github.vscode-pull-request-github", + "davidanson.vscode-markdownlint", + "foxundermoon.shell-format", + "timonwong.shellcheck", + "ban.spellright", + "redhat.vscode-yaml", + "koichisasada.vscode-rdbg", + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000..867e7ff68c3d3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "rdbg", + "name": "Debug Homebrew command", + "request": "launch", + "rdbgPath": "${workspaceFolder}/Library/Homebrew/shims/gems/rdbg", + "command": "brew debugger --", + "script": "${fileBasenameNoExtension}", + "askParameters": true + }, + { + "type": "rdbg", + "name": "Attach to Homebrew debugger", + "request": "attach", + "rdbgPath": "${workspaceFolder}/Library/Homebrew/shims/gems/rdbg", + "env": { + "TMPDIR": "/private/tmp/", + } + } + ] +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000000000..49ec5f4a56542 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "Homebrew": { + "type": "stdio", + "command": "brew", + "args": [ + "mcp-server" + ] + } + } +} diff --git a/.vscode/ruby-lsp-activate.sh b/.vscode/ruby-lsp-activate.sh new file mode 100755 index 0000000000000..150b53386c8a8 --- /dev/null +++ b/.vscode/ruby-lsp-activate.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# This might be sourced from zsh, so support both +if [[ -n "${BASH_SOURCE[0]}" ]]; then + SCRIPT_PATH="${BASH_SOURCE[0]}" +elif [[ -n "${ZSH_VERSION}" ]]; then + # This is zsh-specific syntax. + # shellcheck disable=SC2296 + SCRIPT_PATH="${(%):-%x}" +else + SCRIPT_PATH="$0" +fi +HOMEBREW_PREFIX="$(cd "$(dirname "${SCRIPT_PATH}")"/../ && pwd)" + +# These are used by the functions needed from utils/ruby.sh +export HOMEBREW_BREW_FILE="${HOMEBREW_PREFIX}/bin/brew" +export HOMEBREW_LIBRARY="${HOMEBREW_PREFIX}/Library" +export BUNDLE_WITH="style:typecheck:vscode" + +# shellcheck source=../Library/Homebrew/utils/ruby.sh +source "${HOMEBREW_PREFIX}/Library/Homebrew/utils/ruby.sh" + +setup-ruby-path +setup-gem-home-bundle-gemfile +ensure-bundle-dependencies + +# setup-ruby-path doesn't add Homebrew's ruby to PATH +homebrew_ruby_bin="$(dirname "${HOMEBREW_RUBY_PATH}")" +export PATH="${homebrew_ruby_bin}:${PATH}" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000..14864f0d4af35 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,71 @@ +{ + "search.exclude": { + "Library/Homebrew/vendor/bundle/ruby/": true, + "Library/Homebrew/vendor/gems/": true, + "Library/Homebrew/vendor/portable-ruby/": true + }, + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.rulers": [ + 80, + 118 + ], + "files.encoding": "utf8", + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "rubyLsp.rubyVersionManager": { + "identifier": "custom" + }, + "spellright.language": [ + "en_GB", + "en_US" + ], + "spellright.documentTypes": [ + "markdown", + ], + "rubyLsp.customRubyCommand": "source ../../.vscode/ruby-lsp-activate.sh", + "rubyLsp.bundleGemfile": "Library/Homebrew/Gemfile", + "rubyLsp.formatter": "rubocop", + "[ruby]": { + "editor.defaultFormatter": "Shopify.ruby-lsp", + "editor.formatOnSave": true, + "editor.formatOnType": true, + "editor.semanticHighlighting.enabled": true, + }, + "sorbet.enabled": true, + "sorbet.lspConfigs": [ + { + "id": "default", + "name": "Brew Typecheck", + "description": "Default configuration", + "command": [ + "./bin/brew", + "typecheck", + "--lsp", + ] + } + ], + "sorbet.selectedLspConfigId": "default", + "shellcheck.customArgs": [ + "--shell=bash", + "--enable=all", + "--external-sources", + "--source-path=${workspaceFolder}/Library" + ], + "shellformat.effectLanguages": [ + "shellscript" + ], + "shellformat.path": "${workspaceFolder}/Library/Homebrew/utils/shfmt.sh", + "shellformat.flag": "-i 2 -ci -ln bash", + "[shellscript]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.shellcheck": "explicit" + } + }, + "simplecov-vscode.path": "Library/Homebrew/test/coverage/.resultset.json", + "simplecov-vscode.enabled": false, + "specstory.cloudSync.enabled": "never" +} diff --git a/.yardopts b/.yardopts deleted file mode 100644 index 74d88803832b1..0000000000000 --- a/.yardopts +++ /dev/null @@ -1,10 +0,0 @@ ---title "Homebrew" ---main Library/Homebrew/README.md ---markup markdown ---no-private ---exclude Library/Homebrew/test/ ---exclude Library/Homebrew/vendor/ ---exclude Library/Homebrew/compat/ -Library/Homebrew/**/*.rb -- -*.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000..01bf823e03927 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# Agent Instructions for Homebrew/brew + +Most importantly, run `./bin/brew lgtm` to verify any file edits before prompting for input to run all style checks and tests. + +This is a Ruby based repository with Bash scripts for faster execution. +It is primarily responsible for providing the `brew` command for the Homebrew package manager. +Please follow these guidelines when contributing: + +When running commands in this repository, use `./bin/brew` (not a system `brew` on `PATH`). + +When running Ruby directly (e.g. `ruby -e ...`, `gem`, profiling tools), never use the system Ruby. Use `./bin/brew ruby -- ` to run Ruby scripts with Homebrew's vendored Ruby and libraries loaded. The system macOS Ruby is an incompatible older version. + +Do not use conventional commit prefixes such as `feat:`, `fix:`, `chore:`, `refactor:`, `perf:` or `ci:`; the `Commit Style` GitHub Actions workflow rejects them. + +## Code Standards + +### Required Before Each Commit + +- Run `./bin/brew typecheck` to verify types are declared correctly using Sorbet. + Individual files/directories cannot be checked. + `./bin/brew typecheck` is fast enough to just be run globally every time. +- Run `./bin/brew style --fix --changed` to lint code formatting using RuboCop. + Individual files can be checked/fixed by passing them as arguments e.g. `./bin/brew style --fix Library/Homebrew/cmd/reinstall.rb` +- Run `./bin/brew tests --online --changed` to ensure that RSpec unit tests are passing (although some online tests may be flaky so can be ignored if they pass on a rerun). + Individual test files can be passed with `--only` e.g. to test `Library/Homebrew/cmd/reinstall.rb` with `Library/Homebrew/test/cmd/reinstall_spec.rb` run `./bin/brew tests --only=cmd/reinstall`. +- Shortcut: `./bin/brew lgtm --online` runs all of the required checks above in one command. +- All of the above can be run via the Homebrew MCP Server (launch with `./bin/brew mcp-server`). + +### Development Flow + +- Write new code (using Sorbet `sig` type signatures and `typed: strict` for new files). +- Write new tests (use at most one `:integration_test` per command, make it a happy-path test and keep it as fast as possible; add another only for essential core functionality in essential non-developer commands). Try `typed: true` as a baseline but revert to `typed: false` if there are not easily fixable errors. + Write fast tests by preferring a single `expect` per unit test and combine expectations in a single test when it is an integration test or has non-trivial `before` for test setup. +- When adding or tightening tests, verify them with a red/green cycle using the exact `--only=file:line` target for the example you changed. +- Formula classes created in specs may be frozen; avoid stubbing class methods on them with RSpec mocks and prefer instance-level stubs or test setup that does not require class-method stubbing. +- RSpec snapshots and restores `ENV` around every example via the global `config.around` hook in `Library/Homebrew/test/spec_helper.rb` (it saves `ENV.to_hash` before `example.run` and calls `ENV.replace` in the `ensure`), so specs can set environment variables directly without a `with_env` wrapper or manual cleanup. +- Keep comments minimal; prefer self-documenting code through strings, variable names, etc. over more comments. +- Put a comment immediately above each `shellcheck disable` explaining why it is needed. +- Aim to wrap human-written user-facing terminal output at around 80 characters; this does not apply to generated output or code. + +## Repository Structure + +- `bin/brew`: Homebrew's `brew` command main Bash entry point script +- `completions/`: Generated shell (`bash`/`fish`/`zsh`) completion files. Don't edit directly, regenerate with `./bin/brew generate-man-completions` +- `Library/Homebrew/`: Homebrew's core Ruby (with a little bash) logic. +- `Library/Homebrew/bundle/`: Homebrew's `brew bundle` command. +- `Library/Homebrew/cask/`: Homebrew's Cask classes and DSL. +- `Library/Homebrew/extend/os/`: Homebrew's OS-specific (i.e. macOS or Linux) class extension logic. +- `Library/Homebrew/formula.rb`: Homebrew's Formula class and DSL. +- `docs/`: Documentation for Homebrew users, contributors and maintainers. Consult these for best practices and help. +- `manpages/`: Generated `man` documentation files. Don't edit directly, regenerate with `./bin/brew generate-man-completions` +- `package/`: Files to generate the macOS `.pkg` file. + +## Key Guidelines + +1. Follow Ruby and Bash best practices and idiomatic patterns. +2. Maintain existing code structure and organisation. +3. Write unit tests for new functionality. +4. Document public APIs and complex logic. +5. Suggest changes to the `docs/` folder when appropriate +6. Follow software principles such as DRY and YAGNI. +7. Keep diffs as minimal as possible. +8. Prefer shelling out via `HOMEBREW_BREW_FILE` instead of requiring `cmd/` or `dev-cmd` when composing brew commands. +9. Inline new or existing methods as methods or local variables unless they are reused 2+ times or needed for unit tests. +10. Avoid `T.must`, `T.cast`, `T.let`, `T.untyped` and `T.anything` where possible while maintaining `typed: strict`; prefer explicit nil checks, precise types and APIs that return non-nil values. If a generic top type is unavoidable, prefer `T.anything` over `T.untyped`. +11. Avoid `T.unsafe(self)` whenever possible; prefer `requires_ancestor` or similar typed module patterns. +12. Prefer `.public_send` over `.send` where possible; call methods directly when practical. Use `.send` only when a private API must be invoked. +13. Keep `extend/os/*` prepends as thin as possible; put the `prepend` in the OS-specific `linux` or `macos` file rather than the shared `extend/os/*` loader with an inline `if`, and prefer putting substantive logic in shared code outside `extend/` when practical so it can be tested on all platforms instead of relying on `:needs_linux` or `:needs_macos` specs. +14. When Bash logic mirrors Ruby logic, keep both implementations in sync and add two-way comments naming the matching Ruby and Bash locations; keep matching helper filenames aligned where practical. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b5be7c10f1f..f880deb64a54f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,3 @@ See Homebrew's [releases on GitHub](https://github.com/Homebrew/brew/releases) for the changelog. + +Release notes for major and minor releases can also be found in the [Homebrew blog](https://brew.sh/blog/). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000..43c994c2d3617 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000000..ef71ecf399470 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,11 @@ +# Note that the naming of this file is incorrect for our case: these people do not "own" the provided files but are +# people with write-access to this repository who wish to approve changes to these files. +# +# Their review is required to merge PRs that change these files. +# +# To be explicit: we will never accept changes to this file adding people from outside the Homebrew GitHub +# organisation. If you are not a Homebrew maintainer: you do not personally "own" or "maintain" any files. +# +# Note: teams without write-access to this repository cannot be listed in this file. + +docs/Support-Tiers.md @Homebrew/lead-maintainers @MikeMcQuaid diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1618b90cd376..cb1a30a4f9686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,35 @@ # Contributing to Homebrew -First time contributing to Homebrew? Read our [Code of Conduct](https://github.com/Homebrew/.github/blob/master/CODE_OF_CONDUCT.md#code-of-conduct) and review [How To Open a Homebrew Pull Request](https://docs.brew.sh/How-To-Open-a-Homebrew-Pull-Request). +First time contributing to Homebrew? Read our [Code of Conduct](https://github.com/Homebrew/.github/blob/HEAD/CODE_OF_CONDUCT.md#code-of-conduct) and review [How to Open a Homebrew Pull Request](https://docs.brew.sh/How-To-Open-a-Homebrew-Pull-Request). + +### Discuss, ask questions about or disagree with changes in Homebrew + +* [Homebrew/discussions](https://github.com/orgs/Homebrew/discussions) is the place for community discussions around Homebrew. +* Our issues and pull requests are for maintainers and contributors to discuss work to be done, not users or contributors asking questions about Homebrew usage or disagreeing with changes already made. ### Report a bug * Run `brew update` (twice). * Run and read `brew doctor`. * Read the [Troubleshooting checklist](https://docs.brew.sh/Troubleshooting). -* Open an issue on the formula's repository or on Homebrew/brew if it's not a formula-specific issue. +* Ideally, open a pull request to fix it, describing both your problem and your proposed solution. +* If not, open an issue on the formula's repository or on Homebrew/brew if it's not a formula-specific issue, but do not open both an issue and a pull request. ### Propose a feature -* Open an issue with a detailed description of your proposed feature, the motivation for it and alternatives considered. Please note we may close this issue or ask you to create a pull-request if this is not something we see as sufficiently high priority. +* Ideally, open a pull request to implement it, describing both the problem it solves for you and your proposed solution. +* If not, open an issue with a detailed description of your proposed feature, the motivation for it and alternatives considered. +* Please note we may close this issue or ask you to create a pull request if this is not something we see as sufficiently high priority. +* Any pull request claiming performance improvements (e.g. "this is faster") must include [Hyperfine](https://github.com/sharkdp/hyperfine) benchmark results demonstrating the improvement. + +### "Artificial Intelligence"/Large Language Model (AI/LLM) usage + +We allow you to create issues and pull requests with AI/LLM with the following requirements (see [Responsible AI Usage](https://docs.brew.sh/Responsible-AI-Usage) for the principles behind them): + +* You must disclose in the initial issue or pull request that you used AI/LLM and what tool/model/etc. you used. +* You must review all AI/LLM generated code, prose, etc. content before you ask anyone in Homebrew to review it for you. +* You must be able to address all pull request review comments, manually if the AI/LLM cannot do so for you. +* Unless you are a maintainer, you may only have one AI-assisted/generated pull request open at a time. +* If you reach the point where you feel unwilling or unable to do the above, please close your issue or pull request. Thanks! diff --git a/Dockerfile b/Dockerfile index 442fe01b993e3..42eb0a8518f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,109 @@ -FROM ubuntu:xenial -LABEL maintainer="Shaun Jackman " - -RUN apt-get update \ - && apt-get install -y --no-install-recommends software-properties-common \ - && add-apt-repository -y ppa:git-core/ppa \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - bzip2 \ - ca-certificates \ - curl \ - file \ - fonts-dejavu-core \ - g++ \ - git \ - libz-dev \ - locales \ - make \ - openssh-client \ - patch \ - sudo \ - uuid-runtime \ - tzdata \ - && rm -rf /var/lib/apt/lists/* - -RUN localedef -i en_US -f UTF-8 en_US.UTF-8 \ - && useradd -m -s /bin/bash linuxbrew \ - && echo 'linuxbrew ALL=(ALL) NOPASSWD:ALL' >>/etc/sudoers -ADD . /home/linuxbrew/.linuxbrew/Homebrew -ARG FORCE_REBUILD -RUN cd /home/linuxbrew/.linuxbrew \ - && mkdir -p bin etc include lib opt sbin share var/homebrew/linked Cellar \ - && ln -s ../Homebrew/bin/brew /home/linuxbrew/.linuxbrew/bin/ \ - && cd /home/linuxbrew/.linuxbrew/Homebrew \ - && git remote set-url origin https://github.com/Homebrew/brew +ARG version=24.04 +# version is passed through by Docker. +# shellcheck disable=SC2154 +FROM ubuntu:"${version}" +ARG DEBIAN_FRONTEND=noninteractive +# Deterministic UID (first user). Helps with docker build cache +ENV USER_ID=1000 +# Delete the default ubuntu user & group UID=1000 GID=1000 in Ubuntu 23.04+ +# that conflicts with the linuxbrew user +RUN touch /var/mail/ubuntu && chown ubuntu /var/mail/ubuntu && userdel -r ubuntu; true + +# We don't want to manually pin versions, happy to use whatever +# Ubuntu thinks is best. +# hadolint ignore=DL3008 + +# `gh` installation taken from https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian-ubuntu-linux-raspberry-pi-os-apt +# /etc/lsb-release is checked inside the container and sets DISTRIB_RELEASE. +# We need `[` instead of `[[` because the shell is `/bin/sh`. +# `:` below is a no-op that works around a ShellCheck parsing error. +# shellcheck disable=SC1091,SC2154,SC2292 +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ + : \ + && retry() { bash -c 'for i in {1..5}; do "$@" && exit 0; [[ $((i)) -lt 5 ]] && sleep $((i)); done; exit 1' -- "$@"; } \ + && retry apt-get update --error-on=any \ + && apt-get install -y --no-install-recommends software-properties-common gnupg-agent \ + && if [ "$(uname -m)" != aarch64 ]; then retry add-apt-repository -y ppa:git-core/ppa; fi \ + && retry apt-get update --error-on=any \ + && apt-get install -y --no-install-recommends \ + acl \ + bubblewrap \ + bzip2 \ + ca-certificates \ + curl \ + file \ + fonts-dejavu-core \ + g++ \ + gawk \ + git \ + gpg \ + less \ + locales \ + make \ + netbase \ + openssh-client \ + patch \ + skopeo \ + sudo \ + unzip \ + uuid-runtime \ + tzdata \ + jq \ + && if [ "$(. /etc/lsb-release; echo "${DISTRIB_RELEASE}" | cut -d. -f1)" -eq 24 ]; then apt-get install -y --no-install-recommends g++-14; fi \ + && mkdir -p /etc/apt/keyrings \ + && chmod 0755 /etc /etc/apt /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list >/dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && apt-get remove --purge -y software-properties-common \ + && apt-get autoremove --purge -y \ + && sed -i -E 's/^(USERGROUPS_ENAB\s+)yes$/\1no/' /etc/login.defs \ + && localedef -i en_US -f UTF-8 en_US.UTF-8 \ + && useradd -u "${USER_ID}" --create-home --shell /bin/bash --user-group linuxbrew \ + && echo 'linuxbrew ALL=(ALL) NOPASSWD:ALL' >>/etc/sudoers \ + && su - linuxbrew -c 'mkdir ~/.linuxbrew' + +USER linuxbrew +ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" \ + HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew \ + HOMEBREW_CELLAR=/home/linuxbrew/.linuxbrew/Cellar \ + HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew \ + XDG_CACHE_HOME=/home/linuxbrew/.cache WORKDIR /home/linuxbrew -ENV PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH \ - SHELL=/bin/bash - -# Install portable-ruby and tap homebrew/core. -RUN HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_AUTO_UPDATE=1 brew tap homebrew/core \ - && chown -R linuxbrew: /home/linuxbrew/.linuxbrew \ - && chmod -R g+w,o-w /home/linuxbrew/.linuxbrew \ - && rm -rf ~/.cache + +ARG HOMEBREW_CORE_REVISION=origin/main + +RUN --mount=type=cache,target=/tmp/homebrew-core,uid="${USER_ID}",sharing=locked \ + # Clone the homebrew-core repo into /tmp/homebrew-core or fetch latest changes if it exists + git clone https://github.com/homebrew/homebrew-core /tmp/homebrew-core || git -C /tmp/homebrew-core fetch origin main \ + && git -C /tmp/homebrew-core checkout --force -B main "${HOMEBREW_CORE_REVISION:-origin/main}" \ + && mkdir -p /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/homebrew/homebrew-core \ + && cp -r /tmp/homebrew-core /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/homebrew/ + +COPY --chown=linuxbrew:linuxbrew . /home/linuxbrew/.linuxbrew/Homebrew + +RUN --mount=type=cache,target=/home/linuxbrew/.cache,uid="${USER_ID}" \ + --mount=type=cache,target=/home/linuxbrew/.bundle,uid="${USER_ID}" \ + mkdir -p \ + .linuxbrew/bin \ + .linuxbrew/etc \ + .linuxbrew/include \ + .linuxbrew/lib \ + .linuxbrew/opt \ + .linuxbrew/sbin \ + .linuxbrew/share \ + .linuxbrew/var/homebrew/linked \ + .linuxbrew/Cellar \ + && ln -s ../Homebrew/bin/brew .linuxbrew/bin/brew \ + && git -C .linuxbrew/Homebrew remote set-url origin https://github.com/Homebrew/brew \ + && git -C .linuxbrew/Homebrew fetch origin \ + && HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_AUTO_UPDATE=1 brew tap --force homebrew/core \ + && brew install-bundler-gems --groups=all \ + && brew cleanup \ + && { git -C .linuxbrew/Homebrew config --unset gc.auto; true; } \ + && { git -C .linuxbrew/Homebrew config --unset homebrew.devcmdrun; true; } \ + && touch .linuxbrew/.homebrewdocker diff --git a/Dockerfile.yml b/Dockerfile.yml deleted file mode 100644 index ac02ce9147764..0000000000000 --- a/Dockerfile.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: '3.7' - -services: - sut: - build: - context: . - cache_from: - - homebrew/brew - args: - - FORCE_REBUILD=1 - command: - - sh - - -xc - - | - /home/linuxbrew/.linuxbrew/bin/brew test-bot - status=$$? - exit $$status diff --git a/Library/.rubocop.yml b/Library/.rubocop.yml index 6f8953a32f0b1..f1f35f386df1f 100644 --- a/Library/.rubocop.yml +++ b/Library/.rubocop.yml @@ -1,114 +1,485 @@ -inherit_from: ./.rubocop_shared.yml +--- +plugins: + - rubocop-md: + plugin_class_name: RuboCop::Markdown::Plugin + - rubocop-performance: + plugin_class_name: RuboCop::Performance::Plugin + - rubocop-rspec: + plugin_class_name: RuboCop::RSpec::Plugin + - rubocop-sorbet: + plugin_class_name: RuboCop::Sorbet::Plugin + - test-prof: + plugin_class_name: RuboCop::TestProf::Plugin + +require: + - ./Homebrew/rubocops.rb + +inherit_mode: + merge: + - Include + - Exclude + +AllCops: + # Only bump this after the first tagged brew release with a new Ruby version, + # otherwise taps will be prompted to use newer Ruby syntax too early. + TargetRubyVersion: 4.0 + NewCops: enable + Include: + - "**/*.rbi" + Exclude: + - "Homebrew/sorbet/rbi/{annotations,dsl,gems}/**/*.rbi" + - "Homebrew/sorbet/rbi/parser*.rbi" + - "Homebrew/bin/*" + - "Homebrew/vendor/**/*" + - "Taps/*/*/vendor/**/*" + - "**/.github/copilot-instructions.md" # This should be treated the same as the `docs/` files. + SuggestExtensions: + rubocop-minitest: false + +Cask/Desc: + Description: "Ensure that the desc stanza conforms to various content and style checks." + Enabled: true + +Cask/HomepageUrlStyling: + Description: "Ensure that the homepage url has the correct format and styling." + Enabled: true + +Cask/StanzaGrouping: + Description: "Ensure that cask stanzas are grouped correctly. More info at https://docs.brew.sh/Cask-Cookbook#stanza-order" + Enabled: true + +Cask/StanzaOrder: + Description: "Ensure that cask stanzas are sorted correctly. More info at https://docs.brew.sh/Cask-Cookbook#stanza-order" + Enabled: true -# enable all formulae audits FormulaAudit: Enabled: true -# enable all formulae strict audits FormulaAuditStrict: Enabled: true -# disable all formulae strict audits by default -NewFormulaAudit: - Enabled: false +Homebrew: + Enabled: true -# make our hashes consistent -Layout/HashAlignment: - EnforcedHashRocketStyle: table - EnforcedColonStyle: table +Homebrew/Blank: + Exclude: + # Core extensions are not available here: + - "Homebrew/startup/bootsnap.rb" -# `system` is a special case and aligns on second argument -Layout/ArgumentAlignment: - Enabled: false +Homebrew/CompactBlank: + Exclude: + # `blank?` is not necessarily available here: + - "Homebrew/extend/enumerable.rb" -# favour parens-less DSL-style arguments -Lint/AmbiguousOperator: +Homebrew/NoFileutilsRmrf: + Include: + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + +Homebrew/OSDependsOn: + Include: + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + Exclude: + - "Homebrew/test/**/*.rb" + +# only used internally +Homebrew/MoveToExtendOS: Enabled: false +Homebrew/NegateInclude: + Exclude: + # `exclude?` is not available here: + - "Homebrew/standalone/init.rb" + - "Homebrew/rubocops/**/*" + - "Homebrew/sorbet/tapioca/**/*" + +Homebrew/UnreferencedLet: + Description: "Removes a lazy `let` whose name is never referenced (its block never runs)." + Include: + - "Homebrew/test/**/*_spec.rb" + # Deletion is unsafe (explicit -A required, never applied on a plain run): the cop is heuristic + # and cannot see references made across files via shared examples or included harnesses. + SafeAutoCorrect: false + +# `system` is a special case and aligns on second argument, so allow this for formulae. +Layout/ArgumentAlignment: + Exclude: + - "Taps/*/*/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" + # this is a bit less "floaty" Layout/CaseIndentation: EnforcedStyle: end +# significantly less indentation involved; more consistent +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + # this is a bit less "floaty" Layout/EndAlignment: EnforcedStyleAlignWith: start_of_line +# make our hashes consistent +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + +# Need to allow #: for external commands. +Layout/LeadingCommentSpace: + Exclude: + - "Taps/*/*/cmd/*.rb" + +# GitHub diff UI wraps beyond 118 characters +Layout/LineLength: + Max: 118 + # ignore manpage comments and long single-line strings + AllowedPatterns: + [ + "#: ", + ' url "', + ' mirror "', + " plist_options ", + ' executable: "', + ' font "', + ' homepage "', + ' name "', + ' pkg "', + ' pkgutil: "', + " sha256 cellar: ", + " sha256 ", + "#{language}", + "#{version.", + ' "/Library/Application Support/', + '"/Library/Caches/', + '"/Library/PreferencePanes/', + ' "~/Library/Application Support/', + '"~/Library/Caches/', + '"~/Library/Containers', + '"~/Application Support', + " was verified as official when first introduced to the cask", + ] + # conflicts with DSL-style path concatenation with `/` Layout/SpaceAroundOperators: Enabled: false -# Auto-correct is broken (https://github.com/rubocop-hq/rubocop/issues/6258) -# and layout is not configurable (https://github.com/rubocop-hq/rubocop/issues/6254). -Layout/RescueEnsureAlignment: - Enabled: false +# makes DSL usage ugly. +Layout/SpaceBeforeBrackets: + Exclude: + - "**/*_spec.rb" + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" # favour parens-less DSL-style arguments Lint/AmbiguousBlockAssociation: Enabled: false +Lint/DuplicateBranch: + Exclude: + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + # so many of these in formulae and can't be autocorrected -# TODO: fix these as `ruby -w` complains about them. -Lint/AmbiguousRegexpLiteral: +Lint/ParenthesesAsGroupedExpression: + Exclude: + - "Taps/*/*/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" + +# unused keyword arguments improve APIs +Lint/UnusedMethodArgument: + AllowUnusedKeywordArguments: true + +# These metrics didn't end up helping. +Metrics: Enabled: false -# assignment in conditions are useful sometimes -# TODO: add parentheses for these and remove -Lint/AssignmentInCondition: +# Disabled because it breaks Sorbet: "The declaration for `with` is missing parameter(s): & (RuntimeError)" +Naming/BlockForwarding: Enabled: false -# we output how to use interpolated strings too often -Lint/InterpolationCheck: +# Allow dashes in filenames. +Naming/FileName: + Regex: !ruby/regexp /^[\w\@\-\+\.]+(\.rb)?$/ + +# Implicitly allow EOS as we use it everywhere. +Naming/HeredocDelimiterNaming: + ForbiddenDelimiters: + - END, EOD, EOF + +Naming/InclusiveLanguage: + CheckStrings: true + FlaggedTerms: + slave: + AllowedRegex: + - "gitslave" # Used in formula `gitslave` + - "log_slave" # Used in formula `ssdb` + - "ssdb_slave" # Used in formula `ssdb` + - "var_slave" # Used in formula `ssdb` + - "patches/13_fix_scope_for_show_slave_status_data.patch" # Used in formula `mytop` + +Naming/MethodName: + AllowedPatterns: + - '\A(fetch_)?HEAD\?\Z' + +Naming/MethodParameterName: + inherit_mode: + merge: + - AllowedNames + +# Allows a nicer API for boolean methods with side effects. +Naming/PredicateMethod: + AllowBangMethods: true + +# Both styles are used depending on context, +# e.g. `sha256` and `something_countable_1`. +Naming/VariableNumber: Enabled: false -# so many of these in formulae and can't be autocorrected -Lint/ParenthesesAsGroupedExpression: +# Makes code less readable for minor performance increases. +Performance/Caller: Enabled: false -# most metrics don't make sense to apply for formulae/taps -Metrics/AbcSize: +# Does not hinder readability, so might as well enable it. +Performance/CaseWhenSplat: + Enabled: true + +# Makes code less readable for minor performance increases. +Performance/MethodObjectAsBlock: + Enabled: false + +RSpec: + Include: + - "Homebrew/test/**/*" + +# Intentionally disabled as it doesn't fit with our code style. +RSpec/AnyInstance: + Enabled: false +RSpec/IncludeExamples: + Enabled: false +RSpec/SpecFilePathFormat: + Enabled: false +RSpec/StubbedMock: Enabled: false -Metrics/ClassLength: +RSpec/SubjectStub: Enabled: false -Metrics/CyclomaticComplexity: +# These were ever-growing numbers, not useful. +RSpec/ExampleLength: Enabled: false -Metrics/MethodLength: +RSpec/MultipleExpectations: Enabled: false -Metrics/ModuleLength: +RSpec/NestedGroups: Enabled: false -Metrics/PerceivedComplexity: +RSpec/MultipleMemoizedHelpers: Enabled: false -# GitHub diff UI wraps beyond 118 characters (so that's the goal) -Layout/LineLength: - Max: 170 - # ignore manpage comments and long single-line strings - IgnoredPatterns: ['#: ', ' url "', ' mirror "', ' plist_options :'] +RSpec/DescribedClassModuleWrapping: + Enabled: true +# Annoying to have these autoremoved. +RSpec/Focus: + AutoCorrect: false +# We use `allow(:foo).to receive(:bar)` everywhere. +RSpec/MessageSpies: + EnforcedStyle: receive + +RSpec/Output: + Exclude: + - "**/fixtures/**/*.rb" + +# This is already the default +Sorbet/FalseSigil: + Enabled: false + +# We generally prefer to colo rbi files with the Ruby files they describe. +Sorbet/ForbidRBIOutsideOfAllowedPaths: + Enabled: false + +# T::Sig is monkey-patched into Module +Sorbet/RedundantExtendTSig: + Enabled: true + +Sorbet/StrictSigil: + Enabled: true + Exclude: + - "Taps/**/*" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "Homebrew/bundle/{formula_dumper,checker,commands/exec}.rb" # These aren't typed: true yet. + - "Homebrew/formula-analytics/pycall-setup.rbi" # Type stubs for external PyCall/InfluxDB dependencies. + - "Homebrew/ignorable.rbi" # Workaround for Sorbet to allow aliasing `raise`. + - "Homebrew/{standalone,startup}/*.rb" # These are loaded before sorbet-runtime + - "Homebrew/test/**/*.rb" + - "Homebrew/test/support/fixtures/rubocop@x.x.x.rbi" # Autogenerated test fixture for RuboCop types. + - "Homebrew/utils/ruby_check_version_script.rb" # A standalone script. + +Sorbet/TrueSigil: + Enabled: true + Exclude: + - "Taps/**/*" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "Homebrew/test/**/*.rb" -# our current conditional style is established -# TODO: enable this when possible -Style/ConditionalAssignment: +# Require &&/|| instead of and/or +Style/AndOr: + EnforcedStyle: always + +# Disabled because it breaks Sorbet: "The declaration for `with` is missing parameter(s): & (RuntimeError)" +Style/ArgumentsForwarding: Enabled: false -# most of our APIs are internal so don't require docs +# Avoid leaking resources. +Style/AutoResourceCleanup: + Enabled: true + +# This makes these a little more obvious. +Style/BarePercentLiterals: + EnforcedStyle: percent_q + +Style/BlockDelimiters: + BracesRequiredMethods: + - "sig" + +Style/ClassAndModuleChildren: + Exclude: + - "**/*.rbi" + +# Use consistent style for better readability. +Style/CollectionMethods: + Enabled: true + +# Don't allow cops to be disabled in casks and formulae. +Style/DisableCopsWithinSourceCodeDirective: + Enabled: true + Include: + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + +# The files actually scanned in this cop are in `Library/Homebrew/.rubocop.yml`. Style/Documentation: - Enabled: false + Exclude: + - "Taps/**/*" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "**/*.rbi" + +# This is impossible to fix if the line exceeds the maximum length. +Style/EmptyMethod: + Exclude: + - "**/*.rbi" + +# This is quite a large change, so don't enforce this yet for formulae. +# We should consider doing so in the future, but be aware of the impact on third-party taps. +Style/FetchEnvVar: + Exclude: + - "Taps/*/*/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" -# don't want this for formulae but re-enabled for Library/Homebrew +# Doesn't make sense for formulae with e.g. requirements. +Style/OneClassPerFile: + Exclude: + - "**/*.rbi" + - "Taps/*/*/*.rb" + - "/**/Abstract/**/*.rb" + - "**/Abstract/**/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" + - "/**/developer/bin/*" + - "**/developer/bin/*" + +# Not used for casks and formulae. Style/FrozenStringLiteralComment: - Enabled: false + EnforcedStyle: always + Exclude: + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "Homebrew/test/**/Casks/**/*.rb" + - "**/*.rbi" + - "**/Brewfile" -# so many of these in formulae and can't be autocorrected +# potential for errors in formulae too high with this Style/GuardClause: + Exclude: + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + +# Allow for license expressions +Style/HashAsLastArrayItem: + Exclude: + - "Taps/*/*/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" + +Style/InverseMethods: + InverseMethods: + :blank?: :present? + +Style/InvertibleUnlessCondition: + Enabled: true + InverseMethods: + # Favor `if a != b` over `unless a == b` + :==: :!= + # Unset this (prefer `unless a.zero?` over `if a.nonzero?`) + :zero?: + :blank?: :present? + +# It's confusing to the ruby novice to support both `it` and `_1` +Style/ItBlockParameter: + EnforcedStyle: only_numbered_parameters + +Style/MutableConstant: + # would rather freeze too much than too little + EnforcedStyle: strict + +# Zero-prefixed octal literals are widely used and understood. +Style/NumericLiteralPrefix: + EnforcedOctalStyle: zero_only + +# Only require this for numbers >= `10_000_000_000`. +Style/NumericLiterals: + MinDigits: 11 + Strict: true + +Style/OpenStructUse: + Exclude: + - "Taps/**/*" + +Style/OptionalBooleanParameter: + AllowedMethods: + # These are overrides of core library methods + # see https://ruby-doc.org/3.3.4/Object.html#method-i-respond_to-3F + - respond_to? + - respond_to_missing? + +# Rescuing `StandardError` is an understood default. +Style/RescueStandardError: + EnforcedStyle: implicit + +# Returning `nil` is unnecessary. +Style/ReturnNil: + Enabled: true + +# We have no use for using `warn` because we +# are calling Ruby with warnings disabled. +Style/StderrPuts: Enabled: false -# depends_on a: :b looks weird in formulae. -Style/HashSyntax: - EnforcedStyle: hash_rockets +# so many of these in formulae and can't be autocorrected +Style/StringConcatenation: Exclude: - - '**/Guardfile' - - '**/cmd/**/*.rb' - - '**/lib/**/*.rb' - - '**/spec/**/*.rb' + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" # ruby style guide favorite Style/StringLiterals: @@ -118,13 +489,40 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes +# Use consistent method names. +Style/StringMethods: + Enabled: true + +# Treating this the same as Style/MethodCallWithArgsParentheses +Style/SuperWithArgsParentheses: + Enabled: false + +# An array of symbols is more readable than a symbol array +# and also allows for easier grepping. +Style/SymbolArray: + EnforcedStyle: brackets + # make things a bit easier to read Style/TernaryParentheses: EnforcedStyle: require_parentheses_when_complex -# messes with existing plist/caveats style -Style/TrailingBodyOnMethodDefinition: - Enabled: false +Style/TopLevelMethodDefinition: + Enabled: true + Exclude: + - "Taps/**/*" + +# Trailing commas make diffs nicer. +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +# `unless ... ||` and `unless ... &&` are hard to mentally parse +Style/UnlessLogicalOperators: + Enabled: true + EnforcedStyle: forbid_logical_operators # a bit confusing to non-Rubyists but useful for longer arrays Style/WordArray: diff --git a/Library/.rubocop_audit.yml b/Library/.rubocop_audit.yml deleted file mode 100644 index 80c224ca06d95..0000000000000 --- a/Library/.rubocop_audit.yml +++ /dev/null @@ -1,4 +0,0 @@ -inherit_from: ./.rubocop.yml - -NewFormulaAudit: - Enabled: true diff --git a/Library/.rubocop_cask.yml b/Library/.rubocop_cask.yml deleted file mode 100644 index 2c128a436a194..0000000000000 --- a/Library/.rubocop_cask.yml +++ /dev/null @@ -1,65 +0,0 @@ -inherit_from: ./.rubocop_shared.yml - -Cask/HomepageMatchesUrl: - Description: 'Ensure that the homepage and url match, otherwise add a comment. More info at https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#when-url-and-homepage-hostnames-differ-add-a-comment' - Enabled: true - Exclude: - - '**/test/support/fixtures/cask/Casks/**/*.rb' - -Cask/HomepageUrlTrailingSlash: - Description: 'Ensure that the homepage url has a slash after the domain name.' - Enabled: true - -Cask/NoDslVersion: - Description: 'Do not use the deprecated DSL version syntax in your cask header.' - Enabled: true - -Cask/StanzaGrouping: - Description: 'Ensure that cask stanzas are grouped correctly. More info at https://github.com/Homebrew/homebrew-cask/blob/master/CONTRIBUTING.md#stanza-order' - Enabled: true - -Cask/StanzaOrder: - Description: 'Ensure that cask stanzas are sorted correctly. More info at https://github.com/Homebrew/homebrew-cask/blob/master/CONTRIBUTING.md#stanza-order' - Enabled: true - -Layout/HashAlignment: - EnforcedHashRocketStyle: table - EnforcedColonStyle: table - -Layout/FirstArrayElementIndentation: - EnforcedStyle: align_brackets - -Layout/FirstHashElementIndentation: - EnforcedStyle: align_braces - -# Casks often contain long URLs and file paths. -Layout/LineLength: - Enabled: false - -# Casks don't need documentation. -Style/Documentation: - Enabled: false - -# These would only be distracting in casks. -Style/FrozenStringLiteralComment: - EnforcedStyle: never - -# Don't use hash rockets. -Style/HashSyntax: - EnforcedStyle: ruby19_no_mixed_keys - -# This is more readable when the regex contains slashes. -Style/RegexpLiteral: - EnforcedStyle: percent_r - -# Use consistent style for all arrays. -Style/WordArray: - EnforcedStyle: brackets - -# This makes multi-line arrays more readable and alignable. -Layout/FirstArrayElementLineBreak: - Enabled: true - -# This makes multi-line hashes more readable and alignable. -Layout/FirstHashElementLineBreak: - Enabled: true diff --git a/Library/.rubocop_rspec.yml b/Library/.rubocop_rspec.yml deleted file mode 100644 index 4ae309f23966b..0000000000000 --- a/Library/.rubocop_rspec.yml +++ /dev/null @@ -1,36 +0,0 @@ -inherit_from: ./.rubocop.yml - -AllCops: - Include: - - '**/*_spec.rb' - Exclude: - - '**/vendor/**/*' - -NewFormulaAudit: - Enabled: true - -# Intentionally disabled as it doesn't fit with our code style. -RSpec/AnyInstance: - Enabled: false -RSpec/ImplicitBlockExpectation: - Enabled: false -RSpec/SubjectStub: - Enabled: false - -# TODO: try to enable these (also requires fixing Homebrew/bundle) -RSpec/ContextWording: - Enabled: false -RSpec/DescribeClass: - Enabled: false -RSpec/LeakyConstantDeclaration: - Enabled: false -RSpec/MessageSpies: - Enabled: false - -# TODO: try to reduce these (also requires fixing Homebrew/bundle) -RSpec/ExampleLength: - Max: 75 -RSpec/MultipleExpectations: - Max: 26 -RSpec/NestedGroups: - Max: 5 diff --git a/Library/.rubocop_shared.yml b/Library/.rubocop_shared.yml deleted file mode 100644 index fbdc59353f23c..0000000000000 --- a/Library/.rubocop_shared.yml +++ /dev/null @@ -1,102 +0,0 @@ -# TODO: Try getting more rules in sync. - -require: ./Homebrew/rubocops.rb - -AllCops: - TargetRubyVersion: 2.6 - DisplayCopNames: false - -# Use `<<~` for heredocs. -Layout/HeredocIndentation: - EnforcedStyle: squiggly - -# Not useful in casks and formulae. -Metrics/BlockLength: - Enabled: false - -# Keyword arguments don't have the same readability -# problems as normal parameters. -Metrics/ParameterLists: - CountKeywordArgs: false - -# Implicitly allow EOS as we use it everywhere. -Naming/HeredocDelimiterNaming: - ForbiddenDelimiters: - - END, EOD, EOF - -# Allow dashes in filenames. -Naming/FileName: - Regex: !ruby/regexp /^[\w\@\-\+\.]+(\.rb)?$/ - -# Both styles are used depending on context, -# e.g. `sha256` and `something_countable_1`. -Naming/VariableNumber: - Enabled: false - -# Avoid leaking resources. -Style/AutoResourceCleanup: - Enabled: true - -# This makes these a little more obvious. -Style/BarePercentLiterals: - EnforcedStyle: percent_q - -# Use consistent style for better readability. -Style/CollectionMethods: - Enabled: true - -# Prefer tokens with type annotations for consistency -# between formatting numbers and strings. -Style/FormatStringToken: - EnforcedStyle: annotated - -# This shouldn't be enabled until LineLength is lower. -Style/IfUnlessModifier: - Enabled: false - -# Only use this for numbers >= `1_000_000`. -Style/NumericLiterals: - MinDigits: 7 - Strict: true - -# Zero-prefixed octal literals are widely used and understood. -Style/NumericLiteralPrefix: - EnforcedOctalStyle: zero_only - -# Rescuing `StandardError` is an understood default. -Style/RescueStandardError: - EnforcedStyle: implicit - -# Returning `nil` is unnecessary. -Style/ReturnNil: - Enabled: true - -# We have no use for using `warn` because we -# are calling Ruby with warnings disabled. -Style/StderrPuts: - Enabled: false - -# Use consistent method names. -Style/StringMethods: - Enabled: true - -# An array of symbols is more readable than a symbol array -# and also allows for easier grepping. -Style/SymbolArray: - EnforcedStyle: brackets - -# Trailing commas make diffs nicer. -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma -Style/TrailingCommaInArrayLiteral: - EnforcedStyleForMultiline: comma -Style/TrailingCommaInHashLiteral: - EnforcedStyleForMultiline: comma - -# Does not hinder readability, so might as well enable it. -Performance/CaseWhenSplat: - Enabled: true - -# Makes code less readable for minor performance increases. -Performance/Caller: - Enabled: false diff --git a/Library/Homebrew/.bundle/config b/Library/Homebrew/.bundle/config index 24c48a4c9df4d..5d1f6bbaea0ed 100644 --- a/Library/Homebrew/.bundle/config +++ b/Library/Homebrew/.bundle/config @@ -2,6 +2,8 @@ BUNDLE_BIN: "false" BUNDLE_CLEAN: "true" BUNDLE_DISABLE_SHARED_GEMS: "true" +BUNDLE_FORCE_RUBY_PLATFORM: "false" +BUNDLE_FORGET_CLI_OPTIONS: "true" BUNDLE_JOBS: "4" BUNDLE_PATH: "vendor/bundle" BUNDLE_RETRY: "3" diff --git a/Library/Homebrew/.rspec_parallel b/Library/Homebrew/.rspec_parallel new file mode 100644 index 0000000000000..7bc3e335f0ac2 --- /dev/null +++ b/Library/Homebrew/.rspec_parallel @@ -0,0 +1,6 @@ +--format QuietProgressFormatter +--format ParallelTests::RSpec::RuntimeLogger +--out <%= ENV["PARALLEL_RSPEC_LOG_PATH"] %> +--format RspecJunitFormatter +--out test/junit/rspec<%= ENV["TEST_ENV_NUMBER"] %>.xml +<%= "--format RSpec::Github::Formatter" if ENV["GITHUB_ACTIONS"] %> diff --git a/Library/Homebrew/.rubocop.yml b/Library/Homebrew/.rubocop.yml index 6ebbe322b20d6..b1912d3642f48 100644 --- a/Library/Homebrew/.rubocop.yml +++ b/Library/Homebrew/.rubocop.yml @@ -1,147 +1,121 @@ -inherit_from: ../.rubocop_rspec.yml +inherit_from: + - ../.rubocop.yml -AllCops: - Include: - - '**/*.rb' - - 'Library/Homebrew/.simplecov' - Exclude: - - 'bin/*' - - '**/Casks/**/*' - - '**/vendor/**/*' - -# messes up system formatting for formulae but good for Homebrew/brew -Layout/ArgumentAlignment: +Homebrew/MoveToExtendOS: Enabled: true -# make rspec formatting more flexible -Layout/MultilineMethodCallIndentation: - Exclude: - - '**/*_spec.rb' - -# so many of these in formulae but none in here -Lint/AmbiguousRegexpLiteral: - Enabled: true - -# `formula do` uses nested method definitions -Lint/NestedMethodDefinition: - Exclude: - - 'test/**/*' - -# so many of these in formulae but none in here -Lint/ParenthesesAsGroupedExpression: - Enabled: true +# Want to preserve our own API for these methods for now. +Naming/PredicatePrefix: + inherit_mode: + merge: + - AllowedMethods + AllowedMethods: + - is_32_bit? + - is_64_bit? -# unused keyword arguments improve APIs -Lint/UnusedMethodArgument: - AllowUnusedKeywordArguments: true +Naming/PredicateMethod: + inherit_mode: + merge: + - WaywardPredicates + WaywardPredicates: + - size? -# TODO: try to bring down all metrics maximums -Metrics/AbcSize: - Enabled: true - Max: 275 -Metrics/BlockLength: - Enabled: true - Max: 1100 -Metrics/BlockNesting: - Enabled: true - Max: 5 -Metrics/ClassLength: - Enabled: true - Max: 1400 -Metrics/CyclomaticComplexity: - Enabled: true - Max: 75 -Metrics/MethodLength: - Enabled: true - Max: 300 -Metrics/ModuleLength: - Enabled: true - Max: 550 -Metrics/PerceivedComplexity: - Enabled: true - Max: 100 - -# GitHub diff UI wraps beyond 118 characters -Layout/LineLength: - Max: 118 - # ignore manpage comments - IgnoredPatterns: ['#: '] - -# we won't change backward compatible predicate names -Naming/PredicateName: - Exclude: - - 'compat/**/*' - # can't rename these - AllowedMethods: is_32_bit?, is_64_bit? - -# whitelist those that are standard -# TODO: try to remove some of these Naming/MethodParameterName: AllowedNames: - - '_' - - 'a' - - 'b' - - 'cc' - - 'c1' - - 'c2' - - 'd' - - 'e' - - 'f' - - 'ff' - - 'fn' - - 'id' - - 'io' - - 'o' - - 'p' - - 'pr' - - 'r' - - 'rb' - - 's' - - 'to' - - 'v' + - go + - uv -# Avoid false positives on modifiers used on symbols of methods -# See https://github.com/rubocop-hq/rubocop/issues/5953 -Style/AccessModifierDeclarations: - Enabled: false - -# make rspec formatting more flexible -Style/BlockDelimiters: - Exclude: - - '**/*_spec.rb' - - '**/shared_examples/**/*.rb' - -# document our public APIs +# Only enforce documentation for public APIs. +# Include list checked by Homebrew/PublicApiDocumentation. Style/Documentation: - Enabled: true + AllowedConstants: + - Homebrew Include: - - 'Library/Homebrew/formula.rb' + - abstract_command.rb + - abstract_subcommand.rb + - autobump_constants.rb + - cask/cask.rb + - cask/dsl.rb + - cask/dsl/version.rb + - cask/url.rb + - development_tools.rb + - download_strategy/abstract_download_strategy.rb + - download_strategy/abstract_file_download_strategy.rb + - download_strategy/bazaar_download_strategy.rb + - download_strategy/curl_apache_mirror_download_strategy.rb + - download_strategy/curl_download_strategy.rb + - download_strategy/curl_github_packages_download_strategy.rb + - download_strategy/curl_post_download_strategy.rb + - download_strategy/cvs_download_strategy.rb + - download_strategy/fossil_download_strategy.rb + - download_strategy/git_download_strategy.rb + - download_strategy/github_git_download_strategy.rb + - download_strategy/homebrew_curl_download_strategy.rb + - download_strategy/mercurial_download_strategy.rb + - download_strategy/no_unzip_curl_download_strategy.rb + - download_strategy/pypi_download_strategy.rb + - download_strategy/subversion_download_strategy.rb + - download_strategy/vcs_download_strategy.rb + - extend/ENV/super.rb + - extend/kernel.rb + - extend/pathname.rb + - formula.rb + - formula_assertions.rb + - formula_free_port.rb + - language/java.rb + - language/node.rb + - language/perl.rb + - language/php.rb + - language/python.rb + - livecheck/strategy/apache.rb + - livecheck/strategy/bitbucket.rb + - livecheck/strategy/cpan.rb + - livecheck/strategy/crate.rb + - livecheck/strategy/extract_plist.rb + - livecheck/strategy/git.rb + - livecheck/strategy/github_latest.rb + - livecheck/strategy/github_releases.rb + - livecheck/strategy/gnome.rb + - livecheck/strategy/gnu.rb + - livecheck/strategy/hackage.rb + - livecheck/strategy/json.rb + - livecheck/strategy/launchpad.rb + - livecheck/strategy/npm.rb + - livecheck/strategy/page_match.rb + - livecheck/strategy/pypi.rb + - livecheck/strategy/ruby_gems.rb + - livecheck/strategy/sourceforge.rb + - livecheck/strategy/sparkle.rb + - livecheck/strategy/xml.rb + - livecheck/strategy/xorg.rb + - livecheck/strategy/yaml.rb + - os.rb + - os/mac.rb + - resource.rb + - startup/config.rb + - utils/gzip.rb + - utils/inreplace.rb + - utils/output.rb + - utils/path.rb + - utils/shebang.rb + - utils/string_inreplace_extension.rb + - version.rb + - tap.rb + Style/DocumentationMethod: - Enabled: true Include: - - 'Library/Homebrew/formula.rb' - -# don't want this for formulae but re-enabled for Library/Homebrew -Style/FrozenStringLiteralComment: - Enabled: true - EnforcedStyle: always + - "formula.rb" -# so many of these in formulae but none in here -Style/GuardClause: +# Ensure @api public methods have proper YARD documentation. +# No Include needed: the cop self-selects by finding @api public comments. +Homebrew/PublicApiDocumentation: Enabled: true -# hash-rockets preferred for formulae, a: 1 preferred here -Style/HashSyntax: - EnforcedStyle: ruby19_no_mixed_keys - -# would rather freeze too much than too little -Style/MutableConstant: - EnforcedStyle: strict - -# LineLength is low enough here to re-enable it. -Style/IfUnlessModifier: +# Ensure cookbook-referenced methods are annotated @api public. +# No Include needed: the cop self-selects from cookbook rubydoc links. +Homebrew/PublicApiCookbook: Enabled: true -# so many of these in formulae but none in here -Style/TrailingBodyOnMethodDefinition: +# Ensure official tap formulae don't use @api internal/@api private methods. +FormulaAudit/NonPublicApiUsage: Enabled: true diff --git a/Library/Homebrew/.ruby-version b/Library/Homebrew/.ruby-version new file mode 100644 index 0000000000000..7636e75650d43 --- /dev/null +++ b/Library/Homebrew/.ruby-version @@ -0,0 +1 @@ +4.0.5 diff --git a/Library/Homebrew/.simplecov b/Library/Homebrew/.simplecov index 38c7d530e13ac..6b48c7ad2699b 100755 --- a/Library/Homebrew/.simplecov +++ b/Library/Homebrew/.simplecov @@ -1,69 +1,99 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require "English" +SimpleCov.enable_for_subprocesses true + SimpleCov.start do coverage_dir File.expand_path("../test/coverage", File.realpath(__FILE__)) root File.expand_path("..", File.realpath(__FILE__)) + command_name "brew" + + # enables branch coverage as well as, the default, line coverage + enable_coverage :branch + + # enables coverage for `eval`ed code + enable_coverage_for_eval + + # ensure that we always default to line coverage + primary_coverage :line # We manage the result cache ourselves and the default of 10 minutes can be - # too low (particularly on Travis CI), causing results from some integration - # tests to be dropped. This causes random fluctuations in test coverage. + # too low causing results from some integration tests to be dropped. This + # causes random fluctuations in test coverage. merge_timeout 86400 - if ENV["HOMEBREW_INTEGRATION_TEST"] - command_name "#{ENV["HOMEBREW_INTEGRATION_TEST"]} (#{$PROCESS_ID})" + at_fork do + # be quiet, the parent process will be in charge of output and checking coverage totals + SimpleCov.print_error_status = false + end + excludes = ["test", "vendor"] + subdirs = Dir.chdir(SimpleCov.root) { Pathname.glob("*") } + .reject { |p| p.extname == ".rb" || excludes.include?(p.to_s) } + .map { |p| "#{p}/**/*.rb" }.join(",") + files = "#{SimpleCov.root}/{#{subdirs},*.rb}" + + if (integration_test_number = ENV.fetch("HOMEBREW_INTEGRATION_TEST", nil)) + # This needs a unique name so it won't be overwritten + command_name "brew_i:#{integration_test_number}" - at_exit do - exit_code = $ERROR_INFO.nil? ? 0 : $ERROR_INFO.status - $stdout.reopen("/dev/null") + # be quiet, the parent process will be in charge of output and checking coverage totals + SimpleCov.print_error_status = false + SimpleCov.at_exit do # Just save result, but don't write formatted output. - coverage_result = Coverage.result - SimpleCov.add_not_loaded_files(coverage_result) + coverage_result = Coverage.result.dup + Dir[files].each do |file| + absolute_path = File.expand_path(file) + coverage_result[absolute_path] ||= SimpleCov::SimulateCoverage.call(absolute_path) + end simplecov_result = SimpleCov::Result.new(coverage_result) SimpleCov::ResultMerger.store_result(simplecov_result) - exit! exit_code + # If an integration test raises a `SystemExit` exception on exit, + # exit immediately using the same status code to avoid reporting + # an error when expecting a non-successful exit status. + raise if $ERROR_INFO.is_a?(SystemExit) end else - command_name "#{command_name} (#{$PROCESS_ID})" - - subdirs = Dir.chdir(SimpleCov.root) { Dir.glob("*") } - .reject { |d| d.end_with?(".rb") || ["test", "vendor"].include?(d) } - .map { |d| "#{d}/**/*.rb" }.join(",") + command_name "brew:#{ENV.fetch("TEST_ENV_NUMBER", $PROCESS_ID)}" # Not using this during integration tests makes the tests 4x times faster # without changing the coverage. - track_files "#{SimpleCov.root}/{#{subdirs},*.rb}" + track_files files end - add_filter %r{^/build.rb$} - add_filter %r{^/config.rb$} - add_filter %r{^/constants.rb$} - add_filter %r{^/postinstall.rb$} - add_filter %r{^/test.rb$} - add_filter %r{^/compat/} - add_filter %r{^/dev-cmd/tests.rb$} + add_filter %r{^/build\.rb$} + add_filter %r{^/config\.rb$} + add_filter %r{^/constants\.rb$} + add_filter %r{^/postinstall\.rb$} + add_filter %r{^/test\.rb$} + add_filter %r{^/dev-cmd/tests\.rb$} + add_filter %r{^/sorbet/} add_filter %r{^/test/} add_filter %r{^/vendor/} + add_filter %r{^/yard/} require "rbconfig" host_os = RbConfig::CONFIG["host_os"] - add_filter %r{/os/mac} if host_os !~ /darwin/ - add_filter %r{/os/linux} if host_os !~ /linux/ + add_filter %r{/os/mac} unless host_os.include?("darwin") + add_filter %r{/os/linux} unless host_os.include?("linux") # Add groups and the proper project name to the output. project_name "Homebrew" - add_group "Cask", %r{^/cask/} + add_group "Cask", %r{^/cask(/|\.rb$)} add_group "Commands", [%r{/cmd/}, %r{^/dev-cmd/}] add_group "Extensions", %r{^/extend/} + add_group "Livecheck", %r{^/livecheck(/|\.rb$)} add_group "OS", [%r{^/extend/os/}, %r{^/os/}] add_group "Requirements", %r{^/requirements/} + add_group "RuboCops", %r{^/rubocops/} + add_group "Unpack Strategies", %r{^/unpack_strategy(/|\.rb$)} add_group "Scripts", [ - %r{^/brew.rb$}, - %r{^/build.rb$}, - %r{^/postinstall.rb$}, - %r{^/test.rb$}, + %r{^/brew\.rb$}, + %r{^/build\.rb$}, + %r{^/postinstall\.rb$}, + %r{^/test\.rb$}, ] end diff --git a/Library/Homebrew/.yardopts b/Library/Homebrew/.yardopts new file mode 100644 index 0000000000000..4bb4553c05ef6 --- /dev/null +++ b/Library/Homebrew/.yardopts @@ -0,0 +1,16 @@ +--title "Homebrew Ruby API" +--main README.md +--markup markdown +--no-private +--plugin sorbet +--load ./yard/docstring_parser.rb +--template-path yard/templates +--exclude sorbet/rbi/gems/ +--exclude test/ +--exclude vendor/ +--exclude yard/ +extend/os/**/*.rb +**/*.rb +**/*.rbi +- +*.md diff --git a/Library/Homebrew/Gemfile b/Library/Homebrew/Gemfile index 065dd6c83effd..f2ae8e3d37311 100644 --- a/Library/Homebrew/Gemfile +++ b/Library/Homebrew/Gemfile @@ -2,22 +2,96 @@ source "https://rubygems.org" -# installed gems -gem "coveralls", "~> 0.8", require: false -gem "parallel_tests" -gem "ronn", require: false -gem "rspec" -gem "rspec-its", require: false -gem "rspec-retry", require: false -gem "rspec-wait", require: false -gem "rubocop" -gem "simplecov", require: false +# The default case (no envs), should always be a restrictive bound on the lowest supported minor version. +# This is the branch that Dependabot will use. +if ENV.fetch("HOMEBREW_USE_RUBY_FROM_PATH", "").empty? + ruby "~> 4.0.0" +else + ruby ">= 4.0.0" +end -# vendored gems -gem "activesupport" +# disallowed gems (should not be used) +# * nokogiri - use rexml instead for XML parsing + +# installed gems (should all be require: false) +# ALL gems that are not vendored should be in a group +group :doc, optional: true do + gem "redcarpet", require: false + gem "yard", require: false + gem "yard-sorbet", require: false +end +group :ast, optional: true do + gem "rubocop-ast", require: false +end +group :formula_test, optional: true do + gem "minitest", require: false +end +group :livecheck, optional: true do + gem "ruby-progressbar", require: false +end +group :man, optional: true do + gem "kramdown", require: false +end +group :pr_upload, :bottle, optional: true do + gem "json_schemer", require: false +end +group :prof, optional: true do + gem "ruby-prof", require: false + gem "stackprof", require: false + gem "vernier", require: false +end +group :pry, optional: true do + gem "pry", require: false +end +group :style, optional: true do + gem "rubocop", require: false + gem "rubocop-md", require: false + gem "rubocop-performance", require: false + gem "rubocop-rspec", require: false + gem "rubocop-sorbet", require: false +end +group :style, :tests, optional: true do + gem "test-prof", require: false +end +group :tests, optional: true do + gem "parallel_tests", require: false + gem "rspec", require: false + gem "rspec-core", require: false + gem "rspec-expectations", require: false + gem "rspec-github", require: false + gem "rspec_junit_formatter", require: false + gem "rspec-retry", require: false + gem "rspec-sorbet", require: false + gem "simplecov", require: false + gem "simplecov-cobertura", require: false +end +group :typecheck, optional: true do + gem "method_source", require: false + gem "sorbet-static-and-runtime", require: false + gem "spoom", require: false + gem "tapioca", require: false +end +group :vscode, optional: true do + gem "ruby-lsp", require: false +end +group :formula_analytics, optional: true do + gem "pycall", require: false +end +group :contributions, optional: true do + gem "csv", require: false +end + +# shared gems (used by multiple groups) +group :audit, :bump_unversioned_casks, :livecheck, optional: true do + gem "rexml", require: false +end + +# vendored gems (no group) +gem "addressable" +gem "base64" gem "concurrent-ruby" -gem "mechanize" +gem "patchelf" gem "plist" -gem "rubocop-performance" -gem "rubocop-rspec" gem "ruby-macho" +gem "sorbet-runtime" +gem "warning" diff --git a/Library/Homebrew/Gemfile.lock b/Library/Homebrew/Gemfile.lock index 6f634ea839b64..7fd049c7a7646 100644 --- a/Library/Homebrew/Gemfile.lock +++ b/Library/Homebrew/Gemfile.lock @@ -1,139 +1,242 @@ GEM remote: https://rubygems.org/ specs: - activesupport (6.0.2.1) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2) - ast (2.4.0) - concurrent-ruby (1.1.5) - connection_pool (2.2.2) - coveralls (0.8.23) - json (>= 1.8, < 3) - simplecov (~> 0.16.1) - term-ansicolor (~> 1.3) - thor (>= 0.19.4, < 2.0) - tins (~> 1.6) - diff-lcs (1.3) - docile (1.3.2) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - hpricot (0.8.6) - http-cookie (1.0.3) - domain_name (~> 0.5) - i18n (1.8.1) - concurrent-ruby (~> 1.0) - jaro_winkler (1.5.4) - json (2.3.0) - mechanize (2.7.6) - domain_name (~> 0.5, >= 0.5.1) - http-cookie (~> 1.0) - mime-types (>= 1.17.2) - net-http-digest_auth (~> 1.1, >= 1.1.1) - net-http-persistent (>= 2.5.2) - nokogiri (~> 1.6) - ntlm-http (~> 0.1, >= 0.1.1) - webrobots (>= 0.0.9, < 0.2) - mime-types (3.3.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2019.1009) - mini_portile2 (2.4.0) - minitest (5.13.0) - mustache (1.1.1) - net-http-digest_auth (1.4.1) - net-http-persistent (3.1.0) - connection_pool (~> 2.2) - nokogiri (1.10.7) - mini_portile2 (~> 2.4.0) - ntlm-http (0.1.1) - parallel (1.19.1) - parallel_tests (2.30.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + bindata (2.5.1) + coderay (1.1.3) + concurrent-ruby (1.3.7) + csv (3.3.5) + diff-lcs (1.6.2) + docile (1.4.1) + drb (2.2.3) + elftools (1.3.1) + bindata (~> 2) + erubi (1.13.1) + hana (1.3.7) + io-console (0.8.2) + json (2.20.0) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + kramdown (2.5.2) + rexml (>= 3.4.4) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + method_source (1.1.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + netrc (0.11.0) + ostruct (0.6.3) + parallel (2.1.0) + parallel_tests (5.7.0) parallel - parser (2.7.0.2) - ast (~> 2.4.0) - plist (3.5.0) - rainbow (3.0.0) - rdiscount (2.2.0.1) - ronn (0.7.3) - hpricot (>= 0.8.2) - mustache (>= 0.7.0) - rdiscount (>= 1.5.8) - rspec (3.9.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-core (3.9.1) - rspec-support (~> 3.9.1) - rspec-expectations (3.9.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + patchelf (1.5.2) + elftools (>= 1.3) + logger (~> 1) + plist (3.7.2) + prism (1.9.0) + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) + public_suffix (7.0.5) + pycall (1.5.2) + racc (1.8.1) + rainbow (3.1.1) + rbi (0.3.14) + prism (~> 1.0) + rbs (>= 4.0.1) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + redcarpet (3.6.1) + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + require-hooks (0.4.0) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-its (1.3.0) - rspec-core (>= 3.0.0) - rspec-expectations (>= 3.0.0) - rspec-mocks (3.9.1) + rspec-support (~> 3.13.0) + rspec-github (3.0.0) + rspec-core (~> 3.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) + rspec-support (~> 3.13.0) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.9.2) - rspec-wait (0.0.9) - rspec (>= 3, < 4) - rubocop (0.79.0) - jaro_winkler (~> 1.5.1) - parallel (~> 1.10) - parser (>= 2.7.0.1) + rspec-sorbet (1.9.2) + sorbet-runtime + rspec-support (3.13.7) + rspec_junit_formatter (0.6.0) + rspec-core (>= 2, < 4, != 2.12.0) + rubocop (1.88.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.7) - rubocop-performance (1.5.2) - rubocop (>= 0.71.0) - rubocop-rspec (1.37.1) - rubocop (>= 0.68.1) - ruby-macho (2.2.0) - ruby-progressbar (1.10.1) - simplecov (0.16.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-md (2.0.4) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-sorbet (0.12.0) + lint_roller + rubocop (>= 1.75.2) + ruby-lsp (0.26.9) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + ruby-macho (5.0.0) + ruby-prof (2.0.5) + base64 + ostruct + ruby-progressbar (1.13.0) + rubydex (0.2.7-aarch64-linux) + rubydex (0.2.7-arm64-darwin) + rubydex (0.2.7-x86_64-darwin) + rubydex (0.2.7-x86_64-linux) + simplecov (0.22.0) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) - sync (0.5.0) - term-ansicolor (1.7.1) - tins (~> 1.0) - thor (1.0.1) - thread_safe (0.3.6) - tins (1.24.0) - sync - tzinfo (1.2.6) - thread_safe (~> 0.1) - unf (0.1.4) - unf_ext - unf_ext (0.0.7.6) - unicode-display_width (1.6.0) - webrobots (0.1.2) - zeitwerk (2.2.2) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (3.2.0) + rexml + simplecov (~> 0.19) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + simpleidn (0.2.3) + sorbet (0.6.13316) + sorbet-static (= 0.6.13316) + sorbet-runtime (0.6.13316) + sorbet-static (0.6.13316-aarch64-linux) + sorbet-static (0.6.13316-universal-darwin) + sorbet-static (0.6.13316-x86_64-linux) + sorbet-static-and-runtime (0.6.13316) + sorbet (= 0.6.13316) + sorbet-runtime (= 0.6.13316) + spoom (1.8.2) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.3.14) + rbs (>= 4.0.0.dev.5) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + stackprof (0.2.28) + tapioca (0.19.2) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (>= 0.3.7) + require-hooks (>= 0.2.2) + rubydex (>= 0.1.0.beta10) + sorbet-static-and-runtime (>= 0.6.12698) + spoom (>= 1.7.16) + thor (>= 1.2.0) + tsort + test-prof (1.6.1) + thor (1.5.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + vernier (1.10.1) + warning (1.6.0) + yard (0.9.44) + yard-sorbet (0.9.0) + sorbet-runtime + yard PLATFORMS - ruby + aarch64-linux + arm64-darwin + x86_64-darwin + x86_64-linux DEPENDENCIES - activesupport + addressable + base64 concurrent-ruby - coveralls (~> 0.8) - mechanize + csv + json_schemer + kramdown + method_source + minitest parallel_tests + patchelf plist - ronn + pry + pycall + redcarpet + rexml rspec - rspec-its + rspec-core + rspec-expectations + rspec-github rspec-retry - rspec-wait + rspec-sorbet + rspec_junit_formatter rubocop + rubocop-ast + rubocop-md rubocop-performance rubocop-rspec + rubocop-sorbet + ruby-lsp ruby-macho + ruby-prof + ruby-progressbar simplecov + simplecov-cobertura + sorbet-runtime + sorbet-static-and-runtime + spoom + stackprof + tapioca + test-prof + vernier + warning + yard + yard-sorbet + +RUBY VERSION + ruby 4.0.5 BUNDLED WITH - 1.17.2 + 4.0.10 diff --git a/Library/Homebrew/PATH.rb b/Library/Homebrew/PATH.rb index 4f46be178ab84..d59e7ee933567 100644 --- a/Library/Homebrew/PATH.rb +++ b/Library/Homebrew/PATH.rb @@ -1,76 +1,93 @@ +# typed: strict # frozen_string_literal: true +require "forwardable" + +# Representation of a `*PATH` environment variable. class PATH include Enumerable extend Forwardable + extend T::Generic - def_delegator :@paths, :each + delegate each: :@paths + Elem = type_member(:out) { { fixed: String } } + Element = T.type_alias { T.nilable(T.any(Pathname, String, PATH)) } + private_constant :Element + Elements = T.type_alias { T.any(Element, T::Array[Element]) } + sig { params(paths: Elements).void } def initialize(*paths) - @paths = parse(*paths) + @paths = T.let(parse(paths), T::Array[String]) end + sig { params(paths: Elements).returns(T.self_type) } def prepend(*paths) - @paths = parse(*paths, *@paths) + @paths = parse(paths + @paths) self end + sig { params(paths: Elements).returns(T.self_type) } def append(*paths) - @paths = parse(*@paths, *paths) + @paths = parse(@paths + paths) self end + sig { params(index: Integer, paths: Elements).returns(T.self_type) } def insert(index, *paths) - @paths = parse(*@paths.insert(index, *paths)) + @paths = parse(@paths.insert(index, *paths)) self end + sig { params(block: T.proc.params(arg0: String).returns(BasicObject)).returns(T.self_type) } def select(&block) self.class.new(@paths.select(&block)) end + sig { params(block: T.proc.params(arg0: String).returns(BasicObject)).returns(T.self_type) } def reject(&block) self.class.new(@paths.reject(&block)) end + sig { returns(T::Array[String]) } def to_ary @paths.dup.to_ary end alias to_a to_ary + sig { returns(String) } def to_str @paths.join(File::PATH_SEPARATOR) end - alias to_s to_str - - def ==(other) - if other.respond_to?(:to_ary) - return true if to_ary == other.to_ary - end - if other.respond_to?(:to_str) - return true if to_str == other.to_str - end + sig { returns(String) } + def to_s = to_str - false + sig { params(other: T.untyped).returns(T::Boolean) } + def ==(other) + (other.respond_to?(:to_ary) && to_ary == other.to_ary) || + (other.respond_to?(:to_str) && to_str == other.to_str) || + false end + sig { returns(T::Boolean) } def empty? @paths.empty? end + sig { returns(T.nilable(T.self_type)) } def existing - existing_path = select(&File.method(:directory?)) + existing_path = select { File.directory?(it) } # return nil instead of empty PATH, to unset environment variables existing_path unless existing_path.empty? end private - def parse(*paths) + sig { params(paths: T::Array[Elements]).returns(T::Array[String]) } + def parse(paths) paths.flatten .compact - .flat_map { |p| Pathname.new(p).to_path.split(File::PATH_SEPARATOR) } + .flat_map { |p| Pathname(p).to_path.split(File::PATH_SEPARATOR) } .uniq end end diff --git a/Library/Homebrew/README.md b/Library/Homebrew/README.md index 3692c77a61fb4..7f6b9bbf55354 100644 --- a/Library/Homebrew/README.md +++ b/Library/Homebrew/README.md @@ -1,9 +1,9 @@ -# Homebrew's Formula API +# Homebrew Ruby API -This is the public API for Homebrew. +This is the API for [Homebrew](https://github.com/Homebrew). The main class you should look at is the {Formula} class (and classes linked from there). That's the class that's used to create Homebrew formulae (i.e. package descriptions). Assume anything else you stumble upon is private. -You may also find the [Formula Cookbook](https://docs.brew.sh/Formula-Cookbook) and [Ruby Style Guide](https://github.com/rubocop-hq/ruby-style-guide#the-ruby-style-guide) helpful in creating formulae. +You may also find the [Formula Cookbook](https://docs.brew.sh/Formula-Cookbook) and [Ruby Style Guide](https://rubystyle.guide) helpful in creating formulae. Good luck! diff --git a/Library/Homebrew/abstract_command.rb b/Library/Homebrew/abstract_command.rb new file mode 100644 index 0000000000000..9a00e276e72df --- /dev/null +++ b/Library/Homebrew/abstract_command.rb @@ -0,0 +1,87 @@ +# typed: strong +# frozen_string_literal: true + +require "cli/parser" +require "shell_command" +require "utils/output" + +module Homebrew + # Subclass this to implement a `brew` command. This is preferred to declaring a named function in the `Homebrew` + # module, because: + # + # - Each Command lives in an isolated namespace. + # - Each Command implements a defined interface. + # - `args` is available as an instance method and thus does not need to be passed as an argument to helper methods. + # - Subclasses no longer need to reference `CLI::Parser` or parse args explicitly. + # + # To subclass, implement a `run` method and provide a `cmd_args` block to document the command and its allowed args. + # To generate method signatures for command args, run `brew typecheck --update`. + # + # @api public + class AbstractCommand + extend T::Helpers + include Utils::Output::Mixin + + abstract! + + class << self + sig { returns(T.nilable(T.class_of(CLI::Args))) } + attr_reader :args_class + + sig { returns(String) } + def command_name + require "utils" + + Utils.underscore(T.must(name).split("::").fetch(-1)) + .tr("_", "-") + .delete_suffix("-cmd") + end + + # @return the AbstractCommand subclass associated with the brew CLI command name. + sig { params(name: String).returns(T.nilable(T.class_of(AbstractCommand))) } + def command(name) = subclasses.find { it.command_name == name } + + sig { returns(T::Boolean) } + def dev_cmd? = T.must(name).start_with?("Homebrew::DevCmd") + + sig { returns(T::Boolean) } + def ruby_cmd? = !include?(Homebrew::ShellCommand) + + sig { returns(CLI::Parser) } + def parser = CLI::Parser.new(self, &@parser_block) + + private + + # The description and arguments of the command should be defined within this block. + # + # @api public + sig { params(block: T.proc.bind(CLI::Parser).void).void } + def cmd_args(&block) + @parser_block = T.let(block, T.nilable(T.proc.void)) + @args_class = T.let(const_set(:Args, Class.new(CLI::Args)), T.nilable(T.class_of(CLI::Args))) + end + end + + sig { returns(CLI::Args) } + attr_reader :args + + sig { params(argv: T::Array[String]).void } + def initialize(argv = ARGV.freeze) + @args = T.let(self.class.parser.parse(argv), CLI::Args) + end + + # This method will be invoked when the command is run. + # + # @api public + sig { abstract.void } + def run; end + end + + module Cmd + # The command class for `brew` itself, allowing its args to be parsed. + class Brew < AbstractCommand + sig { override.void } + def run; end + end + end +end diff --git a/Library/Homebrew/abstract_subcommand.rb b/Library/Homebrew/abstract_subcommand.rb new file mode 100644 index 0000000000000..948b69dc0c1c9 --- /dev/null +++ b/Library/Homebrew/abstract_subcommand.rb @@ -0,0 +1,112 @@ +# typed: strict +# frozen_string_literal: true + +require "cli/parser" +require "abstract_command" +require "utils/output" + +module Homebrew + # Subclass this to implement a subcommand for a `brew` command. + # + # @api public + class AbstractSubcommand + extend T::Helpers + include Utils::Output::Mixin + + abstract! + + class << self + sig { returns(String) } + def subcommand_name + require "utils" + + class_name = name + raise TypeError, "anonymous subcommands do not have names" if class_name.nil? + + Utils.underscore(class_name.split("::").fetch(-1)) + .tr("_", "-") + .delete_suffix("-subcommand") + end + + sig { params(command: T.class_of(Homebrew::AbstractCommand)).returns(T::Array[T.class_of(AbstractSubcommand)]) } + def subcommands_for(command) + namespace = "#{command.name}::" + subclasses.select do |subcommand| + subcommand.name&.start_with?(namespace) + end + end + + sig { params(parser: CLI::Parser, command: T.class_of(Homebrew::AbstractCommand)).void } + def define_all(parser, command:) + subcommands_for(command).each do |subcommand| + subcommand.define(parser) + end + end + + sig { params(parser: CLI::Parser).void } + def define(parser) + parser_block = @parser_block + raise TypeError, "subcommand arguments have not been defined" if parser_block.nil? + + parser.subcommand( + subcommand_name, + aliases: @aliases || [], + alias_options: @alias_options || {}, + default: @default || false, + ) do + instance_eval(&parser_block) + end + end + + private + + # The description and arguments of the subcommand should be defined within this block. + # + # @api public + sig { + params( + aliases: T::Array[String], + alias_options: T::Hash[String, String], + default: T::Boolean, + block: T.proc.bind(CLI::Parser).void, + ).void + } + def subcommand_args(aliases: [], alias_options: {}, default: false, &block) + @aliases = T.let(aliases, T.nilable(T::Array[String])) + @alias_options = T.let(alias_options, T.nilable(T::Hash[String, String])) + @default = T.let(default, T.nilable(T::Boolean)) + @parser_block = T.let(block, T.nilable(T.proc.void)) + end + end + + sig { returns(T.untyped) } + attr_reader :args + + sig { params(args: T.untyped, context: T.untyped, targets: T.untyped, quiet: T::Boolean, cleanup: T::Boolean).void } + def initialize(args, context: nil, targets: nil, quiet: false, cleanup: true) + @args = args + @context = context + @targets = targets + @quiet = quiet + @cleanup = cleanup + end + + sig { returns(T.untyped) } + attr_reader :context + + sig { returns(T.untyped) } + attr_reader :targets + + sig { returns(T::Boolean) } + attr_reader :quiet + + sig { returns(T::Boolean) } + attr_reader :cleanup + + # This method will be invoked when the subcommand is run. + # + # @api public + sig { abstract.void } + def run; end + end +end diff --git a/Library/Homebrew/aliases/alias.rb b/Library/Homebrew/aliases/alias.rb new file mode 100644 index 0000000000000..713976d01edec --- /dev/null +++ b/Library/Homebrew/aliases/alias.rb @@ -0,0 +1,120 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "utils/output" + +module Homebrew + module Aliases + class Alias + include ::Utils::Output::Mixin + + sig { returns(String) } + attr_accessor :name + + sig { returns(T.nilable(String)) } + attr_accessor :command + + sig { params(name: String, command: T.nilable(String)).void } + def initialize(name, command = nil) + @name = T.let(name.strip, String) + @command = T.let(nil, T.nilable(String)) + @script = T.let(nil, T.nilable(Pathname)) + @symlink = T.let(nil, T.nilable(Pathname)) + + @command = if command&.start_with?("!", "%") + command[1..] + elsif command + "brew #{command}" + end + end + + sig { returns(T::Boolean) } + def reserved? + Aliases.reserved.include? name + end + + sig { returns(T::Boolean) } + def cmd_exists? + path = which("brew-#{name}.rb") || which("brew-#{name}") + !path.nil? && path.realpath.parent != HOMEBREW_ALIASES + end + + sig { returns(Pathname) } + def script + @script ||= Pathname.new("#{HOMEBREW_ALIASES}/#{name.gsub(/\W/, "_")}") + end + + sig { returns(Pathname) } + def symlink + @symlink ||= Pathname.new("#{HOMEBREW_PREFIX}/bin/brew-#{name}") + end + + sig { returns(T::Boolean) } + def valid_symlink? + symlink.realpath.parent == HOMEBREW_ALIASES.realpath + rescue NameError + false + end + + sig { void } + def link + FileUtils.rm symlink if File.symlink? symlink + FileUtils.ln_s script, symlink + end + + sig { params(opts: T::Hash[Symbol, T::Boolean]).void } + def write(opts = {}) + odie "'#{name}' is a reserved command. Sorry." if reserved? + odie "'brew #{name}' already exists. Sorry." if cmd_exists? + + return if !opts[:override] && script.exist? + + content = if command + <<~EOS + #: * `#{name}` [args...] + #: `brew #{name}` is an alias for `#{command}` + #{command} $* + EOS + else + <<~EOS + #: * `#{name}` [args...] + #: `brew #{name}` is an alias for *command* + + # This is a Homebrew alias script. It'll be called when the user + # types `brew #{name}`. Any remaining arguments are passed to + # this script. You can retrieve those with $*, or only the first + # one with $1. Please keep your script on one line. + + # TODO: Replace the line below with your script + echo "Hello I'm 'brew "#{name}"' and my args are:" $* + EOS + end + + script.open("w") do |f| + f.write <<~EOS + #! #{`which bash`.chomp} + # alias: brew #{name} + #{content} + EOS + end + script.chmod 0744 + link + end + + sig { void } + def remove + odie "'brew #{name}' is not aliased to anything." if !symlink.exist? || !valid_symlink? + + script.unlink + symlink.unlink + end + + sig { void } + def edit + write(override: false) + exec_editor script.to_s + end + end + end +end diff --git a/Library/Homebrew/aliases/aliases.rb b/Library/Homebrew/aliases/aliases.rb new file mode 100644 index 0000000000000..2f4de6476744c --- /dev/null +++ b/Library/Homebrew/aliases/aliases.rb @@ -0,0 +1,87 @@ +# typed: strict +# frozen_string_literal: true + +require "aliases/alias" +require "utils/output" + +module Homebrew + module Aliases + extend Utils::Output::Mixin + + # Lazily computed to avoid a load-time cycle: `Commands.internal_commands` + # requires every `cmd/*.rb`, including `cmd/alias.rb`, which itself + # requires this file. + sig { returns(T::Array[String]) } + def self.reserved + @reserved ||= T.let( + (Commands.internal_commands + + Commands.internal_developer_commands + + Commands.internal_commands_aliases + + %w[alias unalias]).freeze, + T.nilable(T::Array[String]), + ) + end + + sig { void } + def self.init + FileUtils.mkdir_p HOMEBREW_ALIASES + end + + sig { params(name: String, command: String).void } + def self.add(name, command) + new_alias = Alias.new(name, command) + odie "alias 'brew #{name}' already exists!" if new_alias.script.exist? + new_alias.write + end + + sig { params(name: String).void } + def self.remove(name) + Alias.new(name).remove + end + + sig { params(only: T::Array[String], block: T.proc.params(name: String, command: String).void).void } + def self.each(only, &block) + Dir["#{HOMEBREW_ALIASES}/*"].each do |path| + next if path.end_with? "~" # skip Emacs-like backup files + next if File.directory?(path) + + _shebang, meta, *lines = File.readlines(path) + name = T.must(meta)[/alias: brew (\S+)/, 1] || File.basename(path) + next if !only.empty? && only.exclude?(name) + + lines.reject! { |line| line.start_with?("#") || line =~ /^\s*$/ } + first_line = lines.fetch(0) + command = first_line.chomp + command.sub!(/ \$\*$/, "") + + if command.start_with? "brew " + command.sub!(/^brew /, "") + else + command = "!#{command}" + end + + yield name, command if block.present? + end + end + + sig { params(aliases: String).void } + def self.show(*aliases) + each([*aliases]) do |name, command| + puts "brew alias #{name}='#{command}'" + existing_alias = Alias.new(name, command) + existing_alias.link unless existing_alias.symlink.exist? + end + end + + sig { params(name: String, command: T.nilable(String)).void } + def self.edit(name, command = nil) + Alias.new(name, command).write unless command.nil? + Alias.new(name, command).edit + end + + sig { void } + def self.edit_all + exec_editor(*Dir[HOMEBREW_ALIASES]) + end + end +end diff --git a/Library/Homebrew/analytics/subcommand.rb b/Library/Homebrew/analytics/subcommand.rb new file mode 100644 index 0000000000000..7433ab5ee4e87 --- /dev/null +++ b/Library/Homebrew/analytics/subcommand.rb @@ -0,0 +1,27 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "cli/parser" + +Dir["#{__dir__}/subcommand/*.rb"].each do |subcommand| + require "analytics/subcommand/#{File.basename(subcommand, ".rb")}" +end + +module Homebrew + module Cmd + class Analytics < Homebrew::AbstractCommand + class << self + sig { params(args: T.untyped).void } + def dispatch(args) + subcommand_class = Homebrew::AbstractSubcommand + .subcommands_for(Homebrew::Cmd::Analytics) + .find do |candidate| + candidate.subcommand_name == args.subcommand + end + T.must(subcommand_class).new(args).run + end + end + end + end +end diff --git a/Library/Homebrew/analytics/subcommand/off.rb b/Library/Homebrew/analytics/subcommand/off.rb new file mode 100644 index 0000000000000..d34640f51e460 --- /dev/null +++ b/Library/Homebrew/analytics/subcommand/off.rb @@ -0,0 +1,26 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "utils/analytics" + +module Homebrew + module Cmd + class Analytics < Homebrew::AbstractCommand + class OffSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew analytics off`: + Turn Homebrew's analytics off. + EOS + named_args :none + end + + sig { override.void } + def run + Utils::Analytics.disable! + end + end + end + end +end diff --git a/Library/Homebrew/analytics/subcommand/on.rb b/Library/Homebrew/analytics/subcommand/on.rb new file mode 100644 index 0000000000000..dc2b63fb68770 --- /dev/null +++ b/Library/Homebrew/analytics/subcommand/on.rb @@ -0,0 +1,26 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "utils/analytics" + +module Homebrew + module Cmd + class Analytics < Homebrew::AbstractCommand + class OnSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew analytics on`: + Turn Homebrew's analytics on. + EOS + named_args :none + end + + sig { override.void } + def run + Utils::Analytics.enable! + end + end + end + end +end diff --git a/Library/Homebrew/analytics/subcommand/regenerate_uuid.rb b/Library/Homebrew/analytics/subcommand/regenerate_uuid.rb new file mode 100644 index 0000000000000..0d25427eba46c --- /dev/null +++ b/Library/Homebrew/analytics/subcommand/regenerate_uuid.rb @@ -0,0 +1,27 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "utils/analytics" + +module Homebrew + module Cmd + class Analytics < Homebrew::AbstractCommand + class RegenerateUuidSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew analytics regenerate-uuid`: + Delete Homebrew's legacy analytics UUID. + EOS + named_args :none + hide_from_man_page! + end + + sig { override.void } + def run + odisabled "brew analytics regenerate-uuid" + end + end + end + end +end diff --git a/Library/Homebrew/analytics/subcommand/state.rb b/Library/Homebrew/analytics/subcommand/state.rb new file mode 100644 index 0000000000000..7d0894e3b119a --- /dev/null +++ b/Library/Homebrew/analytics/subcommand/state.rb @@ -0,0 +1,31 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "utils/analytics" + +module Homebrew + module Cmd + class Analytics < Homebrew::AbstractCommand + class StateSubcommand < Homebrew::AbstractSubcommand + subcommand_args default: true do + usage_banner <<~EOS + `brew analytics` [`state`]: + Display the current state of Homebrew's analytics. + EOS + named_args :none + end + + sig { override.void } + def run + if Utils::Analytics.disabled? + puts "InfluxDB analytics are disabled." + else + puts "InfluxDB analytics are enabled." + end + puts "Google Analytics were destroyed." + end + end + end + end +end diff --git a/Library/Homebrew/api.rb b/Library/Homebrew/api.rb new file mode 100644 index 0000000000000..0204115260b55 --- /dev/null +++ b/Library/Homebrew/api.rb @@ -0,0 +1,440 @@ +# typed: strict +# frozen_string_literal: true + +require "api/analytics" +require "api/cask" +require "api/formula" +require "api/internal" +require "api/formula_struct" +require "api/cask_struct" +require "base64" +require "download_queue" +require "utils/output" + +module Homebrew + # Helper functions for using Homebrew's formulae.brew.sh API. + module API + extend Utils::Output::Mixin + + extend T::Generic + extend Cachable + + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[String, T.untyped] } } + # rubocop:enable Style/MutableConstant + + HOMEBREW_CACHE_API = T.let((HOMEBREW_CACHE/"api").freeze, Pathname) + HOMEBREW_CACHE_API_SOURCE = T.let((HOMEBREW_CACHE/"api-source").freeze, Pathname) + DEFAULT_API_STALE_SECONDS = T.let(7 * 24 * 60 * 60, Integer) # 7 days + + sig { params(endpoint: String).returns(T::Hash[String, T.untyped]) } + def self.fetch(endpoint) + return cache[endpoint] if cache.present? && cache.key?(endpoint) + + api_url = "#{Homebrew::EnvConfig.api_domain}/#{endpoint}" + output = Utils::Curl.curl_output("--fail", api_url) + if !output.success? && Homebrew::EnvConfig.api_domain != HOMEBREW_API_DEFAULT_DOMAIN + # Fall back to the default API domain and try again + api_url = "#{HOMEBREW_API_DEFAULT_DOMAIN}/#{endpoint}" + output = Utils::Curl.curl_output("--fail", api_url) + end + raise ArgumentError, "No file found at: #{Tty.underline}#{api_url}#{Tty.reset}" unless output.success? + + cache[endpoint] = JSON.parse(output.stdout, freeze: true) + rescue JSON::ParserError + raise ArgumentError, "Invalid JSON file: #{Tty.underline}#{api_url}#{Tty.reset}" + end + + sig { params(target: Pathname, stale_seconds: T.nilable(Integer)).returns(T::Boolean) } + def self.skip_download?(target:, stale_seconds:) + return true if Homebrew.running_as_root_but_not_owned_by_root? + return false if !target.exist? || target.empty? + return true unless stale_seconds + + (Time.now - stale_seconds) < target.mtime + end + + sig { + params( + endpoint: String, + target: Pathname, + stale_seconds: T.nilable(Integer), + download_queue: DownloadQueue, + enqueue: T::Boolean, + ).returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) + } + def self.fetch_json_api_file(endpoint, target: HOMEBREW_CACHE_API/endpoint, + stale_seconds: nil, download_queue: Homebrew.default_download_queue, + enqueue: false) + # Lazy-load dependency. + require "development_tools" + + retry_count = 0 + url = "#{Homebrew::EnvConfig.api_domain}/#{endpoint}" + default_url = "#{HOMEBREW_API_DEFAULT_DOMAIN}/#{endpoint}" + + if Homebrew.running_as_root_but_not_owned_by_root? && + (!target.exist? || target.empty?) + odie "Need to download #{url} but cannot as root! Run `brew update` without `sudo` first then try again." + end + + curl_args = Utils::Curl.curl_args(retries: 0) + [ + "--compressed", + "--speed-limit", ENV.fetch("HOMEBREW_CURL_SPEED_LIMIT"), + "--speed-time", ENV.fetch("HOMEBREW_CURL_SPEED_TIME"), + # This is a Curl format token, not a Ruby one. + # rubocop:disable Style/FormatStringToken + "--write-out", "%{stderr}HTTP status: %{http_code}" + # rubocop:enable Style/FormatStringToken + ] + + insecure_download = DevelopmentTools.ca_file_substitution_required? || + DevelopmentTools.curl_substitution_required? + skip_download = skip_download?(target:, stale_seconds:) + + if enqueue + unless skip_download + require "api/json_download" + download = Homebrew::API::JSONDownload.new(endpoint, target:, stale_seconds:) + download_queue.enqueue(download) + end + return [{}, false] + end + + json_data = begin + download_succeeded = T.let(false, T::Boolean) + begin + args = curl_args.dup + args.prepend("--time-cond", target.to_s) if target.exist? && !target.empty? + if insecure_download + opoo DevelopmentTools.insecure_download_warning(endpoint) + args.append("--insecure") + end + unless skip_download + ohai "Downloading #{url}" if $stdout.tty? && !Context.current.quiet? + # Disable retries here, we handle them ourselves below. + Utils::Curl.curl_download(*args, url, to: target, retries: 0, show_error: false) + download_succeeded = true + end + rescue ErrorDuringExecution + if url == default_url + raise unless target.exist? + raise if target.empty? + elsif retry_count.zero? || !target.exist? || target.empty? + # Fall back to the default API domain and try again + # This block will be executed only once, because we set `url` to `default_url` + url = default_url + target.unlink if target.exist? && target.empty? + skip_download = false + + retry + end + + opoo "#{target.basename}: update failed, falling back to cached version." + end + + # Only refresh the cache mtime after a successful curl revalidation/download. + # Touching after a failed download would mark a stale cache as fresh and + # cause `skip_download?` to short-circuit subsequent retries until cleanup. + if download_succeeded + mtime = insecure_download ? Time.new(1970, 1, 1) : Time.now + FileUtils.touch(target, mtime:) + end + # Can use `target.read` again when/if https://github.com/sorbet/sorbet/pull/8999 is merged/released. + JSON.parse(File.read(target, encoding: Encoding::UTF_8), freeze: true) + rescue JSON::ParserError + target.unlink + retry_count += 1 + skip_download = false + odie "Cannot download non-corrupt #{url}!" if retry_count > Homebrew::EnvConfig.curl_retries.to_i + + retry + end + + if endpoint.end_with?(".jws.json") + success, data = verify_and_parse_jws(json_data) + unless success + target.unlink + odie <<~EOS + Failed to verify integrity (#{data}) of: + #{url} + Potential MITM attempt detected. Please run `brew update` and try again. + EOS + end + [data, !skip_download] + else + [json_data, !skip_download] + end + end + + sig { + params(json: T::Hash[String, T.untyped], + bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) + } + def self.merge_variations(json, bottle_tag: T.unsafe(nil)) + return json unless json.key?("variations") + + bottle_tag ||= Homebrew::SimulateSystem.current_tag + + if (variation = json.dig("variations", bottle_tag.to_s).presence) || + (variation = json.dig("variations", bottle_tag.to_sym).presence) + json = json.merge(variation) + end + + json.except("variations") + end + + sig { void } + def self.fetch_api_files! + download_queue = Homebrew::DownloadQueue.new + + stale_seconds = if ENV["HOMEBREW_API_UPDATED"].present? || + (Homebrew::EnvConfig.no_auto_update? && !Homebrew::EnvConfig.force_api_auto_update?) + nil + elsif Homebrew.auto_update_command? + Homebrew::EnvConfig.api_auto_update_secs.to_i + else + DEFAULT_API_STALE_SECONDS + end + + # The internal API is now always used; read this only to surface its deprecation. + Homebrew::EnvConfig.use_internal_api? + Homebrew::API::Internal.fetch_packages_api!(download_queue:, stale_seconds:, enqueue: true) + + ENV["HOMEBREW_API_UPDATED"] = "1" + + begin + download_queue.fetch + ensure + download_queue.shutdown + end + end + + sig { void } + def self.write_names_and_aliases + Homebrew::API::Internal.write_formula_names_and_aliases + Homebrew::API::Internal.write_cask_names + end + + sig { params(names: T::Array[String], type: String, regenerate: T::Boolean).returns(T::Boolean) } + def self.write_names_file!(names, type, regenerate:) + names_path = HOMEBREW_CACHE_API/"#{type}_names.txt" + if !names_path.exist? || regenerate + names_path.unlink if names_path.exist? + names_path.write(names.sort.join("\n")) + return true + end + + false + end + + sig { params(aliases: T::Hash[String, String], type: String, regenerate: T::Boolean).returns(T::Boolean) } + def self.write_aliases_file!(aliases, type, regenerate:) + aliases_path = HOMEBREW_CACHE_API/"#{type}_aliases.txt" + if !aliases_path.exist? || regenerate + aliases_text = aliases.map do |alias_name, real_name| + "#{alias_name}|#{real_name}" + end + aliases_path.unlink if aliases_path.exist? + aliases_path.write(aliases_text.sort.join("\n")) + return true + end + + false + end + + sig { + params( + formulae: T::Hash[String, T::Hash[String, T.untyped]], + regenerate: T::Boolean, + ).returns(T::Boolean) + } + def self.write_executables_file!(formulae, regenerate:) + executables_path = HOMEBREW_CACHE_API/"internal/executables.txt" + executables_lines = formulae.filter_map do |name, hash| + executables = T.cast(hash["executables"], T.nilable(T::Array[String])) + next if executables.blank? + + "#{name}:#{executables.join(" ")}" + end + if executables_lines.empty? + begin + executables_path.unlink + return true + rescue Errno::ENOENT + return false + end + end + + contents = "#{executables_lines.sort.join("\n")}\n" + cached_contents = begin + executables_path.read unless regenerate + rescue Errno::ENOENT + nil + end + if regenerate || cached_contents != contents + executables_path.dirname.mkpath + executables_path.write(contents) + return true + end + + false + end + + sig { params(target: Pathname).returns(T::Boolean) } + def self.download_executables_file_from_github_packages!(target) + github_packages_url = "https://ghcr.io/v2/homebrew/command-not-found/executables" + manifest_args = [ + "--fail", "--location", + "--header", "Accept: application/vnd.oci.image.manifest.v1+json", + "#{github_packages_url}/manifests/latest" + ] + if HOMEBREW_GITHUB_PACKAGES_AUTH.present? + manifest_args.insert(-2, "--header", "Authorization: #{HOMEBREW_GITHUB_PACKAGES_AUTH}") + end + + manifest_output = Utils::Curl.curl_output(*manifest_args, show_error: false) + return false unless manifest_output.success? + + manifest = JSON.parse(manifest_output.stdout) + layers = T.cast(manifest.fetch("layers"), T::Array[T::Hash[String, T.untyped]]) + layer = layers.find do |candidate| + candidate.dig("annotations", "org.opencontainers.image.title") == target.basename.to_s + end + return false if layer.nil? + + digest = T.cast(layer["digest"], T.nilable(String)) + return false if digest.blank? + + download_args = ["--fail"] + if HOMEBREW_GITHUB_PACKAGES_AUTH.present? + download_args += ["--header", "Authorization: #{HOMEBREW_GITHUB_PACKAGES_AUTH}"] + end + download_args << "#{github_packages_url}/blobs/#{digest}" + target.dirname.mkpath + Utils::Curl.curl_download(*download_args, to: target, show_error: false) + FileUtils.touch(target) + true + rescue ErrorDuringExecution, JSON::ParserError, KeyError, TypeError + target.unlink if target.exist? && target.empty? + + false + end + + sig { + params(json_data: T::Hash[String, T.untyped]) + .returns([T::Boolean, T.any(String, T::Array[T.untyped], T::Hash[String, T.untyped])]) + } + private_class_method def self.verify_and_parse_jws(json_data) + signatures = json_data["signatures"] + homebrew_signature = signatures&.find { |sig| sig.dig("header", "kid") == "homebrew-1" } + return false, "key not found" if homebrew_signature.nil? + + header = JSON.parse(Base64.urlsafe_decode64(homebrew_signature["protected"])) + if header["alg"] != "PS512" || header["b64"] != false # NOTE: nil has a meaning of true + return false, "invalid algorithm" + end + + require "openssl" + + pubkey = OpenSSL::PKey::RSA.new((HOMEBREW_LIBRARY_PATH/"api/homebrew-1.pem").read) + signing_input = "#{homebrew_signature["protected"]}.#{json_data["payload"]}" + unless pubkey.verify_pss("SHA512", + Base64.urlsafe_decode64(homebrew_signature["signature"]), + signing_input, + salt_length: :digest, + mgf1_hash: "SHA512") + return false, "signature mismatch" + end + + [true, JSON.parse(json_data["payload"], freeze: true)] + end + + sig { params(path: Pathname).returns(T.nilable(Tap)) } + def self.tap_from_source_download(path) + path = path.expand_path + source_relative_path = path.relative_path_from(Homebrew::API::HOMEBREW_CACHE_API_SOURCE) + return if source_relative_path.to_s.start_with?("../") + + org, repo = source_relative_path.each_filename.first(2) + return if org.blank? || repo.blank? + + Tap.fetch(org, repo) + end + + sig { returns(T::Array[String]) } + def self.formula_names + Homebrew::API::Internal.formula_hashes.keys + end + + sig { params(name: String).returns(T::Boolean) } + def self.formula_name?(name) + Homebrew::API::Internal.formula_hashes.key?(name) + end + + sig { returns(T::Hash[String, String]) } + def self.formula_aliases + Homebrew::API::Internal.formula_aliases + end + + sig { returns(T::Hash[String, String]) } + def self.formula_renames + Homebrew::API::Internal.formula_renames + end + + sig { returns(T::Hash[String, String]) } + def self.formula_tap_migrations + Homebrew::API::Internal.formula_tap_migrations + end + + sig { returns(Pathname) } + def self.cached_formula_json_file_path + Homebrew::API::Internal.cached_packages_json_file_path + end + + sig { returns(T::Array[String]) } + def self.cask_tokens + Homebrew::API::Internal.cask_hashes.keys + end + + sig { params(token: String).returns(T::Boolean) } + def self.cask_token?(token) + Homebrew::API::Internal.cask_hashes.key?(token) + end + + sig { returns(T::Hash[String, String]) } + def self.cask_renames + Homebrew::API::Internal.cask_renames + end + + sig { returns(T::Hash[String, String]) } + def self.cask_tap_migrations + Homebrew::API::Internal.cask_tap_migrations + end + + sig { returns(Pathname) } + def self.cached_cask_json_file_path + Homebrew::API::Internal.cached_packages_json_file_path + end + end + + sig { type_parameters(:U).params(block: T.proc.returns(T.type_parameter(:U))).returns(T.type_parameter(:U)) } + def self.with_no_api_env(&block) + return yield if Homebrew::EnvConfig.no_install_from_api? + + with_env(HOMEBREW_NO_INSTALL_FROM_API: "1", HOMEBREW_AUTOMATICALLY_SET_NO_INSTALL_FROM_API: "1", &block) + end + + sig { + type_parameters(:U).params( + condition: T::Boolean, + block: T.proc.returns(T.type_parameter(:U)), + ).returns(T.type_parameter(:U)) + } + def self.with_no_api_env_if_needed(condition, &block) + return yield unless condition + + with_no_api_env(&block) + end +end diff --git a/Library/Homebrew/api/analytics.rb b/Library/Homebrew/api/analytics.rb new file mode 100644 index 0000000000000..ce105b9fd9221 --- /dev/null +++ b/Library/Homebrew/api/analytics.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module API + # Helper functions for using the analytics JSON API. + module Analytics + class << self + sig { returns(String) } + def analytics_api_path + "analytics" + end + + sig { params(category: String, days: T.any(Integer, String)).returns(T::Hash[String, T.untyped]) } + def fetch(category, days) + Homebrew::API.fetch "#{analytics_api_path}/#{category}/#{days}d.json" + end + end + end + end +end diff --git a/Library/Homebrew/api/cask.rb b/Library/Homebrew/api/cask.rb new file mode 100644 index 0000000000000..dda39e0299626 --- /dev/null +++ b/Library/Homebrew/api/cask.rb @@ -0,0 +1,168 @@ +# typed: strict +# frozen_string_literal: true + +require "cachable" +require "api" +require "api/source_download" +require "download_queue" +require "api/cask/cask_struct_generator" + +module Homebrew + module API + # Helper functions for using the cask JSON API. + module Cask + extend T::Generic + extend Cachable + + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[String, T.untyped] } } + # rubocop:enable Style/MutableConstant + + DEFAULT_API_FILENAME = "cask.jws.json" + + private_class_method :cache + + sig { params(name: String).returns(T::Hash[String, T.untyped]) } + def self.cask_json(name) + fetch_cask_json! name if !cache.key?("cask_json") || !cache.fetch("cask_json").key?(name) + + cache.fetch("cask_json").fetch(name) + end + + sig { params(name: String).void } + def self.fetch_cask_json!(name) + endpoint = "cask/#{name}.json" + json_cask, updated = Homebrew::API.fetch_json_api_file endpoint + + json_cask = JSON.parse((HOMEBREW_CACHE_API/endpoint).read) unless updated + + cache["cask_json"] ||= {} + cache["cask_json"][name] = json_cask + end + + sig { + params( + cask: ::Cask::Cask, + download_queue: Homebrew::DownloadQueue, + enqueue: T::Boolean, + ).returns(Homebrew::API::SourceDownload) + } + def self.source_download(cask, download_queue: Homebrew.default_download_queue, enqueue: false) + download = source_download_for(cask) + + if enqueue + download_queue.enqueue(download) + elsif !download.symlink_location.exist? + download.fetch + end + + download + end + + sig { params(cask: ::Cask::Cask).returns(Homebrew::API::SourceDownload) } + def self.source_download_for(cask) + path = cask.ruby_source_path.to_s + sha256 = cask.ruby_source_checksum[:sha256] + checksum = Checksum.new(sha256) if sha256 + git_head = cask.tap_git_head || "HEAD" + tap = cask.tap&.full_name || "Homebrew/homebrew-cask" + + Homebrew::API::SourceDownload.new( + "https://raw.githubusercontent.com/#{tap}/#{git_head}/#{path}", + checksum, + mirrors: [ + "#{HOMEBREW_API_DEFAULT_DOMAIN}/cask-source/#{File.basename(path)}", + ], + cache: HOMEBREW_CACHE_API_SOURCE/"#{tap}/#{git_head}/Cask", + ) + end + + sig { params(cask: ::Cask::Cask).returns(::Cask::Cask) } + def self.source_download_cask(cask) + download = source_download(cask) + + ::Cask::CaskLoader::FromPathLoader.new(download.symlink_location) + .load(config: cask.config) + end + + sig { returns(Pathname) } + def self.cached_json_file_path + HOMEBREW_CACHE_API/DEFAULT_API_FILENAME + end + + sig { + params(download_queue: ::Homebrew::DownloadQueue, stale_seconds: T.nilable(Integer), enqueue: T::Boolean) + .returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) + } + def self.fetch_api!(download_queue: Homebrew.default_download_queue, stale_seconds: nil, enqueue: false) + Homebrew::API.fetch_json_api_file DEFAULT_API_FILENAME, stale_seconds:, download_queue:, enqueue: + end + + sig { + params(download_queue: ::Homebrew::DownloadQueue, stale_seconds: T.nilable(Integer), enqueue: T::Boolean) + .returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) + } + def self.fetch_tap_migrations!(download_queue: Homebrew.default_download_queue, stale_seconds: nil, + enqueue: false) + Homebrew::API.fetch_json_api_file "cask_tap_migrations.jws.json", stale_seconds:, download_queue:, enqueue: + end + + sig { returns(T::Boolean) } + def self.download_and_cache_data! + json_casks, updated = fetch_api! + + cache["renames"] = {} + cache["casks"] = json_casks.to_h do |json_cask| + token = json_cask["token"] + + json_cask.fetch("old_tokens", []).each do |old_token| + cache["renames"][old_token] = token + end + + [token, json_cask.except("token")] + end + + updated + end + private_class_method :download_and_cache_data! + + sig { returns(T::Hash[String, T::Hash[String, T.untyped]]) } + def self.all_casks + unless cache.key?("casks") + json_updated = download_and_cache_data! + write_names(regenerate: json_updated) + end + + cache.fetch("casks") + end + + sig { returns(T::Hash[String, String]) } + def self.all_renames + unless cache.key?("renames") + json_updated = download_and_cache_data! + write_names(regenerate: json_updated) + end + + cache.fetch("renames") + end + + sig { returns(T::Hash[String, T.untyped]) } + def self.tap_migrations + unless cache.key?("tap_migrations") + json_migrations, = fetch_tap_migrations! + cache["tap_migrations"] = json_migrations + end + + cache.fetch("tap_migrations") + end + + sig { params(regenerate: T::Boolean).void } + def self.write_names(regenerate: false) + download_and_cache_data! unless cache.key?("casks") + + Homebrew::API.write_names_file!(all_casks.keys, "cask", regenerate:) + end + end + end +end diff --git a/Library/Homebrew/api/cask/cask_struct_generator.rb b/Library/Homebrew/api/cask/cask_struct_generator.rb new file mode 100644 index 0000000000000..fa10057fad3a4 --- /dev/null +++ b/Library/Homebrew/api/cask/cask_struct_generator.rb @@ -0,0 +1,158 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module API + module Cask + # Methods for generating CaskStruct instances from API data. + module CaskStructGenerator + module_function + + # NOTE: this will be used to load installed cask JSON files, + # so it must never fail with older JSON API versions + sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) } + def generate_cask_struct_hash(hash, bottle_tag: Homebrew::SimulateSystem.current_tag, ignore_types: false) + hash = Homebrew::API.merge_variations(hash, bottle_tag:).dup.deep_symbolize_keys.transform_keys(&:to_s) + + hash["conflicts_with_args"] = hash["conflicts_with"]&.to_h + + hash["container_args"] = hash["container"]&.to_h do |key, value| + next [key, value.to_sym] if key == :type + + [key, value] + end + + if (depends_on = hash["depends_on"]) + hash["depends_on_args"] = process_depends_on(depends_on) + end + + if (deprecate_args = hash["deprecate_args"]) + deprecate_args = deprecate_args.dup + deprecate_args[:because] = + DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :cask) + hash["deprecate_args"] = deprecate_args + end + + if (disable_args = hash["disable_args"]) + disable_args = disable_args.dup + disable_args[:because] = DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :cask) + hash["disable_args"] = disable_args + end + + hash["names"] = hash["name"] + + if (artifacts = hash["artifacts"]) + hash["raw_artifacts"] = process_artifacts(artifacts) + end + + hash["raw_caveats"] = hash["caveats"] + + hash["renames"] = hash["rename"]&.map do |operation| + [operation[:from], operation[:to]] + end + + hash["ruby_source_checksum"] = { + sha256: hash.dig("ruby_source_checksum", :sha256), + }.compact + + hash["ruby_source_path"] = hash["ruby_source_path"]&.to_s + + hash["sha256"] = hash["sha256"].to_s + hash["sha256"] = :no_check if hash["sha256"] == "no_check" + + hash["tap_string"] = hash["tap"] + + hash["url_args"] = [hash["url"].to_s] + + if (url_specs = hash["url_specs"]) + hash["url_kwargs"] = process_url_specs(url_specs) + end + + # Should match CaskStruct::PREDICATES + hash["auto_updates_present"] = hash["auto_updates"].present? + hash["caveats_present"] = hash["caveats"].present? + hash["conflicts_present"] = hash["conflicts_with"].present? + hash["container_present"] = hash["container"].present? + hash["depends_on_present"] = hash["depends_on_args"].present? + hash["deprecate_present"] = hash["deprecate_args"].present? + hash["desc_present"] = hash["desc"].present? + hash["disable_present"] = hash["disable_args"].present? + hash["homepage_present"] = hash["homepage"].present? + + CaskStruct.from_hash(hash, ignore_types:) + end + + sig { params(depends_on: T::Hash[Symbol, T.untyped]).returns(CaskStruct::DependsOnArgs) } + def process_depends_on(depends_on) + depends_on.to_h do |key, value| + # Arch dependencies are encoded like `{ type: :intel, bits: 64 }` + # but `depends_on arch:` only accepts `:intel` or `:arm64` + if key == :arch + next [:arch, :intel] if value.first[:type].to_sym == :intel + + next [:arch, :arm64] + end + next [key, :any] if key == :linux + + next [key, value] unless [:macos, :maximum_macos].include?(key) + + value = value.to_h if value.is_a?(MacOSRequirement) + dep_type = value.keys.first + next [key, :any] unless dep_type + + if dep_type.to_sym == :== + version_symbols = value[dep_type].filter_map do |version| + MacOSVersion::SYMBOLS.key(version) + end + next [key, version_symbols.presence] + end + + version_symbol = value[dep_type].first + next [key, :any] if version_symbol.blank? + + version_symbol = MacOSVersion::SYMBOLS.key(version_symbol) + next [key, version_symbol] if [:>=, :<=].include?(dep_type.to_sym) && version_symbol + + version_dep = "#{dep_type} :#{version_symbol}" if version_symbol + [key, version_dep] + end.compact + end + + sig { params(artifacts: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Array[CaskStruct::ArtifactArgs]) } + def process_artifacts(artifacts) + artifacts.map do |artifact| + key = T.must(artifact.keys.first) + + # Pass an empty block to artifacts like postflight that can't be loaded from the API, + # but need to be set to something. + next [key, [], {}, Homebrew::API::CaskStruct::EMPTY_BLOCK] if artifact[key].nil? + + args = artifact[key] + kwargs = if args.last.is_a?(Hash) + args.pop + else + {} + end + [key, args, kwargs, nil] + end + end + + sig { params(url_specs: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.anything]) } + def process_url_specs(url_specs) + url_specs.to_h do |key, value| + value = case key + when :user_agent + Utils.convert_to_string_or_symbol(value) + when :using + value.to_sym + else + value + end + + [key, value] + end.compact_blank + end + end + end + end +end diff --git a/Library/Homebrew/api/cask_download.rb b/Library/Homebrew/api/cask_download.rb new file mode 100644 index 0000000000000..1665102c664df --- /dev/null +++ b/Library/Homebrew/api/cask_download.rb @@ -0,0 +1,39 @@ +# typed: strict +# frozen_string_literal: true + +require "api/cask_struct" +require "cask/cask" +require "cask/download" + +module Homebrew + module API + module CaskDownload + sig { + params( + token: String, + cask_struct: Homebrew::API::CaskStruct, + quarantine: T.nilable(T::Boolean), + require_sha: T::Boolean, + ).returns(T.nilable(::Cask::Download)) + } + def self.download(token:, cask_struct:, quarantine: nil, require_sha: false) + return if cask_struct.languages.any? + return if cask_struct.url_args.empty? + + cask = ::Cask::Cask.new( + token, + tap: CoreCaskTap.instance, + loaded_from_api: true, + loaded_from_internal_api: true, + ) do + version cask_struct.version + sha256 cask_struct.sha256 + url(*cask_struct.url_args, **cask_struct.url_kwargs) + homepage cask_struct.homepage if cask_struct.homepage? + end + + ::Cask::Download.new(cask, quarantine:, require_sha:) + end + end + end +end diff --git a/Library/Homebrew/api/cask_struct.rb b/Library/Homebrew/api/cask_struct.rb new file mode 100644 index 0000000000000..d5c2d2511a595 --- /dev/null +++ b/Library/Homebrew/api/cask_struct.rb @@ -0,0 +1,231 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module API + class CaskStruct < T::Struct + sig { params(cask_hash: T::Hash[String, T.untyped], ignore_types: T::Boolean).returns(CaskStruct) } + def self.from_hash(cask_hash, ignore_types: false) + return super(cask_hash) if ignore_types + + cask_hash = ::Cask::Cask.deep_remove_placeholders(cask_hash) + cask_hash = cask_hash.transform_keys(&:to_sym) + .slice(*decorator.all_props) + .compact_blank + new(**cask_hash) + end + + PREDICATES = [ + :auto_updates, + :caveats, + :conflicts, + :container, + :depends_on, + :deprecate, + :desc, + :disable, + :homepage, + ].freeze + + EMPTY_BLOCK = T.let(-> {}.freeze, T.proc.void) + EMPTY_BLOCK_PLACEHOLDER = :empty_block + + ArtifactArgs = T.type_alias do + [ + Symbol, + T::Array[T.anything], + T::Hash[Symbol, T.anything], + T.nilable(T.proc.void), + ] + end + + PREDICATES.each do |predicate_name| + present_method_name = :"#{predicate_name}_present" + predicate_method_name = :"#{predicate_name}?" + + const present_method_name, T::Boolean, default: false + + define_method(predicate_method_name) do + send(present_method_name) + end + end + + DependsOnArgs = T.type_alias do + T::Hash[ + # Keys are dependency types like :macos, :arch, :cask, :formula + Symbol, + # Values can be any of: + T.any( + # Strings like ">= :catalina" for :macos + String, + # Symbols like :intel or :arm64 for :arch + Symbol, + # Array of strings or symbols for :cask and :formula + T::Array[T.any(String, Symbol)], + ), + ] + end + + # Changes to this struct must be mirrored in Homebrew::API::Cask.generate_cask_struct_hash + const :auto_updates, T::Boolean, default: false + const :caveats_rosetta, T::Boolean, default: false + const :conflicts_with_args, T::Hash[Symbol, T::Array[String]], default: {} + const :container_args, { nested: T.nilable(String), type: T.nilable(Symbol) }, + default: { nested: nil, type: nil } + const :depends_on_args, DependsOnArgs, default: {} + const :deprecate_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {} + const :desc, T.nilable(String) + const :disable_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {} + const :homepage, T.nilable(String) + const :languages, T::Array[String], default: [] + const :names, T::Array[String], default: [] + const :renames, T::Array[[String, String]], default: [] + const :ruby_source_checksum, T::Hash[Symbol, T.nilable(String)], default: { sha256: nil } + const :ruby_source_path, T.nilable(String) + const :sha256, T.any(String, Symbol) + const :tap_string, T.nilable(String) + const :url_args, T::Array[String], default: [] + const :url_kwargs, T::Hash[Symbol, T.anything], default: {} + const :version, T.any(String, Symbol) + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + case other + when CaskStruct + serialize == other.serialize + else + false + end + end + + sig { params(appdir: T.any(Pathname, String)).returns(T::Array[ArtifactArgs]) } + def artifacts(appdir:) + deep_remove_placeholders(raw_artifacts, appdir.to_s) + end + + sig { params(appdir: T.any(Pathname, String)).returns(T.nilable(String)) } + def caveats(appdir:) + deep_remove_placeholders(raw_caveats, appdir.to_s) + end + + sig { returns(T::Hash[String, T.untyped]) } + def serialize + hash = self.class.decorator.all_props.filter_map do |prop| + next if PREDICATES.any? { |predicate| prop == :"#{predicate}_present" } + + [prop.to_s, send(prop)] + end.to_h + + hash["raw_artifacts"] = ::Utils.deep_compact_blank(raw_artifacts.map do |artifact| + serialize_artifact_args(artifact) + end, compact_zero: false) + + hash = ::Utils.deep_stringify_symbols(hash) + raw_artifacts = hash["raw_artifacts"] + hash = ::Utils.deep_compact_blank(hash) + hash["raw_artifacts"] = raw_artifacts if raw_artifacts.present? + hash + end + + sig { params(hash: T::Hash[String, T.untyped]).returns(CaskStruct) } + def self.deserialize(hash) + hash = ::Utils.deep_unstringify_symbols(hash) + + PREDICATES.each do |name| + source_value = case name + when :auto_updates then hash["auto_updates"] + when :caveats then hash["raw_caveats"] + when :conflicts then hash["conflicts_with_args"] + when :desc then hash["desc"] + when :homepage then hash["homepage"] + else hash["#{name}_args"] + end + + hash["#{name}_present"] = source_value.present? + end + + hash["raw_artifacts"] = if (raw_artifacts = hash["raw_artifacts"]) + raw_artifacts.map { |artifact| deserialize_artifact_args(artifact) } + end + + from_hash(hash) + end + + sig { params(artifact: ArtifactArgs).returns(T::Array[T.untyped]) } + def serialize_artifact_args(artifact) + key, args, kwargs, block = artifact + + # We can't serialize Procs, so always use an empty block placeholder to be deserialized as `-> {}`. + block = EMPTY_BLOCK_PLACEHOLDER unless block.nil? + + [key, args, kwargs, block] + end + + # Format artifact args pairs into proper [key, args, kwargs, block] format since serialization removed blanks. + sig { + params( + args: T.any( + [Symbol], + [Symbol, T::Array[T.anything]], + [Symbol, T::Hash[Symbol, T.anything]], + [Symbol, Symbol], + [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything]], + [Symbol, T::Array[T.anything], Symbol], + [Symbol, T::Hash[Symbol, T.anything], Symbol], + [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything], Symbol], + ), + ).returns(ArtifactArgs) + } + def self.deserialize_artifact_args(args) + case args + in [key] then [key, [], {}, nil] + in [key, Array => array] then [key, array, {}, nil] + in [key, Hash => hash] then [key, [], hash, nil] + in [key, EMPTY_BLOCK_PLACEHOLDER] then [key, [], {}, EMPTY_BLOCK] + in [key, Array => array, Hash => hash] then [key, array, hash, nil] + in [key, Array => array, EMPTY_BLOCK_PLACEHOLDER] then [key, array, {}, EMPTY_BLOCK] + in [key, Hash => hash, EMPTY_BLOCK_PLACEHOLDER] then [key, [], hash, EMPTY_BLOCK] + in [key, Array => array, Hash => hash, EMPTY_BLOCK_PLACEHOLDER] then [key, array, hash, EMPTY_BLOCK] + else + # The block argument should only ever be EMPTY_BLOCK_PLACEHOLDER or nil, so we should never reach this case. + raise "Invalid artifact args: #{args.inspect}" + end + end + + private + + const :raw_artifacts, T::Array[ArtifactArgs], default: [] + const :raw_caveats, T.nilable(String) + + sig { + type_parameters(:U) + .params( + value: T.type_parameter(:U), + appdir: String, + ) + .returns(T.type_parameter(:U)) + } + def deep_remove_placeholders(value, appdir) + value = case value + when Hash + value.transform_values do |v| + deep_remove_placeholders(v, appdir) + end + when Array + value.map do |v| + deep_remove_placeholders(v, appdir) + end + when String + value.gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home) + .gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX) + .gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR) + .gsub(HOMEBREW_CASK_APPDIR_PLACEHOLDER, appdir) + else + value + end + + T.cast(value, T.type_parameter(:U)) + end + end + end +end diff --git a/Library/Homebrew/api/formula.rb b/Library/Homebrew/api/formula.rb new file mode 100644 index 0000000000000..b2054494321bf --- /dev/null +++ b/Library/Homebrew/api/formula.rb @@ -0,0 +1,214 @@ +# typed: strict +# frozen_string_literal: true + +require "cachable" +require "api" +require "api/source_download" +require "local_patch" +require "download_queue" +require "api/formula/formula_struct_generator" + +module Homebrew + module API + # Helper functions for using the formula JSON API. + module Formula + extend T::Generic + extend Cachable + + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[String, T.untyped] } } + # rubocop:enable Style/MutableConstant + + DEFAULT_API_FILENAME = "formula.jws.json" + + private_class_method :cache + + sig { params(name: String).returns(T::Hash[String, T.untyped]) } + def self.formula_json(name) + fetch_formula_json! name if !cache.key?("formula_json") || !cache.fetch("formula_json").key?(name) + + cache.fetch("formula_json").fetch(name) + end + + sig { params(name: String).void } + def self.fetch_formula_json!(name) + endpoint = "formula/#{name}.json" + json_formula, updated = Homebrew::API.fetch_json_api_file endpoint + + json_formula = JSON.parse((HOMEBREW_CACHE_API/endpoint).read) unless updated + + cache["formula_json"] ||= {} + cache["formula_json"][name] = json_formula + end + + sig { + params( + formula: ::Formula, + path: String, + checksum: T.nilable(Checksum), + download_queue: Homebrew::DownloadQueue, + enqueue: T::Boolean, + ).returns(Homebrew::API::SourceDownload) + } + def self.source_download_path(formula, path, checksum: nil, download_queue: Homebrew.default_download_queue, + enqueue: false) + unless LocalPatch.valid_path?(path) + raise ArgumentError, "API source path must be a relative path within the repository." + end + + path = Pathname(path).cleanpath + + git_head = formula.tap_git_head || "HEAD" + tap = formula.tap&.full_name || "Homebrew/homebrew-core" + + download = Homebrew::API::SourceDownload.new( + "https://raw.githubusercontent.com/#{tap}/#{git_head}/#{path}", + checksum, + cache: HOMEBREW_CACHE_API_SOURCE/"#{tap}/#{git_head}"/path.dirname, + ) + + if enqueue + download_queue.enqueue(download) + elsif !download.symlink_location.exist? || !download.symlink_location.symlink? + download.fetch + end + + download + end + + sig { + params( + formula: ::Formula, + download_queue: Homebrew::DownloadQueue, + enqueue: T::Boolean, + ).returns(Homebrew::API::SourceDownload) + } + def self.source_download(formula, download_queue: Homebrew.default_download_queue, enqueue: false) + path = formula.ruby_source_path || "Formula/#{formula.name}.rb" + source_download_path(formula, path, checksum: formula.ruby_source_checksum, download_queue:, enqueue:) + end + + sig { params(formula: ::Formula).returns(::Formula) } + def self.source_download_formula(formula) + download = source_download(formula) + + unless download.symlink_location.exist? + raise CannotInstallFormulaError, + "#{formula.full_name} source code not found at #{download.symlink_location}. " \ + "Try `rm -rf $(brew --cache)/api-source` and retrying." + end + + source_formula = with_env(HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS: "1") do + Formulary.factory(download.symlink_location, + formula.active_spec_sym, + alias_path: formula.alias_path, + flags: formula.class.build_flags) + end + + source_formula.resources.each do |resource| + resource.patches.grep(LocalPatch) do |patch| + source_download_path(formula, patch.file.to_s) + end + end + source_formula.patchlist.grep(LocalPatch) do |patch| + source_download_path(formula, patch.file.to_s) + end + + source_formula + end + + sig { returns(Pathname) } + def self.cached_json_file_path + HOMEBREW_CACHE_API/DEFAULT_API_FILENAME + end + + sig { + params(download_queue: Homebrew::DownloadQueue, stale_seconds: T.nilable(Integer), enqueue: T::Boolean) + .returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) + } + def self.fetch_api!(download_queue: Homebrew.default_download_queue, stale_seconds: nil, enqueue: false) + Homebrew::API.fetch_json_api_file DEFAULT_API_FILENAME, stale_seconds:, download_queue:, enqueue: + end + + sig { + params(download_queue: Homebrew::DownloadQueue, stale_seconds: T.nilable(Integer), enqueue: T::Boolean) + .returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) + } + def self.fetch_tap_migrations!(download_queue: Homebrew.default_download_queue, stale_seconds: nil, + enqueue: false) + Homebrew::API.fetch_json_api_file "formula_tap_migrations.jws.json", stale_seconds:, download_queue:, enqueue: + end + + sig { returns(T::Boolean) } + def self.download_and_cache_data! + json_formulae, updated = fetch_api! + + cache["aliases"] = {} + cache["renames"] = {} + cache["formulae"] = json_formulae.to_h do |json_formula| + json_formula["aliases"].each do |alias_name| + cache["aliases"][alias_name] = json_formula["name"] + end + (json_formula["oldnames"] || [json_formula["oldname"]].compact).each do |oldname| + cache["renames"][oldname] = json_formula["name"] + end + + [json_formula["name"], json_formula.except("name")] + end + + updated + end + private_class_method :download_and_cache_data! + + sig { returns(T::Hash[String, T.untyped]) } + def self.all_formulae + unless cache.key?("formulae") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache.fetch("formulae") + end + + sig { returns(T::Hash[String, String]) } + def self.all_aliases + unless cache.key?("aliases") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache.fetch("aliases") + end + + sig { returns(T::Hash[String, String]) } + def self.all_renames + unless cache.key?("renames") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache.fetch("renames") + end + + sig { returns(T::Hash[String, T.untyped]) } + def self.tap_migrations + unless cache.key?("tap_migrations") + json_migrations, = fetch_tap_migrations! + cache["tap_migrations"] = json_migrations + end + + cache.fetch("tap_migrations") + end + + sig { params(regenerate: T::Boolean).void } + def self.write_names_and_aliases(regenerate: false) + download_and_cache_data! unless cache.key?("formulae") + + Homebrew::API.write_names_file!(all_formulae.keys, "formula", regenerate:) + Homebrew::API.write_aliases_file!(all_aliases, "formula", regenerate:) + Homebrew::API.write_executables_file!(all_formulae, regenerate:) + end + end + end +end diff --git a/Library/Homebrew/api/formula/formula_struct_generator.rb b/Library/Homebrew/api/formula/formula_struct_generator.rb new file mode 100644 index 0000000000000..7ddb864160595 --- /dev/null +++ b/Library/Homebrew/api/formula/formula_struct_generator.rb @@ -0,0 +1,323 @@ +# typed: strict +# frozen_string_literal: true + +require "deprecate_disable" + +module Homebrew + module API + module Formula + # Methods for generating FormulaStruct instances from API data. + module FormulaStructGenerator + module_function + + # `:codesign` and custom requirement classes are not supported. + API_SUPPORTED_REQUIREMENTS = [:arch, :linux, :macos, :maximum_macos, :xcode].freeze + private_constant :API_SUPPORTED_REQUIREMENTS + + DependencyHash = T.type_alias do + T::Hash[ + # Keys are strings of the dependency type (e.g. "dependencies", "build_dependencies") + String, + # Values are arrays of either: + T::Array[ + T.any( + # Formula name: "foo" + String, + # Hash like { "foo" => :build } or { "foo" => [:build, :test] } + T::Hash[ + String, + T.any(Symbol, T::Array[Symbol]), + ], + # Hash like { since: :catalina } for uses_from_macos_bounds + T::Hash[Symbol, Symbol], + ), + ], + ] + end + + RequirementsArray = T.type_alias do + T::Array[T::Hash[String, T.untyped]] + end + + sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag).returns(FormulaStruct) } + def generate_formula_struct_hash(hash, bottle_tag: Homebrew::SimulateSystem.current_tag) + hash = Homebrew::API.merge_variations(hash, bottle_tag:).dup.deep_stringify_keys + + if (caveats = hash["caveats"]) + hash["caveats"] = Formulary.replace_placeholders(caveats) + end + + hash["bottle_checksums"] = begin + files = hash.dig("bottle", "stable", "files") || {} + files.map do |tag, bottle_spec| + { + cellar: Utils.convert_to_string_or_symbol(bottle_spec.fetch("cellar")), + tag.to_sym => bottle_spec.fetch("sha256"), + } + end + end + + hash["bottle_rebuild"] = hash.dig("bottle", "stable", "rebuild") + + conflicts_with = hash["conflicts_with"] || [] + conflicts_with_reasons = hash["conflicts_with_reasons"] || [] + hash["conflicts"] = conflicts_with.zip(conflicts_with_reasons).map do |name, reason| + if reason.present? + [name, { because: reason }] + else + [name, {}] + end + end + + if (deprecate_args = hash["deprecate_args"]) + deprecate_args = deprecate_args.dup.transform_keys(&:to_sym) + deprecate_args[:because] = + DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :formula) + hash["deprecate_args"] = deprecate_args + end + + if (disable_args = hash["disable_args"]) + disable_args = disable_args.dup.transform_keys(&:to_sym) + disable_args[:because] = + DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :formula) + hash["disable_args"] = disable_args + end + + hash["head_url_args"] = begin + # Fall back to "" to satisfy the type checker. If the head URL is missing, head_present will be false. + url = hash.dig("urls", "head", "url") || "" + specs = { + branch: hash.dig("urls", "head", "branch"), + using: hash.dig("urls", "head", "using")&.to_sym, + }.compact_blank + [url, specs] + end + + if (keg_only_hash = hash["keg_only_reason"]) + reason = Utils.convert_to_string_or_symbol(keg_only_hash.fetch("reason")) + explanation = keg_only_hash["explanation"] + hash["keg_only_args"] = [reason, explanation].compact + end + + hash["license"] = SPDX.string_to_license_expression(hash["license"]) + + hash["link_overwrite_paths"] = hash["link_overwrite"] + + if (reason = hash["no_autobump_message"]) + reason = reason.to_sym if NO_AUTOBUMP_REASONS_LIST.key?(reason.to_sym) + hash["no_autobump_args"] = { because: reason } + end + + if (condition = hash["pour_bottle_only_if"]) + hash["pour_bottle_args"] = { only_if: condition.to_sym } + end + + hash["ruby_source_checksum"] = hash.dig("ruby_source_checksum", "sha256") + + if (service_hash = hash["service"]) + service_hash = Homebrew::Service.from_hash(service_hash) + + hash["service_run_args"], hash["service_run_kwargs"] = case (run = service_hash[:run]) + when Hash + [[], run] + when Array, String + [[run], {}] + else + [[], {}] + end + + hash["service_name_args"] = service_hash[:name] + + hash["service_args"] = service_hash.filter_map do |key, arg| + [key.to_sym, arg] if key != :name && key != :run + end + end + + hash["stable_checksum"] = hash.dig("urls", "stable", "checksum") + + hash["stable_url_args"] = begin + url = hash.dig("urls", "stable", "url") + specs = { + tag: hash.dig("urls", "stable", "tag"), + revision: hash.dig("urls", "stable", "revision"), + using: hash.dig("urls", "stable", "using")&.to_sym, + }.compact_blank + [url, specs] + end + + hash["stable_version"] = hash.dig("versions", "stable") + + # Do dependency processing last because it's more involved and depends on other fields + hash["requirements_array"] = hash["requirements"] + + stable_dependency_hash = { + "dependencies" => hash["dependencies"] || [], + "build_dependencies" => hash["build_dependencies"] || [], + "test_dependencies" => hash["test_dependencies"] || [], + "recommended_dependencies" => hash["recommended_dependencies"] || [], + "optional_dependencies" => hash["optional_dependencies"] || [], + "uses_from_macos" => hash["uses_from_macos"] || [], + "uses_from_macos_bounds" => hash["uses_from_macos_bounds"] || [], + } + + stable_dependencies, stable_uses_from_macos = process_dependencies_and_requirements( + stable_dependency_hash, + hash["requirements_array"], + :stable, + ) + + # hash["head_dependencies"] is always present unless the stable and head deps are identical + head_dependency_hash = hash["head_dependencies"] || stable_dependency_hash + head_dependencies, head_uses_from_macos = process_dependencies_and_requirements( + head_dependency_hash, + hash["requirements_array"], + :head, + ) + + hash["stable_dependencies"] = stable_dependencies + hash["stable_patches"] = hash["patches"] || [] + hash["stable_uses_from_macos"] = stable_uses_from_macos + hash["head_dependencies"] = head_dependencies + hash["head_uses_from_macos"] = head_uses_from_macos + + # Should match FormulaStruct::PREDICATES + hash["bottle_present"] = hash["bottle"].present? + hash["deprecate_present"] = hash["deprecate_args"].present? + hash["disable_present"] = hash["disable_args"].present? + hash["head_present"] = hash.dig("urls", "head").present? + hash["keg_only_present"] = hash["keg_only_reason"].present? + hash["no_autobump_present"] = hash["no_autobump_message"].present? + hash["pour_bottle_present"] = hash["pour_bottle_only_if"].present? + hash["service_present"] = hash["service"].present? + hash["service_run_present"] = hash.dig("service", "run").present? + hash["service_name_present"] = hash.dig("service", "name").present? + hash["stable_present"] = hash.dig("urls", "stable").present? + + FormulaStruct.from_hash(hash) + end + + sig { + params(deps_hash: T.nilable(DependencyHash), requirements_array: T.nilable(RequirementsArray), spec: Symbol) + .returns([T::Array[FormulaStruct::DependsOnArgs], T::Array[FormulaStruct::UsesFromMacOSArgs]]) + } + def process_dependencies_and_requirements(deps_hash, requirements_array, spec) + deps, uses_from_macos = if deps_hash.present? + deps_hash = symbolize_dependency_hash(deps_hash) + [process_dependencies(deps_hash), process_uses_from_macos(deps_hash)] + else + [[], []] + end + + requirements = if requirements_array.present? + process_requirements(requirements_array, spec) + else + [] + end + + [deps + requirements, uses_from_macos] + end + + # Convert from { "dependencies" => ["foo", { "bar" => "build" }, { "baz" => ["build", "test"] }] } + # to { "dependencies" => ["foo", { "bar" => :build }, { "baz" => [:build, :test] }] } + sig { params(hash: DependencyHash).returns(DependencyHash) } + def symbolize_dependency_hash(hash) + hash = hash.dup + + if (uses_from_macos_bounds = hash["uses_from_macos_bounds"]) + uses_from_macos_bounds = + T.cast(uses_from_macos_bounds, T::Array[T::Hash[T.any(String, Symbol), T.any(String, Symbol)]]) + hash["uses_from_macos_bounds"] = uses_from_macos_bounds.map do |bound| + bound.to_h { |key, value| [key.to_sym, value.to_sym] } + end + end + + hash.transform_values do |deps| + deps.map do |dep| + next dep unless dep.is_a?(Hash) + + dep.transform_values do |types| + case types + when Array + types.map(&:to_sym) + else + types.to_sym + end + end + end + end + end + + sig { params(deps_hash: DependencyHash).returns(T::Array[FormulaStruct::DependsOnArgs]) } + def process_dependencies(deps_hash) + dependencies = deps_hash.fetch("dependencies", []) + dependencies + [:build, :test, :recommended, :optional].filter_map do |type| + deps_hash["#{type}_dependencies"]&.map do |dep| + { dep => type } + end + end.flatten(1) + end + + sig { params(requirements_array: RequirementsArray, spec: Symbol).returns(T::Array[FormulaStruct::DependsOnArgs]) } + def process_requirements(requirements_array, spec) + requirements_array.filter_map do |req| + next unless req["specs"].include?(spec.to_s) + + req_name = req["name"].to_sym + next if API_SUPPORTED_REQUIREMENTS.exclude?(req_name) + + req_version = case req_name + when :arch + req["version"]&.to_sym + when :macos, :maximum_macos + MacOSVersion::SYMBOLS.key(req["version"]) + else + req["version"] + end + + req_tags = [] + req_tags << req_version if req_version.present? + req_tags += req.fetch("contexts", []).map do |tag| + case tag + when String + tag.to_sym + when Hash + tag.deep_transform_keys(&:to_sym) + else + tag + end + end + + if req_tags.empty? + req_name + else + { req_name => req_tags } + end + end + end + + sig { params(deps_hash: DependencyHash).returns(T::Array[FormulaStruct::UsesFromMacOSArgs]) } + def process_uses_from_macos(deps_hash) + uses_from_macos = deps_hash.fetch("uses_from_macos", []) + + uses_from_macos_bounds = deps_hash.fetch("uses_from_macos_bounds", []) + uses_from_macos_bounds = T.cast(uses_from_macos_bounds, T::Array[T::Hash[Symbol, Symbol]]) + + uses_from_macos.zip(uses_from_macos_bounds).map do |entry, bounds| + bounds ||= {} + bounds = bounds.transform_keys(&:to_sym).transform_values(&:to_sym) + + if entry.is_a?(Hash) + # The key is the dependency name, the value is the dep type. Only the type should be a symbol + entry = entry.deep_transform_values(&:to_sym) + # When passing both a dep type and bounds, uses_from_macos expects them both in the first argument + entry = entry.merge(bounds) + [entry, {}] + else + [entry, bounds] + end + end + end + end + end + end +end diff --git a/Library/Homebrew/api/formula_bottle.rb b/Library/Homebrew/api/formula_bottle.rb new file mode 100644 index 0000000000000..f72c886461a6f --- /dev/null +++ b/Library/Homebrew/api/formula_bottle.rb @@ -0,0 +1,46 @@ +# typed: strict +# frozen_string_literal: true + +require "api/formula_struct" +require "bottle" +require "bottle_specification" +require "pkg_version" + +module Homebrew + module API + module FormulaBottle + sig { + params( + name: String, + formula_struct: Homebrew::API::FormulaStruct, + bottle_tag: Utils::Bottles::Tag, + ).returns(T.nilable(::Bottle)) + } + def self.bottle(name:, formula_struct:, bottle_tag: Utils::Bottles.tag) + return unless formula_struct.stable? + return unless formula_struct.bottle? + + bottle_specification = BottleSpecification.new + bottle_specification.root_url( + if Homebrew::EnvConfig.bottle_domain == HOMEBREW_BOTTLE_DEFAULT_DOMAIN + HOMEBREW_BOTTLE_DEFAULT_DOMAIN + else + Homebrew::EnvConfig.bottle_domain + end, + ) + bottle_specification.rebuild(formula_struct.bottle_rebuild) + formula_struct.bottle_checksums.each { |args| bottle_specification.sha256(args) } + + return unless bottle_specification.tag?(bottle_tag) + + ::Bottle.new( + nil, + bottle_specification, + bottle_tag, + name:, + pkg_version: PkgVersion.new(::Version.new(formula_struct.stable_version), formula_struct.revision), + ) + end + end + end +end diff --git a/Library/Homebrew/api/formula_struct.rb b/Library/Homebrew/api/formula_struct.rb new file mode 100644 index 0000000000000..7f79f0771fb11 --- /dev/null +++ b/Library/Homebrew/api/formula_struct.rb @@ -0,0 +1,254 @@ +# typed: strict +# frozen_string_literal: true + +require "service" +require "utils/spdx" +require "install_steps" + +module Homebrew + module API + class FormulaStruct < T::Struct + sig { params(formula_hash: T::Hash[String, T.untyped]).returns(FormulaStruct) } + def self.from_hash(formula_hash) + formula_hash = ::Formula.deep_remove_placeholders(formula_hash) + formula_hash = formula_hash.transform_keys(&:to_sym) + .slice(*decorator.all_props) + .compact_blank + new(**formula_hash) + end + + PREDICATES = [ + :bottle, + :deprecate, + :disable, + :head, + :keg_only, + :no_autobump, + :pour_bottle, + :service, + :service_run, + :service_name, + :stable, + ].freeze + + SKIP_SERIALIZATION = [ + # Bottle checksums have special serialization done by the serialize_bottle method + :bottle_checksums, + ].freeze + + SPECS = [:head, :stable].freeze + + # :any_skip_relocation is the most common in homebrew/core + DEFAULT_CELLAR = :any_skip_relocation + + DependsOnArgs = T.type_alias do + T.any( + # Dependencies + T.any( + # Formula name: "foo" + String, + # Formula name and dependency type: { "foo" => :build } + T::Hash[String, Symbol], + ), + # Requirements + T.any( + # Requirement name: :macos + Symbol, + # Requirement name and other info: { macos: :build } + T::Hash[Symbol, T::Array[T.anything]], + ), + ) + end + + UsesFromMacOSArgs = T.type_alias do + [ + T.any( + # Formula name: "foo" + String, + # Formula name and dependency type: { "foo" => :build } + # Formula name, dependency type, and version bounds: { "foo" => :build, since: :catalina } + T::Hash[T.any(String, Symbol), T.any(Symbol, T::Array[Symbol])], + ), + # If the first argument is only a name, this argument contains the version bounds: { since: :catalina } + T::Hash[Symbol, Symbol], + ] + end + + PREDICATES.each do |predicate_name| + present_method_name = :"#{predicate_name}_present" + predicate_method_name = :"#{predicate_name}?" + + const present_method_name, T::Boolean, default: false + + define_method(predicate_method_name) do + send(present_method_name) + end + end + + # Changes to this struct must be mirrored in Homebrew::API::Formula.generate_formula_struct_hash + const :aliases, T::Array[String], default: [] + const :bottle_checksums, T::Array[T::Hash[Symbol, T.any(String, Symbol)]], default: [] + const :bottle_rebuild, Integer, default: 0 + const :caveats, T.nilable(String) + const :conflicts, T::Array[[String, T::Hash[Symbol, String]]], default: [] + const :deprecate_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {} + const :desc, String + const :disable_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {} + const :executables, T::Array[String], default: [] + const :head_dependencies, T::Array[DependsOnArgs], default: [] + const :head_url_args, [String, T::Hash[Symbol, T.anything]], default: ["", {}] + const :head_uses_from_macos, T::Array[UsesFromMacOSArgs], default: [] + const :homepage, String + const :keg_only_args, T::Array[T.any(String, Symbol)], default: [] + const :license, SPDX::LicenseExpression + const :link_overwrite_paths, T::Array[String], default: [] + const :no_autobump_args, T::Hash[Symbol, T.any(String, Symbol)], default: {} + const :oldnames, T::Array[String], default: [] + const :post_install_defined, T::Boolean, default: false + const :post_install_steps, Homebrew::InstallSteps::Steps, default: [] + const :pour_bottle_args, T::Hash[Symbol, Symbol], default: {} + const :revision, Integer, default: 0 + const :ruby_source_checksum, String + const :service_args, T::Array[[Symbol, BasicObject]], default: [] + const :service_name_args, T::Hash[Symbol, String], default: {} + const :service_run_args, T::Array[Homebrew::Service::RunParam], default: [] + const :service_run_kwargs, T::Hash[Symbol, Homebrew::Service::RunParam], default: {} + const :stable_dependencies, T::Array[DependsOnArgs], default: [] + const :stable_patches, T::Array[T::Hash[T.any(String, Symbol), T.untyped]], default: [] + const :stable_checksum, T.nilable(String) + const :stable_url_args, [String, T::Hash[Symbol, T.anything]], default: ["", {}] + const :stable_uses_from_macos, T::Array[UsesFromMacOSArgs], default: [] + const :stable_version, String + const :version_scheme, Integer, default: 0 + const :versioned_formulae, T::Array[String], default: [] + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + case other + when FormulaStruct + serialize == other.serialize + else + false + end + end + + sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T.nilable(T::Hash[String, T.untyped])) } + def serialize_bottle(bottle_tag: ::Utils::Bottles.tag) + bottle_collector = ::Utils::Bottles::Collector.new + bottle_checksums.each do |bottle_info| + bottle_info = bottle_info.dup + cellar = bottle_info.delete(:cellar) || :any + tag = T.must(bottle_info.keys.first) + checksum = T.cast(bottle_info.values.first, String) + + bottle_collector.add( + ::Utils::Bottles::Tag.from_symbol(tag), + checksum: Checksum.new(checksum), + cellar:, + ) + end + return unless (bottle_spec = bottle_collector.specification_for(bottle_tag)) + + tag = (bottle_spec.tag if bottle_spec.tag != bottle_tag) + cellar = (bottle_spec.cellar if bottle_spec.cellar != DEFAULT_CELLAR) + + { + "bottle_tag" => tag&.to_sym, + "bottle_cellar" => cellar, + "bottle_checksum" => bottle_spec.checksum.to_s, + } + end + + sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) } + def serialize(bottle_tag: ::Utils::Bottles.tag) + hash = self.class.decorator.all_props.filter_map do |prop| + next if PREDICATES.any? { |predicate| prop == :"#{predicate}_present" } + next if SKIP_SERIALIZATION.include?(prop) + + [prop.to_s, send(prop)] + end.to_h + + if (bottle_hash = serialize_bottle(bottle_tag:)) + hash = hash.merge(bottle_hash) + end + + hash = ::Utils.deep_stringify_symbols(hash) + + service_args = hash["service_args"] + hash = ::Utils.deep_compact_blank(hash) + + # service_args may have falsey values that we don't want to remove, like `keep_alive successful_exit: false` + hash["service_args"] = service_args if service_args&.any? + + hash + end + + sig { params(hash: T::Hash[String, T.untyped], bottle_tag: ::Utils::Bottles::Tag).returns(FormulaStruct) } + def self.deserialize(hash, bottle_tag: ::Utils::Bottles.tag) + hash = ::Utils.deep_unstringify_symbols(hash) + + # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below + PREDICATES.each do |name| + hash["#{name}_present"] = hash["#{name}_args"].present? + end + + if (bottle_checksum = hash["bottle_checksum"]) + tag = hash.fetch("bottle_tag", bottle_tag.to_sym) + cellar = hash.fetch("bottle_cellar", DEFAULT_CELLAR) + + hash["bottle_present"] = true + hash["bottle_checksums"] = [{ cellar: cellar, tag => bottle_checksum }] + else + hash["bottle_present"] = false + end + + # *_url_args need to be in [String, Hash] format, but the hash may have been dropped if empty + SPECS.each do |key| + if (url_args = hash["#{key}_url_args"]) + hash["#{key}_present"] = true + hash["#{key}_url_args"] = format_arg_pair(url_args, last: {}) + else + hash["#{key}_present"] = false + end + + next unless (uses_from_macos = hash["#{key}_uses_from_macos"]) + + hash["#{key}_uses_from_macos"] = uses_from_macos.map do |args| + format_arg_pair(args, last: {}) + end + end + + hash["conflicts"] = if (conflicts = hash["conflicts"]) + conflicts.map { |conflict| format_arg_pair(conflict, last: {}) } + end + + from_hash(hash) + end + + # Format argument pairs into proper [first, last] format if serialization has removed some elements. + # Pass a default value for last to be used when only one element is present. + # + # format_arg_pair(["foo"], last: {}) # => ["foo", {}] + # format_arg_pair([{ "foo" => :build }], last: {}) # => [{ "foo" => :build }, {}] + # format_arg_pair(["foo", { since: :catalina }], last: {}) # => ["foo", { since: :catalina }] + sig { + type_parameters(:U, :V) + .params( + args: T.any([T.type_parameter(:U)], [T.type_parameter(:U), T.type_parameter(:V)]), + last: T.type_parameter(:V), + ).returns([T.type_parameter(:U), T.type_parameter(:V)]) + } + def self.format_arg_pair(args, last:) + args = case args + in [elem] + [elem, last] + in [elem1, elem2] + [elem1, elem2] + end + + # The case above is exhaustive so args will never be nil, but sorbet cannot infer that. + T.must(args) + end + end + end +end diff --git a/Library/Homebrew/api/homebrew-1.pem b/Library/Homebrew/api/homebrew-1.pem new file mode 100644 index 0000000000000..ba15b24146a52 --- /dev/null +++ b/Library/Homebrew/api/homebrew-1.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyKoOYzp1rhwXISRi61BY +XBEr2PalSK8lEVOL2USy7mpy0OubOlFyujawyQcBcCn+uPOJ/WaK+POhNWcLLoiK +L2m8GViaQm7SMwdLKUXFgKSPHcG/1m6Vu+TNBKTfQqT60PjEYIrn5NW9ZrM0cUhK +REmsbeAMBevdSaW9UwY9iIhprrgovvT8SzKhF8ZOIZKXfJX4VNk0y/7VJYNuGGqH +3npxV7OKd4yTGRGqFcC9kJ84me3thiu0yqlOjASmfWIwIwcfp4j6BEM2LuqKd7yX +h51/O+MTthkuxV36moDKfdgdOFsvlCFkziaYLScCX9lOlmZHtOfJTAOXxTmM7qGr +wTGK0vhvTi8k9dBmH/dccredQBtPOfM/FEdeyakGLoTcDguiBS/4El3I2KtF6B2h +OGoBumR915/cI4drr5yPMduZ7gjs7ZEZnVkeVzic24TfUHpnOYzrhucNJtHMBDj9 +6d1Gk82AhtuF9KlusLmCb6qXCWQSp/A4RZpN37E/p9q8rLp/7B/zp8X2TVvecPNy +BdMagdktdEqK7WPlYMcUp56JaOph8vqYoU+oGyCpWoLvcXFb75o4eefuu6Rs5SyM +c9JCCJ0DDFPjCRFnGPkvsKxFCzMFqH1jpWH0RQIrgmNVM5PO84iRH9YJsSPQzpMj +KvK/ZH4YgR9wNkBNagFo7lsCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/Library/Homebrew/api/internal.rb b/Library/Homebrew/api/internal.rb new file mode 100644 index 0000000000000..a366886d73234 --- /dev/null +++ b/Library/Homebrew/api/internal.rb @@ -0,0 +1,221 @@ +# typed: strict +# frozen_string_literal: true + +require "cachable" +require "api" +require "api/source_download" +require "download_queue" + +module Homebrew + module API + # Helper functions for using the JSON internal API. + module Internal + extend T::Generic + extend Cachable + + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[String, T.untyped] } } + # rubocop:enable Style/MutableConstant + + private_class_method :cache + + sig { returns(Utils::Bottles::Tag) } + private_class_method def self.effective_tag + @effective_tag ||= T.let(SimulateSystem.current_tag, T.nilable(Utils::Bottles::Tag)) + end + + sig { returns(Utils::Bottles::Tag) } + private_class_method def self.fallback_tag + effective_tag + end + + sig { returns(String) } + private_class_method def self.packages_endpoint + "internal/packages.#{effective_tag}.jws.json" + end + + sig { params(name: String).returns(Homebrew::API::FormulaStruct) } + def self.formula_struct(name) + return cache["formula_structs"][name] if cache.key?("formula_structs") && cache["formula_structs"].key?(name) + + hash = formula_hashes[name] + raise "No formula found for #{name}" unless hash + + struct = Homebrew::API::FormulaStruct.deserialize(hash, bottle_tag: effective_tag) + + cache["formula_structs"] ||= {} + cache["formula_structs"][name] = struct + + struct + end + + sig { params(name: String).returns(Homebrew::API::CaskStruct) } + def self.cask_struct(name) + return cache["cask_structs"][name] if cache.key?("cask_structs") && cache["cask_structs"].key?(name) + + hash = cask_hashes[name] + raise "No cask found for #{name}" unless hash + + struct = Homebrew::API::CaskStruct.deserialize(hash) + + cache["cask_structs"] ||= {} + cache["cask_structs"][name] = struct + + struct + end + + sig { returns(Pathname) } + def self.cached_packages_json_file_path + HOMEBREW_CACHE_API/packages_endpoint + end + + sig { + params(download_queue: Homebrew::DownloadQueue, stale_seconds: T.nilable(Integer), enqueue: T::Boolean) + .returns([T::Hash[String, T.untyped], T::Boolean]) + } + def self.fetch_packages_api!(download_queue: Homebrew.default_download_queue, stale_seconds: nil, + enqueue: false) + old_failed = Homebrew.failed? + json_contents, updated = begin + Homebrew::API.fetch_json_api_file(packages_endpoint, stale_seconds:, download_queue:, enqueue:) + rescue ErrorDuringExecution => e + raise if e.stderr.exclude?("HTTP status: 404") || effective_tag == fallback_tag + + @effective_tag = fallback_tag + Homebrew.failed = old_failed + retry + end + + [T.cast(json_contents, T::Hash[String, T.untyped]), updated] + end + + sig { returns(T::Boolean) } + def self.download_and_cache_data! + json_contents, updated = fetch_packages_api! + cache["formula_structs"] = {} + cache["cask_structs"] = {} + cache["formula_aliases"] = json_contents["formula_aliases"] + cache["formula_renames"] = json_contents["formula_renames"] + cache["cask_renames"] = json_contents["cask_renames"] + cache["formula_tap_git_head"] = json_contents["formula_tap_git_head"] + cache["cask_tap_git_head"] = json_contents["cask_tap_git_head"] + cache["formula_tap_migrations"] = json_contents["formula_tap_migrations"] + cache["cask_tap_migrations"] = json_contents["cask_tap_migrations"] + cache["formula_hashes"] = json_contents["formulae"] + cache["cask_hashes"] = json_contents["casks"] + + updated + end + private_class_method :download_and_cache_data! + + sig { params(regenerate: T::Boolean).void } + def self.write_formula_names_and_aliases(regenerate: false) + download_and_cache_data! unless cache.key?("formula_hashes") + + Homebrew::API.write_names_file!(formula_hashes.keys, "formula", regenerate:) + Homebrew::API.write_aliases_file!(formula_aliases, "formula", regenerate:) + Homebrew::API.write_executables_file!(formula_hashes, regenerate:) + end + + sig { params(regenerate: T::Boolean).void } + def self.write_cask_names(regenerate: false) + download_and_cache_data! unless cache.key?("cask_hashes") + + Homebrew::API.write_names_file!(cask_hashes.keys, "cask", regenerate:) + end + + sig { returns(T::Hash[String, T::Hash[String, T.untyped]]) } + def self.formula_hashes + unless cache.key?("formula_hashes") + updated = download_and_cache_data! + write_formula_names_and_aliases(regenerate: updated) + end + + cache["formula_hashes"] + end + + sig { returns(T::Hash[String, String]) } + def self.formula_aliases + unless cache.key?("formula_aliases") + updated = download_and_cache_data! + write_formula_names_and_aliases(regenerate: updated) + end + + cache["formula_aliases"] + end + + sig { returns(T::Hash[String, String]) } + def self.formula_renames + unless cache.key?("formula_renames") + updated = download_and_cache_data! + write_formula_names_and_aliases(regenerate: updated) + end + + cache["formula_renames"] + end + + sig { returns(T::Hash[String, String]) } + def self.formula_tap_migrations + unless cache.key?("formula_tap_migrations") + updated = download_and_cache_data! + write_formula_names_and_aliases(regenerate: updated) + end + + cache["formula_tap_migrations"] + end + + sig { returns(String) } + def self.formula_tap_git_head + unless cache.key?("formula_tap_git_head") + updated = download_and_cache_data! + write_formula_names_and_aliases(regenerate: updated) + end + + cache["formula_tap_git_head"] + end + + sig { returns(T::Hash[String, T::Hash[String, T.untyped]]) } + def self.cask_hashes + unless cache.key?("cask_hashes") + updated = download_and_cache_data! + write_cask_names(regenerate: updated) + end + + cache["cask_hashes"] + end + + sig { returns(T::Hash[String, String]) } + def self.cask_renames + unless cache.key?("cask_renames") + updated = download_and_cache_data! + write_cask_names(regenerate: updated) + end + + cache["cask_renames"] + end + + sig { returns(T::Hash[String, String]) } + def self.cask_tap_migrations + unless cache.key?("cask_tap_migrations") + updated = download_and_cache_data! + write_cask_names(regenerate: updated) + end + + cache["cask_tap_migrations"] + end + + sig { returns(String) } + def self.cask_tap_git_head + unless cache.key?("cask_tap_git_head") + updated = download_and_cache_data! + write_cask_names(regenerate: updated) + end + + cache["cask_tap_git_head"] + end + end + end +end + +require "extend/os/api/internal" diff --git a/Library/Homebrew/api/json_download.rb b/Library/Homebrew/api/json_download.rb new file mode 100644 index 0000000000000..319bd0221d79f --- /dev/null +++ b/Library/Homebrew/api/json_download.rb @@ -0,0 +1,55 @@ +# typed: strict +# frozen_string_literal: true + +require "downloadable" + +module Homebrew + module API + class JSONDownloadStrategy < AbstractDownloadStrategy + sig { params(url: String, name: String, version: T.nilable(T.any(String, Version)), meta: T.untyped).void } + def initialize(url, name, version, **meta) + super + @target = T.let(meta.fetch(:target), Pathname) + @stale_seconds = T.let(meta[:stale_seconds], T.nilable(Integer)) + end + + sig { override.params(timeout: T.nilable(T.any(Integer, Float))).returns(Pathname) } + def fetch(timeout: nil) + with_context quiet: quiet? do + Homebrew::API.fetch_json_api_file(url, target: cached_location, stale_seconds: meta[:stale_seconds]) + end + cached_location + end + + sig { override.returns(T.nilable(Integer)) } + def fetched_size + File.size?(cached_location) + end + + sig { override.returns(Pathname) } + def cached_location + meta.fetch(:target) + end + end + + class JSONDownload + include Downloadable + + sig { params(url: String, target: Pathname, stale_seconds: T.nilable(Integer)).void } + def initialize(url, target:, stale_seconds:) + super() + @url = T.let(URL.new(url, using: API::JSONDownloadStrategy, target:, stale_seconds:), URL) + @target = target + @stale_seconds = stale_seconds + end + + sig { override.returns(API::JSONDownloadStrategy) } + def downloader + T.cast(super, API::JSONDownloadStrategy) + end + + sig { override.returns(String) } + def download_queue_type = "JSON API" + end + end +end diff --git a/Library/Homebrew/api/source_download.rb b/Library/Homebrew/api/source_download.rb new file mode 100644 index 0000000000000..c4a1b91b5a037 --- /dev/null +++ b/Library/Homebrew/api/source_download.rb @@ -0,0 +1,53 @@ +# typed: strict +# frozen_string_literal: true + +require "downloadable" + +module Homebrew + module API + class SourceDownloadStrategy < CurlDownloadStrategy + sig { override.returns(Pathname) } + def symlink_location + cache/name + end + end + + class SourceDownload + include Downloadable + + sig { + params( + url: String, + checksum: T.nilable(Checksum), + mirrors: T::Array[String], + cache: T.nilable(Pathname), + ).void + } + def initialize(url, checksum, mirrors: [], cache: nil) + super() + @url = T.let(URL.new(url, using: API::SourceDownloadStrategy), URL) + @checksum = checksum + @mirrors = mirrors + @cache = cache + end + + sig { override.returns(API::SourceDownloadStrategy) } + def downloader + T.cast(super, API::SourceDownloadStrategy) + end + + sig { override.returns(String) } + def download_queue_type = "API Source" + + sig { override.returns(Pathname) } + def cache + @cache || super + end + + sig { returns(Pathname) } + def symlink_location + downloader.symlink_location + end + end + end +end diff --git a/Library/Homebrew/api_hashable.rb b/Library/Homebrew/api_hashable.rb new file mode 100644 index 0000000000000..00d0963b7bdf7 --- /dev/null +++ b/Library/Homebrew/api_hashable.rb @@ -0,0 +1,61 @@ +# typed: strict +# frozen_string_literal: true + +# Used to substitute common paths with generic placeholders when generating JSON for the API. +module APIHashable + sig { void } + def generating_hash! + return if generating_hash? + + # Apply monkeypatches for API generation + @old_homebrew_prefix = T.let(HOMEBREW_PREFIX, T.nilable(Pathname)) + @old_homebrew_cellar = T.let(HOMEBREW_CELLAR, T.nilable(Pathname)) + @old_home = T.let(Dir.home, T.nilable(String)) + @old_git_config_global = T.let(ENV.fetch("GIT_CONFIG_GLOBAL", nil), T.nilable(String)) + Object.send(:remove_const, :HOMEBREW_PREFIX) + Object.const_set(:HOMEBREW_PREFIX, Pathname.new(HOMEBREW_PREFIX_PLACEHOLDER)) + ENV["HOME"] = HOMEBREW_HOME_PLACEHOLDER + ENV["GIT_CONFIG_GLOBAL"] = File.join(@old_home, ".gitconfig") + + @generating_hash = T.let(true, T.nilable(T::Boolean)) + end + + sig { void } + def generated_hash! + return unless generating_hash? + + # Revert monkeypatches for API generation + Object.send(:remove_const, :HOMEBREW_PREFIX) + Object.const_set(:HOMEBREW_PREFIX, @old_homebrew_prefix) + ENV["HOME"] = @old_home + ENV["GIT_CONFIG_GLOBAL"] = @old_git_config_global + + @generating_hash = false + end + + sig { returns(T::Boolean) } + def generating_hash? + @generating_hash ||= false + @generating_hash == true + end + + sig { type_parameters(:U).params(value: T.type_parameter(:U)).returns(T.type_parameter(:U)) } + def deep_remove_placeholders(value) + return value if generating_hash? + + value = case value + when Hash + value.transform_values { |v| deep_remove_placeholders(v) } + when Array + value.map { |v| deep_remove_placeholders(v) } + when String + value.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX) + .gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR) + .gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home) + else + value + end + + T.cast(value, T.type_parameter(:U)) + end +end diff --git a/Library/Homebrew/ask.rb b/Library/Homebrew/ask.rb new file mode 100644 index 0000000000000..c5431f67245b1 --- /dev/null +++ b/Library/Homebrew/ask.rb @@ -0,0 +1,36 @@ +# typed: strict +# frozen_string_literal: true + +require "io/console" +require "utils/output" + +module Homebrew + module Ask + extend Utils::Output::Mixin + + sig { params(action: String).returns(T::Boolean) } + def self.confirm?(action:) + return false if !$stdin.tty? || !$stdout.tty? + + ohai "Do you want to proceed with the #{action}? [y/n]" + loop do + result = begin + $stdin.getch + rescue Interrupt + exit 1 + end + exit 1 unless result + + result = result.chomp.strip.downcase + if result == "y" + return true + # N, Escape, Ctrl-C and Ctrl-D. + elsif ["n", "\e", "\u0003", "\u0004"].include?(result) + exit 1 + else + puts "Invalid input. Please press 'y' to proceed, or 'n' to abort." + end + end + end + end +end diff --git a/Library/Homebrew/ast_constants.rb b/Library/Homebrew/ast_constants.rb new file mode 100644 index 0000000000000..86bb168911640 --- /dev/null +++ b/Library/Homebrew/ast_constants.rb @@ -0,0 +1,58 @@ +# typed: strict +# frozen_string_literal: true + +require "macos_version" + +FORMULA_COMPONENT_PRECEDENCE_LIST = T.let([ + [{ name: :include, type: :method_call }], + [{ name: :desc, type: :method_call }], + [{ name: :homepage, type: :method_call }], + [{ name: :url, type: :method_call }], + [{ name: :mirror, type: :method_call }], + [{ name: :version, type: :method_call }], + [{ name: :sha256, type: :method_call }], + [{ name: :license, type: :method_call }], + [{ name: :revision, type: :method_call }], + [{ name: :version_scheme, type: :method_call }], + [{ name: :compatibility_version, type: :method_call }], + [{ name: :head, type: :method_call }], + [{ name: :stable, type: :block_call }], + [{ name: :livecheck, type: :block_call }], + [{ name: :no_autobump!, type: :method_call }], + [{ name: :bottle, type: :block_call }], + [{ name: :pour_bottle?, type: :block_call }], + [{ name: :head, type: :block_call }], + [{ name: :bottle, type: :method_call }], + [{ name: :keg_only, type: :method_call }], + [{ name: :option, type: :method_call }], + [{ name: :deprecated_option, type: :method_call }], + [{ name: :deprecate!, type: :method_call }], + [{ name: :disable!, type: :method_call }], + [{ name: :depends_on, type: :method_call }], + [{ name: :uses_from_macos, type: :method_call }], + [{ name: :on_macos, type: :block_call }], + *MacOSVersion::SYMBOLS.keys.map do |os_name| + [{ name: :"on_#{os_name}", type: :block_call }] + end, + [{ name: :on_system, type: :block_call }], + [{ name: :on_linux, type: :block_call }], + [{ name: :on_arm, type: :block_call }], + [{ name: :on_intel, type: :block_call }], + [{ name: :conflicts_with, type: :method_call }], + [{ name: :preserve_rpath, type: :method_call }], + [{ name: :skip_clean, type: :method_call }], + [{ name: :cxxstdlib_check, type: :method_call }], + [{ name: :link_overwrite, type: :method_call }], + [{ name: :fails_with, type: :method_call }, { name: :fails_with, type: :block_call }], + [{ name: :pypi_packages, type: :method_call }], + [{ name: :resource, type: :block_call }], + [{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }], + [{ name: :needs, type: :method_call }], + [{ name: :allow_network_access!, type: :method_call }], + [{ name: :deny_network_access!, type: :method_call }], + [{ name: :install, type: :method_definition }], + [{ name: :post_install, type: :method_definition }], + [{ name: :caveats, type: :method_definition }], + [{ name: :plist_options, type: :method_call }, { name: :plist, type: :method_definition }], + [{ name: :test, type: :block_call }], +].freeze, T::Array[T::Array[{ name: Symbol, type: Symbol }]]) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb new file mode 100644 index 0000000000000..9ec91cd6ea678 --- /dev/null +++ b/Library/Homebrew/attestation.rb @@ -0,0 +1,268 @@ +# typed: strict +# frozen_string_literal: true + +require "date" +require "json" +require "utils/popen" +require "utils/github/api" +require "exceptions" +require "system_command" +require "utils/output" + +module Homebrew + module Attestation + extend SystemCommand::Mixin + extend Utils::Output::Mixin + + # @api private + HOMEBREW_CORE_REPO = "Homebrew/homebrew-core" + + # @api private + BACKFILL_REPO = "trailofbits/homebrew-brew-verify" + + # No backfill attestations after this date are considered valid. + # + # This date is shortly after the backfill operation for homebrew-core + # completed, as can be seen here: . + # + # In effect, this means that, even if an attacker is able to compromise the backfill + # signing workflow, they will be unable to convince a verifier to accept their newer, + # malicious backfilled signatures. + # + # @api private + BACKFILL_CUTOFF = DateTime.new(2024, 3, 14).freeze + + # Raised when the attestation was not found. + # + # @api private + class MissingAttestationError < RuntimeError; end + + # Raised when attestation verification fails. + # + # @api private + class InvalidAttestationError < RuntimeError; end + + # Raised if attestation verification cannot continue due to missing + # credentials. + # + # @api private + class GhAuthNeeded < RuntimeError; end + + # Raised if attestation verification cannot continue due to invalid + # credentials. + # + # @api private + class GhAuthInvalid < RuntimeError; end + + # Raised if attestation verification cannot continue due to `gh` + # being incompatible with attestations, typically because it's too old. + # + # @api private + class GhIncompatible < RuntimeError; end + + # Returns a path to a suitable `gh` executable for attestation verification. + # + # @api private + sig { returns(Pathname) } + def self.gh_executable + @gh_executable ||= T.let(nil, T.nilable(Pathname)) + return @gh_executable if @gh_executable + + # NOTE: We set HOMEBREW_NO_VERIFY_ATTESTATIONS when installing `gh` itself, + # to prevent a cycle during bootstrapping. This can eventually be resolved + # by vendoring a pure-Ruby Sigstore verifier client. + @gh_executable = with_env(HOMEBREW_NO_VERIFY_ATTESTATIONS: "1") do + ensure_executable!("gh", reason: "verifying attestations", latest: true) + end + end + + # Prioritize installing `gh` first if it's in the formula list + # or check for the existence of the `gh` executable elsewhere. + # + # This ensures that a valid version of `gh` is installed before + # we use it to check the attestations of any other formulae we + # want to install. + # + # @api private + sig { params(formulae: T::Array[Formula]).returns(T::Array[Formula]) } + def self.sort_formulae_for_install(formulae) + if (gh = formulae.find { |f| f.full_name == "gh" }) + [gh] | formulae + else + Homebrew::Attestation.gh_executable + formulae + end + end + + # Verifies the given bottle against a cryptographic attestation of build provenance. + # + # The provenance is verified as originating from `signing_repository`, which is a `String` + # that should be formatted as a GitHub `owner/repository`. + # + # Callers may additionally pass in `signing_workflow`, which will scope the attestation + # down to an exact GitHub Actions workflow, in + # `https://github/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF` format. + # + # @return [Hash] the JSON-decoded response. + # @raise [GhAuthNeeded] on any authentication failures + # @raise [InvalidAttestationError] on any verification failures + # + # @api private + sig { + params(bottle: Bottle, signing_repo: String, + signing_workflow: T.nilable(String), subject: T.nilable(String)).returns(T::Hash[String, T.untyped]) + } + def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) + cmd = ["attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", + "json"] + + cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? + + # Fail early if we have no credentials. The command below invariably + # fails without them, so this saves us an unnecessary subshell. + credentials = GitHub::API.credentials + raise GhAuthNeeded, "missing credentials" if credentials.blank? + + begin + result = system_command!(gh_executable, args: cmd, + env: { "GH_TOKEN" => credentials, "GH_HOST" => "github.com" }, + secrets: [credentials], print_stderr: false, chdir: HOMEBREW_TEMP) + rescue ErrorDuringExecution => e + if e.exitstatus == 1 && e.stderr.include?("unknown command") + raise GhIncompatible, "gh CLI is incompatible with attestations" + end + + # Even if we have credentials, they may be invalid or malformed. + if e.exitstatus == 4 || e.stderr.include?("HTTP 401: Bad credentials") + raise GhAuthInvalid, "invalid credentials" + end + + raise MissingAttestationError, "attestation not found: #{e}" if e.stderr.include?("HTTP 404: Not Found") + + raise InvalidAttestationError, "attestation verification failed: #{e}" + end + + begin + attestations = JSON.parse(result.stdout) + rescue JSON::ParserError + raise InvalidAttestationError, "attestation verification returned malformed JSON" + end + + # `gh attestation verify` returns a JSON array of one or more results, + # for all attestations that match the input's digest. We want to additionally + # filter these down to just the attestation whose subject(s) contain the bottle's name. + # As of 2024-12-04 GitHub's Artifact Attestation feature can put multiple subjects + # in a single attestation, so we check every subject in each attestation + # and select the first attestation with a matching subject. + # In particular, this happens with v2.0.0 and later of the + # `actions/attest` action. + subject = bottle.filename.to_s if subject.blank? + + attestation = if bottle.tag.to_sym == :all + # :all-tagged bottles are created by `brew bottle --merge`, and are not directly + # bound to their own filename (since they're created by deduplicating other filenames). + # To verify these, we parse each attestation subject and look for one with a matching + # formula (name, version), but not an exact tag match. + # This is sound insofar as the signature has already been verified. However, + # longer term, we should also directly attest to `:all`-tagged bottles. + attestations.find do |a| + candidate_subjects = a.dig("verificationResult", "statement", "subject") + candidate_subjects.any? do |candidate| + candidate["name"].start_with? "#{bottle.filename.name}--#{bottle.filename.version}" + end + end + else + attestations.find do |a| + candidate_subjects = a.dig("verificationResult", "statement", "subject") + candidate_subjects.any? { |candidate| candidate["name"] == subject } + end + end + + raise InvalidAttestationError, "no attestation matches subject: #{subject}" if attestation.blank? + + attestation + end + + ATTESTATION_MAX_RETRIES = 5 + + # Verifies the given bottle against a cryptographic attestation of build provenance + # from homebrew-core's CI, falling back on a "backfill" attestation for older bottles. + # + # This is a specialization of `check_attestation` for homebrew-core. + # + # @return [Hash] the JSON-decoded response + # @raise [GhAuthNeeded] on any authentication failures + # @raise [InvalidAttestationError] on any verification failures + # + # @api private + sig { params(bottle: Bottle).returns(T::Hash[String, T.untyped]) } + def self.check_core_attestation(bottle) + begin + # Ideally, we would also constrain the signing workflow here, but homebrew-core + # currently uses multiple signing workflows to produce bottles + # (e.g. `dispatch-build-bottle.yml`, `dispatch-rebottle.yml`, etc.). + # + # We could check each of these (1) explicitly (slow), (2) by generating a pattern + # to pass into `--cert-identity-regex` (requires us to build up a Go-style regex), + # or (3) by checking the resulting JSON for the expected signing workflow. + # + # Long term, we should probably either do (3) *or* switch to a single reusable + # workflow, which would then be our sole identity. However, GitHub's + # attestations currently do not include reusable workflow state by default. + attestation = check_attestation bottle, HOMEBREW_CORE_REPO + return attestation + rescue MissingAttestationError + odebug "falling back on backfilled attestation for #{bottle.filename}" + + # Our backfilled attestation is a little unique: the subject is not just the bottle + # filename, but also has the bottle's hosted URL hash prepended to it. + # This was originally unintentional, but has a virtuous side effect of further + # limiting domain separation on the backfilled signatures (by committing them to + # their original bottle URLs). + url_sha256 = if EnvConfig.bottle_domain == HOMEBREW_BOTTLE_DEFAULT_DOMAIN + Digest::SHA256.hexdigest(bottle.url) + else + # If our bottle is coming from a mirror, we need to recompute the expected + # non-mirror URL to make the hash match. + checksum = bottle.resource.checksum + odie "#{bottle.resource.name} checksum is nil" if checksum.nil? + path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name, + checksum, bottle.filename + url = "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/#{path}" + + Digest::SHA256.hexdigest(url) + end + subject = "#{url_sha256}--#{bottle.filename}" + + # We don't pass in a signing workflow for backfill signatures because + # some backfilled bottle signatures were signed from the 'backfill' + # branch, and others from 'main' of trailofbits/homebrew-brew-verify + # so the signing workflow is slightly different which causes some bottles to incorrectly + # fail when checking their attestation. This shouldn't meaningfully affect security + # because if somehow someone could generate false backfill attestations + # from a different workflow we will still catch it because the + # attestation would have been generated after our cutoff date. + backfill_attestation = check_attestation bottle, BACKFILL_REPO, nil, subject + timestamp = backfill_attestation.dig("verificationResult", "verifiedTimestamps", + 0, "timestamp") + + raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if timestamp.nil? + + if DateTime.parse(timestamp) > BACKFILL_CUTOFF + raise InvalidAttestationError, "backfill attestation post-dates cutoff" + end + end + + backfill_attestation + rescue InvalidAttestationError + @attestation_retry_count ||= T.let(Hash.new(0), T.nilable(T::Hash[Bottle, Integer])) + raise if @attestation_retry_count[bottle] >= ATTESTATION_MAX_RETRIES + + sleep_time = 3 ** @attestation_retry_count[bottle] + opoo "Failed to verify attestation. Retrying in #{sleep_time}s..." + sleep sleep_time if ENV["HOMEBREW_TESTS"].blank? + @attestation_retry_count[bottle] += 1 + retry + end + end +end diff --git a/Library/Homebrew/autobump_constants.rb b/Library/Homebrew/autobump_constants.rb new file mode 100644 index 0000000000000..fe72c83887c86 --- /dev/null +++ b/Library/Homebrew/autobump_constants.rb @@ -0,0 +1,24 @@ +# typed: strict +# frozen_string_literal: true + +NO_AUTOBUMP_REASONS_INTERNAL = T.let({ + extract_plist: "livecheck uses `:extract_plist` strategy", + latest_version: "`version` is set to `:latest`", +}.freeze, T::Hash[Symbol, String]) + +NO_AUTOBUMP_REASONS_DEPRECATED = T.let({ + requires_manual_review: "a manual review of this package is required for inclusion in autobump", +}.freeze, T::Hash[Symbol, String]) + +# The valid symbols for passing to `no_autobump!` in a `Formula` or `Cask`. +# @api public +NO_AUTOBUMP_REASONS_LIST = T.let( + { + incompatible_version_format: "the package has a version format that can only be updated manually", + bumped_by_upstream: "updates to the package are handled by the upstream developers", + } + .merge(NO_AUTOBUMP_REASONS_INTERNAL) + .merge(NO_AUTOBUMP_REASONS_DEPRECATED) + .freeze, + T::Hash[Symbol, String], +) diff --git a/Library/Homebrew/bottle.rb b/Library/Homebrew/bottle.rb new file mode 100644 index 0000000000000..03bb608d7e142 --- /dev/null +++ b/Library/Homebrew/bottle.rb @@ -0,0 +1,328 @@ +# typed: strict +# frozen_string_literal: true + +class Bottle + include Downloadable + + class Filename + sig { returns(String) } + attr_reader :name, :tag + + sig { returns(PkgVersion) } + attr_reader :version + + sig { returns(Integer) } + attr_reader :rebuild + + sig { params(formula: Formula, tag: Utils::Bottles::Tag, rebuild: Integer).returns(T.attached_class) } + def self.create(formula, tag, rebuild) + new(formula.name, formula.pkg_version, tag, rebuild) + end + + sig { params(name: String, version: PkgVersion, tag: Utils::Bottles::Tag, rebuild: Integer).void } + def initialize(name, version, tag, rebuild) + @name = T.let(File.basename(name), String) + + raise ArgumentError, "Invalid bottle name" unless Utils.safe_filename?(@name) + raise ArgumentError, "Invalid bottle version" unless Utils.safe_filename?(version.to_s) + + @version = version + @tag = T.let(tag.to_unstandardized_sym.to_s, String) + @rebuild = rebuild + end + + sig { returns(String) } + def to_str + "#{name}--#{version}#{extname}" + end + + sig { returns(String) } + def to_s = to_str + + sig { returns(String) } + def json + "#{name}--#{version}.#{tag}.bottle.json" + end + + sig { returns(String) } + def url_encode + ERB::Util.url_encode("#{name}-#{version}#{extname}") + end + + sig { returns(String) } + def github_packages + "#{name}--#{version}#{extname}" + end + + sig { returns(String) } + def extname + s = rebuild.positive? ? ".#{rebuild}" : "" + ".#{tag}.bottle#{s}.tar.gz" + end + end + + extend Forwardable + + sig { returns(String) } + attr_reader :name + + sig { returns(Resource) } + attr_reader :resource + + sig { returns(Utils::Bottles::Tag) } + attr_reader :tag + + sig { returns(T.any(String, Symbol)) } + attr_reader :cellar + + sig { returns(Integer) } + attr_reader :rebuild + + def_delegators :resource, :url, :verify_download_integrity + def_delegators :resource, :cached_download, :downloader + + sig { + params( + formula: T.nilable(Formula), + spec: BottleSpecification, + tag: T.nilable(Utils::Bottles::Tag), + name: T.nilable(String), + pkg_version: T.nilable(PkgVersion), + ).void + } + def initialize(formula, spec, tag = nil, name: nil, pkg_version: nil) + super() + + resource_name = T.let(nil, T.nilable(String)) + if formula + name = formula.name + pkg_version = formula.pkg_version + else + raise ArgumentError, "Bottle name is required" if name.nil? + raise ArgumentError, "Bottle version is required" if pkg_version.nil? + + resource_name = name + end + + @name = T.let(name, String) + @pkg_version = T.let(pkg_version, PkgVersion) + @resource = T.let(Resource.new(resource_name), Resource) + @resource.owner = formula if formula + @spec = spec + + tag_spec = spec.tag_specification_for(Utils::Bottles.tag(tag)) + + odie "#{@name} tag specification for tag #{tag} is nil" if tag_spec.nil? + + @tag = T.let(tag_spec.tag, Utils::Bottles::Tag) + @cellar = T.let(tag_spec.cellar, T.any(String, Symbol)) + @rebuild = T.let(spec.rebuild, Integer) + + @resource.version(@pkg_version.to_s) + @resource.checksum = tag_spec.checksum + + @fetch_tab_retried = T.let(false, T::Boolean) + + root_url(spec.root_url, spec.root_url_specs) + end + + sig { + override.params( + verify_download_integrity: T::Boolean, + timeout: T.nilable(T.any(Integer, Float)), + quiet: T::Boolean, + ).returns(Pathname) + } + def fetch(verify_download_integrity: true, timeout: nil, quiet: false) + resource.fetch(verify_download_integrity:, timeout:, quiet:) + rescue DownloadError + raise unless fallback_on_error? + + fetch_tab + retry + end + + sig { override.returns(T::Boolean) } + def downloaded_and_valid? + return false unless cached_download.file? + + resource_checksum = resource.checksum + return false if resource_checksum.nil? + + downloader = resource.downloader + return false unless downloader.is_a?(CurlGitHubPackagesDownloadStrategy) + return false unless downloader.immutable_bottle_blob? + + downloader.bottle_blob_sha256 == resource_checksum.hexdigest + end + + sig { override.returns(T.nilable(Integer)) } + def total_size + bottle_size || super + end + + sig { override.void } + def clear_cache + @resource.clear_cache + github_packages_manifest_resource&.clear_cache + @fetch_tab_retried = false + end + + sig { returns(T::Boolean) } + def compatible_locations? + @spec.compatible_locations?(tag: @tag) + end + + # Does the bottle need to be relocated? + sig { returns(T::Boolean) } + def skip_relocation? + attrs = tab_attributes + tab = Tab.new(attrs) unless attrs.empty? + @spec.skip_relocation?(tag: @tag, tab:) + end + + sig { void } + def stage = downloader.stage + + sig { params(timeout: T.nilable(T.any(Integer, Float)), quiet: T::Boolean).void } + def fetch_tab(timeout: nil, quiet: false) + return unless (resource = github_packages_manifest_resource) + + begin + resource.fetch(timeout:, quiet:) + rescue DownloadError + raise unless fallback_on_error? + + retry + rescue Resource::BottleManifest::Error + raise if @fetch_tab_retried + + @fetch_tab_retried = true + resource.clear_cache + retry + end + end + + sig { returns(T::Hash[String, T.untyped]) } + def tab_attributes + if (resource = github_packages_manifest_resource) && resource.downloaded? + return resource.tab + end + + {} + end + + sig { returns(T.nilable(Integer)) } + def bottle_size + resource = github_packages_manifest_resource + return unless resource&.downloaded? + + resource.bottle_size + end + + sig { returns(T.nilable(Integer)) } + def installed_size + resource = github_packages_manifest_resource + return unless resource&.downloaded? + + resource.installed_size + end + + sig { returns(T.nilable(T::Array[String])) } + def path_exec_files + resource = github_packages_manifest_resource + return unless resource&.downloaded? + + resource.path_exec_files + end + + sig { returns(T.nilable(T::Hash[String, Object])) } + def sbom_supplement + resource = github_packages_manifest_resource + return unless resource&.downloaded_and_valid? + + resource.sbom_supplement + end + + sig { returns(Filename) } + def filename = Filename.new(@name, @pkg_version, @tag, @spec.rebuild) + + sig { returns(T.nilable(Resource::BottleManifest)) } + def github_packages_manifest_resource + return if @resource.download_strategy != CurlGitHubPackagesDownloadStrategy + + @github_packages_manifest_resource ||= T.let( + begin + resource = Resource::BottleManifest.new(self) + + resource_version = @resource.version + odie "resource version is nil" if resource_version.nil? + + version_rebuild = GitHubPackages.version_rebuild(resource_version, rebuild) + resource.version(version_rebuild) + + image_name = GitHubPackages.image_formula_name(@name) + image_tag = GitHubPackages.image_version_rebuild(version_rebuild) + resource.url( + "#{root_url}/#{image_name}/manifests/#{image_tag}", + using: CurlGitHubPackagesDownloadStrategy, + headers: ["Accept: application/vnd.oci.image.index.v1+json"], + ) + T.cast(resource.downloader, CurlGitHubPackagesDownloadStrategy).resolved_basename = + "#{name}-#{version_rebuild}.bottle_manifest.json" + resource + end, + T.nilable(Resource::BottleManifest), + ) + end + + sig { override.returns(String) } + def download_queue_type = "Bottle" + + sig { override.returns(String) } + def download_queue_name = "#{name} (#{resource.version})" + + private + + sig { params(specs: T::Hash[Symbol, T.anything]).returns(T::Hash[Symbol, T.anything]) } + def select_download_strategy(specs) + odie "cannot select download strategy for #{name} because root_url is nil" if @root_url.nil? + specs[:using] ||= DownloadStrategyDetector.detect(@root_url) + specs[:bottle] = true + specs + end + + sig { returns(T::Boolean) } + def fallback_on_error? + # Use the default bottle domain as a fallback mirror + if @resource.url&.start_with?(Homebrew::EnvConfig.bottle_domain) && + Homebrew::EnvConfig.bottle_domain != HOMEBREW_BOTTLE_DEFAULT_DOMAIN + opoo "Bottle missing, falling back to the default domain..." + root_url(HOMEBREW_BOTTLE_DEFAULT_DOMAIN) + @github_packages_manifest_resource = T.let(nil, T.nilable(Resource::BottleManifest)) + true + else + false + end + end + + sig { params(val: T.nilable(String), specs: T::Hash[Symbol, T.anything]).returns(T.nilable(String)) } + def root_url(val = nil, specs = {}) + return @root_url if val.nil? + + @root_url = T.let(val, T.nilable(String)) + + filename = self.filename + resource_checksum = resource.checksum + odie "resource checksum is nil" if resource_checksum.nil? + + path, resolved_basename = Utils::Bottles.path_resolved_basename(val, name, resource_checksum, filename) + @resource.url("#{val}/#{path}", **select_download_strategy(specs)) + return unless resolved_basename.present? + + downloader = @resource.downloader + return unless downloader.is_a?(CurlGitHubPackagesDownloadStrategy) + + downloader.resolved_basename = resolved_basename + end +end diff --git a/Library/Homebrew/bottle_publisher.rb b/Library/Homebrew/bottle_publisher.rb deleted file mode 100644 index 9911f992354a2..0000000000000 --- a/Library/Homebrew/bottle_publisher.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -require "utils" -require "formula_info" - -class BottlePublisher - def initialize(tap, changed_formulae_names, bintray_org, no_publish, warn_on_publish_failure) - @tap = tap - @changed_formulae_names = changed_formulae_names - @no_publish = no_publish - @bintray_org = bintray_org - @warn_on_publish_failure = warn_on_publish_failure - end - - def publish_and_check_bottles - # Formulae with affected bottles that were published - bintray_published_formulae = [] - - # Publish bottles on Bintray - unless @no_publish - published = publish_changed_formula_bottles - bintray_published_formulae.concat(published) - end - - # Verify bintray publishing after all patches have been applied - bintray_published_formulae.uniq! - verify_bintray_published(bintray_published_formulae) - end - - def publish_changed_formula_bottles - raise "Need to load formulae to publish them!" if ENV["HOMEBREW_DISABLE_LOAD_FORMULA"] - - published = [] - bintray_creds = { user: ENV["HOMEBREW_BINTRAY_USER"], key: ENV["HOMEBREW_BINTRAY_KEY"] } - if bintray_creds[:user] && bintray_creds[:key] - @changed_formulae_names.each do |name| - f = Formula[name] - next if f.bottle_unneeded? || f.bottle_disabled? - - bintray_org = @bintray_org || @tap.user.downcase - next unless publish_bottle_file_on_bintray(f, bintray_org, bintray_creds) - - published << f.full_name - end - else - opoo "You must set HOMEBREW_BINTRAY_USER and HOMEBREW_BINTRAY_KEY to add or update bottles on Bintray!" - end - published - end - - # Publishes the current bottle files for a given formula to Bintray - def publish_bottle_file_on_bintray(f, bintray_org, creds) - repo = Utils::Bottles::Bintray.repository(f.tap) - package = Utils::Bottles::Bintray.package(f.name) - info = FormulaInfo.lookup(f.full_name) - raise "Failed publishing bottle: failed reading formula info for #{f.full_name}" if info.nil? - - unless info.bottle_info_any - opoo "No bottle defined in formula #{package}" - return false - end - version = info.pkg_version - ohai "Publishing on Bintray: #{package} #{version}" - curl "--write-out", '\n', "--silent", "--fail", - "--user", "#{creds[:user]}:#{creds[:key]}", "--request", "POST", - "--header", "Content-Type: application/json", - "--data", '{"publish_wait_for_secs": 0}', - "https://api.bintray.com/content/#{bintray_org}/#{repo}/#{package}/#{version}/publish" - true - rescue => e - raise unless @warn_on_publish_failure - - onoe e - false - end - - # Verifies that formulae have been published on Bintray by downloading a bottle file - # for each one. Blocks until the published files are available. - # Raises an error if the verification fails. - # This does not currently work for `brew pull`, because it may have cached the old - # version of a formula. - def verify_bintray_published(formulae_names) - return if formulae_names.empty? - - raise "Need to load formulae to verify their publication!" if ENV["HOMEBREW_DISABLE_LOAD_FORMULA"] - - ohai "Verifying bottles published on Bintray" - formulae = formulae_names.map { |n| Formula[n] } - max_retries = 300 # shared among all bottles - poll_retry_delay_seconds = 2 - - HOMEBREW_CACHE.cd do - formulae.each do |f| - retry_count = 0 - wrote_dots = false - # Choose arbitrary bottle just to get the host/port for Bintray right - jinfo = FormulaInfo.lookup(f.full_name) - unless jinfo - opoo "Cannot publish bottle: Failed reading info for formula #{f.full_name}" - next - end - bottle_info = jinfo.bottle_info_any - unless bottle_info - opoo "No bottle defined in formula #{f.full_name}" - next - end - - # Poll for publication completion using a quick partial HEAD, to avoid spurious error messages - # 401 error is normal while file is still in async publishing process - url = URI(bottle_info["url"]) - puts "Verifying bottle: #{File.basename(url.path)}" - http = Net::HTTP.new(url.host, url.port) - http.use_ssl = true - retry_count = 0 - http.start do - loop do - req = Net::HTTP::Head.new url - req.initialize_http_header "User-Agent" => HOMEBREW_USER_AGENT_RUBY - res = http.request req - break if res.is_a?(Net::HTTPSuccess) || res.code == "302" - - unless res.is_a?(Net::HTTPClientError) - raise "Failed to find published #{f} bottle at #{url} (#{res.code} #{res.message})!" - end - - raise "Failed to find published #{f} bottle at #{url}!" if retry_count >= max_retries - - print(wrote_dots ? "." : "Waiting on Bintray.") - wrote_dots = true - sleep poll_retry_delay_seconds - retry_count += 1 - end - end - - # Actual download and verification - # We do a retry on this, too, because sometimes the external curl will fail even - # when the prior HEAD has succeeded. - puts "\n" if wrote_dots - filename = File.basename(url.path) - curl_retry_delay_seconds = 4 - max_curl_retries = 1 - retry_count = 0 - # We're in the cache; make sure to force re-download - loop do - curl_download url, to: filename - break - rescue - raise "Failed to download #{f} bottle from #{url}!" if retry_count >= max_curl_retries - - puts "curl download failed; retrying in #{curl_retry_delay_seconds} sec" - sleep curl_retry_delay_seconds - curl_retry_delay_seconds *= 2 - retry_count += 1 - end - checksum = Checksum.new(:sha256, bottle_info["sha256"]) - Pathname.new(filename).verify_checksum(checksum) - end - end - end -end diff --git a/Library/Homebrew/bottle_specification.rb b/Library/Homebrew/bottle_specification.rb new file mode 100644 index 0000000000000..6f5b1a7c8b52a --- /dev/null +++ b/Library/Homebrew/bottle_specification.rb @@ -0,0 +1,177 @@ +# typed: strict +# frozen_string_literal: true + +class BottleSpecification + include Utils::Output::Mixin + + # Relocatable cellar using placeholders, e.g. `@@HOMEBREW_PREFIX@@`. + # Requires relocating text files and binaries. + ANY_CELLAR = :any + + # Relocatable cellar using placeholders, e.g. `@@HOMEBREW_PREFIX@@`. + # Does not need to relocate binaries but still relocates text files. + ANY_SKIP_RELOCATION_CELLAR = :any_skip_relocation + + RELOCATABLE_CELLARS = T.let([ANY_CELLAR, ANY_SKIP_RELOCATION_CELLAR].freeze, T::Array[Symbol]) + + sig { returns(T.nilable(Tap)) } + attr_accessor :tap + + sig { returns(Utils::Bottles::Collector) } + attr_reader :collector + + sig { returns(T::Hash[Symbol, T.untyped]) } + attr_reader :root_url_specs + + sig { returns(String) } + attr_reader :repository + + sig { void } + def initialize + @rebuild = T.let(0, Integer) + @repository = T.let(Homebrew::DEFAULT_REPOSITORY, String) + @collector = T.let(Utils::Bottles::Collector.new, Utils::Bottles::Collector) + @root_url_specs = T.let({}, T::Hash[Symbol, T.untyped]) + @root_url = T.let(nil, T.nilable(String)) + end + + sig { params(val: Integer).returns(Integer) } + def rebuild(val = T.unsafe(nil)) + val.nil? ? @rebuild : @rebuild = val + end + + sig { params(var: T.nilable(String), specs: T::Hash[Symbol, T.untyped]).returns(String) } + def root_url(var = nil, specs = {}) + if var.nil? + @root_url ||= if (github_packages_url = GitHubPackages.root_url_if_match(Homebrew::EnvConfig.bottle_domain)) + github_packages_url + else + Homebrew::EnvConfig.bottle_domain + end + else + @root_url = if (github_packages_url = GitHubPackages.root_url_if_match(var)) + github_packages_url + else + var + end + @root_url_specs.merge!(specs) + @root_url + end + end + + sig { override.params(other: BasicObject).returns(T::Boolean) } + def ==(other) + case other + when self.class + rebuild == other.rebuild && collector == other.collector && + root_url == other.root_url && root_url_specs == other.root_url_specs && tap == other.tap + else false + end + end + alias eql? == + + sig { params(tag: Utils::Bottles::Tag).returns(T.any(Symbol, String)) } + def tag_to_cellar(tag = Utils::Bottles.tag) + spec = collector.specification_for(tag) + if spec.present? + spec.cellar + else + tag.default_cellar + end + end + + sig { params(tag: Utils::Bottles::Tag).returns(T::Boolean) } + def compatible_locations?(tag: Utils::Bottles.tag) + cellar = tag_to_cellar(tag) + + return true if RELOCATABLE_CELLARS.include?(cellar) + + prefix = Pathname(cellar.to_s).parent.to_s + + cellar_relocatable = cellar.size >= HOMEBREW_CELLAR.to_s.size && ENV["HOMEBREW_RELOCATE_BUILD_PREFIX"].present? + prefix_relocatable = prefix.size >= HOMEBREW_PREFIX.to_s.size && ENV["HOMEBREW_RELOCATE_BUILD_PREFIX"].present? + + compatible_cellar = cellar == HOMEBREW_CELLAR.to_s || cellar_relocatable + compatible_prefix = prefix == HOMEBREW_PREFIX.to_s || prefix_relocatable + + compatible_cellar && compatible_prefix + end + + # Does the {Bottle} this {BottleSpecification} belongs to need to be relocated? + # + # This will always return false on Linux unless a `tab` is provided that + # reports the bottle was built with Homebrew 5.1.15 or newer. The caller must + # make sure that the provided `tab` is for the requested `tag`. + sig { params(tag: Utils::Bottles::Tag, tab: T.nilable(Tab)).returns(T::Boolean) } + def skip_relocation?(tag: Utils::Bottles.tag, tab: nil) + spec = collector.specification_for(tag) + spec&.cellar == ANY_SKIP_RELOCATION_CELLAR + end + + sig { params(tag: Utils::Bottles::Tag, no_older_versions: T::Boolean).returns(T::Boolean) } + def tag?(tag, no_older_versions: false) + collector.tag?(tag, no_older_versions:) + end + + # Checksum methods in the DSL's bottle block take + # a Hash, which indicates the platform the checksum applies on. + # Example bottle block syntax: + # bottle do + # sha256 cellar: :any_skip_relocation, big_sur: "69489ae397e4645..." + # sha256 cellar: :any, catalina: "449de5ea35d0e94..." + # end + sig { params(hash: T::Hash[T.any(Symbol, String), T.any(String, Symbol)]).void } + def sha256(hash) + sha256_regex = /^[a-f0-9]{64}$/i + + # find new `sha256 big_sur: "69489ae397e4645..."` format + tag, digest = hash.find do |key, value| + # Don't use `odie` in this case. We want to be able to catch this exception + # in runtime when getting committed version info in formula auditor + raise LegacyDSLError.new(:sha256, hash) if key.is_a?(String) && key.match?(sha256_regex) && value.is_a?(Symbol) + + key.is_a?(Symbol) && value.is_a?(String) && value.match?(sha256_regex) + end + + odie "Invalid sha256 hash: #{digest}" if !tag || !digest + + tag = Utils::Bottles::Tag.from_symbol(T.cast(tag, Symbol)) + + cellar = hash[:cellar] || tag.default_cellar + + collector.add(tag, checksum: Checksum.new(digest.to_s), cellar:) + end + + sig { + params(tag: Utils::Bottles::Tag, no_older_versions: T::Boolean) + .returns(T.nilable(Utils::Bottles::TagSpecification)) + } + def tag_specification_for(tag, no_older_versions: false) + collector.specification_for(tag, no_older_versions:) + end + + sig { returns(T::Array[{ "tag" => Symbol, "digest" => Checksum, "cellar" => T.any(Symbol, String) }]) } + def checksums + tags = collector.tags.sort_by do |tag| + version = tag.to_macos_version + # Give `arm64` bottles a higher priority so they are first. + priority = (tag.arch == :arm64) ? 3 : 2 + "#{priority}.#{version}_#{tag}" + rescue MacOSVersion::Error + # Sort non-macOS tags below macOS tags, and arm64 tags before other tags. + priority = (tag.arch == :arm64) ? 1 : 0 + "#{priority}.#{tag}" + end + tags.reverse.map do |tag| + spec = collector.specification_for(tag) + odie "Specification for tag #{tag} is nil" if spec.nil? + { + "tag" => spec.tag.to_sym, + "digest" => spec.checksum, + "cellar" => spec.cellar, + } + end + end +end + +require "extend/os/bottle_specification" diff --git a/Library/Homebrew/brew.rb b/Library/Homebrew/brew.rb index 11dd3ff30b952..13b3bacd31119 100644 --- a/Library/Homebrew/brew.rb +++ b/Library/Homebrew/brew.rb @@ -1,168 +1,235 @@ +# typed: strict # frozen_string_literal: true -raise "HOMEBREW_BREW_FILE was not exported! Please call bin/brew directly!" unless ENV["HOMEBREW_BREW_FILE"] - -std_trap = trap("INT") { exit! 130 } # no backtrace thanks - -# check ruby version before requiring any modules. -RUBY_X, RUBY_Y, = RUBY_VERSION.split(".").map(&:to_i) -if RUBY_X < 2 || (RUBY_X == 2 && RUBY_Y < 6) - raise "Homebrew must be run under Ruby 2.6! You're running #{RUBY_VERSION}." +# `HOMEBREW_STACKPROF` should be set via `brew prof --stackprof`, not manually. +if ENV["HOMEBREW_STACKPROF"] + require "rubygems" + require "stackprof" + StackProf.start(mode: :wall, raw: true) end -# Load Bundler first of all if it's needed to avoid Gem version conflicts. -if ENV["HOMEBREW_INSTALL_BUNDLER_GEMS_FIRST"] - require_relative "utils/gems" - Homebrew.install_bundler_gems! +raise "HOMEBREW_BREW_FILE was not exported! Please call bin/brew directly!" unless ENV["HOMEBREW_BREW_FILE"] +if $PROGRAM_NAME != __FILE__ && !$PROGRAM_NAME.end_with?("/bin/ruby-prof") + raise "#{__FILE__} must not be loaded via `require`." end -# Also define here so we can rescue regardless of location. -class MissingEnvironmentVariables < RuntimeError; end - -begin - require_relative "global" -rescue MissingEnvironmentVariables => e - raise e if ENV["HOMEBREW_MISSING_ENV_RETRY"] - - if ENV["HOMEBREW_DEVELOPER"] - $stderr.puts <<~EOS - Warning: #{e.message} - Retrying with `exec #{ENV["HOMEBREW_BREW_FILE"]}`! - EOS - end +std_trap = trap("INT") { exit! 130 } # no backtrace thanks - ENV["HOMEBREW_MISSING_ENV_RETRY"] = "1" - exec ENV["HOMEBREW_BREW_FILE"], *ARGV -end +require_relative "global" +require "utils/output" begin trap("INT", std_trap) # restore default CTRL-C handler + if ENV["CI"] + $stdout.sync = true + $stderr.sync = true + end + empty_argv = ARGV.empty? help_flag_list = %w[-h --help --usage -?] help_flag = !ENV["HOMEBREW_HELP"].nil? - cmd = nil + help_cmd_index = T.let(nil, T.nilable(Integer)) + cmd = T.let(nil, T.nilable(String)) - ARGV.dup.each_with_index do |arg, i| + ARGV.each_with_index do |arg, i| break if help_flag && cmd if arg == "help" && !cmd # Command-style help: `help ` is fine, but ` help` is not. help_flag = true - elsif !cmd && !help_flag_list.include?(arg) + help_cmd_index = i + elsif !cmd && help_flag_list.exclude?(arg) cmd = ARGV.delete_at(i) end end - path = PATH.new(ENV["PATH"]) - homebrew_path = PATH.new(ENV["HOMEBREW_PATH"]) + ARGV.delete_at(help_cmd_index) if help_cmd_index + + require "cli/parser" + args = Homebrew::CLI::Parser.new(Homebrew::Cmd::Brew).parse(ARGV.dup.freeze, ignore_invalid_options: true) + Context.current = args.context + + path = PATH.new(ENV.fetch("PATH")) + homebrew_path = PATH.new(ENV.fetch("HOMEBREW_PATH")) + + # Add shared wrappers. + path.prepend(HOMEBREW_SHIMS_PATH/"shared") + homebrew_path.prepend(HOMEBREW_SHIMS_PATH/"shared") - # Add SCM wrappers. - path.prepend(HOMEBREW_SHIMS_PATH/"scm") - homebrew_path.prepend(HOMEBREW_SHIMS_PATH/"scm") + ENV["PATH"] = path.to_s - ENV["PATH"] = path + require "commands" + + internal_cmd = T.let(false, T::Boolean) + external_ruby_v2_cmd = T.let(false, T::Boolean) + external_ruby_cmd_path = T.let(nil, T.nilable(Pathname)) + external_cmd_path = T.let(nil, T.nilable(Pathname)) if cmd - internal_cmd = require? HOMEBREW_LIBRARY_PATH/"cmd"/cmd + cmd = Commands::HOMEBREW_INTERNAL_COMMAND_ALIASES.fetch(cmd, cmd) + internal_cmd = Commands.valid_internal_cmd?(cmd) || Commands.valid_internal_dev_cmd?(cmd) unless internal_cmd - internal_dev_cmd = require? HOMEBREW_LIBRARY_PATH/"dev-cmd"/cmd - internal_cmd = internal_dev_cmd - if internal_dev_cmd && !ARGV.homebrew_developer? - if (HOMEBREW_REPOSITORY/".git/config").exist? - system "git", "config", "--file=#{HOMEBREW_REPOSITORY}/.git/config", - "--replace-all", "homebrew.devcmdrun", "true" - end - ENV["HOMEBREW_DEV_CMD_RUN"] = "1" - end - end - end + # Add contributed commands to PATH before checking. + homebrew_path.append(Commands.tap_cmd_directories) - unless internal_cmd - # Add contributed commands to PATH before checking. - homebrew_path.append(Tap.cmd_directories) + # External commands expect a normal PATH + ENV["PATH"] = homebrew_path.to_s - # External commands expect a normal PATH - ENV["PATH"] = homebrew_path + external_ruby_v2_cmd = !Commands.external_ruby_v2_cmd_path(cmd).nil? + external_ruby_cmd_path = Commands.external_ruby_cmd_path(cmd) unless external_ruby_v2_cmd + external_cmd_path = Commands.external_cmd_path(cmd) if !external_ruby_v2_cmd && external_ruby_cmd_path.nil? + end end # Usage instructions should be displayed if and only if one of: # - a help flag is passed AND a command is matched # - a help flag is passed AND there is no command specified # - no arguments are passed - # - if cmd is Cask, let Cask handle the help command instead - if (empty_argv || help_flag) && cmd != "cask" + if empty_argv || help_flag require "help" - Homebrew::Help.help cmd, empty_argv: empty_argv - # `Homebrew.help` never returns, except for external/unknown commands. + # `Homebrew::Help.help` may defer to a self-documenting external command's own + # `--help` (e.g. `brew help `). Pass `--help`, not the Homebrew help flag + # that triggered this (`-h`, `--usage`, `-?`), which the command may not know. + if external_cmd_path + ARGV.reject! { |arg| help_flag_list.include?(arg) } + ARGV.push("--help") + end + Homebrew::Help.help cmd, remaining_args: args.remaining, empty_argv: + # `Homebrew::Help.help` never returns, except for unknown and deferred commands. end - if internal_cmd - Homebrew.send cmd.to_s.tr("-", "_").downcase - elsif which "brew-#{cmd}" - %w[CACHE LIBRARY_PATH].each do |env| - ENV["HOMEBREW_#{env}"] = Object.const_get("HOMEBREW_#{env}").to_s + if cmd.nil? + raise UsageError, "Unknown command: brew #{ARGV.join(" ")}" + elsif internal_cmd || external_ruby_v2_cmd + cmd_class = Homebrew::AbstractCommand.command(cmd) + if cmd_class&.include?(Homebrew::ShellCommand) + exec (HOMEBREW_LIBRARY_PATH.parent.parent/"bin/brew").to_s, cmd, *ARGV end - exec "brew-#{cmd}", *ARGV - elsif (path = which("brew-#{cmd}.rb")) && require?(path) + Homebrew.running_command = cmd + if cmd_class + unless Homebrew::EnvConfig.no_install_from_api? + require "api" + Homebrew::API.fetch_api_files! + end + + command_instance = cmd_class.new + + require "utils/analytics" + Utils::Analytics.report_command_run(command_instance) + command_instance.run + else + Utils::Output.odisabled "Calling `brew #{cmd}` without subclassing `AbstractCommand`", + "subclassing of `Homebrew::AbstractCommand` " \ + "(see https://docs.brew.sh/External-Commands)" + begin + Homebrew.public_send Commands.method_name(cmd) + rescue NoMethodError => e + converted_cmd = cmd.downcase.tr("-", "_") + case_error = "undefined method `#{converted_cmd}' for module Homebrew" + private_method_error = "private method `#{converted_cmd}' called for module Homebrew" + Utils::Output.odie "Unknown command: brew #{cmd}" if [case_error, private_method_error].include?(e.message) + + raise + end + end + elsif external_ruby_cmd_path + Homebrew.running_command = cmd + Homebrew.require?(external_ruby_cmd_path) exit Homebrew.failed? ? 1 : 0 + elsif external_cmd_path + ENV["HOMEBREW_CACHE"] = HOMEBREW_CACHE.to_s + ENV["HOMEBREW_LIBRARY_PATH"] = HOMEBREW_LIBRARY_PATH.to_s + exec external_cmd_path.to_s, *ARGV else - possible_tap = OFFICIAL_CMD_TAPS.find { |_, cmds| cmds.include?(cmd) } - possible_tap = Tap.fetch(possible_tap.first) if possible_tap - - odie "Unknown command: #{cmd}" if !possible_tap || possible_tap.installed? - - # Unset HOMEBREW_HELP to avoid confusing the tap - ENV.delete("HOMEBREW_HELP") if help_flag - tap_commands = [] - cgroup = Utils.popen_read("cat", "/proc/1/cgroup") - if !cgroup.include?("azpl_job") && !cgroup.include?("docker") - brew_uid = HOMEBREW_BREW_FILE.stat.uid - tap_commands += %W[/usr/bin/sudo -u ##{brew_uid}] if Process.uid.zero? && !brew_uid.zero? - end - tap_commands += %W[#{HOMEBREW_BREW_FILE} tap #{possible_tap.name}] - safe_system(*tap_commands) - ENV["HOMEBREW_HELP"] = "1" if help_flag - exec HOMEBREW_BREW_FILE, cmd, *ARGV + raise UsageError, "Unknown command: brew #{cmd}" end rescue UsageError => e require "help" - Homebrew::Help.help cmd, usage_error: e.message + Homebrew::Help.help cmd, remaining_args: args&.remaining || [], usage_error: e.message rescue SystemExit => e - onoe "Kernel.exit" if ARGV.debug? && !e.success? - $stderr.puts e.backtrace if ARGV.debug? + Utils::Output.onoe "Kernel.exit" if args&.debug? && !e.success? + if args&.debug? || ARGV.include?("--debug") + require "utils/backtrace" + $stderr.puts Utils::Backtrace.clean(e) + end raise rescue Interrupt $stderr.puts # seemingly a newline is typical exit 130 rescue BuildError => e Utils::Analytics.report_build_error(e) - e.dump + e.dump(verbose: args&.verbose? || false) + + if OS.not_tier_one_configuration? + $stderr.puts <<~EOS + This build failure was expected, as this is not a Tier 1 configuration: + #{Formatter.url("https://docs.brew.sh/Support-Tiers")} + #{Formatter.bold("Do not report any issues to Homebrew/* repositories!")} + Read the above document instead before opening any issues or PRs. + EOS + elsif (formula = e.formula) && (formula.head? || formula.deprecated? || formula.disabled?) + reason = if formula.head? + "was built from an unstable upstream --HEAD" + elsif formula.deprecated? + "is deprecated" + elsif formula.disabled? + "is disabled" + end + $stderr.puts <<~EOS + #{formula.name}'s formula #{reason}. + This build failure is expected behaviour. + EOS + end + exit 1 rescue RuntimeError, SystemCallError => e raise if e.message.empty? - onoe e - $stderr.puts e.backtrace if ARGV.debug? - exit 1 -rescue MethodDeprecatedError => e - onoe e - if e.issues_url - $stderr.puts "If reporting this issue please do so at (not Homebrew/brew or Homebrew/core):" - $stderr.puts " #{Formatter.url(e.issues_url)}" + Utils::Output.onoe e + if args&.debug? || ARGV.include?("--debug") + require "utils/backtrace" + $stderr.puts Utils::Backtrace.clean(e) end - $stderr.puts e.backtrace if ARGV.debug? + exit 1 +# Catch any other types of exceptions. rescue Exception => e # rubocop:disable Lint/RescueException - onoe e - if internal_cmd && defined?(OS::ISSUES_URL) && - !ENV["HOMEBREW_NO_AUTO_UPDATE"] - $stderr.puts "#{Tty.bold}Please report this bug:#{Tty.reset}" - $stderr.puts " #{Formatter.url(OS::ISSUES_URL)}" + Utils::Output.onoe e + + method_deprecated_error = e.is_a?(MethodDeprecatedError) + require "utils/backtrace" + $stderr.puts Utils::Backtrace.clean(e) if args&.debug? || ARGV.include?("--debug") || !method_deprecated_error + + if OS.not_tier_one_configuration? + $stderr.puts <<~EOS + This error was expected, as this is not a Tier 1 configuration: + #{Formatter.url("https://docs.brew.sh/Support-Tiers")} + #{Formatter.bold("Do not report any issues to Homebrew/* repositories!")} + Read the above document instead before opening any issues or PRs. + EOS + elsif Homebrew::EnvConfig.no_auto_update? && + (fetch_head = HOMEBREW_REPOSITORY/".git/FETCH_HEAD") && + (!fetch_head.exist? || (fetch_head.mtime.to_date < Date.today)) + $stderr.puts "#{Tty.bold}You have disabled automatic updates and have not updated today.#{Tty.reset}" + $stderr.puts "#{Tty.bold}Do not report this issue until you've run `brew update` and tried again.#{Tty.reset}" + elsif (issues_url = (method_deprecated_error && e.issues_url) || Utils::Backtrace.tap_error_url(e)) + $stderr.puts Utils::Output.issue_reporting_message(issues_url) + elsif internal_cmd && !method_deprecated_error + if OS.nix_managed_homebrew? + $stderr.puts Utils::Output.issue_reporting_message(OS::ISSUES_URL) + else + $stderr.puts Utils::Output.issue_reporting_message(OS::ISSUES_URL, homebrew: true) + end end - $stderr.puts e.backtrace + exit 1 else exit 1 if Homebrew.failed? +ensure + if ENV["HOMEBREW_STACKPROF"] + StackProf.stop + StackProf.results("prof/stackprof.dump") + end end diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index 421129f8b5561..528dbd5d86f45 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -1,267 +1,861 @@ -# Force UTF-8 to avoid encoding issues for users with broken locale settings. -if [[ "$(locale charmap 2>/dev/null)" != "UTF-8" ]] +##### +##### First do the essential, fast things to ensure commands like `brew --prefix` and others that we want +##### to be able to `source` in shell configurations run quickly. +##### + +case "${MACHTYPE}" in + arm64-* | aarch64-*) + HOMEBREW_PROCESSOR="arm64" + ;; + x86_64-*) + HOMEBREW_PROCESSOR="x86_64" + ;; + *) + HOMEBREW_PROCESSOR="$(uname -m)" + ;; +esac + +case "${OSTYPE}" in + darwin*) + HOMEBREW_SYSTEM="Darwin" + HOMEBREW_MACOS="1" + ;; + linux*) + HOMEBREW_SYSTEM="Linux" + HOMEBREW_LINUX="1" + ;; + *) + HOMEBREW_SYSTEM="$(uname -s)" + ;; +esac +HOMEBREW_PHYSICAL_PROCESSOR="${HOMEBREW_PROCESSOR}" + +HOMEBREW_MACOS_ARM_DEFAULT_PREFIX="/opt/homebrew" +HOMEBREW_MACOS_ARM_DEFAULT_REPOSITORY="${HOMEBREW_MACOS_ARM_DEFAULT_PREFIX}" +HOMEBREW_LINUX_DEFAULT_PREFIX="/home/linuxbrew/.linuxbrew" +HOMEBREW_LINUX_DEFAULT_REPOSITORY="${HOMEBREW_LINUX_DEFAULT_PREFIX}/Homebrew" +HOMEBREW_GENERIC_DEFAULT_PREFIX="/usr/local" +HOMEBREW_GENERIC_DEFAULT_REPOSITORY="${HOMEBREW_GENERIC_DEFAULT_PREFIX}/Homebrew" +if [[ -n "${HOMEBREW_MACOS}" && "${HOMEBREW_PROCESSOR}" == "arm64" ]] +then + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_MACOS_ARM_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_MACOS_ARM_DEFAULT_REPOSITORY}" +elif [[ -n "${HOMEBREW_LINUX}" ]] then - export LC_ALL="en_US.UTF-8" + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_LINUX_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_LINUX_DEFAULT_REPOSITORY}" +else + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_GENERIC_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_GENERIC_DEFAULT_REPOSITORY}" fi -# USER isn't always set so provide a fall back for `brew` and subprocesses. -export USER=${USER:-`id -un`} +if [[ -n "${HOMEBREW_MACOS}" ]] +then + HOMEBREW_DEFAULT_CACHE="${HOME}/Library/Caches/Homebrew" + HOMEBREW_DEFAULT_LOGS="${HOME}/Library/Logs/Homebrew" + HOMEBREW_DEFAULT_TEMP="/private/tmp" +else + CACHE_HOME="${HOMEBREW_XDG_CACHE_HOME:-${HOME}/.cache}" + HOMEBREW_DEFAULT_CACHE="${CACHE_HOME}/Homebrew" + HOMEBREW_DEFAULT_LOGS="${CACHE_HOME}/Homebrew/Logs" + if [[ -r "/var/tmp" && -w "/var/tmp" ]] + then + HOMEBREW_DEFAULT_TEMP="/var/tmp" + else + HOMEBREW_DEFAULT_TEMP="/tmp" + fi +fi + +realpath() { + (cd "$1" &>/dev/null && pwd -P) +} + +# Support systems where HOMEBREW_PREFIX is the default, +# but a parent directory is a symlink. +# Example: Fedora Silverblue symlinks /home -> /var/home +if [[ "${HOMEBREW_PREFIX}" != "${HOMEBREW_DEFAULT_PREFIX}" && "$(realpath "${HOMEBREW_DEFAULT_PREFIX}")" == "${HOMEBREW_PREFIX}" ]] +then + HOMEBREW_PREFIX="${HOMEBREW_DEFAULT_PREFIX}" +fi + +# Support systems where HOMEBREW_REPOSITORY is the default, +# but a parent directory is a symlink. +# Example: Fedora Silverblue symlinks /home -> var/home +if [[ "${HOMEBREW_REPOSITORY}" != "${HOMEBREW_DEFAULT_REPOSITORY}" && "$(realpath "${HOMEBREW_DEFAULT_REPOSITORY}")" == "${HOMEBREW_REPOSITORY}" ]] +then + HOMEBREW_REPOSITORY="${HOMEBREW_DEFAULT_REPOSITORY}" +fi # Where we store built products; a Cellar in HOMEBREW_PREFIX (often /usr/local # for bottles) unless there's already a Cellar in HOMEBREW_REPOSITORY. -if [[ -d "$HOMEBREW_REPOSITORY/Cellar" ]] +# These variables are set by bin/brew +# shellcheck disable=SC2154 +if [[ -d "${HOMEBREW_REPOSITORY}/Cellar" ]] then - HOMEBREW_CELLAR="$HOMEBREW_REPOSITORY/Cellar" + HOMEBREW_CELLAR="${HOMEBREW_REPOSITORY}/Cellar" else - HOMEBREW_CELLAR="$HOMEBREW_PREFIX/Cellar" + HOMEBREW_CELLAR="${HOMEBREW_PREFIX}/Cellar" fi -case "$*" in - --prefix) echo "$HOMEBREW_PREFIX"; exit 0 ;; - --cellar) echo "$HOMEBREW_CELLAR"; exit 0 ;; - --repository|--repo) echo "$HOMEBREW_REPOSITORY"; exit 0 ;; +# Support systems where HOMEBREW_CELLAR's parent directory is a symlink. +# Example: Fedora Silverblue symlinks /home -> /var/home +HOMEBREW_CELLAR_DEFAULT_PREFIX="${HOMEBREW_DEFAULT_PREFIX}/Cellar" +if [[ "${HOMEBREW_CELLAR}" != "${HOMEBREW_CELLAR_DEFAULT_PREFIX}" && "$(realpath "${HOMEBREW_CELLAR_DEFAULT_PREFIX}")" == "${HOMEBREW_CELLAR}" ]] +then + HOMEBREW_CELLAR="${HOMEBREW_CELLAR_DEFAULT_PREFIX}" +fi + +HOMEBREW_CASKROOM="${HOMEBREW_PREFIX}/Caskroom" + +HOMEBREW_CACHE="${HOMEBREW_CACHE:-${HOMEBREW_DEFAULT_CACHE}}" +HOMEBREW_LOGS="${HOMEBREW_LOGS:-${HOMEBREW_DEFAULT_LOGS}}" +HOMEBREW_TEMP="${HOMEBREW_TEMP:-${HOMEBREW_DEFAULT_TEMP}}" +if [[ ! -w "${HOMEBREW_TEMP}" ]] +then + HOMEBREW_TEMP="${HOMEBREW_DEFAULT_TEMP}" +fi + +# brew shellenv needs HOMEBREW_MACOS_VERSION_NUMERIC +if [[ -n "${HOMEBREW_MACOS}" ]] +then + HOMEBREW_MACOS_VERSION="$(/usr/bin/sw_vers -productVersion)" + + IFS=. read -r -a MACOS_VERSION_ARRAY < <(printf '%s' "${HOMEBREW_MACOS_VERSION}") + printf -v HOMEBREW_MACOS_VERSION_NUMERIC "%02d%02d%02d" "${MACOS_VERSION_ARRAY[@]}" + + unset MACOS_VERSION_ARRAY +fi + +# commands that take a single or no arguments. +# HOMEBREW_LIBRARY set by bin/brew +# shellcheck disable=SC2154 +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 +case "$1" in + formulae) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/formulae.sh" + homebrew-formulae + exit 0 + ;; + casks) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/casks.sh" + homebrew-casks + exit 0 + ;; + shellenv) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/shellenv.sh" + shift + homebrew-shellenv "$1" + exit 0 + ;; esac -# A depth of 1 means this command was directly invoked by a user. -# Higher depths mean this command was invoked by another Homebrew command. -export HOMEBREW_COMMAND_DEPTH=$((HOMEBREW_COMMAND_DEPTH + 1)) +source "${HOMEBREW_LIBRARY}/Homebrew/help.sh" + +# functions that take multiple arguments or handle multiple commands. +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 +case "$@" in + --cellar) + echo "${HOMEBREW_CELLAR}" + exit 0 + ;; + --repository | --repo) + echo "${HOMEBREW_REPOSITORY}" + exit 0 + ;; + --caskroom) + echo "${HOMEBREW_CASKROOM}" + exit 0 + ;; + --cache) + echo "${HOMEBREW_CACHE}" + exit 0 + ;; + --taps) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/--taps.sh" + homebrew---taps "$@" && exit 0 + ;; + # falls back to cmd/--prefix.rb and cmd/--cellar.rb on a non-zero return + --prefix* | --cellar*) + source "${HOMEBREW_LIBRARY}/Homebrew/formula_path.sh" + homebrew-formula-path "$@" && exit 0 + ;; + # falls back to cmd/command.rb on a non-zero return + command*) + source "${HOMEBREW_LIBRARY}/Homebrew/command_path.sh" + homebrew-command-path "$@" && exit 0 + ;; + # falls back to cmd/list.rb on a non-zero return + list* | ls*) + source "${HOMEBREW_LIBRARY}/Homebrew/list.sh" + homebrew-list "$@" && exit 0 + ;; + # homebrew-tap only handles invocations with no arguments + tap) + source "${HOMEBREW_LIBRARY}/Homebrew/tap.sh" + homebrew-tap "$@" + exit 0 + ;; + # falls back to cmd/help.rb on a non-zero return + help | --help | -h | --usage | "-?" | "") + homebrew-help "$@" && exit 0 + ;; +esac -ohai() { - if [[ -t 1 && -z "$HOMEBREW_NO_COLOR" ]] # check whether stdout is a tty. +# Check `HOMEBREW_FORCE_BREW_WRAPPER` for all non-trivial commands +# (i.e. not defined above this line e.g. formulae or --cellar). +if [[ -n "${HOMEBREW_FORCE_BREW_WRAPPER:-}" ]] +then + source "${HOMEBREW_LIBRARY}/Homebrew/utils/wrapper.sh" + check-brew-wrapper "$1" +fi + +# commands that take a single or no arguments and need to write to HOMEBREW_PREFIX. +# HOMEBREW_LIBRARY set by bin/brew +# shellcheck disable=SC2154 +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 +case "$1" in + setup-ruby) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/setup-ruby.sh" + shift + homebrew-setup-ruby "$1" + exit 0 + ;; +esac + +##### +##### Next, define all other helper functions. +##### + +source "${HOMEBREW_LIBRARY}/Homebrew/utils.sh" + +check-run-command-as-root() { + [[ "${EUID}" == 0 || "${UID}" == 0 ]] || return + + # Allow Azure Pipelines/GitHub Actions/Docker/Podman/Concourse/Kubernetes to do everything as root (as it's normal there) + [[ -f /.dockerenv ]] && return + [[ -f /run/.containerenv ]] && return + [[ -f /proc/1/cgroup ]] && grep -E "azpl_job|actions_job|docker|garden|kubepods" -q /proc/1/cgroup && return + + # `brew as-console-user` is run by root-owned MDM/Munki/Jamf workflows so it + # can immediately dispatch the requested Homebrew command as the console user. + [[ "${HOMEBREW_COMMAND}" == "as-console-user" ]] && return + + # `brew setup-sandbox` is intended to be run with `sudo` to prepare the + # Homebrew sandbox. + [[ "${HOMEBREW_COMMAND}" == "setup-sandbox" ]] && return + + # `brew services` may need `sudo` for system-wide daemons. + if [[ "${HOMEBREW_COMMAND}" == "services" ]] then - echo -e "\\033[34m==>\\033[0m \\033[1m$*\\033[0m" # blue arrow and bold text - else - echo "==> $*" + # Need to disable Bootsnap when running as root to avoid permission errors: + # https://github.com/Homebrew/brew/issues/19904 + export HOMEBREW_NO_BOOTSNAP="1" + + return fi + + # It's fine to run this as root as it's not changing anything. + [[ "${HOMEBREW_COMMAND}" == "--prefix" ]] && return + + odie <&2 # highlight Error with underline and red color - else - echo -n "Error: " >&2 + odie <&2 + return 0 else - echo "$*" >&2 + return 1 fi } -odie() { - onoe "$@" - exit 1 -} +# These variables are set from various Homebrew scripts. +# shellcheck disable=SC2154 +auto-update() { + [[ -z "${HOMEBREW_HELP}" ]] || return + [[ -z "${HOMEBREW_NO_AUTO_UPDATE}" ]] || return + [[ -z "${HOMEBREW_AUTO_UPDATING}" ]] || return + [[ -z "${HOMEBREW_UPDATE_AUTO}" ]] || return + [[ -z "${HOMEBREW_AUTO_UPDATE_CHECKED}" ]] || return + # Worktrees may share Git metadata with another checkout, so skip background updates. + [[ ! -f "${HOMEBREW_REPOSITORY}/.git" ]] || return -safe_cd() { - cd "$@" >/dev/null || odie "Error: failed to cd to $*!" -} + # If we've checked for updates, we don't need to check again. + export HOMEBREW_AUTO_UPDATE_CHECKED="1" + + if [[ -n "${HOMEBREW_AUTO_UPDATE_COMMAND}" ]] + then + export HOMEBREW_AUTO_UPDATING="1" + + # Look for commands that may be referring to a formula/cask in a specific + # 3rd-party tap so they can be auto-updated more often (as they do not get + # their data from the API). + AUTO_UPDATE_TAP_COMMANDS=( + install + outdated + upgrade + ) + if check-array-membership "${HOMEBREW_COMMAND}" "${AUTO_UPDATE_TAP_COMMANDS[@]}" + then + for arg in "$@" + do + if [[ "${arg}" == */*/* ]] && [[ "${arg}" != Homebrew/* ]] && [[ "${arg}" != homebrew/* ]] + then + + HOMEBREW_AUTO_UPDATE_TAP="1" + break + fi + done + fi -brew() { - "$HOMEBREW_BREW_FILE" "$@" + # When auto-updating before a zero-argument `brew upgrade` or `brew outdated`, + # that command lists the outdated packages itself so skip doing so here too. + # Two-way sync: `dump` in `Library/Homebrew/cmd/update_report/reporter_hub.rb`. + if [[ "${HOMEBREW_COMMAND}" == "upgrade" || "${HOMEBREW_COMMAND}" == "outdated" ]] + then + HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED="1" + for arg in "${@:2}" + do + [[ "${arg}" == -* ]] && continue + HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED="" + break + done + [[ -n "${HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED}" ]] && export HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED + fi + + if [[ -z "${HOMEBREW_AUTO_UPDATE_SECS}" ]] + then + if [[ -n "${HOMEBREW_NO_INSTALL_FROM_API}" || -n "${HOMEBREW_AUTO_UPDATE_TAP}" ]] + then + # 5 minutes + HOMEBREW_AUTO_UPDATE_SECS="300" + elif [[ -n "${HOMEBREW_DEV_CMD_RUN}" ]] + then + # 1 hour + HOMEBREW_AUTO_UPDATE_SECS="3600" + else + # 24 hours + HOMEBREW_AUTO_UPDATE_SECS="86400" + fi + fi + + repo_fetch_heads=("${HOMEBREW_REPOSITORY}/.git/FETCH_HEAD") + # We might have done an auto-update recently, but not a core/cask clone auto-update. + # So we check the core/cask clone FETCH_HEAD too. + if [[ -n "${HOMEBREW_AUTO_UPDATE_CORE_TAP}" && -d "${HOMEBREW_CORE_REPOSITORY}/.git" ]] + then + repo_fetch_heads+=("${HOMEBREW_CORE_REPOSITORY}/.git/FETCH_HEAD") + fi + if [[ -n "${HOMEBREW_AUTO_UPDATE_CASK_TAP}" && -d "${HOMEBREW_CASK_REPOSITORY}/.git" ]] + then + repo_fetch_heads+=("${HOMEBREW_CASK_REPOSITORY}/.git/FETCH_HEAD") + fi + + # Skip auto-update if all of the selected repositories have been checked in the + # last $HOMEBREW_AUTO_UPDATE_SECS. + needs_auto_update= + for repo_fetch_head in "${repo_fetch_heads[@]}" + do + if [[ ! -f "${repo_fetch_head}" ]] || + [[ -z "$(find "${repo_fetch_head}" -type f -newermt "-${HOMEBREW_AUTO_UPDATE_SECS} seconds" 2>/dev/null)" ]] + then + needs_auto_update=1 + break + fi + done + if [[ -z "${needs_auto_update}" ]] + then + unset HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED + return + fi + + brew update --auto-update + + unset HOMEBREW_AUTO_UPDATING + unset HOMEBREW_AUTO_UPDATE_TAP + unset HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED + + if [[ $# -gt 0 ]] + then + # exec a new process to set any new environment variables. + exec "${HOMEBREW_BREW_FILE}" "$@" + fi + fi + + unset AUTO_UPDATE_COMMANDS + unset AUTO_UPDATE_CORE_TAP_COMMANDS + unset AUTO_UPDATE_CASK_TAP_COMMANDS + unset HOMEBREW_AUTO_UPDATE_CORE_TAP + unset HOMEBREW_AUTO_UPDATE_CASK_TAP } -git() { - "$HOMEBREW_LIBRARY/Homebrew/shims/scm/git" "$@" +# Only `brew update-if-needed` should be handled here. +# We want it as fast as possible but it needs auto-update() defined above. +# HOMEBREW_LIBRARY set by bin/brew +# shellcheck disable=SC2154 +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 +# Don't need to pass through any arguments. +# shellcheck disable=SC2119 +case "$@" in + update-if-needed) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/update-if-needed.sh" + homebrew-update-if-needed + exit 0 + ;; +esac + +##### +##### Setup output so e.g. odie looks as nice as possible. +##### + +# Colorize output on GitHub Actions. +# This is set by the user environment. +# shellcheck disable=SC2154 +if [[ -n "${GITHUB_ACTIONS}" ]] +then + export HOMEBREW_COLOR="1" +fi + +# Force UTF-8 to avoid encoding issues for users with broken locale settings. +if [[ -n "${HOMEBREW_MACOS}" ]] +then + if [[ "$(locale charmap)" != "UTF-8" ]] + then + export LC_ALL="en_US.UTF-8" + fi +else + if ! command -v locale >/dev/null + then + export LC_ALL=C + elif [[ "$(locale charmap)" != "UTF-8" ]] + then + locales="$(locale -a)" + c_utf_regex='\bC\.(utf8|UTF-8)\b' + en_us_regex='\ben_US\.(utf8|UTF-8)\b' + utf_regex='\b[a-z][a-z]_[A-Z][A-Z]\.(utf8|UTF-8)\b' + if [[ ${locales} =~ ${c_utf_regex} || ${locales} =~ ${en_us_regex} || ${locales} =~ ${utf_regex} ]] + then + export LC_ALL="${BASH_REMATCH[0]}" + else + export LC_ALL=C + fi + fi +fi + +##### +##### odie as quickly as possible. +##### + +if [[ "${HOMEBREW_PREFIX}" == "/" || "${HOMEBREW_PREFIX}" == "/usr" ]] +then + # it may work, but I only see pain this route and don't want to support it + odie "Cowardly refusing to continue at this prefix: ${HOMEBREW_PREFIX}" +fi + +##### +##### Now, do everything else (that may be a bit slower). +##### + +# Docker image deprecation +if [[ -f "${HOMEBREW_REPOSITORY}/.docker-deprecate" && -z "${HOMEBREW_TESTS}" ]] +then + read -r DOCKER_DEPRECATION_MESSAGE <"${HOMEBREW_REPOSITORY}/.docker-deprecate" + if [[ -n "${GITHUB_ACTIONS}" ]] + then + echo "::warning::${DOCKER_DEPRECATION_MESSAGE}" >&2 + else + opoo "${DOCKER_DEPRECATION_MESSAGE}" + fi +fi + +# USER isn't always set so provide a fall back for `brew` and subprocesses. +export USER="${USER:-$(id -un)}" + +# A depth of 1 means this command was directly invoked by a user. +# Higher depths mean this command was invoked by another Homebrew command. +export HOMEBREW_COMMAND_DEPTH="$((HOMEBREW_COMMAND_DEPTH + 1))" + +setup_curl() { + # This is set by the user environment. + # shellcheck disable=SC2154 + HOMEBREW_BREWED_CURL_PATH="${HOMEBREW_PREFIX}/opt/curl/bin/curl" + if [[ -n "${HOMEBREW_FORCE_BREWED_CURL}" && -x "${HOMEBREW_BREWED_CURL_PATH}" ]] && + "${HOMEBREW_BREWED_CURL_PATH}" --version &>/dev/null + then + HOMEBREW_CURL="${HOMEBREW_BREWED_CURL_PATH}" + elif [[ -n "${HOMEBREW_CURL_PATH}" ]] + then + HOMEBREW_CURL="${HOMEBREW_CURL_PATH}" + else + HOMEBREW_CURL="curl" + fi } -numeric() { - # Condense the exploded argument into a single return value. - # shellcheck disable=SC2086,SC2183 - printf "%01d%02d%02d%03d" ${1//[.rc]/ } 2>/dev/null +setup_git() { + # This is set by the user environment. + # shellcheck disable=SC2154 + if [[ -n "${HOMEBREW_FORCE_BREWED_GIT}" && -x "${HOMEBREW_PREFIX}/opt/git/bin/git" ]] && + "${HOMEBREW_PREFIX}/opt/git/bin/git" --version &>/dev/null + then + HOMEBREW_GIT="${HOMEBREW_PREFIX}/opt/git/bin/git" + elif [[ -n "${HOMEBREW_GIT_PATH}" ]] + then + HOMEBREW_GIT="${HOMEBREW_GIT_PATH}" + else + HOMEBREW_GIT="git" + fi } -HOMEBREW_VERSION="$(git -C "$HOMEBREW_REPOSITORY" describe --tags --dirty --abbrev=7 2>/dev/null)" -HOMEBREW_USER_AGENT_VERSION="$HOMEBREW_VERSION" -if [[ -z "$HOMEBREW_VERSION" ]] +setup_git + +GIT_DESCRIBE_CACHE="${HOMEBREW_REPOSITORY}/.git/describe-cache" +GIT_REVISION=$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" rev-parse HEAD 2>/dev/null) + +# safe fallback in case git rev-parse fails e.g. if this is not considered a safe git directory +if [[ -z "${GIT_REVISION}" ]] then - HOMEBREW_VERSION=">=2.2.0 (shallow or no git repository)" - HOMEBREW_USER_AGENT_VERSION="2.X.Y" + read -r GIT_HEAD 2>/dev/null <"${HOMEBREW_REPOSITORY}/.git/HEAD" + if [[ "${GIT_HEAD}" == "ref: refs/heads/main" ]] + then + read -r GIT_REVISION 2>/dev/null <"${HOMEBREW_REPOSITORY}/.git/refs/heads/main" + elif [[ "${GIT_HEAD}" == "ref: refs/heads/stable" ]] + then + read -r GIT_REVISION 2>/dev/null <"${HOMEBREW_REPOSITORY}/.git/refs/heads/stable" + fi + unset GIT_HEAD fi -if [[ "$HOMEBREW_PREFIX" = "/" || "$HOMEBREW_PREFIX" = "/usr" ]] +if [[ -n "${GIT_REVISION}" ]] then - # it may work, but I only see pain this route and don't want to support it - odie "Cowardly refusing to continue at this prefix: $HOMEBREW_PREFIX" + GIT_DESCRIBE_CACHE_FILE="${GIT_DESCRIBE_CACHE}/${GIT_REVISION}" + if [[ -r "${GIT_DESCRIBE_CACHE_FILE}" ]] && "${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" diff --quiet --no-ext-diff 2>/dev/null + then + read -r GIT_DESCRIBE_CACHE_HOMEBREW_VERSION <"${GIT_DESCRIBE_CACHE_FILE}" + if [[ -n "${GIT_DESCRIBE_CACHE_HOMEBREW_VERSION}" && "${GIT_DESCRIBE_CACHE_HOMEBREW_VERSION}" != *"-dirty" ]] + then + HOMEBREW_VERSION="${GIT_DESCRIBE_CACHE_HOMEBREW_VERSION}" + fi + unset GIT_DESCRIBE_CACHE_HOMEBREW_VERSION + fi + + if [[ -z "${HOMEBREW_VERSION}" ]] + then + HOMEBREW_VERSION="$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" describe --tags --dirty --abbrev=7 2>/dev/null)" + # Don't output any permissions errors here. The user may not have write + # permissions to the cache but we don't care because it's an optional + # performance improvement. + rm -rf "${GIT_DESCRIBE_CACHE}" 2>/dev/null + mkdir -p "${GIT_DESCRIBE_CACHE}" 2>/dev/null + echo "${HOMEBREW_VERSION}" | tee "${GIT_DESCRIBE_CACHE_FILE}" &>/dev/null + fi + unset GIT_DESCRIBE_CACHE_FILE +else + # Don't care about permission errors here either. + rm -rf "${GIT_DESCRIBE_CACHE}" 2>/dev/null fi +unset GIT_REVISION +unset GIT_DESCRIBE_CACHE -HOMEBREW_SYSTEM="$(uname -s)" -case "$HOMEBREW_SYSTEM" in - Darwin) HOMEBREW_MACOS="1" ;; - Linux) HOMEBREW_LINUX="1" ;; +HOMEBREW_USER_AGENT_VERSION="${HOMEBREW_VERSION}" +if [[ -z "${HOMEBREW_VERSION}" ]] +then + HOMEBREW_VERSION=">=4.3.0 (shallow or no git repository)" + HOMEBREW_USER_AGENT_VERSION="4.X.Y" +fi + +HOMEBREW_CORE_REPOSITORY="${HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-core" +# Used in --version.sh +# shellcheck disable=SC2034 +HOMEBREW_CASK_REPOSITORY="${HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask" + +# Shift the -v to the end of the parameter list +if [[ "$1" == "-v" ]] +then + shift + set -- "$@" -v +fi + +# commands that take a single or no arguments. +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 +case "$1" in + --version | -v) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/--version.sh" + homebrew-version + exit 0 + ;; + mcp-server) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/mcp-server.sh" + homebrew-mcp-server "$@" + exit 0 + ;; esac -if [[ -n "$HOMEBREW_MACOS" ]] +setup_curl + +HOMEBREW_API_DEFAULT_DOMAIN="https://formulae.brew.sh/api" +HOMEBREW_BOTTLE_DEFAULT_DOMAIN="https://ghcr.io/v2/homebrew/core" + +# TODO: bump version when new macOS is released or announced and update references in: +# - docs/Installation.md +# - https://github.com/Homebrew/install/blob/HEAD/install.sh +# - Library/Homebrew/os/mac.rb (latest_sdk_version) +# - Library/Homebrew/os/mac/xcode.rb (latest_version), (minimum_version) +# and, if needed: +# - MacOSVersion::SYMBOLS +HOMEBREW_MACOS_NEWEST_UNSUPPORTED="27" +# TODO: bump version when new macOS is released +HOMEBREW_MACOS_NEWEST_SUPPORTED="26" +# TODO: bump version when new macOS is released and update references in: +# - docs/Installation.md +# - HOMEBREW_MACOS_OLDEST_SUPPORTED in .github/workflows/release.yml +# - `os-version min` in package/Distribution.xml +# - https://github.com/Homebrew/install/blob/HEAD/install.sh +HOMEBREW_MACOS_OLDEST_SUPPORTED="14" +HOMEBREW_MACOS_OLDEST_ALLOWED="10.15" + +if [[ -n "${HOMEBREW_MACOS}" ]] then - HOMEBREW_PROCESSOR="$(uname -p)" HOMEBREW_PRODUCT="Homebrew" HOMEBREW_SYSTEM="Macintosh" - # This is i386 even on x86_64 machines - [[ "$HOMEBREW_PROCESSOR" = "i386" ]] && HOMEBREW_PROCESSOR="Intel" - HOMEBREW_MACOS_VERSION="$(/usr/bin/sw_vers -productVersion)" - HOMEBREW_OS_VERSION="macOS $HOMEBREW_MACOS_VERSION" - # Don't change this from Mac OS X to match what macOS itself does in Safari on 10.12 - HOMEBREW_OS_USER_AGENT_VERSION="Mac OS X $HOMEBREW_MACOS_VERSION" + [[ "${HOMEBREW_PROCESSOR}" == "x86_64" ]] && HOMEBREW_PROCESSOR="Intel" + # Don't change this from Mac OS X to match what macOS itself does in Safari + HOMEBREW_OS_USER_AGENT_VERSION="Mac OS X ${HOMEBREW_MACOS_VERSION}" - # Intentionally set this variable by exploding another. - # shellcheck disable=SC2086,SC2183 - printf -v HOMEBREW_MACOS_VERSION_NUMERIC "%02d%02d%02d" ${HOMEBREW_MACOS_VERSION//./ } + if [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == "1" ]] + then + # used in vendor-install.sh and update.sh + # shellcheck disable=SC2034 + HOMEBREW_PHYSICAL_PROCESSOR="arm64" + fi + + IFS=. read -r -a MACOS_VERSION_ARRAY < <(printf '%s' "${HOMEBREW_MACOS_OLDEST_ALLOWED}") + printf -v HOMEBREW_MACOS_OLDEST_ALLOWED_NUMERIC "%02d%02d%02d" "${MACOS_VERSION_ARRAY[@]}" + + unset MACOS_VERSION_ARRAY - # Refuse to run on pre-Mavericks - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "100900" ]] + # Don't include minor versions for Big Sur and later. + if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -gt "110000" ]] then - printf "ERROR: Your version of macOS (%s) is too old to run Homebrew!\\n" "$HOMEBREW_MACOS_VERSION" >&2 - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "100700" ]] + HOMEBREW_OS_VERSION="macOS ${HOMEBREW_MACOS_VERSION%.*}" + else + HOMEBREW_OS_VERSION="macOS ${HOMEBREW_MACOS_VERSION}" + fi + + # Refuse to run on pre-Catalina + # odisabled: remove support for Catalina September (or later) 2026 + if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "${HOMEBREW_MACOS_OLDEST_ALLOWED_NUMERIC}" ]] + then + printf "ERROR: Your version of macOS (%s) is too old to run Homebrew!\\n" "${HOMEBREW_MACOS_VERSION}" >&2 + if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "100700" ]] then printf " For 10.4 - 10.6 support see: https://github.com/mistydemeo/tigerbrew\\n" >&2 + else + printf " For 10.5 - %s support see: https://www.macports.org\\n" "${HOMEBREW_MACOS_VERSION}" >&2 fi printf "\\n" >&2 fi - # The system Curl is too old for some modern HTTPS certificates on - # older macOS versions. - # - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "101000" ]] + # The system libressl has a bug before macOS 10.15.6 where it incorrectly handles expired roots. + if [[ -z "${HOMEBREW_SYSTEM_CURL_TOO_OLD}" && "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "101506" ]] then - HOMEBREW_SYSTEM_CURL_TOO_OLD="1" - HOMEBREW_FORCE_BREWED_CURL="1" + HOMEBREW_SYSTEM_CA_CERTIFICATES_TOO_OLD="1" + HOMEBREW_FORCE_BREWED_CA_CERTIFICATES="1" fi - # The system Git on macOS versions before Sierra is too old for some Homebrew functionality we rely on. + # Some Git versions are too old for some Homebrew functionality we rely on. HOMEBREW_MINIMUM_GIT_VERSION="2.14.3" - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "101200" ]] +else + if [[ -r "/proc/cpuinfo" ]] && + [[ "${HOMEBREW_PROCESSOR}" == "x86_64" ]] then - HOMEBREW_FORCE_BREWED_GIT="1" + if ! grep -qE '^(flags|Features).*\bssse3\b' /proc/cpuinfo + then + odie "Homebrew's x86_64 support on Linux requires a CPU with SSSE3 support!" + fi fi - HOMEBREW_CACHE="${HOMEBREW_CACHE:-${HOME}/Library/Caches/Homebrew}" - HOMEBREW_LOGS="${HOMEBREW_LOGS:-${HOME}/Library/Logs/Homebrew}" - HOMEBREW_SYSTEM_TEMP="/private/tmp" - - # Set a variable when the macOS system Ruby is new enough to avoid spawning - # a Ruby process unnecessarily. - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "101500" ]] - then - unset HOMEBREW_MACOS_SYSTEM_RUBY_NEW_ENOUGH - else - # Used in ruby.sh. - # shellcheck disable=SC2034 - HOMEBREW_MACOS_SYSTEM_RUBY_NEW_ENOUGH="1" - fi -else - HOMEBREW_PROCESSOR="$(uname -m)" HOMEBREW_PRODUCT="${HOMEBREW_SYSTEM}brew" - [[ -n "$HOMEBREW_LINUX" ]] && HOMEBREW_OS_VERSION="$(lsb_release -sd 2>/dev/null)" + # Don't try to follow /etc/os-release + # shellcheck disable=SC1091,SC2154 + [[ -n "${HOMEBREW_LINUX}" ]] && HOMEBREW_OS_VERSION="$(source /etc/os-release && echo "${PRETTY_NAME}")" : "${HOMEBREW_OS_VERSION:=$(uname -r)}" - HOMEBREW_OS_USER_AGENT_VERSION="$HOMEBREW_OS_VERSION" + HOMEBREW_OS_USER_AGENT_VERSION="${HOMEBREW_OS_VERSION}" # Ensure the system Curl is a version that supports modern HTTPS certificates. HOMEBREW_MINIMUM_CURL_VERSION="7.41.0" - system_curl_version_output="$($(command -v curl) --version 2>/dev/null)" - system_curl_name_and_version="${system_curl_version_output%% (*}" - if [[ $(numeric "${system_curl_name_and_version##* }") -lt $(numeric "$HOMEBREW_MINIMUM_CURL_VERSION") ]] + + curl_version_output="$(${HOMEBREW_CURL} --version 2>/dev/null)" + curl_name_and_version="${curl_version_output%% (*}" + if [[ "$(numeric "${curl_name_and_version##* }")" -lt "$(numeric "${HOMEBREW_MINIMUM_CURL_VERSION}")" ]] then - HOMEBREW_SYSTEM_CURL_TOO_OLD="1" - HOMEBREW_FORCE_BREWED_CURL="1" + message="Please update your system curl or set HOMEBREW_CURL_PATH to a newer version. +Minimum required version: ${HOMEBREW_MINIMUM_CURL_VERSION} + Your curl version: ${curl_name_and_version##* } + Your curl executable: $(type -p "${HOMEBREW_CURL}")" + + if [[ -z ${HOMEBREW_CURL_PATH} ]] + then + HOMEBREW_SYSTEM_CURL_TOO_OLD=1 + HOMEBREW_FORCE_BREWED_CURL=1 + if [[ -z ${HOMEBREW_CURL_WARNING} ]] + then + onoe "${message}" + HOMEBREW_CURL_WARNING=1 + fi + else + odie "${message}" + fi fi # Ensure the system Git is at or newer than the minimum required version. # Git 2.7.4 is the version of git on Ubuntu 16.04 LTS (Xenial Xerus). HOMEBREW_MINIMUM_GIT_VERSION="2.7.0" - system_git_version_output="$($(command -v git) --version 2>/dev/null)" + git_version_output="$(${HOMEBREW_GIT} --version 2>/dev/null)" # $extra is intentionally discarded. # shellcheck disable=SC2034 - IFS=. read -r major minor micro build extra <<< "${system_git_version_output##* }" - if [[ $(numeric "$major.$minor.$micro.$build") -lt $(numeric "$HOMEBREW_MINIMUM_GIT_VERSION") ]] + IFS='.' read -r major minor micro build extra < <(printf '%s' "${git_version_output##* }") + if [[ "$(numeric "${major}.${minor}.${micro}.${build}")" -lt "$(numeric "${HOMEBREW_MINIMUM_GIT_VERSION}")" ]] then - HOMEBREW_FORCE_BREWED_GIT="1" + message="Please update your system Git or set HOMEBREW_GIT_PATH to a newer version. +Minimum required version: ${HOMEBREW_MINIMUM_GIT_VERSION} + Your Git version: ${major}.${minor}.${micro}.${build} + Your Git executable: $(unset git && type -p "${HOMEBREW_GIT}")" + if [[ -z ${HOMEBREW_GIT_PATH} ]] + then + HOMEBREW_FORCE_BREWED_GIT="1" + if [[ -z ${HOMEBREW_GIT_WARNING} ]] + then + onoe "${message}" + HOMEBREW_GIT_WARNING=1 + fi + else + odie "${message}" + fi fi - CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}" - HOMEBREW_CACHE="${HOMEBREW_CACHE:-${CACHE_HOME}/Homebrew}" - HOMEBREW_LOGS="${HOMEBREW_LOGS:-${CACHE_HOME}/Homebrew/Logs}" - HOMEBREW_SYSTEM_TEMP="/tmp" - - unset HOMEBREW_MACOS_SYSTEM_RUBY_NEW_ENOUGH -fi - -if [[ -n "$HOMEBREW_MACOS" || -n "$HOMEBREW_FORCE_HOMEBREW_ON_LINUX" ]] -then - HOMEBREW_BOTTLE_DEFAULT_DOMAIN="https://homebrew.bintray.com" -else - HOMEBREW_BOTTLE_DEFAULT_DOMAIN="https://linuxbrew.bintray.com" -fi - -HOMEBREW_TEMP="${HOMEBREW_TEMP:-${HOMEBREW_SYSTEM_TEMP}}" - -if [[ -n "$HOMEBREW_FORCE_BREWED_CURL" && - -x "$HOMEBREW_PREFIX/opt/curl/bin/curl" ]] && - "$HOMEBREW_PREFIX/opt/curl/bin/curl" --version >/dev/null -then - HOMEBREW_CURL="$HOMEBREW_PREFIX/opt/curl/bin/curl" -elif [[ -n "$HOMEBREW_DEVELOPER" && -x "$HOMEBREW_CURL_PATH" ]] -then - HOMEBREW_CURL="$HOMEBREW_CURL_PATH" -else - HOMEBREW_CURL="curl" + HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION="2.13" fi -if [[ -n "$HOMEBREW_FORCE_BREWED_GIT" && - -x "$HOMEBREW_PREFIX/opt/git/bin/git" ]] && - "$HOMEBREW_PREFIX/opt/git/bin/git" --version >/dev/null -then - HOMEBREW_GIT="$HOMEBREW_PREFIX/opt/git/bin/git" -elif [[ -n "$HOMEBREW_DEVELOPER" && -x "$HOMEBREW_GIT_PATH" ]] +setup_ca_certificates() { + if [[ -n "${HOMEBREW_FORCE_BREWED_CA_CERTIFICATES}" && -f "${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" ]] + then + export SSL_CERT_FILE="${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" + export GIT_SSL_CAINFO="${HOMEBREW_PREFIX}/etc/ca-certificates/cert.pem" + export GIT_SSL_CAPATH="${HOMEBREW_PREFIX}/etc/ca-certificates" + fi +} +setup_ca_certificates + +# Redetermine curl and git paths as we may have forced some options above. +setup_curl +setup_git + +# A bug in the auto-update process prior to 3.1.2 means $HOMEBREW_BOTTLE_DOMAIN +# could be passed down with the default domain. +# This is problematic as this is will be the old bottle domain. +# This workaround is necessary for many CI images starting on old version, +# and will only be unnecessary when updating from <3.1.2 is not a concern. +# That will be when macOS 12 is the minimum required version. +# HOMEBREW_BOTTLE_DOMAIN is set from the user environment +# shellcheck disable=SC2154 +if [[ -n "${HOMEBREW_BOTTLE_DEFAULT_DOMAIN}" ]] && + [[ "${HOMEBREW_BOTTLE_DOMAIN}" == "${HOMEBREW_BOTTLE_DEFAULT_DOMAIN}" ]] then - HOMEBREW_GIT="$HOMEBREW_GIT_PATH" -else - HOMEBREW_GIT="git" + unset HOMEBREW_BOTTLE_DOMAIN fi -HOMEBREW_USER_AGENT="$HOMEBREW_PRODUCT/$HOMEBREW_USER_AGENT_VERSION ($HOMEBREW_SYSTEM; $HOMEBREW_PROCESSOR $HOMEBREW_OS_USER_AGENT_VERSION)" -curl_version_output="$("$HOMEBREW_CURL" --version 2>/dev/null)" +HOMEBREW_USER_AGENT="${HOMEBREW_PRODUCT}/${HOMEBREW_USER_AGENT_VERSION} (${HOMEBREW_SYSTEM}; ${HOMEBREW_PROCESSOR} ${HOMEBREW_OS_USER_AGENT_VERSION})" +curl_version_output="$(curl --version 2>/dev/null)" curl_name_and_version="${curl_version_output%% (*}" -HOMEBREW_USER_AGENT_CURL="$HOMEBREW_USER_AGENT ${curl_name_and_version// //}" - -# Declared in bin/brew -export HOMEBREW_BREW_FILE -export HOMEBREW_PREFIX -export HOMEBREW_REPOSITORY -export HOMEBREW_LIBRARY -export HOMEBREW_SYSTEM_TEMP -export HOMEBREW_TEMP +HOMEBREW_USER_AGENT_CURL="${HOMEBREW_USER_AGENT} ${curl_name_and_version// //}" -# Declared in brew.sh +# Timeout values to check for dead connections +# We don't use --max-time to support slow connections +HOMEBREW_CURL_SPEED_LIMIT=100 +HOMEBREW_CURL_SPEED_TIME=5 + +export HOMEBREW_HELP_MESSAGE export HOMEBREW_VERSION +export HOMEBREW_MACOS_ARM_DEFAULT_PREFIX +export HOMEBREW_LINUX_DEFAULT_PREFIX +export HOMEBREW_GENERIC_DEFAULT_PREFIX +export HOMEBREW_DEFAULT_PREFIX +export HOMEBREW_MACOS_ARM_DEFAULT_REPOSITORY +export HOMEBREW_LINUX_DEFAULT_REPOSITORY +export HOMEBREW_GENERIC_DEFAULT_REPOSITORY +export HOMEBREW_DEFAULT_REPOSITORY +export HOMEBREW_DEFAULT_CACHE export HOMEBREW_CACHE +export HOMEBREW_DEFAULT_LOGS export HOMEBREW_LOGS +export HOMEBREW_DEFAULT_TEMP +export HOMEBREW_TEMP export HOMEBREW_CELLAR +export HOMEBREW_CASKROOM export HOMEBREW_SYSTEM +export HOMEBREW_SYSTEM_CA_CERTIFICATES_TOO_OLD export HOMEBREW_CURL +export HOMEBREW_BREWED_CURL_PATH +export HOMEBREW_CURL_WARNING export HOMEBREW_SYSTEM_CURL_TOO_OLD export HOMEBREW_GIT +export HOMEBREW_GIT_WARNING export HOMEBREW_MINIMUM_GIT_VERSION +export HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION +export HOMEBREW_PHYSICAL_PROCESSOR export HOMEBREW_PROCESSOR export HOMEBREW_PRODUCT export HOMEBREW_OS_VERSION export HOMEBREW_MACOS_VERSION export HOMEBREW_MACOS_VERSION_NUMERIC +export HOMEBREW_MACOS_NEWEST_UNSUPPORTED +export HOMEBREW_MACOS_NEWEST_SUPPORTED +export HOMEBREW_MACOS_OLDEST_SUPPORTED +export HOMEBREW_MACOS_OLDEST_ALLOWED export HOMEBREW_USER_AGENT export HOMEBREW_USER_AGENT_CURL +export HOMEBREW_API_DEFAULT_DOMAIN export HOMEBREW_BOTTLE_DEFAULT_DOMAIN +export HOMEBREW_CURL_SPEED_LIMIT +export HOMEBREW_CURL_SPEED_TIME -if [[ -n "$HOMEBREW_MACOS" && -x "/usr/bin/xcode-select" ]] +if [[ -n "${HOMEBREW_MACOS}" && -x "/usr/bin/xcode-select" ]] then - XCODE_SELECT_PATH=$('/usr/bin/xcode-select' --print-path 2>/dev/null) - if [[ "$XCODE_SELECT_PATH" = "/" ]] + XCODE_SELECT_PATH="$('/usr/bin/xcode-select' --print-path 2>/dev/null)" + if [[ "${XCODE_SELECT_PATH}" == "/" ]] then odie <&2 + mkdir -p "${HOMEBREW_CACHE}/api" + if [[ -d "${original_cache}/api" ]] + then + cp -R "${original_cache}/api/." "${HOMEBREW_CACHE}/api/" &>/dev/null || true + fi + export HOMEBREW_CACHE + fi fi # Set HOMEBREW_DEV_CMD_RUN for users who have run a development command. # This makes them behave like HOMEBREW_DEVELOPERs for brew update. -if [[ -z "$HOMEBREW_DEVELOPER" ]] +if [[ -z "${HOMEBREW_DEVELOPER}" ]] then - export HOMEBREW_GIT_CONFIG_FILE="$HOMEBREW_REPOSITORY/.git/config" - HOMEBREW_GIT_CONFIG_DEVELOPERMODE="$(git config --file="$HOMEBREW_GIT_CONFIG_FILE" --get homebrew.devcmdrun 2>/dev/null)" - if [[ "$HOMEBREW_GIT_CONFIG_DEVELOPERMODE" = "true" ]] + export HOMEBREW_GIT_CONFIG_FILE="${HOMEBREW_REPOSITORY}/.git/config" + HOMEBREW_GIT_CONFIG_DEVELOPERMODE="$(git config --file="${HOMEBREW_GIT_CONFIG_FILE}" --get homebrew.devcmdrun 2>/dev/null)" + if [[ "${HOMEBREW_GIT_CONFIG_DEVELOPERMODE}" == "true" ]] then export HOMEBREW_DEV_CMD_RUN="1" fi # Don't allow non-developers to customise Ruby warnings. unset HOMEBREW_RUBY_WARNINGS +fi - # Disable Ruby options we don't need. RubyGems provides a decent speedup. - RUBY_DISABLE_OPTIONS="--disable=gems,did_you_mean,rubyopt" -else - # Don't disable did_you_mean for developers as it's useful. - RUBY_DISABLE_OPTIONS="--disable=gems,rubyopt" +unset HOMEBREW_AUTO_UPDATE_COMMAND + +# Check for commands that should call `brew update --auto-update` first. +AUTO_UPDATE_COMMANDS=( + install + outdated + upgrade + bundle + release +) +if check-array-membership "${HOMEBREW_COMMAND}" "${AUTO_UPDATE_COMMANDS[@]}" || + [[ "${HOMEBREW_COMMAND}" == "tap" && "${HOMEBREW_ARG_COUNT}" -gt 1 ]] +then + export HOMEBREW_AUTO_UPDATE_COMMAND="1" fi -if [[ -z "$HOMEBREW_RUBY_WARNINGS" ]] +# Check for commands that should auto-update the homebrew-core tap. +AUTO_UPDATE_CORE_TAP_COMMANDS=( + bump + bump-formula-pr +) +if check-array-membership "${HOMEBREW_COMMAND}" "${AUTO_UPDATE_CORE_TAP_COMMANDS[@]}" +then + export HOMEBREW_AUTO_UPDATE_COMMAND="1" + export HOMEBREW_AUTO_UPDATE_CORE_TAP="1" +elif [[ -z "${HOMEBREW_AUTO_UPDATING}" ]] then - export HOMEBREW_RUBY_WARNINGS="-W0" + unset HOMEBREW_AUTO_UPDATE_CORE_TAP fi -if [[ -z "$HOMEBREW_BOTTLE_DOMAIN" ]] +# Check for commands that should auto-update the homebrew-cask tap. +AUTO_UPDATE_CASK_TAP_COMMANDS=( + bump + bump-cask-pr + bump-unversioned-casks +) +if check-array-membership "${HOMEBREW_COMMAND}" "${AUTO_UPDATE_CASK_TAP_COMMANDS[@]}" +then + export HOMEBREW_AUTO_UPDATE_COMMAND="1" + export HOMEBREW_AUTO_UPDATE_CASK_TAP="1" +elif [[ -z "${HOMEBREW_AUTO_UPDATING}" ]] then - export HOMEBREW_BOTTLE_DOMAIN="$HOMEBREW_BOTTLE_DEFAULT_DOMAIN" + unset HOMEBREW_AUTO_UPDATE_CASK_TAP fi -HOMEBREW_DEFAULT_BREW_GIT_REMOTE="https://github.com/Homebrew/brew" -if [[ -z "$HOMEBREW_BREW_GIT_REMOTE" ]] +if [[ -z "${HOMEBREW_RUBY_WARNINGS}" ]] then - HOMEBREW_BREW_GIT_REMOTE="$HOMEBREW_DEFAULT_BREW_GIT_REMOTE" + export HOMEBREW_RUBY_WARNINGS="-W1" fi -export HOMEBREW_BREW_GIT_REMOTE -if [[ -n "$HOMEBREW_MACOS" ]] || [[ -n "$HOMEBREW_FORCE_HOMEBREW_ON_LINUX" ]] +export HOMEBREW_BREW_DEFAULT_GIT_REMOTE="https://github.com/Homebrew/brew" +if [[ -z "${HOMEBREW_BREW_GIT_REMOTE}" ]] then - HOMEBREW_DEFAULT_CORE_GIT_REMOTE="https://github.com/Homebrew/homebrew-core" -else - HOMEBREW_DEFAULT_CORE_GIT_REMOTE="https://github.com/Homebrew/linuxbrew-core" + HOMEBREW_BREW_GIT_REMOTE="${HOMEBREW_BREW_DEFAULT_GIT_REMOTE}" fi +export HOMEBREW_BREW_GIT_REMOTE -if [[ -z "$HOMEBREW_CORE_GIT_REMOTE" ]] +export HOMEBREW_CORE_DEFAULT_GIT_REMOTE="https://github.com/Homebrew/homebrew-core" +if [[ -z "${HOMEBREW_CORE_GIT_REMOTE}" ]] then - HOMEBREW_CORE_GIT_REMOTE="$HOMEBREW_DEFAULT_CORE_GIT_REMOTE" + HOMEBREW_CORE_GIT_REMOTE="${HOMEBREW_CORE_DEFAULT_GIT_REMOTE}" fi export HOMEBREW_CORE_GIT_REMOTE -if [[ -f "$HOMEBREW_LIBRARY/Homebrew/cmd/$HOMEBREW_COMMAND.sh" ]] +# Set HOMEBREW_DEVELOPER_COMMAND if the command being run is a developer command +unset HOMEBREW_DEVELOPER_COMMAND +if [[ -f "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${HOMEBREW_COMMAND}.sh" ]] || + [[ -f "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${HOMEBREW_COMMAND}.rb" ]] then - HOMEBREW_BASH_COMMAND="$HOMEBREW_LIBRARY/Homebrew/cmd/$HOMEBREW_COMMAND.sh" -elif [[ -f "$HOMEBREW_LIBRARY/Homebrew/dev-cmd/$HOMEBREW_COMMAND.sh" ]] + export HOMEBREW_DEVELOPER_COMMAND="1" +fi + +if [[ -n "${HOMEBREW_DEVELOPER_COMMAND}" && -z "${HOMEBREW_DEVELOPER}" && -z "${HOMEBREW_DEV_CMD_RUN}" ]] then - if [[ -z "$HOMEBREW_DEVELOPER" ]] - then - git config --file="$HOMEBREW_GIT_CONFIG_FILE" --replace-all homebrew.devcmdrun true 2>/dev/null - export HOMEBREW_DEV_CMD_RUN="1" - fi - HOMEBREW_BASH_COMMAND="$HOMEBREW_LIBRARY/Homebrew/dev-cmd/$HOMEBREW_COMMAND.sh" + opoo </dev/null + export HOMEBREW_DEV_CMD_RUN="1" fi -check-run-command-as-root() { - [[ "$(id -u)" = 0 ]] || return +# Enable features for developers and CI before they become the default for everyone. +if [[ -n "${HOMEBREW_DEVELOPER}" ]] +then + : +fi - # Allow Azure Pipelines/Docker/Concourse/Kubernetes to do everything as root (as it's normal there) - [[ -f /proc/1/cgroup ]] && grep -E "azpl_job|docker|garden|kubepods" -q /proc/1/cgroup && return +# Enable features for users who have run a devcmd before they become the default for everyone. +if [[ -n "${HOMEBREW_DEVELOPER}" || -n "${HOMEBREW_DEV_CMD_RUN}" ]] +then + : +fi - # Homebrew Services may need `sudo` for system-wide daemons. - [[ "$HOMEBREW_COMMAND" = "services" ]] && return +# Only enable runtime typechecking for commands where correctness matters more +# than performance. +if [[ "${HOMEBREW_COMMAND}" == "test" || "${HOMEBREW_COMMAND}" == "test-bot" || + "${HOMEBREW_COMMAND}" == "tests" ]] +then + export HOMEBREW_SORBET_RUNTIME="1" +fi - # It's fine to run this as root as it's not changing anything. - [[ "$HOMEBREW_COMMAND" = "--prefix" ]] && return +if [[ -z "${HOMEBREW_FORCE_RUBY_COMMAND:-}" && -f "${HOMEBREW_LIBRARY}/Homebrew/cmd/${HOMEBREW_COMMAND}.sh" ]] +then + HOMEBREW_BASH_COMMAND="${HOMEBREW_LIBRARY}/Homebrew/cmd/${HOMEBREW_COMMAND}.sh" +elif [[ -z "${HOMEBREW_FORCE_RUBY_COMMAND:-}" && -f "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${HOMEBREW_COMMAND}.sh" ]] +then + HOMEBREW_BASH_COMMAND="${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${HOMEBREW_COMMAND}.sh" +fi - odie <&2 <&2 -} - -update-preinstall() { - [[ -z "$HOMEBREW_HELP" ]] || return - [[ -z "$HOMEBREW_NO_AUTO_UPDATE" ]] || return - [[ -z "$HOMEBREW_AUTO_UPDATING" ]] || return - [[ -z "$HOMEBREW_UPDATE_PREINSTALL" ]] || return - [[ -z "$HOMEBREW_AUTO_UPDATE_CHECKED" ]] || return - - # If we've checked for updates, we don't need to check again. - export HOMEBREW_AUTO_UPDATE_CHECKED="1" +# Use this configuration file instead of ~/.ssh/config when fetching git over SSH. +if [[ -n "${HOMEBREW_SSH_CONFIG_PATH}" ]] +then + export GIT_SSH_COMMAND="ssh -F${HOMEBREW_SSH_CONFIG_PATH}" +fi - if [[ "$HOMEBREW_COMMAND" = "cask" ]] - then - if [[ "$HOMEBREW_CASK_COMMAND" != "upgrade" && $HOMEBREW_ARG_COUNT -lt 3 ]] - then - return - fi - elif [[ "$HOMEBREW_COMMAND" != "upgrade" && $HOMEBREW_ARG_COUNT -lt 2 ]] +if [[ -n "${HOMEBREW_DOCKER_REGISTRY_TOKEN}" ]] +then + export HOMEBREW_GITHUB_PACKAGES_AUTH="Bearer ${HOMEBREW_DOCKER_REGISTRY_TOKEN}" +elif [[ -n "${HOMEBREW_DOCKER_REGISTRY_BASIC_AUTH_TOKEN}" ]] +then + # If the token is to the special value "none", unset any existing auth to use anonymous access. + if [[ "${HOMEBREW_DOCKER_REGISTRY_BASIC_AUTH_TOKEN}" == "none" ]] then - return + unset HOMEBREW_GITHUB_PACKAGES_AUTH + else + export HOMEBREW_GITHUB_PACKAGES_AUTH="Basic ${HOMEBREW_DOCKER_REGISTRY_BASIC_AUTH_TOKEN}" fi +else + export HOMEBREW_GITHUB_PACKAGES_AUTH="Bearer QQ==" +fi - if [[ "$HOMEBREW_COMMAND" = "install" || "$HOMEBREW_COMMAND" = "upgrade" || - "$HOMEBREW_COMMAND" = "bump-formula-pr" || - "$HOMEBREW_COMMAND" = "tap" && $HOMEBREW_ARG_COUNT -gt 1 || - "$HOMEBREW_CASK_COMMAND" = "install" || "$HOMEBREW_CASK_COMMAND" = "upgrade" ]] - then - export HOMEBREW_AUTO_UPDATING="1" - - # Skip auto-update if the cask/core tap has been updated in the - # last $HOMEBREW_AUTO_UPDATE_SECS. - if [[ "$HOMEBREW_COMMAND" = "cask" ]] - then - tap_fetch_head="$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-cask/.git/FETCH_HEAD" - else - tap_fetch_head="$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-core/.git/FETCH_HEAD" - fi - if [[ -f "$tap_fetch_head" && - -n "$(find "$tap_fetch_head" -type f -mtime -"${HOMEBREW_AUTO_UPDATE_SECS}"s 2>/dev/null)" ]] - then - return - fi - - if [[ -z "$HOMEBREW_VERBOSE" ]] - then - update-preinstall-timer & - timer_pid=$! - fi - - brew update --preinstall - - if [[ -n "$timer_pid" ]] - then - kill "$timer_pid" 2>/dev/null - wait "$timer_pid" 2>/dev/null - fi +# Avoid picking up any random `sudo` in `PATH`. +if [[ -x /usr/bin/sudo ]] +then + SUDO=/usr/bin/sudo +else + # Do this after ensuring we're using default Bash builtins. + SUDO="$(command -v sudo 2>/dev/null)" +fi - unset HOMEBREW_AUTO_UPDATING +# Reset sudo timestamp to avoid running unauthorized sudo commands +if [[ -n "${SUDO}" ]] +then + "${SUDO}" --reset-timestamp 2>/dev/null || true +fi +unset SUDO - # exec a new process to set any new environment variables. - exec "$HOMEBREW_BREW_FILE" "$@" - fi -} +# Remove internal variables +unset HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS -if [[ -n "$HOMEBREW_BASH_COMMAND" ]] +if [[ -n "${HOMEBREW_BASH_COMMAND}" ]] then # source rather than executing directly to ensure the entire file is read into # memory before it is run. This makes running a Bash script behave more like # a Ruby script and avoids hard-to-debug issues if the Bash script is updated # at the same time as being run. # - # Don't need shellcheck to follow this `source`. + # Shellcheck can't follow this dynamic `source`. # shellcheck disable=SC1090 - source "$HOMEBREW_BASH_COMMAND" - { update-preinstall "$@"; "homebrew-$HOMEBREW_COMMAND" "$@"; exit $?; } + source "${HOMEBREW_BASH_COMMAND}" + + { + auto-update "$@" + "homebrew-${HOMEBREW_COMMAND}" "$@" + exit $? + } + else - # Don't need shellcheck to follow this `source`. - # shellcheck disable=SC1090 - source "$HOMEBREW_LIBRARY/Homebrew/utils/ruby.sh" + source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" setup-ruby-path # Unshift command back into argument list (unless argument list was empty). - [[ "$HOMEBREW_ARG_COUNT" -gt 0 ]] && set -- "$HOMEBREW_COMMAND" "$@" - { update-preinstall "$@"; exec "$HOMEBREW_RUBY_PATH" $HOMEBREW_RUBY_WARNINGS "$RUBY_DISABLE_OPTIONS" "$HOMEBREW_LIBRARY/Homebrew/brew.rb" "$@"; } + [[ "${HOMEBREW_ARG_COUNT}" -gt 0 ]] && set -- "${HOMEBREW_COMMAND}" "$@" + # HOMEBREW_RUBY_PATH set by utils/ruby.sh + # shellcheck disable=SC2154 + { + auto-update "$@" + exec "${HOMEBREW_RUBY_PATH}" "${HOMEBREW_RUBY_WARNINGS}" "${HOMEBREW_RUBY_DISABLE_OPTIONS}" \ + "${HOMEBREW_LIBRARY}/Homebrew/brew.rb" "$@" + } fi diff --git a/Library/Homebrew/brew_irb_helpers.rb b/Library/Homebrew/brew_irb_helpers.rb new file mode 100644 index 0000000000000..f07bfa1f2ee77 --- /dev/null +++ b/Library/Homebrew/brew_irb_helpers.rb @@ -0,0 +1,23 @@ +# typed: strict +# frozen_string_literal: true + +# Helper methods for the Homebrew IRB/PRY shell run by `brew irb` + +require "formula" +require "formulary" +require "cask/cask_loader" + +class String + # @!visibility private + sig { params(args: Integer).returns(Formula) } + def f(*args) + Formulary.factory(self, *args) + end + + # @!visibility private + sig { params(config: T.nilable(T::Hash[Symbol, T.untyped])).returns(Cask::Cask) } + def c(config: nil) + Cask::CaskLoader.load(self, config: Cask::Config.new(**config)) + end +end +require "brew_irb_helpers/symbol" diff --git a/Library/Homebrew/brew_irb_helpers/symbol.rb b/Library/Homebrew/brew_irb_helpers/symbol.rb new file mode 100644 index 0000000000000..7e18a0695186a --- /dev/null +++ b/Library/Homebrew/brew_irb_helpers/symbol.rb @@ -0,0 +1,16 @@ +# typed: strict +# frozen_string_literal: true + +class Symbol + # @!visibility private + sig { params(args: Integer).returns(Formula) } + def f(*args) + to_s.f(*args) + end + + # @!visibility private + sig { params(config: T.nilable(T::Hash[Symbol, T.untyped])).returns(Cask::Cask) } + def c(config: nil) + to_s.c(config:) + end +end diff --git a/Library/Homebrew/brew_irbrc b/Library/Homebrew/brew_irbrc new file mode 100644 index 0000000000000..07df706732b43 --- /dev/null +++ b/Library/Homebrew/brew_irbrc @@ -0,0 +1,11 @@ +# NOTE: We use a non-standard config file name to reduce name clashes with +# other IRB config files like `.irbrc`. +# NOTE: This doesn't work with system Ruby for some reason. + +require_relative "global" +require_relative "brew_irb_helpers" +require "irb/completion" + +IRB.conf[:HISTORY_FILE] = "#{Dir.home}/.brew_irb_history" +IRB.conf[:IRB_NAME] = "brew" +H = Homebrew diff --git a/Library/Homebrew/build.rb b/Library/Homebrew/build.rb index 5826a86430725..be833176c02f7 100644 --- a/Library/Homebrew/build.rb +++ b/Library/Homebrew/build.rb @@ -1,73 +1,87 @@ +# typed: strict # frozen_string_literal: true # This script is loaded by formula_installer as a separate instance. # Thrown exceptions are propagated back to the parent process over a pipe +raise "#{__FILE__} must not be loaded via `require`." if $PROGRAM_NAME != __FILE__ + old_trap = trap("INT") { exit! 130 } -require "global" +require_relative "global" require "build_options" -require "cxxstdlib" require "keg" require "extend/ENV" -require "debrew" require "fcntl" -require "socket" +require "utils/socket" +require "cmd/install" +require "json/add/exception" +require "utils/output" +require "extend/pathname/write_mkpath_extension" +# A formula build. class Build - attr_reader :formula, :deps, :reqs + include Utils::Output::Mixin + + sig { returns(Formula) } + attr_reader :formula + + sig { returns(T::Array[Dependency]) } + attr_reader :deps + + sig { returns(Requirements) } + attr_reader :reqs - def initialize(formula, options) + sig { returns(Homebrew::Cmd::InstallCmd::Args) } + attr_reader :args + + sig { params(formula: Formula, options: Options, args: Homebrew::Cmd::InstallCmd::Args).void } + def initialize(formula, options, args:) @formula = formula @formula.build = BuildOptions.new(options, formula.options) + @args = args + @deps = T.let([], T::Array[Dependency]) + @reqs = T.let(Requirements.new, Requirements) - if ARGV.ignore_deps? - @deps = [] - @reqs = [] - else - @deps = expand_deps - @reqs = expand_reqs - end - end + return if args.ignore_dependencies? - def post_superenv_hacks - # Only allow Homebrew-approved directories into the PATH, unless - # a formula opts-in to allowing the user's path. - return unless formula.env.userpaths? || reqs.any? { |rq| rq.env.userpaths? } - - ENV.userpaths! + @deps = expand_deps + @reqs = expand_reqs end + sig { params(dependent: Formula).returns(BuildOptions) } def effective_build_options_for(dependent) args = dependent.build.used_options args |= Tab.for_formula(dependent).used_options BuildOptions.new(args, dependent.options) end + sig { returns(Requirements) } def expand_reqs formula.recursive_requirements do |dependent, req| + dependent = T.cast(dependent, Formula) build = effective_build_options_for(dependent) - if req.prune_from_option?(build) - Requirement.prune - elsif req.prune_if_build_and_not_dependent?(dependent, formula) - Requirement.prune + if req.prune_from_option?(build) || req.prune_if_build_and_not_dependent?(dependent, formula) || req.test? + next Dependable::PRUNE end end end + sig { returns(T::Array[Dependency]) } def expand_deps formula.recursive_dependencies do |dependent, dep| - build = effective_build_options_for(dependent) - if dep.prune_from_option?(build) - Dependency.prune - elsif dep.prune_if_build_and_not_dependent?(dependent, formula) - Dependency.prune + build = effective_build_options_for(T.cast(dependent, Formula)) + if dep.prune_from_option?(build) || + dep.prune_if_build_and_not_dependent?(T.cast(dependent, Formula), formula) || + (dep.test? && !dep.build?) || dep.implicit? + next Dependable::PRUNE elsif dep.build? - Dependency.keep_but_prune_recursive_deps + next Dependable::KEEP_BUT_PRUNE_RECURSIVE_DEPS end end end + sig { void } def install formula_deps = deps.map(&:to_formula) keg_only_deps = formula_deps.select(&:keg_only?) @@ -77,21 +91,38 @@ def install fixopt(dep) unless dep.opt_prefix.directory? end - ENV.activate_extensions! - - if superenv? - ENV.keg_only_deps = keg_only_deps - ENV.deps = formula_deps - ENV.run_time_deps = run_time_deps - ENV.x11 = reqs.any? { |rq| rq.is_a?(X11Requirement) } - ENV.setup_build_environment(formula) - post_superenv_hacks - reqs.each(&:modify_build_environment) - deps.each(&:modify_build_environment) + ENV.activate_extensions!(env: args.env) + + if superenv?(args.env) + superenv = ENV + superenv.keg_only_deps = keg_only_deps + superenv.deps = formula_deps + superenv.run_time_deps = run_time_deps + ENV.setup_build_environment( + formula:, + cc: args.cc, + build_bottle: args.build_bottle?, + bottle_arch: args.bottle_arch, + debug_symbols: args.debug_symbols?, + ) + reqs.each do |req| + req.modify_build_environment( + env: args.env, cc: args.cc, build_bottle: args.build_bottle?, bottle_arch: args.bottle_arch, + ) + end else - ENV.setup_build_environment(formula) - reqs.each(&:modify_build_environment) - deps.each(&:modify_build_environment) + ENV.setup_build_environment( + formula:, + cc: args.cc, + build_bottle: args.build_bottle?, + bottle_arch: args.bottle_arch, + debug_symbols: args.debug_symbols?, + ) + reqs.each do |req| + req.modify_build_environment( + env: args.env, cc: args.cc, build_bottle: args.build_bottle?, bottle_arch: args.bottle_arch, + ) + end keg_only_deps.each do |dep| ENV.prepend_path "PATH", dep.opt_bin.to_s @@ -105,60 +136,94 @@ def install end new_env = { - "TMPDIR" => HOMEBREW_TEMP, - "TEMP" => HOMEBREW_TEMP, - "TMP" => HOMEBREW_TEMP, + "TMPDIR" => HOMEBREW_TEMP.to_s, + "TEMP" => HOMEBREW_TEMP.to_s, + "TMP" => HOMEBREW_TEMP.to_s, } with_env(new_env) do - formula.extend(Debrew::Formula) if ARGV.debug? - - formula.brew do |_formula, staging| - # For head builds, HOMEBREW_FORMULA_PREFIX should include the commit, - # which is not known until after the formula has been staged. - ENV["HOMEBREW_FORMULA_PREFIX"] = formula.prefix - - staging.retain! if ARGV.keep_tmp? - formula.patch + if args.debug? && !Homebrew::EnvConfig.disable_debrew? + require "debrew" + formula.extend(Debrew::Formula) + end - if ARGV.git? - system "git", "init" - system "git", "add", "-A" - end - if ARGV.interactive? - ohai "Entering interactive mode" - puts "Type `exit` to return and finalize the installation." - puts "Install to this prefix: #{formula.prefix}" - - if ARGV.git? - puts "This directory is now a git repo. Make your changes and then use:" - puts " git diff | pbcopy" - puts "to copy the diff to the clipboard." + formula.update_head_version + + formula.brew( + fetch: false, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + interactive: args.interactive?, + ) do + with_env( + # For head builds, HOMEBREW_FORMULA_PREFIX should include the commit, + # which is not known until after the formula has been staged. + HOMEBREW_FORMULA_PREFIX: formula.prefix, + # https://reproducible-builds.org/docs/build-path/ + HOMEBREW_FORMULA_BUILDPATH: formula.buildpath, + # https://reproducible-builds.org/docs/source-date-epoch/ + SOURCE_DATE_EPOCH: formula.source_modified_time.to_i.to_s, + # Avoid make getting confused about timestamps. + # https://github.com/Homebrew/homebrew-core/pull/87470 + TZ: "UTC0", + ) do + if args.git? + formula.selective_patch(is_data: false) + system "git", "init" + system "git", "add", "-A" + formula.selective_patch(is_data: true) + else + formula.patch end - interactive_shell(formula) - else - formula.prefix.mkpath - - (formula.logs/"00.options.out").write \ - "#{formula.full_name} #{formula.build.used_options.sort.join(" ")}".strip - formula.install - - stdlibs = detect_stdlibs(ENV.compiler) - tab = Tab.create(formula, ENV.compiler, stdlibs.first) - tab.write - - # Find and link metafiles - formula.prefix.install_metafiles formula.buildpath - formula.prefix.install_metafiles formula.libexec if formula.libexec.exist? + if args.interactive? + ohai "Entering interactive mode..." + puts <<~EOS + Type `exit` to return and finalize the installation. + Install to this prefix: #{formula.prefix} + EOS + + if args.git? + puts <<~EOS + This directory is now a Git repository. Make your changes and then use: + git diff | pbcopy + to copy the diff to the clipboard. + EOS + end + + interactive_shell(formula) + else + formula.prefix.mkpath + formula.logs.mkpath + + (formula.logs/"00.options.out").write \ + "#{formula.full_name} #{formula.build.used_options.sort.join(" ")}".strip + + Pathname.activate_extensions! + formula.install + + stdlibs = detect_stdlibs + tab = Tab.create(formula, ENV.compiler, stdlibs.first) + tab.write + + # Find and link metafiles + formula.prefix.install_metafiles T.must(formula.buildpath) + if formula.libexec.exist? + require "metafiles" + no_metafiles = formula.prefix.children.none? { |p| p.file? && Metafiles.copy?(p.basename.to_s) } + formula.prefix.install_metafiles formula.libexec if no_metafiles + end + + normalize_pod2man_outputs!(formula) + end end end end end - def detect_stdlibs(compiler) + sig { returns(T::Array[Symbol]) } + def detect_stdlibs keg = Keg.new(formula.prefix) - CxxStdlib.check_compatibility(formula, deps, keg, compiler) # The stdlib recorded in the install receipt is used during dependency # compatibility checks, so we only care about the stdlib that libraries @@ -166,32 +231,57 @@ def detect_stdlibs(compiler) keg.detect_cxx_stdlibs(skip_executables: true) end - def fixopt(f) - path = if f.linked_keg.directory? && f.linked_keg.symlink? - f.linked_keg.resolved_path - elsif f.prefix.directory? - f.prefix - elsif (kids = f.rack.children).size == 1 && kids.first.directory? - kids.first + sig { params(formula: Formula).void } + def fixopt(formula) + path = if formula.linked_keg.directory? && formula.linked_keg.symlink? + formula.linked_keg.resolved_path + elsif formula.prefix.directory? + formula.prefix + elsif (children = formula.rack.children.presence) && children.size == 1 && + (first_child = children.first) && first_child.directory? + first_child else raise end - Keg.new(path).optlink + Keg.new(path).optlink(verbose: args.verbose?) rescue - raise "#{f.opt_prefix} not present or broken\nPlease reinstall #{f.full_name}. Sorry :(" + raise "#{formula.opt_prefix} not present or broken\nPlease reinstall #{formula.full_name}. Sorry :(" + end + + sig { params(formula: Formula).void } + def normalize_pod2man_outputs!(formula) + keg = Keg.new(formula.prefix) + keg.normalize_pod2man_outputs! end end begin - error_pipe = UNIXSocket.open(ENV["HOMEBREW_ERROR_PIPE"], &:recv_io) + # Undocumented opt-out for internal use. + # We need to allow formulae from paths here due to how we pass them through. + ENV["HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS"] = "1" + + formula_path = ARGV.first + args = Homebrew::Cmd::InstallCmd.new.args + Context.current = args.context + + error_pipe = Utils::UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io) error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC) trap("INT", old_trap) - formula = ARGV.formulae.first - options = Options.create(ARGV.flags_only) - build = Build.new(formula, options) + if formula_path&.end_with?(".json") + raise "build.rb received an API JSON file as the formula path: #{formula_path}. " \ + "This usually means the formula source was not downloaded from the API. " \ + "Try clearing the cache: rm -rf $(brew --cache)/api-source" + end + + formula = args.named.to_formulae.fetch(0) + options = Options.create(args.flags_only) + build = Build.new(formula, options, args:) + build.install +# Any exception means the build did not complete. +# The `case` for what to do per-exception class is further down. rescue Exception => e # rubocop:disable Lint/RescueException error_hash = JSON.parse e.to_json @@ -200,17 +290,25 @@ def fixopt(f) # BuildErrors are specific to build processes and not other # children, which is why we create the necessary state here # and not in Utils.safe_fork. - if error_hash["json_class"] == "BuildError" + case e + when BuildError error_hash["cmd"] = e.cmd error_hash["args"] = e.args error_hash["env"] = e.env - elsif error_hash["json_class"] == "ErrorDuringExecution" + when ErrorDuringExecution error_hash["cmd"] = e.cmd - error_hash["status"] = e.status.exitstatus + error_hash["status"] = if e.status.is_a?(Process::Status) + { + exitstatus: e.exitstatus, + termsig: e.termsig, + } + else + e.status + end error_hash["output"] = e.output end - error_pipe.puts error_hash.to_json - error_pipe.close + error_pipe&.puts error_hash.to_json + error_pipe&.close exit! 1 end diff --git a/Library/Homebrew/build_environment.rb b/Library/Homebrew/build_environment.rb index ad0508d22af49..e5cf72a00091e 100644 --- a/Library/Homebrew/build_environment.rb +++ b/Library/Homebrew/build_environment.rb @@ -1,68 +1,88 @@ +# typed: strict # frozen_string_literal: true +# Settings for the build environment. class BuildEnvironment + sig { params(settings: Symbol).void } def initialize(*settings) - @settings = Set.new(*settings) + @settings = T.let(Set.new(settings), T::Set[Symbol]) end + sig { params(args: T::Enumerable[Symbol]).returns(T.self_type) } def merge(*args) @settings.merge(*args) self end - def <<(o) - @settings << o + sig { params(option: Symbol).returns(T.self_type) } + def <<(option) + @settings << option self end + sig { returns(T::Boolean) } def std? @settings.include? :std end - def userpaths? - @settings.include? :userpaths - end - + # DSL for specifying build environment settings. module DSL + # Initialise @env for each class which may use this DSL (e.g. each formula subclass). + # `env` may never be called and it needs to be initialised before the class is frozen. + sig { params(child: T.untyped).void } + def inherited(child) + super + child.instance_eval do + @env = T.let(BuildEnvironment.new, T.nilable(BuildEnvironment)) + end + end + + sig { overridable.params(settings: Symbol).returns(T.nilable(BuildEnvironment)) } def env(*settings) - @env ||= BuildEnvironment.new - @env.merge(settings) + env = @env + Kernel.raise ArgumentError, "#{self} has not been initialized with a BuildEnvironment" if env.nil? + + env.merge(settings) end end -end -module Homebrew - module_function + KEYS = %w[ + CC CXX LD OBJC OBJCXX + HOMEBREW_CC + CFLAGS CXXFLAGS CPPFLAGS LDFLAGS SDKROOT MAKEFLAGS + CMAKE_PREFIX_PATH CMAKE_INCLUDE_PATH CMAKE_LIBRARY_PATH CMAKE_FRAMEWORK_PATH + MACOSX_DEPLOYMENT_TARGET PKG_CONFIG_PATH PKG_CONFIG_LIBDIR + HOMEBREW_DEBUG HOMEBREW_MAKE_JOBS HOMEBREW_VERBOSE + all_proxy ftp_proxy http_proxy https_proxy no_proxy + HOMEBREW_SVN HOMEBREW_GIT + HOMEBREW_SDKROOT + MAKE GIT CPP + ACLOCAL_PATH PATH CPATH + LD_LIBRARY_PATH LD_RUN_PATH LD_PRELOAD LIBRARY_PATH + ].freeze + private_constant :KEYS - def build_env_keys(env) - %w[ - CC CXX LD OBJC OBJCXX - HOMEBREW_CC HOMEBREW_CXX - CFLAGS CXXFLAGS CPPFLAGS LDFLAGS SDKROOT MAKEFLAGS - CMAKE_PREFIX_PATH CMAKE_INCLUDE_PATH CMAKE_LIBRARY_PATH CMAKE_FRAMEWORK_PATH - MACOSX_DEPLOYMENT_TARGET PKG_CONFIG_PATH PKG_CONFIG_LIBDIR - HOMEBREW_DEBUG HOMEBREW_MAKE_JOBS HOMEBREW_VERBOSE - HOMEBREW_SVN HOMEBREW_GIT - HOMEBREW_SDKROOT - MAKE GIT CPP - ACLOCAL_PATH PATH CPATH - LD_LIBRARY_PATH LD_RUN_PATH LD_PRELOAD LIBRARY_PATH - ].select { |key| env.key?(key) } + sig { params(env: T::Hash[String, T.nilable(T.any(String, T::Boolean, PATH))]).returns(T::Array[String]) } + def self.keys(env) + KEYS & env.keys end - def dump_build_env(env, f = $stdout) - keys = build_env_keys(env) + sig { params(env: T::Hash[String, T.nilable(T.any(String, T::Boolean, PATH))], out: IO).void } + def self.dump(env, out = $stdout) + keys = self.keys(env) keys -= %w[CC CXX OBJC OBJCXX] if env["CC"] == env["HOMEBREW_CC"] keys.each do |key| - value = env[key] - s = +"#{key}: #{value}" + value = env.fetch(key) + + string = "#{key}: #{value}" case key when "CC", "CXX", "LD" - s << " => #{Pathname.new(value).realpath}" if File.symlink?(value) + value = T.cast(value, String) + string << " => #{Pathname.new(value).realpath}" if value.present? && File.symlink?(value) end - s.freeze - f.puts s + string.freeze + out.puts string end end end diff --git a/Library/Homebrew/build_options.rb b/Library/Homebrew/build_options.rb index fc81bb82182e6..0ce68cffe5516 100644 --- a/Library/Homebrew/build_options.rb +++ b/Library/Homebrew/build_options.rb @@ -1,32 +1,42 @@ +# typed: strict # frozen_string_literal: true +# Options for a formula build. class BuildOptions - # @private + sig { params(args: Options, options: Options).void } def initialize(args, options) @args = args @options = options end - # True if a {Formula} is being built with a specific option - # (which isn't named `with-*` or `without-*`). - # @deprecated - def include?(name) - @args.include?("--#{name}") - end - # True if a {Formula} is being built with a specific option. - #
args << "--i-want-spam" if build.with? "spam"
   #
+  # ### Examples
+  #
+  # ```ruby
+  # args << "--i-want-spam" if build.with? "spam"
+  # ```
+  #
+  # ```ruby
   # args << "--qt-gui" if build.with? "qt" # "--with-qt" ==> build.with? "qt"
+  # ```
+  #
+  # If a formula presents a user with a choice, but the choice must be fulfilled:
   #
-  # # If a formula presents a user with a choice, but the choice must be fulfilled:
+  # ```ruby
   # if build.with? "example2"
   #   args << "--with-example2"
   # else
   #   args << "--with-example1"
-  # end
+ # end + # ``` + sig { params(val: T.any(String, Dependable)).returns(T::Boolean) } def with?(val) - option_names = val.respond_to?(:option_names) ? val.option_names : [val] + option_names = if val.is_a?(String) + [val] + else + val.option_names + end option_names.any? do |name| if option_defined? "with-#{name}" @@ -40,69 +50,81 @@ def with?(val) end # True if a {Formula} is being built without a specific option. - #
args << "--no-spam-plz" if build.without? "spam"
+ # + # ### Example + # + # ```ruby + # args << "--no-spam-plz" if build.without? "spam" + # ``` + sig { params(val: T.any(String, Dependable)).returns(T::Boolean) } def without?(val) !with?(val) end # True if a {Formula} is being built as a bottle (i.e. binary package). + sig { returns(T::Boolean) } def bottle? include? "build-bottle" end # True if a {Formula} is being built with {Formula.head} instead of {Formula.stable}. - #
args << "--some-new-stuff" if build.head?
- #
# If there are multiple conditional arguments use a block instead of lines.
-  #  if build.head?
-  #    args << "--i-want-pizza"
-  #    args << "--and-a-cold-beer" if build.with? "cold-beer"
-  #  end
+ # + # ### Examples + # + # ```ruby + # args << "--some-new-stuff" if build.head? + # ``` + # + # If there are multiple conditional arguments use a block instead of lines. + # + # ```ruby + # if build.head? + # args << "--i-want-pizza" + # args << "--and-a-cold-beer" if build.with? "cold-beer" + # end + # ``` + sig { returns(T::Boolean) } def head? include? "HEAD" end - # True if a {Formula} is being built with {Formula.devel} instead of {Formula.stable}. - #
args << "--some-beta" if build.devel?
- def devel? - include? "devel" - end - - # True if a {Formula} is being built with {Formula.stable} instead of {Formula.devel} - # or {Formula.head}. This is the default. - #
args << "--some-beta" if build.devel?
+ # True if a {Formula} is being built with {Formula.stable} instead of {Formula.head}. + # This is the default. + # + # ### Example + # + # ```ruby + # args << "--some-feature" if build.stable? + # ``` + sig { returns(T::Boolean) } def stable? - !(head? || devel?) - end - - # True if a {Formula} is being built universally. - # e.g. on newer Intel Macs this means a combined x86_64/x86 binary/library. - #
args << "--universal-binary" if build.universal?
- def universal? - include?("universal") && option_defined?("universal") - end - - # True if a {Formula} is being built in C++11 mode. - def cxx11? - include?("c++11") && option_defined?("c++11") + !head? end # True if the build has any arguments or options specified. + sig { returns(T::Boolean) } def any_args_or_options? !@args.empty? || !@options.empty? end - # @private + sig { returns(Options) } def used_options @options & @args end - # @private + sig { returns(Options) } def unused_options @options - @args end private + sig { params(name: String).returns(T::Boolean) } + def include?(name) + @args.include?("--#{name}") + end + + sig { params(name: String).returns(T::Boolean) } def option_defined?(name) @options.include? name end diff --git a/Library/Homebrew/bump.rb b/Library/Homebrew/bump.rb new file mode 100644 index 0000000000000..c77f861ac231f --- /dev/null +++ b/Library/Homebrew/bump.rb @@ -0,0 +1,168 @@ +# typed: strict +# frozen_string_literal: true + +require "system_command" +require "tap" +require "utils/formatter" +require "utils/git" +require "utils/github" +require "utils/output" +require "utils/popen" + +module Homebrew + # @api internal + module Bump + extend SystemCommand::Mixin + extend Utils::Output::Mixin + + class Commit < T::Struct + const :sourcefile_path, Pathname + const :old_contents, String + const :commit_message, String + const :additional_files, T::Array[Pathname], default: [] + end + + class BumpInfo < T::Struct + const :package_tap, Tap + const :branch_name, String + const :pr_title, String + const :pr_message, String + const :commits, T::Array[Commit] + end + + sig { params(command: String, user_message: T.nilable(String)).returns(String) } + def self.pr_message(command, user_message:) + pr_message = "" + if user_message.present? + pr_message += <<~EOS + #{user_message} + + --- + + EOS + end + pr_message += "Created with `brew #{command}`." + pr_message + end + + sig { params(info: BumpInfo, dry_run: T::Boolean, no_fork: T::Boolean, fork_org: T.nilable(String), commit: T::Boolean).returns(T.nilable(String)) } + def self.create_pr(info, dry_run: false, no_fork: false, fork_org: nil, commit: false) + tap = info.package_tap + branch = info.branch_name + pr_message = info.pr_message + pr_title = info.pr_title + commits = info.commits + + tap_remote_repo = tap.remote_repository + raise ArgumentError, "The tap #{tap.name} does not have a remote repository!" unless tap_remote_repo + + remote_branch = tap.git_repository.origin_branch_name + raise "The tap #{tap.name} does not have a default branch!" if remote_branch.blank? + + remote_url = T.let(nil, T.nilable(String)) + username = T.let(nil, T.nilable(String)) + + tap.path.cd do + if no_fork + remote_url = Utils.popen_read("git", "remote", "get-url", "--push", "origin").chomp + username = tap.user + add_auth_token_to_url!(remote_url) + else + begin + remote_url, username = forked_repo_info!(tap_remote_repo, org: fork_org) + rescue *GitHub::API::ERRORS => e + commits.each do |commit| + commit.sourcefile_path.atomic_write(commit.old_contents) + end + odie "Unable to fork: #{e.message}!" + end + end + + next if dry_run + + git_dir = Utils.popen_read("git", "rev-parse", "--git-dir").chomp + shallow = !git_dir.empty? && File.exist?("#{git_dir}/shallow") + safe_system "git", "fetch", "--unshallow", "origin" if !commit && shallow + safe_system "git", "checkout", "--no-track", "-b", branch, "origin/#{remote_branch}" unless commit + Utils::Git.set_name_email! + end + + commits.each do |commit| + sourcefile_path = commit.sourcefile_path + commit_message = commit.commit_message + additional_files = commit.additional_files + + sourcefile_path.parent.cd do + git_dir = Utils.popen_read("git", "rev-parse", "--git-dir").chomp + shallow = !git_dir.empty? && File.exist?("#{git_dir}/shallow") + changed_files = [sourcefile_path] + changed_files += additional_files if additional_files.present? + + if dry_run + ohai "git checkout --no-track -b #{branch} origin/#{remote_branch}" + ohai "git fetch --unshallow origin" if shallow + ohai "git add #{changed_files.join(" ")}" + ohai "git commit --no-edit --verbose --message='#{commit_message}' " \ + "-- #{changed_files.join(" ")}" + ohai "git push --set-upstream #{redacted_url(remote_url)} #{branch}:#{branch}" + ohai "git checkout --quiet -" + ohai "create pull request with GitHub API (base branch: #{remote_branch})" + else + safe_system "git", "add", *changed_files + Utils::Git.set_name_email! + safe_system "git", "commit", "--no-edit", "--verbose", + "--message=#{commit_message}", + "--", *changed_files + end + end + end + + return if commit || dry_run + return unless remote_url + + tap.path.cd do + system_command!("git", args: ["push", "--set-upstream", remote_url, "#{branch}:#{branch}"], + print_stdout: true) + safe_system "git", "checkout", "--quiet", "-" + + begin + return GitHub.create_pull_request(tap_remote_repo, pr_title, + "#{username}:#{branch}", remote_branch, pr_message)["html_url"] + rescue *GitHub::API::ERRORS => e + commits.each do |commit| + commit.sourcefile_path.atomic_write(commit.old_contents) + end + odie "Unable to open pull request for #{tap_remote_repo}: #{e.message}!" + end + end + end + + sig { params(url: String).returns(String) } + private_class_method def self.add_auth_token_to_url!(url) + if GitHub::API.credentials_type == :env_token + url.sub!(%r{^https://github\.com/}, "https://x-access-token:#{GitHub::API.credentials}@github.com/") + end + url + end + + # Redact any token `add_auth_token_to_url!` embedded, so dry-run output doesn't leak it. + sig { params(url: T.nilable(String)).returns(String) } + private_class_method def self.redacted_url(url) + Formatter.redact_secrets(url.to_s, [GitHub::API.credentials].compact) + end + + sig { params(tap_remote_repo: String, org: T.nilable(String)).returns([String, String]) } + private_class_method def self.forked_repo_info!(tap_remote_repo, org: nil) + response = GitHub.create_fork(tap_remote_repo, org:) + # GitHub API responds immediately but fork takes a few seconds to be ready. + sleep 1 until GitHub.fork_exists?(tap_remote_repo, org:) + remote_url = if system("git", "config", "--local", "--get-regexp", "remote..*.url", "git@github.com:.*") + response.fetch("ssh_url") + else + add_auth_token_to_url!(response.fetch("clone_url")) + end + username = response.fetch("owner").fetch("login") + [remote_url, username] + end + end +end diff --git a/Library/Homebrew/bump_version_parser.rb b/Library/Homebrew/bump_version_parser.rb new file mode 100644 index 0000000000000..17063e2523e66 --- /dev/null +++ b/Library/Homebrew/bump_version_parser.rb @@ -0,0 +1,66 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + # Class handling architecture-specific version information. + class BumpVersionParser + VERSION_SYMBOLS = [:general, :arm, :intel].freeze + + sig { returns(T.nilable(T.any(Version, Cask::DSL::Version))) } + attr_reader :arm, :general, :intel + + sig { + params(general: T.nilable(T.any(Version, String)), + arm: T.nilable(T.any(Version, String)), + intel: T.nilable(T.any(Version, String))).void + } + def initialize(general: nil, arm: nil, intel: nil) + @general = T.let(parse_version(general), T.nilable(T.any(Version, Cask::DSL::Version))) if general.present? + @arm = T.let(parse_version(arm), T.nilable(T.any(Version, Cask::DSL::Version))) if arm.present? + @intel = T.let(parse_version(intel), T.nilable(T.any(Version, Cask::DSL::Version))) if intel.present? + + return if @general.present? + raise UsageError, "`--version` must not be empty." if arm.blank? && intel.blank? + end + + sig { + params(version: T.any(Version, String)) + .returns(T.nilable(T.any(Version, Cask::DSL::Version))) + } + def parse_version(version) + if version.is_a?(Version) + version + elsif version.is_a?(String) + parse_cask_version(version) + else + # :nocov: + T.absurd(version) + # :nocov: + end + end + + sig { params(version: String).returns(T.nilable(Cask::DSL::Version)) } + def parse_cask_version(version) + if version == "latest" + Cask::DSL::Version.new(:latest) + else + Cask::DSL::Version.new(version) + end + end + + sig { returns(T::Boolean) } + def blank? + @general.blank? && @arm.blank? && @intel.blank? + end + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + case other + when BumpVersionParser + (general == other.general) && (arm == other.arm) && (intel == other.intel) + else + false + end + end + end +end diff --git a/Library/Homebrew/bundle.rb b/Library/Homebrew/bundle.rb new file mode 100644 index 0000000000000..04cceb71de2ee --- /dev/null +++ b/Library/Homebrew/bundle.rb @@ -0,0 +1,177 @@ +# typed: strict +# frozen_string_literal: true + +require "English" + +module Homebrew + module Bundle + class << self + sig { params(args_upgrade_formula: T.nilable(String)).void } + def upgrade_formulae=(args_upgrade_formula) + @upgrade_formulae = args_upgrade_formula.to_s.split(",") + end + + sig { returns(T::Array[String]) } + def upgrade_formulae + @upgrade_formulae || [] + end + + sig { params(cmd: T.any(String, Pathname), args: T.anything, verbose: T::Boolean).returns(T::Boolean) } + def system(cmd, *args, verbose: false) + return super cmd, *args if verbose + + env = {} + + # Make sure node's bin opt path is part of the PATH + # This is essential for the npm bundle extension that calls node directly + if Npm.package_manager_executable && cmd.to_s == Npm.package_manager_executable.to_s + node_bin = "#{HOMEBREW_PREFIX}/opt/node/bin" + env["PATH"] = "#{ENV.fetch("PATH")}:#{node_bin}" + end + + logs = [] + success = T.let(false, T::Boolean) + IO.popen(env, [cmd, *args], err: [:child, :out]) do |pipe| + while (buf = pipe.gets) + logs << buf + end + Process.wait(pipe.pid) + success = $CHILD_STATUS.success? + pipe.close + end + puts logs.join unless success + success + end + + sig { params(args: T.anything, verbose: T::Boolean).returns(T::Boolean) } + def brew(*args, verbose: false) + system(HOMEBREW_BREW_FILE, *args, verbose:) + end + + sig { returns(T::Boolean) } + def cask_installed? + @cask_installed ||= File.directory?("#{HOMEBREW_PREFIX}/Caskroom") && + (File.directory?("#{HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask") || + !Homebrew::EnvConfig.no_install_from_api?) + end + + sig { params(block: T.proc.returns(T.anything)).returns(T.untyped) } + def exchange_uid_if_needed!(&block) + euid = Process.euid + uid = Process.uid + return yield if euid == uid + + old_euid = euid + process_reexchangeable = Process::UID.re_exchangeable? + if process_reexchangeable + Process::UID.re_exchange + else + Process::Sys.seteuid(uid) + end + + home = T.must(Etc.getpwuid(Process.uid)).dir + return_value = with_env("HOME" => home, &block) + + if process_reexchangeable + Process::UID.re_exchange + else + Process::Sys.seteuid(old_euid) + end + + return_value + end + + sig { params(formula_name: String).returns(T.nilable(String)) } + def formula_versions_from_env(formula_name) + @formula_versions_from_env ||= begin + formula_versions = {} + + ENV.each do |key, value| + match = key.match(/^HOMEBREW_BUNDLE_FORMULA_VERSION_(.+)$/) + next if match.blank? + + env_formula_name = match[1] + next if env_formula_name.blank? + + ENV.delete(key) + formula_versions[env_formula_name] = value + end + + formula_versions + end + + # Fix up formula name for a valid environment variable name. + formula_env_name = formula_name.upcase + .gsub("@", "AT") + .tr("+", "X") + .tr("-", "_") + + @formula_versions_from_env[formula_env_name] + end + + sig { returns(T.nilable(T::Hash[String, String])) } + def formula_versions_from_env_cache + @formula_versions_from_env + end + + sig { params(formula_versions: T.nilable(T::Hash[String, String])).void } + def formula_versions_from_env_cache=(formula_versions) + @formula_versions_from_env = formula_versions + end + + sig { void } + def prepend_pkgconf_path_if_needed!; end + + sig { void } + def reset! + @cask_installed = T.let(nil, T.nilable(T::Boolean)) + @formula_versions_from_env = T.let(nil, T.nilable(T::Hash[String, String])) + @upgrade_formulae = T.let(nil, T.nilable(T::Array[String])) + end + + # Marks Brewfile formulae as installed_on_request to prevent autoremove + # from removing them when their dependents are uninstalled. + sig { params(entries: T::Array[Dsl::Entry]).void } + def mark_as_installed_on_request!(entries) + return if entries.empty? + + require "tab" + + installed_formulae = Formula.installed_formula_names + return if installed_formulae.empty? + + use_brew_tab = T.let(false, T::Boolean) + + formulae_to_update = entries.filter_map do |entry| + next if entry.type != :brew + + name = entry.name + next if installed_formulae.exclude?(name) + + tab = Tab.for_name(name) + tabfile = tab.tabfile + next unless tabfile&.exist? + next if tab.installed_on_request + + next name if use_brew_tab + + tab.installed_on_request = true + + begin + tab.write + nil + rescue Errno::EACCES + # Some wrappers might treat `brew bundle` with lower permissions due to its execution of user code. + # Running through `brew tab` ensures proper privilege escalation by going through the wrapper again. + use_brew_tab = true + name + end + end + + brew "tab", "--installed-on-request", *formulae_to_update if use_brew_tab + end + end + end +end + +require "extend/os/bundle/bundle" diff --git a/Library/Homebrew/bundle/adder.rb b/Library/Homebrew/bundle/adder.rb new file mode 100644 index 0000000000000..4cf0ec3717a8f --- /dev/null +++ b/Library/Homebrew/bundle/adder.rb @@ -0,0 +1,42 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/brewfile" +require "bundle/dumper" + +module Homebrew + module Bundle + module Adder + module_function + + sig { params(args: String, type: Symbol, global: T::Boolean, file: String, describe: T::Boolean).void } + def add(*args, type:, global:, file:, describe: false) + brewfile_path = Brewfile.path(global:, file:) + brewfile_path.write("") unless brewfile_path.exist? + + brewfile = Brewfile.read(global:, file:) + content = brewfile.input + new_content = args.map do |arg| + desc = case type + when :brew + Formulary.factory(arg).desc + when :cask + ::Cask::CaskLoader.load(arg).desc + end + + entry = "#{type} \"#{arg}\"" + if describe && desc.present? + desc.split("\n").map { |s| "# #{s}\n" }.join + entry + else + entry + end + end + + content << new_content.join("\n") << "\n" + path = Dumper.brewfile_path(global:, file:) + + Dumper.write_file path, content + end + end + end +end diff --git a/Library/Homebrew/bundle/brew.rb b/Library/Homebrew/bundle/brew.rb new file mode 100644 index 0000000000000..95043683c0a5f --- /dev/null +++ b/Library/Homebrew/bundle/brew.rb @@ -0,0 +1,799 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "tsort" +require "utils" +require "utils/output" +require "bundle/package_type" +require "trust" + +module Homebrew + module Bundle + class Brew < Homebrew::Bundle::PackageType + extend Utils::Output::Mixin + + class << self + sig { override.returns(Symbol) } + def type = :brew + + sig { override.returns(String) } + def check_label = "Formula" + + sig { override.params(subclass: T.class_of(Homebrew::Bundle::PackageType)).void } + def inherited(subclass) + return if subclass.name == "Homebrew::Bundle::Brew::Services" + + super + end + + sig { override.void } + def reset! + require "bundle/brew_services" + + Homebrew::Bundle::Brew::Services.reset! + @installed_formulae = T.let(nil, T.nilable(T::Array[String])) + @outdated_formulae = T.let(nil, T.nilable(T::Array[String])) + @pinned_formulae = T.let(nil, T.nilable(T::Array[String])) + @formulae = T.let(nil, T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) + @formulae_by_full_name = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) + @formulae_by_name = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) + @formula_aliases = T.let(nil, T.nilable(T::Hash[String, String])) + @formula_oldnames = T.let(nil, T.nilable(T::Hash[String, String])) + end + + sig { override.params(name: String, no_upgrade: T::Boolean, verbose: T::Boolean, options: T.untyped).returns(T::Boolean) } + def preinstall!(name, no_upgrade: false, verbose: false, **options) + new(name, options).preinstall!(no_upgrade:, verbose:) + end + + sig { + override.params(name: String, preinstall: T::Boolean, no_upgrade: T::Boolean, verbose: T::Boolean, + force: T::Boolean, options: T.untyped).returns(T::Boolean) + } + def install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options) + new(name, options).install!(preinstall:, no_upgrade:, verbose:, force:) + end + + # Override makes `name` a required argument unlike the parent's default-argument signature. + # rubocop:disable Sorbet/AllowIncompatibleOverride + sig { + override(allow_incompatible: true).params(name: String, options: Homebrew::Bundle::EntryOptions).returns(String) + } + # rubocop:enable Sorbet/AllowIncompatibleOverride + def install_verb(name, options = {}) + _ = options + + return "Installing" unless formula_upgradable?(name) + + "Upgrading" + end + + sig { params(formula: String, no_upgrade: T::Boolean).returns(T::Boolean) } + def formula_installed_and_up_to_date?(formula, no_upgrade: false) + return false unless formula_installed?(formula) + return true if no_upgrade_with_args?(no_upgrade, formula) + + !formula_upgradable?(formula) + end + + sig { params(formula: String, options: Homebrew::Bundle::EntryOptions).returns(T.nilable(T::Boolean)) } + def link_status_to_check(formula, options) + unless options.key?(:link) + return case formula_dump_link_status(formula) + when true + false + when false + true + end + end + + expected_link_status?(options[:link], keg_only: Formula[formula].keg_only?) + end + + sig { params(link: Homebrew::Bundle::EntryOption, keg_only: T::Boolean).returns(T::Boolean) } + def expected_link_status?(link, keg_only:) + case link + when :overwrite, true + true + when false + false + else + !keg_only + end + end + + sig { params(formula: String).returns(T.nilable(T::Boolean)) } + def formula_dump_link_status(formula) + ( + @formulae_by_full_name&.[](formula) || + @formulae_by_name&.[](Utils.name_from_full_name(formula)) + )&.fetch(:link?) + end + + sig { params(no_upgrade: T::Boolean, formula_name: String).returns(T::Boolean) } + def no_upgrade_with_args?(no_upgrade, formula_name) + no_upgrade && Bundle.upgrade_formulae.exclude?(formula_name) + end + + sig { params(formula: String, array: T::Array[String]).returns(T::Boolean) } + def formula_in_array?(formula, array) + return true if array.include?(formula) + return true if array.include?(Utils.name_from_full_name(formula)) + + old_name = formula_oldnames[formula] + old_name ||= formula_oldnames[Utils.name_from_full_name(formula)] + return true if old_name && array.include?(old_name) + + resolved_full_name = formula_aliases[formula] + return false unless resolved_full_name + return true if array.include?(resolved_full_name) + return true if array.include?(Utils.name_from_full_name(resolved_full_name)) + + false + end + + sig { params(formula: String).returns(T::Boolean) } + def formula_installed?(formula) + # Fully qualified tap formulae can be checked by their Cellar rack name + # without loading the formula from an untrusted tap. + return installed_formulae.include?(Utils.name_from_full_name(formula)) if Utils.full_name?(formula) + + formula_in_array?(formula, installed_formulae) + end + + sig { params(formula: String).returns(T::Boolean) } + def formula_upgradable?(formula) + return false unless formula_installed?(formula) + + # Reading the formula is needed for authoritative outdated state, so + # report trust problems before the upgrade check tries to load it. + if Utils.full_name?(formula) && Homebrew::EnvConfig.require_tap_trust? + require "trust" + + unless Homebrew::Trust.trusted?(:formula, formula) + opoo "Cannot check whether #{formula} is outdated because its tap is not trusted. " \ + "Run `brew trust --formula #{formula}` to trust it." + return true + end + end + + # Check local cache first and then authoritative Homebrew source. + (formula_in_array?(formula, upgradable_formulae) && Formula[formula].outdated?) || false + end + + sig { returns(T::Array[String]) } + def installed_formulae + @installed_formulae ||= Formula.installed_formula_names + end + + sig { returns(T::Array[String]) } + def upgradable_formulae + outdated_formulae - pinned_formulae + end + + sig { returns(T::Array[String]) } + def outdated_formulae + @outdated_formulae ||= formulae.filter_map { |f| f[:name] if f[:outdated?] } + end + + sig { returns(T::Array[String]) } + def pinned_formulae + @pinned_formulae ||= formulae.filter_map { |f| f[:name] if f[:pinned?] } + end + + sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) } + def find_formula(name) + formula = T.cast(formulae_by_full_name(name), T.nilable(T::Hash[Symbol, T.untyped])) + formula.presence || formulae_by_name(name) + end + + sig { params(name: String).returns(T::Array[String]) } + def formula_dep_names(name) + find_formula(name)&.fetch(:dependencies, []) || [] + end + + # Returns recursive dependency names for lock conflict detection. + sig { params(name: String).returns(T::Set[String]) } + def recursive_dep_names(name) + require "formula" + Formula[name].recursive_dependencies.to_set(&:name) + rescue FormulaUnavailableError + Set.new + end + + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def formulae + return @formulae if @formulae + + formulae_by_full_name + # formulae_by_full_name sets @formulae as a side effect of calling sort! + T.cast(@formulae, T::Array[T::Hash[Symbol, T.untyped]]) + end + + # Returns the full `@formulae_by_full_name` map when called without a name, + # or a single formula's attribute hash when called with a name. + sig { + params(name: T.nilable(String)).returns( + T.nilable(T.any(T::Hash[Symbol, T.untyped], T::Hash[String, T::Hash[Symbol, T.untyped]])), + ) + } + def formulae_by_full_name(name = nil) + return @formulae_by_full_name[name] if name.present? && @formulae_by_full_name&.key?(name) + + require "formula" + require "formulary" + Formulary.enable_factory_cache! + + @formulae_by_name ||= {} + @formulae_by_full_name ||= {} + + if name.nil? + formulae = Formula.installed.map { add_formula(it) } + sort!(formulae) + return @formulae_by_full_name + end + + formula = Formula[name] + add_formula(formula) + rescue FormulaUnavailableError => e + opoo "'#{name}' formula is unreadable: #{e}" + {} + end + + sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) } + def formulae_by_name(name) + T.cast(formulae_by_full_name(name), T.nilable(T::Hash[Symbol, T.untyped])) || @formulae_by_name&.[](name) + end + + sig { override.params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def dump(describe: false, no_restart: false) + require "bundle/brew_services" + + requested_formula = formulae.select do |f| + f[:installed_on_request?] + end + trusted_formulae = Homebrew::Trust.trusted_entries(:formula) + requested_formula.map do |f| + brewline = if describe && f[:desc].present? + f[:desc].split("\n").map { |s| "# #{s}\n" }.join + else + "" + end + brewline += "brew \"#{f[:full_name]}\"" + + args = f[:args].map { |arg| "\"#{arg}\"" }.sort.join(", ") + brewline += ", args: [#{args}]" unless f[:args].empty? + brewline += ", restart_service: :changed" if !no_restart && Services.started?(f[:full_name]) + brewline += ", link: #{f[:link?]}" unless f[:link?].nil? + brewline += ", trusted: true" if trusted_formulae.include?(f[:full_name]) + brewline + end.join("\n") + end + + sig { override.params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def dump_output(describe: false, no_restart: false) + dump(describe:, no_restart:) + end + + sig { override.params(name: String, options: T::Hash[Symbol, T.untyped], no_upgrade: T::Boolean).returns(T.nilable(String)) } + def fetchable_name(name, options = {}, no_upgrade: false) + _ = options + + return if formula_installed_and_up_to_date?(name, no_upgrade:) + + name + end + + sig { returns(T::Hash[String, String]) } + def formula_aliases + return @formula_aliases if @formula_aliases + + @formula_aliases = {} + formulae.each do |f| + aliases = f[:aliases] + next if aliases.blank? + + aliases.each do |a| + @formula_aliases[a] = f[:full_name] + if (tap_name = Utils.tap_from_full_name(f[:full_name])) + @formula_aliases["#{tap_name}/#{a}"] = f[:full_name] + end + end + end + @formula_aliases + end + + sig { returns(T::Hash[String, String]) } + def formula_oldnames + return @formula_oldnames if @formula_oldnames + + @formula_oldnames = {} + formulae.each do |f| + oldnames = f[:oldnames] + next if oldnames.blank? + + oldnames.each do |oldname| + @formula_oldnames[oldname] = f[:full_name] + if (tap_name = Utils.tap_from_full_name(f[:full_name])) + @formula_oldnames["#{tap_name}/#{oldname}"] = f[:full_name] + end + end + end + @formula_oldnames + end + + private + + sig { params(formula: Formula).returns(T::Hash[Symbol, T.untyped]) } + def add_formula(formula) + hash = formula_to_hash formula + + raise "formulae_by_name is nil" if @formulae_by_name.nil? + raise "formulae_by_full_name is nil" if @formulae_by_full_name.nil? + + @formulae_by_name[hash[:name]] = hash + @formulae_by_full_name[hash[:full_name]] = hash + + hash + end + + sig { params(formula: Formula).returns(T::Hash[Symbol, T.untyped]) } + def formula_to_hash(formula) + keg = if formula.linked? + link = true if formula.keg_only? + formula.linked_keg + else + link = false unless formula.keg_only? + formula.any_installed_prefix + end + + if keg + require "tab" + + tab = Tab.for_keg(keg) + args = tab.used_options.map(&:name) + version = begin + keg.realpath.basename + rescue + # silently handle broken symlinks + nil + end.to_s + args << "HEAD" if version.start_with?("HEAD") + installed_on_request = tab.installed_on_request + runtime_dependencies = if (runtime_deps = tab.runtime_dependencies) + T.cast(runtime_deps, T::Array[T::Hash[String, T.untyped]]).filter_map { |d| d["full_name"] } + end + poured_from_bottle = tab.poured_from_bottle + end + + runtime_dependencies ||= formula.runtime_dependencies.map(&:name) + + bottled = if (stable = formula.stable) && stable.bottle_defined? + bottle_hash = formula.bottle_hash.deep_symbolize_keys + stable.bottled? + end + + { + name: formula.name, + desc: formula.desc, + oldnames: formula.oldnames, + full_name: formula.full_name, + aliases: formula.aliases, + any_version_installed?: formula.any_version_installed?, + args: Array(args).uniq, + version:, + installed_on_request?: installed_on_request != false, + dependencies: runtime_dependencies, + build_dependencies: formula.deps.select(&:build?).map(&:name).uniq, + conflicts_with: formula.conflicts.map(&:name), + pinned?: formula.pinned? || false, + outdated?: formula.outdated? || false, + link?: link, + poured_from_bottle?: poured_from_bottle || false, + bottle: bottle_hash || false, + bottled: bottled || false, + official_tap: formula.tap&.official? || false, + } + end + + sig { params(formulae: T::Array[T::Hash[Symbol, T.untyped]]).void } + def sort!(formulae) + # Step 1: Sort by formula full name while putting tap formulae behind core formulae. + # So we can have a nicer output. + formulae = formulae.sort do |a, b| + if a[:full_name].exclude?("/") && b[:full_name].include?("/") + -1 + elsif a[:full_name].include?("/") && b[:full_name].exclude?("/") + 1 + else + a[:full_name] <=> b[:full_name] + end + end + + # Step 2: Sort by formula dependency topology. + topo = Topo.new + formulae.each do |f| + topo[f[:name]] = topo[f[:full_name]] = f[:dependencies].filter_map do |dep| + ff = formulae_by_name(dep) + next if ff.blank? + next unless ff[:any_version_installed?] + + ff[:full_name] + end + end + + raise "formulae_by_full_name is nil" if @formulae_by_full_name.nil? + raise "formulae_by_name is nil" if @formulae_by_name.nil? + + @formulae = topo.tsort + .map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] } + .uniq { |f| f[:full_name] } + rescue TSort::Cyclic => e + e.message =~ /\["([^"]*)".*"([^"]*)"\]/ + cycle_first = Regexp.last_match(1) + cycle_last = Regexp.last_match(2) + odie e.message if !cycle_first || !cycle_last + + odie <<~EOS + Formulae dependency graph sorting failed (likely due to a circular dependency): + #{cycle_first}: #{topo[cycle_first] if topo} + #{cycle_last}: #{topo[cycle_last] if topo} + Please run the following commands and try again: + brew update + brew uninstall --ignore-dependencies --force #{cycle_first} #{cycle_last} + brew install #{cycle_first} #{cycle_last} + EOS + end + end + + sig { params(name: String, options: T::Hash[Symbol, T.untyped]).void } + def initialize(name = "", options = {}) + super() + @full_name = name + @name = T.let(Utils.name_from_full_name(name), String) + @args = T.let(options.fetch(:args, []).map { |arg| "--#{arg}" }, T::Array[String]) + @conflicts_with_arg = T.let(options.fetch(:conflicts_with, []), T::Array[String]) + @restart_service = T.let(options[:restart_service], T.nilable(T.any(Symbol, T::Boolean))) + @start_service = T.let(options.fetch(:start_service, @restart_service), T.nilable(T.any(Symbol, T::Boolean))) + @link = T.let(options.fetch(:link, nil), T.nilable(T.any(Symbol, T::Boolean))) + @postinstall = T.let(options.fetch(:postinstall, nil), T.nilable(String)) + @version_file = T.let(options.fetch(:version_file, nil), T.nilable(String)) + @trusted = T.let(options.fetch(:trusted, false), T::Boolean) + @changed = T.let(nil, T.nilable(T::Boolean)) + end + + sig { override.params(formula: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(formula, no_upgrade: false) + name = formula_name(formula) + return false unless self.class.formula_installed_and_up_to_date?(name, no_upgrade:) + + options = formula_options(formula) + link_status = self.class.link_status_to_check(name, options) + return true if link_status.nil? + + Formula[name].linked? == link_status + end + + sig { override.params(name: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(name, no_upgrade:) + formula = formula_name(name) + options = formula_options(name) + return super(formula, no_upgrade:) unless self.class.formula_installed_and_up_to_date?(formula, no_upgrade:) + + link_status = self.class.link_status_to_check(formula, options) + return super(formula, no_upgrade:) if link_status.nil? + + link_status = link_status ? "linked" : "unlinked" + "Formula #{formula} needs to be #{link_status}." + end + + sig { + override.params( + entries: T::Array[Dsl::Entry], + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false) + requested = instance_of?(Homebrew::Bundle::Brew) ? checkable_entries(entries) : format_checkable(entries) + + if exit_on_first_error + exit_early_check(requested, no_upgrade:) + else + full_check(requested, no_upgrade:) + end + end + + sig { params(no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Boolean) } + def preinstall!(no_upgrade: false, verbose: false) + if installed? && (self.class.no_upgrade_with_args?(no_upgrade, @name) || !upgradable?) + puts "Skipping install of #{@name} formula. It is already installed." if verbose + @changed = nil + return false + end + + true + end + + sig { params(preinstall: T::Boolean, no_upgrade: T::Boolean, verbose: T::Boolean, force: T::Boolean).returns(T::Boolean) } + def install!(preinstall: true, no_upgrade: false, verbose: false, force: false) + install_result = if preinstall + install_change_state!(no_upgrade:, verbose:, force:) + else + true + end + result = install_result + + if installed? + service_result = service_change_state!(verbose:) + result &&= service_result + + link_result = link_change_state!(verbose:) + result &&= link_result + + postinstall_result = postinstall_change_state!(verbose:) + result &&= postinstall_result + + if result && @version_file.present? + # Use the version from the environment if it hasn't changed. + # Strip the revision number because it's not part of the non-Homebrew version. + version = if !changed? && (env_version = Bundle.formula_versions_from_env(@name)) + PkgVersion.parse(env_version).version + else + Formula[@full_name].version + end.to_s + File.write(@version_file, "#{version}\n") + + puts "Wrote #{@name} version #{version} to #{@version_file}" if verbose + end + end + + result + end + + sig { params(no_upgrade: T::Boolean, verbose: T::Boolean, force: T::Boolean).returns(T::Boolean) } + def install_change_state!(no_upgrade:, verbose:, force:) + require "tap" + + # Trust before tapping: installing the tap loads the formula, which + # triggers the trust check before any later step could grant trust. + # Only fully-qualified names map to a tap, so unqualified names cannot + # be meaningfully trusted. + Homebrew::Trust.trust!(:formula, @full_name) if @trusted && Utils.full_name?(@full_name) + + if (tap_with_name = ::Tap.with_formula_name(@full_name)) + tap, = tap_with_name + tap.ensure_installed! + end + + return false unless resolve_conflicts!(verbose:) + + if installed? + upgrade_formula!(verbose:, force:) + else + install_formula!(verbose:, force:) + end + end + + sig { returns(T::Boolean) } + def start_service? + @start_service.present? + end + + sig { returns(T::Boolean) } + def start_service_needed? + require "bundle/brew_services" + start_service? && !Services.started?(@full_name) + end + + sig { returns(T::Boolean) } + def restart_service? + @restart_service.present? + end + + sig { returns(T::Boolean) } + def restart_service_needed? + return false unless restart_service? + + # Restart if `restart_service: :always`, or if the formula was installed or upgraded + @restart_service.to_s == "always" || changed? + end + + sig { returns(T::Boolean) } + def changed? + @changed.present? + end + + sig { params(verbose: T::Boolean).returns(T::Boolean) } + def service_change_state!(verbose:) + require "bundle/brew_services" + + file = Services.versioned_service_file(@name)&.to_s + + if restart_service_needed? + puts "Restarting #{@name} service." if verbose + Services.restart(@full_name, file:, verbose:) + elsif start_service_needed? + puts "Starting #{@name} service." if verbose + Services.start(@full_name, file:, verbose:) + else + true + end + end + + sig { params(verbose: T::Boolean).returns(T::Boolean) } + def link_change_state!(verbose: false) + link_args = [] + link_status = self.class.expected_link_status?(@link, keg_only: keg_only?) + cmd = if link_status + link_args << "--force" if unlinked_and_keg_only? + link_args << "--overwrite" if @link == :overwrite + "link" unless linked? + elsif linked? + "unlink" + end + + if cmd.present? + verb = "#{cmd}ing".capitalize + with_args = " with #{link_args.join(" ")}" if link_args.present? + puts "#{verb} #{@name} formula#{with_args}." if verbose + return Bundle.brew(cmd, *link_args, @name, verbose:) + end + + true + end + + sig { params(verbose: T::Boolean).returns(T::Boolean) } + def postinstall_change_state!(verbose:) + return true if @postinstall.blank? + return true unless changed? + + puts "Running postinstall for #{@name}: #{@postinstall}" if verbose + Kernel.system(@postinstall) || false + end + + private + + sig { params(formula: Object).returns(String) } + def formula_name(formula) + return formula.name if formula.is_a?(Dsl::Entry) + return formula if formula.is_a?(String) + + raise "formula must be a String or Dsl::Entry, got #{formula.class}: #{formula}" + end + + sig { params(formula: Object).returns(Homebrew::Bundle::EntryOptions) } + def formula_options(formula) + return formula.options if formula.is_a?(Dsl::Entry) + + {} + end + + sig { returns(T::Boolean) } + def installed? + self.class.formula_installed?(@name) + end + + sig { returns(T::Boolean) } + def linked? + Formula[@full_name].linked? + end + + sig { returns(T::Boolean) } + def keg_only? + Formula[@full_name].keg_only? + end + + sig { returns(T::Boolean) } + def unlinked_and_keg_only? + !linked? && keg_only? + end + + sig { returns(T::Boolean) } + def upgradable? + self.class.formula_upgradable?(@full_name) + end + + sig { returns(T::Array[String]) } + def conflicts_with + @conflicts_with ||= T.let( + begin + conflicts_with = Set.new + conflicts_with += @conflicts_with_arg + + if (formula = T.cast(self.class.formulae_by_full_name(@full_name), + T.nilable(T::Hash[Symbol, T.untyped]))) && + (formula_conflicts_with = formula[:conflicts_with]) + conflicts_with += formula_conflicts_with + end + + conflicts_with.to_a + end, + T.nilable(T::Array[String]), + ) + end + + sig { params(verbose: T::Boolean).returns(T::Boolean) } + def resolve_conflicts!(verbose:) + conflicts_with.each do |conflict| + next unless self.class.formula_installed?(conflict) + + if verbose + puts <<~EOS + Unlinking #{conflict} formula. + It is currently installed and conflicts with #{@name}. + EOS + end + return false unless Bundle.brew("unlink", conflict, verbose:) + + next unless restart_service? + + require "bundle/brew_services" + puts "Stopping #{conflict} service (if it is running)." if verbose + Services.stop(conflict, verbose:) + end + + true + end + + sig { params(verbose: T::Boolean, force: T::Boolean).returns(T::Boolean) } + def install_formula!(verbose:, force:) + install_args = @args.dup + install_args << "--force" << "--overwrite" if force + install_args << "--skip-link" if @link == false + with_args = " with #{install_args.join(" ")}" if install_args.present? + puts "Installing #{@name} formula#{with_args}. It is not currently installed." if verbose + unless Bundle.brew("install", "--formula", @full_name, *install_args, verbose:) + @changed = nil + return false + end + + self.class.installed_formulae << @name + @changed = true + true + end + + sig { params(verbose: T::Boolean, force: T::Boolean).returns(T::Boolean) } + def upgrade_formula!(verbose:, force:) + upgrade_args = [] + upgrade_args << "--force" if force + with_args = " with #{upgrade_args.join(" ")}" if upgrade_args.present? + puts "Upgrading #{@name} formula#{with_args}. It is installed but not up-to-date." if verbose + unless Bundle.brew("upgrade", "--formula", @name, *upgrade_args, verbose:) + @changed = nil + return false + end + + @changed = true + true + end + + class Topo < Hash + extend T::Generic + include TSort + + K = type_member { { fixed: String } } + V = type_member { { fixed: T::Array[String] } } + Elem = type_member(:out) { { fixed: [String, T::Array[String]] } } + + # TSort interface requires a broader block return type than our implementation. + # rubocop:disable Sorbet/AllowIncompatibleOverride + sig { + override(allow_incompatible: true).params(block: T.proc.params(arg0: String).returns(BasicObject)).void + } + # rubocop:enable Sorbet/AllowIncompatibleOverride + def each_key(&block) + keys.each(&block) + end + alias tsort_each_node each_key + + sig { override.params(node: String, block: T.proc.params(arg0: String).void).void } + def tsort_each_child(node, &block) + fetch(node, []).sort.each(&block) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/brew_services.rb b/Library/Homebrew/bundle/brew_services.rb new file mode 100644 index 0000000000000..5c717d55ca1df --- /dev/null +++ b/Library/Homebrew/bundle/brew_services.rb @@ -0,0 +1,167 @@ +# typed: strict +# frozen_string_literal: true + +require "services/system" +require "utils/output" +require "bundle/brew" +require "bundle/dsl" + +module Homebrew + module Bundle + class Brew + class Services < Homebrew::Bundle::Brew + extend Utils::Output::Mixin + + class << self + sig { override.void } + def reset! + @started_services = nil + end + + # Action methods that return a success/failure boolean, not predicate methods. + # rubocop:disable Naming/PredicateMethod + sig { params(name: String, keep: T::Boolean, verbose: T::Boolean).returns(T::Boolean) } + def stop(name, keep: false, verbose: false) + return true unless started?(name) + + args = ["services", "stop", name] + args << "--keep" if keep + return false unless Bundle.brew(*args, verbose:) + + started_services.delete(name) + true + end + + sig { params(name: String, file: T.nilable(String), verbose: T::Boolean).returns(T::Boolean) } + def start(name, file: nil, verbose: false) + args = ["services", "start", name] + args << "--file=#{file}" if file + return false unless Bundle.brew(*args, verbose:) + + started_services << name + true + end + + sig { params(name: String, file: T.nilable(T.any(Pathname, String)), verbose: T::Boolean).returns(T::Boolean) } + def run(name, file: nil, verbose: false) + args = ["services", "run", name] + args << "--file=#{file}" if file + return false unless Bundle.brew(*args, verbose:) + + started_services << name + true + end + + sig { params(name: String, file: T.nilable(String), verbose: T::Boolean).returns(T::Boolean) } + def restart(name, file: nil, verbose: false) + args = ["services", "restart", name] + args << "--file=#{file}" if file + return false unless Bundle.brew(*args, verbose:) + + started_services << name + true + end + # rubocop:enable Naming/PredicateMethod + + sig { params(name: String).returns(T::Boolean) } + def started?(name) + started_services.include? name + end + + sig { returns(T::Array[String]) } + def started_services_without_daemon_manager + odie Homebrew::Services::System::MISSING_DAEMON_MANAGER_EXCEPTION_MESSAGE + end + + sig { returns(T::Array[String]) } + def started_services + @started_services ||= T.let( + if !Homebrew::Services::System.launchctl? && !Homebrew::Services::System.systemctl? + started_services_without_daemon_manager + else + states_to_skip = %w[stopped none] + + services_list = JSON.parse(Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "list", "--json")) + services_list.filter_map do |hash| + hash.fetch("name") if states_to_skip.exclude?(hash.fetch("status")) + end + end, + T.nilable(T::Array[String]), + ) + end + + sig { params(name: String).returns(T.nilable(Pathname)) } + def versioned_service_file(name) + env_version = Bundle.formula_versions_from_env(name) + return if env_version.nil? + + formula = Formula[name] + prefix = formula.rack/env_version + return unless prefix.directory? + + service_file = if Homebrew::Services::System.launchctl? + prefix/"#{formula.plist_name}.plist" + else + prefix/"#{formula.service_name}.service" + end + + service_file if service_file.file? + end + end + + sig { override.params(name: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(name, no_upgrade:) + _ = no_upgrade + + "Service #{name} needs to be started." + end + + sig { override.params(formula: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(formula, no_upgrade: false) + _ = no_upgrade + entry = T.cast(formula, Homebrew::Bundle::Dsl::Entry) + + return true unless formula_needs_to_start?(entry_to_formula(entry)) + + name = entry.name + return true if self.class.started?(name) + + # `brew services list` returns base names, so fall back to the last + # path component for tap-qualified entries (e.g., "user/tap/formula"). + base_name = Utils.name_from_full_name(name) + return true if base_name != name && self.class.started?(base_name) + + old_name = lookup_old_name(name) + return true if old_name && self.class.started?(old_name) + + false + end + + sig { params(entry: Homebrew::Bundle::Dsl::Entry).returns(Homebrew::Bundle::Brew) } + def entry_to_formula(entry) + Homebrew::Bundle::Brew.new(entry.name, entry.options) + end + + sig { params(formula: Homebrew::Bundle::Brew).returns(T::Boolean) } + def formula_needs_to_start?(formula) + formula.start_service? || formula.restart_service? + end + + sig { params(service_name: String).returns(T.nilable(String)) } + def lookup_old_name(service_name) + @old_names ||= T.let(Homebrew::Bundle::Brew.formula_oldnames, T.nilable(T::Hash[String, String])) + old_name = @old_names[service_name] + old_name ||= @old_names[Utils.name_from_full_name(service_name)] + old_name + end + + sig { params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries) + end + end + end + end +end + +require "extend/os/bundle/brew_services" diff --git a/Library/Homebrew/bundle/brewfile.rb b/Library/Homebrew/bundle/brewfile.rb new file mode 100644 index 0000000000000..8dd00c281ff14 --- /dev/null +++ b/Library/Homebrew/bundle/brewfile.rb @@ -0,0 +1,74 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" + +module Homebrew + module Bundle + module Brewfile + sig { + params( + dash_writes_to_stdout: T::Boolean, + global: T::Boolean, + file: T.nilable(String), + ).returns(Pathname) + } + def self.path(dash_writes_to_stdout: false, global: false, file: nil) + env_bundle_file_global = ENV.fetch("HOMEBREW_BUNDLE_FILE_GLOBAL", nil) + env_bundle_file = ENV.fetch("HOMEBREW_BUNDLE_FILE", nil) + user_config_home = ENV.fetch("HOMEBREW_USER_CONFIG_HOME", nil) + + filename = if global + if env_bundle_file_global.present? + env_bundle_file_global + else + raise "'HOMEBREW_BUNDLE_FILE' cannot be specified with '--global'" if env_bundle_file.present? + + home_brewfile = Bundle.exchange_uid_if_needed! do + "#{Dir.home}/.Brewfile" + end + user_config_home_brewfile = "#{user_config_home}/Brewfile" + + if user_config_home.present? && Dir.exist?(user_config_home) && + (File.exist?(user_config_home_brewfile) || !File.exist?(home_brewfile)) + user_config_home_brewfile + else + home_brewfile + end + end + elsif file.present? + handle_file_value(file, dash_writes_to_stdout) + elsif env_bundle_file.present? + env_bundle_file + else + "Brewfile" + end + + Pathname.new(filename).expand_path(Dir.pwd) + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(Dsl) } + def self.read(global: false, file: nil) + Homebrew::Bundle::Dsl.new(Brewfile.path(global:, file:)) + rescue Errno::ENOENT + raise "No Brewfile found" + end + + sig { + params( + filename: String, + dash_writes_to_stdout: T::Boolean, + ).returns(String) + } + private_class_method def self.handle_file_value(filename, dash_writes_to_stdout) + if filename != "-" + filename + elsif dash_writes_to_stdout + "/dev/stdout" + else + "/dev/stdin" + end + end + end + end +end diff --git a/Library/Homebrew/bundle/cask.rb b/Library/Homebrew/bundle/cask.rb new file mode 100644 index 0000000000000..e4728d9452a49 --- /dev/null +++ b/Library/Homebrew/bundle/cask.rb @@ -0,0 +1,291 @@ +# typed: strict +# frozen_string_literal: true + +require "utils" +require "utils/output" +require "bundle/package_type" +require "trust" + +module Homebrew + module Bundle + class Cask < Homebrew::Bundle::PackageType + extend ::Utils::Output::Mixin + + class << self + sig { override.returns(Symbol) } + def type = :cask + + sig { override.returns(String) } + def check_label = "Cask" + + sig { override.void } + def reset! + @casks = T.let(nil, T.nilable(T::Array[::Cask::Cask])) + @cask_names = T.let(nil, T.nilable(T::Array[String])) + @cask_oldnames = T.let(nil, T.nilable(T::Hash[String, String])) + @installed_casks = T.let(nil, T.nilable(T::Array[String])) + @outdated_casks = T.let(nil, T.nilable(T::Array[String])) + end + + sig { returns(T::Array[::Cask::Cask]) } + def casks + return [] unless Bundle.cask_installed? + + require "cask/caskroom" + @casks ||= T.let(::Cask::Caskroom.casks, T.nilable(T::Array[::Cask::Cask])) + end + + # Override makes `name` a required argument unlike the parent's default-argument signature. + # rubocop:disable Sorbet/AllowIncompatibleOverride + sig { + override(allow_incompatible: true).params(name: String, options: Homebrew::Bundle::EntryOptions).returns(String) + } + # rubocop:enable Sorbet/AllowIncompatibleOverride + def install_verb(name, options = {}) + return "Installing" if !cask_installed?(name) || !upgrading?(false, name, options) + + "Upgrading" + end + + sig { override.params(name: String, no_upgrade: T::Boolean, verbose: T::Boolean, options: T.untyped).returns(T::Boolean) } + def preinstall!(name, no_upgrade: false, verbose: false, **options) + if cask_installed?(name) && !upgrading?(no_upgrade, name, options) + puts "Skipping install of #{name} cask. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params(name: String, preinstall: T::Boolean, no_upgrade: T::Boolean, verbose: T::Boolean, + force: T::Boolean, options: T.untyped).returns(T::Boolean) + } + def install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options) + return true unless preinstall + + full_name = T.cast(options.fetch(:full_name, name), String) + + # Only fully-qualified names map to a tap, so unqualified tokens + # cannot be meaningfully trusted. + Homebrew::Trust.trust!(:cask, full_name) if options[:trusted] && Utils.full_name?(full_name) + + install_result = if cask_installed?(name) && upgrading?(no_upgrade, name, options) + status = "#{options[:greedy] ? "may not be" : "not"} up-to-date" + puts "Upgrading #{name} cask. It is installed but #{status}." if verbose + Bundle.brew("upgrade", "--cask", full_name, verbose:) + else + args = options.fetch(:args, []).filter_map do |k, v| + case v + when TrueClass + "--#{k}" + when FalseClass, NilClass + nil + else + "--#{k}=#{v}" + end + end + + args << "--force" if force + args << "--adopt" unless args.include?("--force") + args.uniq! + + with_args = " with #{args.join(" ")}" if args.present? + puts "Installing #{name} cask#{with_args}. It is not currently installed." if verbose + + if Bundle.brew("install", "--cask", full_name, *args, verbose:) + installed_casks << name + true + else + false + end + end + result = install_result + + if cask_installed?(name) + postinstall_result = postinstall_change_state!(name:, options:, verbose:) + result &&= postinstall_result + end + + result + end + + sig { params(name: String, no_upgrade: T::Boolean, options: T.untyped).returns(T::Boolean) } + def installable_or_upgradable?(name, no_upgrade: false, **options) + !cask_installed?(name) || upgrading?(no_upgrade, name, options) + end + + sig { params(name: String, options: Homebrew::Bundle::EntryOptions, no_upgrade: T::Boolean).returns(T.nilable(String)) } + def fetchable_name(name, options = {}, no_upgrade: false) + full_name = T.cast(options.fetch(:full_name, name), String) + return unless installable_or_upgradable?(name, no_upgrade:, **options) + + full_name + end + + sig { params(cask: String, no_upgrade: T::Boolean).returns(T::Boolean) } + def cask_installed_and_up_to_date?(cask, no_upgrade: false) + return false unless cask_installed?(cask) + return true if no_upgrade + + !cask_upgradable?(cask) + end + + sig { params(cask: String, array: T::Array[String]).returns(T::Boolean) } + def cask_in_array?(cask, array) + return true if array.include?(cask) + + array.include?(Utils.name_from_full_name(cask)) + end + + sig { params(cask: String).returns(T::Boolean) } + def cask_installed?(cask) + return true if cask_in_array?(cask, installed_casks) + + old_name = cask_oldnames[cask] + old_name ||= cask_oldnames[Utils.name_from_full_name(cask)] + return false unless old_name + return false unless cask_in_array?(old_name, installed_casks) + + opoo "#{cask} was renamed to #{old_name}" + + true + end + + sig { params(cask: String).returns(T::Boolean) } + def cask_upgradable?(cask) + cask_in_array?(cask, outdated_casks) + end + + sig { returns(T::Array[String]) } + def installed_casks + @installed_casks ||= cask_names + end + + sig { returns(T::Array[String]) } + def outdated_casks + @outdated_casks ||= outdated_cask_names + end + + sig { returns(T::Array[String]) } + def cask_names + @cask_names ||= casks.map(&:to_s) + end + + sig { returns(T::Array[String]) } + def outdated_cask_names + return [] unless Bundle.cask_installed? + + casks.reject(&:pinned?) + .select { |c| c.outdated?(greedy: false) } + .map(&:to_s) + end + + sig { params(cask_name: String).returns(T::Boolean) } + def cask_is_outdated_using_greedy?(cask_name) + return false unless Bundle.cask_installed? + + cask = casks.find { |installed_cask| installed_cask.to_s == cask_name } + return false if cask.nil? || cask.pinned? + + cask.outdated?(greedy: true) + end + + sig { override.params(describe: T::Boolean).returns(String) } + def dump(describe: false) + trusted_casks = Homebrew::Trust.trusted_entries(:cask) + casks.map do |cask| + description = "# #{cask.desc}\n" if describe && cask.desc.present? + config = ", args: { #{explicit_s(cask.config)} }" if cask.config.present? && cask.config.explicit.present? + caskline = "#{description}cask \"#{cask.full_name}\"#{config}" + caskline += ", trusted: true" if trusted_casks.include?(cask.full_name) + caskline + end.join("\n") + end + + sig { override.params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def dump_output(describe: false, no_restart: false) + _ = no_restart + + dump(describe:) + end + + sig { returns(T::Hash[String, String]) } + def cask_oldnames + @cask_oldnames ||= casks.each_with_object({}) do |c, hash| + oldnames = c.old_tokens + next if oldnames.blank? + + oldnames.each do |oldname| + hash[oldname] = c.full_name + if (tap_name = Utils.tap_from_full_name(c.full_name)) + hash["#{tap_name}/#{oldname}"] = c.full_name + end + end + end + end + + sig { params(cask_list: T::Array[String]).returns(T::Array[String]) } + def formula_dependencies(cask_list) + return [] if cask_list.blank? + + require "cask/cask_loader" + + installed_cask_objects = casks + cask_list.flat_map do |cask_name| + cask = installed_cask_objects.find do |installed_cask| + cask_name == installed_cask.to_s || cask_name == installed_cask.full_name + end + cask ||= begin + ::Cask::CaskLoader.load(cask_name) + rescue ::Cask::CaskUnavailableError + nil + end + next unless cask + + cask.depends_on[:formula] + end.compact + end + + private + + sig { params(no_upgrade: T::Boolean, name: String, options: Homebrew::Bundle::EntryOptions).returns(T::Boolean) } + def upgrading?(no_upgrade, name, options) + return false if no_upgrade + return true if cask_upgradable?(name) + return false unless options[:greedy] + + cask_is_outdated_using_greedy?(name) + end + + sig { params(name: String, options: Homebrew::Bundle::EntryOptions, verbose: T::Boolean).returns(T::Boolean) } + def postinstall_change_state!(name:, options:, verbose:) + postinstall = T.cast(options.fetch(:postinstall, nil), T.nilable(String)) + return true if postinstall.blank? + + puts "Running postinstall for #{name}: #{postinstall}" if verbose + Kernel.system(postinstall) || false + end + + sig { params(cask_config: ::Cask::Config).returns(String) } + def explicit_s(cask_config) + cask_config.explicit.map do |key, value| + # inverse of #env - converts :languages config key back to --language flag + if key == :languages + key = "language" + value = Array(cask_config.explicit.fetch(:languages, [])).join(",") + end + "#{key}: \"#{value.to_s.sub(/^#{Dir.home}/, "~")}\"" + end.join(", ") + end + end + + sig { override.params(cask: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(cask, no_upgrade: false) + raise "cask must be a String, got #{cask.class}: #{cask}" unless cask.is_a?(String) + + self.class.cask_installed_and_up_to_date?(cask, no_upgrade:) + end + end + end +end diff --git a/Library/Homebrew/bundle/checker.rb b/Library/Homebrew/bundle/checker.rb new file mode 100644 index 0000000000000..332e9762221a7 --- /dev/null +++ b/Library/Homebrew/bundle/checker.rb @@ -0,0 +1,182 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" +require "bundle/extensions" +require "bundle/package_types" +require "bundle/brew_services" + +module Homebrew + module Bundle + module Checker + class CheckResult < T::Struct + const :work_to_be_done, T::Boolean + const :errors, T::Array[String] + end + + CheckStep = T.type_alias { Symbol } + + CORE_CHECKS = [ + :taps_to_tap, + :casks_to_install, + :registered_extensions_to_install, + :apps_to_install, + :formulae_to_install, + :formulae_to_start, + ].freeze + + sig { + params( + global: T::Boolean, + file: T.nilable(String), + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(CheckResult) + } + def self.check(global: false, file: nil, exit_on_first_error: false, no_upgrade: false, verbose: false) + require "bundle/brewfile" + @dsl = T.let(@dsl, T.nilable(Homebrew::Bundle::Dsl)) + @dsl ||= Brewfile.read(global:, file:) + + errors = T.let([], T::Array[String]) + enumerator = exit_on_first_error ? :find : :map + + work_to_be_done = CORE_CHECKS.public_send(enumerator) do |check_step| + check_errors = public_send(check_step, exit_on_first_error:, no_upgrade:, verbose:) + any_errors = check_errors.any? + errors.concat(check_errors) if any_errors + any_errors + end + + work_to_be_done = Array(work_to_be_done).flatten.any? + + CheckResult.new(work_to_be_done:, errors:) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.apps_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false) + extension_errors(:apps_to_install, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.formulae_to_start(exit_on_first_error: false, no_upgrade: false, verbose: false) + raise ArgumentError, "dsl is unset!" unless @dsl + + Homebrew::Bundle::Brew::Services.new.find_actionable( + @dsl.entries, + exit_on_first_error:, no_upgrade:, verbose:, + ) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.taps_to_tap(exit_on_first_error: false, no_upgrade: false, verbose: false) + package_type_errors(:tap, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.casks_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false) + package_type_errors(:cask, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.formulae_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false) + package_type_errors(:brew, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { + params( + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.registered_extensions_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false) + extension_errors(:registered_extensions_to_install, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { + params( + step: Symbol, + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.extension_errors(step, exit_on_first_error:, no_upgrade:, verbose:) + raise ArgumentError, "dsl is unset!" unless @dsl + + matching_extensions = Homebrew::Bundle.extensions.select { |extension| extension.legacy_check_step == step } + errors = T.let([], T::Array[String]) + + matching_extensions.each do |extension| + check_errors = extension.check( + @dsl.entries, + exit_on_first_error:, no_upgrade:, verbose:, + ) + next if check_errors.empty? + + return check_errors if exit_on_first_error + + errors.concat(check_errors) + end + + errors + end + + sig { void } + def self.reset! + @dsl = T.let(nil, T.nilable(Homebrew::Bundle::Dsl)) + Homebrew::Bundle.package_types.each(&:reset!) + Homebrew::Bundle.extensions.each(&:reset!) + end + + sig { + params( + type: Symbol, + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.package_type_errors(type, exit_on_first_error:, no_upgrade:, verbose:) + raise ArgumentError, "dsl is unset!" unless @dsl + + package_type = Homebrew::Bundle.package_type(type) + return [] if package_type.nil? + + package_type.check(@dsl.entries, exit_on_first_error:, no_upgrade:, verbose:) + end + end + end +end diff --git a/Library/Homebrew/bundle/dsl.rb b/Library/Homebrew/bundle/dsl.rb new file mode 100644 index 0000000000000..7b9ef8816abc9 --- /dev/null +++ b/Library/Homebrew/bundle/dsl.rb @@ -0,0 +1,182 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module Bundle + EntryOptionScalar = T.type_alias { T.nilable(T.any(String, Integer, Symbol, TrueClass, FalseClass)) } + NestedEntryOptionValue = T.type_alias { T.any(EntryOptionScalar, T::Array[String]) } + NestedEntryOptions = T.type_alias { T::Hash[Symbol, NestedEntryOptionValue] } + EntryOption = T.type_alias { T.any(EntryOptionScalar, T::Array[String], NestedEntryOptions) } + EntryOptions = T.type_alias { T::Hash[Symbol, EntryOption] } + EntryInputOptions = T.type_alias { T::Hash[Symbol, Object] } + + class Dsl + class Entry + sig { returns(Symbol) } + attr_reader :type + + sig { returns(String) } + attr_reader :name + + sig { returns(Homebrew::Bundle::EntryOptions) } + attr_reader :options + + sig { params(type: Symbol, name: String, options: Homebrew::Bundle::EntryOptions).void } + def initialize(type, name, options = {}) + @type = type + @name = name + @options = options + end + + sig { returns(String) } + def to_s + name + end + end + + sig { returns(T::Array[Entry]) } + attr_reader :entries + + sig { returns(T::Hash[Symbol, T.untyped]) } + attr_reader :cask_arguments + + sig { returns(String) } + attr_reader :input + + sig { params(path: T.any(Pathname, StringIO)).void } + def initialize(path) + @path = path + path_read = path.read + raise "path_read is nil" unless path_read + + @input = T.let(path_read, String) + @entries = T.let([], T::Array[Entry]) + @cask_arguments = T.let({}, T::Hash[Symbol, T.untyped]) + + begin + process + # Want to catch all exceptions for e.g. syntax errors. + rescue Exception => e # rubocop:disable Lint/RescueException + error_msg = "Invalid Brewfile: #{e.message}" + raise RuntimeError, error_msg, e.backtrace + end + end + + sig { void } + def process + instance_eval(@input, @path.to_s) + end + + sig { params(args: T::Hash[Symbol, T.untyped]).void } + def cask_args(args) + @cask_arguments.merge!(args) + end + + sig { params(name: String, options: Homebrew::Bundle::EntryOptions).void } + def brew(name, options = {}) + name = Homebrew::Bundle::Dsl.sanitize_brew_name(name) + @entries << Entry.new(:brew, name, options) + end + + sig { params(name: String, options: Homebrew::Bundle::EntryOptions).void } + def cask(name, options = {}) + options[:full_name] = name + name = Homebrew::Bundle::Dsl.sanitize_cask_name(name) + options[:args] = + @cask_arguments.merge T.cast(options.fetch(:args, {}), T::Hash[Symbol, NestedEntryOptionValue]) + @entries << Entry.new(:cask, name, options) + end + + sig { + params( + name: String, + clone_target: T.nilable(String), + options: Homebrew::Bundle::EntryOptions, + keyword_options: Homebrew::Bundle::EntryOption, + ).void + } + def tap(name, clone_target = nil, options = {}, **keyword_options) + options.merge!(keyword_options) + options[:clone_target] = clone_target + name = Homebrew::Bundle::Dsl.sanitize_tap_name(name) + @entries << Entry.new(:tap, name, options) + end + + HOMEBREW_TAP_ARGS_REGEX = %r{^([\w-]+)/(homebrew-)?([\w-]+)$} + HOMEBREW_CORE_FORMULA_REGEX = %r{^homebrew/homebrew/([\w+-.@]+)$}i + HOMEBREW_TAP_FORMULA_REGEX = %r{^([\w-]+)/([\w-]+)/([\w+-.@]+)$} + + sig { params(name: String).returns(String) } + def self.sanitize_brew_name(name) + name = name.downcase + if name =~ HOMEBREW_CORE_FORMULA_REGEX + sanitized_name = Regexp.last_match(1) + raise "sanitized_name is nil" unless sanitized_name + + sanitized_name + elsif name =~ HOMEBREW_TAP_FORMULA_REGEX + user = Regexp.last_match(1) + repo = Regexp.last_match(2) + name = Regexp.last_match(3) + raise "repo is nil" unless repo + + "#{user}/#{repo.sub("homebrew-", "")}/#{name}" + else + name + end + end + + sig { params(name: String).returns(String) } + def self.sanitize_tap_name(name) + name = name.downcase + if name =~ HOMEBREW_TAP_ARGS_REGEX + "#{Regexp.last_match(1)}/#{Regexp.last_match(3)}" + else + name + end + end + + sig { params(name: String).returns(String) } + def self.sanitize_cask_name(name) + require "utils" + Utils.name_from_full_name(name).downcase + end + + sig { + override.params(method_name: Symbol, args: T.untyped, options: T.untyped, + block: T.nilable(T.proc.void)).returns(T.untyped) + } + def method_missing(method_name, *args, **options, &block) + require "bundle/extensions" + extension = Homebrew::Bundle.extension(method_name) + return super if extension.nil? + raise ArgumentError, "blocks are not supported for #{method_name}" if block + + # Extension DSL entries follow the existing Brewfile calling convention: + # a required name plus an optional options hash, passed positionally, + # with keywords, or both. + unless (1..2).cover?(args.length) + raise ArgumentError, + "wrong number of arguments (given #{args.length}, expected 1..2)" + end + + positional_options = {} + if args.length == 2 + positional_options = args[1] + unless positional_options.is_a? Hash + raise ArgumentError, + "options(#{positional_options.inspect}) should be a Hash object" + end + end + + @entries << extension.entry(args.first, positional_options.merge(options)) + end + + sig { override.params(method_name: T.any(String, Symbol), include_private: T::Boolean).returns(T::Boolean) } + def respond_to_missing?(method_name, include_private = false) + require "bundle/extensions" + !Homebrew::Bundle.extension(method_name).nil? || super + end + end + end +end diff --git a/Library/Homebrew/bundle/dumper.rb b/Library/Homebrew/bundle/dumper.rb new file mode 100644 index 0000000000000..96244ee8bceef --- /dev/null +++ b/Library/Homebrew/bundle/dumper.rb @@ -0,0 +1,99 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "bundle/dsl" +require "bundle/extensions" +require "bundle/package_types" + +module Homebrew + module Bundle + module Dumper + sig { params(brewfile_path: Pathname, force: T::Boolean).returns(T::Boolean) } + private_class_method def self.can_write_to_brewfile?(brewfile_path, force: false) + raise "#{brewfile_path} already exists" if should_not_write_file?(brewfile_path, overwrite: force) + + true + end + + sig { + params( + describe: T::Boolean, + no_restart: T::Boolean, + formulae: T::Boolean, + taps: T::Boolean, + casks: T::Boolean, + extension_types: Homebrew::Bundle::ExtensionTypes, + ).returns(String) + } + def self.build_brewfile(describe:, no_restart:, formulae:, taps:, casks:, extension_types: {}) + selected_package_types = extension_types.dup + selected_package_types[:tap] = taps + selected_package_types[:brew] = formulae + selected_package_types[:cask] = casks + dumped_formulae = if formulae + Homebrew::Bundle::Brew.formulae.filter_map { |f| f[:full_name] if f[:installed_on_request?] } + else + [] + end + dumped_casks = if casks + Homebrew::Bundle::Cask.casks.map(&:full_name) + else + [] + end + content = [] + Homebrew::Bundle.dump_package_types.select(&:dump_supported?).each do |package_type| + next unless selected_package_types.fetch(package_type.type, false) + + content << if package_type == Homebrew::Bundle::Tap + Homebrew::Bundle::Tap.dump(dumped_formulae:, dumped_casks:) + else + package_type.dump_output(describe:, no_restart:) + end + end + "#{content.reject(&:empty?).join("\n")}\n" + end + + sig { + params( + global: T::Boolean, + file: T.nilable(String), + describe: T::Boolean, + force: T::Boolean, + no_restart: T::Boolean, + formulae: T::Boolean, + taps: T::Boolean, + casks: T::Boolean, + extension_types: Homebrew::Bundle::ExtensionTypes, + ).void + } + def self.dump_brewfile(global:, file:, describe:, force:, no_restart:, formulae:, taps:, casks:, + extension_types: {}) + path = brewfile_path(global:, file:) + can_write_to_brewfile?(path, force:) + content = build_brewfile( + describe:, no_restart:, taps:, formulae:, casks:, extension_types:, + ) + write_file path, content + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(Pathname) } + def self.brewfile_path(global: false, file: nil) + require "bundle/brewfile" + Brewfile.path(dash_writes_to_stdout: true, global:, file:) + end + + sig { params(file: Pathname, overwrite: T::Boolean).returns(T::Boolean) } + private_class_method def self.should_not_write_file?(file, overwrite: false) + file.exist? && !overwrite && file.to_s != "/dev/stdout" + end + + sig { params(file: Pathname, content: String).void } + def self.write_file(file, content) + Bundle.exchange_uid_if_needed! do + file.open("w") { |io| io.write content } + end + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions.rb b/Library/Homebrew/bundle/extensions.rb new file mode 100644 index 0000000000000..1b268c2eb2012 --- /dev/null +++ b/Library/Homebrew/bundle/extensions.rb @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +extensions_dir = File.join(__dir__, "extensions") +# Preserve the historical Brewfile section order for dumped extension entries; +# add new extensions to the end. +legacy_order = %w[mac_app_store vscode_extension go cargo uv flatpak winget].freeze +extension_files = Dir.glob(File.join(extensions_dir, "*.rb")).sort_by do |file| + basename = File.basename(file, ".rb") + [legacy_order.index(basename) || legacy_order.length, basename] +end +extension_files.each do |file| + basename = File.basename(file, ".rb") + next if basename == "extension" + + require "bundle/extensions/#{basename}" +end diff --git a/Library/Homebrew/bundle/extensions/cargo.rb b/Library/Homebrew/bundle/extensions/cargo.rb new file mode 100644 index 0000000000000..200898efb5304 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/cargo.rb @@ -0,0 +1,115 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class Cargo < Extension + class << self + sig { override.returns(Symbol) } + def type = :cargo + + sig { override.returns(String) } + def check_label = "Cargo Package" + + sig { override.returns(String) } + def banner_name = "Cargo packages" + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[String])) + @installed_packages = T.let(nil, T.nilable(T::Array[String])) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.returns(String) } + def package_manager_name + "rust" + end + + sig { override.returns(T.nilable(Pathname)) } + def package_manager_executable + which("cargo", ORIGINAL_PATHS) + end + + sig { override.returns(T::Array[String]) } + def packages + packages = @packages + return packages if packages + + @packages = if (cargo = package_manager_executable) && + (!cargo.to_s.start_with?("/") || cargo.exist?) + with_env(cargo_env(cargo)) do + parse_package_list(`#{cargo} install --list`) + end + end + return [] if @packages.nil? + + @packages + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, verbose: false) + _ = with + + cargo = package_manager_executable! + + with_env(cargo_env(cargo)) do + Bundle.system(cargo.to_s, "install", "--locked", name, verbose:) + end + end + + sig { override.returns(T::Array[String]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages.dup + end + + sig { override.params(name: String, executable: Pathname).void } + def uninstall_package!(name, executable: Pathname.new("")) + Bundle.system(executable.to_s, "uninstall", name, verbose: false) + end + + sig { override.params(executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(executable) + cargo_env(executable) + end + + sig { params(output: String).returns(T::Array[String]) } + def parse_package_list(output) + output.lines.filter_map do |line| + next if line.match?(/^\s/) + + match = line.match(/\A(?[^\s:]+)\s+v[0-9A-Za-z.+-]+/) + match[:name] if match + end.uniq + end + private :parse_package_list + + sig { params(cargo: Pathname).returns(T::Hash[String, String]) } + def cargo_env(cargo) + { + "CARGO_HOME" => ENV.fetch("HOMEBREW_CARGO_HOME", nil), + "CARGO_INSTALL_ROOT" => ENV.fetch("HOMEBREW_CARGO_INSTALL_ROOT", nil), + "PATH" => "#{cargo.dirname}:#{ENV.fetch("PATH")}", + "RUSTUP_HOME" => ENV.fetch("HOMEBREW_RUSTUP_HOME", nil), + }.compact + end + private :cargo_env + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb new file mode 100644 index 0000000000000..fd5f83f234545 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -0,0 +1,411 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/package_type" + +module Homebrew + module Bundle + ExtensionTypes = T.type_alias { T::Hash[Symbol, T::Boolean] } + + class Extension < Homebrew::Bundle::PackageType + extend T::Helpers + + abstract! + + sig { override.params(subclass: T.class_of(Homebrew::Bundle::PackageType)).void } + def self.inherited(subclass) + super + Homebrew::Bundle.register_extension(T.cast(subclass, T.class_of(Homebrew::Bundle::Extension))) + end + + sig { abstract.returns(String) } + def self.banner_name; end + + sig { params(description: String).returns(String) } + def self.switch_description(description) + description + end + + sig { params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) } + def self.entry(name, options = {}) + raise "unknown options(#{options.keys.inspect}) for #{type}" if options.present? + + Dsl::Entry.new(type, name) + end + + sig { returns(String) } + def self.flag + type.to_s.tr("_", "-") + end + + sig { returns(Symbol) } + def self.predicate_method + :"#{type}?" + end + + sig { returns(String) } + def self.package_manager_name + flag + end + + sig { returns(T::Boolean) } + def self.package_manager_installed? + package_manager_executable.present? + end + + sig { returns(T.nilable(Pathname)) } + def self.package_manager_executable + which(package_manager_name, ORIGINAL_PATHS) + end + + sig { returns(Pathname) } + def self.package_manager_executable! + package_manager_executable || raise("#{package_manager_name} is not installed") + end + + sig { params(executable: Pathname).returns(T::Hash[String, String]) } + def self.package_manager_env(executable) + { "PATH" => "#{executable.dirname}:#{ORIGINAL_PATHS.join(":")}" } + end + + sig { + type_parameters(:U) + .params(_blk: T.proc.params(executable: Pathname).returns(T.type_parameter(:U))) + .returns(T.type_parameter(:U)) + } + def self.with_package_manager_env(&_blk) + executable = package_manager_executable! + with_env(package_manager_env(executable)) { yield executable } + end + + sig { returns(String) } + def self.package_description + check_label.downcase + end + + sig { returns(T::Boolean) } + def self.dump_supported? + true + end + + sig { returns(String) } + def self.dump_disable_description + "`dump` without #{banner_name}." + end + + sig { returns(Symbol) } + def self.dump_disable_env + :"bundle_dump_no_#{type}" + end + + sig { returns(Symbol) } + def self.cleanup_disable_env + :"bundle_cleanup_no_#{type}" + end + + sig { returns(T::Boolean) } + def self.dump_disable_supported? + true + end + + sig { returns(String) } + def self.cleanup_disable_description + "`cleanup` without #{banner_name}." + end + + sig { returns(Symbol) } + def self.dump_disable_predicate_method + disable_predicate_method + end + + sig { returns(Symbol) } + def self.disable_predicate_method + :"no_#{type}?" + end + + sig { returns(T::Boolean) } + def self.add_supported? + true + end + + sig { returns(T::Boolean) } + def self.remove_supported? + true + end + + sig { returns(T::Boolean) } + def self.install_supported? + true + end + + sig { override.params(_name: String, _options: Homebrew::Bundle::EntryOptions).returns(String) } + def self.install_verb(_name = "", _options = {}) + "Installing" + end + + sig { + params( + name: String, + options: Homebrew::Bundle::EntryOptions, + no_upgrade: T::Boolean, + ).returns(T.nilable(String)) + } + def self.fetchable_name(name, options = {}, no_upgrade: false) + _ = name + _ = options + _ = no_upgrade + + nil + end + + sig { returns(T.nilable(String)) } + def self.cleanup_heading + nil + end + + sig { returns(T::Boolean) } + def self.cleanup_supported? + !cleanup_heading.nil? + end + + sig { abstract.void } + def self.reset!; end + + sig { abstract.returns(T::Array[T.untyped]) } + def self.packages; end + + sig { abstract.returns(T::Array[T.untyped]) } + def self.installed_packages; end + + sig { params(package: Object).returns(String) } + def self.dump_entry(package) + line = "#{type} #{quote(dump_name(package))}" + with = dump_with(package) + return line if with.blank? + + formatted_with = with.map { |requirement| quote(requirement) }.join(", ") + "#{line}, with: [#{formatted_with}]" + end + + sig { params(value: String).returns(String) } + def self.quote(value) + value.inspect + end + + sig { params(package: Object).returns(String) } + def self.dump_name(package) + package.to_s + end + + sig { params(_package: Object).returns(T.nilable(T::Array[String])) } + def self.dump_with(_package) + nil + end + + sig { override.returns(String) } + def self.dump + packages.map { |package| dump_entry(package) }.join("\n") + end + + sig { params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def self.dump_output(describe: false, no_restart: false) + _ = describe + _ = no_restart + + dump + end + + sig { + params( + entries: T::Array[Dsl::Entry], + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.check(entries, exit_on_first_error: false, no_upgrade: false, verbose: false) + new.find_actionable(entries, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { params(entries: T::Array[Dsl::Entry]).returns(T::Array[String]) } + def self.cleanup_items(entries) + return [].freeze unless package_manager_installed? + + kept_packages = entries.filter_map do |entry| + entry.name if entry.type == type + end + + return [].freeze if kept_packages.empty? + + installed_names = packages.map { |pkg| dump_name(pkg) } + installed_names - kept_packages + end + + sig { params(item: String).returns(String) } + def self.cleanup_item_name(item) + item + end + + sig { returns(Symbol) } + def self.legacy_check_step + :registered_extensions_to_install + end + + sig { params(items: T::Array[String]).void } + def self.cleanup!(items) + executable = package_manager_executable + return if executable.nil? + + with_env(package_manager_env(executable)) do + items.each do |name| + uninstall_package!(name, executable:) + end + end + puts "Uninstalled #{items.size} #{banner_name}#{"s" if items.size != 1}" + end + + sig { params(name: String, executable: Pathname).void } + def self.uninstall_package!(name, executable: Pathname.new("")) + raise NotImplementedError, "#{self} must override `uninstall_package!` or `cleanup!`." + end + + sig { params(name: String, with: T.nilable(T::Array[String])).returns(Object) } + def self.package_record(name, with: nil) + _ = with + + name + end + + sig { params(name: String, with: T.nilable(T::Array[String])).returns(T::Boolean) } + def self.package_installed?(name, with: nil) + installed_packages.include?(package_record(name, with:)) + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + no_upgrade: T::Boolean, + verbose: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def self.preinstall!(name, with: nil, no_upgrade: false, verbose: false, **_options) + _ = no_upgrade + + ensure_package_manager_installed!(name, verbose:) + + if package_installed?(name, with:) + puts "Skipping install of #{name} #{package_description}. It is already installed." if verbose + return false + end + + true + end + + sig { params(name: String, verbose: T::Boolean).void } + def self.ensure_package_manager_installed!(name, verbose: false) + return if package_manager_installed? + + puts "Installing #{package_manager_name}. It is not currently installed." if verbose + Bundle.system(HOMEBREW_BREW_FILE, "install", "--formula", package_manager_name, verbose:) + # `formula_versions_from_env` consumes the env vars once at startup, so + # keep the cached values across reset when bootstrapping a manager. + formula_versions_from_env = T.let( + Bundle.formula_versions_from_env_cache, + T.nilable(T::Hash[String, String]), + ) + upgrade_formulae = Bundle.upgrade_formulae + Bundle.reset! + Bundle.formula_versions_from_env_cache = formula_versions_from_env + Bundle.upgrade_formulae = upgrade_formulae.join(",") + return if package_manager_installed? + + raise "Unable to install #{name} #{package_description}. " \ + "#{package_manager_name} installation failed." + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def self.install!(name, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + **_options) + _ = no_upgrade + _ = force + + return true unless preinstall + + puts "Installing #{name} #{package_description}. It is not currently installed." if verbose + return false unless install_package!(name, with:, verbose:) + + package = package_record(name, with:) + installed_packages << package unless installed_packages.include?(package) + packages << package unless packages.include?(package) + true + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(package, no_upgrade:) + "#{self.class.check_label} #{self.class.dump_name(package)} needs to be installed." + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + self.class.package_installed?(self.class.dump_name(package), with: self.class.dump_with(package)) + end + + sig { + overridable.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def self.install_package!(name, with: nil, verbose: false) + _ = name + _ = with + _ = verbose + + raise NotImplementedError, "#{self} must override `install_package!` or `install!`." + end + end + + class << self + sig { params(extension: T.class_of(Extension)).void } + def register_extension(extension) + @extensions ||= T.let([], T.nilable(T::Array[T.class_of(Extension)])) + @extensions.reject! { |registered| registered.name == extension.name } + @extensions << extension + end + + sig { returns(T::Array[T.class_of(Extension)]) } + def extensions + @extensions ||= T.let([], T.nilable(T::Array[T.class_of(Extension)])) + @extensions + end + + sig { params(type: T.any(Symbol, String)).returns(T.nilable(T.class_of(Extension))) } + def extension(type) + requested_type = type.to_sym + extensions.find { |registered| registered.type == requested_type } + end + + sig { + params( + type: T.any(Symbol, String), + ).returns(T.nilable(T.class_of(PackageType))) + } + def installable(type) + package_type(type) || extension(type) + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/flatpak.rb b/Library/Homebrew/bundle/extensions/flatpak.rb new file mode 100644 index 0000000000000..f805cbf2b55bb --- /dev/null +++ b/Library/Homebrew/bundle/extensions/flatpak.rb @@ -0,0 +1,431 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class Flatpak < Extension + Package = T.type_alias { { name: String, remote: String, remote_url: T.nilable(String) } } + + class << self + sig { override.returns(Symbol) } + def type = :flatpak + + sig { override.returns(String) } + def check_label = "Flatpak" + + sig { override.returns(String) } + def banner_name = "Flatpak packages" + + sig { override.params(description: String).returns(String) } + def switch_description(description) + "#{super} Note: Linux only." + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + "flatpaks" + end + + sig { override.params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) } + def entry(name, options = {}) + unknown_options = options.keys - [:remote, :url] + raise "unknown options(#{unknown_options.inspect}) for flatpak" if unknown_options.present? + + remote = options[:remote] + url = options[:url] + if !remote.nil? && !remote.is_a?(String) + raise "options[:remote](#{remote.inspect}) should be a String object" + end + raise "options[:url](#{url.inspect}) should be a String object" if !url.nil? && !url.is_a?(String) + + # Validate: url: can only be used with a named remote (not a URL remote) + if url && remote&.start_with?("http://", "https://") + raise "url: parameter cannot be used when remote: is already a URL" + end + + normalized_options = {} + normalized_options[:remote] = remote || "flathub" + normalized_options[:url] = url if url + + Dsl::Entry.new(type, name, normalized_options) + end + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[String])) + @packages_with_remotes = T.let(nil, T.nilable(T::Array[Package])) + @remote_urls = T.let(nil, T.nilable(T::Hash[String, String])) + @installed_packages = T.let(nil, T.nilable(T::Array[Package])) + end + + sig { returns(T::Hash[String, String]) } + def remote_urls + remote_urls = @remote_urls + return remote_urls if remote_urls + + @remote_urls = if (flatpak = package_manager_executable) + output = `#{flatpak} remote-list --system --columns=name,url 2>/dev/null`.chomp + urls = {} + output.split("\n").each do |line| + parts = line.strip.split("\t") + next if parts.size < 2 + + name = parts[0] + url = parts[1] + urls[name] = url if name && url + end + urls + end + return {} if @remote_urls.nil? + + @remote_urls + end + + sig { returns(T::Array[Package]) } + def packages_with_remotes + packages_with_remotes = @packages_with_remotes + return packages_with_remotes if packages_with_remotes + + @packages_with_remotes = if (flatpak = package_manager_executable) + # List applications with their origin remote + # Using --app to filter applications only + # Using --columns=application,origin to get app IDs and their remotes + output = `#{flatpak} list --app --columns=application,origin 2>/dev/null`.chomp + + packages = output.split("\n").filter_map do |line| + parts = line.strip.split("\t") + name = parts[0] + next if parts.empty? || name.nil? || name.empty? + + remote = parts[1] || "flathub" + package = T.let({ name:, remote:, remote_url: T.let(nil, T.nilable(String)) }, Package) + remote_url = remote_urls[remote] + package[:remote_url] = remote_url + package + end + packages.sort_by { |pkg| pkg[:name].to_s } + end + return [] if @packages_with_remotes.nil? + + @packages_with_remotes + end + + sig { override.returns(T::Array[String]) } + def packages + packages = @packages + return packages if packages + + @packages = packages_with_remotes.map { |pkg| pkg[:name] } + end + + sig { override.returns(T::Array[Package]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages_with_remotes.dup + end + + sig { override.params(package: Object).returns(String) } + def dump_entry(package) + package = T.cast(package, Package) + remote = package[:remote] + remote_url = package[:remote_url] + name = package[:name] + + if remote == "flathub" + # Tier 1: Don't specify remote for flathub (default) + "flatpak #{quote(name)}" + elsif remote&.end_with?("-origin") + # Tier 2: Single-app remote - dump with URL only + if remote_url.present? + "flatpak #{quote(name)}, remote: #{quote(remote_url)}" + else + # Fallback if URL not available (shouldn't happen for -origin remotes) + "flatpak #{quote(name)}, remote: #{quote(remote)}" + end + elsif remote_url.present? + # Tier 3: Named shared remote - dump with name and URL + "flatpak #{quote(name)}, remote: #{quote(remote)}, url: #{quote(remote_url)}" + else + # Named remote without URL (user-defined or system remote) + "flatpak #{quote(name)}, remote: #{quote(remote)}" + end + end + + sig { override.returns(String) } + def dump + packages_with_remotes.map { |package| dump_entry(package) }.join("\n") + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + no_upgrade: T::Boolean, + verbose: T::Boolean, + remote: String, + url: T.nilable(String), + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, with: nil, no_upgrade: false, verbose: false, remote: "flathub", url: nil, **_options) + _ = with + _ = no_upgrade + _ = url + + return false unless package_manager_installed? + + # Check if package is installed at all (regardless of remote) + if package_installed?(name) + puts "Skipping install of #{name} Flatpak. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + remote: String, + url: T.nilable(String), + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + remote: "flathub", url: nil, **_options) + _ = with + _ = no_upgrade + _ = force + + return true unless package_manager_installed? + return true unless preinstall + + flatpak = package_manager_executable!.to_s + + # 3-tier remote handling: + # - Tier 1: no URL → use named remote (default: flathub) + # - Tier 2: URL only → single-app remote (-origin) + # - Tier 3: URL + name → named shared remote + + if url.present? + # Tier 3: Named remote with URL - create shared remote + puts "Installing #{name} Flatpak from #{remote} (#{url}). It is not currently installed." if verbose + ensure_named_remote_exists!(flatpak, remote, url, verbose:) + actual_remote = remote + elsif remote.start_with?("http://", "https://") + if remote.end_with?(".flatpakref") + # .flatpakref files - install directly (Flatpak handles single-app remote natively) + puts "Installing #{name} Flatpak from #{remote}. It is not currently installed." if verbose + return install_flatpakref!(flatpak, name, remote, verbose:) + else + # Tier 2: URL only - create single-app remote + actual_remote = generate_single_app_remote_name(name) + if verbose + puts "Installing #{name} Flatpak from #{actual_remote} (#{remote}). It is not currently installed." + end + ensure_single_app_remote_exists!(flatpak, actual_remote, remote, verbose:) + end + else + # Tier 1: Named remote (default: flathub) + puts "Installing #{name} Flatpak from #{remote}. It is not currently installed." if verbose + actual_remote = remote + end + + # Install from the remote + return false unless Bundle.system(flatpak, "install", "-y", "--system", actual_remote, name, verbose:) + + package = { name:, remote: actual_remote, remote_url: url } + packages_with_remotes = T.let(@packages_with_remotes || [], T::Array[Package]) + packages_with_remotes << package + @packages_with_remotes = packages_with_remotes + @installed_packages = packages_with_remotes.dup + @packages = packages_with_remotes.map { |pkg| pkg[:name] } + true + end + + # Install from a .flatpakref file (Tier 2 variant - Flatpak handles single-app remote natively) + sig { params(flatpak: String, name: String, url: String, verbose: T::Boolean).returns(T::Boolean) } + def install_flatpakref!(flatpak, name, url, verbose:) + return false unless Bundle.system(flatpak, "install", "-y", "--system", url, verbose:) + + # Get the actual remote name used by Flatpak + output = `#{flatpak} list --app --columns=application,origin 2>/dev/null`.chomp + installed = output.split("\n").find { |line| line.start_with?(name) } + actual_remote = installed ? installed.split("\t")[1] : "#{name}-origin" + actual_remote ||= "#{name}-origin" + package = { name:, remote: actual_remote, remote_url: nil } + packages_with_remotes = T.let(@packages_with_remotes || [], T::Array[Package]) + packages_with_remotes << package + @packages_with_remotes = packages_with_remotes + @installed_packages = packages_with_remotes.dup + @packages = packages_with_remotes.map { |pkg| pkg[:name] } + true + end + + # Generate a single-app remote name (Tier 2) + # Pattern: -origin (matches Flatpak's native behavior for .flatpakref) + sig { params(app_id: String).returns(String) } + def generate_single_app_remote_name(app_id) + "#{app_id}-origin" + end + + # Ensure a single-app remote exists (Tier 2) + # Safe to replace if URL differs since it's isolated per-app + sig { params(flatpak: String, remote_name: String, url: String, verbose: T::Boolean).void } + def ensure_single_app_remote_exists!(flatpak, remote_name, url, verbose:) + existing_url = get_remote_url(flatpak, remote_name) + + if existing_url && existing_url != url + # Single-app remote with different URL - safe to replace + puts "Replacing single-app remote #{remote_name} (URL changed)" if verbose + Bundle.system(flatpak, "remote-delete", "--system", "--force", remote_name, verbose:) + existing_url = nil + end + + return if existing_url + + puts "Adding single-app remote #{remote_name} from #{url}" if verbose + add_remote!(flatpak, remote_name, url, verbose:) + end + + # Ensure a named shared remote exists (Tier 3) + # Warn but don't change if URL differs (user explicitly named it) + sig { params(flatpak: String, remote_name: String, url: String, verbose: T::Boolean).void } + def ensure_named_remote_exists!(flatpak, remote_name, url, verbose:) + existing_url = get_remote_url(flatpak, remote_name) + + if existing_url && existing_url != url + # Named remote with different URL - warn but don't change (user explicitly named it) + puts "Warning: Remote '#{remote_name}' exists with different URL (#{existing_url}), using existing" + return + end + + return if existing_url + + puts "Adding named remote #{remote_name} from #{url}" if verbose + add_remote!(flatpak, remote_name, url, verbose:) + end + + # Get URL for an existing remote, or nil if not found + sig { params(flatpak: String, remote_name: String).returns(T.nilable(String)) } + def get_remote_url(flatpak, remote_name) + output = `#{flatpak} remote-list --system --columns=name,url 2>/dev/null`.chomp + output.split("\n").each do |line| + parts = line.split("\t") + return parts[1] if parts[0] == remote_name + end + nil + end + + # Add a remote with appropriate flags + sig { params(flatpak: String, remote_name: String, url: String, verbose: T::Boolean).returns(T::Boolean) } + def add_remote!(flatpak, remote_name, url, verbose:) + if url.end_with?(".flatpakrepo") + Bundle.system(flatpak, "remote-add", "--if-not-exists", "--system", remote_name, url, verbose:) + else + # For bare repository URLs, add with --no-gpg-verify for user repos + Bundle.system( + flatpak, "remote-add", "--if-not-exists", "--system", "--no-gpg-verify", remote_name, url, verbose: + ) + end + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + remote: T.nilable(String), + ).returns(T::Boolean) + } + def package_installed?(name, with: nil, remote: nil) + _ = with + + if remote + installed_packages.any? { |pkg| pkg[:name] == name && pkg[:remote] == remote } + else + installed_packages.any? { |pkg| pkg[:name] == name } + end + end + + sig { params(entries: T::Array[Dsl::Entry]).returns(T::Array[String]) } + def cleanup_items(entries) + return [].freeze unless package_manager_installed? + + kept_flatpaks = entries.filter_map do |entry| + entry.name if entry.type == type + end + + return [].freeze if kept_flatpaks.empty? + + packages - kept_flatpaks + end + + sig { params(flatpaks: T::Array[String]).void } + def cleanup!(flatpaks) + flatpaks.each do |flatpak_name| + Kernel.system("flatpak", "uninstall", "-y", "--system", flatpak_name) + end + puts "Uninstalled #{flatpaks.size} flatpak#{"s" if flatpaks.size != 1}" + end + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries).map do |entry| + { name: entry.name, options: entry.options } + end + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(package, no_upgrade:) + _ = no_upgrade + + name = if package.is_a?(Hash) + package[:name] + else + package + end + "#{self.class.check_label} #{name} needs to be installed." + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + _ = no_upgrade + + return self.class.package_installed?(T.cast(package, String)) unless package.is_a?(Hash) + + flatpak = package + name = T.cast(flatpak[:name], String) + options = T.cast(flatpak[:options], T::Hash[Symbol, String]) + remote = options.fetch(:remote, "flathub") + url = options[:url] + + # 3-tier remote handling: + # - Tier 1: Named remote → check with that remote name + # - Tier 2: URL only → resolve to single-app remote name (-origin) + # - Tier 3: URL + name → check with the named remote + actual_remote = if url.blank? && remote.start_with?("http://", "https://") + # Tier 2: URL only - resolve to single-app remote name + # (.flatpakref - check by name only since remote name varies) + return self.class.package_installed?(name) if remote.end_with?(".flatpakref") + + self.class.generate_single_app_remote_name(name) + else + # Tier 1 (named remote) and Tier 3 (named remote with URL) both use the remote name + remote + end + + self.class.package_installed?(name, remote: actual_remote) + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/go.rb b/Library/Homebrew/bundle/extensions/go.rb new file mode 100644 index 0000000000000..5e5ac2f525819 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/go.rb @@ -0,0 +1,129 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class Go < Extension + class << self + sig { override.returns(Symbol) } + def type = :go + + sig { override.returns(String) } + def check_label = "Go Package" + + sig { override.returns(String) } + def banner_name = "Go packages" + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[String])) + @installed_packages = T.let(nil, T.nilable(T::Array[String])) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.returns(T::Array[String]) } + def packages + packages = @packages + return packages if packages + + @packages = if (go = package_manager_executable) + ENV["GOBIN"] = ENV.fetch("HOMEBREW_GOBIN", nil) + ENV["GOPATH"] = ENV.fetch("HOMEBREW_GOPATH", nil) + gobin = `#{go} env GOBIN`.chomp + gopath = `#{go} env GOPATH`.chomp + bin_dir = gobin.empty? ? "#{gopath}/bin" : gobin + if File.directory?(bin_dir) + binaries = Dir.glob("#{bin_dir}/*").select do |file| + File.executable?(file) && !File.directory?(file) && !File.symlink?(file) + end + + binaries.filter_map do |binary| + output = `#{go} version -m "#{binary}" 2>/dev/null` + next if output.empty? + + lines = output.split("\n") + path_line = lines.find { |line| line.strip.start_with?("path\t") } + next unless path_line + + # Parse the output to find the path line + # Format: "\tpath\tgithub.com/user/repo" + parts = path_line.split("\t") + # Extract the package path (second field after splitting by tab) + # The line format is: "\tpath\tgithub.com/user/repo" + path = parts[2]&.strip + + # `command-line-arguments` is a dummy package name for binaries built + # from a list of source files instead of a specific package name. + # https://github.com/golang/go/issues/36043 + next if path == "command-line-arguments" + + path + end.uniq + end + end + return [] if @packages.nil? + + @packages + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, verbose: false) + _ = with + + go = package_manager_executable! + + Bundle.system(go.to_s, "install", "#{name}@latest", verbose:) + end + + sig { override.returns(T::Array[String]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages.dup + end + + sig { override.params(items: T::Array[String]).void } + def cleanup!(items) + go = package_manager_executable + return if go.nil? + + gobin = `#{go} env GOBIN`.chomp + gopath = `#{go} env GOPATH`.chomp + bin_dir = gobin.empty? ? "#{gopath}/bin" : gobin + return unless File.directory?(bin_dir) + + removed = 0 + Dir.glob("#{bin_dir}/*").each do |binary| + next if !File.executable?(binary) || File.directory?(binary) || File.symlink?(binary) + + output = `#{go} version -m "#{binary}" 2>/dev/null` + next if output.empty? + + path_line = output.split("\n").find { |line| line.strip.start_with?("path\t") } + next unless path_line + + module_path = path_line.split("\t")[2]&.strip + next unless items.include?(module_path) + + FileUtils.rm_f(binary) + removed += 1 + end + puts "Uninstalled #{removed} #{banner_name}#{"s" if removed != 1}" + end + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/krew.rb b/Library/Homebrew/bundle/extensions/krew.rb new file mode 100644 index 0000000000000..532b93a7bfd13 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/krew.rb @@ -0,0 +1,92 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class Krew < Extension + class << self + sig { override.returns(Symbol) } + def type = :krew + + sig { override.returns(String) } + def check_label = "Krew Plugin" + + sig { override.returns(String) } + def banner_name = "Krew plugins" + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[String])) + @installed_packages = T.let(nil, T.nilable(T::Array[String])) + @package_manager_executable = T.let(nil, T.nilable(Pathname)) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.returns(T.nilable(Pathname)) } + def package_manager_executable + @package_manager_executable ||= T.let(which("kubectl-krew", ORIGINAL_PATHS), T.nilable(Pathname)) + end + + sig { override.returns(T::Array[String]) } + def packages + packages = @packages + return packages if packages + + @packages = if package_manager_installed? + with_package_manager_env do |krew| + parse_plugin_list(`#{krew} list 2>/dev/null`) + end + else + [] + end + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, verbose: false) + _ = with + + with_package_manager_env do |krew| + Bundle.system(krew.to_s, "install", name, verbose:) + end + end + + sig { override.returns(T::Array[String]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages.dup + end + + sig { params(output: String).returns(T::Array[String]) } + def parse_plugin_list(output) + output.lines.filter_map do |line| + line = line.strip + next if line.empty? + + name = line.split(/\s+/).first + name.presence + end.uniq + end + private :parse_plugin_list + + sig { override.params(name: String, executable: Pathname).void } + def uninstall_package!(name, executable: Pathname.new("")) + Bundle.system(executable.to_s, "uninstall", name, verbose: false) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/mac_app_store.rb b/Library/Homebrew/bundle/extensions/mac_app_store.rb new file mode 100644 index 0000000000000..d02ab8648d2a2 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/mac_app_store.rb @@ -0,0 +1,303 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" +require "json" + +module Homebrew + module Bundle + class MacAppStore < Extension + class App < T::Struct + const :id, String + const :name, String + end + CheckablePackages = T.type_alias { T.any(T::Array[Object], T::Hash[Integer, String]) } + + class << self + sig { override.returns(Symbol) } + def type = :mas + + sig { override.returns(String) } + def check_label = "App" + + sig { override.returns(String) } + def banner_name = "Mac App Store dependencies" + + sig { override.returns(Symbol) } + def legacy_check_step + :apps_to_install + end + + sig { override.returns(T::Boolean) } + def add_supported? + false + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + "Mac App Store apps" + end + + sig { override.params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) } + def entry(name, options = {}) + id = options[:id] + raise "options[:id](#{id}) should be an Integer object" unless id.is_a? Integer + + Dsl::Entry.new(type, name, id:) + end + + sig { override.void } + def reset! + @apps = T.let(nil, T.nilable(T::Array[[String, String]])) + @packages = T.let(nil, T.nilable(T::Array[App])) + @installed_app_ids = T.let(nil, T.nilable(T::Array[String])) + @outdated_app_ids = T.let(nil, T.nilable(T::Array[String])) + end + + sig { returns(T::Array[[String, String]]) } + def apps + apps = @apps + return apps if apps + + @apps = if (mas = package_manager_executable) + `#{mas} list 2>/dev/null`.split("\n").filter_map do |app| + app_details = app.match(/\A\s*(?\d+)\s+(?.*?)\s+\((?[\d.]*)\)\Z/) + next if app_details.nil? + + id = app_details[:id] + name = app_details[:name] + next if id.nil? || name.nil? + + # Only add the application details should we have a valid match. + # Strip unprintable characters + [id, name.gsub(/[[:cntrl:]]|\p{C}/, "")] + end + end + return [] if @apps.nil? + + @apps + end + + sig { returns(T::Array[String]) } + def app_ids + apps.map(&:first) + end + + sig { override.returns(T::Array[App]) } + def packages + packages = @packages + return packages if packages + + @packages = apps.sort_by { |_, name| name.downcase }.map { |id, name| App.new(id:, name:) } + end + + sig { override.returns(T::Array[App]) } + def installed_packages + packages + end + + sig { returns(T::Array[String]) } + def installed_app_ids + installed_app_ids = @installed_app_ids + return installed_app_ids if installed_app_ids + + @installed_app_ids = app_ids + end + + sig { override.params(package: Object).returns(String) } + def dump_entry(package) + app = T.cast(package, App) + "mas #{quote(app.name)}, id: #{app.id}" + end + + sig { params(app: App).returns(String) } + def cleanup_item(app) + JSON.generate("id" => app.id, "name" => app.name) + end + + sig { override.params(item: String).returns(String) } + def cleanup_item_name(item) + app = parse_cleanup_item(item) + "#{app.name} (#{app.id})" + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[String]) } + def cleanup_items(entries) + return [].freeze unless package_manager_installed? + + kept_app_ids = entries.filter_map do |entry| + entry.options[:id].to_s if entry.type == type + end + return [].freeze if kept_app_ids.empty? + + packages.reject { |app| app.id.to_i.zero? || kept_app_ids.any? { |id| app.id.to_i == id.to_i } } + .map { |app| cleanup_item(app) } + end + + sig { override.params(items: T::Array[String]).void } + def cleanup!(items) + mas = package_manager_executable + return if mas.nil? + + items.each do |item| + Bundle.system(mas, "uninstall", parse_cleanup_item(item).id, verbose: false) + end + puts "Uninstalled #{items.size} Mac App Store app#{"s" if items.size != 1}" + end + + sig { params(id: Integer).returns(T::Boolean) } + def app_id_installed?(id) + installed_app_ids.any? { |app_id| app_id.to_i == id } + end + + sig { params(id: Integer).returns(T::Boolean) } + def app_id_upgradable?(id) + outdated_app_ids.any? { |app_id| app_id.to_i == id } + end + + sig { params(id: Integer, no_upgrade: T::Boolean).returns(T::Boolean) } + def app_id_installed_and_up_to_date?(id, no_upgrade: false) + return false unless app_id_installed?(id) + return true if no_upgrade + + !app_id_upgradable?(id) + end + + sig { returns(T::Array[String]) } + def outdated_app_ids + outdated_app_ids = @outdated_app_ids + return outdated_app_ids if outdated_app_ids + + @outdated_app_ids = if (mas = package_manager_executable) + `#{mas} outdated 2>/dev/null`.split("\n").map do |app| + app.split(" ", 2).first.to_s + end + end + return [] if @outdated_app_ids.nil? + + @outdated_app_ids + end + + sig { + override.params( + name: String, + id: T.nilable(Integer), + with: T.nilable(T::Array[String]), + no_upgrade: T::Boolean, + verbose: T::Boolean, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, id = nil, with: nil, no_upgrade: false, verbose: false, **options) + _ = with + id ||= T.cast(options[:id], T.nilable(Integer)) + raise ArgumentError, "missing keyword: id" if id.nil? + + unless package_manager_installed? + puts "Installing mas. It is not currently installed." if verbose + Bundle.system(HOMEBREW_BREW_FILE, "install", "mas", verbose:) + raise "Unable to install #{name} app. mas installation failed." unless package_manager_installed? + end + + if app_id_installed?(id) && + (no_upgrade || !app_id_upgradable?(id)) + puts "Skipping install of #{name} app. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params( + name: String, + id: T.nilable(Integer), + with: T.nilable(T::Array[String]), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, id = nil, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + **options) + _ = with + id ||= T.cast(options[:id], T.nilable(Integer)) + raise ArgumentError, "missing keyword: id" if id.nil? + + _ = no_upgrade + _ = force + + return true unless preinstall + + mas = package_manager_executable! + + if app_id_installed?(id) + puts "Upgrading #{name} app. It is installed but not up-to-date." if verbose + return false unless Bundle.system(mas, "upgrade", id.to_s, verbose:) + + return true + end + + puts "Installing #{name} app. It is not currently installed." if verbose + installed = Bundle.system(mas, "install", id.to_s, verbose:) || + Bundle.system(mas, "get", id.to_s, verbose:) + return false unless installed + + apps << [id.to_s, name] unless apps.any? { |app_id, _app_name| app_id.to_i == id } + packages << App.new(id: id.to_s, name:) unless packages.any? { |app| app.id.to_i == id } + installed_app_ids << id.to_s unless installed_app_ids.include?(id.to_s) + true + end + + sig { params(item: String).returns(App) } + def parse_cleanup_item(item) + parsed = JSON.parse(item) + raise TypeError, "Invalid Mac App Store cleanup item: #{item}" unless parsed.is_a?(Hash) + + id = parsed["id"] + name = parsed["name"] + raise TypeError, "Invalid Mac App Store cleanup item: #{item}" if !id.is_a?(String) || !name.is_a?(String) + + App.new(id:, name:) + end + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries).map do |entry| + [T.cast(entry.options.fetch(:id), Integer), entry.name] + end + end + + sig { override.params(packages: CheckablePackages, no_upgrade: T::Boolean).returns(T::Array[String]) } + def exit_early_check(packages, no_upgrade:) + (packages.is_a?(Hash) ? packages.to_a : packages).each do |id, name| + next if installed_and_up_to_date?(id, no_upgrade:) + + return [failure_reason(name, no_upgrade:)] + end + [] + end + + sig { override.params(packages: CheckablePackages, no_upgrade: T::Boolean).returns(T::Array[String]) } + def full_check(packages, no_upgrade:) + (packages.is_a?(Hash) ? packages.to_a : packages) + .reject { |id, _name| installed_and_up_to_date?(id, no_upgrade:) } + .map { |_id, name| failure_reason(name, no_upgrade:) } + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(package, no_upgrade:) + reason = no_upgrade ? "needs to be installed." : "needs to be installed or updated." + "#{self.class.check_label} #{package} #{reason}" + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + self.class.app_id_installed_and_up_to_date?(T.cast(package, Integer), no_upgrade:) + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/npm.rb b/Library/Homebrew/bundle/extensions/npm.rb new file mode 100644 index 0000000000000..1a539414867cd --- /dev/null +++ b/Library/Homebrew/bundle/extensions/npm.rb @@ -0,0 +1,99 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" +require "language/node" + +module Homebrew + module Bundle + class Npm < Extension + class << self + sig { override.returns(Symbol) } + def type = :npm + + sig { override.returns(String) } + def check_label = "npm Package" + + sig { override.returns(String) } + def banner_name = "npm packages" + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[String])) + @installed_packages = T.let(nil, T.nilable(T::Array[String])) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.returns(String) } + def package_manager_name + "node" + end + + sig { override.returns(T.nilable(Pathname)) } + def package_manager_executable + which("npm", ORIGINAL_PATHS) + end + + sig { override.returns(T::Array[String]) } + def packages + packages = @packages + return packages if packages + + @packages = if (npm = package_manager_executable) && + (!npm.to_s.start_with?("/") || npm.exist?) + with_env(package_manager_env(npm)) do + parse_package_list(`#{npm} list -g --depth=0 --json 2>/dev/null`) + end + end + return [] if @packages.nil? + + @packages + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, verbose: false) + _ = with + + npm = package_manager_executable! + + Bundle.system(npm.to_s, "install", *Language::Node.npm_install_security_args, "-g", name, verbose:) + end + + sig { override.returns(T::Array[String]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages.dup + end + + sig { override.params(name: String, executable: Pathname).void } + def uninstall_package!(name, executable: Pathname.new("")) + Bundle.system(executable.to_s, "uninstall", "-g", name, verbose: false) + end + + sig { params(output: String).returns(T::Array[String]) } + def parse_package_list(output) + return [] if output.blank? + + json = JSON.parse(output) + deps = json.fetch("dependencies", {}) + deps.keys.reject { |name| name == "npm" } + rescue JSON::ParserError + [] + end + private :parse_package_list + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/uv.rb b/Library/Homebrew/bundle/extensions/uv.rb new file mode 100644 index 0000000000000..071478c7bc9c4 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/uv.rb @@ -0,0 +1,368 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class Uv < Extension + WithOptions = T.type_alias { T::Hash[Symbol, T.any(String, T::Array[String])] } + Tool = T.type_alias { { name: String, with: T::Array[String], source: T.nilable(String) } } + Checkable = T.type_alias { { name: String, options: WithOptions } } + ToolEntry = T.type_alias { T.any(Tool, Checkable) } + + SOURCE_REQUIREMENT_REGEX = T.let(%r{\A(?:git\+|https?://|file://|\.{0,2}/)|\.git\z}, Regexp) + + class << self + sig { override.returns(Symbol) } + def type = :uv + + sig { override.returns(String) } + def check_label = "uv Tool" + + sig { override.returns(String) } + def banner_name = "uv tools" + + sig { override.params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) } + def entry(name, options = {}) + unknown_options = options.keys - [:with, :source] + raise "unknown options(#{unknown_options.inspect}) for uv" if unknown_options.present? + + with = options[:with] + if !with.nil? && (!with.is_a?(Array) || with.any? { |requirement| !requirement.is_a?(String) }) + raise "options[:with](#{with.inspect}) should be an Array of String objects" + end + + source = options.fetch(:source, nil) + if !source.nil? && !source.is_a?(String) + raise "options[:source](#{source.inspect}) should be a String object" + end + + normalized_options = {} + normalized_with = normalize_with(with || []) + normalized_options[:with] = normalized_with if normalized_with.present? + normalized_source = normalize_source(source) + normalized_options[:source] = normalized_source if normalized_source.present? + + Dsl::Entry.new(:uv, name, normalized_options) + end + + sig { override.void } + def reset! + @packages = T.let(nil, T.nilable(T::Array[Tool])) + @installed_packages = T.let(nil, T.nilable(T::Array[Tool])) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.returns(T::Array[Tool]) } + def packages + packages = @packages + return packages if packages + + @packages = if (uv = package_manager_executable) + output = `#{uv} tool list --show-with --show-extras --show-version-specifiers 2>/dev/null` + parse_tool_list(output) + end + return [] if @packages.nil? + + @packages + end + + sig { override.params(package: Object).returns(String) } + def dump_name(package) + package_name(T.cast(package, ToolEntry)) + end + + sig { override.params(package: Object).returns(T.nilable(T::Array[String])) } + def dump_with(package) + package_with(T.cast(package, ToolEntry)) + end + + sig { params(package: Object).returns(T.nilable(String)) } + def dump_source(package) + package_source(T.cast(package, ToolEntry)) + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + source: T.nilable(String), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, source: nil, verbose: false) + uv = package_manager_executable! + + args = ["tool", "install", source.presence || name] + normalize_with(with || []).each do |requirement| + args << "--with" + args << requirement + end + + Bundle.system(uv.to_s, *args, verbose:) + end + + sig { override.returns(T::Array[Tool]) } + def installed_packages + installed_packages = @installed_packages + return installed_packages if installed_packages + + @installed_packages = packages.dup + end + + sig { params(output: String).returns(T::Array[Tool]) } + def parse_tool_list(output) + entries = T.let([], T::Array[Tool]) + + output.each_line do |line| + match = line.match(/\A(\S+)\s+v\S+/) + next unless match + + name = match[1] + next if name.nil? + + extras_raw = line[/\[extras:\s*([^\]]+)\]/, 1] + name = name_with_extras(name, extras_raw) + with_raw = line[/\[with:\s*([^\]]+)\]/, 1] + required_raw = line[/\[required:\s*([^\]]+)\]/, 1] + + entries << { + name: name, + with: parse_with_requirements(with_raw), + source: parse_source(required_raw), + } + end + + entries.sort_by { |entry| entry[:name].to_s } + end + private :parse_tool_list + + sig { params(required_raw: T.nilable(String)).returns(T.nilable(String)) } + def parse_source(required_raw) + source = normalize_source(required_raw) + return if source.nil? + return source if source.match?(SOURCE_REQUIREMENT_REGEX) + + nil + end + private :parse_source + + sig { params(name: String, extras_raw: T.nilable(String)).returns(String) } + def name_with_extras(name, extras_raw) + return name if extras_raw.blank? + + extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort + return name if extras.empty? + + "#{name}[#{extras.join(",")}]" + end + private :name_with_extras + + sig { params(with_raw: T.nilable(String)).returns(T::Array[String]) } + def parse_with_requirements(with_raw) + return [] if with_raw.blank? + + entries = T.let([], T::Array[String]) + with_raw.split(", ").each do |token| + requirement = token.strip + next if requirement.empty? + + if continuation_constraint?(requirement) && entries.any? + last_requirement = entries.pop + entries << "#{last_requirement}, #{normalize_constraint(requirement)}" if last_requirement + else + entries << requirement + end + end + + entries.uniq.sort + end + private :parse_with_requirements + + sig { params(requirement: String).returns(T::Boolean) } + def continuation_constraint?(requirement) + requirement.match?(/\A(?:<=|>=|!=|==|~=|<|>)\s*\S/) + end + private :continuation_constraint? + + sig { params(requirement: String).returns(String) } + def normalize_constraint(requirement) + requirement.strip.sub(/\A(<=|>=|!=|==|~=|<|>)\s+/, "\\1") + end + private :normalize_constraint + + sig { params(with: T::Array[String]).returns(T::Array[String]) } + def normalize_with(with) + with.map(&:strip).reject(&:empty?).uniq.sort + end + private :normalize_with + + sig { params(source: T.nilable(String)).returns(T.nilable(String)) } + def normalize_source(source) + source.presence&.strip + end + private :normalize_source + + sig { params(name: String).returns(String) } + def normalize_name(name) + match = name.strip.match(/\A(?[^\[\]]+)(?:\[(?[^\]]+)\])?\z/) + return name.strip unless match + + base = match[:base] + return name.strip if base.nil? + + extras_raw = match[:extras] + return base.strip if extras_raw.blank? + + extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort + return base.strip if extras.empty? + + "#{base.strip}[#{extras.join(",")}]" + end + private :normalize_name + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + source: T.nilable(String), + ).returns(Object) + } + def package_record(name, with: nil, source: nil) + normalized_options(name, with: with || [], source:) + end + + sig { params(name: String, with: T::Array[String], source: T.nilable(String)).returns(Tool) } + def normalized_options(name, with:, source: nil) + { + name: normalize_name(name), + with: normalize_with(with), + source: normalize_source(source), + } + end + private :normalized_options + + sig { params(package: ToolEntry).returns(String) } + def package_name(package) + package[:name] + end + private :package_name + + sig { params(package: ToolEntry).returns(T.nilable(T::Array[String])) } + def package_with(package) + if package.key?(:with) + package[:with] + else + package[:options].fetch(:with, []) + end + end + private :package_with + + sig { params(package: ToolEntry).returns(T.nilable(String)) } + def package_source(package) + return package[:source] if package.key?(:source) + + T.cast(package[:options].fetch(:source, nil), T.nilable(String)) + end + private :package_source + + sig { override.params(package: Object).returns(String) } + def dump_entry(package) + line = super + source = dump_source(package) + line = "#{line}, source: #{quote(source)}" if source.present? + + line + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + source: T.nilable(String), + no_upgrade: T::Boolean, + verbose: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, with: nil, source: nil, no_upgrade: false, verbose: false, **_options) + _ = no_upgrade + + ensure_package_manager_installed!(name, verbose:) + + if package_installed?(name, with:, source:) + puts "Skipping install of #{name} #{package_description}. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + source: T.nilable(String), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, with: nil, source: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + **_options) + _ = no_upgrade + _ = force + + return true unless preinstall + + puts "Installing #{name} #{package_description}. It is not currently installed." if verbose + return false unless install_package!(name, with:, source:, verbose:) + + package = normalized_options(name, with: with || [], source:) + installed_packages << package unless installed_packages.include?(package) + packages << package unless packages.include?(package) + true + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + source: T.nilable(String), + ).returns(T::Boolean) + } + def package_installed?(name, with: nil, source: nil) + installed_packages.include?(package_record(name, with:, source:)) + end + + sig { override.params(name: String, executable: Pathname).void } + def uninstall_package!(name, executable: Pathname.new("")) + Bundle.system(executable.to_s, "tool", "uninstall", name, verbose: false) + end + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries).map do |entry| + { name: entry.name, options: entry.options } + end + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + self.class.package_installed?( + self.class.dump_name(package), + with: self.class.dump_with(package), + source: self.class.dump_source(package), + ) + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/vscode_extension.rb b/Library/Homebrew/bundle/extensions/vscode_extension.rb new file mode 100644 index 0000000000000..915d8f461dd26 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/vscode_extension.rb @@ -0,0 +1,194 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" + +module Homebrew + module Bundle + class VscodeExtension < Extension + EXTENSION_ID_REGEX = /\A[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9._-]*\z/i + + class << self + sig { override.returns(Symbol) } + def type = :vscode + + sig { override.returns(String) } + def check_label = "VSCode Extension" + + sig { override.returns(String) } + def banner_name = "VSCode (and forks/variants) extensions" + + sig { override.void } + def reset! + @extensions = T.let(nil, T.nilable(T::Array[String])) + @installed_extensions = T.let(nil, T.nilable(T::Array[String])) + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + "VSCode extensions" + end + + sig { override.params(name: String, with: T.nilable(T::Array[String])).returns(Object) } + def package_record(name, with: nil) + _ = with + + name.downcase + end + + sig { override.returns(T.nilable(Pathname)) } + def package_manager_executable + which("code", ORIGINAL_PATHS) || + which("codium", ORIGINAL_PATHS) || + which("cursor", ORIGINAL_PATHS) || + which("code-insiders", ORIGINAL_PATHS) + end + + sig { returns(T::Array[String]) } + def extensions + extensions = @extensions + return extensions if extensions + + @extensions = if (vscode = package_manager_executable) + Bundle.exchange_uid_if_needed! do + ENV["WSL_DISTRO_NAME"] = ENV.fetch("HOMEBREW_WSL_DISTRO_NAME", nil) + `"#{vscode}" --list-extensions 2>/dev/null` + end.split("\n").map(&:strip).grep(EXTENSION_ID_REGEX).map(&:downcase) + end + return [] if @extensions.nil? + + @extensions + end + + sig { override.returns(T::Array[String]) } + def packages + extensions + end + + sig { override.returns(T::Array[String]) } + def installed_packages + installed_extensions + end + + sig { returns(T::Array[String]) } + def installed_extensions + installed_extensions = @installed_extensions + return installed_extensions if installed_extensions + + @installed_extensions = extensions.dup + end + + sig { override.params(name: String, with: T.nilable(T::Array[String])).returns(T::Boolean) } + def package_installed?(name, with: nil) + _ = with + + installed_extensions.include?(name.downcase) + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + no_upgrade: T::Boolean, + verbose: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, with: nil, no_upgrade: false, verbose: false, **_options) + _ = with + _ = no_upgrade + + if !package_manager_installed? && Bundle.cask_installed? + puts "Installing visual-studio-code. It is not currently installed." if verbose + Bundle.system(HOMEBREW_BREW_FILE, "install", "--cask", "visual-studio-code", verbose:) + end + + if package_installed?(name) + puts "Skipping install of #{name} VSCode extension. It is already installed." if verbose + return false + end + + unless package_manager_installed? + raise "Unable to install #{name} VSCode extension. VSCode is not installed." + end + + true + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + verbose: T::Boolean, + ).returns(T::Boolean) + } + def install_package!(name, with: nil, verbose: false) + _ = with + + vscode = package_manager_executable! + + Bundle.exchange_uid_if_needed! do + Bundle.system(vscode, "--install-extension", name, verbose:) + end + end + + sig { + override.params( + name: String, + with: T.nilable(T::Array[String]), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + **_options) + _ = with + _ = no_upgrade + _ = force + + return true unless preinstall + + puts "Installing #{name} VSCode extension. It is not currently installed." if verbose + return false unless install_package!(name, verbose:) + + package = T.cast(package_record(name), String) + installed_extensions << package unless installed_extensions.include?(package) + if @extensions + @extensions << package unless @extensions.include?(package) + else + @extensions = [package] + end + + true + end + + sig { params(entries: T::Array[Object]).returns(T::Array[String]) } + def cleanup_items(entries) + kept_extensions = entries.filter_map do |entry| + entry = T.cast(entry, Dsl::Entry) + entry.name.downcase if entry.type == type + end + + return [].freeze if kept_extensions.empty? + + packages - kept_extensions + end + + sig { params(extensions: T::Array[String]).void } + def cleanup!(extensions) + vscode = package_manager_executable + return if vscode.nil? + + Bundle.exchange_uid_if_needed! do + extensions.each do |extension| + Kernel.system(vscode.to_s, "--uninstall-extension", extension) + end + end + end + end + end + end +end diff --git a/Library/Homebrew/bundle/extensions/winget.rb b/Library/Homebrew/bundle/extensions/winget.rb new file mode 100644 index 0000000000000..0868c834fbea5 --- /dev/null +++ b/Library/Homebrew/bundle/extensions/winget.rb @@ -0,0 +1,536 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" +require "json" +require "tempfile" +require "utils/popen" + +module Homebrew + module Bundle + # Support dumping and installing Windows packages through WinGet from WSL. + class Winget < Extension + # Parsed WinGet package details. + class App < T::Struct + const :id, String + const :name, String + const :source, String + end + + DEFAULT_SOURCE = "winget" + SOURCES = T.let([DEFAULT_SOURCE, "msstore"].freeze, T::Array[String]) + ELEVATED_INSTALL_FAILURE_PATTERNS = T.let([ + /Installer failed with exit code:\s*1603/i, + /\b(?:admin|administrator|elevat|UAC)\b/i, + ].freeze, T::Array[Regexp]) + INSTALLER_UI_FAILURE_PATTERNS = T.let([ + /\b(?:interactive|user input|user cancelled)\b/i, + ].freeze, T::Array[Regexp]) + INTERNAL_PACKAGE_PATTERNS = T.let([ + /\AApp Installer\z/i, + /\A9NBLGGH4NNS1\z/i, + /\AMicrosoft Store\z/i, + /\AStore Experience Host\z/i, + /\AWindows (?:Feature|Web) Experience Pack\z/i, + /\AMicrosoft Edge WebView2 Runtime\z/i, + /\AMicrosoft Visual C\+\+/i, + /\AWindows App Runtime/i, + /\AMicrosoft\.(?:AppInstaller|DesktopAppInstaller|DirectX|DotNet|Edge|EdgeWebView2Runtime|GameInput)\b/i, + /\AMicrosoft\.(?:HEVCVideoExtension|NET\.Native|RawImageExtension)\b/i, + /\AMicrosoft\.(?:OneDrive|WSL)\z/i, + /\AMicrosoft\.(?:Services\.Store\.Engagement|StorePurchaseApp|UI\.Xaml|VCLibs|WindowsAppRuntime)\b/i, + /\AMicrosoft\.(?:WindowsStore|WebMediaExtensions|WebpImageExtension|VP9VideoExtensions)\b/i, + /\AMicrosoft\.VCRedist\./i, + /\ANvidia\.PhysX\z/i, + ].freeze, T::Array[Regexp]) + + class << self + sig { override.returns(Symbol) } + def type = :winget + + sig { override.returns(String) } + def check_label = "WinGet Package" + + sig { override.returns(String) } + def banner_name = "WinGet packages" + + sig { override.params(description: String).returns(String) } + def switch_description(description) + "#{super} Note: WSL only." + end + + sig { override.returns(T::Boolean) } + def add_supported? + false + end + + sig { override.returns(T.nilable(String)) } + def cleanup_heading + banner_name + end + + sig { override.params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) } + def entry(name, options = {}) + unknown_options = options.keys - [:id, :source] + raise "unknown options(#{unknown_options.inspect}) for winget" if unknown_options.present? + + id = options.fetch(:id, name) + raise "options[:id](#{id.inspect}) should be a String object" unless id.is_a?(String) + + source = options.fetch(:source, DEFAULT_SOURCE) + raise "options[:source](#{source.inspect}) should be a String object" unless source.is_a?(String) + unless SOURCES.include?(source) + raise "options[:source](#{source.inspect}) should be one of #{SOURCES.inspect}" + end + + Dsl::Entry.new(type, name, id:, source:) + end + + sig { override.returns(T.nilable(Pathname)) } + def package_manager_executable + return unless OS.wsl? + + which("winget.exe", ORIGINAL_PATHS) || windows_apps_executables.find(&:executable?) + end + + sig { returns(T::Array[Pathname]) } + def windows_apps_executables + [ + ENV.fetch("LOCALAPPDATA", nil)&.+("\\Microsoft\\WindowsApps\\winget.exe"), + ENV.fetch("USERPROFILE", nil)&.+("\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe"), + windows_local_appdata&.+("\\Microsoft\\WindowsApps\\winget.exe"), + ].compact.uniq.filter_map do |path| + windows_path_to_wsl_path(path) if path.exclude?("%") + end + end + + sig { returns(T.nilable(String)) } + def windows_local_appdata + cmd = which("cmd.exe", ORIGINAL_PATHS) || Pathname.new("/mnt/c/Windows/System32/cmd.exe") + return unless cmd.executable? + + `"#{cmd}" /d /c echo %LOCALAPPDATA% 2>/dev/null`.strip.presence + end + + sig { params(path: String).returns(T.nilable(Pathname)) } + def windows_path_to_wsl_path(path) + path = path.tr("\\", "/") + return Pathname.new(path) if path.start_with?("/") + + match = path.match(%r{\A([A-Za-z]):/(.+)\z}) + return if match.nil? + + drive = match[1] + relative_path = match[2] + return if drive.nil? || relative_path.nil? + + Pathname.new("/mnt/#{drive.downcase}/#{relative_path}") + end + + sig { override.void } + def reset! + @apps = T.let(nil, T.nilable(T::Array[App])) + @packages = T.let(nil, T.nilable(T::Array[App])) + @installed_app_records = T.let(nil, T.nilable(T::Array[[String, String]])) + end + + sig { returns(T::Array[App]) } + def apps + apps = @apps + return apps if apps + + @apps = if (winget = package_manager_executable) + SOURCES.flat_map do |source| + export_apps(winget, source:) + end + end + return [] if @apps.nil? + + @apps + end + + sig { params(winget: Pathname, source: String).returns(T::Array[App]) } + def export_apps(winget, source:) + names = listed_app_names(winget, source:) + exported_apps(winget, source:).map do |app| + App.new(id: app.id, name: names.fetch(app.id.downcase, app.name), source: app.source) + end + end + + sig { params(winget: Pathname, source: String).returns(T::Array[App]) } + def exported_apps(winget, source:) + Tempfile.create(["brew-bundle-winget", ".json"]) do |file| + next [] unless Kernel.system(winget.to_s, "export", "--source", source, "--output", + windows_export_path(file.path), "--accept-source-agreements", + "--disable-interactivity", out: File::NULL, err: File::NULL) + + parse_export(File.read(file.path), source:) + end + end + + sig { params(winget: Pathname, source: String).returns(T::Hash[String, String]) } + def listed_app_names(winget, source:) + output = Utils.popen_read(winget, "list", "--source", source, "--accept-source-agreements", + "--disable-interactivity", "--nowarn", err: :close) + + parse_list_names(output) + end + + sig { params(output: String).returns(T::Hash[String, String]) } + def parse_list_names(output) + lines = output.encode("UTF-8", invalid: :replace, undef: :replace) + .delete("\r") + .lines + .map(&:chomp) + header_index = lines.index { |line| line.match?(/\bName\s+Id\s+Version\b/) } + return {} if header_index.nil? + + header = lines[header_index] + return {} if header.nil? + + header_start = header.index("Name") + id_column = header.index("Id", header_start || 0) + version_column = header.index("Version", header_start || 0) + return {} if header_start.nil? || id_column.nil? || version_column.nil? + + lines.drop(header_index + 1).each_with_object({}) do |line, names| + next if line.blank? || line[header_start..].to_s.match?(/\A-+\z/) + + name = line[header_start...id_column].to_s.strip + id = line[id_column...version_column].to_s.strip + names[id.downcase] = name if name.present? && id.present? + end + end + + sig { params(path: String).returns(String) } + def windows_export_path(path) + wslpath = which("wslpath", ORIGINAL_PATHS) + return path if wslpath.nil? + + Utils.safe_popen_read(wslpath, "-w", path, err: :close).chomp.presence || path + rescue ErrorDuringExecution + path + end + + sig { params(output: String, source: String).returns(T::Array[App]) } + def parse_export(output, source:) + export = JSON.parse(output) + return [] unless export.is_a?(Hash) + + sources = export["Sources"] + return [] unless sources.is_a?(Array) + + sources.flat_map do |source_export| + next [] unless source_export.is_a?(Hash) + + packages = source_export["Packages"] + next [] unless packages.is_a?(Array) + + packages.filter_map do |package| + next unless package.is_a?(Hash) + + id = package["PackageIdentifier"] + next if !id.is_a?(String) || id.blank? + + App.new(id:, name: id, source:) + end + end + rescue JSON::ParserError + [] + end + + sig { override.returns(T::Array[App]) } + def packages + packages = @packages + return packages if packages + + @packages = apps.reject { |app| internal_package?(app) } + .sort_by { |app| [SOURCES.index(app.source) || SOURCES.length, app.name.downcase] } + end + + sig { override.returns(T::Array[App]) } + def installed_packages + apps + end + + sig { params(app: App).returns(T::Boolean) } + def internal_package?(app) + INTERNAL_PACKAGE_PATTERNS.any? do |pattern| + pattern.match?(app.id) || pattern.match?(app.name) + end + end + + sig { returns(T::Array[[String, String]]) } + def installed_app_records + installed_app_records = @installed_app_records + return installed_app_records if installed_app_records + + @installed_app_records = apps.map { |app| [app.id, app.source] } + end + + sig { override.params(package: Object).returns(String) } + def dump_name(package) + T.cast(package, App).name + end + + sig { override.params(package: Object).returns(String) } + def dump_entry(package) + app = T.cast(package, App) + line = "winget #{quote(app.name)}" + line += ", id: #{quote(app.id)}" if app.id != app.name + return line if app.source == DEFAULT_SOURCE + + "#{line}, source: #{quote(app.source)}" + end + + sig { params(app: App).returns(String) } + def cleanup_item(app) + JSON.generate("id" => app.id, "name" => app.name, "source" => app.source) + end + + sig { params(item: String).returns(String) } + def cleanup_item_name(item) + app = parse_cleanup_item(item) + return app.id if app.name == app.id && app.source == DEFAULT_SOURCE + return "#{app.id} (#{app.source})" if app.name == app.id + + return "#{app.name} (#{app.id})" if app.source == DEFAULT_SOURCE + + "#{app.name} (#{app.id}, #{app.source})" + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[String]) } + def cleanup_items(entries) + kept_apps = entries.filter_map do |entry| + next if entry.type != type + + [entry.options.fetch(:id, entry.name).to_s, entry.options.fetch(:source, DEFAULT_SOURCE).to_s] + end + return [].freeze if kept_apps.empty? + + winget = package_manager_executable + return [].freeze if winget.nil? + + cleanup_packages = SOURCES.flat_map { |source| exported_apps(winget, source:) } + .reject { |app| internal_package?(app) } + .sort_by do |app| + [SOURCES.index(app.source) || SOURCES.length, app.name.downcase] + end + packages_to_cleanup = cleanup_packages.reject do |app| + kept_apps.any? { |id, source| app.id.casecmp?(id) && app.source == source } + end + packages_to_cleanup.map { |app| cleanup_item(app) } + end + + sig { override.params(items: T::Array[String]).void } + def cleanup!(items) + winget = package_manager_executable + return if winget.nil? + + items.each do |item| + app = parse_cleanup_item(item) + Bundle.system(winget, "uninstall", "--id", app.id, "--exact", "--source", app.source, + "--accept-source-agreements", "--disable-interactivity", verbose: false) + end + puts "Uninstalled #{items.size} WinGet package#{"s" if items.size != 1}" + end + + sig { params(id: String, source: String).returns(T::Boolean) } + def app_installed?(id, source:) + installed_app_records.any? { |app_id, app_source| app_id.casecmp?(id) && app_source == source } + end + + sig { + override.params( + name: String, + id: T.nilable(String), + with: T.nilable(T::Array[String]), + no_upgrade: T::Boolean, + verbose: T::Boolean, + source: String, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, id: nil, with: nil, no_upgrade: false, verbose: false, source: DEFAULT_SOURCE, + **options) + _ = with + _ = no_upgrade + _ = options + + id ||= name + + unless package_manager_installed? + raise "Unable to install #{name} WinGet package. winget.exe is not installed." + end + + if app_installed?(id, source:) + puts "Skipping install of #{name} WinGet package. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params( + name: String, + id: T.nilable(String), + with: T.nilable(T::Array[String]), + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + source: String, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, id: nil, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false, + source: DEFAULT_SOURCE, **options) + _ = with + _ = no_upgrade + _ = force + _ = options + + return true unless preinstall + + id ||= name + winget = package_manager_executable! + args = ["install", "--id", id, "--exact", "--source", source, + "--accept-source-agreements", "--accept-package-agreements", + "--disable-interactivity"] + success, output = run_install_command(winget, args, verbose:, elevated: false) + if !success && elevation_failure?(output) + puts "WinGet install for #{name} may require Windows UAC/elevation; retrying elevated." + success, elevated_output = run_install_command(winget, args, verbose:, elevated: true) + output = elevated_output.presence || output + end + unless success + report_install_failure(name, id:, source:, output:) + return false + end + + unless apps.any? { |app| app.id.casecmp?(id) && app.source == source } + apps << App.new(id:, name:, source:) + @packages = nil + end + installed_app_records << [id, source] unless installed_app_records.any? do |app_id, app_source| + app_id.casecmp?(id) && app_source == source + end + true + end + + sig { + params( + winget: Pathname, + args: T::Array[String], + verbose: T::Boolean, + elevated: T::Boolean, + ).returns([T::Boolean, String]) + } + def run_install_command(winget, args, verbose:, elevated:) + return run_elevated_install_command(winget, args, verbose:) if elevated + + logs = T.let([], T::Array[String]) + success = T.let(false, T::Boolean) + IO.popen([winget.to_s, *args], err: [:child, :out]) do |pipe| + while (line = pipe.gets) + print line if verbose + logs << line + end + Process.wait(pipe.pid) + success = $CHILD_STATUS.success? + pipe.close + end + [success, logs.join] + end + + sig { params(winget: Pathname, args: T::Array[String], verbose: T::Boolean).returns([T::Boolean, String]) } + def run_elevated_install_command(winget, args, verbose:) + powershell = which("powershell.exe", ORIGINAL_PATHS) || + Pathname.new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") + return [false, "powershell.exe is not available.\n"] unless powershell.executable? + + winget_path = winget.to_s.include?("/") ? windows_export_path(winget.to_s) : winget.to_s + argument_list = args.map { |arg| powershell_quote(arg) }.join(", ") + script = <<~POWERSHELL + $startProcessArgs = @{ + FilePath = #{powershell_quote(winget_path)} + ArgumentList = @(#{argument_list}) + Verb = 'RunAs' + Wait = $true + PassThru = $true + } + $process = Start-Process @startProcessArgs + $process.WaitForExit() + exit $process.ExitCode + POWERSHELL + + [Bundle.system(powershell, "-NoProfile", "-Command", script, verbose:), ""] + end + + sig { params(value: String).returns(String) } + def powershell_quote(value) + "'#{value.gsub("'", "''")}'" + end + + sig { params(output: String).returns(T::Boolean) } + def elevation_failure?(output) + ELEVATED_INSTALL_FAILURE_PATTERNS.any? { |pattern| output.match?(pattern) } + end + + sig { params(output: String).returns(T::Boolean) } + def installer_ui_failure?(output) + INSTALLER_UI_FAILURE_PATTERNS.any? { |pattern| output.match?(pattern) } + end + + sig { params(name: String, id: String, source: String, output: String).void } + def report_install_failure(name, id:, source:, output:) + puts "WinGet failed to install #{name} (#{id}) from #{source}." + if elevation_failure?(output) + puts "The installer may require Windows UAC/elevation." + puts "Try installing it from an elevated Windows Terminal:" + puts " winget install --id #{id} --exact --source #{source} --disable-interactivity" + elsif installer_ui_failure?(output) + puts "The installer appears to require installer UI or user input, which brew bundle does not automate." + puts "Install it manually from Windows:" + puts " winget install --id #{id} --exact --source #{source}" + else + puts "Try installing it manually from Windows:" + puts " winget install --id #{id} --exact --source #{source}" + end + end + + sig { params(item: String).returns(App) } + def parse_cleanup_item(item) + parsed = JSON.parse(item) + raise TypeError, "Invalid WinGet cleanup item: #{item}" unless parsed.is_a?(Hash) + + id = parsed["id"] + name = parsed["name"] + source = parsed["source"] + if !id.is_a?(String) || !name.is_a?(String) || !source.is_a?(String) + raise TypeError, "Invalid WinGet cleanup item: #{item}" + end + + App.new(id:, name:, source:) + end + end + + sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries).map do |entry| + App.new(id: T.cast(entry.options.fetch(:id), String), name: entry.name, + source: T.cast(entry.options.fetch(:source), String)) + end + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + _ = no_upgrade + + app = T.cast(package, App) + self.class.app_installed?(app.id, source: app.source) + end + end + end +end diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb new file mode 100644 index 0000000000000..d7854835e4805 --- /dev/null +++ b/Library/Homebrew/bundle/installer.rb @@ -0,0 +1,220 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" +require "bundle/package_types" +require "bundle/skipper" +require "bundle/trust" +require "trust" +require "utils/output" + +module Homebrew + module Bundle + module Installer + extend ::Utils::Output::Mixin + + class InstallableEntry < T::Struct + const :name, String + const :options, Homebrew::Bundle::EntryOptions + const :verb, String + const :cls, T.class_of(Homebrew::Bundle::PackageType) + + sig { returns(String) } + def full_name + T.cast(options.fetch(:full_name, name), String) + end + + sig { returns(T.nilable(String)) } + def tap_name + ::Utils.tap_from_full_name(full_name) + end + end + + sig { void } + def self.reset! + Homebrew::Bundle.reset! + Homebrew::Bundle::Cask.reset! + Homebrew::Bundle::Tap.reset! + end + + sig { + params( + entries: T::Array[Dsl::Entry], + global: T::Boolean, + file: T.nilable(String), + no_lock: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + jobs: Integer, + quiet: T::Boolean, + ).returns(T::Boolean) + } + def self.install!(entries, global: false, file: nil, no_lock: false, no_upgrade: false, verbose: false, + force: false, jobs: 1, quiet: false) + success = 0 + failure = 0 + + installable_entries = T.let([], T::Array[InstallableEntry]) + installable_brewfile_entries = T.let([], T::Array[Dsl::Entry]) + entries.each do |entry| + next if Homebrew::Bundle::Skipper.skip? entry + + name = entry.name + options = entry.options + type = entry.type + cls = Homebrew::Bundle.installable(type) + next if cls.nil? || !cls.install_supported? + + installable_brewfile_entries << entry + installable_entries << InstallableEntry.new(name:, options:, verb: cls.install_verb(name, options), cls:) + end + + # Apply `trusted: true` Brewfile options before anything fetches or + # loads the entries: the fetch phase and upgrade checks load formulae + # and casks, which triggers the tap trust check before the per-entry + # install step could grant trust. + Homebrew::Bundle::Trust.entries(installable_brewfile_entries).each do |type, name| + Homebrew::Trust.trust!(type, name) + end + + if (fetchable_names = fetchable_formulae_and_casks(installable_entries, no_upgrade:).presence) + fetchable_names_joined = fetchable_names.join(", ") + puts Formatter.success("Fetching #{fetchable_names_joined}") unless quiet + unless Bundle.brew("fetch", *fetchable_names, verbose:) + $stderr.puts Formatter.error "`brew bundle` failed! Failed to fetch #{fetchable_names_joined}" + return false + end + end + + if jobs > 1 && installable_entries.size > 1 + require "bundle/parallel_installer" + + parallel = ParallelInstaller.new( + installable_entries, jobs:, no_upgrade:, verbose:, force:, quiet: + ) + parallel_success, parallel_failure = parallel.run! + success += parallel_success + failure += parallel_failure + else + installable_entries.each do |entry| + if install_entry!(entry, no_upgrade:, verbose:, force:, quiet:) + success += 1 + else + failure += 1 + end + end + end + + unless failure.zero? + require "utils" + dependency = Utils.pluralize("dependency", failure) + $stderr.puts Formatter.error "`brew bundle` failed! #{failure} Brewfile #{dependency} failed to install" + return false + end + + unless quiet + require "utils" + dependency = Utils.pluralize("dependency", success) + puts Formatter.success "`brew bundle` complete! #{success} Brewfile #{dependency} now installed." + end + + true + end + + sig { + params( + entries: T::Array[InstallableEntry], + no_upgrade: T::Boolean, + ).returns(T::Array[String]) + } + def self.fetchable_formulae_and_casks(entries, no_upgrade:) + installed_taps = Tap.installed_taps + + entries.filter_map do |entry| + next if tap_dependencies(entry, entries:, installed_taps:).present? + + entry.cls.fetchable_name(entry.name, entry.options, no_upgrade:) + end + end + + sig { + params( + entry: InstallableEntry, + entries: T::Array[InstallableEntry], + installed_taps: T::Array[String], + ).returns(T::Array[String]) + } + def self.tap_dependencies(entry, entries:, installed_taps:) + return [] unless [Brew, Cask].include?(entry.cls) + + if (tap_name = entry.tap_name) + return installed_taps.exclude?(tap_name) ? [tap_name] : [] + end + + tap_names = entries.filter_map do |tap_entry| + tap_entry.name if tap_entry.cls == Tap && installed_taps.exclude?(tap_entry.name) + end + return [] if tap_names.empty? + return [] unless unavailable_without_tap?(entry) + + tap_names + end + + sig { params(entry: InstallableEntry).returns(T::Boolean) } + def self.unavailable_without_tap?(entry) + require "api" + + case entry.cls.name + when "Homebrew::Bundle::Brew" + !Homebrew::API.formula_name?(entry.name) && + Homebrew::API.formula_aliases.exclude?(entry.name) && + Homebrew::API.formula_renames.exclude?(entry.name) + when "Homebrew::Bundle::Cask" + !Homebrew::API.cask_token?(entry.name) && + Homebrew::API.cask_renames.exclude?(entry.name) + else + false + end + rescue => e + opoo "Treating `#{entry.name}` as dependent on Brewfile taps because Homebrew could not " \ + "check API metadata: #{e}" + true + end + private_class_method :unavailable_without_tap? + + sig { + params( + entry: InstallableEntry, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + quiet: T::Boolean, + ).returns(T::Boolean) + } + def self.install_entry!(entry, no_upgrade:, verbose:, force:, quiet:) + name = entry.name + options = entry.options + verb = entry.verb + cls = entry.cls + + preinstall = if cls.preinstall!(name, **options, no_upgrade:, verbose:) + puts Formatter.success("#{verb} #{name}") + true + else + puts "Using #{name}" unless quiet + false + end + + if cls.install!(name, **options, + preinstall:, no_upgrade:, verbose:, force:) + true + else + $stderr.puts Formatter.error("#{verb} #{name} has failed!") + false + end + end + private_class_method :install_entry! + end + end +end diff --git a/Library/Homebrew/bundle/lister.rb b/Library/Homebrew/bundle/lister.rb new file mode 100644 index 0000000000000..7df0c9b214ab9 --- /dev/null +++ b/Library/Homebrew/bundle/lister.rb @@ -0,0 +1,35 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" +require "bundle/extensions" + +module Homebrew + module Bundle + module Lister + sig { + params(entries: T::Array[Dsl::Entry], formulae: T::Boolean, casks: T::Boolean, taps: T::Boolean, + extension_types: Homebrew::Bundle::ExtensionTypes).void + } + def self.list(entries, formulae:, casks:, taps:, extension_types: {}) + entries.each do |entry| + puts entry.name if show?(entry.type, formulae:, casks:, taps:, extension_types:) + end + end + + sig { + params(type: Symbol, formulae: T::Boolean, casks: T::Boolean, taps: T::Boolean, + extension_types: Homebrew::Bundle::ExtensionTypes) + .returns(T::Boolean) + } + private_class_method def self.show?(type, formulae:, casks:, taps:, extension_types:) + return true if formulae && type == :brew + return true if casks && type == :cask + return true if taps && type == :tap + return true if extension_types.fetch(type, false) + + false + end + end + end +end diff --git a/Library/Homebrew/bundle/package_type.rb b/Library/Homebrew/bundle/package_type.rb new file mode 100644 index 0000000000000..2ab36426cb0d0 --- /dev/null +++ b/Library/Homebrew/bundle/package_type.rb @@ -0,0 +1,198 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" + +module Homebrew + module Bundle + class PackageType + extend T::Helpers + + abstract! + + sig { params(subclass: T.class_of(Homebrew::Bundle::PackageType)).void } + def self.inherited(subclass) + super + return if subclass.name == "Homebrew::Bundle::Extension" + + Homebrew::Bundle.register_package_type(subclass) + end + + sig { abstract.returns(Symbol) } + def self.type; end + + sig { abstract.returns(String) } + def self.check_label; end + + sig { returns(T::Boolean) } + def self.dump_supported? + true + end + + sig { returns(T::Boolean) } + def self.install_supported? + true + end + + sig { overridable.params(_name: String, _options: Homebrew::Bundle::EntryOptions).returns(String) } + def self.install_verb(_name = "", _options = {}) + "Installing" + end + + sig { + params( + name: String, + options: Homebrew::Bundle::EntryOptions, + no_upgrade: T::Boolean, + ).returns(T.nilable(String)) + } + def self.fetchable_name(name, options = {}, no_upgrade: false) + _ = name + _ = options + _ = no_upgrade + + nil + end + + sig { abstract.void } + def self.reset!; end + + sig { + abstract.params( + name: String, + no_upgrade: T::Boolean, + verbose: T::Boolean, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def self.preinstall!(name, no_upgrade: false, verbose: false, **options); end + + sig { + abstract.params( + name: String, + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def self.install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options); end + + sig { + params( + entries: T::Array[Dsl::Entry], + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def self.check(entries, exit_on_first_error: false, no_upgrade: false, verbose: false) + new.find_actionable(entries, exit_on_first_error:, no_upgrade:, verbose:) + end + + sig { abstract.returns(String) } + def self.dump; end + + sig { params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def self.dump_output(describe: false, no_restart: false) + _ = describe + _ = no_restart + + dump + end + + sig { params(packages: T::Array[Object], no_upgrade: T::Boolean).returns(T::Array[String]) } + def exit_early_check(packages, no_upgrade:) + packages.each do |pkg| + next if installed_and_up_to_date?(pkg, no_upgrade:) + + return [failure_reason(pkg, no_upgrade:)] + end + [] + end + + sig { overridable.params(name: Object, no_upgrade: T::Boolean).returns(String) } + def failure_reason(name, no_upgrade:) + reason = if no_upgrade && Bundle.upgrade_formulae.exclude?(name) + "needs to be installed." + else + "needs to be installed or updated." + end + "#{self.class.check_label} #{name} #{reason}" + end + + sig { params(packages: T::Array[Object], no_upgrade: T::Boolean).returns(T::Array[String]) } + def full_check(packages, no_upgrade:) + packages.reject { |pkg| installed_and_up_to_date?(pkg, no_upgrade:) } + .map { |pkg| failure_reason(pkg, no_upgrade:) } + end + + sig { params(all_entries: T::Array[Dsl::Entry]).returns(T::Array[Dsl::Entry]) } + def checkable_entries(all_entries) + require "bundle/skipper" + all_entries.filter_map do |entry| + next if entry.type != self.class.type + next if Bundle::Skipper.skip?(entry) + + entry + end + end + + sig { params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) } + def format_checkable(entries) + checkable_entries(entries).map(&:name) + end + + sig { params(_pkg: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(_pkg, no_upgrade: false) + raise NotImplementedError + end + + sig { + params( + entries: T::Array[Dsl::Entry], + exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + ).returns(T::Array[String]) + } + def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false) + requested = format_checkable(entries) + + if exit_on_first_error + exit_early_check(requested, no_upgrade:) + else + full_check(requested, no_upgrade:) + end + end + end + + class << self + sig { params(package_type: T.class_of(PackageType)).void } + def register_package_type(package_type) + @package_types ||= T.let([], T.nilable(T::Array[T.class_of(PackageType)])) + @package_types.reject! { |registered| registered.name == package_type.name } + @package_types << package_type + end + + sig { returns(T::Array[T.class_of(PackageType)]) } + def package_types + @package_types ||= T.let([], T.nilable(T::Array[T.class_of(PackageType)])) + @package_types + end + + sig { params(type: T.any(Symbol, String)).returns(T.nilable(T.class_of(PackageType))) } + def package_type(type) + requested_type = type.to_sym + package_types.find { |registered| registered.type == requested_type } + end + + sig { returns(T::Array[T.class_of(PackageType)]) } + def dump_package_types + core_package_types = [:tap, :brew, :cask].filter_map { |type| package_type(type) } + (core_package_types + (package_types - core_package_types)).uniq + end + end + end +end diff --git a/Library/Homebrew/bundle/package_types.rb b/Library/Homebrew/bundle/package_types.rb new file mode 100644 index 0000000000000..5fff31a219175 --- /dev/null +++ b/Library/Homebrew/bundle/package_types.rb @@ -0,0 +1,8 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/package_type" +require "bundle/tap" +require "bundle/brew" +require "bundle/cask" +require "bundle/extensions" diff --git a/Library/Homebrew/bundle/parallel_installer.rb b/Library/Homebrew/bundle/parallel_installer.rb new file mode 100644 index 0000000000000..9f651d897e054 --- /dev/null +++ b/Library/Homebrew/bundle/parallel_installer.rb @@ -0,0 +1,302 @@ +# typed: strict +# frozen_string_literal: true + +require "concurrent/executors" +require "concurrent/promises" +require "monitor" +require "utils" +require "bundle/package_types" + +module Homebrew + module Bundle + class ParallelInstaller + sig { + params( + entries: T::Array[Installer::InstallableEntry], + jobs: Integer, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + quiet: T::Boolean, + ).void + } + def initialize(entries, jobs:, no_upgrade:, verbose:, force:, quiet:) + @entries = entries + @jobs = jobs + @no_upgrade = no_upgrade + @verbose = verbose + @force = force + @quiet = quiet + @pool = T.let(Concurrent::FixedThreadPool.new(jobs), Concurrent::FixedThreadPool) + @output_mutex = T.let(Monitor.new, Monitor) + # Cask installs may trigger interactive sudo prompts that write + # directly to the terminal. Serialize them so Password: prompts + # don't interleave with status output from other workers. + @cask_install_mutex = T.let(Mutex.new, Mutex) + end + + sig { returns([Integer, Integer]) } + def run! + success = 0 + failure = 0 + + tap_entries, pending_entries = @entries.partition { |entry| entry.cls == Homebrew::Bundle::Tap } + tap_entries.each_slice(@jobs) do |batch| + tap_success, tap_failure = install_entries_parallel!(batch) + success += tap_success + failure += tap_failure + end + ::Tap.clear_cache if tap_entries.present? + + require "tap" + installed_taps = Homebrew::Bundle::Tap.installed_taps + pending_entries.each do |entry| + tap_with_name = if entry.cls == Homebrew::Bundle::Brew + ::Tap.with_formula_name(entry.full_name) + elsif entry.cls == Homebrew::Bundle::Cask + ::Tap.with_cask_token(entry.full_name) + end + next unless tap_with_name + + tap = tap_with_name.first + next if installed_taps.include?(tap.name) || tap_entries.any? { |tap_entry| tap_entry.name == tap.name } + + tap.ensure_installed! + installed_taps << tap.name + end + + prepare_attestation_verification!(pending_entries) + dependency_map = build_dependency_map(pending_entries) + completed = T.let(Set.new, T::Set[String]) + until pending_entries.empty? + ready_entries = pending_entries.select do |entry| + dependency_map.fetch(entry.name, Set.new).all? { |dependency| completed.include?(dependency) } + end + + if ready_entries.empty? + pending_entries.each do |entry| + installed = install_entry!(entry) + completed << entry.name + if installed + success += 1 + else + failure += 1 + end + end + break + end + + batch = ready_entries.take(@jobs) + batch_success, batch_failure = install_entries_parallel!(batch) + success += batch_success + failure += batch_failure + + pending_entries -= batch + completed.merge(batch.map(&:name)) + end + + [success, failure] + ensure + @pool.shutdown + @pool.wait_for_termination + end + + private + + sig { params(entries: T::Array[Installer::InstallableEntry]).returns(T::Hash[String, T::Set[String]]) } + def build_dependency_map(entries) + installed_taps = Homebrew::Bundle::Tap.installed_taps + attestation_formula = if Homebrew::EnvConfig.verify_attestations? + entries.find { |entry| entry.cls == Homebrew::Bundle::Brew && entry.name == "gh" } + end + + # Phase 1: Map both full and short names so dep lookups work either way. + entry_name_map = entries.each_with_object({}) do |entry, map| + map[entry.name] = entry.name + map[normalize_formula_name(entry.name)] = entry.name + end + + # Phase 2: Direct dependencies declared in the Brewfile. Determines + # install ordering (entry A must finish before entry B starts). + brewfile_deps = T.let({}, T::Hash[String, T::Array[String]]) + entries.each do |entry| + deps = case entry.cls.name + when "Homebrew::Bundle::Brew" + Homebrew::Bundle::Brew.formula_dep_names(entry.name) + when "Homebrew::Bundle::Cask" + Homebrew::Bundle::Cask.formula_dependencies([entry.full_name]) + else + [] + end + + # Entries from non-default taps depend on the tap being installed first. + deps += Homebrew::Bundle::Installer.tap_dependencies(entry, entries:, installed_taps:) + if attestation_formula && [Homebrew::Bundle::Brew, Homebrew::Bundle::Cask].include?(entry.cls) && + entry.name != attestation_formula.name + deps << attestation_formula.name + end + + brewfile_deps[entry.name] = deps + end + + # Phase 3: Recursive dependency sets for lock conflict detection. + # `FormulaInstaller#lock` locks all recursive dependencies before + # installing, even when pouring bottles. + cask_names = T.let(entries.select { |e| e.cls == Homebrew::Bundle::Cask }.to_set(&:name), T::Set[String]) + recursive_deps = T.let({}, T::Hash[String, T::Set[String]]) + entries.each do |entry| + recursive_deps[entry.name] = case entry.cls.name + when "Homebrew::Bundle::Brew" + Homebrew::Bundle::Brew.recursive_dep_names(entry.name) + when "Homebrew::Bundle::Cask" + cask_dep_names(entry.name, cask_names) + else + Set.new + end + end + + # Phase 4: Merge explicit ordering and implicit lock conflicts. + entries.each_with_object({}) do |entry, map| + depends_on = brewfile_deps.fetch(entry.name).each_with_object(Set.new) do |dep, set| + name = entry_name_map[dep] || entry_name_map[normalize_formula_name(dep)] + set << name if name.present? && name != entry.name + end + + # Later entries wait for earlier ones when they share any recursive dep. + entry_rdeps = recursive_deps.fetch(entry.name) + entries.each do |earlier| + break if earlier.name == entry.name + next if depends_on.include?(earlier.name) + + earlier_rdeps = recursive_deps.fetch(earlier.name) + depends_on << earlier.name if entry_rdeps.intersect?(earlier_rdeps) + end + + map[entry.name] = depends_on + end + end + + sig { params(name: String).returns(String) } + def normalize_formula_name(name) + Utils.name_from_full_name(name) + end + + sig { params(entries: T::Array[Installer::InstallableEntry]).void } + def prepare_attestation_verification!(entries) + return unless Homebrew::EnvConfig.verify_attestations? + return unless entries.any? { |entry| [Homebrew::Bundle::Brew, Homebrew::Bundle::Cask].include?(entry.cls) } + return if entries.any? { |entry| entry.cls == Homebrew::Bundle::Brew && entry.name == "gh" } + + require "attestation" + + Homebrew::Attestation.gh_executable + end + + # Walk cask-on-cask dependencies transitively, returning the set of + # cask names (from the Brewfile) that this cask depends on. + sig { params(name: String, cask_names: T::Set[String]).returns(T::Set[String]) } + def cask_dep_names(name, cask_names) + return Set.new unless Bundle.cask_installed? + + require "cask/cask_loader" + cask = ::Cask::CaskLoader.load(name) + direct = Array(cask.depends_on[:cask]).to_set + # Only include deps that are also in the Brewfile. + direct & cask_names + rescue ::Cask::CaskUnavailableError + Set.new + end + + sig { params(entries: T::Array[Installer::InstallableEntry]).returns([Integer, Integer]) } + def install_entries_parallel!(entries) + futures = entries.to_h do |entry| + [entry, Concurrent::Promises.future_on(@pool, entry) do |install_entry| + install_entry!(install_entry) + end] + end + + success = 0 + failure = 0 + entries.each do |entry| + installed = begin + futures.fetch(entry).value! == true + rescue => e + write_output(Formatter.error("Installing #{entry.name} has failed!"), stream: $stderr) + write_output("[#{entry.name}] #{e.message}", stream: $stderr) if @verbose + false + end + + if installed + success += 1 + else + failure += 1 + end + end + + [success, failure] + end + + sig { params(entry: Installer::InstallableEntry).returns(T::Boolean) } + def install_entry!(entry) + # Cask installs can trigger sudo password prompts that write directly + # to /dev/tty. Hold the output lock for the entire install so that + # status messages from parallel formula workers don't interleave with + # the Password: prompt. Monitor is reentrant, so write_output calls + # inside do_install_entry! can re-acquire the lock on the same thread. + if entry.cls == Homebrew::Bundle::Cask + @cask_install_mutex.synchronize do + result = @output_mutex.synchronize { do_install_entry!(entry) } + # Interactive prompts (sudo, macOS security frameworks) can leave + # the terminal cursor mid-line on /dev/tty with no trailing + # newline. Clear any trailing prompt text with \r + CSI-K so the + # next worker's status message overwrites it rather than appending + # to produce "Password:Using foo". Writes nothing visible when + # the line is already clean, so formula and cask output stay + # visually uniform. + clear_tty_line + result + end + else + do_install_entry!(entry) + end + end + + sig { params(entry: Installer::InstallableEntry).returns(T::Boolean) } + def do_install_entry!(entry) + name = entry.name + options = entry.options + verb = entry.verb + cls = entry.cls + + preinstall = if cls.preinstall!(name, **options, no_upgrade: @no_upgrade, verbose: @verbose) + write_output(Formatter.success("#{verb} #{name}")) + true + else + write_output("Using #{name}") unless @quiet + false + end + + if cls.install!(name, **options, + preinstall:, no_upgrade: @no_upgrade, verbose: @verbose, force: @force) + true + else + write_output(Formatter.error("#{verb} #{name} has failed!"), stream: $stderr) + false + end + end + + sig { params(message: String, stream: IO).void } + def write_output(message, stream: $stdout) + @output_mutex.synchronize { stream.puts(message) } + end + + sig { void } + def clear_tty_line + File.open("/dev/tty", "w") { |f| f.print("\r\e[K") } + rescue Errno::ENXIO, Errno::ENOENT, Errno::EACCES, Errno::EPERM + # No TTY available (CI, piped output) - nothing to clean up. + nil + end + end + end +end diff --git a/Library/Homebrew/bundle/remover.rb b/Library/Homebrew/bundle/remover.rb new file mode 100644 index 0000000000000..3fb8213babda7 --- /dev/null +++ b/Library/Homebrew/bundle/remover.rb @@ -0,0 +1,90 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/output" + +module Homebrew + module Bundle + module Remover + extend ::Utils::Output::Mixin + + sig { params(args: String, type: Symbol, global: T::Boolean, file: T.nilable(String)).void } + def self.remove(*args, type:, global:, file:) + require "bundle/brewfile" + require "bundle/dumper" + + brewfile = Brewfile.read(global:, file:) + content = brewfile.input + entry_type = type.to_s if type != :none + escaped_args = args.flat_map do |arg| + names = if type == :brew + possible_names(arg) + else + [arg] + end + + names.uniq.map { |a| Regexp.escape(a) } + end + + entry_regex = /#{entry_type}(\s+|\(\s*)"(#{escaped_args.join("|")})"/ + new_lines = T.let([], T::Array[String]) + + content.split("\n").compact.each do |line| + if line.match?(entry_regex) + name = line[entry_regex, 2] + remove_package_description_comment(new_lines, T.must(name)) + else + new_lines << line + end + end + + new_content = "#{new_lines.join("\n")}\n" + + if content.chomp == new_content.chomp && + type == :none && + args.any? { |arg| possible_names(arg, raise_error: false).count > 1 } + opoo "No matching entries found in Brewfile. Try again with `--formula` to match formula " \ + "aliases and old formula names." + return + end + + path = Dumper.brewfile_path(global:, file:) + Dumper.write_file path, new_content + end + + sig { params(formula_name: String, raise_error: T::Boolean).returns(T::Array[String]) } + def self.possible_names(formula_name, raise_error: true) + formula = find_formula_or_cask(formula_name, raise_error:) + return [] if formula.nil? || !formula.is_a?(Formula) + + [formula_name, formula.name, formula.full_name, *formula.aliases, *formula.oldnames].compact.uniq + end + + sig { params(lines: T::Array[String], package_name: String).void } + def self.remove_package_description_comment(lines, package_name) + comment = lines.last&.match(/^\s*#\s+(?.+)$/)&.[](:desc) + return unless comment + return if find_formula_or_cask(package_name)&.desc != comment + + lines.pop + end + + sig { params(name: String, raise_error: T::Boolean).returns(T.nilable(T.any(Formula, ::Cask::Cask))) } + def self.find_formula_or_cask(name, raise_error: false) + formula = begin + Formulary.factory(name) + rescue FormulaUnavailableError + raise if raise_error + end + + return formula if formula.present? + + begin + ::Cask::CaskLoader.load(name) + rescue ::Cask::CaskUnavailableError + raise if raise_error + end + end + end + end +end diff --git a/Library/Homebrew/bundle/skipper.rb b/Library/Homebrew/bundle/skipper.rb new file mode 100644 index 0000000000000..a6fa8bfc37f2b --- /dev/null +++ b/Library/Homebrew/bundle/skipper.rb @@ -0,0 +1,55 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module Bundle + module Skipper + class << self + sig { params(entry: Dsl::Entry, silent: T::Boolean).returns(T::Boolean) } + def skip?(entry, silent: false) + require "bundle/brew" + + full_name = entry.options[:full_name] + return true if @failed_taps&.any? do |tap| + prefix = "#{tap}/" + entry.name.start_with?(prefix) || (full_name.is_a?(String) && full_name.start_with?(prefix)) + end + + entry_type_skips = Array(skipped_entries[entry.type]) + return false if entry_type_skips.empty? + + # Check the name or ID particularly for Mac App Store entries where they + # can have spaces in the names (and the `mas` output format changes on + # occasion). + entry_ids = [entry.name, entry.options[:id]&.to_s].compact + return false unless entry_type_skips.intersect?(entry_ids) + + puts Formatter.warning "Skipping #{entry.name}" unless silent + true + end + + sig { params(tap_name: String).void } + def tap_failed!(tap_name) + @failed_taps ||= T.let([], T.nilable(T::Array[String])) + @failed_taps << tap_name + end + + private + + sig { returns(T::Hash[Symbol, T.nilable(T::Array[String])]) } + def skipped_entries + return @skipped_entries if @skipped_entries + + @skipped_entries ||= T.let({}, T.nilable(T::Hash[Symbol, T.nilable(T::Array[String])])) + [:brew, :cask, :mas, :tap, :flatpak, :winget].each do |type| + @skipped_entries[type] = + ENV["HOMEBREW_BUNDLE_#{type.to_s.upcase}_SKIP"]&.split + end + @skipped_entries + end + end + end + end +end + +require "extend/os/bundle/skipper" diff --git a/Library/Homebrew/bundle/subcommand.rb b/Library/Homebrew/bundle/subcommand.rb new file mode 100644 index 0000000000000..23cdb7a20bb90 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand.rb @@ -0,0 +1,103 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" +require "cli/parser" +require "env_config" +require "etc" +require "bundle/subcommand_context" +require "utils/output" + +Dir["#{__dir__}/subcommand/*.rb"].each do |subcommand| + require "bundle/subcommand/#{File.basename(subcommand, ".rb")}" +end + +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + extend Utils::Output::Mixin + + class << self + sig { + params( + args: T.untyped, + extensions: T::Array[T.class_of(Homebrew::Bundle::Extension)], + ).void + } + def dispatch(args, extensions:) + ask = Homebrew::EnvConfig.ask? + + # Don't want to ask for input in Bundle + ENV["HOMEBREW_ASK"] = nil + ENV["HOMEBREW_NO_ASK"] = "1" + + Homebrew::EnvConfig.bundle_dump_describe? if !args.describe? && !args.no_describe? + + context = context(args, extensions:, ask:) + Homebrew::Bundle.upgrade_formulae = args.upgrade_formulae + + if args.install? + redirect_stdout($stderr) do + InstallSubcommand.new(args, context:, quiet: true, cleanup: false).run + end + end + + subcommand_class = Homebrew::AbstractSubcommand.subcommands_for(Homebrew::Cmd::Bundle).find do |candidate| + candidate.subcommand_name == context.subcommand + end + raise UsageError, "Unknown subcommand: #{context.subcommand}" unless subcommand_class + + subcommand_class.new(args, context:, quiet: args.quiet?).run + end + + sig { + params( + args: T.untyped, + extensions: T::Array[T.class_of(Homebrew::Bundle::Extension)], + ask: T::Boolean, + ).returns(SubcommandContext) + } + def context(args, extensions:, ask: false) + subcommand = T.let(args.subcommand || "install", String) + jobs_arg = args.jobs || Homebrew::EnvConfig.bundle_jobs + jobs = if jobs_arg == "auto" + [Etc.nprocessors, 4].min + else + jobs_arg&.to_i || 1 + end + no_upgrade = if args.upgrade? + false + else + args.no_upgrade?.present? + end + + SubcommandContext.new( + subcommand:, + global: args.global?, + file: args.file, + no_upgrade:, + verbose: args.verbose?, + force: args.force?, + ask:, + jobs: [jobs, 1].max, + zap: args.zap?, + no_type_args: no_type_args?(args, extensions:), + extensions:, + ) + end + + sig { + params( + args: T.untyped, + extensions: T::Array[T.class_of(Homebrew::Bundle::Extension)], + ).returns(T::Boolean) + } + def no_type_args?(args, extensions:) + ([args.formulae?, args.casks?, args.taps?] + + extensions.map { |extension| args.public_send(extension.predicate_method) }).none? + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/add.rb b/Library/Homebrew/bundle/subcommand/add.rb new file mode 100644 index 0000000000000..eb0be3f6534cc --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/add.rb @@ -0,0 +1,73 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" + +require "bundle/adder" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class AddSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + extensions = Homebrew::Bundle.extensions + usage_banner <<~EOS + `brew bundle add` [...]: + Add entries to your `Brewfile`. Adds formulae by default. Use #{["`--cask`", "`--tap`", *extensions.select(&:add_supported?).map { |extension| "`--#{extension.flag}`" }].to_sentence} to add the corresponding entry instead. + EOS + named_args min: 1 + switch "--install", + description: "Run `install` before adding entries." + switch "--formula", "--formulae", "--brews", + description: "Add Homebrew formula entries." + switch "--cask", "--casks", + description: "Add Homebrew cask entries." + switch "--tap", "--taps", + description: "Add Homebrew tap entries." + extensions.select(&:add_supported?).each do |extension| + switch "--#{extension.flag}", + description: extension.switch_description("Add entries for #{extension.banner_name}.") + end + switch "--no-describe", + description: "Do not add description comments above each line. Description comments are " \ + "the default.", + env: :bundle_no_describe + switch "--describe", + description: "Add a description comment above each line, unless the " \ + "dependency does not have a description. This is the default unless " \ + "`$HOMEBREW_BUNDLE_NO_DESCRIBE` is set.", + env: :bundle_describe, + replacement: "the default behaviour", + odeprecated: true + conflicts "--describe", "--no-describe" + end + + sig { override.void } + def run + selected_types = context.selected_types(args) + raise UsageError, "`add` supports only one type of entry at a time." if selected_types.count != 1 + + type = case (t = selected_types.first) + when :none then :brew + when :mas then raise UsageError, "`add` does not support `--mas`." + else t + end + + extension = Homebrew::Bundle.extension(type) + if extension && !extension.add_supported? + raise UsageError, + "`add` does not support `--#{extension.flag}`." + end + + Homebrew::Bundle::Adder.add( + *args.named, + type:, + global: context.global, + file: context.file, + describe: args.describe? && !args.no_describe?, + ) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/check.rb b/Library/Homebrew/bundle/subcommand/check.rb new file mode 100644 index 0000000000000..72fb3232ba40f --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/check.rb @@ -0,0 +1,69 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +require "bundle/checker" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class CheckSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle check`: + Check if all dependencies present in the `Brewfile` are installed. + + This provides a successful exit code if everything is up-to-date, making it useful for scripting. Use `--verbose` to list unmet dependencies. + EOS + named_args :none + switch "-v", "--verbose", + description: "List all missing dependencies." + switch "--no-upgrade", + description: "Do not check for outdated dependencies. " \ + "Note they may still be upgraded by `brew install` if needed.", + env: :bundle_no_upgrade + switch "--install", + description: "Run `install` before checking dependencies." + end + + sig { override.void } + def run + output_errors = context.verbose + exit_on_first_error = !context.verbose + check_result = Homebrew::Bundle::Checker.check( + global: context.global, file: context.file, + exit_on_first_error:, no_upgrade: context.no_upgrade, verbose: context.verbose + ) + + # Allow callers of `brew bundle check` to specify when they've already + # output some formulae errors. + check_missing_formulae = ENV.fetch("HOMEBREW_BUNDLE_CHECK_ALREADY_OUTPUT_FORMULAE_ERRORS", "") + .strip + .split + + if check_result.work_to_be_done + puts "brew bundle can't satisfy your Brewfile's dependencies." if check_missing_formulae.blank? + + if output_errors + check_result.errors.each do |error| + if (match = error.match(/^Formula (.+) needs to be installed/)) && + check_missing_formulae.include?(match[1]) + next + end + + puts "→ #{error}" + end + else + puts "Run `brew bundle check --verbose` to list unmet dependencies." + end + + puts "Satisfy missing dependencies with `brew bundle install`." + exit 1 + end + + puts "The Brewfile's dependencies are satisfied." unless quiet + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/cleanup.rb b/Library/Homebrew/bundle/subcommand/cleanup.rb new file mode 100644 index 0000000000000..d06c032e37cc5 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/cleanup.rb @@ -0,0 +1,380 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" +require "cleanup" + +require "utils/formatter" +require "utils" +require "bundle/dsl" +require "bundle/extensions" +require "bundle/trust" +require "trust" +require "ask" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class CleanupSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle cleanup`: + Uninstall all dependencies not present in the `Brewfile`. + + This workflow is useful for maintainers or testers who regularly install lots of formulae. + + Unless `--force` is passed, this returns a 1 exit code if anything would be removed. + EOS + named_args :none + switch "--install", + description: "Run `install` before cleaning up dependencies." + switch "-f", "--force", + description: "Actually perform cleanup operations." + switch "--all", + description: "Clean up all supported dependencies." + switch "--formula", "--formulae", "--brews", + description: "Clean up Homebrew formula dependencies." + switch "--no-formula", "--no-formulae", "--no-brews", + description: "Clean up without Homebrew formula dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_CLEANUP_NO_BREW` is set." + switch "--no-cleanup-brew", + description: "Clean up without Homebrew formula dependencies.", + env: :bundle_cleanup_no_brew + switch "--cask", "--casks", + description: "Clean up Homebrew cask dependencies." + switch "--no-cask", "--no-casks", + description: "Clean up without Homebrew cask dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_CLEANUP_NO_CASK` is set." + switch "--no-cleanup-cask", + description: "Clean up without Homebrew cask dependencies.", + env: :bundle_cleanup_no_cask + switch "--tap", "--taps", + description: "Clean up Homebrew tap dependencies." + switch "--no-tap", "--no-taps", + description: "Clean up without Homebrew tap dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_CLEANUP_NO_TAP` is set." + switch "--no-cleanup-tap", + description: "Clean up without Homebrew tap dependencies.", + env: :bundle_cleanup_no_tap + Homebrew::Bundle.extensions.select(&:cleanup_supported?).each do |extension| + env = "HOMEBREW_#{extension.cleanup_disable_env.to_s.upcase}" + switch "--#{extension.flag}", + description: extension.switch_description("Clean up #{extension.banner_name}.") + switch "--no-#{extension.flag}", + description: "#{extension.cleanup_disable_description} " \ + "Enabled by default if `$#{env}` is set." + switch "--no-cleanup-#{extension.flag}", + description: extension.cleanup_disable_description, + env: extension.cleanup_disable_env + end + switch "--zap", + description: "Clean up casks using the `zap` command instead of `uninstall`." + end + + sig { override.void } + def run + core_type_options = context.core_type_options(args, "cleanup", all: args.all?) + self.class.cleanup( + global: context.global, + file: context.file, + force: context.force, + zap: context.zap, + ask: context.ask || !context.force, + formulae: core_type_options.fetch(:formulae), + casks: core_type_options.fetch(:casks), + taps: core_type_options.fetch(:taps), + extension_types: context.extensions.select(&:cleanup_supported?).to_h do |extension| + [ + extension.type, + !context.extension_disabled?(args, extension) && + (context.extension_selected?(args, extension) || args.all? || context.no_type_args), + ] + end, + ) + end + + sig { void } + def self.reset! + require "bundle/cask" + require "bundle/brew" + require "bundle/tap" + require "bundle/brew_services" + + @dsl = T.let(nil, T.nilable(Homebrew::Bundle::Dsl)) + @kept_casks = nil + @kept_formulae = nil + Homebrew::Bundle::Cask.reset! + Homebrew::Bundle::Brew.reset! + Homebrew::Bundle::Tap.reset! + Homebrew::Bundle::Brew::Services.reset! + Homebrew::Bundle.extensions.each(&:reset!) + end + + sig { + params(global: T::Boolean, file: T.nilable(String), force: T::Boolean, zap: T::Boolean, + dsl: T.nilable(Homebrew::Bundle::Dsl), formulae: T::Boolean, casks: T::Boolean, taps: T::Boolean, + ask: T::Boolean, extension_types: Homebrew::Bundle::ExtensionTypes).void + } + def self.cleanup(global: false, file: nil, force: false, zap: false, dsl: nil, + formulae: true, casks: true, taps: true, ask: false, extension_types: {}) + read_dsl_from_brewfile!(global:, file:, dsl:) + + cleanup_formulae = formulae + cleanup_casks = casks + cleanup_taps = taps + extension_types = Homebrew::Bundle.extensions.select(&:cleanup_supported?).to_h do |extension| + [extension.type, true] + end.merge(extension_types) + casks = if casks + casks_to_uninstall(global:, file:) + else + [] + end + formulae = if formulae + formulae_to_uninstall(global:, file:) + else + [] + end + taps = if taps + taps_to_untap(global:, file:) + else + [] + end + cleanup_extensions = Homebrew::Bundle.extensions.select(&:cleanup_supported?).filter_map do |extension| + next unless extension_types.fetch(extension.type, false) + raise ArgumentError, "dsl is unset!" unless @dsl + + [extension, extension.cleanup_items(@dsl.entries)] + end + if force + dsl = @dsl + raise ArgumentError, "dsl is unset!" unless dsl + + Homebrew::Trust.replace!(Homebrew::Bundle::Trust.entries(dsl.entries)) + + if casks.any? + args = if zap + ["--zap"] + else + [] + end + Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--cask", *args, "--force", *casks + puts "Uninstalled #{casks.size} cask#{"s" if casks.size != 1}" + end + + if formulae.any? + # Mark Brewfile formulae as installed_on_request to prevent autoremove + # from removing them when their dependents are uninstalled + Homebrew::Bundle.mark_as_installed_on_request!(dsl.entries) + + Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", *formulae + puts "Uninstalled #{formulae.size} formula#{"e" if formulae.size != 1}" + end + + Kernel.system HOMEBREW_BREW_FILE, "untap", *taps if taps.any? + + cleanup_extensions.each do |extension, items| + next if items.empty? + + extension.cleanup!(items) + end + + cleanup = system_output_no_stderr(HOMEBREW_BREW_FILE, "cleanup") + puts cleanup unless cleanup.empty? + else + would_uninstall = false + + if casks.any? + puts "Would uninstall casks:" + puts Formatter.columns casks + would_uninstall = true + end + + if formulae.any? + puts "Would uninstall formulae:" + puts Formatter.columns formulae + would_uninstall = true + end + + if taps.any? + puts "Would untap:" + puts Formatter.columns taps + would_uninstall = true + end + + cleanup_extensions.each do |extension, items| + next if items.empty? + + puts "Would uninstall #{extension.cleanup_heading}:" + puts Formatter.columns items.map { |item| extension.cleanup_item_name(item) } + would_uninstall = true + end + + would_cleanup = Cleanup.printed_dry_run_output?(Cleanup.dry_run_output) + + puts "Run `brew bundle cleanup --force` to make these changes." if would_uninstall || would_cleanup + if ask && (would_uninstall || would_cleanup) && Homebrew::Ask.confirm?(action: "cleanup") + cleanup(global:, file:, force: true, zap:, dsl: @dsl, formulae: cleanup_formulae, casks: cleanup_casks, + taps: cleanup_taps, extension_types:) + return + end + exit 1 if would_uninstall + end + end + + sig { params(global: T::Boolean, file: T.nilable(String), dsl: T.nilable(Homebrew::Bundle::Dsl)).void } + def self.read_dsl_from_brewfile!(global: false, file: nil, dsl: nil) + @dsl = T.let( + if dsl + dsl + else + require "bundle/brewfile" + Homebrew::Bundle::Brewfile.read(global:, file:) + end, + T.nilable(Homebrew::Bundle::Dsl), + ) + end + + sig { returns(T.nilable(Homebrew::Bundle::Dsl)) } + def self.dsl + T.let(@dsl, T.nilable(Homebrew::Bundle::Dsl)) + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(T::Array[String]) } + def self.casks_to_uninstall(global: false, file: nil) + raise ArgumentError, "@dsl is unset!" unless @dsl + + require "bundle/cask" + Homebrew::Bundle::Cask.cask_names - kept_casks(global:, file:) + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(T::Array[String]) } + def self.formulae_to_uninstall(global: false, file: nil) + raise ArgumentError, "@dsl is unset!" unless @dsl + + kept_formulae = self.kept_formulae(global:, file:) + + require "bundle/brew" + current_formulae = Homebrew::Bundle::Brew.formulae + current_formulae.reject! do |f| + Homebrew::Bundle::Brew.formula_in_array?(f[:full_name], kept_formulae) + end + + # Don't try to uninstall formulae with keepme references + current_formulae.reject! do |f| + Formula[f[:full_name]].installed_kegs.any? do |keg| + keg.keepme_refs.present? + end + end + current_formulae.map { |f| f[:full_name] } + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(T::Array[String]) } + private_class_method def self.kept_formulae(global: false, file: nil) + require "bundle/brew" + require "bundle/cask" + + @kept_formulae ||= T.let( + begin + raise ArgumentError, "dsl is unset!" unless @dsl + + kept_formulae = @dsl.entries.select { |e| e.type == :brew }.map(&:name) + kept_formulae += Homebrew::Bundle::Cask.formula_dependencies(kept_casks) + kept_formulae.map! do |f| + Homebrew::Bundle::Brew.formula_aliases.fetch( + f, + Homebrew::Bundle::Brew.formula_oldnames.fetch(f, f), + ) + end + + kept_formulae + recursive_dependencies(Homebrew::Bundle::Brew.formulae, kept_formulae) + end, + T.nilable(T::Array[String]), + ) + end + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(T::Array[String]) } + private_class_method def self.kept_casks(global: false, file: nil) + return @kept_casks if @kept_casks + raise ArgumentError, "dsl is unset!" unless @dsl + + kept_casks = @dsl.entries.select { |e| e.type == :cask }.flat_map(&:name) + kept_casks.map! do |c| + Homebrew::Bundle::Cask.cask_oldnames.fetch(c, c) + end + @kept_casks = T.let(kept_casks, T.nilable(T::Array[String])) + raise "kept_casks is nil" unless @kept_casks + + @kept_casks + end + + sig { + params(current_formulae: T::Array[T::Hash[Symbol, T.untyped]], formulae_names: T::Array[String], + top_level: T::Boolean).returns(T::Array[String]) + } + private_class_method def self.recursive_dependencies(current_formulae, formulae_names, top_level: true) + @checked_formulae_names = T.let([], T.nilable(T::Array[String])) if top_level + dependencies = T.let([], T::Array[String]) + + formulae_names.each do |name| + raise "checked_formulae_names is unset!" unless @checked_formulae_names + next if @checked_formulae_names.include?(name) + + formula = current_formulae.find { |f| f[:full_name] == name } + next unless formula + + f_deps = formula[:dependencies] + unless formula[:poured_from_bottle?] + f_deps += formula[:build_dependencies] + f_deps.uniq! + end + next unless f_deps + next if f_deps.empty? + + @checked_formulae_names << name + f_deps += recursive_dependencies(current_formulae, f_deps, top_level: false) + dependencies += f_deps + end + + dependencies.uniq + end + + IGNORED_TAPS = %w[homebrew/core].freeze + + sig { params(global: T::Boolean, file: T.nilable(String)).returns(T::Array[String]) } + def self.taps_to_untap(global: false, file: nil) + raise ArgumentError, "@dsl is unset!" unless @dsl + + require "bundle/tap" + + kept_formulae = self.kept_formulae(global:, file:).filter_map { lookup_formula(it) } + kept_taps = @dsl.entries.select { |e| e.type == :tap }.map(&:name) + kept_taps += @dsl.entries.filter_map do |entry| + case entry.type + when :brew + Utils.tap_from_full_name(entry.name) + when :cask + Utils.tap_from_full_name(T.cast(entry.options.fetch(:full_name, entry.name), String)) + end + end + kept_taps += kept_formulae.filter_map(&:tap).map(&:name) + current_taps = Homebrew::Bundle::Tap.tap_names + current_taps - kept_taps - IGNORED_TAPS + end + + sig { params(formula: String).returns(T.nilable(Formula)) } + private_class_method def self.lookup_formula(formula) + Formulary.factory(formula) + rescue TapFormulaUnavailableError + # ignore these as an unavailable formula implies there is no tap to worry about + nil + end + + sig { params(cmd: T.any(Pathname, String), args: T.anything).returns(String) } + def self.system_output_no_stderr(cmd, *args) + IO.popen([cmd, *args], err: :close).read + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/dump.rb b/Library/Homebrew/bundle/subcommand/dump.rb new file mode 100644 index 0000000000000..fba484424fc94 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/dump.rb @@ -0,0 +1,100 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" + +require "bundle/dumper" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class DumpSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + extensions = Homebrew::Bundle.extensions + usage_banner <<~EOS + `brew bundle dump`: + Write all installed casks/formulae/images/taps into a `Brewfile` in the current directory or to a custom file specified with the `--file` option. This is useful as an installed-state snapshot and can be kept in version control and diffed. + EOS + named_args :none + switch "--install", + description: "Run `install` before dumping dependencies." + switch "-f", "--force", + description: "Overwrite an existing `Brewfile`." + switch "--formula", "--formulae", "--brews", + description: "Dump Homebrew formula dependencies." + switch "--no-formula", "--no-formulae", "--no-brews", + description: "Dump without Homebrew formula dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_DUMP_NO_BREW` is set." + switch "--no-dump-brew", + description: "Dump without Homebrew formula dependencies.", + env: :bundle_dump_no_brew + switch "--cask", "--casks", + description: "Dump Homebrew cask dependencies." + switch "--no-cask", "--no-casks", + description: "Dump without Homebrew cask dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_DUMP_NO_CASK` is set." + switch "--no-dump-cask", + description: "Dump without Homebrew cask dependencies.", + env: :bundle_dump_no_cask + switch "--tap", "--taps", + description: "Dump Homebrew tap dependencies." + switch "--no-tap", "--no-taps", + description: "Dump without Homebrew tap dependencies. " \ + "Enabled by default if `$HOMEBREW_BUNDLE_DUMP_NO_TAP` is set." + switch "--no-dump-tap", + description: "Dump without Homebrew tap dependencies.", + env: :bundle_dump_no_tap + extensions.select(&:dump_supported?).each do |extension| + switch "--#{extension.flag}", + description: extension.switch_description("Dump #{extension.banner_name}.") + end + extensions.select(&:dump_disable_supported?).each do |extension| + env = "HOMEBREW_#{extension.dump_disable_env.to_s.upcase}" + switch "--no-#{extension.flag}", + description: "#{extension.dump_disable_description} " \ + "Enabled by default if `$#{env}` is set." + switch "--no-dump-#{extension.flag}", + description: extension.dump_disable_description, + env: extension.dump_disable_env + end + switch "--no-describe", + description: "Do not add description comments above each line. Description comments are " \ + "the default.", + env: :bundle_no_describe + switch "--describe", + description: "Add a description comment above each line, unless the " \ + "dependency does not have a description. This is the default unless " \ + "`$HOMEBREW_BUNDLE_NO_DESCRIBE` is set.", + env: :bundle_describe, + replacement: "the default behaviour", + odeprecated: true + conflicts "--describe", "--no-describe" + switch "--no-restart", + description: "Do not add `restart_service` to formula lines." + end + + sig { override.void } + def run + core_type_options = context.core_type_options(args, "dump") + Homebrew::Bundle::Dumper.dump_brewfile( + global: context.global, + file: context.file, + describe: args.describe? && !args.no_describe?, + force: context.force, + no_restart: args.no_restart?, + taps: core_type_options.fetch(:taps), + formulae: core_type_options.fetch(:formulae), + casks: core_type_options.fetch(:casks), + extension_types: context.extensions.select(&:dump_supported?).to_h do |extension| + disabled = extension.dump_disable_supported? && + context.extension_dump_disabled?(args, extension) + enabled = !disabled && + (context.extension_selected?(args, extension) || context.no_type_args) + [extension.type, enabled] + end, + ) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/edit.rb b/Library/Homebrew/bundle/subcommand/edit.rb new file mode 100644 index 0000000000000..1620f0f7d5a88 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/edit.rb @@ -0,0 +1,29 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class EditSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle edit`: + Edit the `Brewfile` in your editor. + EOS + named_args :none + switch "--install", + description: "Run `install` before editing the `Brewfile`." + end + + sig { override.void } + def run + require "bundle/brewfile" + + exec_editor(Homebrew::Bundle::Brewfile.path(global: context.global, file: context.file)) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/env.rb b/Library/Homebrew/bundle/subcommand/env.rb new file mode 100644 index 0000000000000..d143129b52eec --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/env.rb @@ -0,0 +1,34 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class EnvSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle env` [`--check`] [`--no-secrets`]: + Print the environment variables that would be set in a `brew bundle exec` environment. + EOS + named_args :none + switch "--install", + description: "Run `install` before printing the environment." + switch "--check", + description: "Check that all dependencies in the Brewfile are installed before " \ + "printing the environment.", + env: :bundle_check + switch "--no-secrets", + description: "Attempt to remove secrets from the environment before printing it.", + env: :bundle_no_secrets + end + + sig { override.void } + def run + ExecSubcommand.run_command("env", args:, context:) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/exec.rb b/Library/Homebrew/bundle/subcommand/exec.rb new file mode 100644 index 0000000000000..4763c9ae24f0c --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/exec.rb @@ -0,0 +1,447 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +require "English" +require "exceptions" +require "extend/ENV" +require "utils" +require "PATH" +require "utils/output" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class ExecSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle exec` [`--check`] [`--no-secrets`] [`--sandbox=`] [`--deny-network`] : + Run an external command in an isolated build environment based on the `Brewfile` dependencies. + + This sanitized build environment ignores unrequested dependencies, which makes sure that things you didn't specify in your `Brewfile` won't get picked up by commands like `bundle install`, `npm install`, etc. It will also add compiler flags which will help with finding keg-only dependencies like `openssl`, `icu4c`, etc. + EOS + named_args :command + switch "--install", + description: "Run `install` before executing the command." + switch "--services", + description: "Temporarily start services while executing the command.", + env: :bundle_services + switch "--check", + description: "Check that all dependencies in the Brewfile are installed before " \ + "executing the command.", + env: :bundle_check + switch "--no-secrets", + description: "Attempt to remove secrets from the environment before executing the command.", + env: :bundle_no_secrets + flag "--sandbox=", + description: "Run in Homebrew's sandbox, allowing writes to and Homebrew's " \ + "temporary and cache directories." + switch "--deny-network", + description: "Deny network access from inside the sandbox.", + depends_on: "--sandbox=" + end + + sig { override.void } + def run + self.class.run_command(*args.named, args:, context:) + end + + sig { params(named_args: String, args: T.untyped, context: Homebrew::Cmd::Bundle::SubcommandContext).void } + def self.run_command(*named_args, args:, context:) + sandbox_path = args.sandbox + sandbox_options = {} + if sandbox_path + sandbox_options[:sandbox_path] = sandbox_path + sandbox_options[:deny_network] = args.deny_network? + end + + run_external_command( + *named_args, + global: context.global, + file: context.file, + subcommand: context.subcommand, + services: args.services?, + check: args.check?, + no_secrets: args.no_secrets?, + **sandbox_options, + ) + end + + extend Utils::Output::Mixin + + PATH_LIKE_ENV_REGEX = /.+#{File::PATH_SEPARATOR}/ + + sig { + params( + args: String, + global: T::Boolean, + file: T.nilable(String), + subcommand: String, + services: T::Boolean, + check: T::Boolean, + no_secrets: T::Boolean, + sandbox_path: T.nilable(String), + deny_network: T::Boolean, + ).void + } + def self.run_external_command( + *args, + global: false, + file: nil, + subcommand: "", + services: false, + check: false, + no_secrets: false, + sandbox_path: nil, + deny_network: false + ) + if check + require "bundle/subcommand/check" + CheckSubcommand.new(args, context: SubcommandContext.new( + subcommand: "check", + global:, + file:, + no_upgrade: false, + verbose: false, + force: false, + ask: false, + jobs: 1, + zap: false, + no_type_args: true, + extensions: Homebrew::Bundle.extensions, + ), quiet: true).run + end + + # Store the old environment so we can check if things were already set + # before we start mutating it. + old_env = ENV.to_h + ENV.clear_sensitive_environment! if no_secrets + + # Setup Homebrew's ENV extensions + ENV.activate_extensions! + + command = args.first + raise UsageError, "No command to execute was specified!" if command.blank? + raise UsageError, "`--sandbox` requires a writable path." if sandbox_path == "" + raise UsageError, "`--deny-network` requires `--sandbox`." if deny_network && sandbox_path.blank? + + require "bundle/brewfile" + @dsl ||= T.let(nil, T.nilable(Homebrew::Bundle::Dsl)) + @dsl = Homebrew::Bundle::Brewfile.read(global:, file:) + + require "formula" + require "formulary" + + ENV.deps = @dsl.entries.filter_map do |entry| + next if entry.type != :brew + + Formulary.factory(entry.name) + end + + # Allow setting all dependencies to be keg-only + # (i.e. should be explicitly in HOMEBREW_*PATHs ahead of HOMEBREW_PREFIX) + ENV.keg_only_deps = if ENV["HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS"].present? + ENV.delete("HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS") + ENV.deps + else + ENV.deps.select(&:keg_only?) + end + ENV.setup_build_environment + + # Enable compiler flag filtering + ENV.refurbish_args + + # Add variable to detect being inside a `brew bundle exec` environment + ENV["HOMEBREW_INSIDE_BUNDLE"] = "1" + + # Set up `nodenv`, `pyenv` and `rbenv` if present. + env_formulae = %w[nodenv pyenv rbenv] + ENV.deps.each do |dep| + dep_name = dep.name + next unless env_formulae.include?(dep_name) + + dep_root = ENV.fetch("HOMEBREW_#{dep_name.upcase}_ROOT", "#{Dir.home}/.#{dep_name}") + ENV.prepend_path "PATH", Pathname.new(dep_root)/"shims" + end + + # Setup pkgconf, if needed, to help locate packages + Homebrew::Bundle.prepend_pkgconf_path_if_needed! + + # For commands which aren't either absolute or relative + # Add the command directory to PATH, since it may get blown away by superenv + if command.exclude?("/") && (which_command = which(command)) + ENV.prepend_path "PATH", which_command.dirname.to_s + end + + # Replace the formula versions from the environment variables + ENV.deps.each do |formula| + formula_name = formula.name + formula_version = Homebrew::Bundle.formula_versions_from_env(formula_name) + next unless formula_version + + ENV.each do |key, value| + opt = %r{/opt/#{formula_name}([/:$])} + next unless value.match(opt) + + cellar = "/Cellar/#{formula_name}/#{formula_version}\\1" + + # Look for PATH-like environment variables + ENV[key] = if key.include?("PATH") && value.match?(PATH_LIKE_ENV_REGEX) + rejected_opts = [] + path = PATH.new(ENV.fetch("PATH")) + .reject do |path_value| + rejected_opts << path_value if path_value.match?(opt) + end + rejected_opts.each do |path_value| + path.prepend(path_value.gsub(opt, cellar)) + end + path.to_s + else + value.gsub(opt, cellar) + end + end + end + + # Ensure brew bundle exec/sh/env commands have access to other tools in the PATH + if (homebrew_path = ENV.fetch("HOMEBREW_PATH", nil)) + ENV.append_path "PATH", homebrew_path + end + + # For commands which aren't either absolute or relative + raise "command was not found in your PATH: #{command}" if command.exclude?("/") && which(command).nil? + + %w[HOMEBREW_TEMP TMPDIR HOMEBREW_TMPDIR].each do |var| + value = ENV.fetch(var, nil) + next if value.blank? + next if File.writable?(value) + + ENV.delete(var) + end + + ENV.each do |key, value| + # Look for PATH-like environment variables + next if key.exclude?("PATH") || !value.match?(PATH_LIKE_ENV_REGEX) + + # Exclude Homebrew shims from the PATH as they don't work + # without all Homebrew environment variables and can interfere with + # non-Homebrew builds. + ENV[key] = PATH.new(value) + .reject do |path_value| + path_value.include?("/Homebrew/shims/") + end.to_s + end + + if subcommand == "env" + ENV.sort.each do |key, value| + # Skip exporting Homebrew internal variables that won't be used by other tools. + # Those Homebrew needs have already been set to global constants and/or are exported again later. + # Setting these globally can interfere with nested Homebrew invocations/environments. + if key.start_with?("HOMEBREW_", "PORTABLE_RUBY_") + ENV.delete(key) + next + end + + # No need to export empty values. + next if value.blank? + + # Skip exporting things that were the same in the old environment. + old_value = old_env[key] + next if old_value == value + + # Look for PATH-like environment variables + if key.include?("PATH") && value.match?(PATH_LIKE_ENV_REGEX) + old_values = old_value.to_s.split(File::PATH_SEPARATOR) + path = PATH.new(value) + .reject do |path_value| + # Exclude existing/old values as they've already been exported. + old_values.include?(path_value) + end + next if path.blank? + + puts "export #{key}=\"#{Utils::Shell.sh_quote(path.to_s)}:${#{key}:-}\"" + else + puts "export #{key}=\"#{Utils::Shell.sh_quote(value)}\"" + end + end + return + elsif subcommand == "sh" + preferred_path = Utils::Shell.preferred_path(default: "/bin/bash") + notice = unless Homebrew::EnvConfig.no_env_hints? + <<~EOS + Your shell has been configured to use a build environment from your `Brewfile`. + This should help you build stuff. + Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`). + When done, type `exit`. + EOS + end + ENV["HOMEBREW_FORCE_API_AUTO_UPDATE"] = nil + args = [Utils::Shell.shell_with_prompt("brew bundle", preferred_path:, notice:)] + end + + require "sandbox" if sandbox_path + + if services + require "bundle/brew_services" + + exit_code = T.let(0, Integer) + run_services(@dsl.entries) do + if sandbox_path + begin + Sandbox.run_command(*args, writable_path: sandbox_path, deny_network:) + rescue ErrorDuringExecution => e + exit_code = e.exitstatus || 1 + end + else + Kernel.system(*args) + if (system_exit_code = $CHILD_STATUS&.exitstatus) + exit_code = system_exit_code + end + end + end + exit!(exit_code) + elsif sandbox_path + Sandbox.run_command(*args, writable_path: sandbox_path, deny_network:) + else + exec(*args) + end + end + + sig { + params( + entries: T::Array[Homebrew::Bundle::Dsl::Entry], + _block: T.proc.params( + entry: Homebrew::Bundle::Dsl::Entry, + info: T::Hash[String, T.untyped], + service_file: Pathname, + conflicting_services: T::Array[T::Hash[String, T.untyped]], + ).void, + ).void + } + private_class_method def self.map_service_info(entries, &_block) + entries_formulae = entries.filter_map do |entry| + next if entry.type != :brew + + formula = Formula[entry.name] + next unless formula.any_version_installed? + + [entry, formula] + end.to_h + + return if entries_formulae.empty? + + conflicts = entries_formulae.to_h do |entry, formula| + [ + entry, + ( + formula.versioned_formulae_names + + formula.conflicts.map(&:name) + + Array(entry.options[:conflicts_with]) + ).uniq, + ] + end + + # The formula + everything that could possible conflict with the service + names_to_query = entries_formulae.flat_map do |entry, formula| + [ + formula.name, + *conflicts.fetch(entry), + ] + end + + # We parse from a command invocation so that brew wrappers can invoke special actions + # for the elevated nature of `brew services` + services_info = JSON.parse( + Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "info", "--json", *names_to_query), + ) + + entries_formulae.filter_map do |entry, formula| + service_file = Homebrew::Bundle::Brew::Services.versioned_service_file(entry.name) + + unless service_file&.file? + prefix = formula.any_installed_prefix + next if prefix.nil? + + service_file = if Homebrew::Services::System.launchctl? + prefix/"#{formula.plist_name}.plist" + else + prefix/"#{formula.service_name}.service" + end + end + + next unless service_file.file? + + info = services_info.find { |candidate| candidate["name"] == formula.name } + conflicting_services = services_info.select do |candidate| + next unless candidate["running"] + + conflicts.fetch(entry).include?(candidate["name"]) + end + + raise "Failed to get service info for #{entry.name}" if info.nil? + + yield entry, info, service_file, conflicting_services + end + end + + sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], _block: T.nilable(T.proc.void)).void } + private_class_method def self.run_services(entries, &_block) + entries_to_stop = [] + services_to_restart = [] + + map_service_info(entries) do |entry, info, service_file, conflicting_services| + # Don't restart if already running this version + loaded_file = Pathname.new(info["loaded_file"].to_s) + next if info["running"] && loaded_file.file? && loaded_file.realpath == service_file.realpath + + if info["running"] && !Homebrew::Bundle::Brew::Services.stop(info["name"], keep: true) + opoo "Failed to stop #{info["name"]} service" + end + + conflicting_services.each do |conflict| + if Homebrew::Bundle::Brew::Services.stop(conflict["name"], keep: true) + services_to_restart << conflict["name"] if conflict["registered"] + else + opoo "Failed to stop #{conflict["name"]} service" + end + end + + unless Homebrew::Bundle::Brew::Services.run(info["name"], file: service_file) + opoo "Failed to start #{info["name"]} service" + end + + entries_to_stop << entry + end + + return unless block_given? + + begin + yield + ensure + # Do a full re-evaluation of services instead state has changed + stop_services(entries_to_stop) + + services_to_restart.each do |service| + next if Homebrew::Bundle::Brew::Services.run(service) + + opoo "Failed to restart #{service} service" + end + end + end + + sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).void } + private_class_method def self.stop_services(entries) + map_service_info(entries) do |_, info, _, _| + next unless info["loaded"] + + # Try avoid services not started by `brew bundle services` + next if Homebrew::Services::System.launchctl? && info["registered"] + + if info["running"] && !Homebrew::Bundle::Brew::Services.stop(info["name"], keep: true) + opoo "Failed to stop #{info["name"]} service" + end + end + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/install.rb b/Library/Homebrew/bundle/subcommand/install.rb new file mode 100644 index 0000000000000..3c18056f3e6eb --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/install.rb @@ -0,0 +1,111 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +require "bundle/brewfile" +require "bundle/installer" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class InstallSubcommand < Homebrew::AbstractSubcommand + subcommand_args alias_options: { "upgrade" => "--upgrade" }, default: true do + usage_banner <<~EOS + `brew bundle` [`install`|`upgrade`]: + Install and upgrade (by default) all dependencies from the `Brewfile`. + + Use this to restore a recorded installed state from a `Brewfile`. + + `brew bundle upgrade` is shorthand for `brew bundle install --upgrade`. + + You can specify the `Brewfile` location using `--file` or by setting the `$HOMEBREW_BUNDLE_FILE` environment variable. + + You can skip the installation of dependencies by adding space-separated values to one or more of the following environment variables: `$HOMEBREW_BUNDLE_BREW_SKIP`, `$HOMEBREW_BUNDLE_CASK_SKIP`, `$HOMEBREW_BUNDLE_MAS_SKIP`, `$HOMEBREW_BUNDLE_TAP_SKIP`. + EOS + named_args :none + switch "-v", "--verbose", + description: "Print output from commands as they are run." + switch "--no-upgrade", + description: "Do not run `brew upgrade` on outdated dependencies. " \ + "Note they may still be upgraded by `brew install` if needed.", + env: :bundle_no_upgrade + switch "--upgrade", + description: "Run `brew upgrade` on outdated dependencies, " \ + "even if `$HOMEBREW_BUNDLE_NO_UPGRADE` is set." + flag "--upgrade-formulae=", "--upgrade-formula=", + description: "Run `brew upgrade` on any of these comma-separated formulae, " \ + "even if `$HOMEBREW_BUNDLE_NO_UPGRADE` is set." + # odeprecated: change default for 5.2 and document HOMEBREW_BUNDLE_JOBS + flag "--jobs=", + description: "Run up to this many formula installations in parallel. " \ + "Defaults to 1 (sequential). Use `auto` for the number of CPU cores (max 4)." + switch "-f", "--force", + description: "Run with `--force`/`--overwrite`." + switch "--cleanup", + description: "Ask to perform cleanup after installing dependencies. Requires `--force`, " \ + "`--force-cleanup` or `$HOMEBREW_ASK`.", + env: [:bundle_install_cleanup, "--global"], + odeprecated: true + switch "--force-cleanup", + description: "Perform cleanup after installing dependencies without asking.", + env: [:bundle_force_install_cleanup, "--global"] + switch "--zap", + description: "Use `zap` instead of `uninstall` when cleaning up casks after " \ + "installing dependencies." + end + + sig { override.void } + def run + if args.zap? && !args.cleanup? && !args.force_cleanup? + raise UsageError, "`--zap` cannot be passed without `--cleanup` or `--force-cleanup`." + end + + if args.cleanup? && !context.force && !args.force_cleanup? && !context.ask + raise UsageError, "`brew bundle install --cleanup` requires `--force`, `--force-cleanup` " \ + "or `$HOMEBREW_ASK`." + end + + @dsl = Homebrew::Bundle::Brewfile.read(global: context.global, file: context.file) + result = Homebrew::Bundle::Installer.install!( + @dsl.entries, + global: context.global, + file: context.file, + no_lock: false, + no_upgrade: context.no_upgrade, + verbose: context.verbose, + force: context.force, + jobs: context.jobs, + quiet: quiet || args.quiet?, + ) + + # Mark Brewfile formulae as installed_on_request to prevent autoremove + # from removing them when their dependents are uninstalled + Homebrew::Bundle.mark_as_installed_on_request!(@dsl.entries) + + result || exit(1) + + return unless cleanup + + cleanup_requested = args.force_cleanup? || args.cleanup? + return unless cleanup_requested + + require "bundle/subcommand/cleanup" + + # Don't need to reset cleanup specifically but this resets all the dumper modules. + Homebrew::Cmd::Bundle::CleanupSubcommand.reset! + Homebrew::Cmd::Bundle::CleanupSubcommand.cleanup( + global: context.global, file: context.file, zap: context.zap, + force: context.force || args.force_cleanup?, + ask: context.ask, dsl: + ) + end + + sig { returns(T.nilable(Homebrew::Bundle::Dsl)) } + def dsl + @dsl ||= T.let(nil, T.nilable(Homebrew::Bundle::Dsl)) + @dsl + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/list.rb b/Library/Homebrew/bundle/subcommand/list.rb new file mode 100644 index 0000000000000..adeaceeb81586 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/list.rb @@ -0,0 +1,52 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" + +require "bundle/brewfile" +require "bundle/lister" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class ListSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle list`: + List all dependencies present in the `Brewfile`. + + By default, only Homebrew formula dependencies are listed. + EOS + named_args :none + switch "--install", + description: "Run `install` before listing dependencies." + switch "--all", + description: "List all dependencies." + switch "--formula", "--formulae", "--brews", + description: "List Homebrew formula dependencies." + switch "--cask", "--casks", + description: "List Homebrew cask dependencies." + switch "--tap", "--taps", + description: "List Homebrew tap dependencies." + Homebrew::Bundle.extensions.each do |extension| + switch "--#{extension.flag}", + description: extension.switch_description("List #{extension.banner_name}.") + end + end + + sig { override.void } + def run + Homebrew::Bundle::Lister.list( + Homebrew::Bundle::Brewfile.read(global: context.global, file: context.file).entries, + formulae: args.formulae? || args.all? || context.no_type_args, + casks: args.casks? || args.all?, + taps: args.taps? || args.all?, + extension_types: context.extensions.to_h do |extension| + [extension.type, context.extension_selected?(args, extension) || args.all?] + end, + ) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/remove.rb b/Library/Homebrew/bundle/subcommand/remove.rb new file mode 100644 index 0000000000000..42cfce3bdec6f --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/remove.rb @@ -0,0 +1,49 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" +require "bundle/extensions/extension" + +require "bundle/remover" +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class RemoveSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + extensions = Homebrew::Bundle.extensions + usage_banner <<~EOS + `brew bundle remove` [...]: + Remove entries that match `name` from your `Brewfile`. Use #{["`--formula`", "`--cask`", "`--tap`", *extensions.select(&:remove_supported?).map { |extension| "`--#{extension.flag}`" }].to_sentence} to remove only entries of the corresponding type. Passing `--formula` also removes matches against formula aliases and old formula names. + EOS + named_args min: 1 + switch "--install", + description: "Run `install` before removing entries." + switch "--formula", "--formulae", "--brews", + description: "Remove Homebrew formula entries, including matches against formula aliases " \ + "and old names." + switch "--cask", "--casks", + description: "Remove Homebrew cask entries." + switch "--tap", "--taps", + description: "Remove Homebrew tap entries." + extensions.select(&:remove_supported?).each do |extension| + switch "--#{extension.flag}", + description: extension.switch_description("Remove entries for #{extension.banner_name}.") + end + end + + sig { override.void } + def run + selected_types = context.selected_types(args) + raise UsageError, "`remove` supports only one type of entry at a time." if selected_types.count != 1 + + Homebrew::Bundle::Remover.remove( + *args.named, + type: selected_types.first, + global: context.global, + file: context.file, + ) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand/sh.rb b/Library/Homebrew/bundle/subcommand/sh.rb new file mode 100644 index 0000000000000..0ba62bb240405 --- /dev/null +++ b/Library/Homebrew/bundle/subcommand/sh.rb @@ -0,0 +1,37 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_subcommand" + +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class ShSubcommand < Homebrew::AbstractSubcommand + subcommand_args do + usage_banner <<~EOS + `brew bundle sh` [`--check`] [`--no-secrets`]: + Run your shell in a `brew bundle exec` environment. + EOS + named_args :none + switch "--install", + description: "Run `install` before starting the shell." + switch "--services", + description: "Temporarily start services while running the shell.", + env: :bundle_services + switch "--check", + description: "Check that all dependencies in the Brewfile are installed before " \ + "starting the shell.", + env: :bundle_check + switch "--no-secrets", + description: "Attempt to remove secrets from the environment before starting the shell.", + env: :bundle_no_secrets + end + + sig { override.void } + def run + ExecSubcommand.run_command("sh", args:, context:) + end + end + end + end +end diff --git a/Library/Homebrew/bundle/subcommand_context.rb b/Library/Homebrew/bundle/subcommand_context.rb new file mode 100644 index 0000000000000..e70b791e63d5f --- /dev/null +++ b/Library/Homebrew/bundle/subcommand_context.rb @@ -0,0 +1,86 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/extensions/extension" +require "abstract_command" + +module Homebrew + module Cmd + class Bundle < Homebrew::AbstractCommand + class SubcommandContext < T::Struct + const :subcommand, String + const :global, T::Boolean + const :file, T.nilable(String) + const :no_upgrade, T::Boolean + const :verbose, T::Boolean + const :force, T::Boolean + const :ask, T::Boolean + const :jobs, Integer + const :zap, T::Boolean + const :no_type_args, T::Boolean + const :extensions, T::Array[T.class_of(Homebrew::Bundle::Extension)] + + sig { params(args: T.untyped, extension: T.class_of(Homebrew::Bundle::Extension)).returns(T::Boolean) } + def extension_selected?(args, extension) + args.public_send(extension.predicate_method) + end + + sig { params(args: T.untyped, extension: T.class_of(Homebrew::Bundle::Extension)).returns(T::Boolean) } + def extension_dump_disabled?(args, extension) + args.public_send(extension.dump_disable_predicate_method) || + args.public_send(:"no_dump_#{extension.type}?") + end + + sig { params(args: T.untyped, extension: T.class_of(Homebrew::Bundle::Extension)).returns(T::Boolean) } + def extension_disabled?(args, extension) + args.public_send(extension.disable_predicate_method) || + args.public_send(:"no_cleanup_#{extension.type}?") + end + + sig { + params(args: T.untyped, prefix: String, all: T::Boolean) + .returns(T::Hash[Symbol, T::Boolean]) + } + def core_type_options(args, prefix, all: false) + { + formulae: type_selected?(args, :formulae?, :no_formulae?, :"no_#{prefix}_brew?", all:), + casks: type_selected?(args, :casks?, :no_casks?, :"no_#{prefix}_cask?", all:), + taps: type_selected?(args, :taps?, :no_taps?, :"no_#{prefix}_tap?", all:), + } + end + + sig { params(args: T.untyped).returns(T::Array[Symbol]) } + def selected_types(args) + # We intentionally omit the s from `brews`, `casks`, and `taps` for ease of handling later. + type_hash = { + brew: args.formulae?, + cask: args.casks?, + tap: args.taps?, + } + extensions.each do |extension| + type_hash[extension.type] = extension_selected?(args, extension) + end + type_hash[:none] = no_type_args + type_hash.select { |_, v| v }.keys + end + + private + + sig { + params(args: T.untyped, predicate_method: Symbol, disabled_predicate_method: Symbol, + env_disabled_predicate_method: Symbol, all: T::Boolean).returns(T::Boolean) + } + def type_selected?(args, predicate_method, disabled_predicate_method, env_disabled_predicate_method, + all: false) + !type_disabled?(args, disabled_predicate_method, env_disabled_predicate_method) && + (args.public_send(predicate_method) || all || no_type_args) + end + + sig { params(args: T.untyped, disabled_methods: Symbol).returns(T::Boolean) } + def type_disabled?(args, *disabled_methods) + disabled_methods.any? { |disabled_method| args.public_send(disabled_method) } + end + end + end + end +end diff --git a/Library/Homebrew/bundle/tap.rb b/Library/Homebrew/bundle/tap.rb new file mode 100644 index 0000000000000..a762160398ed4 --- /dev/null +++ b/Library/Homebrew/bundle/tap.rb @@ -0,0 +1,187 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "bundle/package_type" +require "trust" + +module Homebrew + module Bundle + class Tap < Homebrew::Bundle::PackageType + class << self + sig { override.returns(Symbol) } + def type = :tap + + sig { override.returns(String) } + def check_label = "Tap" + + sig { override.void } + def reset! + @taps = T.let(nil, T.nilable(T::Array[::Tap])) + @installed_taps = T.let(nil, T.nilable(T::Array[String])) + end + + sig { + override.params( + name: String, + no_upgrade: T::Boolean, + verbose: T::Boolean, + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def preinstall!(name, no_upgrade: false, verbose: false, **_options) + _ = no_upgrade + + if installed_taps.include? name + puts "Skipping install of #{name} tap. It is already installed." if verbose + return false + end + + true + end + + sig { + override.params( + name: String, + preinstall: T::Boolean, + no_upgrade: T::Boolean, + verbose: T::Boolean, + force: T::Boolean, + clone_target: T.nilable(String), + _options: Homebrew::Bundle::EntryOption, + ).returns(T::Boolean) + } + def install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, clone_target: nil, + **_options) + _ = no_upgrade + + return true unless preinstall + + puts "Installing #{name} tap. It is not currently installed." if verbose + args = [] + official_tap = name.downcase.start_with? "homebrew/" + args << "--force" if force || (official_tap && Homebrew::EnvConfig.developer?) + + success = if clone_target + Bundle.brew("tap", name, clone_target, *args, verbose:) + else + Bundle.brew("tap", name, *args, verbose:) + end + + unless success + require "bundle/skipper" + Homebrew::Bundle::Skipper.tap_failed!(name) + return false + end + + require "tap" + ::Tap.fetch(name).clear_cache + installed_taps << name + true + end + + sig { override.params(_name: String, _options: Homebrew::Bundle::EntryOptions).returns(String) } + def install_verb(_name = "", _options = {}) + "Tapping" + end + + sig { override.params(dumped_formulae: T::Array[String], dumped_casks: T::Array[String]).returns(String) } + def dump(dumped_formulae: [], dumped_casks: []) + taps.map do |tap| + remote = if (tap_remote = tap.remote) && tap_remote != tap.default_remote + if (api_token = ENV.fetch("HOMEBREW_GITHUB_API_TOKEN", false).presence) + # Replace the API token in the remote URL with interpolation. + # Keep the interpolation unevaluated until the Brewfile is evaluated. + tap_remote = tap_remote.gsub api_token, "\#{ENV.fetch(\"HOMEBREW_GITHUB_API_TOKEN\")}" + end + ", \"#{tap_remote}\"" + end + tapline = "tap \"#{tap.name}\"#{remote}" + trusted = if Homebrew::Trust.explicitly_trusted_tap?(tap) + true + else + tap_trust = T.let({}, T::Hash[Symbol, T::Array[String]]) + { + formula: [:formulae, dumped_formulae], + cask: [:casks, dumped_casks], + command: [:commands, []], + }.each do |type, values| + key, dumped_items = values + trusted_items = Homebrew::Trust.trusted_entries(type).filter_map do |entry| + reference, _, item = entry.rpartition("/") + next if reference.blank? || item.blank? + next if reference != tap.name && !tap.matches_reference?(reference) + next if dumped_items.include?("#{tap.name}/#{item}") + + item + end.sort.uniq + tap_trust[key] = trusted_items if trusted_items.present? + end + tap_trust.presence + end + + if trusted == true + tapline += ", trusted: true" + elsif trusted.present? + trusted_options = trusted.map do |key, values| + "#{key}: [#{values.map(&:inspect).join(", ")}]" + end.join(", ") + tapline += ", trusted: { #{trusted_options} }" + end + tapline + end.sort.uniq.join("\n") + end + + sig { override.params(describe: T::Boolean, no_restart: T::Boolean).returns(String) } + def dump_output(describe: false, no_restart: false) + _ = describe + _ = no_restart + + dump + end + + sig { returns(T::Array[String]) } + def tap_names + taps.map(&:name) + end + + sig { returns(T::Array[String]) } + def installed_taps + @installed_taps ||= T.let(tap_names, T.nilable(T::Array[String])) + end + + sig { returns(T::Array[::Tap]) } + def taps + @taps ||= begin + require "tap" + ::Tap.select(&:installed?).to_a + end + end + private :taps + end + + sig { + override.params(entries: T::Array[Dsl::Entry], exit_on_first_error: T::Boolean, + no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[String]) + } + def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false) + _ = exit_on_first_error + _ = no_upgrade + _ = verbose + + requested_taps = format_checkable(entries) + return [] if requested_taps.empty? + + current_taps = self.class.tap_names + (requested_taps - current_taps).map { |entry| "Tap #{entry} needs to be tapped." } + end + + sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) } + def installed_and_up_to_date?(package, no_upgrade: false) + _ = no_upgrade + + self.class.installed_taps.include?(T.cast(package, String)) + end + end + end +end diff --git a/Library/Homebrew/bundle/trust.rb b/Library/Homebrew/bundle/trust.rb new file mode 100644 index 0000000000000..f18695484cb4a --- /dev/null +++ b/Library/Homebrew/bundle/trust.rb @@ -0,0 +1,81 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" +require "trust" +require "utils" + +module Homebrew + module Bundle + # Converts Brewfile `trusted` options into trust-store entries. + module Trust + TRUSTED_ITEM_KEYS = T.let({ + formula: [:formula, :formulae], + cask: [:cask, :casks], + command: [:command, :commands], + }.freeze, T::Hash[Symbol, T::Array[Symbol]]) + private_constant :TRUSTED_ITEM_KEYS + + sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).returns(T::Array[[Symbol, String]]) } + def self.entries(entries) + # Resolve every item through `Homebrew::Trust.target`, the same canonicalizer `brew trust` + # uses, so bundle does not write a second, divergent entry for a custom-remote tap. A + # `brew`/`cask` entry takes its remote from the matching `tap` entry, which can appear later + # in the Brewfile, so map each tap name to its declared remote first. + tap_remotes = entries.filter_map do |entry| + next if entry.type != :tap + + clone_target = entry.options[:clone_target].presence + [entry.name.downcase, clone_target.to_s] if clone_target + end.to_h + + entries.flat_map do |entry| + trusted = entry.options[:trusted] + next [] if trusted.blank? + + targets = T.let([], T::Array[[Symbol, String, T.nilable(String)]]) + case entry.type + when :tap + tap_remote = entry.options[:clone_target].presence&.to_s + if trusted == true + targets << [:tap, entry.name, tap_remote] + elsif trusted.is_a?(Hash) + unsupported_keys = trusted.keys - TRUSTED_ITEM_KEYS.values.flatten + if unsupported_keys.present? + raise UsageError, + "Unsupported trusted keys: #{unsupported_keys.join(", ")}" + end + + TRUSTED_ITEM_KEYS.each do |type, keys| + keys.each do |key| + Array(trusted[key]).each do |item| + item_name = case item + when String, Symbol, Integer + Utils.name_from_full_name(item.to_s) + end + next if item_name.blank? + + targets << [type, "#{entry.name}/#{item_name}", tap_remote] + end + end + end + end + when :brew, :cask + full_name = T.cast(entry.options.fetch(:full_name, entry.name), String) + next [] if trusted != true + # Only fully-qualified names map to a tap, so unqualified names cannot be trusted. + next [] unless Utils.full_name?(full_name) + + type = (entry.type == :brew) ? :formula : :cask + tap_name = Utils.tap_from_full_name(full_name) + canonical_tap_name = Dsl.sanitize_tap_name(tap_name) if tap_name + tap_remote = tap_remotes[canonical_tap_name] if canonical_tap_name + targets << [type, full_name, tap_remote] + end + + targets.map { |type, name, tap_remote| Homebrew::Trust.target(name, type:, tap_remote:) } + end.uniq + end + end + end +end diff --git a/Library/Homebrew/bundle_version.rb b/Library/Homebrew/bundle_version.rb new file mode 100644 index 0000000000000..ee0cccd83e8fd --- /dev/null +++ b/Library/Homebrew/bundle_version.rb @@ -0,0 +1,128 @@ +# typed: strict +# frozen_string_literal: true + +require "system_command" + +module Homebrew + # Representation of a macOS bundle version, commonly found in `Info.plist` files. + class BundleVersion + include Comparable + + extend SystemCommand::Mixin + + sig { params(info_plist_path: Pathname).returns(T.nilable(T.attached_class)) } + def self.from_info_plist(info_plist_path) + plist = system_command!("plutil", args: ["-convert", "xml1", "-o", "-", info_plist_path]).plist + from_info_plist_content(plist) + end + + sig { params(plist: T::Hash[String, T.untyped]).returns(T.nilable(T.attached_class)) } + def self.from_info_plist_content(plist) + short_version = plist["CFBundleShortVersionString"].presence + version = plist["CFBundleVersion"].presence + + new(short_version, version) if short_version || version + end + + sig { params(package_info_path: Pathname).returns(T.nilable(T.attached_class)) } + def self.from_package_info(package_info_path) + require "rexml/document" + + xml = REXML::Document.new(package_info_path.read) + + bundle_version_bundle = xml.get_elements("//pkg-info//bundle-version//bundle").first + bundle_id = bundle_version_bundle["id"] if bundle_version_bundle + return if bundle_id.blank? + + bundle = xml.get_elements("//pkg-info//bundle").find { |b| b["id"] == bundle_id } + return unless bundle + + short_version = bundle["CFBundleShortVersionString"] + version = bundle["CFBundleVersion"] + + new(short_version, version) if short_version || version + end + + sig { returns(T.nilable(String)) } + attr_reader :short_version, :version + + sig { params(short_version: T.nilable(String), version: T.nilable(String)).void } + def initialize(short_version, version) + # Remove version from short version, if present. + short_version = short_version&.sub(/\s*\(#{Regexp.escape(version)}\)\Z/, "") if version + + @short_version = T.let(short_version.presence, T.nilable(String)) + @version = T.let(version.presence, T.nilable(String)) + + return if @short_version || @version + + raise ArgumentError, "`short_version` and `version` cannot both be `nil` or empty" + end + + sig { params(other: BundleVersion).returns(T.nilable(Integer)) } + def <=>(other) + return super unless instance_of?(other.class) + + make_version = ->(v) { v ? Version.new(v) : Version::NULL } + + version = self.version.then(&make_version) + other_version = other.version.then(&make_version) + + difference = version <=> other_version + + # If `version` is equal or cannot be compared, compare `short_version` instead. + if difference.nil? || difference.zero? + short_version = self.short_version.then(&make_version) + other_short_version = other.short_version.then(&make_version) + + short_version_difference = short_version <=> other_short_version + + return short_version_difference unless short_version_difference.nil? + end + + difference + end + + sig { params(other: BundleVersion).returns(T::Boolean) } + def ==(other) + instance_of?(other.class) && short_version == other.short_version && version == other.version + end + alias eql? == + + # Create a nicely formatted version (on a best effort basis). + sig { returns(String) } + def nice_version + nice_parts.join(",") + end + + sig { returns(T::Array[String]) } + def nice_parts + short_version = self.short_version + version = self.version + + return [T.must(short_version)] if short_version == version + + if short_version && version + return [version] if version.match?(/\A\d+(\.\d+)+\Z/) && version.start_with?("#{short_version}.") + return [short_version] if short_version.match?(/\A\d+(\.\d+)+\Z/) && short_version.start_with?("#{version}.") + + if short_version.match?(/\A\d+(\.\d+)*\Z/) && version.match?(/\A\d+\Z/) + return [short_version] if short_version.start_with?("#{version}.") || short_version.end_with?(".#{version}") + + return [short_version, version] + end + end + + [short_version, version].compact + end + private :nice_parts + + sig { returns(T::Hash[Symbol, String]) } + def to_h + { + short_version:, + version:, + }.compact + end + end +end diff --git a/Library/Homebrew/cachable.rb b/Library/Homebrew/cachable.rb new file mode 100644 index 0000000000000..f2068e26f0bfb --- /dev/null +++ b/Library/Homebrew/cachable.rb @@ -0,0 +1,18 @@ +# typed: strict +# frozen_string_literal: true + +module Cachable + extend T::Generic + + # Sorbet type members are mutable by design and cannot be frozen. + Cache = type_member { { upper: T::Hash[T.anything, T.anything] } } + sig { returns(Cache) } + def cache + @cache ||= T.let(T.cast({}, Cache), T.nilable(Cache)) + end + + sig { void } + def clear_cache + cache.clear + end +end diff --git a/Library/Homebrew/cache_store.rb b/Library/Homebrew/cache_store.rb index 842161ec1fa23..6cf444e3f9e53 100644 --- a/Library/Homebrew/cache_store.rb +++ b/Library/Homebrew/cache_store.rb @@ -1,61 +1,112 @@ +# typed: strict # frozen_string_literal: true require "json" # -# `CacheStoreDatabase` acts as an interface to a persistent storage mechanism +# {CacheStoreDatabase} acts as an interface to a persistent storage mechanism # residing in the `HOMEBREW_CACHE`. # class CacheStoreDatabase + extend T::Generic + + # Sorbet type members are mutable by design and cannot be frozen. + Key = type_member # rubocop:disable Style/MutableConstant + # Sorbet type members are mutable by design and cannot be frozen. + Value = type_member # rubocop:disable Style/MutableConstant + # Yields the cache store database. # Closes the database after use if it has been loaded. - # - # @param [Symbol] type - # @yield [CacheStoreDatabase] self - def self.use(type) - database = CacheStoreDatabase.new(type) - return_value = yield(database) - database.close_if_open! + sig { + type_parameters(:U) + .params( + type: Symbol, + _blk: T.proc.params(arg0: CacheStoreDatabase[T.anything, T.anything]).returns(T.type_parameter(:U)), + ) + .returns(T.type_parameter(:U)) + } + def self.use(type, &_blk) + @db_type_reference_hash ||= T.let({}, T.nilable(T::Hash[T.untyped, T.untyped])) + @db_type_reference_hash[type] ||= {} + type_ref = @db_type_reference_hash[type] + + type_ref[:count] ||= 0 + type_ref[:count] += 1 + + type_ref[:db] ||= CacheStoreDatabase.new(type) + + return_value = yield(type_ref[:db]) + if type_ref[:count].positive? + type_ref[:count] -= 1 + else + type_ref[:count] = 0 + end + + if type_ref[:count].zero? + type_ref[:db].write_if_dirty! + type_ref.delete(:db) + end + return_value end + # Creates a CacheStoreDatabase. + sig { params(type: Symbol).void } + def initialize(type) + @type = type + @dirty = T.let(false, T.nilable(T::Boolean)) + end + # Sets a value in the underlying database (and creates it if necessary). + sig { params(key: Key, value: Value).void } def set(key, value) + dirty! db[key] = value end # Gets a value from the underlying database (if it already exists). + sig { params(key: Key).returns(T.nilable(Value)) } def get(key) return unless created? db[key] end - # Gets a value from the underlying database (if it already exists). + # Deletes a value from the underlying database (if it already exists). + sig { params(key: Key).void } def delete(key) return unless created? + dirty! db.delete(key) end + # Deletes all content from the underlying database (if it already exists). + sig { void } + def clear! + return unless created? + + dirty! + db.clear + end + # Closes the underlying database (if it is created and open). - def close_if_open! - return unless @db + sig { void } + def write_if_dirty! + return unless dirty? cache_path.dirname.mkpath cache_path.atomic_write(JSON.dump(@db)) end - # Returns `true` if the cache file has been created for the given `@type` - # - # @return [Boolean] + # Returns `true` if the cache file has been created for the given `@type`. + sig { returns(T::Boolean) } def created? cache_path.exist? end # Returns the modification time of the cache file (if it already exists). - # - # @return [Time] + sig { returns(T.nilable(Time)) } def mtime return unless created? @@ -63,88 +114,63 @@ def mtime end # Performs a `select` on the underlying database. - # - # @return [Array] + sig { + overridable.params(block: T.proc.params(arg0: Key, arg1: Value).returns(BasicObject)).returns(T::Hash[Key, Value]) + } def select(&block) db.select(&block) end # Returns `true` if the cache is empty. - # - # @return [Boolean] + sig { returns(T::Boolean) } def empty? db.empty? end + # Performs a `each_key` on the underlying database. + sig { + params(block: T.proc.params(arg0: Key).returns(BasicObject)).returns(T::Hash[Key, Value]) + } + def each_key(&block) + db.each_key(&block) + end + private # Lazily loaded database in read/write mode. If this method is called, a - # database file with be created in the `HOMEBREW_CACHE` with name - # corresponding to the `@type` instance variable - # - # @return [Hash] db + # database file will be created in the `HOMEBREW_CACHE` with a name + # corresponding to the `@type` instance variable. + sig { returns(T::Hash[Key, Value]) } def db - @db ||= begin - JSON.parse(cache_path.read) if created? + @db ||= T.let({}, T.nilable(T::Hash[Key, Value])) + return @db if !@db.empty? || !created? + + begin + result = JSON.parse(cache_path.read) + @db = result if result.is_a?(Hash) rescue JSON::ParserError - nil + # Ignore parse errors end - @db ||= {} - end - - # Creates a CacheStoreDatabase - # - # @param [Symbol] type - # @return [nil] - def initialize(type) - @type = type + @db end # The path where the database resides in the `HOMEBREW_CACHE` for the given - # `@type` - # - # @return [String] + # `@type`. + sig { returns(Pathname) } def cache_path HOMEBREW_CACHE/"#{@type}.json" end -end -# -# `CacheStore` provides methods to mutate and fetch data from a persistent -# storage mechanism. -# -class CacheStore - # @param [CacheStoreDatabase] database - # @return [nil] - def initialize(database) - @database = database - end - - # Inserts new values or updates existing cached values to persistent storage - # mechanism - # - # @abstract - def update!(*) - raise NotImplementedError + # Sets that the cache needs to be written to disk. + sig { void } + def dirty! + @dirty = true end - # Fetches cached values in persistent storage according to the type of data - # stored - # - # @abstract - def fetch(*) - raise NotImplementedError + # Returns `true` if the cache needs to be written to disk. + sig { returns(T::Boolean) } + def dirty? + !!@dirty end - - # Deletes data from the cache based on a condition defined in a concrete class - # - # @abstract - def delete!(*) - raise NotImplementedError - end - - protected - - # @return [CacheStoreDatabase] - attr_reader :database end +require "cache_store/cache_store" diff --git a/Library/Homebrew/cache_store/cache_store.rb b/Library/Homebrew/cache_store/cache_store.rb new file mode 100644 index 0000000000000..436570db88fe4 --- /dev/null +++ b/Library/Homebrew/cache_store/cache_store.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +# +# {CacheStore} provides methods to mutate and fetch data from a persistent +# storage mechanism. +# +class CacheStore + extend T::Generic + extend T::Helpers + + abstract! + + # Sorbet type members are mutable by design and cannot be frozen. + Key = type_member # rubocop:disable Style/MutableConstant + # Sorbet type members are mutable by design and cannot be frozen. + Value = type_member # rubocop:disable Style/MutableConstant + + sig { params(database: CacheStoreDatabase[Key, Value]).void } + def initialize(database) + @database = database + end + + protected + + sig { returns(CacheStoreDatabase[Key, Value]) } + attr_reader :database +end diff --git a/Library/Homebrew/cask.rb b/Library/Homebrew/cask.rb new file mode 100644 index 0000000000000..9bb3b7444a1b8 --- /dev/null +++ b/Library/Homebrew/cask.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact" +require "cask/audit" +require "cask/auditor" +require "cask/cache" +require "cask/cask_loader" +require "cask/cask" +require "cask/caskroom" +require "cask/config" +require "cask/exceptions" +require "cask/denylist" +require "cask/download" +require "cask/dsl" +require "cask/installer" +require "cask/macos" +require "cask/metadata" +require "cask/migrator" +require "cask/pkg" +require "cask/quarantine" +require "cask/staged" +require "cask/tab" +require "cask/url" +require "cask/utils" diff --git a/Library/Homebrew/cask/all.rb b/Library/Homebrew/cask/all.rb deleted file mode 100644 index 3a6e489a783ec..0000000000000 --- a/Library/Homebrew/cask/all.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "hardware" - -require "cask/artifact" -require "cask/audit" -require "cask/auditor" -require "cask/cache" -require "cask/cask" -require "cask/cask_loader" -require "cask/caskroom" -require "cask/checkable" -require "cask/cmd" -require "cask/exceptions" -require "cask/installer" -require "cask/macos" -require "cask/pkg" -require "cask/staged" -require "cask/utils" -require "cask/verify" diff --git a/Library/Homebrew/cask/artifact.rb b/Library/Homebrew/cask/artifact.rb index 07d8925155a11..4bc895d068433 100644 --- a/Library/Homebrew/cask/artifact.rb +++ b/Library/Homebrew/cask/artifact.rb @@ -1,15 +1,19 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/app" +require "cask/artifact/appimage" require "cask/artifact/artifact" # generic 'artifact' stanza require "cask/artifact/audio_unit_plugin" require "cask/artifact/binary" require "cask/artifact/colorpicker" require "cask/artifact/dictionary" +require "cask/artifact/install_steps" require "cask/artifact/font" require "cask/artifact/input_method" require "cask/artifact/installer" require "cask/artifact/internet_plugin" +require "cask/artifact/keyboard_layout" require "cask/artifact/manpage" require "cask/artifact/vst_plugin" require "cask/artifact/vst3_plugin" @@ -18,7 +22,12 @@ require "cask/artifact/preflight_block" require "cask/artifact/prefpane" require "cask/artifact/qlplugin" +require "cask/artifact/mdimporter" require "cask/artifact/screen_saver" +require "cask/artifact/bashcompletion" +require "cask/artifact/fishcompletion" +require "cask/artifact/generated_completion" +require "cask/artifact/zshcompletion" require "cask/artifact/service" require "cask/artifact/stage_only" require "cask/artifact/suite" @@ -26,6 +35,29 @@ require "cask/artifact/zap" module Cask + # Module containing all cask artifact classes. module Artifact + MACOS_ONLY_ARTIFACTS = [ + ::Cask::Artifact::App, + ::Cask::Artifact::AudioUnitPlugin, + ::Cask::Artifact::Colorpicker, + ::Cask::Artifact::Dictionary, + ::Cask::Artifact::InputMethod, + ::Cask::Artifact::InternetPlugin, + ::Cask::Artifact::KeyboardLayout, + ::Cask::Artifact::Mdimporter, + ::Cask::Artifact::Pkg, + ::Cask::Artifact::Prefpane, + ::Cask::Artifact::Qlplugin, + ::Cask::Artifact::ScreenSaver, + ::Cask::Artifact::Service, + ::Cask::Artifact::Suite, + ::Cask::Artifact::VstPlugin, + ::Cask::Artifact::Vst3Plugin, + ].freeze + + LINUX_ONLY_ARTIFACTS = [ + ::Cask::Artifact::AppImage, + ].freeze end end diff --git a/Library/Homebrew/cask/artifact/abstract_artifact.rb b/Library/Homebrew/cask/artifact/abstract_artifact.rb index 5f82ecbb08672..ad1dc17f3abd1 100644 --- a/Library/Homebrew/cask/artifact/abstract_artifact.rb +++ b/Library/Homebrew/cask/artifact/abstract_artifact.rb @@ -1,29 +1,56 @@ +# typed: strict # frozen_string_literal: true +require "extend/object/deep_dup" +require "env_config" +require "sandbox" +require "tempfile" +require "tmpdir" +require "utils/output" + module Cask module Artifact + # Abstract superclass for all artifacts. class AbstractArtifact + extend T::Helpers + extend ::Utils::Output::Mixin + + abstract! + include Comparable - extend Predicable + include ::Utils::Output::Mixin + # T.anything or the union of all possible argument types would be better choice, but it's convenient to be + # able to invoke `.inspect`, `.to_s`, etc. without the overhead of type guards. + DirectivesType = T.type_alias { Object } + sig { overridable.returns(String) } def self.english_name - @english_name ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1 \2') + @english_name ||= T.let(T.must(name).sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1 \2'), T.nilable(String)) end + sig { returns(String) } def self.english_article - @english_article ||= (english_name =~ /^[aeiou]/i) ? "an" : "a" + @english_article ||= T.let(/^[aeiou]/i.match?(english_name) ? "an" : "a", T.nilable(String)) end + sig { overridable.returns(Symbol) } def self.dsl_key - @dsl_key ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym + @dsl_key ||= T.let(T.must(name).sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym, + T.nilable(Symbol)) end + sig { overridable.returns(Symbol) } def self.dirmethod - @dirmethod ||= "#{dsl_key}dir".to_sym + @dirmethod ||= T.let(:"#{dsl_key}dir", T.nilable(Symbol)) end + sig { abstract.returns(String) } + def summarize; end + + sig { params(path: T.any(String, Pathname)).returns(Pathname) } def staged_path_join_executable(path) path = Pathname(path) + path = path.expand_path if path.to_s.start_with?("~") absolute_path = if path.absolute? path @@ -40,56 +67,88 @@ def staged_path_join_executable(path) end end - def <=>(other) - return unless other.class < AbstractArtifact - return 0 if self.class == other.class - - @@sort_order ||= [ # rubocop:disable Style/ClassVars - PreflightBlock, - # The `uninstall` stanza should be run first, as it may - # depend on other artifacts still being installed. - Uninstall, - Installer, - # `pkg` should be run before `binary`, so - # targets are created prior to linking. - # `pkg` should be run before `app`, since an `app` could - # contain a nested installer (e.g. `wireshark`). - Pkg, + sig { returns(T::Hash[T.class_of(AbstractArtifact), Integer]) } + def sort_order + @sort_order ||= T.let( [ - App, - Suite, - Artifact, - Colorpicker, - Prefpane, - Qlplugin, - Dictionary, - Font, - Service, - InputMethod, - InternetPlugin, - AudioUnitPlugin, - VstPlugin, - Vst3Plugin, - ScreenSaver, - ], - Binary, - Manpage, - PostflightBlock, - Zap, - ].each_with_index.flat_map { |classes, i| [*classes].map { |c| [c, i] } }.to_h - - (@@sort_order[self.class] <=> @@sort_order[other.class]).to_i + PreflightBlock, + PreflightSteps, + UninstallPreflightSteps, + # The `uninstall` stanza should be run first, as it may + # depend on other artifacts still being installed. + Uninstall, + Installer, + # `pkg` should be run before `binary`, so + # targets are created prior to linking. + # `pkg` should be run before `app`, since an `app` could + # contain a nested installer (e.g. `wireshark`). + Pkg, + [ + App, + Suite, + Artifact, + Colorpicker, + Prefpane, + Qlplugin, + Mdimporter, + Dictionary, + Font, + Service, + InputMethod, + InternetPlugin, + KeyboardLayout, + AudioUnitPlugin, + VstPlugin, + Vst3Plugin, + ScreenSaver, + ], + Binary, + Manpage, + [ + BashCompletion, + FishCompletion, + ZshCompletion, + ], + PostflightSteps, + UninstallPostflightSteps, + PostflightBlock, + Zap, + ].each_with_index.flat_map { |classes, i| Array(classes).map { |c| [c, i] } }.to_h, + T.nilable(T::Hash[T.class_of(AbstractArtifact), Integer]), + ) + end + + sig { override.params(other: BasicObject).returns(T.nilable(Integer)) } + def <=>(other) + case other + when AbstractArtifact + return 0 if instance_of?(other.class) + + (sort_order[self.class] <=> sort_order[other.class]).to_i + end end # TODO: this sort of logic would make more sense in dsl.rb, or a # constructor called from dsl.rb, so long as that isn't slow. + sig { + params( + arguments: DirectivesType, + stanza: T.any(String, Symbol), + default_arguments: T::Hash[Symbol, T.anything], + override_arguments: T::Hash[Symbol, T.anything], + key: T.nilable(Symbol), + ).returns([T.nilable(String), T::Hash[Symbol, T.untyped]]) + } def self.read_script_arguments(arguments, stanza, default_arguments = {}, override_arguments = {}, key = nil) # TODO: when stanza names are harmonized with class names, # stanza may not be needed as an explicit argument description = key ? "#{stanza} #{key.inspect}" : stanza.to_s - # backward-compatible string value - arguments = { executable: arguments } if arguments.is_a?(String) + arguments = case arguments + when String then { executable: arguments } # backward-compatible string value + when Hash then arguments.dup # Avoid mutating the original argument + else odie "Unsupported arguments type #{arguments.class}" + end # key sanity permitted_keys = [:args, :input, :executable, :must_succeed, :sudo, :print_stdout, :print_stderr] @@ -117,19 +176,84 @@ def self.read_script_arguments(arguments, stanza, default_arguments = {}, overri [executable, arguments] end + sig { returns(Cask) } attr_reader :cask - def initialize(cask) + sig { params(cask: Cask, dsl_args: T.anything).void } + def initialize(cask, *dsl_args) @cask = cask + @dirmethod = T.let(nil, T.nilable(Symbol)) + @dsl_args = T.let(dsl_args.deep_dup, T::Array[T.anything]) + @dsl_key = T.let(nil, T.nilable(Symbol)) + @english_article = T.let(nil, T.nilable(String)) + @english_name = T.let(nil, T.nilable(String)) + @sort_order = T.let(nil, T.nilable(T::Hash[T.class_of(AbstractArtifact), Integer])) end + sig { returns(Config) } def config cask.config end + sig { returns(T.nilable(Sandbox)) } + def cask_sandbox + Sandbox.ensure_sandbox_installed! + return unless Sandbox.available? + + Sandbox.new.tap do |sandbox| + sandbox.allow_read(path: cask.staged_path, type: :subpath) + sandbox.allow_write_temp_and_cache + sandbox.deny_read_home + sandbox.deny_all_network + end + end + + sig { + params( + env: T::Hash[String, T.any(String, T::Boolean, PATH)], + args: T::Array[T.any(String, Pathname)], + home: String, + ).returns(T::Array[T.any(String, Pathname)]) + } + def cask_sandbox_command(env, args, home:) + env = { "HOME" => home }.merge(env) + ["/usr/bin/env", *env.map { |key, value| "#{key}=#{value}" }, *args] + end + + sig { + params( + sandbox: Sandbox, + args: T::Array[T.any(String, Pathname)], + input: T.any(String, T::Array[String]), + ).void + } + def run_cask_sandbox(sandbox, args, input: []) + return sandbox.run(*args) if Array(input).empty? + + Tempfile.create("homebrew-cask-script-input", HOMEBREW_TEMP) do |input_file| + input_file.write(Array(input).join) + input_file.close + sandbox.allow_read(path: input_file.path) + sandbox.run( + "/bin/sh", + "-c", + "input=$1; shift; exec \"$@\" < \"$input\"", + "sh", + input_file.path, + *args, + ) + end + end + + sig { returns(String) } def to_s "#{summarize} (#{self.class.english_name})" end + + sig { returns(T::Array[T.anything]) } + def to_args + @dsl_args.compact_blank + end end end end diff --git a/Library/Homebrew/cask/artifact/abstract_flight_block.rb b/Library/Homebrew/cask/artifact/abstract_flight_block.rb index 3eb47fe82f67e..789f5d07547f9 100644 --- a/Library/Homebrew/cask/artifact/abstract_flight_block.rb +++ b/Library/Homebrew/cask/artifact/abstract_flight_block.rb @@ -1,48 +1,62 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_artifact" module Cask module Artifact + # Abstract superclass for block artifacts. class AbstractFlightBlock < AbstractArtifact + sig { override.returns(Symbol) } def self.dsl_key super.to_s.sub(/_block$/, "").to_sym end + sig { returns(Symbol) } def self.uninstall_dsl_key - dsl_key.to_s.prepend("uninstall_").to_sym + :"uninstall_#{dsl_key}" end + sig { returns(T::Hash[Symbol, DirectivesType]) } attr_reader :directives + sig { params(cask: Cask, directives: DirectivesType).void } def initialize(cask, **directives) super(cask) @directives = directives end - def install_phase(**) + sig { params(_options: T.anything).void } + def install_phase(**_options) abstract_phase(self.class.dsl_key) end - def uninstall_phase(**) + sig { params(_options: T.anything).void } + def uninstall_phase(**_options) abstract_phase(self.class.uninstall_dsl_key) end + sig { override.returns(String) } + def summarize + directives.keys.join(", ") + end + private + sig { params(dsl_key: Symbol).returns(T::Class[::Cask::DSL::Base]) } def class_for_dsl_key(dsl_key) namespace = self.class.name.to_s.sub(/::.*::.*$/, "") + # The DSL class name is derived dynamically from the flight block's key. + # rubocop:disable Sorbet/ConstantsFromStrings self.class.const_get("#{namespace}::DSL::#{dsl_key.to_s.split("_").map(&:capitalize).join}") + # rubocop:enable Sorbet/ConstantsFromStrings end + sig { params(dsl_key: Symbol).void } def abstract_phase(dsl_key) return if (block = directives[dsl_key]).nil? - class_for_dsl_key(dsl_key).new(cask).instance_eval(&block) - end - - def summarize - directives.keys.map(&:to_s).join(", ") + class_for_dsl_key(dsl_key).new(cask).instance_eval(&T.cast(block, T.proc.returns(T.anything))) end end end diff --git a/Library/Homebrew/cask/artifact/abstract_uninstall.rb b/Library/Homebrew/cask/artifact/abstract_uninstall.rb index a58552f7866fd..73d9e78becd98 100644 --- a/Library/Homebrew/cask/artifact/abstract_uninstall.rb +++ b/Library/Homebrew/cask/artifact/abstract_uninstall.rb @@ -1,15 +1,22 @@ +# typed: strict # frozen_string_literal: true require "timeout" require "utils/user" require "cask/artifact/abstract_artifact" -require "extend/hash_validator" -using HashValidator +require "cask/pkg" +require "cask/utils" +require "cask/utils/trash" +require "extend/hash/keys" +require "system_command" module Cask module Artifact + # Abstract superclass for uninstall artifacts. class AbstractUninstall < AbstractArtifact + include SystemCommand::Mixin + ORDERED_DIRECTIVES = [ :early_script, :launchctl, @@ -24,121 +31,205 @@ class AbstractUninstall < AbstractArtifact :rmdir, ].freeze + METADATA_KEYS = [ + :on_upgrade, + ].freeze + + sig { params(cask: Cask, directives: DirectivesType).returns(AbstractUninstall) } def self.from_args(cask, **directives) - new(cask, directives) + new(cask, **directives) end + sig { returns(T::Hash[Symbol, DirectivesType]) } attr_reader :directives - def initialize(cask, directives) - directives.assert_valid_keys!(*ORDERED_DIRECTIVES) + sig { params(cask: Cask, directives: DirectivesType).void } + def initialize(cask, **directives) + directives.assert_valid_keys(*ORDERED_DIRECTIVES, *METADATA_KEYS) - super(cask) - directives[:signal] = [*directives[:signal]].flatten.each_slice(2).to_a + super + directives[:signal] = Array(directives[:signal]).flatten.each_slice(2).to_a @directives = directives + # This is already included when loading from the API. + return if cask.loaded_from_api? return unless directives.key?(:kext) cask.caveats do + T.bind(self, ::Cask::DSL::Caveats) kext end end + sig { returns(T::Hash[Symbol, DirectivesType]) } def to_h directives.to_h end + sig { override.returns(String) } def summarize - to_h.flat_map { |key, val| [*val].map { |v| "#{key.inspect} => #{v.inspect}" } }.join(", ") + to_h.flat_map { |key, val| Array(val).map { |v| "#{key.inspect} => #{v.inspect}" } }.join(", ") + end + + sig { returns(T::Array[String]) } + def bundle_ids_to_reopen + @bundle_ids_to_reopen ||= T.let([], T.nilable(T::Array[String])) end private + sig { params(options: DirectivesType).void } def dispatch_uninstall_directives(**options) ORDERED_DIRECTIVES.each do |directive_sym| dispatch_uninstall_directive(directive_sym, **options) end end + sig { params(directive_sym: Symbol, options: T.anything).void } def dispatch_uninstall_directive(directive_sym, **options) return unless directives.key?(directive_sym) args = directives[directive_sym] - send("uninstall_#{directive_sym}", *(args.is_a?(Hash) ? [args] : args), **options) + send(:"uninstall_#{directive_sym}", *(args.is_a?(Hash) ? [args] : args), **options) end + sig { returns(Symbol) } def stanza self.class.dsl_key end # Preserve prior functionality of script which runs first. Should rarely be needed. # :early_script should not delete files, better defer that to :script. - # If Cask writers never need :early_script it may be removed in the future. + # If cask writers never need :early_script it may be removed in the future. + sig { params(directives: DirectivesType, options: T.anything).void } def uninstall_early_script(directives, **options) uninstall_script(directives, directive_name: :early_script, **options) end # :launchctl must come before :quit/:signal for cases where app would instantly re-launch - def uninstall_launchctl(*services, command: nil, **_) + sig { params(services: String, command: T.class_of(SystemCommand), _kwargs: T.anything).void } + def uninstall_launchctl(*services, command:, **_kwargs) + booleans = [false, true] + + all_services = [] + + # if launchctl item contains a wildcard, find matching process(es) services.each do |service| + all_services << service unless service.include?("*") + next unless service.include?("*") + + found_services = find_launchctl_with_wildcard(service) + next if found_services.blank? + + found_services.each { |found_service| all_services << found_service } + end + + all_services.each do |service| ohai "Removing launchctl service #{service}" - [false, true].each do |with_sudo| + booleans.each do |sudo| plist_status = command.run( "/bin/launchctl", - args: ["list", service], - sudo: with_sudo, print_stderr: false + args: ["list", service], + sudo:, + sudo_as_root: sudo, + print_stderr: false, ).stdout - if plist_status.match?(/^\{/) - command.run!("/bin/launchctl", args: ["remove", service], sudo: with_sudo) + if plist_status.start_with?("{") + result = command.run( + "/bin/launchctl", + args: ["remove", service], + must_succeed: false, + sudo:, + sudo_as_root: sudo, + ) + next unless result.success? + sleep 1 end paths = [ - +"/Library/LaunchAgents/#{service}.plist", - +"/Library/LaunchDaemons/#{service}.plist", + "/Library/LaunchAgents/#{service}.plist", + "/Library/LaunchDaemons/#{service}.plist", ] - paths.each { |elt| elt.prepend(ENV["HOME"]).freeze } unless with_sudo + paths.each { |elt| elt.prepend(Dir.home).freeze } unless sudo paths = paths.map { |elt| Pathname(elt) }.select(&:exist?) paths.each do |path| - command.run!("/bin/rm", args: ["-f", "--", path], sudo: with_sudo) + command.run("/bin/rm", args: ["-f", "--", path], must_succeed: false, sudo:, sudo_as_root: sudo) end # undocumented and untested: pass a path to uninstall :launchctl next unless Pathname(service).exist? - command.run!("/bin/launchctl", args: ["unload", "-w", "--", service], sudo: with_sudo) - command.run!("/bin/rm", args: ["-f", "--", service], sudo: with_sudo) + command.run( + "/bin/launchctl", + args: ["unload", "-w", "--", service], + must_succeed: false, + sudo:, + sudo_as_root: sudo, + ) + command.run( + "/bin/rm", + args: ["-f", "--", service], + must_succeed: false, + sudo:, + sudo_as_root: sudo, + ) sleep 1 end end end + sig { params(bundle_id: String).returns(T::Array[[Integer, Integer, T.nilable(String)]]) } def running_processes(bundle_id) system_command!("/bin/launchctl", args: ["list"]) - .stdout.lines + .stdout.lines.drop(1) .map { |line| line.chomp.split("\t") } .map { |pid, state, id| [pid.to_i, state.to_i, id] } .select do |(pid, _, id)| - pid.nonzero? && id.match?(/^#{Regexp.escape(bundle_id)}($|\.\d+)/) + pid.nonzero? && /\A(?:application\.)?#{Regexp.escape(bundle_id)}(?:\.\d+){0,2}\Z/.match?(id) + end + end + + sig { params(search: String).returns(T::Array[String]) } + def find_launchctl_with_wildcard(search) + regex = Regexp.escape(search).gsub("\\*", ".*") + system_command!("/bin/launchctl", args: ["list"]) + .stdout.lines.drop(1) # skip stdout column headers + .filter_map do |line| + pid, _state, id = line.chomp.split(/\s+/) + id if pid.to_i.nonzero? && T.must(id).match?(regex) end end + sig { returns(String) } def automation_access_instructions - "Enable Automation Access for “Terminal > System Events” in " \ - "“System Preferences > Security > Privacy > Automation” if you haven't already." + <<~EOS + Enable Automation access for "Terminal → System Events" in: + #{::Cask::Utils.privacy_security_preference_pane("Automation")} + if you haven't already. + EOS end # :quit/:signal must come before :kext so the kext will not be in use by a running process - def uninstall_quit(*bundle_ids, command: nil, **_) + sig { + params( + bundle_ids: String, + command: T.nilable(T.class_of(SystemCommand)), + upgrade: T::Boolean, + _kwargs: T.anything, + ).void + } + def uninstall_quit(*bundle_ids, command: nil, upgrade: false, **_kwargs) bundle_ids.each do |bundle_id| next unless running?(bundle_id) - unless User.current.gui? + unless T.must(User.current).gui? opoo "Not logged into a GUI; skipping quitting application ID '#{bundle_id}'." next end ohai "Quitting application '#{bundle_id}'..." + quit_succeeded = T.let(false, T::Boolean) begin Timeout.timeout(10) do Kernel.loop do @@ -147,15 +238,19 @@ def uninstall_quit(*bundle_ids, command: nil, **_) next if running?(bundle_id) puts "Application '#{bundle_id}' quit successfully." + quit_succeeded = true break end end rescue Timeout::Error opoo "Application '#{bundle_id}' did not quit. #{automation_access_instructions}" end + + bundle_ids_to_reopen << bundle_id if upgrade && quit_succeeded end end + sig { params(bundle_id: String).returns(T::Boolean) } def running?(bundle_id) script = <<~JAVASCRIPT 'use strict'; @@ -175,9 +270,10 @@ def running?(bundle_id) JAVASCRIPT system_command("osascript", args: ["-l", "JavaScript", "-e", script, bundle_id], - print_stderr: true).status.success? + print_stderr: true).status.success? || false end + sig { params(bundle_id: String).returns(SystemCommand::Result) } def quit(bundle_id) script = <<~JAVASCRIPT 'use strict'; @@ -205,14 +301,17 @@ def quit(bundle_id) private :quit # :signal should come after :quit so it can be used as a backup when :quit fails - def uninstall_signal(*signals, command: nil, **_) + sig { + params(signals: [String, String], command: T.nilable(T.class_of(SystemCommand)), _kwargs: T.anything).void + } + def uninstall_signal(*signals, command: nil, **_kwargs) signals.each do |pair| - raise CaskInvalidError.new(cask, "Each #{stanza} :signal must consist of 2 elements.") unless pair.size == 2 + raise CaskInvalidError.new(cask, "Each #{stanza} :signal must consist of 2 elements.") if pair.size != 2 signal, bundle_id = pair ohai "Signalling '#{signal}' to application ID '#{bundle_id}'" pids = running_processes(bundle_id).map(&:first) - next unless pids.any? + next if pids.none? # Note that unlike :quit, signals are sent from the current user (not # upgraded to the superuser). This is a todo item for the future, but @@ -220,14 +319,27 @@ def uninstall_signal(*signals, command: nil, **_) # misapplied "kill" by root could bring down the system. The fact that we # learned the pid from AppleScript is already some degree of protection, # though indirect. + # TODO: check the user that owns the PID and don't try to kill those from other users. odebug "Unix ids are #{pids.inspect} for processes with bundle identifier #{bundle_id}" - Process.kill(signal, *pids) + begin + Process.kill(signal, *pids) + rescue Errno::EPERM => e + opoo "Failed to kill #{bundle_id} PIDs #{pids.join(", ")} with signal #{signal}: #{e}" + end sleep 3 end end - def uninstall_login_item(*login_items, command: nil, upgrade: false, **_) - return if upgrade + sig { + params( + login_items: T.any(String, T::Hash[Symbol, T.any(String, Pathname)]), + command: T.nilable(T.class_of(SystemCommand)), + successor: T.nilable(Cask), + _kwargs: T.anything, + ).void + } + def uninstall_login_item(*login_items, command: nil, successor: nil, **_kwargs) + return if successor apps = cask.artifacts.select { |a| a.class.dsl_key == :app } derived_login_items = apps.map { |a| { path: a.target } } @@ -256,23 +368,54 @@ def uninstall_login_item(*login_items, command: nil, upgrade: false, **_) end # :kext should be unloaded before attempting to delete the relevant file - def uninstall_kext(*kexts, command: nil, **_) + sig { params(kexts: String, command: T.nilable(T.class_of(SystemCommand)), _kwargs: T.anything).void } + def uninstall_kext(*kexts, command: nil, **_kwargs) kexts.each do |kext| ohai "Unloading kernel extension #{kext}" - is_loaded = system_command!("/usr/sbin/kextstat", args: ["-l", "-b", kext], sudo: true).stdout + is_loaded = system_command!( + "/usr/sbin/kextstat", + args: ["-l", "-b", kext], + sudo: true, + sudo_as_root: true, + ).stdout if is_loaded.length > 1 - system_command!("/sbin/kextunload", args: ["-b", kext], sudo: true) + system_command!( + "/sbin/kextunload", + args: ["-b", kext], + sudo: true, + sudo_as_root: true, + ) sleep 1 end - system_command!("/usr/sbin/kextfind", args: ["-b", kext], sudo: true).stdout.chomp.lines.each do |kext_path| + found_kexts = system_command!( + "/usr/sbin/kextfind", + args: ["-b", kext], + sudo: true, + sudo_as_root: true, + ).stdout.chomp.lines + found_kexts.each do |kext_path| ohai "Removing kernel extension #{kext_path}" - system_command!("/bin/rm", args: ["-rf", kext_path], sudo: true) + system_command!( + "/bin/rm", + args: ["-rf", kext_path], + sudo: true, + sudo_as_root: true, + ) end end end # :script must come before :pkgutil, :delete, or :trash so that the script file is not already deleted - def uninstall_script(directives, directive_name: :script, force: false, command: nil, **_) + sig { + params( + directives: DirectivesType, + command: T.class_of(SystemCommand), + directive_name: Symbol, + force: T::Boolean, + _kwargs: T.anything, + ).void + } + def uninstall_script(directives, command:, directive_name: :script, force: false, **_kwargs) # TODO: Create a common `Script` class to run this and Artifact::Installer. executable, script_arguments = self.class.read_script_arguments(directives, "uninstall", @@ -286,20 +429,21 @@ def uninstall_script(directives, directive_name: :script, force: false, command: executable_path = staged_path_join_executable(executable) if (executable_path.absolute? && !executable_path.exist?) || - (!executable_path.absolute? && (which executable_path).nil?) + (!executable_path.absolute? && which(executable_path.to_s).nil?) message = "uninstall script #{executable} does not exist" raise CaskError, "#{message}." unless force - opoo "#{message}, skipping." + opoo "#{message}; skipping." return end - command.run(executable_path, script_arguments) + command.run(executable_path, **script_arguments) sleep 1 end - def uninstall_pkgutil(*pkgs, command: nil, **_) - ohai "Uninstalling packages:" + sig { params(pkgs: String, command: T.class_of(SystemCommand), _kwargs: T.anything).void } + def uninstall_pkgutil(*pkgs, command:, **_kwargs) + ohai "Uninstalling packages with `sudo` (which may request your password)..." pkgs.each do |regex| ::Cask::Pkg.all_matching(regex, command).each do |pkg| puts pkg.package_id @@ -308,29 +452,50 @@ def uninstall_pkgutil(*pkgs, command: nil, **_) end end - def each_resolved_path(action, paths) + # This returns T::Enumerable[[Pathname, T::Array[Pathname]]] when called without a block, + # but sorbet doesn't support overloads. + sig { + params( + action: Symbol, + paths: T::Array[T.any(Pathname, String)], + _block: T.nilable(T.proc.params(path: T.any(Pathname, String), resolved_paths: T::Array[Pathname]).void), + ).returns(T.untyped) + } + def each_resolved_path(action, paths, &_block) return enum_for(:each_resolved_path, action, paths) unless block_given? paths.each do |path| - resolved_path = Pathname.new(path) - - resolved_path = resolved_path.expand_path if path.to_s.start_with?("~") + resolved_path = Pathname.new(path.to_s.sub(%r{^~(?=(/|$))}, Dir.home)) - if resolved_path.relative? || resolved_path.split.any? { |part| part.to_s == ".." } + if resolved_path.relative? opoo "Skipping #{Formatter.identifier(action)} for relative path '#{path}'." next end - if MacOS.undeletable?(resolved_path) - opoo "Skipping #{Formatter.identifier(action)} for undeletable path '#{path}'." + if resolved_path.each_filename.any? { |part| [".", ".."].include?(part) } + opoo "Skipping #{Formatter.identifier(action)} for path with relative segments '#{path}'." next end - yield path, Pathname.glob(resolved_path) + begin + resolved_paths = Pathname.glob(resolved_path).reject do |target| + next false unless undeletable?(target) + + opoo "Skipping #{Formatter.identifier(action)} for undeletable path '#{target}'." + true + end + yield path, resolved_paths + rescue Errno::EPERM + raise if ::Cask::Utils.full_disk_access_enabled? + + odie "Unable to remove some files. Please enable Full Disk Access for your terminal under " \ + "#{::Cask::Utils.privacy_security_preference_pane("Full Disk Access")}." + end end end - def uninstall_delete(*paths, command: nil, **_) + sig { params(paths: T.any(Pathname, String), command: T.class_of(SystemCommand), _kwargs: T.anything).void } + def uninstall_delete(*paths, command:, **_kwargs) return if paths.empty? ohai "Removing files:" @@ -345,61 +510,95 @@ def uninstall_delete(*paths, command: nil, **_) end end + sig { params(paths: T.any(Pathname, String), options: T.anything).void } def uninstall_trash(*paths, **options) return if paths.empty? resolved_paths = each_resolved_path(:trash, paths).to_a - ohai "Trashing files:" - puts resolved_paths.map(&:first) + ohai "Trashing files:", resolved_paths.map(&:first) trash_paths(*resolved_paths.flat_map(&:last), **options) end - def trash_paths(*paths, command: nil, **_) + sig { + params(paths: Pathname, command: T.nilable(T.class_of(SystemCommand)), _kwargs: T.anything) + .returns(T.nilable([T::Array[String], T::Array[String]])) + } + def trash_paths(*paths, command: nil, **_kwargs) return if paths.empty? - stdout, stderr, = system_command HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", - args: paths, - print_stderr: false - - trashed = stdout.split(":").sort - untrashable = stderr.split(":").sort + trashed, untrashable = ::Cask::Utils::Trash.trash(*paths, command:) return trashed, untrashable if untrashable.empty? - untrashable.delete_if do |path| - Utils.gain_permissions(path, ["-R"], SystemCommand) do - system_command! HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", - args: [path], - print_stderr: false + opoo "The following files could not be trashed, please do so manually:" + $stderr.puts untrashable + + [trashed, untrashable] + end + + sig { params(directories: Pathname).returns(T::Boolean) } + def all_dirs?(*directories) + directories.all?(&:directory?) + end + + sig { params(directories: Pathname, command: T.class_of(SystemCommand), _kwargs: T.anything).void } + def recursive_rmdir(*directories, command:, **_kwargs) + directories.all? do |resolved_path| + puts resolved_path.sub(Dir.home, "~") + + if resolved_path.readable? + children = resolved_path.children + + next false unless children.all? { |child| child.directory? || child.basename.to_s == ".DS_Store" } + else + lines = command.run!("/bin/ls", args: ["-A", "-F", "--", resolved_path], sudo: true, print_stderr: false) + .stdout.lines.map(&:chomp) + .flat_map(&:chomp) + + # Using `-F` above outputs directories ending with `/`. + next false unless lines.all? { |l| l.end_with?("/") || l == ".DS_Store" } + + children = lines.map { |l| resolved_path/l.delete_suffix("/") } end - true - rescue - false - end + # Directory counts as empty if it only contains a `.DS_Store`. + if children.include?(ds_store = resolved_path/".DS_Store") + Utils.gain_permissions_remove(ds_store, command:) + children.delete(ds_store) + end - opoo "The following files could not trashed, please do so manually:" - $stderr.puts untrashable + next false unless recursive_rmdir(*children, command:) - [trashed, untrashable] + begin + Utils.gain_permissions_rmdir(resolved_path, command:) + rescue Errno::ENOTEMPTY, ErrorDuringExecution + next false + end + + true + end end - def uninstall_rmdir(*directories, command: nil, **_) + sig { params(directories: T.any(Pathname, String), kwargs: T.anything).void } + def uninstall_rmdir(*directories, **kwargs) return if directories.empty? ohai "Removing directories if empty:" - each_resolved_path(:rmdir, directories) do |path, resolved_paths| - puts path - resolved_paths.select(&:directory?).each do |resolved_path| - if (ds_store = resolved_path.join(".DS_Store")).exist? - command.run!("/bin/rm", args: ["-f", "--", ds_store], sudo: true, print_stderr: false) - end - command.run("/bin/rmdir", args: ["--", resolved_path], sudo: true, print_stderr: false) - end + each_resolved_path(:rmdir, directories) do |_path, resolved_paths| + next unless resolved_paths.all?(&:directory?) + + recursive_rmdir(*resolved_paths, **kwargs) end end + + sig { params(target: Pathname).returns(T::Boolean) } + def undeletable?(target) + !target.parent.writable? + end end end end + +require "extend/os/cask/artifact/abstract_uninstall" diff --git a/Library/Homebrew/cask/artifact/app.rb b/Library/Homebrew/cask/artifact/app.rb index 99bf5fcbb5254..dbae46760ccfb 100644 --- a/Library/Homebrew/cask/artifact/app.rb +++ b/Library/Homebrew/cask/artifact/app.rb @@ -1,10 +1,43 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `app` stanza. class App < Moved + sig { + override.params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + successor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).void + } + def install_phase( + adopt: false, + auto_updates: false, + force: false, + verbose: false, + predecessor: nil, + successor: nil, + reinstall: false, + command: SystemCommand + ) + super + + return if target.ascend.none? { OS::Mac.system_dir?(it) } + + odebug "Fixing up '#{target}' permissions for installation to '#{target.parent}'" + # Ensure that globally installed applications can be accessed by all users. + # We shell out to `chmod` instead of using `FileUtils.chmod` so that using `+X` works correctly. + command.run!("chmod", args: ["-R", "a+rX", target], sudo: !target.writable?) + end end end end diff --git a/Library/Homebrew/cask/artifact/appimage.rb b/Library/Homebrew/cask/artifact/appimage.rb new file mode 100644 index 0000000000000..edf00aa98c979 --- /dev/null +++ b/Library/Homebrew/cask/artifact/appimage.rb @@ -0,0 +1,34 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/symlinked" + +module Cask + module Artifact + class AppImage < Symlinked + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) + Pathname.new("#{config.appimagedir}/#{target}") + end + + sig { + override.params( + force: T::Boolean, + adopt: T::Boolean, + command: T.class_of(SystemCommand), + _options: T.anything, + ).void + } + def link(force: false, adopt: false, command: SystemCommand, **_options) + super + return if source.executable? + + if source.writable? + FileUtils.chmod "+x", source + else + command.run!("chmod", args: ["+x", source], sudo: true) + end + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/artifact.rb b/Library/Homebrew/cask/artifact/artifact.rb index 1252f40f03405..82d3b2e27c41d 100644 --- a/Library/Homebrew/cask/artifact/artifact.rb +++ b/Library/Homebrew/cask/artifact/artifact.rb @@ -1,37 +1,37 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" -require "extend/hash_validator" -using HashValidator - module Cask module Artifact + # Generic artifact corresponding to the `artifact` stanza. class Artifact < Moved + sig { override.returns(String) } def self.english_name "Generic Artifact" end - def self.from_args(cask, *args) - source_string, target_hash = args - - raise CaskInvalidError.new(cask.token, "no source given for #{english_name}") if source_string.nil? - - unless target_hash.is_a?(Hash) - raise CaskInvalidError.new(cask.token, "target required for #{english_name} '#{source_string}'") + sig { + override.params( + cask: Cask, + source: T.any(String, Pathname), + options: T.untyped, # required due to https://github.com/sorbet/sorbet/issues/10114 + ).returns(T.attached_class) + } + def self.from_args(cask, source, options = nil) + raise CaskInvalidError.new(cask.token, "No source provided for #{english_name}.") if source.blank? + + unless options&.key?(:target) + raise CaskInvalidError.new(cask.token, "#{english_name} '#{source}' requires a target.") end - target_hash.assert_valid_keys!(:target) - - new(cask, source_string, **target_hash) - end - - def resolve_target(target) - Pathname(target) + new(cask, source, **options) end - def initialize(cask, source, target: nil) - super(cask, source, target: target) + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) + super end end end diff --git a/Library/Homebrew/cask/artifact/audio_unit_plugin.rb b/Library/Homebrew/cask/artifact/audio_unit_plugin.rb index b027d294d3c5d..0a8b71eaa8310 100644 --- a/Library/Homebrew/cask/artifact/audio_unit_plugin.rb +++ b/Library/Homebrew/cask/artifact/audio_unit_plugin.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `audio_unit_plugin` stanza. class AudioUnitPlugin < Moved end end diff --git a/Library/Homebrew/cask/artifact/bashcompletion.rb b/Library/Homebrew/cask/artifact/bashcompletion.rb new file mode 100644 index 0000000000000..c3f235489bf24 --- /dev/null +++ b/Library/Homebrew/cask/artifact/bashcompletion.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/shellcompletion" + +module Cask + module Artifact + # Artifact corresponding to the `bash_completion` stanza. + class BashCompletion < ShellCompletion + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) + name = if File.extname(target).nil? + target + else + new_name = File.basename(target, File.extname(target)) + odebug "Renaming completion #{target} to #{new_name}" + + new_name + end + + config.bash_completion/name + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/binary.rb b/Library/Homebrew/cask/artifact/binary.rb index 39c525762e15d..ef04f59449bc0 100644 --- a/Library/Homebrew/cask/artifact/binary.rb +++ b/Library/Homebrew/cask/artifact/binary.rb @@ -1,18 +1,28 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/symlinked" module Cask module Artifact + # Artifact corresponding to the `binary` stanza. class Binary < Symlinked - def link(command: nil, **options) - super(command: command, **options) + sig { + override.params( + force: T::Boolean, + adopt: T::Boolean, + command: T.class_of(SystemCommand), + _options: T.anything, + ).void + } + def link(force: false, adopt: false, command: SystemCommand, **_options) + super return if source.executable? if source.writable? FileUtils.chmod "+x", source else - command.run!("/bin/chmod", args: ["+x", source], sudo: true) + command.run!("chmod", args: ["+x", source], sudo: true) end end end diff --git a/Library/Homebrew/cask/artifact/colorpicker.rb b/Library/Homebrew/cask/artifact/colorpicker.rb index 627260c333b50..a0d1bc7b5fe35 100644 --- a/Library/Homebrew/cask/artifact/colorpicker.rb +++ b/Library/Homebrew/cask/artifact/colorpicker.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `colorpicker` stanza. class Colorpicker < Moved end end diff --git a/Library/Homebrew/cask/artifact/dictionary.rb b/Library/Homebrew/cask/artifact/dictionary.rb index fe2c2b65b141b..a247c0949e9a3 100644 --- a/Library/Homebrew/cask/artifact/dictionary.rb +++ b/Library/Homebrew/cask/artifact/dictionary.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `dictionary` stanza. class Dictionary < Moved end end diff --git a/Library/Homebrew/cask/artifact/fishcompletion.rb b/Library/Homebrew/cask/artifact/fishcompletion.rb new file mode 100644 index 0000000000000..8db8721af26fd --- /dev/null +++ b/Library/Homebrew/cask/artifact/fishcompletion.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/shellcompletion" + +module Cask + module Artifact + # Artifact corresponding to the `fish_completion` stanza. + class FishCompletion < ShellCompletion + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) + name = if target.to_s.end_with? ".fish" + target + else + new_name = "#{File.basename(target, File.extname(target))}.fish" + odebug "Renaming completion #{target} to #{new_name}" + + new_name + end + + config.fish_completion/name + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/font.rb b/Library/Homebrew/cask/artifact/font.rb index 3f9f385189394..2902156de6e1b 100644 --- a/Library/Homebrew/cask/artifact/font.rb +++ b/Library/Homebrew/cask/artifact/font.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `font` stanza. class Font < Moved end end diff --git a/Library/Homebrew/cask/artifact/generated_completion.rb b/Library/Homebrew/cask/artifact/generated_completion.rb new file mode 100644 index 0000000000000..1f30fb3048083 --- /dev/null +++ b/Library/Homebrew/cask/artifact/generated_completion.rb @@ -0,0 +1,187 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/abstract_artifact" +require "cask/artifact/bashcompletion" +require "cask/artifact/fishcompletion" +require "cask/artifact/zshcompletion" +require "extend/hash/keys" +require "tempfile" +require "utils/shell_completion" + +module Cask + module Artifact + # Artifact corresponding to the `generate_completions_from_executable` stanza. + class GeneratedCompletion < AbstractArtifact + SUPPORTED_SHELLS = [:bash, :zsh, :fish, :pwsh].freeze + + sig { override.returns(Symbol) } + def self.dsl_key + :generate_completions_from_executable + end + + sig { + params( + cask: Cask, + args: T.any(Pathname, String), + base_name: T.nilable(String), + shell_parameter_format: T.nilable(T.any(Symbol, String)), + shells: T.nilable(T::Array[T.any(Symbol, String)]), + ).returns(T.attached_class) + } + def self.from_args(cask, *args, base_name: nil, shell_parameter_format: nil, shells: nil) + raise CaskInvalidError.new(cask.token, "'#{dsl_key}' requires at least one command") if args.empty? + + commands = args.to_a + resolved_shells = (shells || ::Utils::ShellCompletion.default_completion_shells(shell_parameter_format)) + .map(&:to_sym) + + unsupported_shells = resolved_shells - SUPPORTED_SHELLS + unless unsupported_shells.empty? + raise CaskInvalidError.new( + cask.token, + "'#{dsl_key}' does not support shell(s): #{unsupported_shells.join(", ")}", + ) + end + + new( + cask, + commands, + base_name:, + shell_parameter_format:, + shells: resolved_shells, + ) + end + + sig { + params( + cask: Cask, + commands: T::Array[T.any(Pathname, String)], + base_name: T.nilable(String), + shell_parameter_format: T.nilable(T.any(Symbol, String)), + shells: T::Array[Symbol], + ).void + } + def initialize(cask, commands, base_name:, shell_parameter_format:, shells:) + super(cask, *commands, base_name:, shell_parameter_format:, shells:) + + @commands = commands + @base_name = base_name + @shell_parameter_format = shell_parameter_format + @shells = shells + @resolved_base_name = T.let(nil, T.nilable(String)) + end + + sig { returns(T::Array[T.any(Pathname, String)]) } + attr_reader :commands + + sig { returns(T.nilable(String)) } + attr_reader :base_name + + sig { returns(T.nilable(T.any(Symbol, String))) } + attr_reader :shell_parameter_format + + sig { returns(T::Array[Symbol]) } + attr_reader :shells + + sig { override.returns(String) } + def summarize + "#{commands.join(" ")} (base_name: #{resolved_base_name}, shells: #{shells.join(", ")})" + end + + sig { params(_options: T.untyped).void } + def install_phase(**_options) + executable = staged_path_join_executable(T.must(commands.first)) + + shells.each do |shell| + popen_read_env = { "SHELL" => shell.to_s } + shell_parameter = ::Utils::ShellCompletion.completion_shell_parameter( + shell_parameter_format, shell, executable.to_s, popen_read_env + ) + + script_path = completion_script_path(shell) + script_path.dirname.mkpath + script_path.write(generate_completion_output([executable, *commands[1..]], shell_parameter, popen_read_env)) + rescue => e + opoo "Failed to generate #{shell} completions from #{executable}: #{e}" + end + end + + sig { params(command: T.class_of(SystemCommand), _options: T.untyped).void } + def uninstall_phase(command: SystemCommand, **_options) + shells.each do |shell| + path = completion_script_path(shell) + next unless path.exist? + + Utils.gain_permissions_remove(path, command:) + rescue => e + opoo "Failed to remove #{shell} generated completions: #{e}" + end + end + + private + + sig { + params( + completion_commands: T::Array[T.any(Pathname, String)], + shell_parameter: T.nilable(T.any(String, T::Array[String])), + env: T::Hash[String, String], + ).returns(String) + } + def generate_completion_output(completion_commands, shell_parameter, env) + sandbox = cask_sandbox + unless sandbox + return ::Utils::ShellCompletion.generate_completion_output(completion_commands, shell_parameter, + env) + end + + Tempfile.create("homebrew-cask-completions", HOMEBREW_TEMP) do |output| + Dir.mktmpdir("homebrew-cask-home") do |home| + sandbox.run( + *cask_sandbox_command( + env, + [ + "/bin/sh", + "-c", + "output=$1; shift; exec \"$@\" > \"$output\"#{" 2>/dev/null" unless ENV["HOMEBREW_STDERR"]}", + "sh", + output.path, + *(completion_commands + Array(shell_parameter)), + ], + home:, + ), + ) + end + output.read + end + end + + sig { returns(String) } + def resolved_base_name + @resolved_base_name ||= T.let(begin + executable = staged_path_join_executable(T.must(commands.first)) + name = base_name || File.basename(executable.to_s) + name = cask.token if name.empty? + name + end, T.nilable(String)) + @resolved_base_name + end + + sig { params(shell: Symbol).returns(Pathname) } + def completion_script_path(shell) + case shell + when :bash + BashCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name) + when :zsh + ZshCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name) + when :fish + FishCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name) + when :pwsh + HOMEBREW_PREFIX/"share/pwsh/completions"/"_#{resolved_base_name}.ps1" + else + raise ArgumentError, "unsupported shell: #{shell}" + end + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/input_method.rb b/Library/Homebrew/cask/artifact/input_method.rb index 80249518443ef..347efba7d5e22 100644 --- a/Library/Homebrew/cask/artifact/input_method.rb +++ b/Library/Homebrew/cask/artifact/input_method.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `input_method` stanza. class InputMethod < Moved end end diff --git a/Library/Homebrew/cask/artifact/install_steps.rb b/Library/Homebrew/cask/artifact/install_steps.rb new file mode 100644 index 0000000000000..9c1ceac781152 --- /dev/null +++ b/Library/Homebrew/cask/artifact/install_steps.rb @@ -0,0 +1,75 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/abstract_artifact" +require "install_steps" + +module Cask + module Artifact + class AbstractInstallSteps < AbstractArtifact + abstract! + + sig { params(cask: Cask, steps: Homebrew::InstallSteps::Steps).void } + def initialize(cask, steps) + super + @steps = T.let(Homebrew::InstallSteps::DSL.normalise_steps(steps), Homebrew::InstallSteps::Steps) + end + + sig { returns(Homebrew::InstallSteps::Steps) } + attr_reader :steps + + sig { override.returns(T::Array[T.anything]) } + def to_args = [{ steps: }] + + sig { override.returns(String) } + def summarize + ::Utils.pluralize("install step", steps.length, include_count: true) + end + + private + + sig { returns(Homebrew::InstallSteps::Runner) } + def runner + Homebrew::InstallSteps::Runner.new(context: cask) + end + end + + class PreflightSteps < AbstractInstallSteps + sig { params(_options: T.anything).void } + def install_phase(**_options) + runner.run(steps) + end + + sig { params(_options: T.anything).void } + def uninstall_phase(**_options) + runner.run(steps, phase: :uninstall) + end + end + + class PostflightSteps < AbstractInstallSteps + sig { params(_options: T.anything).void } + def install_phase(**_options) + runner.run(steps) + end + + sig { params(_options: T.anything).void } + def uninstall_phase(**_options) + runner.run(steps, phase: :uninstall) + end + end + + class UninstallPreflightSteps < AbstractInstallSteps + sig { params(_options: T.anything).void } + def uninstall_phase(**_options) + runner.run(steps) + end + end + + class UninstallPostflightSteps < AbstractInstallSteps + sig { params(_options: T.anything).void } + def uninstall_phase(**_options) + runner.run(steps) + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/installer.rb b/Library/Homebrew/cask/artifact/installer.rb index e1be5f5054cd0..423c3c71359b4 100644 --- a/Library/Homebrew/cask/artifact/installer.rb +++ b/Library/Homebrew/cask/artifact/installer.rb @@ -1,30 +1,26 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_artifact" - -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" module Cask module Artifact + # Artifact corresponding to the `installer` stanza. class Installer < AbstractArtifact - VALID_KEYS = Set.new([ - :manual, - :script, - ]).freeze - - module ManualInstaller - def install_phase(**) + VALID_KEYS = T.let(Set.new([ + :manual, + :script, + ]).freeze, T::Set[Symbol]) + + sig { params(command: T.class_of(SystemCommand), _options: T.anything).void } + def install_phase(command: SystemCommand, **_options) + if manual_install puts <<~EOS - To complete the installation of Cask #{cask}, you must also - run the installer at: - '#{cask.staged_path.join(path)}' + Cask #{cask} only provides a manual installer. To run it and complete the installation: + open #{cask.staged_path.join(path).to_s.shellescape} EOS - end - end - - module ScriptInstaller - def install_phase(command: nil, **_) + else ohai "Running #{self.class.dsl_key} script '#{path}'" executable_path = staged_path_join_executable(path) @@ -32,13 +28,15 @@ def install_phase(command: nil, **_) command.run!( executable_path, **args, - env: { "PATH" => PATH.new( - HOMEBREW_PREFIX/"bin", HOMEBREW_PREFIX/"sbin", ENV["PATH"] + env: { "PATH" => PATH.new( + HOMEBREW_PREFIX/"bin", HOMEBREW_PREFIX/"sbin", ENV.fetch("PATH") ) }, + reset_uid: !args[:sudo], ) end end + sig { params(cask: Cask, args: T.untyped).returns(T.attached_class) } def self.from_args(cask, **args) raise CaskInvalidError.new(cask, "'installer' stanza requires an argument.") if args.empty? @@ -52,45 +50,53 @@ def self.from_args(cask, **args) args = { script: args } end - unless args.keys.count == 1 + if args.keys.count != 1 raise CaskInvalidError.new( cask, "invalid 'installer' stanza: Only one of #{VALID_KEYS.inspect} is permitted.", ) end - args.assert_valid_keys!(*VALID_KEYS) + args.assert_valid_keys(*VALID_KEYS) new(cask, **args) end - attr_reader :path, :args + sig { returns(Pathname) } + attr_reader :path + + sig { returns(T::Hash[Symbol, T.untyped]) } + attr_reader :args + sig { returns(T::Boolean) } + attr_reader :manual_install + + sig { params(cask: Cask, args: T.untyped).void } def initialize(cask, **args) - super(cask) + super if args.key?(:manual) - @path = Pathname(args[:manual]) - @args = [] - extend(ManualInstaller) - return - end - - path, @args = self.class.read_script_arguments( - args[:script], self.class.dsl_key.to_s, { must_succeed: true, sudo: false }, print_stdout: true - ) - raise CaskInvalidError.new(cask, "#{self.class.dsl_key} missing executable") if path.nil? + @path = T.let(Pathname(args[:manual]), Pathname) + @args = T.let({}, T::Hash[Symbol, T.untyped]) + @manual_install = T.let(true, T::Boolean) + else + script_path, script_args = self.class.read_script_arguments( + args[:script], self.class.dsl_key.to_s, { must_succeed: true, sudo: false }, print_stdout: true + ) + raise CaskInvalidError.new(cask, "#{self.class.dsl_key} missing executable") if script_path.nil? - @path = Pathname(path) - extend(ScriptInstaller) + @path = T.let(Pathname(script_path), Pathname) + @args = T.let(script_args, T::Hash[Symbol, T.untyped]) + @manual_install = T.let(false, T::Boolean) + end end - def summarize - path.to_s - end + sig { override.returns(String) } + def summarize = path.to_s + sig { returns(T::Hash[Symbol, T.untyped]) } def to_h - { path: path }.tap do |h| - h[:args] = args unless is_a?(ManualInstaller) + { path: }.tap do |h| + h[:args] = args unless manual_install end end end diff --git a/Library/Homebrew/cask/artifact/internet_plugin.rb b/Library/Homebrew/cask/artifact/internet_plugin.rb index 21dd0b37f10af..a603b14200037 100644 --- a/Library/Homebrew/cask/artifact/internet_plugin.rb +++ b/Library/Homebrew/cask/artifact/internet_plugin.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `internet_plugin` stanza. class InternetPlugin < Moved end end diff --git a/Library/Homebrew/cask/artifact/keyboard_layout.rb b/Library/Homebrew/cask/artifact/keyboard_layout.rb new file mode 100644 index 0000000000000..ddfb89725291c --- /dev/null +++ b/Library/Homebrew/cask/artifact/keyboard_layout.rb @@ -0,0 +1,59 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/moved" + +module Cask + module Artifact + # Artifact corresponding to the `keyboard_layout` stanza. + class KeyboardLayout < Moved + sig { + override.params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + successor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).void + } + def install_phase(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, + successor: nil, reinstall: false, command: SystemCommand) + super + delete_keyboard_layout_cache(command:) + end + + sig { + override.params( + skip: T::Boolean, + force: T::Boolean, + adopt: T::Boolean, + verbose: T::Boolean, + successor: T.nilable(Cask), + upgrade: T::Boolean, + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).void + } + def uninstall_phase(skip: false, force: false, adopt: false, verbose: false, successor: nil, upgrade: false, + reinstall: false, command: SystemCommand) + super + delete_keyboard_layout_cache(command:) + end + + private + + sig { params(command: T.class_of(SystemCommand)).void } + def delete_keyboard_layout_cache(command: SystemCommand) + command.run!( + "/bin/rm", + args: ["-f", "--", "/System/Library/Caches/com.apple.IntlDataCache.le*"], + sudo: true, + sudo_as_root: true, + ) + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/manpage.rb b/Library/Homebrew/cask/artifact/manpage.rb index 942a83c43846d..17ec39d8a39d2 100644 --- a/Library/Homebrew/cask/artifact/manpage.rb +++ b/Library/Homebrew/cask/artifact/manpage.rb @@ -1,27 +1,39 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/symlinked" module Cask module Artifact + # Artifact corresponding to the `manpage` stanza. class Manpage < Symlinked + sig { returns(String) } attr_reader :section - def self.from_args(cask, source) - section = source.to_s[/\.([1-8]|n|l)$/, 1] + sig { + override.params( + cask: Cask, + source: T.any(String, Pathname), + _target_hash: T.anything, + ).returns(T.attached_class) + } + def self.from_args(cask, source, _target_hash = nil) + section = source.to_s[/\.([1-8]|n|l)(?:\.gz)?$/, 1] raise CaskInvalidError, "'#{source}' is not a valid man page name" unless section new(cask, source, section) end + sig { params(cask: Cask, source: T.any(String, Pathname), section: String).void } def initialize(cask, source, section) @section = section super(cask, source) end - def resolve_target(target) + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) config.manpagedir.join("man#{section}", target) end end diff --git a/Library/Homebrew/cask/artifact/mdimporter.rb b/Library/Homebrew/cask/artifact/mdimporter.rb new file mode 100644 index 0000000000000..6e1768f11f059 --- /dev/null +++ b/Library/Homebrew/cask/artifact/mdimporter.rb @@ -0,0 +1,41 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/moved" + +module Cask + module Artifact + # Artifact corresponding to the `mdimporter` stanza. + class Mdimporter < Moved + sig { override.returns(String) } + def self.english_name + "Spotlight metadata importer" + end + + sig { + override.params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + options: T.anything, + ).void + } + def install_phase(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, + reinstall: false, command: SystemCommand, **options) + super + reload_spotlight(command:, **options) + end + + private + + sig { params(command: T.class_of(SystemCommand), _options: T.anything).void } + def reload_spotlight(command:, **_options) + command.run!("/usr/bin/mdimport", args: ["-r", target]) + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/moved.rb b/Library/Homebrew/cask/artifact/moved.rb index 7774bff8ec110..b8e597e004952 100644 --- a/Library/Homebrew/cask/artifact/moved.rb +++ b/Library/Homebrew/cask/artifact/moved.rb @@ -1,22 +1,53 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/relocated" +require "cask/quarantine" module Cask module Artifact + # Superclass for all artifacts that are installed by moving them to the target location. class Moved < Relocated + sig { returns(String) } def self.english_description "#{english_name}s" end - def install_phase(**options) - move(**options) + sig { + overridable.params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + successor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).void + } + def install_phase(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, + successor: nil, reinstall: false, command: SystemCommand) + move(adopt:, auto_updates:, force:, verbose:, predecessor:, successor:, reinstall:, command:) end - def uninstall_phase(**options) - move_back(**options) + sig { + overridable.params( + skip: T::Boolean, + force: T::Boolean, + adopt: T::Boolean, + verbose: T::Boolean, + successor: T.nilable(Cask), + upgrade: T::Boolean, + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).void + } + def uninstall_phase(skip: false, force: false, adopt: false, verbose: false, successor: nil, upgrade: false, + reinstall: false, command: SystemCommand) + move_back(skip:, force:, adopt:, successor:, command:) end + sig { returns(String) } def summarize_installed if target.exist? "#{printable_target} (#{target.abv})" @@ -27,48 +58,150 @@ def summarize_installed private - def move(force: false, command: nil, **options) - if Utils.path_occupied?(target) - message = "It seems there is already #{self.class.english_article} " \ - "#{self.class.english_name} at '#{target}'" - raise CaskError, "#{message}." unless force - - opoo "#{message}; overwriting." - delete(target, force: force, command: command, **options) - end - + sig { + params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + successor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + ).returns(T.nilable(SystemCommand::Result)) + } + def move(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, successor: nil, + reinstall: false, command: SystemCommand) unless source.exist? raise CaskError, "It seems the #{self.class.english_name} source '#{source}' is not there." end - ohai "Moving #{self.class.english_name} '#{source.basename}' to '#{target}'." - if target.dirname.ascend.find(&:directory?).writable? - target.dirname.mkpath - else - command.run!("/bin/mkdir", args: ["-p", target.dirname], sudo: true) + if Utils.path_occupied?(target) + if target.directory? && target.children.empty? && matching_artifact?(predecessor) + # An upgrade removed the directory contents but left the directory itself (see below). + unless source.directory? + if target.parent.writable? && !force + target.rmdir + else + Utils.gain_permissions_remove(target, command:) + end + end + else + if adopt + ohai "Adopting existing #{self.class.english_name} at '#{target}'" + + unless auto_updates + source_plist = Pathname("#{source}/Contents/Info.plist") + target_plist = Pathname("#{target}/Contents/Info.plist") + same = if source_plist.size? && + (source_bundle_version = Homebrew::BundleVersion.from_info_plist(source_plist)) && + target_plist.size? && + (target_bundle_version = Homebrew::BundleVersion.from_info_plist(target_plist)) + if source_bundle_version.short_version == target_bundle_version.short_version + if source_bundle_version.version == target_bundle_version.version + true + else + onoe "The bundle version of #{source} is #{source_bundle_version.version} but " \ + "is #{target_bundle_version.version} for #{target}!" + false + end + else + onoe "The bundle short version of #{source} is #{source_bundle_version.short_version} but " \ + "is #{target_bundle_version.short_version} for #{target}!" + false + end + else + command.run( + "/usr/bin/diff", + args: ["--recursive", "--brief", source, target], + verbose:, + print_stdout: verbose, + ).success? + end + + unless same + raise CaskError, + "It seems the existing #{self.class.english_name} is different from " \ + "the one being installed." + end + end + + # Remove the source as we don't need to move it to the target location + FileUtils.rm_r(source) + + return post_move(command) + end + + message = "It seems there is already #{self.class.english_article} " \ + "#{self.class.english_name} at '#{target}'" + raise CaskError, "#{message}." if !force && !adopt + + opoo "#{message}; overwriting." + delete(target, force:, successor:, command:) + end end - if target.dirname.writable? + ohai "Moving #{self.class.english_name} '#{source.basename}' to '#{target}'" + + Utils.gain_permissions_mkpath(target.dirname, command:) unless target.dirname.exist? + + if target.directory? && Quarantine.app_management_permissions_granted?(app: target, command:) + if target.writable? + source.children.each { |child| FileUtils.move(child, target/child.basename) } + else + command.run!("/bin/cp", args: ["-pR", *source.children, target], + sudo: true) + end + Quarantine.copy_xattrs(source, target, command:) + FileUtils.rm_r(source) + elsif target.dirname.writable? FileUtils.move(source, target) else - command.run!("/bin/mv", args: [source, target], sudo: true) + # default sudo user isn't necessarily able to write to Homebrew's locations + # e.g. with runas_default set in the sudoers (5) file. + command.run!("/bin/cp", args: ["-pR", source, target], sudo: true) + FileUtils.rm_r(source) end + post_move(command) + end + + # Performs any actions necessary after the source has been moved to the target location. + sig { params(command: T.class_of(SystemCommand)).returns(T.nilable(SystemCommand::Result)) } + def post_move(command) FileUtils.ln_sf target, source - add_altname_metadata(target, source.basename, command: command) + add_altname_metadata(target, source.basename, command:) + end + + sig { params(cask: T.nilable(Cask)).returns(T::Boolean) } + def matching_artifact?(cask) + return false unless cask + + cask.artifacts.any? do |a| + a.instance_of?(self.class) && instance_of?(a.class) && a.target == target + end end - def move_back(skip: false, force: false, command: nil, **options) + sig { + params( + skip: T::Boolean, + force: T::Boolean, + adopt: T::Boolean, + command: T.class_of(SystemCommand), + successor: T.nilable(Cask), + ).void + } + def move_back(skip: false, force: false, adopt: false, command: SystemCommand, successor: nil) FileUtils.rm source if source.symlink? && source.dirname.join(source.readlink) == target if Utils.path_occupied?(source) message = "It seems there is already #{self.class.english_article} " \ "#{self.class.english_name} at '#{source}'" - raise CaskError, "#{message}." unless force + raise CaskError, "#{message}." if !force && !adopt opoo "#{message}; overwriting." - delete(source, force: force, command: command, **options) + delete(source, force:, successor:, command:) end unless target.exist? @@ -77,27 +210,62 @@ def move_back(skip: false, force: false, command: nil, **options) raise CaskError, "It seems the #{self.class.english_name} source '#{target}' is not there." end - ohai "Backing #{self.class.english_name} '#{target.basename}' up to '#{source}'." + ohai "Backing up #{self.class.english_name} '#{target.basename}' to '#{source}'" source.dirname.mkpath # We need to preserve extended attributes between copies. - command.run!("/bin/cp", args: ["-pR", target, source], sudo: !target.parent.writable?) + # This may fail and need sudo if the source has files with restricted permissions. + [!source.parent.writable?, true].uniq.each do |sudo| + result = command.run( + "/bin/cp", + args: backup_copy_args(target, source), + must_succeed: sudo, + sudo:, + ) + break if result.success? + end - delete(target, force: force, command: command, **options) + delete(target, force:, successor:, command:) end - def delete(target, force: false, command: nil, **_) - ohai "Removing #{self.class.english_name} '#{target}'." - raise CaskError, "Cannot remove undeletable #{self.class.english_name}." if MacOS.undeletable?(target) + sig { + params( + target: Pathname, + force: T::Boolean, + successor: T.nilable(Cask), + command: T.class_of(SystemCommand), + ).void + } + def delete(target, force: false, successor: nil, command: SystemCommand) + ohai "Removing #{self.class.english_name} '#{target}'" + raise CaskError, "Cannot remove undeletable #{self.class.english_name}." if undeletable?(target) return unless Utils.path_occupied?(target) - if target.parent.writable? && !force - target.rmtree + if target.directory? && matching_artifact?(successor) && Quarantine.app_management_permissions_granted?( + app: target, command:, + ) + # If an app folder is deleted, macOS considers the app uninstalled and removes some data. + # Remove only the contents to handle this case. + target.children.each do |child| + Utils.gain_permissions_remove(child, command:) + end else - Utils.gain_permissions_remove(target, command: command) + Utils.gain_permissions_remove(target, command:) end end + + sig { params(target: Pathname).returns(T::Boolean) } + def undeletable?(target) + !target.parent.writable? + end + + sig { overridable.params(target: Pathname, source: Pathname).returns(T::Array[T.any(String, Pathname)]) } + def backup_copy_args(target, source) + ["-pR", target, source] + end end end end + +require "extend/os/cask/artifact/moved" diff --git a/Library/Homebrew/cask/artifact/pkg.rb b/Library/Homebrew/cask/artifact/pkg.rb index b5414cf21f23d..e4ef341db606f 100644 --- a/Library/Homebrew/cask/artifact/pkg.rb +++ b/Library/Homebrew/cask/artifact/pkg.rb @@ -1,46 +1,66 @@ +# typed: strict # frozen_string_literal: true require "plist" require "utils/user" require "cask/artifact/abstract_artifact" - -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" module Cask module Artifact + # Artifact corresponding to the `pkg` stanza. class Pkg < AbstractArtifact - attr_reader :pkg_relative_path + sig { returns(Pathname) } + attr_reader :path + + sig { returns(T::Hash[Symbol, T.untyped]) } + attr_reader :stanza_options + sig { params(cask: Cask, path: T.any(String, Pathname), stanza_options: T.untyped).returns(T.attached_class) } def self.from_args(cask, path, **stanza_options) - stanza_options.assert_valid_keys!(:allow_untrusted, :choices) + # odeprecated: `allow_untrusted` disables certificate verification and is being removed. + stanza_options.assert_valid_keys(:allow_untrusted, :choices) new(cask, path, **stanza_options) end - attr_reader :path, :stanza_options - + sig { params(cask: Cask, path: T.any(String, Pathname), stanza_options: T.untyped).void } def initialize(cask, path, **stanza_options) - super(cask) - @path = cask.staged_path.join(path) + super + @path = T.let(cask.staged_path.join(path), Pathname) @stanza_options = stanza_options end + sig { override.returns(String) } def summarize path.relative_path_from(cask.staged_path).to_s end - def install_phase(**options) - run_installer(**options) + sig { + params( + command: T.class_of(SystemCommand), + verbose: T::Boolean, + _options: T.anything, + ).void + } + def install_phase(command: SystemCommand, verbose: false, **_options) + run_installer(command:, verbose:) end private - def run_installer(command: nil, verbose: false, **_options) - ohai "Running installer for #{cask}; your password may be necessary." - ohai "Package installers may write to any location; options such as --appdir are ignored." + sig { params(command: T.class_of(SystemCommand), verbose: T::Boolean).void } + def run_installer(command: SystemCommand, verbose: false) + ohai "Running installer for #{cask} with `sudo` (which may request your password)..." unless path.exist? - raise CaskError, "pkg source file not found: '#{path.relative_path_from(cask.staged_path)}'" + pkg = path.relative_path_from(cask.staged_path) + pkgs = Pathname.glob(cask.staged_path/"**"/"*.pkg").map { |path| path.relative_path_from(cask.staged_path) } + + message = "Could not find PKG source file '#{pkg}'" + message += ", found #{pkgs.map { |path| "'#{path}'" }.to_sentence} instead" if pkgs.any? + message += "." + + raise CaskError, message end args = [ @@ -48,19 +68,34 @@ def run_installer(command: nil, verbose: false, **_options) "-target", "/" ] args << "-verboseR" if verbose + # odeprecated: `allow_untrusted` disables certificate verification and is being removed. args << "-allowUntrusted" if stanza_options.fetch(:allow_untrusted, false) with_choices_file do |choices_path| args << "-applyChoiceChangesXML" << choices_path if choices_path + + current_user_str = User.current&.to_s env = { - "LOGNAME" => User.current, - "USER" => User.current, - "USERNAME" => User.current, + "LOGNAME" => current_user_str, + "USER" => current_user_str, + "USERNAME" => current_user_str, } - command.run!("/usr/sbin/installer", sudo: true, args: args, print_stdout: true, env: env) + + command.run!( + "/usr/sbin/installer", + sudo: true, + sudo_as_root: true, + args:, + print_stdout: true, + env:, + ) end end - def with_choices_file + sig { + params(_blk: T.proc.params(choices_path: T.nilable(String)).void) + .void + } + def with_choices_file(&_blk) choices = stanza_options.fetch(:choices, {}) return yield nil if choices.empty? diff --git a/Library/Homebrew/cask/artifact/postflight_block.rb b/Library/Homebrew/cask/artifact/postflight_block.rb index 43cb49f5bd13f..fb0e7b5efc2e6 100644 --- a/Library/Homebrew/cask/artifact/postflight_block.rb +++ b/Library/Homebrew/cask/artifact/postflight_block.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_flight_block" module Cask module Artifact + # Artifact corresponding to the `postflight` stanza. class PostflightBlock < AbstractFlightBlock end end diff --git a/Library/Homebrew/cask/artifact/preflight_block.rb b/Library/Homebrew/cask/artifact/preflight_block.rb index 341cdb1e0f907..d2845590356e2 100644 --- a/Library/Homebrew/cask/artifact/preflight_block.rb +++ b/Library/Homebrew/cask/artifact/preflight_block.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_flight_block" module Cask module Artifact + # Artifact corresponding to the `preflight` stanza. class PreflightBlock < AbstractFlightBlock end end diff --git a/Library/Homebrew/cask/artifact/prefpane.rb b/Library/Homebrew/cask/artifact/prefpane.rb index 393d6e5c4aacc..58008240f57c9 100644 --- a/Library/Homebrew/cask/artifact/prefpane.rb +++ b/Library/Homebrew/cask/artifact/prefpane.rb @@ -1,10 +1,13 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `prefpane` stanza. class Prefpane < Moved + sig { override.returns(String) } def self.english_name "Preference Pane" end diff --git a/Library/Homebrew/cask/artifact/qlplugin.rb b/Library/Homebrew/cask/artifact/qlplugin.rb index b9c5f8f069ab6..5ea73db2f1566 100644 --- a/Library/Homebrew/cask/artifact/qlplugin.rb +++ b/Library/Homebrew/cask/artifact/qlplugin.rb @@ -1,27 +1,59 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `qlplugin` stanza. class Qlplugin < Moved + sig { override.returns(String) } def self.english_name - "QuickLook Plugin" + "Quick Look Plugin" end - def install_phase(**options) - super(**options) - reload_quicklook(**options) + sig { + override.params( + adopt: T::Boolean, + auto_updates: T.nilable(T::Boolean), + force: T::Boolean, + verbose: T::Boolean, + predecessor: T.nilable(Cask), + successor: T.nilable(Cask), + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + options: T.anything, + ).void + } + def install_phase(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, + successor: nil, reinstall: false, command: SystemCommand, **options) + super + reload_quicklook(command:) end - def uninstall_phase(**options) - super(**options) - reload_quicklook(**options) + sig { + override.params( + skip: T::Boolean, + force: T::Boolean, + adopt: T::Boolean, + verbose: T::Boolean, + successor: T.nilable(Cask), + upgrade: T::Boolean, + reinstall: T::Boolean, + command: T.class_of(SystemCommand), + options: T.anything, + ).void + } + def uninstall_phase(skip: false, force: false, adopt: false, verbose: false, successor: nil, upgrade: false, + reinstall: false, command: SystemCommand, **options) + super + reload_quicklook(command:) end private - def reload_quicklook(command: nil, **_) + sig { params(command: T.class_of(SystemCommand)).void } + def reload_quicklook(command: SystemCommand) command.run!("/usr/bin/qlmanage", args: ["-r"]) end end diff --git a/Library/Homebrew/cask/artifact/relocated.rb b/Library/Homebrew/cask/artifact/relocated.rb index 81e47f73c9406..e9a9b49248058 100644 --- a/Library/Homebrew/cask/artifact/relocated.rb +++ b/Library/Homebrew/cask/artifact/relocated.rb @@ -1,20 +1,25 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_artifact" - -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" module Cask module Artifact + # Superclass for all artifacts which have a source and a target location. class Relocated < AbstractArtifact - def self.from_args(cask, *args) - source_string, target_hash = args - + sig { + overridable.params( + cask: Cask, + source_string: T.any(String, Pathname), + target_hash: T.untyped, + ).returns(T.attached_class) + } + def self.from_args(cask, source_string, target_hash = nil) if target_hash - raise CaskInvalidError unless target_hash.respond_to?(:keys) + raise CaskInvalidError, cask unless target_hash.respond_to?(:keys) - target_hash.assert_valid_keys!(:target) + target_hash.assert_valid_keys(:target) end target_hash ||= {} @@ -22,29 +27,54 @@ def self.from_args(cask, *args) new(cask, source_string, **target_hash) end - def resolve_target(target) - config.public_send(self.class.dirmethod).join(target) + sig { overridable.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: config.public_send(self.class.dirmethod)) + target = Pathname(target) + + if target.relative? + return target.expand_path if target.descend.first.to_s == "~" + return base_dir/target if base_dir + end + + target end - attr_reader :source, :target + sig { + params(cask: Cask, source: T.any(String, Pathname), target_hash: T.any(String, Pathname)) + .void + } + def initialize(cask, source, **target_hash) + super + + target = target_hash[:target] + @source = T.let(nil, T.nilable(Pathname)) + @source_string = T.let(source.to_s, String) + @target = T.let(nil, T.nilable(Pathname)) + @target_string = T.let(target.to_s, String) + end - def initialize(cask, source, target: nil) - super(cask) + sig { returns(Pathname) } + def source + @source ||= begin + base_path = cask.staged_path + base_path = base_path.join(T.must(cask.url).only_path) if cask.url&.only_path.present? + base_path.join(@source_string) + end + end - @source_string = source.to_s - @target_string = target.to_s - source = cask.staged_path.join(source) - @source = source - target ||= source.basename - @target = resolve_target(target) + sig { returns(Pathname) } + def target + @target ||= resolve_target(@target_string.presence || source.basename) end + sig { returns(T::Array[T.anything]) } def to_a [@source_string].tap do |ary| ary << { target: @target_string } unless @target_string.empty? end end + sig { override.returns(String) } def summarize target_string = @target_string.empty? ? "" : " -> #{@target_string}" "#{@source_string}#{target_string}" @@ -53,33 +83,41 @@ def summarize private ALT_NAME_ATTRIBUTE = "com.apple.metadata:kMDItemAlternateNames" + private_constant :ALT_NAME_ATTRIBUTE # Try to make the asset searchable under the target name. Spotlight # respects this attribute for many filetypes, but ignores it for App # bundles. Alfred 2.2 respects it even for App bundles. - def add_altname_metadata(file, altname, command: nil) - return if altname.to_s.casecmp(file.basename.to_s).zero? + sig { params(file: Pathname, altname: Pathname, command: T.class_of(SystemCommand)).returns(T.nilable(SystemCommand::Result)) } + def add_altname_metadata(file, altname, command:) + return if altname.to_s.casecmp(file.basename.to_s)&.zero? odebug "Adding #{ALT_NAME_ATTRIBUTE} metadata" altnames = command.run("/usr/bin/xattr", args: ["-p", ALT_NAME_ATTRIBUTE, file], print_stderr: false).stdout.sub(/\A\((.*)\)\Z/, '\1') - odebug "Existing metadata is: '#{altnames}'" + odebug "Existing metadata is: #{altnames}" altnames.concat(", ") unless altnames.empty? altnames.concat(%Q("#{altname}")) altnames = "(#{altnames})" # Some packages are shipped as u=rx (e.g. Bitcoin Core) - command.run!("/bin/chmod", args: ["--", "u+rw", file, file.realpath]) + command.run!("chmod", + args: ["--", "u+rw", file, file.realpath], + sudo: !file.writable? || !file.realpath.writable?) command.run!("/usr/bin/xattr", args: ["-w", ALT_NAME_ATTRIBUTE, altnames, file], - print_stderr: false) + print_stderr: false, + sudo: !file.writable?) end + sig { returns(String) } def printable_target - target.to_s.sub(/^#{ENV['HOME']}(#{File::SEPARATOR}|$)/, "~/") + target.to_s.sub(/^#{Dir.home}(#{File::SEPARATOR}|$)/, "~/") end end end end + +require "extend/os/cask/artifact/relocated" diff --git a/Library/Homebrew/cask/artifact/screen_saver.rb b/Library/Homebrew/cask/artifact/screen_saver.rb index ba304162567fd..cc9cd6249fdcb 100644 --- a/Library/Homebrew/cask/artifact/screen_saver.rb +++ b/Library/Homebrew/cask/artifact/screen_saver.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `screen_saver` stanza. class ScreenSaver < Moved end end diff --git a/Library/Homebrew/cask/artifact/service.rb b/Library/Homebrew/cask/artifact/service.rb index b42d22c43edc2..032fd78788e54 100644 --- a/Library/Homebrew/cask/artifact/service.rb +++ b/Library/Homebrew/cask/artifact/service.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `service` stanza. class Service < Moved end end diff --git a/Library/Homebrew/cask/artifact/shellcompletion.rb b/Library/Homebrew/cask/artifact/shellcompletion.rb new file mode 100644 index 0000000000000..41e1fdf1f8119 --- /dev/null +++ b/Library/Homebrew/cask/artifact/shellcompletion.rb @@ -0,0 +1,15 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/symlinked" + +module Cask + module Artifact + class ShellCompletion < Symlinked + sig { override.overridable.params(_: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(_, base_dir: nil) + raise CaskInvalidError, "Shell completion without shell info" + end + end + end +end diff --git a/Library/Homebrew/cask/artifact/stage_only.rb b/Library/Homebrew/cask/artifact/stage_only.rb index d0b2295c3884d..0d7252ee8ef4c 100644 --- a/Library/Homebrew/cask/artifact/stage_only.rb +++ b/Library/Homebrew/cask/artifact/stage_only.rb @@ -1,23 +1,30 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_artifact" module Cask module Artifact + # Artifact corresponding to the `stage_only` stanza. class StageOnly < AbstractArtifact - def self.from_args(cask, *args) - raise CaskInvalidError.new(cask.token, "'stage_only' takes only a single argument: true") if args != [true] + sig { params(cask: Cask, args: T.anything, kwargs: T.anything).returns(StageOnly) } + def self.from_args(cask, *args, **kwargs) + if (args != [true] && args != ["true"]) || kwargs.present? + raise CaskInvalidError.new(cask.token, "'stage_only' takes only a single argument: true") + end - new(cask) - end - - def initialize(cask) - super(cask) + new(cask, true) end + sig { returns(T::Array[T::Boolean]) } def to_a [true] end + + sig { override.returns(String) } + def summarize + "true" + end end end end diff --git a/Library/Homebrew/cask/artifact/suite.rb b/Library/Homebrew/cask/artifact/suite.rb index 4d8332f70949e..ffaef57bee946 100644 --- a/Library/Homebrew/cask/artifact/suite.rb +++ b/Library/Homebrew/cask/artifact/suite.rb @@ -1,14 +1,18 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `suite` stanza. class Suite < Moved + sig { override.returns(String) } def self.english_name "App Suite" end + sig { override.returns(Symbol) } def self.dirmethod :appdir end diff --git a/Library/Homebrew/cask/artifact/symlinked.rb b/Library/Homebrew/cask/artifact/symlinked.rb index 7acb7e9694d5a..67781e69092b2 100644 --- a/Library/Homebrew/cask/artifact/symlinked.rb +++ b/Library/Homebrew/cask/artifact/symlinked.rb @@ -1,26 +1,45 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/relocated" module Cask module Artifact + # Superclass for all artifacts which are installed by symlinking them to the target location. class Symlinked < Relocated + sig { returns(String) } def self.link_type_english_name "Symlink" end + sig { returns(String) } def self.english_description "#{english_name} #{link_type_english_name}s" end - def install_phase(**options) - link(**options) + sig { + params( + force: T::Boolean, + adopt: T::Boolean, + command: T.class_of(SystemCommand), + options: T.anything, + ).void + } + def install_phase(force: false, adopt: false, command: SystemCommand, **options) + link(force:, adopt:, command:, **options) end - def uninstall_phase(**options) - unlink(**options) + sig { + params( + command: T.class_of(SystemCommand), + _options: T.anything, + ).void + } + def uninstall_phase(command: SystemCommand, **_options) + unlink(command:) end + sig { returns(String) } def summarize_installed if target.symlink? && target.exist? && target.readlink.exist? "#{printable_target} -> #{target.readlink} (#{target.readlink.abv})" @@ -37,35 +56,79 @@ def summarize_installed private - def link(**options) + sig { + overridable.params( + force: T::Boolean, + adopt: T::Boolean, + command: T.class_of(SystemCommand), + _options: T.anything, + ).void + } + def link(force: false, adopt: false, command: SystemCommand, **_options) unless source.exist? raise CaskError, "It seems the #{self.class.link_type_english_name.downcase} " \ "source '#{source}' is not there." end - if target.exist? && !target.symlink? - raise CaskError, - "It seems there is already #{self.class.english_article} " \ - "#{self.class.english_name} at '#{target}'; not linking." + if target.exist? + message = "It seems there is already #{self.class.english_article} " \ + "#{self.class.english_name} at '#{target}'" + + if (force || adopt) && target.symlink? && + (target.realpath == source.realpath || target.realpath.to_s.start_with?("#{cask.caskroom_path}/")) + opoo "#{message}; overwriting." + Utils.gain_permissions_remove(target, command:) + elsif (formula = conflicting_formula) + opoo "#{message} from formula #{formula}; skipping link." + return + else + raise CaskError, "#{message}." + end end - ohai "Linking #{self.class.english_name} '#{source.basename}' to '#{target}'." - create_filesystem_link(**options) + ohai "Linking #{self.class.english_name} '#{source.basename}' to '#{target}'" + create_filesystem_link(command) end - def unlink(**) + sig { params(command: T.class_of(SystemCommand)).void } + def unlink(command: SystemCommand) return unless target.symlink? - ohai "Unlinking #{self.class.english_name} '#{target}'." - target.delete + ohai "Unlinking #{self.class.english_name} '#{target}'" + + if (formula = conflicting_formula) + odebug "#{target} is from formula #{formula}; skipping unlink." + return + end + + Utils.gain_permissions_remove(target, command:) + end + + sig { params(command: T.class_of(SystemCommand)).void } + def create_filesystem_link(command) + Utils.gain_permissions_mkpath(target.dirname, command:) + + command.run! "/bin/ln", args: ["--no-dereference", "--force", "--symbolic", source, target], + sudo: !target.dirname.writable? end - def create_filesystem_link(command: nil, **_) - target.dirname.mkpath - command.run!("/bin/ln", args: ["-h", "-f", "-s", "--", source, target]) - add_altname_metadata(source, target.basename, command: command) + # Check if the target file is a symlink that originates from a formula + # with the same name as this cask, indicating a potential conflict + sig { returns(T.nilable(String)) } + def conflicting_formula + if target.symlink? && target.exist? && + (match = target.realpath.to_s.match(%r{^#{HOMEBREW_CELLAR}/(?[^/]+)/}o)) + match[:formula] + end + rescue => e + # If we can't determine the realpath or any other error occurs, + # don't treat it as a conflicting formula file + odebug "Error checking for conflicting formula file: #{e}" + nil end end end end + +require "extend/os/cask/artifact/symlinked" diff --git a/Library/Homebrew/cask/artifact/uninstall.rb b/Library/Homebrew/cask/artifact/uninstall.rb index cdd2da2b8636d..528af40dd715b 100644 --- a/Library/Homebrew/cask/artifact/uninstall.rb +++ b/Library/Homebrew/cask/artifact/uninstall.rb @@ -1,17 +1,54 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_uninstall" module Cask module Artifact + # Artifact corresponding to the `uninstall` stanza. class Uninstall < AbstractUninstall - def uninstall_phase(**options) - ORDERED_DIRECTIVES.reject { |directive_sym| directive_sym == :rmdir } - .each do |directive_sym| - dispatch_uninstall_directive(directive_sym, **options) - end + UPGRADE_REINSTALL_SKIP_DIRECTIVES = [:signal].freeze + + sig { + params( + upgrade: T::Boolean, + reinstall: T::Boolean, + quit: T::Boolean, + options: T.anything, + ).void + } + def uninstall_phase(upgrade: false, reinstall: false, quit: true, **options) + raw_on_upgrade = directives[:on_upgrade] + on_upgrade_syms = + case raw_on_upgrade + when Symbol + [raw_on_upgrade] + when Array + raw_on_upgrade.map(&:to_sym) + else + [] + end + on_upgrade_set = on_upgrade_syms.to_set + + filtered_directives = ORDERED_DIRECTIVES.filter do |directive_sym| + next false if directive_sym == :rmdir + next false if directive_sym == :quit && !quit + + if (upgrade || reinstall) && + UPGRADE_REINSTALL_SKIP_DIRECTIVES.include?(directive_sym) && + on_upgrade_set.exclude?(directive_sym) + next false + end + + true + end + + filtered_directives.each do |directive_sym| + dispatch_uninstall_directive(directive_sym, **options, upgrade:) + end end + sig { params(options: T.anything).void } def post_uninstall_phase(**options) dispatch_uninstall_directive(:rmdir, **options) end diff --git a/Library/Homebrew/cask/artifact/vst3_plugin.rb b/Library/Homebrew/cask/artifact/vst3_plugin.rb index ea5dc05d058d0..eeed160d685d3 100644 --- a/Library/Homebrew/cask/artifact/vst3_plugin.rb +++ b/Library/Homebrew/cask/artifact/vst3_plugin.rb @@ -1,10 +1,16 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `vst3_plugin` stanza. class Vst3Plugin < Moved + sig { override.returns(String) } + def self.english_name + "VST3 Plugin" + end end end end diff --git a/Library/Homebrew/cask/artifact/vst_plugin.rb b/Library/Homebrew/cask/artifact/vst_plugin.rb index 6796e286ca7c7..6e04087ec7bec 100644 --- a/Library/Homebrew/cask/artifact/vst_plugin.rb +++ b/Library/Homebrew/cask/artifact/vst_plugin.rb @@ -1,10 +1,16 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/moved" module Cask module Artifact + # Artifact corresponding to the `vst_plugin` stanza. class VstPlugin < Moved + sig { override.returns(String) } + def self.english_name + "VST Plugin" + end end end end diff --git a/Library/Homebrew/cask/artifact/zap.rb b/Library/Homebrew/cask/artifact/zap.rb index cd1ccc736da81..8773bb367acd4 100644 --- a/Library/Homebrew/cask/artifact/zap.rb +++ b/Library/Homebrew/cask/artifact/zap.rb @@ -1,10 +1,13 @@ +# typed: strict # frozen_string_literal: true require "cask/artifact/abstract_uninstall" module Cask module Artifact + # Artifact corresponding to the `zap` stanza. class Zap < AbstractUninstall + sig { params(options: T.anything).void } def zap_phase(**options) dispatch_uninstall_directives(**options) end diff --git a/Library/Homebrew/cask/artifact/zshcompletion.rb b/Library/Homebrew/cask/artifact/zshcompletion.rb new file mode 100644 index 0000000000000..9d80204ed73ab --- /dev/null +++ b/Library/Homebrew/cask/artifact/zshcompletion.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/shellcompletion" + +module Cask + module Artifact + # Artifact corresponding to the `zsh_completion` stanza. + class ZshCompletion < ShellCompletion + sig { override.params(target: T.any(String, Pathname), base_dir: T.nilable(Pathname)).returns(Pathname) } + def resolve_target(target, base_dir: nil) + name = if target.to_s.start_with? "_" + target + else + new_name = "_#{File.basename(target, File.extname(target))}" + odebug "Renaming completion #{target} to #{new_name}" + + new_name + end + + config.zsh_completion/name + end + end + end +end diff --git a/Library/Homebrew/cask/artifact_set.rb b/Library/Homebrew/cask/artifact_set.rb new file mode 100644 index 0000000000000..8f1da460b4594 --- /dev/null +++ b/Library/Homebrew/cask/artifact_set.rb @@ -0,0 +1,24 @@ +# typed: strict +# frozen_string_literal: true + +module Cask + # Sorted set containing all cask artifacts. + class ArtifactSet < ::Set + extend T::Generic + + Elem = type_member(:out) { { fixed: Artifact::AbstractArtifact } } + + sig { params(block: T.nilable(T.proc.params(arg0: Elem).returns(T.untyped))).void } + def each(&block) + return enum_for(T.must(__method__)) { size } unless block + + to_a.each(&block) + self + end + + sig { returns(T::Array[Artifact::AbstractArtifact]) } + def to_a + super.sort + end + end +end diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index ab571db636361..457bf00766e5c 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -1,71 +1,157 @@ +# typed: strict # frozen_string_literal: true -require "cask/blacklist" -require "cask/checkable" +require "cask/denylist" require "cask/download" +require "cask/installer" +require "cask/quarantine" require "digest" +require "livecheck/livecheck" +require "source_location" +require "system_command" +require "utils/backtrace" +require "formula_name_cask_token_auditor" require "utils/curl" -require "utils/git" +require "utils/shared_audits" +require "utils/output" module Cask + # Audit a cask for various problems. class Audit - include Checkable - extend Predicable - - attr_reader :cask, :commit_range, :download + include SystemCommand::Mixin + include ::Utils::Curl + include ::Utils::Output::Mixin + + Error = T.type_alias do + { + message: T.nilable(String), + location: T.nilable(Homebrew::SourceLocation), + corrected: T::Boolean, + } + end - attr_predicate :check_appcast? + sig { returns(Cask) } + attr_reader :cask + + sig { returns(T.nilable(Download)) } + attr_reader :download + + sig { + params( + cask: ::Cask::Cask, download: T::Boolean, quarantine: T::Boolean, + online: T.nilable(T::Boolean), strict: T.nilable(T::Boolean), signing: T.nilable(T::Boolean), + new_cask: T.nilable(T::Boolean), only: T::Array[String], except: T::Array[String] + ).void + } + def initialize( + cask, + download: false, quarantine: false, + online: nil, strict: nil, signing: nil, + new_cask: nil, only: [], except: [] + ) + # `new_cask` implies `online`, `strict` and `signing` + online = new_cask if online.nil? + strict = new_cask if strict.nil? + signing = new_cask if signing.nil? + + # `online` and `signing` imply `download` + download ||= online || signing - def initialize(cask, check_appcast: false, download: false, check_token_conflicts: false, - commit_range: nil, command: SystemCommand) @cask = cask - @check_appcast = check_appcast - @download = download - @commit_range = commit_range - @check_token_conflicts = check_token_conflicts - @command = command + @download = T.let(nil, T.nilable(Download)) + @download = Download.new(cask, quarantine:) if download + @online = online + @strict = strict + @signing = signing + @new_cask = new_cask + @only = only + @except = except + @livecheck_result = T.let(nil, T.nilable(T.any(T::Boolean, Symbol))) end - def check_token_conflicts? - @check_token_conflicts - end + sig { returns(T::Boolean) } + def new_cask? = !!@new_cask + + sig { returns(T::Boolean) } + def online? =!!@online + + sig { returns(T::Boolean) } + def signing? = !!@signing + + sig { returns(T::Boolean) } + def strict? = !!@strict + sig { returns(::Cask::Audit) } def run! - check_blacklist - check_required_stanzas - check_version - check_sha256 - check_url - check_generic_artifacts - check_token_conflicts - check_download - check_https_availability - check_single_pre_postflight - check_single_uninstall_zap - check_untrusted_pkg - check_hosting_with_appcast - check_latest_with_appcast - check_latest_with_auto_updates - check_stanza_requires_uninstall - check_appcast_contains_version + only_audits = @only + except_audits = @except + + private_methods.map(&:to_s).grep(/^audit_/).each do |audit_method_name| + name = audit_method_name.delete_prefix("audit_") + next if !only_audits.empty? && only_audits.exclude?(name) + next if except_audits.include?(name) + + send(audit_method_name) + end + self rescue => e - odebug "#{e.message}\n#{e.backtrace.join("\n")}" + odebug e, ::Utils::Backtrace.clean(e) add_error "exception while auditing #{cask}: #{e.message}" self end + sig { returns(T::Array[Error]) } + def errors + @errors ||= T.let([], T.nilable(T::Array[Error])) + end + + sig { returns(T::Boolean) } + def errors? + errors.any? + end + + sig { returns(T::Boolean) } def success? - !(errors? || warnings?) + !errors? + end + + sig { + params( + message: T.nilable(String), + location: T.nilable(Homebrew::SourceLocation), + strict_only: T::Boolean, + ).void + } + def add_error(message, location: nil, strict_only: false) + # Only raise non-critical audits if the user specified `--strict`. + return if strict_only && !@strict + + errors << { message:, location:, corrected: false } end - def summary_header - "audit for #{cask}" + sig { returns(T.nilable(String)) } + def result + Formatter.error("failed") if errors? + end + + sig { returns(T.nilable(String)) } + def summary + return if success? + + summary = ["audit for #{cask}: #{result}"] + + errors.each do |error| + summary << " #{Formatter.error("-")} #{error[:message]}" + end + + summary.join("\n") end private - def check_untrusted_pkg + sig { void } + def audit_untrusted_pkg odebug "Auditing pkg stanza: allow_untrusted" return if @cask.sourcefile_path.nil? @@ -74,274 +160,1284 @@ def check_untrusted_pkg return if tap.nil? return if tap.user != "Homebrew" - return unless cask.artifacts.any? { |k| k.is_a?(Artifact::Pkg) && k.stanza_options.key?(:allow_untrusted) } + return if cask.artifacts.none? { |k| k.is_a?(Artifact::Pkg) && k.stanza_options.key?(:allow_untrusted) } - add_warning "allow_untrusted is not permitted in official Homebrew Cask taps" + add_error "allow_untrusted is not permitted in official Homebrew Cask taps" end - def check_stanza_requires_uninstall + sig { void } + def audit_stanza_requires_uninstall odebug "Auditing stanzas which require an uninstall" return if cask.artifacts.none? { |k| k.is_a?(Artifact::Pkg) || k.is_a?(Artifact::Installer) } - return if cask.artifacts.any? { |k| k.is_a?(Artifact::Uninstall) } + return if cask.artifacts.any?(Artifact::Uninstall) - add_warning "installer and pkg stanzas require an uninstall stanza" + add_error "installer and pkg stanzas require an uninstall stanza" end - def check_single_pre_postflight + sig { void } + def audit_single_pre_postflight odebug "Auditing preflight and postflight stanzas" if cask.artifacts.count { |k| k.is_a?(Artifact::PreflightBlock) && k.directives.key?(:preflight) } > 1 - add_warning "only a single preflight stanza is allowed" + add_error "only a single preflight stanza is allowed" end count = cask.artifacts.count do |k| k.is_a?(Artifact::PostflightBlock) && k.directives.key?(:postflight) end - return unless count > 1 + return if count <= 1 - add_warning "only a single postflight stanza is allowed" + add_error "only a single postflight stanza is allowed" end - def check_single_uninstall_zap + sig { void } + def audit_single_uninstall_zap odebug "Auditing single uninstall_* and zap stanzas" - if cask.artifacts.count { |k| k.is_a?(Artifact::Uninstall) } > 1 - add_warning "only a single uninstall stanza is allowed" - end - count = cask.artifacts.count do |k| k.is_a?(Artifact::PreflightBlock) && k.directives.key?(:uninstall_preflight) end - add_warning "only a single uninstall_preflight stanza is allowed" if count > 1 + add_error "only a single uninstall_preflight stanza is allowed" if count > 1 count = cask.artifacts.count do |k| k.is_a?(Artifact::PostflightBlock) && k.directives.key?(:uninstall_postflight) end - add_warning "only a single uninstall_postflight stanza is allowed" if count > 1 + add_error "only a single uninstall_postflight stanza is allowed" if count > 1 - return unless cask.artifacts.count { |k| k.is_a?(Artifact::Zap) } > 1 + return if cask.artifacts.count { |k| k.is_a?(Artifact::Zap) } <= 1 - add_warning "only a single zap stanza is allowed" + add_error "only a single zap stanza is allowed" end - def check_required_stanzas + sig { void } + def audit_required_stanzas odebug "Auditing required stanzas" [:version, :sha256, :url, :homepage].each do |sym| add_error "a #{sym} stanza is required" unless cask.send(sym) end add_error "at least one name stanza is required" if cask.name.empty? # TODO: specific DSL knowledge should not be spread around in various files like this - installable_artifacts = cask.artifacts.reject { |k| [:uninstall, :zap].include?(k) } + rejected_artifacts = [:uninstall, :zap] + installable_artifacts = cask.artifacts.reject { |k| rejected_artifacts.include?(k) } add_error "at least one activatable artifact stanza is required" if installable_artifacts.empty? end - def check_version - return unless cask.version + sig { void } + def audit_description + # Fonts seldom benefit from descriptions and requiring them disproportionately + # increases the maintenance burden. + return if cask.tap == "homebrew/cask" && cask.token.include?("font-") - check_no_string_version_latest - check_no_file_separator_in_version + add_error("Cask should have a description. Please add a `desc` stanza.", strict_only: true) if cask.desc.blank? end - def check_no_string_version_latest - odebug "Verifying version :latest does not appear as a string ('latest')" - return unless cask.version.raw_version == "latest" + sig { void } + def audit_version_special_characters + return unless cask.version - add_error "you should use version :latest instead of version 'latest'" - end + return if cask.version.latest? - def check_no_file_separator_in_version - odebug "Verifying version does not contain '#{File::SEPARATOR}'" - return unless cask.version.raw_version.is_a?(String) - return unless cask.version.raw_version.include?(File::SEPARATOR) + raw_version = cask.version.raw_version + return if raw_version.exclude?(":") && raw_version.exclude?("/") - add_error "version should not contain '#{File::SEPARATOR}'" + add_error "version should not contain colons or slashes" end - def check_sha256 - return unless cask.sha256 + sig { void } + def audit_no_string_version_latest + return unless cask.version - check_sha256_no_check_if_latest - check_sha256_actually_256 - check_sha256_invalid + odebug "Auditing version :latest does not appear as a string ('latest')" + return if cask.version.raw_version != "latest" + + add_error "you should use version :latest instead of version 'latest'" end - def check_sha256_no_check_if_latest - odebug "Verifying sha256 :no_check with version :latest" + sig { void } + def audit_sha256_no_check_if_latest + return unless cask.sha256 + return unless cask.version + + odebug "Auditing sha256 :no_check with version :latest" return unless cask.version.latest? return if cask.sha256 == :no_check add_error "you should use sha256 :no_check when version is :latest" end - def check_sha256_actually_256(sha256: cask.sha256, stanza: "sha256") - odebug "Verifying #{stanza} string is a legal SHA-256 digest" - return unless sha256.is_a?(String) - return if sha256.length == 64 && sha256[/^[0-9a-f]+$/i] + sig { void } + def audit_sha256_no_check_if_unversioned + return unless cask.sha256 + return if cask.sha256 == :no_check + + return unless cask.url&.unversioned? - add_error "#{stanza} string must be of 64 hexadecimal characters" + add_error "Use `sha256 :no_check` when URL is unversioned." end - def check_sha256_invalid(sha256: cask.sha256, stanza: "sha256") - odebug "Verifying #{stanza} is not a known invalid value" + sig { void } + def audit_sha256_actually_256 + return unless cask.sha256 + + odebug "Auditing sha256 string is a legal SHA-256 digest" + return unless cask.sha256.is_a?(Checksum) + return if cask.sha256.length == 64 && cask.sha256[/^[0-9a-f]+$/i] + + add_error "sha256 string must be of 64 hexadecimal characters" + end + + sig { void } + def audit_sha256_invalid + return unless cask.sha256 + + odebug "Auditing sha256 is not a known invalid value" empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - return unless sha256 == empty_sha256 + return if cask.sha256 != empty_sha256 - add_error "cannot use the sha256 for an empty string in #{stanza}: #{empty_sha256}" + add_error "cannot use the sha256 for an empty string: #{empty_sha256}" end - def check_latest_with_appcast - return unless cask.version.latest? - return unless cask.appcast + sig { void } + def audit_latest_with_livecheck + return unless cask.version&.latest? + return unless cask.livecheck_defined? + return if cask.livecheck.skip? - add_warning "Casks with an appcast should not use version :latest" + add_error "Casks with a `livecheck` should not use `version :latest`." end - def check_latest_with_auto_updates - return unless cask.version.latest? + sig { void } + def audit_latest_with_auto_updates + return unless cask.version&.latest? return unless cask.auto_updates - add_warning "Casks with `version :latest` should not use `auto_updates`" + add_error "Casks with `version :latest` should not use `auto_updates`." end - def check_hosting_with_appcast - return if cask.appcast + LIVECHECK_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#stanza-livecheck" + private_constant :LIVECHECK_REFERENCE_URL - add_appcast = "please add an appcast. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/appcast.md" + sig { void } + def audit_hosting_with_livecheck + return if cask.deprecated? || cask.disabled? + return if cask.version&.latest? + return if (url = cask.url).nil? + return if cask.livecheck_defined? + return if audit_livecheck_version == :auto_detected - case cask.url.to_s - when %r{github.com/([^/]+)/([^/]+)/releases/download/(\S+)} - return if cask.version.latest? + add_livecheck = "please add a livecheck. See #{Formatter.url(LIVECHECK_REFERENCE_URL)}" - add_warning "Download uses GitHub releases, #{add_appcast}" + case url.to_s when %r{sourceforge.net/(\S+)} - return if cask.version.latest? + return unless online? - add_warning "Download is hosted on SourceForge, #{add_appcast}" + add_error "Download is hosted on SourceForge, #{add_livecheck}", location: url.location when %r{dl.devmate.com/(\S+)} - add_warning "Download is hosted on DevMate, #{add_appcast}" + add_error "Download is hosted on DevMate, #{add_livecheck}", location: url.location when %r{rink.hockeyapp.net/(\S+)} - add_warning "Download is hosted on HockeyApp, #{add_appcast}" + add_error "Download is hosted on HockeyApp, #{add_livecheck}", location: url.location end end - def check_url - return unless cask.url + SOURCEFORGE_OSDN_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#sourceforgeosdn-urls" + private_constant :SOURCEFORGE_OSDN_REFERENCE_URL - check_download_url_format - end + sig { void } + def audit_download_url_format + return if (url = cask.url).nil? - def check_download_url_format odebug "Auditing URL format" - if bad_sourceforge_url? - add_warning "SourceForge URL format incorrect. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls" - elsif bad_osdn_url? - add_warning "OSDN URL format incorrect. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls" - end + return unless bad_sourceforge_url? + + add_error "SourceForge URL format incorrect. See #{Formatter.url(SOURCEFORGE_OSDN_REFERENCE_URL)}", + location: url.location end - def bad_url_format?(regex, valid_formats_array) - return false unless cask.url.to_s.match?(regex) + sig { void } + def audit_download_url_is_osdn + return if (url = cask.url).nil? + return unless bad_osdn_url? - valid_formats_array.none? { |format| cask.url.to_s =~ format } + add_error "OSDN download urls are disabled.", location: url.location, strict_only: true end - def bad_sourceforge_url? - bad_url_format?(/sourceforge/, - [ - %r{\Ahttps://sourceforge\.net/projects/[^/]+/files/latest/download\Z}, - %r{\Ahttps://downloads\.sourceforge\.net/(?!(project|sourceforge)\/)}, - ]) + VERIFIED_URL_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#when-url-and-homepage-domains-differ-add-verified" + private_constant :VERIFIED_URL_REFERENCE_URL + + sig { void } + def audit_unnecessary_verified + return unless cask.url + return unless verified_present? + return unless url_match_homepage? + return unless verified_matches_url? + + add_error "The URL's domain #{Formatter.url(domain)} matches the homepage domain " \ + "#{Formatter.url(homepage)}, the 'verified' parameter of the 'url' stanza is unnecessary. " \ + "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}" end - def bad_osdn_url? - bad_url_format?(/osd/, [%r{\Ahttps?://([^/]+.)?dl\.osdn\.jp/}]) + sig { void } + def audit_missing_verified + return unless cask.url + return if file_url? + return if url_match_homepage? + return if verified_present? + + add_error "The URL's domain #{Formatter.url(domain)} does not match the homepage domain " \ + "#{Formatter.url(homepage)}, a 'verified' parameter has to be added to the 'url' stanza. " \ + "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}" end - def check_generic_artifacts - cask.artifacts.select { |a| a.is_a?(Artifact::Artifact) }.each do |artifact| + sig { void } + def audit_no_match + return if (url = cask.url).nil? + return unless verified_present? + return if verified_matches_url? + + add_error "Verified URL #{Formatter.url(url_from_verified)} does not match URL " \ + "#{Formatter.url(strip_url_scheme(url.to_s))}. " \ + "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}", + location: url.location + end + + sig { void } + def audit_generic_artifacts + cask.artifacts.grep(Artifact::Artifact).each do |artifact| unless artifact.target.absolute? add_error "target must be absolute path for #{artifact.class.english_name} #{artifact.source}" end end end - def check_token_conflicts - return unless check_token_conflicts? - return unless core_formula_names.include?(cask.token) + sig { void } + def audit_languages + @cask.languages.each do |language| + Locale.parse(language) + rescue Locale::ParserError + add_error "Locale '#{language}' is invalid." + end + end + + sig { void } + def audit_token + token_auditor = Homebrew::FormulaNameCaskTokenAuditor.new(cask.token) + return if (errors = token_auditor.errors).none? - add_warning "possible duplicate, cask token conflicts with Homebrew core formula: #{core_formula_url}" + add_error "Cask token '#{cask.token}' must not contain #{errors.to_sentence(two_words_connector: " or ", + last_word_connector: " or ")}." end - def core_tap - @core_tap ||= CoreTap.instance + sig { void } + def audit_token_conflicts + Homebrew.with_no_api_env do + return unless core_formula_names.include?(cask.token) + + add_error("cask token conflicts with an existing homebrew/core formula: #{Formatter.url(core_formula_url)}") + end end - def core_formula_names - core_tap.formula_names + sig { void } + def audit_token_bad_words + return unless new_cask? + + token = cask.token + + add_error "cask token contains .app" if token.end_with? ".app" + + match_data = /-(?alpha|beta|rc|release-candidate)$/.match(cask.token) + if match_data && cask.tap&.official? + add_error "cask token contains version designation '#{match_data[:designation]}'" + end + + add_error("cask token mentions launcher", strict_only: true) if token.end_with? "launcher" + + add_error("cask token mentions desktop", strict_only: true) if token.end_with? "desktop" + + add_error("cask token mentions platform", strict_only: true) if token.end_with? "mac", "osx", "macos" + + add_error("cask token mentions architecture", strict_only: true) if token.end_with? "x86", "32_bit", "x86_64", + "64_bit" + + frameworks = %w[cocoa qt gtk wx java] + return if frameworks.include?(token) || !token.end_with?(*frameworks) + + add_error("cask token mentions framework", strict_only: true) end - def core_formula_url - "#{core_tap.default_remote}/blob/master/Formula/#{cask.token}.rb" + sig { void } + def audit_download + return if (download = self.download).blank? || (url = cask.url).nil? + + begin + download.fetch + rescue => e + add_error "download not possible: #{e}", location: url.location + end end - def check_download - return unless download && cask.url + sig { void } + def audit_livecheck_unneeded_long_version + return if cask.version.nil? || (url = cask.url).nil? + return if cask.livecheck.strategy != :sparkle + return unless cask.version.csv.second + return if cask.url.to_s.include? cask.version.csv.second + return if cask.version.csv.third.present? && cask.url.to_s.include?(cask.version.csv.third) + + add_error "Download does not require additional version components. Use `&:short_version` in the livecheck", + location: url.location, + strict_only: true + end - odebug "Auditing download" - downloaded_path = download.perform - Verify.all(cask, downloaded_path) - rescue => e - add_error "download not possible: #{e.message}" + sig { void } + def audit_signing + return if download.blank? + + url = cask.url + return if url.nil? + + return if !cask.tap&.official? && !signing? + return if cask.deprecated? && cask.deprecation_reason != :fails_gatekeeper_check + + unless Quarantine.available? + odebug "Quarantine support is not available, skipping signing audit" + return + end + + odebug "Auditing signing" + is_in_skiplist = cask.tap&.audit_exception(:signing_audit_skiplist, cask.token, + Homebrew::SimulateSystem.current_arch.to_s) || + cask.tap&.audit_exception(:signing_audit_skiplist, cask.token, "all") + + extract_artifacts(include_manual_installers: true) do |artifacts, tmpdir| + is_container = artifacts.any? do |artifact| + artifact.is_a?(Artifact::App) || artifact.is_a?(Artifact::Pkg) || + (artifact.is_a?(Artifact::Installer) && [".app", ".pkg"].include?(artifact.path.extname.downcase)) + end + + any_signing_failure = artifacts.any? do |artifact| + next false if artifact.is_a?(Artifact::Binary) && is_container == true + + artifact_path = case artifact + when Artifact::Pkg, Artifact::Installer + artifact.path + else + artifact.source + end + + artifact_path = artifact_path.relative_path_from(cask.staged_path) if artifact_path.absolute? + path = tmpdir/artifact_path + + unless Quarantine.detect(path) + odebug "#{path} does not have quarantine attributes, skipping signing audit" + next false + end + + result = case artifact + when Artifact::Pkg + system_command("spctl", args: ["--assess", "--type", "install", path], print_stderr: false) + when Artifact::App + next opoo "gktool not found, skipping app signing audit" unless which("gktool") + + system_command("gktool", args: ["scan", path], print_stderr: false) + when Artifact::Installer + if artifact.path.extname.downcase == ".app" + next opoo "gktool not found, skipping app signing audit" unless which("gktool") + + system_command("gktool", args: ["scan", path], print_stderr: false) + elsif artifact.path.extname.downcase == ".pkg" + system_command("spctl", args: ["--assess", "--type", "install", path], print_stderr: false) + else + next false + end + when Artifact::Binary + # Shell scripts cannot be signed, so we skip them + next false if path.text_executable? + + system_command("codesign", args: ["--verify", "-R=notarized", "--check-notarization", path], + print_stderr: false) + else + add_error "Unknown artifact type: #{artifact.class}", location: url.location + next + end + + next false if result.success? + next true if cask.deprecated? && cask.deprecation_reason == :fails_gatekeeper_check + next true if is_in_skiplist + + signing_failure_message = <<~EOS + Signature verification failed: + #{result.merged_output} + EOS + + if cask.tap&.official? + signing_failure_message += <<~EOS + The homebrew/cask tap requires all casks to be signed and notarized by Apple. + Please contact the upstream developer and ask them to sign and notarize their software. + EOS + end + + add_error signing_failure_message + + true + end + + return if any_signing_failure + + add_error "Cask is in the signing audit skiplist, but does not need to be skipped!" if is_in_skiplist + + return unless cask.deprecated? + return if cask.deprecation_reason != :fails_gatekeeper_check + + add_error <<~EOS + Cask is deprecated because it failed Gatekeeper checks but all artifacts now pass! + Remove the deprecate/disable stanza or update the deprecate/disable reason. + EOS + end + end + + sig { + params( + include_manual_installers: T::Boolean, + _block: T.nilable(T.proc.params( + arg0: T::Array[T.any(Artifact::Installer, Artifact::Pkg, Artifact::Relocated)], + arg1: Pathname, + ).void), + ).void + } + def extract_artifacts(include_manual_installers: false, &_block) + return unless online? + return if (download = self.download).nil? + + artifacts = cask.artifacts.select do |artifact| + artifact.is_a?(Artifact::Pkg) || + artifact.is_a?(Artifact::App) || + artifact.is_a?(Artifact::Binary) || + (include_manual_installers && + artifact.is_a?(Artifact::Installer) && + artifact.manual_install && + [".app", ".pkg"].include?(artifact.path.extname.downcase)) + end + + if @artifacts_extracted && @tmpdir + yield artifacts, @tmpdir if block_given? + return + end + + return if artifacts.empty? + + @tmpdir ||= T.let(Pathname(Dir.mktmpdir("cask-audit", HOMEBREW_TEMP)), T.nilable(Pathname)) + + # Clean up tmp dir when @tmpdir object is destroyed + ObjectSpace.define_finalizer( + @tmpdir, + proc { FileUtils.remove_entry(@tmpdir) }, + ) + + ohai "Downloading and extracting artifacts" + + downloaded_path = download.fetch + + primary_container = UnpackStrategy.detect(downloaded_path, type: @cask.container&.type, merge_xattrs: true) + return if primary_container.nil? + + # If the container has any dependencies we need to install them or unpacking will fail. + if primary_container.dependencies.any? + + install_options = { + show_header: true, + installed_on_request: false, + verbose: false, + }.compact + + Homebrew::Install.perform_preinstall_checks_once + formula_installers = primary_container.dependencies.filter_map do |dep| + next unless dep.is_a?(Formula) + next if dep.linked? + + FormulaInstaller.new( + dep, + **install_options, + ) + end + valid_formula_installers = Homebrew::Install.fetch_formulae(formula_installers) + + formula_installers.each do |fi| + next unless valid_formula_installers.include?(fi) + + fi.install + fi.finish + end + end + + # Extract the container to the temporary directory. + primary_container.extract_nestedly(to: @tmpdir, basename: downloaded_path.basename, verbose: false) + + if (nested_container = @cask.container&.nested) + FileUtils.chmod_R "+rw", @tmpdir/nested_container, force: true, verbose: false + UnpackStrategy.detect(@tmpdir/nested_container, merge_xattrs: true) + .extract_nestedly(to: @tmpdir, verbose: false) + end + + # Propagate quarantine attributes from the downloaded file to extracted contents. + # This is necessary because some extraction tools (like 7zr) don't preserve xattrs. + Quarantine.propagate(from: downloaded_path, to: @tmpdir) if Quarantine.detect(downloaded_path) + + # 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)) + + # Yield the artifacts and temp directory to the block if provided. + yield artifacts, @tmpdir if block_given? end - def check_appcast_contains_version - return unless check_appcast? - return if cask.appcast.to_s.empty? - return if cask.appcast.configuration == :no_check + sig { void } + def audit_rosetta + return if (url = cask.url).nil? + return unless online? + # Rosetta 2 is only for ARM-capable macOS versions, which are Big Sur (11.x) and later + return if Homebrew::SimulateSystem.current_arch != :arm + return if MacOSVersion::SYMBOLS.fetch(Homebrew::SimulateSystem.current_os, "10") < "11" + return if cask.depends_on.macos&.maximum_version.to_s < "11" + + odebug "Auditing Rosetta 2 requirement" + + extract_artifacts do |artifacts, tmpdir| + is_container = artifacts.any? { |a| a.is_a?(Artifact::App) || a.is_a?(Artifact::Pkg) } + + mentions_rosetta = cask.caveats.include?("requires Rosetta 2") + requires_intel = cask.depends_on.arch&.any? { |arch| arch[:type] == :intel } + + artifacts_to_test = artifacts.filter do |artifact| + next false if !artifact.is_a?(Artifact::App) && !artifact.is_a?(Artifact::Binary) + next false if artifact.is_a?(Artifact::Binary) && is_container + + true + end + + next if artifacts_to_test.blank? + + any_requires_rosetta = artifacts_to_test.any? do |artifact| + artifact = T.cast(artifact, T.any(Artifact::App, Artifact::Binary)) + path = tmpdir/artifact.source.relative_path_from(cask.staged_path) + + result = case artifact + when Artifact::App + files = Dir[path/"Contents/MacOS/*"].select do |f| + File.executable?(f) && !File.directory?(f) && !f.end_with?(".dylib") + end + add_error "No binaries in App: #{artifact.source}", location: url.location if files.empty? + + main_binary = get_plist_main_binary(path) + main_binary ||= files.fetch(0) + + system_command("lipo", args: ["-archs", main_binary], print_stderr: false) + when Artifact::Binary + binary_path = path.to_s.gsub(cask.appdir, tmpdir.to_s) + system_command("lipo", args: ["-archs", binary_path], print_stderr: true) + else + T.absurd(artifact) + end + + # binary stanza can contain shell scripts, so we just continue if lipo fails. + next false unless result.success? + + odebug "Architectures: #{result.merged_output}" + + unless /arm64|x86_64/.match?(result.merged_output) + add_error "Artifacts architecture is no longer supported by macOS!", + location: url.location + next + end + + result.merged_output.exclude?("arm64") && result.merged_output.include?("x86_64") + end + + if any_requires_rosetta + if !mentions_rosetta && !requires_intel + add_error "At least one artifact requires Rosetta 2 but this is not indicated by the caveats!", + location: url.location + end + elsif mentions_rosetta + add_error "No artifacts require Rosetta 2 but the caveats say otherwise!", + location: url.location + end + end + end + + sig { returns(T.nilable(T.any(T::Boolean, Symbol))) } + def audit_livecheck_version + return @livecheck_result unless @livecheck_result.nil? + return unless online? + return unless cask.version + + odebug "Auditing livecheck version" + + referenced_cask, = Homebrew::Livecheck.resolve_livecheck_reference(cask) + + # Respect skip conditions for a referenced cask + if referenced_cask + skip_info = Homebrew::Livecheck::SkipConditions.referenced_skip_information( + referenced_cask, + Homebrew::Livecheck.package_or_resource_name(cask), + ) + end + + # Respect cask skip conditions (e.g. deprecated, disabled, latest, unversioned) + skip_info ||= Homebrew::Livecheck::SkipConditions.skip_information(cask) + if skip_info.present? + @livecheck_result = :skip + return @livecheck_result + end + + result = Homebrew::Livecheck.latest_version( + cask, + referenced_formula_or_cask: referenced_cask, + ) + if result + throttle = cask.livecheck.throttle + throttle_days = cask.livecheck.throttle_days + if referenced_cask + throttle ||= referenced_cask.livecheck.throttle + throttle_days ||= referenced_cask.livecheck.throttle_days + end + + latest_version = (throttle || throttle_days) ? result[:latest_throttled] : result[:latest] + end + + if latest_version && (cask.version.to_s == latest_version.to_s) + @livecheck_result = :auto_detected + return @livecheck_result + end + + add_error "Version '#{cask.version}' differs from '#{latest_version}' retrieved by livecheck." - appcast_stanza = cask.appcast.to_s - appcast_contents, = curl_output("--compressed", "--user-agent", HOMEBREW_USER_AGENT_FAKE_SAFARI, "--location", - "--globoff", "--max-time", "5", appcast_stanza) - version_stanza = cask.version.to_s - if cask.appcast.configuration.blank? - adjusted_version_stanza = version_stanza.split(",")[0].split("-")[0].split("_")[0] + @livecheck_result = false + end + + sig { void } + def audit_min_os + return unless online? + + odebug "Auditing minimum macOS version" + + bundle_min_os = cask_bundle_min_os + sparkle_min_os = cask_sparkle_min_os + + app_min_os = [bundle_min_os, sparkle_min_os].compact.max + debug_messages = [] + debug_messages << "from artifact: #{bundle_min_os.to_sym}" if bundle_min_os + debug_messages << "from upstream: #{sparkle_min_os.to_sym}" if sparkle_min_os + odebug "Detected minimum macOS: #{app_min_os.to_sym} (#{debug_messages.join(" | ")})" if app_min_os + return if app_min_os.nil? || app_min_os <= HOMEBREW_MACOS_OLDEST_ALLOWED + + on_system_block_min_os = cask.on_system_block_min_os + depends_on_min_os = cask.depends_on.macos&.minimum_version + + cask_min_os = [on_system_block_min_os, depends_on_min_os].compact.max + debug_messages = [] + debug_messages << "from on_system block: #{on_system_block_min_os.to_sym}" if on_system_block_min_os + if depends_on_min_os > HOMEBREW_MACOS_OLDEST_ALLOWED + debug_messages << "from depends_on stanza: #{depends_on_min_os.to_sym}" + end + odebug "Declared minimum macOS: #{cask_min_os.to_sym} (#{debug_messages.join(" | ").presence || "default"})" + return if cask_min_os.to_sym == app_min_os.to_sym + # ignore declared minimum OS < 11.x when auditing as ARM a cask with arch-specific artifacts + return if OnSystem.arch_condition_met?(:arm) && + cask.on_system_blocks_exist? && + cask_min_os.present? && + app_min_os < MacOSVersion.new("11") && + app_min_os < cask_min_os + + min_os_definition = if cask_min_os > HOMEBREW_MACOS_OLDEST_ALLOWED + definition = if T.must(on_system_block_min_os.to_s <=> depends_on_min_os.to_s).positive? + "an on_system block" + else + "a depends_on stanza" + end + "#{definition} with a minimum macOS version of #{cask_min_os.to_sym.inspect}" + else + "no minimum macOS version" + end + source = T.must(bundle_min_os.to_s <=> sparkle_min_os.to_s).positive? ? "Artifact" : "Upstream" + add_error "#{source} defined #{app_min_os.to_sym.inspect} as the minimum macOS version " \ + "but the cask declared #{min_os_definition}" + end + + sig { returns(T.nilable(MacOSVersion)) } + def cask_sparkle_min_os + return unless online? + return unless cask.livecheck_defined? + return if cask.livecheck.strategy != :sparkle + + # `Sparkle` strategy blocks that use the `items` argument (instead of + # `item`) contain arbitrary logic that ignores/overrides the strategy's + # sorting, so we can't identify which item would be first/newest here. + return if cask.livecheck.strategy_block.present? && + cask.livecheck.strategy_block.parameters[0] == [:opt, :items] + + content = Homebrew::Livecheck::Strategy.page_content(cask.livecheck.url)[:content] + return if content.blank? + + begin + items = Homebrew::Livecheck::Strategy::Sparkle.sort_items( + Homebrew::Livecheck::Strategy::Sparkle.filter_items( + Homebrew::Livecheck::Strategy::Sparkle.items_from_content(content), + ), + ) + rescue + return + end + return if items.blank? + + normalize_min_os(items[0]&.minimum_system_version) + end + + sig { returns(T.nilable(MacOSVersion)) } + def cask_bundle_min_os + return unless online? + + min_os = T.let(nil, T.untyped) + @staged_path ||= T.let(cask.staged_path, T.nilable(Pathname)) + + extract_artifacts do |artifacts, tmpdir| + artifacts.each do |artifact| + next if artifact.is_a?(Artifact::Installer) + + artifact_path = artifact.is_a?(Artifact::Pkg) ? artifact.path : artifact.source + path = tmpdir/artifact_path.relative_path_from(cask.staged_path) + + # Handle .pkg artifacts by expanding and checking Distribution file + if artifact.is_a?(Artifact::Pkg) + pkg_expanded_dir = tmpdir/"pkg-expanded" + begin + system_command!("pkgutil", args: ["--expand", path.to_s, pkg_expanded_dir.to_s]) + + distribution_file = pkg_expanded_dir/"Distribution" + if File.exist?(distribution_file) + distribution_content = File.read(distribution_file) + if (match = distribution_content.match(/[^"]+)"/)) + min_os = match[:version] + break if min_os + end + end + rescue + break + end + end + + info_plist_paths = Dir.glob("#{path}/**/Contents/Info.plist") + + # Ensure the main `Info.plist` file is checked first, as this can + # sometimes use the min_os version from a framework instead + if info_plist_paths.delete("#{path}/Contents/Info.plist") + info_plist_paths.insert(0, "#{path}/Contents/Info.plist") + end + + info_plist_paths.each do |plist_path| + next unless File.exist?(plist_path) + + plist = system_command!("plutil", args: ["-convert", "xml1", "-o", "-", plist_path]).plist + min_os = plist["LSMinimumSystemVersion"].presence + break if min_os + + # Get the app bundle path from the plist path + app_bundle_path = Pathname(plist_path).dirname.dirname + next unless (main_binary = get_plist_main_binary(app_bundle_path)) + next if !File.exist?(main_binary) || File.open(main_binary, "rb") { |f| f.read(2) == "#!" } + + macho = MachO.open(main_binary) + min_os = case macho + when MachO::MachOFile + [ + macho[:LC_VERSION_MIN_MACOSX].first&.version_string, + macho[:LC_BUILD_VERSION].first&.minos_string, + ] + when MachO::FatFile + # Collect requirements by architecture + arch_min_os = { arm: [], intel: [] } + macho.machos.each do |slice| + macos_reqs = [ + slice[:LC_VERSION_MIN_MACOSX].first&.version_string, + slice[:LC_BUILD_VERSION].first&.minos_string, + ] + + case slice.cputype + when *Hardware::CPU::ARM_ARCHS + arch_min_os[:arm].concat(macos_reqs) + when *Hardware::CPU::INTEL_ARCHS + arch_min_os[:intel].concat(macos_reqs) + end + end + + # Only use the requirements for the current architecture + arch_min_os.fetch(Homebrew::SimulateSystem.current_arch, []) + end.compact.max + break if min_os + end + break if min_os + end + end + + normalize_min_os(min_os) + end + + sig { params(min_os: T.nilable(T.any(String, MacOSVersion))).returns(T.nilable(MacOSVersion)) } + def normalize_min_os(min_os) + return if min_os.nil? + return if min_os.is_a?(String) && min_os.blank? + + min_os = if min_os.is_a?(MacOSVersion) + min_os.strip_patch + else + MacOSVersion.new(min_os).strip_patch + end + + # Big Sur is sometimes identified as 10.16, so we override it to the + # expected macOS version (11). + min_os = MacOSVersion.new("11") if min_os == "10.16" + + min_os + rescue MacOSVersion::Error + nil + end + + sig { params(path: Pathname).returns(T.nilable(String)) } + def get_plist_main_binary(path) + return unless online? + + plist_path = "#{path}/Contents/Info.plist" + return unless File.exist?(plist_path) + + plist = system_command!("plutil", args: ["-convert", "xml1", "-o", "-", plist_path]).plist + binary = plist["CFBundleExecutable"].presence + return unless binary + + binary_path = "#{path}/Contents/MacOS/#{binary}" + + binary_path if File.exist?(binary_path) && File.executable?(binary_path) + end + + sig { void } + def audit_github_prerelease_version + return if (url = cask.url).nil? + + odebug "Auditing GitHub prerelease" + user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + tag = SharedAudits.github_tag_from_url(url.to_s) + tag ||= cask.version + error = SharedAudits.github_release(user, repo, tag, cask:) + add_error error, location: url.location if error + end + + sig { void } + def audit_gitlab_prerelease_version + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + odebug "Auditing GitLab prerelease" + + tag = SharedAudits.gitlab_tag_from_url(url.to_s) + tag ||= cask.version + error = SharedAudits.gitlab_release(user, repo, tag, cask:) + add_error error, location: url.location if error + end + + sig { void } + def audit_forgejo_prerelease_version + return if (url = cask.url).nil? + + odebug "Auditing Forgejo prerelease" + user, repo = get_repo_data(%r{https?://codeberg\.org/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + tag = SharedAudits.forgejo_tag_from_url(url.to_s) + tag ||= cask.version + error = SharedAudits.forgejo_release(user, repo, tag, cask:) + add_error error, location: url.location if error + end + + sig { void } + def audit_github_repository_archived + # Deprecated/disabled casks may have an archived repository. + return if cask.deprecated? || cask.disabled? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + metadata = SharedAudits.github_repo_data(user, repo) + return if metadata.nil? + + add_error "GitHub repo is archived", location: url.location if metadata["archived"] + end + + sig { void } + def audit_gitlab_repository_archived + # Deprecated/disabled casks may have an archived repository. + return if cask.deprecated? || cask.disabled? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + odebug "Auditing GitLab repo archived" + + metadata = SharedAudits.gitlab_repo_data(user, repo) + return if metadata.nil? + + add_error "GitLab repo is archived", location: url.location if metadata["archived"] + end + + sig { void } + def audit_forgejo_repository_archived + return if cask.deprecated? || cask.disabled? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://codeberg\.org/([^/]+)/([^/]+)/?.*}) if online? + return if user.nil? || repo.nil? + + metadata = SharedAudits.forgejo_repo_data(user, repo) + return if metadata.nil? + + return unless metadata["archived"] + + add_error "Forgejo repository is archived since #{metadata["archived_at"]}", + location: url.location + end + + sig { void } + def audit_github_repository + return unless new_cask? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) + return if user.nil? || repo.nil? + + odebug "Auditing GitHub repo" + + self_submission = self_submission?(user) + error = SharedAudits.github(user, repo, self_submission:) + add_error error, location: url.location if error + end + + sig { void } + def audit_gitlab_repository + return unless new_cask? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*}) + return if user.nil? || repo.nil? + + odebug "Auditing GitLab repo" + + self_submission = self_submission?(user) + error = SharedAudits.gitlab(user, repo, self_submission:) + add_error error, location: url.location if error + end + + sig { void } + def audit_bitbucket_repository + return unless new_cask? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://bitbucket\.org/([^/]+)/([^/]+)/?.*}) + return if user.nil? || repo.nil? + + odebug "Auditing Bitbucket repo" + + self_submission = self_submission?(user) + error = SharedAudits.bitbucket(user, repo, self_submission:) + add_error error, location: url.location if error + end + + sig { void } + def audit_forgejo_repository + return unless new_cask? + return if (url = cask.url).nil? + + user, repo = get_repo_data(%r{https?://codeberg\.org/([^/]+)/([^/]+)/?.*}) + return if user.nil? || repo.nil? + + odebug "Auditing Forgejo repo" + + self_submission = self_submission?(user) + error = SharedAudits.forgejo(user, repo, self_submission:) + add_error error, location: url.location if error + end + + sig { void } + def audit_conflicts_with + return if !cask.tap&.official? || cask.conflicts_with.nil? + + Homebrew.with_no_api_env do + nonexisting_conflicting_casks = cask.conflicts_with.fetch(:cask, Set.new) - core_cask_tokens + nonexisting_conflicting_casks.each do |c| + add_error("cask conflicts with non-existing cask `#{c}`") + end + end + end + + sig { void } + def audit_denylist + return unless cask.tap&.official? + return unless (reason = Denylist.reason(cask.token)) + + add_error "#{cask.token} is not allowed: #{reason}" + end + + sig { void } + def audit_reverse_migration + return unless new_cask? + return unless cask.tap&.official? + return unless cask.tap&.tap_migrations&.key?(cask.token) + + add_error "#{cask.token} is listed in tap_migrations.json" + end + + sig { void } + def audit_homepage_https_availability + return unless online? + return unless (homepage = cask.homepage) + + user_agents = if cask.tap&.audit_exception(:simple_user_agent_for_homepage, cask.token) + ["curl"] else - adjusted_version_stanza = cask.appcast.configuration + [:browser, :default] + end + + validate_url_for_https_availability( + homepage, SharedAudits::URL_TYPE_HOMEPAGE, + user_agents:, + check_content: true, + strict: strict? + ) + end + + sig { void } + def audit_url_https_availability + return unless online? + return unless (url = cask.url) + return if url.using + + validate_url_for_https_availability( + url, "binary URL", + location: url.location, + user_agents: [url.user_agent], + referer: url.referer + ) + end + + sig { void } + def audit_livecheck_https_availability + return unless online? + return unless cask.livecheck_defined? + return unless (url = cask.livecheck.url) + return if url.is_a?(Symbol) + + options = cask.livecheck.options + return if options.post_form || options.post_json + + # Validating HTTPS availability is unnecessary if the check uses HTTPS + # and does not fail. + if url.start_with?("https:") && audit_livecheck_version != false + odebug "Skipping livecheck_https_availability audit (working HTTPS livecheck)" + return end - return if appcast_contents.include? adjusted_version_stanza - add_warning "appcast at URL '#{appcast_stanza}' does not contain"\ - " the version number '#{adjusted_version_stanza}':\n#{appcast_contents}" - rescue - add_error "appcast at URL '#{appcast_stanza}' offline or looping" + odebug "Auditing livecheck HTTPS availability" + validate_url_for_https_availability( + url, "livecheck URL", + check_content: true, + user_agents: [:default, :browser] + ) + end + + sig { void } + def audit_cask_path + return unless (tap = cask.tap)&.core_cask_tap? + + expected_path = tap.new_cask_path(cask.token) + + return if cask.sourcefile_path.to_s.end_with?(expected_path.to_s) + + add_error "Cask should be located in '#{expected_path}'" + end + + sig { void } + def audit_deprecate_disable + error = SharedAudits.check_deprecate_disable_reason(cask) + add_error error if error + end + + sig { + params( + url_to_check: T.any(String, URL), + url_type: String, + location: T.nilable(Homebrew::SourceLocation), + options: T.untyped, + ).void + } + def validate_url_for_https_availability(url_to_check, url_type, location: nil, **options) + problem = curl_check_http_content(url_to_check.to_s, url_type, **options) + exception = cask.tap&.audit_exception(:secure_connection_audit_skiplist, cask.token, url_to_check.to_s) + + if problem + add_error problem, location: location unless exception + elsif exception + add_error "#{url_to_check} is in the secure connection audit skiplist but does not need to be skipped", + location: + end + end + + sig { params(regex: T.any(String, Regexp)).returns(T.nilable(T::Array[String])) } + def get_repo_data(regex) + return unless online? + + _, user, repo = *regex.match(cask.url.to_s) + _, user, repo = *regex.match(cask.homepage) unless user + return if !user || !repo + + repo.gsub!(/.git$/, "") + + [user, repo] + end + + sig { params(repo_owner: String).returns(T::Boolean) } + def self_submission?(repo_owner) + return false if repo_owner.empty? + + SharedAudits.self_submission_for_repo_owner?(repo_owner) + end + + sig { + params(regex: T.any(String, Regexp), valid_formats_array: T::Array[T.any(String, Regexp)]).returns(T::Boolean) + } + def bad_url_format?(regex, valid_formats_array) + return false unless cask.url.to_s.match?(regex) + + valid_formats_array.none? { |format| cask.url.to_s.match?(format) } end - def check_blacklist - return if cask.tap&.user != "Homebrew" - return unless reason = Blacklist.blacklisted_reason(cask.token) + sig { returns(T::Boolean) } + def bad_sourceforge_url? + bad_url_format?(%r{((downloads|\.dl)\.|//)sourceforge}, + [ + %r{\Ahttps://sourceforge\.net/projects/[^/]+/files/latest/download\Z}, + %r{\Ahttps://downloads\.sourceforge\.net/(?!(project|sourceforge)/)}, + ]) + end - add_error "#{cask.token} is blacklisted: #{reason}" + sig { returns(T::Boolean) } + def bad_osdn_url? + T.must(domain).match?(%r{^(?:\w+\.)*osdn\.jp(?=/|$)}) end - def check_https_availability - return unless download + sig { returns(T.nilable(String)) } + def homepage + URI(cask.homepage.to_s).host + end - if !cask.url.blank? && !cask.url.using - check_url_for_https_availability(cask.url, user_agents: [cask.url.user_agent]) + sig { returns(T.nilable(String)) } + def domain + URI(cask.url.to_s).host + end + + sig { returns(T::Boolean) } + def url_match_homepage? + host = cask.url.to_s + host_uri = URI(host) + host = if host.match?(/:\d/) && host_uri.port != 80 + "#{host_uri.host}:#{host_uri.port}" + else + host_uri.host + end + + home = homepage + return false if home.blank? + + home.downcase! + if (split_host = T.must(host).split(".")).length >= 3 + host = T.must(split_host[-2..]).join(".") end - check_url_for_https_availability(cask.appcast) unless cask.appcast.blank? - check_url_for_https_availability(cask.homepage, user_agents: [:browser]) unless cask.homepage.blank? + if (split_home = home.split(".")).length >= 3 + home = T.must(split_home[-2..]).join(".") + end + host == home + end + + sig { params(url: String).returns(String) } + def strip_url_scheme(url) + url.sub(%r{^[^:/]+://(www\.)?}, "") end - def check_url_for_https_availability(url_to_check, user_agents: [:default]) - problem = curl_check_http_content(url_to_check.to_s, user_agents: user_agents) - add_error problem if problem + sig { returns(T.nilable(String)) } + def url_from_verified + return unless (verified_url = T.must(cask.url).verified) + + strip_url_scheme(verified_url) + end + + sig { returns(T::Boolean) } + def verified_matches_url? + url_domain, url_path = strip_url_scheme(cask.url.to_s).split("/", 2) + verified_domain, verified_path = url_from_verified&.split("/", 2) + + domains_match = (url_domain == verified_domain) || + (verified_domain && url_domain&.end_with?(".#{verified_domain}")) + paths_match = !verified_path || url_path&.start_with?(verified_path) + (domains_match && paths_match) || false + end + + sig { returns(T::Boolean) } + def verified_present? + cask.url&.verified.present? + end + + sig { returns(T::Boolean) } + def file_url? + URI(cask.url.to_s).scheme == "file" + end + + sig { returns(Tap) } + def core_tap + @core_tap ||= T.let(CoreTap.instance, T.nilable(Tap)) + end + + sig { returns(T::Array[String]) } + def core_formula_names + core_tap.formula_names + end + + sig { returns(Tap) } + def core_cask_tap + @core_cask_tap ||= T.let(CoreCaskTap.instance, T.nilable(Tap)) + end + + sig { returns(T::Array[String]) } + def core_cask_tokens + core_cask_tap.cask_tokens + end + + sig { returns(String) } + def core_formula_url + formula_path = Formulary.core_path(cask.token) + .to_s + .delete_prefix(core_tap.path.to_s) + "#{core_tap.default_remote}/blob/HEAD#{formula_path}" end end end diff --git a/Library/Homebrew/cask/auditor.rb b/Library/Homebrew/cask/auditor.rb index 844fa8d9ee1c6..19cad7680e473 100644 --- a/Library/Homebrew/cask/auditor.rb +++ b/Library/Homebrew/cask/auditor.rb @@ -1,78 +1,148 @@ +# typed: strict # frozen_string_literal: true -require "cask/download" +require "cask/audit" +require "utils/output" module Cask + # Helper class for auditing all available languages of a cask. class Auditor - include Checkable - extend Predicable - - def self.audit(cask, audit_download: false, audit_appcast: false, - check_token_conflicts: false, quarantine: true, commit_range: nil) - new(cask, audit_download: audit_download, - audit_appcast: audit_appcast, - check_token_conflicts: check_token_conflicts, - quarantine: quarantine, commit_range: commit_range).audit + include ::Utils::Output::Mixin + + # TODO: use argument forwarding (...) when Sorbet supports it in strict mode + sig { + params( + cask: ::Cask::Cask, audit_download: T::Boolean, audit_online: T.nilable(T::Boolean), + audit_strict: T.nilable(T::Boolean), audit_signing: T.nilable(T::Boolean), + audit_new_cask: T.nilable(T::Boolean), quarantine: T::Boolean, + any_named_args: T::Boolean, language: T.nilable(String), only: T::Array[String], except: T::Array[String] + ).returns(T::Set[Audit::Error]) + } + def self.audit( + cask, audit_download: false, audit_online: nil, audit_strict: nil, audit_signing: nil, + audit_new_cask: nil, quarantine: false, any_named_args: false, language: nil, + only: [], except: [] + ) + new( + cask, audit_download:, audit_online:, audit_strict:, audit_signing:, + audit_new_cask:, quarantine:, any_named_args:, language:, only:, except: + ).audit end - attr_reader :cask, :commit_range + sig { returns(::Cask::Cask) } + attr_reader :cask + + sig { returns(T.nilable(String)) } + attr_reader :language - def initialize(cask, audit_download: false, audit_appcast: false, - check_token_conflicts: false, quarantine: true, commit_range: nil) + sig { + params( + cask: ::Cask::Cask, audit_download: T::Boolean, audit_online: T.nilable(T::Boolean), + audit_strict: T.nilable(T::Boolean), audit_signing: T.nilable(T::Boolean), + audit_new_cask: T.nilable(T::Boolean), quarantine: T::Boolean, + any_named_args: T::Boolean, language: T.nilable(String), only: T::Array[String], except: T::Array[String] + ).void + } + def initialize( + cask, + audit_download: false, + audit_online: nil, + audit_strict: nil, + audit_signing: nil, + audit_new_cask: nil, + quarantine: false, + any_named_args: false, + language: nil, + only: [], + except: [] + ) @cask = cask @audit_download = audit_download - @audit_appcast = audit_appcast + @audit_online = audit_online + @audit_new_cask = audit_new_cask + @audit_strict = audit_strict + @audit_signing = audit_signing @quarantine = quarantine - @commit_range = commit_range - @check_token_conflicts = check_token_conflicts + @any_named_args = any_named_args + @language = language + @only = only + @except = except end - def audit_download? - @audit_download - end + LANGUAGE_BLOCK_LIMIT = 10 - attr_predicate :audit_appcast?, :quarantine? + sig { returns(T::Set[Audit::Error]) } + def audit + errors = Set.new - def check_token_conflicts? - @check_token_conflicts - end + if !language && !(blocks = language_blocks).empty? + sample_languages = if blocks.length > LANGUAGE_BLOCK_LIMIT && !@audit_new_cask + sample_keys = T.must(blocks.keys.sample(LANGUAGE_BLOCK_LIMIT)) + ohai "Auditing a sample of available languages for #{cask}: " \ + "#{sample_keys.map { |lang| lang[0].to_s }.to_sentence}" + blocks.select { |k| sample_keys.include?(k) } + else + blocks + end - def audit - if !ARGV.value("language") && language_blocks - audit_all_languages + sample_languages.each_key do |l| + audit = audit_languages(l) + if audit.summary.present? && output_summary?(audit) + ohai "Auditing language: #{l.map { |lang| "'#{lang}'" }.to_sentence}" if output_summary? + puts audit.summary + end + errors += audit.errors + end else - audit_cask_instance(cask) + audit = audit_cask_instance(cask) + puts audit.summary if audit.summary.present? && output_summary?(audit) + errors += audit.errors end + + errors end private - def audit_all_languages - saved_languages = MacOS.instance_variable_get(:@languages) - begin - language_blocks.keys.all?(&method(:audit_languages)) - ensure - MacOS.instance_variable_set(:@languages, saved_languages) - end + sig { params(audit: T.nilable(Audit)).returns(T::Boolean) } + def output_summary?(audit = nil) + return true if @any_named_args + return true if @audit_strict + return false if audit.nil? + + audit.errors? end + sig { params(languages: T::Array[String]).returns(::Cask::Audit) } def audit_languages(languages) - ohai "Auditing language: #{languages.map { |lang| "'#{lang}'" }.to_sentence}" - MacOS.instance_variable_set(:@languages, languages) - audit_cask_instance(CaskLoader.load(cask.sourcefile_path)) + original_config = cask.config + begin + localized_config = original_config.merge(Config.new(explicit: { languages: })) + cask.config = localized_config + + audit_cask_instance(cask) + ensure + cask.config = original_config + end end + sig { params(cask: ::Cask::Cask).returns(::Cask::Audit) } def audit_cask_instance(cask) - download = audit_download? && Download.new(cask, quarantine: quarantine?) - audit = Audit.new(cask, check_appcast: audit_appcast?, - download: download, - check_token_conflicts: check_token_conflicts?, - commit_range: commit_range) + audit = Audit.new( + cask, + online: @audit_online, + strict: @audit_strict, + signing: @audit_signing, + new_cask: @audit_new_cask, + download: @audit_download, + quarantine: @quarantine, + only: @only, + except: @except, + ) audit.run! - puts audit.summary - audit.success? end + sig { returns(T::Hash[T::Array[String], T.proc.returns(T.untyped)]) } def language_blocks cask.instance_variable_get(:@dsl).instance_variable_get(:@language_blocks) end diff --git a/Library/Homebrew/cask/blacklist.rb b/Library/Homebrew/cask/blacklist.rb deleted file mode 100644 index c37ac82fefa49..0000000000000 --- a/Library/Homebrew/cask/blacklist.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Cask - module Blacklist - def self.blacklisted_reason(name) - case name - when /^adobe\-(after|illustrator|indesign|photoshop|premiere)/ - "Adobe casks were removed because they are too difficult to maintain." - when /^audacity$/ - "Audacity was removed because it is too difficult to download programmatically." - when /^pharo$/ - "Pharo developers maintain their own tap." - end - end - end -end diff --git a/Library/Homebrew/cask/cache.rb b/Library/Homebrew/cask/cache.rb index 2286398620650..9735e4789d813 100644 --- a/Library/Homebrew/cask/cache.rb +++ b/Library/Homebrew/cask/cache.rb @@ -1,11 +1,12 @@ +# typed: strict # frozen_string_literal: true module Cask + # Helper functions for the cask cache. module Cache - module_function - - def path - @path ||= HOMEBREW_CACHE/"Cask" + sig { returns(Pathname) } + def self.path + @path ||= T.let(HOMEBREW_CACHE/"Cask", T.nilable(Pathname)) end end end diff --git a/Library/Homebrew/cask/cask.rb b/Library/Homebrew/cask/cask.rb index 6c387c548b8b6..11c0fda9ed202 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -1,187 +1,786 @@ +# typed: strict # frozen_string_literal: true +require "bundle_version" require "cask/cask_loader" require "cask/config" require "cask/dsl" require "cask/metadata" -require "searchable" +require "cask/tab" +require "utils/output" +require "api_hashable" +require "trust" module Cask + # An instance of a cask. class Cask - extend Enumerable extend Forwardable - extend Searchable + extend APIHashable + extend ::Utils::Output::Mixin include Metadata - attr_reader :token, :sourcefile_path, :config + # The unique identifier for this {Cask}, used to refer to it in commands + # and tap paths. + # e.g. `firefox` + # + # @api public + sig { returns(String) } + attr_reader :token - def self.each - return to_enum unless block_given? + # The configuration of this {Cask}. + # + # @api internal + sig { returns(Config) } + attr_reader :config - Tap.flat_map(&:cask_files).each do |f| - yield CaskLoader::FromTapPathLoader.new(f).load - rescue CaskUnreadableError => e + sig { returns(T.nilable(Pathname)) } + attr_reader :sourcefile_path + + sig { returns(T.nilable(String)) } + attr_reader :source + + sig { returns(Config) } + attr_reader :default_config + + sig { returns(T.nilable(CaskLoader::ILoader)) } + attr_reader :loader + + sig { returns(T.nilable(Pathname)) } + attr_accessor :download + + sig { returns(T::Boolean) } + attr_accessor :allow_reassignment + + sig { params(eval_all: T::Boolean).returns(T::Array[Cask]) } + def self.all(eval_all: false) + if !eval_all && !Homebrew::EnvConfig.tap_trust_configured? + raise ArgumentError, + "Cask::Cask#all cannot be used without `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1`" + end + + # Load core casks from tokens so they load from the API when the core cask is not tapped. + tokens_and_files = CoreCaskTap.instance.cask_tokens + tokens_and_files += Tap.reject(&:core_cask_tap?).flat_map(&:cask_files) + .then { |files| Homebrew::Trust.trusted_cask_files(files) } + tokens_and_files.filter_map do |token_or_file| + CaskLoader.load(token_or_file) + rescue CaskUnreadableError, CaskInvalidError => e + # Don't let one broken cask break commands. But do complain. opoo e.message + + nil end end - def tap - return super if block_given? # Object#tap + # This collides with Kernel#tap, complicating the type signature. + # Overload sigs are not supported by Sorbet, otherwise we would use: + # sig { params(blk: T.proc.params(arg0: Cask).void).returns(T.self_type) } + # sig { params(blk: NilClass).returns(T.nilable(Tap)) } + # Using a union type would require casts or type guards at call sites, + # so T.untyped is used as the return type instead. + sig { params(blk: T.nilable(T.proc.params(arg0: Cask).void)).returns(T.untyped) } + def tap(&blk) + return super if block_given? # Kernel#tap @tap end - def initialize(token, sourcefile_path: nil, tap: nil, &block) + sig { + params( + token: String, + sourcefile_path: T.nilable(Pathname), + source: T.nilable(String), + tap: T.nilable(Tap), + loaded_from_api: T::Boolean, + loaded_from_internal_api: T::Boolean, + api_source: T.nilable(T::Hash[String, T.untyped]), + config: T.nilable(Config), + allow_reassignment: T::Boolean, + loader: T.nilable(CaskLoader::ILoader), + block: T.nilable(T.proc.bind(DSL).void), + ).void + } + def initialize(token, sourcefile_path: nil, source: nil, tap: nil, loaded_from_api: false, + loaded_from_internal_api: false, api_source: nil, config: nil, allow_reassignment: false, + loader: nil, &block) @token = token @sourcefile_path = sourcefile_path + @source = source @tap = tap - @block = block - self.config = Config.for_cask(self) + @allow_reassignment = allow_reassignment + @loaded_from_api = loaded_from_api + @loaded_from_internal_api = loaded_from_internal_api + @api_source = api_source + @loader = loader + # Sorbet has trouble with bound procs assigned to instance variables: + # https://github.com/sorbet/sorbet/issues/6843 + @block = T.let(block, T.untyped) + + @default_config = T.let(config || Config.new, Config) + + @config = T.let(if config_path.exist? + Config.from_json(File.read(config_path), ignore_invalid_keys: true) + else + @default_config + end, Config) + refresh end + sig { returns(T::Boolean) } + def loaded_from_api? = @loaded_from_api + + sig { returns(T::Boolean) } + def loaded_from_internal_api? = @loaded_from_internal_api + + sig { returns(T.any(String, Pathname)) } + def reloadable_ref + return full_name if loaded_from_api? + + sourcefile_path || raise("unexpected nil cask sourcefile_path") + end + + sig { returns(T.nilable(T::Hash[String, T.untyped])) } + attr_reader :api_source + + # An old name for the cask. + sig { returns(T::Array[String]) } + def old_tokens + @old_tokens ||= T.let( + if (t = tap) + Tap.tap_migration_oldnames(t, token) + + t.cask_reverse_renames.fetch(token, []) + else + [] + end, + T.nilable(T::Array[String]), + ) + end + + sig { params(config: Config).void } def config=(config) @config = config - @dsl = DSL.new(self) + refresh + end + + sig { void } + def refresh + @dsl = T.let(DSL.new(self), T.nilable(DSL)) return unless @block - @dsl.instance_eval(&@block) - @dsl.language_eval + dsl!.instance_eval(&@block) + dsl!.language_eval + rescue NoMethodError => e + raise CaskInvalidError.new(token, e.message), e.backtrace end - DSL::DSL_METHODS.each do |method_name| - define_method(method_name) { |&block| @dsl.send(method_name, &block) } - end + # Refresh the cask as evaluated on `tag` and yield. Returns `nil` instead of + # raising when the cask has `on_system` blocks that omit the tag. + sig { + type_parameters(:U) + .params(tag: ::Utils::Bottles::Tag, _block: T.proc.returns(T.type_parameter(:U))) + .returns(T.nilable(T.type_parameter(:U))) + } + def refresh_for_tag(tag, &_block) + Homebrew::SimulateSystem.with(os: tag.system, arch: tag.arch) do + refresh + yield + end + rescue CaskInvalidError, CaskUnreadableError + raise unless on_system_blocks_exist? - def timestamped_versions - Pathname.glob(metadata_timestamped_path(version: "*", timestamp: "*")) - .map { |p| p.relative_path_from(p.parent.parent) } - .sort_by(&:basename) # sort by timestamp - .map { |p| p.split.map(&:to_s) } + nil end - def versions - timestamped_versions.map(&:first) - .reverse - .uniq - .reverse + def_delegators :@dsl, *::Cask::DSL::DSL_METHODS + + sig { returns(DSL::Caveats) } + def caveats_object = dsl!.caveats_object + + sig { params(caskroom_path: Pathname).returns(T::Array[[String, String]]) } + def timestamped_versions(caskroom_path: self.caskroom_path) + pattern = metadata_timestamped_path(version: "*", timestamp: "*", caskroom_path:).to_s + relative_paths = Pathname.glob(pattern) + .map { |p| p.relative_path_from(p.parent.parent) } + # Sorbet is unaware that Pathname is sortable: https://github.com/sorbet/sorbet/issues/6844 + T.unsafe(relative_paths).sort_by(&:basename) # sort by timestamp + .map { |p| p.split.map(&:to_s) } end - def full_name - return token if tap.nil? - return token if tap.user == "Homebrew" + # The fully-qualified token of this {Cask}. + # + # @api internal + sig { returns(String) } + def full_token + return token if (t = tap).nil? + return token if t.core_cask_tap? - "#{tap.name}/#{token}" + "#{t.name}/#{token}" end + # Alias for {#full_token}. + # + # @api internal + sig { returns(String) } + def full_name = full_token + + sig { returns(T::Boolean) } def installed? - !versions.empty? + installed_caskfile&.exist? || false end - def install_time - _, time = timestamped_versions.last - return unless time + sig { returns(T::Boolean) } + def any_version_installed? = installed? + + sig { returns(T::Boolean) } + def font? + artifacts.all?(Artifact::Font) + end + + sig { returns(T::Boolean) } + def supports_linux? + return true if depends_on.requires_linux? - Time.strptime(time, Metadata::TIMESTAMP_FORMAT) + !depends_on.requires_macos? end + sig { returns(T::Boolean) } + def supports_macos? + !depends_on.requires_linux? + end + + # The caskfile is needed during installation when there are + # `*flight` blocks or the cask has multiple languages + sig { returns(T::Boolean) } + def caskfile_only? + languages.any? || artifacts.any?(Artifact::AbstractFlightBlock) + end + + sig { returns(T::Boolean) } + def uninstall_flight_blocks? + artifacts.any? do |artifact| + case artifact + when Artifact::PreflightBlock + artifact.directives.key?(:uninstall_preflight) + when Artifact::PostflightBlock + artifact.directives.key?(:uninstall_postflight) + end + end + end + + sig { returns(T.nilable(Time)) } + def install_time + # /.metadata///Casks/.{rb,json} -> + caskfile = installed_caskfile + Time.strptime(caskfile.dirname.dirname.basename.to_s, Metadata::TIMESTAMP_FORMAT) if caskfile + end + + sig { returns(T.nilable(Pathname)) } def installed_caskfile - installed_version = timestamped_versions.last - metadata_master_container_path.join(*installed_version, "Casks", "#{token}.rb") + Caskroom.cask_installed_caskfile(token, old_tokens:) + end + + sig { returns(T.nilable(String)) } + def installed_version + # /.metadata///Casks/.{rb,json} -> + Caskroom.cask_installed_version(token, old_tokens:) + end + + sig { void } + def pin + return unless (installed_version = self.installed_version) + + versioned_path = caskroom_path/installed_version + return unless versioned_path.exist? + + HOMEBREW_PINNED_CASKS.mkpath + return if pinned? + + pin_path.unlink if pin_path.file? || pin_path.symlink? + pin_path.make_relative_symlink(versioned_path) + end + + sig { void } + def unpin + pin_path.unlink if pin_path.symlink? + HOMEBREW_PINNED_CASKS.rmdir_if_possible + end + + sig { returns(T::Boolean) } + def pinned? + pin_path.symlink? && pin_path.exist? + end + + sig { returns(T::Boolean) } + def pinnable? + return false unless (installed_version = self.installed_version) + + (caskroom_path/installed_version).exist? + end + + sig { returns(T.nilable(String)) } + def pinned_version + pin_path.resolved_path.basename.to_s if pinned? + end + + sig { returns(Pathname) } + def pin_path + HOMEBREW_PINNED_CASKS/token + end + + sig { returns(T.nilable(String)) } + def bundle_short_version + bundle_version&.short_version + end + + sig { returns(T.nilable(String)) } + def bundle_long_version + bundle_version&.version + end + + sig { returns(Tab) } + def tab + Tab.for_cask(self) end + sig { returns(Pathname) } def config_path - metadata_master_container_path/"config.json" + metadata_main_container_path/"config.json" end + sig { returns(T::Boolean) } + def checksumable? + return false if (url = self.url).nil? + + DownloadStrategyDetector.detect(url.to_s, url.using) <= AbstractFileDownloadStrategy || false + end + + sig { returns(Pathname) } + def download_sha_path + metadata_main_container_path/"LATEST_DOWNLOAD_SHA256" + end + + sig { returns(String) } + def new_download_sha + require "cask/installer" + + # Call checksumable? before hashing + @new_download_sha ||= T.let( + Installer.new(self, verify_download_integrity: false) + .download(quiet: true) + .instance_eval { |x| Digest::SHA256.file(x).hexdigest }, + T.nilable(String), + ) + end + + sig { returns(T::Boolean) } + def outdated_download_sha? + return true unless checksumable? + + current_download_sha = download_sha_path.read if download_sha_path.exist? + current_download_sha.blank? || current_download_sha != new_download_sha + end + + sig { returns(Pathname) } def caskroom_path - @caskroom_path ||= Caskroom.path.join(token) + @caskroom_path ||= T.let(Caskroom.path.join(token), T.nilable(Pathname)) end - def outdated?(greedy = false) - !outdated_versions(greedy).empty? + # Check if the installed cask is outdated. + # + # @api internal + sig { + params(greedy: T::Boolean, greedy_latest: T.nilable(T::Boolean), greedy_auto_updates: T.nilable(T::Boolean)) + .returns(T::Boolean) + } + def outdated?(greedy: false, greedy_latest: false, greedy_auto_updates: false) + !outdated_version(greedy:, greedy_latest:, + greedy_auto_updates:).nil? end - def outdated_versions(greedy = false) + sig { + params(greedy: T::Boolean, greedy_latest: T.nilable(T::Boolean), greedy_auto_updates: T.nilable(T::Boolean)) + .returns(T.nilable(String)) + } + def outdated_version(greedy: false, greedy_latest: false, greedy_auto_updates: false) # special case: tap version is not available - return [] if version.nil? + return if version.nil? + + if version.latest? + return installed_version if (greedy || greedy_latest) && outdated_download_sha? - if greedy - return versions if version.latest? - elsif auto_updates - return [] + return end - installed = versions - current = installed.last + return if installed_version == version + + if auto_updates && !greedy && !greedy_auto_updates + return unless Homebrew::EnvConfig.upgrade_auto_updates_casks? + + return installed_version if auto_updates_bundle_outdated? + + return + end + + installed_version + end + + sig { + params( + greedy: T::Boolean, + verbose: T::Boolean, + json: T::Boolean, + greedy_latest: T::Boolean, + greedy_auto_updates: T::Boolean, + ).returns(T.any(String, T::Hash[Symbol, T.untyped])) + } + def outdated_info(greedy, verbose, json, greedy_latest, greedy_auto_updates) + return token if !verbose && !json + + installed_version = outdated_version(greedy:, greedy_latest:, + greedy_auto_updates:).to_s + + if json + { + name: token, + installed_versions: [installed_version], + current_version: version, + pinned: pinned?, + pinned_version:, + } + else + pinned = " [pinned at #{pinned_version}]" if pinned? + + "#{token} (#{installed_version}) != #{version}#{pinned}" + end + end + + sig { returns(T.nilable(String)) } + def ruby_source_path + return @ruby_source_path if defined?(@ruby_source_path) + + return unless (sfp = sourcefile_path) + return unless (t = tap) + + @ruby_source_path = T.let(sfp.relative_path_from(t.path).to_s, T.nilable(String)) + end - # not outdated unless there is a different version on tap - return [] if current == version + sig { returns(T::Hash[Symbol, T.nilable(String)]) } + def ruby_source_checksum + @ruby_source_checksum ||= T.let( + begin + sfp = sourcefile_path + { + sha256: sfp ? Digest::SHA256.file(sfp).hexdigest : nil, + }.freeze + end, + T.nilable(T::Hash[Symbol, T.nilable(String)]), + ) + end - # collect all installed versions that are different than tap version and return them - installed.reject { |v| v == version } + sig { returns(T::Array[String]) } + def languages + @languages ||= T.let(dsl!.languages, T.nilable(T::Array[String])) end - def to_s - @token + sig { returns(T.nilable(String)) } + def tap_git_head + @tap_git_head ||= T.let(tap&.git_head, T.nilable(String)) + rescue TapUnavailableError + nil + end + + sig { params(cask_struct: Homebrew::API::CaskStruct, tap_git_head: T.nilable(String)).void } + def populate_from_api!(cask_struct, tap_git_head:) + raise ArgumentError, "Expected cask to be loaded from the API" unless loaded_from_api? + + @languages = cask_struct.languages + @tap_git_head = tap_git_head + @ruby_source_path = cask_struct.ruby_source_path + @ruby_source_checksum = cask_struct.ruby_source_checksum end + # The string representation of this {Cask}, returning its {#token}. + # + # @api public + sig { returns(String) } + def to_s = token + + sig { returns(String) } + def inspect + "#" + end + + sig { returns(Integer) } def hash token.hash end + sig { params(other: T.untyped).returns(T::Boolean) } def eql?(other) - token == other.token + instance_of?(other.class) && token == other.token end alias == eql? + sig { returns(T::Hash[String, T.untyped]) } def to_h { - "token" => token, - "name" => name, - "homepage" => homepage, - "url" => url, - "appcast" => appcast, - "version" => version, - "sha256" => sha256, - "artifacts" => artifacts.map(&method(:to_h_gsubs)), - "caveats" => (to_h_string_gsubs(caveats) unless caveats.empty?), - "depends_on" => depends_on, - "conflicts_with" => conflicts_with, - "container" => container, - "auto_updates" => auto_updates, + "token" => token, + "full_token" => full_name, + "old_tokens" => old_tokens, + "tap" => tap&.name, + "name" => name, + "desc" => desc, + "homepage" => homepage, + "url" => url, + "url_specs" => url_specs, + "version" => version, + "autobump" => autobump?, + "no_autobump_message" => no_autobump_message, + "skip_livecheck" => livecheck.skip?, + "installed" => installed_version, + "installed_time" => install_time&.to_i, + "bundle_version" => bundle_long_version, + "bundle_short_version" => bundle_short_version, + "pinned" => pinned?, + "pinned_version" => pinned_version, + "outdated" => outdated?, + "sha256" => sha256, + "artifacts" => artifacts_list, + "caveats" => caveats_for_api, + "caveats_rosetta" => caveats_object.invoked?(:requires_rosetta) || nil, + "depends_on" => depends_on, + "conflicts_with" => conflicts_with, + "container" => container&.pairs, + "rename" => rename_list, + "auto_updates" => auto_updates, + "deprecated" => deprecated?, + "deprecation_date" => deprecation_date, + "deprecation_reason" => deprecation_reason, + "deprecation_replacement_formula" => deprecation_replacement_formula, + "deprecation_replacement_cask" => deprecation_replacement_cask, + "deprecate_args" => deprecate_args, + "disabled" => disabled?, + "disable_date" => disable_date, + "disable_reason" => disable_reason, + "disable_replacement_formula" => disable_replacement_formula, + "disable_replacement_cask" => disable_replacement_cask, + "disable_args" => disable_args, + "tap_git_head" => tap_git_head, + "languages" => languages, + "ruby_source_path" => ruby_source_path, + "ruby_source_checksum" => ruby_source_checksum, } end - private + HASH_KEYS_TO_SKIP = %w[outdated installed pinned pinned_version versions].freeze + private_constant :HASH_KEYS_TO_SKIP + + AUTO_UPDATES_BAD_BUNDLE_VERSIONS = %w[0 0.0].freeze + private_constant :AUTO_UPDATES_BAD_BUNDLE_VERSIONS + + sig { returns(T::Hash[String, T.untyped]) } + def to_hash_with_variations + if loaded_from_internal_api? + raise UsageError, "Cannot call #to_hash_with_variations on casks loaded from the internal API" + end + + if loaded_from_api? && (json_cask = api_source) && !Homebrew::EnvConfig.no_install_from_api? + return api_to_local_hash(json_cask.dup) + end + + hash = to_h + variations = {} + + if dsl!.on_system_blocks_exist? + begin + OnSystem::VALID_OS_ARCH_TAGS.each do |bottle_tag| + next if bottle_tag.linux? && dsl!.os.nil? && !dsl!.sha256_set_for_linux? + + macos_requirements = [depends_on.macos, depends_on.maximum_macos].compact + next if bottle_tag.macos? && + macos_requirements.present? && + !dsl!.depends_on_set_in_block? && + macos_requirements.any? { |requirement| !requirement.allows?(bottle_tag.to_macos_version) } + + refresh_for_tag(bottle_tag) do + to_h.each do |key, value| + next if HASH_KEYS_TO_SKIP.include? key + next if value.to_s == hash[key].to_s + + variations[bottle_tag.to_sym] ||= {} + variations[bottle_tag.to_sym][key] = value + end + end + end + ensure + refresh + end + end + + hash["variations"] = variations + hash + end + + sig { returns(T::Hash[String, T.untyped]) } + def to_installed_json_hash + cask_url = url + return {} if cask_url.nil? || cask_url.only_path.blank? - def to_h_string_gsubs(string) - string.to_s - .gsub(ENV["HOME"], "$HOME") - .gsub(HOMEBREW_PREFIX, "$(brew --prefix)") + { "url_specs" => { "only_path" => cask_url.only_path } } end - def to_h_array_gsubs(array) - array.to_a.map do |value| - to_h_gsubs(value) + sig { params(uninstall_only: T::Boolean).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def artifacts_list(uninstall_only: false) + artifacts.filter_map do |artifact| + case artifact + when Artifact::AbstractFlightBlock + uninstall_flight_block = artifact.directives.key?(:uninstall_preflight) || + artifact.directives.key?(:uninstall_postflight) + next if uninstall_only && !uninstall_flight_block + + # Only indicate whether this block is used as we don't load it from the API + { artifact.summarize.to_sym => nil } + else + zap_artifact = artifact.is_a?(Artifact::Zap) + uninstall_artifact = artifact.respond_to?(:uninstall_phase) || artifact.respond_to?(:post_uninstall_phase) + next if uninstall_only && !zap_artifact && !uninstall_artifact + + entry = T.let( + { artifact.class.dsl_key => artifact.to_args }, + T::Hash[Symbol, T.any(String, T::Array[T.anything])], + ) + entry[:target] = artifact.target.to_s if !uninstall_only && artifact.is_a?(Artifact::Relocated) + entry + end end end - def to_h_hash_gsubs(hash) - hash.to_h.each_with_object({}) do |(key, value), h| - h[key] = to_h_gsubs(value) + sig { params(uninstall_only: T::Boolean).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def rename_list(uninstall_only: false) + rename.filter_map do |rename| + { from: rename.from, to: rename.to } end - rescue TypeError - to_h_array_gsubs(hash) end - def to_h_gsubs(value) - if value.respond_to? :to_h - to_h_hash_gsubs(value) - elsif value.respond_to? :to_a - to_h_array_gsubs(value) - else - to_h_string_gsubs(value) + private + + # Returns caveats text for API serialization, excluding conditional + # built-in caveats that depend on the current machine's state. + # These are stored as separate boolean fields (e.g. caveats_rosetta) + # and evaluated at install time instead. + sig { returns(T.nilable(String)) } + def caveats_for_api + Tty.strip_ansi(caveats_object.to_s_without_conditional) + .presence + end + + sig { returns(T.nilable(Homebrew::BundleVersion)) } + def bundle_version + @bundle_version ||= T.let( + if (bundle = artifacts.find { |a| a.is_a?(Artifact::App) }&.target) && + (plist = Pathname("#{bundle}/Contents/Info.plist")) && plist.exist? && plist.readable? + Homebrew::BundleVersion.from_info_plist(plist) + end, + T.nilable(Homebrew::BundleVersion), + ) + end + + sig { returns(DSL) } + def dsl! + @dsl || raise("unexpected nil @dsl") + end + + sig { returns(T.nilable(Artifact::App)) } + def single_app_artifact + app_artifacts = artifacts.grep(Artifact::App) + return unless app_artifacts.one? + + app_artifacts.first + end + + sig { returns(T.nilable(Pathname)) } + def installed_app_info_plist + return unless (app_artifact = single_app_artifact) + + info_plist = app_artifact.target/"Contents/Info.plist" + info_plist if info_plist.exist? && info_plist.readable? + end + + sig { params(first: T.nilable(String), second: T.nilable(String)).returns(T.nilable(Integer)) } + def compare_version_strings(first, second) + return if first.blank? || second.blank? + return if first.split(".").size != second.split(".").size + + Version.new(first) <=> Version.new(second) + rescue + nil + end + + sig { returns(T::Boolean) } + def auto_updates_bundle_outdated? + return false if !auto_updates || version.latest? + return false unless installed_app_info_plist + + tap_short_version = version.csv.first.to_s.presence || version.to_s + + begin + installed_short_version = bundle_short_version + installed_bundle_version = bundle_long_version + rescue ErrorDuringExecution + return false + end + installed_bundle_version = nil if AUTO_UPDATES_BAD_BUNDLE_VERSIONS.include?(installed_bundle_version) + installed_short_version = nil if AUTO_UPDATES_BAD_BUNDLE_VERSIONS.include?(installed_short_version) + return false if installed_short_version.nil? && installed_bundle_version.nil? + + # Some apps split a cask version like 2.61-2057 across the short + # version and bundle version fields. + if installed_short_version && installed_bundle_version + combined_version_comparisons = version.csv.filter_map do |candidate| + compare_version_strings("#{installed_short_version}-#{installed_bundle_version}", candidate.to_s) + end + return false if combined_version_comparisons.include?(0) + return false if combined_version_comparisons.present? && combined_version_comparisons.exclude?(-1) + end + + return false if [installed_short_version, installed_bundle_version].any? do |installed_plist_version| + compare_version_strings(installed_plist_version, tap_short_version)&.zero? + end + + short_comparison = compare_version_strings(installed_short_version, tap_short_version) + return true if short_comparison == -1 + return false if short_comparison == 1 + + build_comparisons = version.csv.filter_map do |candidate| + compare_version_strings(installed_bundle_version, candidate.to_s) + end + return false if build_comparisons.empty? + return false if build_comparisons.include?(0) + + build_comparisons.include?(-1) + end + + sig { params(hash: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) } + def api_to_local_hash(hash) + hash["token"] = token + hash["installed"] = installed_version + hash["pinned"] = pinned? + hash["pinned_version"] = pinned_version + hash["outdated"] = outdated? + hash + end + + sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) } + def url_specs + url&.specs.dup.tap do |url_specs| + case url_specs&.dig(:user_agent) + when :default + url_specs.delete(:user_agent) + when Symbol + url_specs[:user_agent] = ":#{url_specs[:user_agent]}" + end end end end diff --git a/Library/Homebrew/cask/cask_loader.rb b/Library/Homebrew/cask/cask_loader.rb index 147bfefa530b6..d452f2e95033e 100644 --- a/Library/Homebrew/cask/cask_loader.rb +++ b/Library/Homebrew/cask/cask_loader.rb @@ -1,74 +1,228 @@ +# typed: strict # frozen_string_literal: true +require "cask/cache" require "cask/cask" require "uri" +require "utils/curl" +require "utils/output" +require "utils/path" +require "extend/hash/keys" +require "extend/ENV/sensitive" +require "api" +require "trust" module Cask + # Loads a cask from various sources. module CaskLoader - class FromContentLoader + extend Context + extend ::Utils::Output::Mixin + + ALLOWED_URL_SCHEMES = %w[file].freeze + private_constant :ALLOWED_URL_SCHEMES + + module ILoader + extend T::Helpers + include ::Utils::Output::Mixin + + interface! + + sig { abstract.params(config: T.nilable(Config)).returns(Cask) } + def load(config:); end + end + + # Loads a cask from a string. + class AbstractContentLoader + include ILoader + extend T::Helpers + + abstract! + + sig { returns(String) } attr_reader :content - def self.can_load?(ref) - return false unless ref.respond_to?(:to_str) + sig { overridable.returns(T.nilable(Tap)) } + attr_reader :tap - content = ref.to_str + sig { void } + def initialize + @content = T.let("", String) + @tap = T.let(nil, T.nilable(Tap)) + @config = T.let(nil, T.nilable(Config)) + end - token = /(?:"[^"]*"|'[^']*')/ - curly = /\(\s*#{token}\s*\)\s*\{.*\}/ - do_end = /\s+#{token}\s+do(?:\s*;\s*|\s+).*end/ - regex = /\A\s*cask(?:#{curly.source}|#{do_end.source})\s*\Z/m + private - content.match?(regex) + sig { + overridable.params( + header_token: String, + options: T.untyped, + block: T.nilable(T.proc.bind(DSL).void), + ).returns(Cask) + } + def cask(header_token, **options, &block) + Cask.new(header_token, source: content, tap:, **options, config: @config, &block) end + end + + # Loads a cask from a string. + class FromContentLoader < AbstractContentLoader + sig { + params(ref: T.any(Pathname, String, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.attached_class)) + } + def self.try_new(ref, warn: false) + return if ref.is_a?(Cask) + + content = ref.to_str - def initialize(content) - @content = content.force_encoding("UTF-8") + # Cache compiled regex + @regex ||= T.let( + begin + token = /(?:"[^"]*"|'[^']*')/ + curly = /\(\s*#{token.source}\s*\)\s*\{.*\}/ + do_end = /\s+#{token.source}\s+do(?:\s*;\s*|\s+).*end/ + /\A\s*cask(?:#{curly.source}|#{do_end.source})\s*\Z/m + end, + T.nilable(Regexp), + ) + + return unless content.match?(@regex) + + new(content) end - def load - instance_eval(content, __FILE__, __LINE__) + sig { params(content: String, tap: Tap).void } + def initialize(content, tap: T.unsafe(nil)) + super() + + @content = T.let(content.dup.force_encoding("UTF-8"), String) + @tap = T.let(tap, T.nilable(Tap)) end - private + sig { override.params(config: T.nilable(Config)).returns(Cask) } + def load(config:) + @config = config - def cask(header_token, **options, &block) - Cask.new(header_token, **options, &block) + ENV.clear_sensitive_environment_for_eval! do + instance_eval(content, __FILE__, __LINE__) + end end end - class FromPathLoader < FromContentLoader - def self.can_load?(ref) - path = Pathname(ref) - path.extname == ".rb" && path.expand_path.exist? + # Loads a cask from a path. + class FromPathLoader < AbstractContentLoader + sig { + overridable.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.attached_class)) + } + def self.try_new(ref, warn: false) + path = case ref + when String + Pathname(ref) + when Pathname + ref + else + return + end + + return unless path.expand_path.exist? + return if invalid_path?(path) + return unless ::Utils::Path.loadable_package_path?(path, :cask) + + new(path) + end + + sig { params(pathname: Pathname, valid_extnames: T::Array[String]).returns(T::Boolean) } + def self.invalid_path?(pathname, valid_extnames: %w[.rb .json]) + return true if valid_extnames.exclude?(pathname.extname) + + @invalid_basenames ||= T.let(%w[INSTALL_RECEIPT.json sbom.spdx.json].freeze, T.nilable(T::Array[String])) + @invalid_basenames.include?(pathname.basename.to_s) end - attr_reader :token, :path + sig { returns(String) } + attr_reader :token + + sig { returns(Pathname) } + attr_reader :path + + sig { params(path: T.any(Pathname, String), token: String).void } + def initialize(path, token: T.unsafe(nil)) + super() - def initialize(path) path = Pathname(path).expand_path - @token = path.basename(".rb").to_s - @path = path + @token = T.let(path.basename(path.extname).basename(".internal").to_s, String) + @path = T.let(path, Pathname) + @tap = T.let(Tap.from_path(path) || Homebrew::API.tap_from_source_download(path), T.nilable(Tap)) + @from_installed_caskfile = T.let(false, T::Boolean) end - def load + sig { override.params(config: T.nilable(Config)).returns(Cask) } + def load(config:) raise CaskUnavailableError.new(token, "'#{path}' does not exist.") unless path.exist? raise CaskUnavailableError.new(token, "'#{path}' is not readable.") unless path.readable? raise CaskUnavailableError.new(token, "'#{path}' is not a file.") unless path.file? - @content = IO.read(path) + Homebrew::Trust.require_trusted_cask!(token, path) + + @content = path.read(encoding: "UTF-8") + @config = config + + if !self.class.invalid_path?(path, valid_extnames: %w[.json]) && + (from_json = JSON.parse(@content)) && + from_json.is_a?(Hash) && + (@from_installed_caskfile || from_json.present?) + begin + from_internal_json = path.to_s.end_with?(".internal.json") + return FromAPILoader.new( + token, + from_json:, + path:, + from_installed_caskfile: @from_installed_caskfile, + from_internal_json:, + ).load(config:) + rescue CaskInvalidError => e + if @from_installed_caskfile + error = CaskUnreadableError.new(token, e.reason) + error.set_backtrace e.backtrace + raise error + end + raise + end + end begin - instance_eval(content, path).tap do |cask| - raise CaskUnreadableError.new(token, "'#{path}' does not contain a cask.") unless cask.is_a?(Cask) + ENV.clear_sensitive_environment_for_eval! do + instance_eval(content, path.to_s).tap do |cask| + raise CaskUnreadableError.new(token, "'#{path}' does not contain a cask.") unless cask.is_a?(Cask) + end end rescue NameError, ArgumentError, ScriptError => e - raise CaskUnreadableError.new(token, e.message) + error = CaskUnreadableError.new(token, e.message) + error.set_backtrace e.backtrace + raise error + rescue CaskInvalidError => e # e.g. NoMethodError from removed DSL methods, wrapped + # as CaskInvalidError by Cask#refresh before reaching here. + if @from_installed_caskfile + error = CaskUnreadableError.new(token, e.reason) + error.set_backtrace e.backtrace + raise error + end + raise end end private + sig { + override.params( + header_token: String, + options: T.untyped, + block: T.nilable(T.proc.bind(DSL).void), + ).returns(Cask) + } def cask(header_token, **options, &block) raise CaskTokenMismatchError.new(token, header_token) if token != header_token @@ -76,31 +230,63 @@ def cask(header_token, **options, &block) end end + # Loads a cask from a URI. class FromURILoader < FromPathLoader - def self.can_load?(ref) - uri_regex = ::URI::DEFAULT_PARSER.make_regexp - return false unless ref.to_s.match?(Regexp.new('\A' + uri_regex.source + '\Z', uri_regex.options)) - - uri = URI(ref) - return false unless uri - return false unless uri.path - - true + sig { + override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.attached_class)) + } + def self.try_new(ref, warn: false) + return if Homebrew::EnvConfig.forbid_packages_from_paths? + + # Cache compiled regex + @uri_regex ||= T.let( + begin + uri_regex = ::URI::RFC2396_PARSER.make_regexp + Regexp.new("\\A#{uri_regex.source}\\Z", uri_regex.options) + end, + T.nilable(Regexp), + ) + + uri = ref.to_s + return unless uri.match?(@uri_regex) + + uri = URI(uri) + return unless uri.path + + new(uri) end + sig { returns(URI::Generic) } attr_reader :url + sig { returns(String) } + attr_reader :name + + sig { params(url: T.any(URI::Generic, String)).void } def initialize(url) - @url = URI(url) - super Cache.path/File.basename(@url.path) + @url = T.let(URI(url), URI::Generic) + url_path = @url.path + raise "unexpected nil url.path" unless url_path + + @name = T.let(File.basename(url_path), String) + super Cache.path/name end - def load + sig { override.params(config: T.nilable(Config)).returns(Cask) } + def load(config:) path.dirname.mkpath + if ALLOWED_URL_SCHEMES.exclude?(url.scheme) + raise UnsupportedInstallationMethod, + "Non-checksummed download of #{name} formula file from an arbitrary URL is unsupported! " \ + "`brew version-install` to install a formula file from your own custom tap " \ + "instead." + end + begin - ohai "Downloading #{url}." - curl_download url, to: path + ohai "Downloading #{url}" + ::Utils::Curl.curl_download url.to_s, to: path rescue ErrorDuringExecution raise CaskUnavailableError.new(token, "Failed to download #{Formatter.url(url)}.") end @@ -109,120 +295,499 @@ def load end end - class FromTapPathLoader < FromPathLoader - def self.can_load?(ref) - super && !Tap.from_path(ref).nil? - end - + # Loads a cask from a specific tap. + class FromTapLoader < FromPathLoader + sig { override.returns(Tap) } attr_reader :tap - def initialize(path) - @tap = Tap.from_path(path) - super(path) - end + sig { + override(allow_incompatible: true) # rubocop:todo Sorbet/AllowIncompatibleOverride + .params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.any(T.attached_class, FromAPILoader))) + } + def self.try_new(ref, warn: false) + ref = ref.to_s - private + return unless (token_tap_type = CaskLoader.tap_cask_token_type(ref, warn:)) - def cask(*args, &block) - super(*args, tap: tap, &block) + loader_from_token_tap_type(token_tap_type) end - end - class FromTapLoader < FromTapPathLoader - def self.can_load?(ref) - ref.to_s.match?(HOMEBREW_TAP_CASK_REGEX) + sig { + params(token_tap_type: [String, Tap, T.nilable(Symbol)]) + .returns(T.nilable(T.any(T.attached_class, FromAPILoader))) + } + def self.loader_from_token_tap_type(token_tap_type) + token, tap, type = token_tap_type + + if type == :migration && tap.core_cask_tap? && (loader = FromAPILoader.try_new(token)) + loader + else + new("#{tap}/#{token}") + end end - def initialize(tapped_name) - user, repo, token = tapped_name.split("/", 3) - super Tap.fetch(user, repo).cask_dir/"#{token}.rb" + sig { params(tapped_token: String).void } + def initialize(tapped_token) + tap_with_token = Tap.with_cask_token(tapped_token) + raise "unexpected nil Tap.with_cask_token" unless tap_with_token + + tap, token = tap_with_token + cask = CaskLoader.find_cask_in_tap(token, tap) + super cask + @tap = T.let(tap, Tap) end - def load - tap.install unless tap.installed? + sig { override.params(config: T.nilable(Config)).returns(Cask) } + def load(config:) + raise TapCaskUnavailableError.new(tap, token) unless tap.installed? super end end + # Loads a cask from an existing {Cask} instance. class FromInstanceLoader - attr_reader :cask - - def self.can_load?(ref) - ref.is_a?(Cask) + include ILoader + + sig { + params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(FromInstanceLoader)) + } + def self.try_new(ref, warn: false) + new(ref) if ref.is_a?(Cask) end + sig { params(cask: Cask).void } def initialize(cask) @cask = cask end - def load - cask + # This is a false positive incompatibililty warning, due to Kernel#load being overridden. + sig { override(allow_incompatible: true).params(config: T.nilable(Config)).returns(Cask) } # rubocop:disable Sorbet/AllowIncompatibleOverride + def load(config:) + @cask + end + end + + # Loads a cask from the JSON API. + class FromAPILoader + include ILoader + + sig { returns(String) } + attr_reader :token + + sig { returns(Pathname) } + attr_reader :path + + sig { returns(T.nilable(T::Hash[String, T.untyped])) } + attr_reader :from_json + + sig { + params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(FromAPILoader)) + } + def self.try_new(ref, warn: false) + return if Homebrew::EnvConfig.no_install_from_api? + return unless ref.is_a?(String) + return unless (token = ref[HOMEBREW_DEFAULT_TAP_CASK_REGEX, :token]) + if !Homebrew::API.cask_token?(token) && + !Homebrew::API.cask_renames.key?(token) + return + end + + ref = "#{CoreCaskTap.instance}/#{token}" + + token, tap, = CaskLoader.tap_cask_token_type(ref, warn:) + new("#{tap}/#{token}") + end + + sig { + params( + token: String, + from_json: T.nilable(T::Hash[String, T.untyped]), + path: T.nilable(Pathname), + from_installed_caskfile: T::Boolean, + from_internal_json: T::Boolean, + ).void + } + def initialize(token, from_json: T.unsafe(nil), path: nil, from_installed_caskfile: false, + from_internal_json: false) + @token = T.let(token.sub(%r{^homebrew/(?:homebrew-)?cask/}i, ""), String) + @sourcefile_path = T.let( + if path + path + elsif from_json + from_internal_json ? Homebrew::API::Internal.cached_packages_json_file_path : Homebrew::API::Cask.cached_json_file_path + else + Homebrew::API.cached_cask_json_file_path + end, + Pathname, + ) + @path = T.let(path || CaskLoader.default_path(@token), Pathname) + @from_json = from_json + @from_installed_caskfile = from_installed_caskfile + @from_internal_json = from_internal_json + end + + # This is a false positive incompatibililty warning, due to Kernel#load being overridden. + sig { override(allow_incompatible: true).params(config: T.nilable(Config)).returns(Cask) } # rubocop:disable Sorbet/AllowIncompatibleOverride + def load(config:) + if (api_source = from_json) + if @from_internal_json + load_from_internal_json(config:, api_source:) + else + load_from_json(config:, api_source:) + end + else + load_from_internal_api(config:) + end + end + + private + + sig { params(config: T.nilable(Config)).returns(Cask) } + def load_from_internal_api(config:) + cask_struct = Homebrew::API::Internal.cask_struct(token) + api_source = Homebrew::API::Internal.cask_hashes.fetch(token) + tap_git_head = Homebrew::API::Internal.cask_tap_git_head + + load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: true) + end + + sig { params(config: T.nilable(Config), api_source: T::Hash[String, T.untyped]).returns(Cask) } + def load_from_json(config:, api_source:) + if @from_installed_caskfile + api_source = api_source.dup + installed_tab = Cask.new(token).tab + api_source["version"] = api_source["version"].presence || + @sourcefile_path.dirname.dirname.dirname.basename.to_s.presence || + installed_tab.version.presence + api_source["artifacts"] ||= installed_tab.uninstall_artifacts || [] + end + + tap_git_head = api_source["tap_git_head"] + cask_struct = Homebrew::API::Cask::CaskStructGenerator.generate_cask_struct_hash( + api_source, ignore_types: @from_installed_caskfile + ) + + load_from_struct(config:, cask_struct:, api_source:, tap_git_head:) + end + + sig { params(config: T.nilable(Config), api_source: T::Hash[String, T.untyped]).returns(Cask) } + def load_from_internal_json(config:, api_source:) + api_source = api_source.dup + tap_git_head = api_source.delete("tap_git_head") + cask_struct = Homebrew::API::CaskStruct.deserialize(api_source) + + load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: true) + end + + sig { + params( + config: T.nilable(Config), + cask_struct: Homebrew::API::CaskStruct, + api_source: T::Hash[String, T.untyped], + tap_git_head: T.nilable(String), + internal_api: T::Boolean, + ).returns(Cask) + } + def load_from_struct(config:, cask_struct:, api_source:, tap_git_head:, internal_api: false) + cask_options = { + loaded_from_api: true, + loaded_from_internal_api: internal_api, + api_source:, + sourcefile_path: @sourcefile_path, + source: JSON.pretty_generate(api_source), + config:, + loader: self, + } + + if (tap_string = cask_struct.tap_string) + cask_options[:tap] = Tap.fetch(tap_string) + end + + api_cask = Cask.new(token, **cask_options) do + version cask_struct.version + sha256 cask_struct.sha256 + + url(*cask_struct.url_args, **cask_struct.url_kwargs) + cask_struct.names.each do |cask_name| + name cask_name + end + desc cask_struct.desc if cask_struct.desc? + homepage cask_struct.homepage if cask_struct.homepage? + + deprecate!(**cask_struct.deprecate_args) if cask_struct.deprecate? + disable!(**cask_struct.disable_args) if cask_struct.disable? + + auto_updates cask_struct.auto_updates if cask_struct.auto_updates? + conflicts_with(**cask_struct.conflicts_with_args) if cask_struct.conflicts? + + cask_struct.renames.each do |from, to| + rename from, to + end + + if cask_struct.depends_on? + args = cask_struct.depends_on_args + begin + depends_on(**args) + rescue MacOSVersion::Error => e + odebug "Ignored invalid macOS version dependency in cask '#{token}': #{args.inspect} (#{e.message})" + nil + end + end + + if cask_struct.container? + container(nested: cask_struct.container_args[:nested], type: cask_struct.container_args[:type]) + end + + cask_struct.artifacts(appdir:).each do |key, args, kwargs, block| + send(key, *args, **kwargs, &block) + end + + caveats T.must(cask_struct.caveats(appdir:)) if cask_struct.caveats? + + if cask_struct.caveats_rosetta + caveats do + # Dynamically defined via `caveat :requires_rosetta` — Sorbet can't resolve it. + T.unsafe(self).requires_rosetta + end + end + end + api_cask.populate_from_api!(cask_struct, tap_git_head:) + api_cask end end + # Loader which tries loading casks from tap paths, failing + # if the same token exists in multiple taps. + class FromNameLoader < FromTapLoader + extend ::Utils::Output::Mixin + + sig { + override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.any(T.attached_class, FromAPILoader))) + } + def self.try_new(ref, warn: false) + return unless ref.is_a?(String) + return unless ref.match?(/\A#{HOMEBREW_TAP_CASK_TOKEN_REGEX}\Z/o) + + token = ref.downcase + + # If it exists in the default tap, never treat it as ambiguous with another tap. + if (core_cask_tap = CoreCaskTap.instance).installed? && (token_tap_type = CaskLoader.tap_cask_token_type( + "#{core_cask_tap}/#{token}", warn: false + )) + migrated_token, migrated_tap, type = token_tap_type + + if warn && [:rename, :migration].include?(type) && + !(type == :migration && migrated_tap.core_tap?) + opoo "Cask #{token} was renamed to " \ + "#{migrated_tap.core_cask_tap? ? migrated_token : "#{migrated_tap}/#{migrated_token}"}." + end + + if (core_cask_loader = loader_from_token_tap_type(token_tap_type))&.path&.exist? + return core_cask_loader + end + end + + loaders = Tap.select { |tap| tap.installed? && !tap.core_cask_tap? } + .filter_map { |tap| super("#{tap}/#{token}", warn:) } + .uniq(&:path) + .select { |loader| loader.is_a?(FromAPILoader) || loader.path.exist? } + + case loaders.count + when 1 + loaders.first + when 2..Float::INFINITY + raise TapCaskAmbiguityError.new(token, loaders) + end + end + end + + # Loader which loads a cask from the installed cask file. + class FromInstalledPathLoader < FromPathLoader + sig { + override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.attached_class)) + } + def self.try_new(ref, warn: false) + token = if ref.is_a?(String) + ref + elsif ref.is_a?(Pathname) + ref.basename(ref.extname).basename(".internal").to_s + end + return unless token + + possible_installed_cask = Cask.new(token) + return unless (installed_caskfile = possible_installed_cask.installed_caskfile) + + new(installed_caskfile) + end + + sig { params(path: T.any(Pathname, String), token: String).void } + def initialize(path, token: "") + super + + installed_tap = Cask.new(@token).tab.tap + @tap = installed_tap if installed_tap + @from_installed_caskfile = T.let(true, T::Boolean) + end + end + + # Pseudo-loader which raises an error when trying to load the corresponding cask. class NullLoader < FromPathLoader - def self.can_load?(*) - true + sig { + override.params(ref: T.any(String, Pathname, Cask, URI::Generic), warn: T::Boolean) + .returns(T.nilable(T.attached_class)) + } + def self.try_new(ref, warn: false) + return if ref.is_a?(Cask) + return if ref.is_a?(URI::Generic) + + new(ref) end + sig { params(ref: T.any(String, Pathname)).void } def initialize(ref) token = File.basename(ref, ".rb") super CaskLoader.default_path(token) end - def load + sig { override.params(config: T.nilable(Config)).returns(Cask) } + def load(config:) raise CaskUnavailableError.new(token, "No Cask with this name exists.") end end + # NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method, + # which would interfere with RSpec mocking of this class method. + T::Sig::WithoutRuntime.sig { params(ref: T.any(String, Pathname, Cask, URI::Generic)).returns(Pathname) } def self.path(ref) - self.for(ref).path + T.cast(self.for(ref, need_path: true), T.any(FromAPILoader, FromPathLoader)).path + end + + # NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method, + # which would interfere with RSpec mocking of this class method. + T::Sig::WithoutRuntime.sig { + params(ref: T.any(String, Symbol, Pathname, Cask, URI::Generic), config: T.nilable(Config), + warn: T::Boolean).returns(Cask) + } + def self.load(ref, config: nil, warn: true) + normalized_ref = ref.is_a?(Symbol) ? ref.to_s : ref + self.for(normalized_ref, warn:).load(config:) end - def self.load(ref) - self.for(ref).load + sig { params(tapped_token: String, warn: T::Boolean).returns(T.nilable([String, Tap, T.nilable(Symbol)])) } + def self.tap_cask_token_type(tapped_token, warn:) + return unless (tap_with_token = Tap.with_cask_token(tapped_token)) + + tap, token = tap_with_token + + type = nil + + if (new_token = tap.cask_renames[token].presence) + old_token = tap.core_cask_tap? ? token : tapped_token + token = new_token + new_token = tap.core_cask_tap? ? token : "#{tap}/#{token}" + type = :rename + elsif (new_tap_name = tap.tap_migrations[token].presence) + new_tap, new_token = Tap.with_cask_token(new_tap_name) + unless new_tap + if new_tap_name.include?("/") + new_tap = Tap.fetch(new_tap_name) + new_token = token + else + new_tap = tap + new_token = new_tap_name + end + end + new_tapped_token = "#{new_tap}/#{new_token}" + + if tapped_token != new_tapped_token + old_token = tap.core_cask_tap? ? token : tapped_token + return unless (token_tap_type = tap_cask_token_type(new_tapped_token, warn: false)) + + token, tap, = token_tap_type + new_token = new_tap.core_cask_tap? ? token : "#{tap}/#{token}" + type = :migration + end + end + + if warn && old_token && new_token + destination_exists = find_cask_in_tap(token, tap).exist? || + (tap.core_cask_tap? && !Homebrew::EnvConfig.no_install_from_api? && + Homebrew::API.cask_token?(token)) + opoo "Cask #{old_token} was renamed to #{new_token}." if destination_exists + end + + [token, tap, type] end - def self.for(ref) + # NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method, + # which would interfere with RSpec mocking of this class method. + T::Sig::WithoutRuntime.sig { + params(ref: T.any(String, Pathname, Cask, URI::Generic), need_path: T::Boolean, warn: T::Boolean) + .returns(ILoader) + } + def self.for(ref, need_path: false, warn: true) [ FromInstanceLoader, FromContentLoader, FromURILoader, + FromAPILoader, FromTapLoader, - FromTapPathLoader, + FromNameLoader, FromPathLoader, + FromInstalledPathLoader, + NullLoader, ].each do |loader_class| - return loader_class.new(ref) if loader_class.can_load?(ref) + if (loader = loader_class.try_new(ref, warn:)) + $stderr.puts "#{$PROGRAM_NAME} (#{loader.class}): loading #{ref}" if verbose? && debug? + return loader + end end - return FromTapPathLoader.new(default_path(ref)) if FromTapPathLoader.can_load?(default_path(ref)) + raise CaskError, "No cask loader found for #{ref.inspect}" + end - case (possible_tap_casks = tap_paths(ref)).count - when 1 - return FromTapPathLoader.new(possible_tap_casks.first) - when 2..Float::INFINITY - loaders = possible_tap_casks.map(&FromTapPathLoader.method(:new)) + sig { params(ref: String, config: T.nilable(Config), warn: T::Boolean).returns(Cask) } + def self.load_prefer_installed(ref, config: nil, warn: true) + tap, token = Tap.with_cask_token(ref) + token ||= ref + tap ||= Cask.new(ref).tab.tap - raise CaskError, <<~EOS - Cask #{ref} exists in multiple taps: - #{loaders.map { |loader| " #{loader.tap}/#{loader.token}" }.join("\n")} - EOS + if tap.nil? + self.load(token, config:, warn:) + else + begin + self.load("#{tap}/#{token}", config:, warn:) + rescue CaskUnavailableError + # cask may be migrated to different tap. Try to search in all taps. + self.load(token, config:, warn:) + end end + end - possible_installed_cask = Cask.new(ref) - return FromPathLoader.new(possible_installed_cask.installed_caskfile) if possible_installed_cask.installed? + sig { params(path: Pathname, config: T.nilable(Config), warn: T::Boolean).returns(Cask) } + def self.load_from_installed_caskfile(path, config: nil, warn: true) + loader = FromInstalledPathLoader.try_new(path, warn:) + loader ||= NullLoader.new(path) - NullLoader.new(ref) + loader.load(config:) end + sig { params(token: T.any(String, Symbol)).returns(Pathname) } def self.default_path(token) - Tap.default_cask_tap.cask_dir/"#{token.to_s.downcase}.rb" + find_cask_in_tap(token.to_s.downcase, CoreCaskTap.instance) end - def self.tap_paths(token) - Tap.map { |t| t.cask_dir/"#{token.to_s.downcase}.rb" } - .select(&:exist?) + sig { params(token: String, tap: Tap).returns(Pathname) } + def self.find_cask_in_tap(token, tap) + filename = "#{token}.rb" + + tap.cask_files_by_name.fetch(token, tap.cask_dir/filename) end end end diff --git a/Library/Homebrew/cask/caskroom.rb b/Library/Homebrew/cask/caskroom.rb index 542ea0bcadff3..536d06a4bc920 100644 --- a/Library/Homebrew/cask/caskroom.rb +++ b/Library/Homebrew/cask/caskroom.rb @@ -1,45 +1,153 @@ +# typed: strict # frozen_string_literal: true require "utils/user" +require "utils/output" module Cask + # Helper functions for interacting with the `Caskroom` directory. + # + # @api internal module Caskroom - module_function + extend ::Utils::Output::Mixin - def path - @path ||= HOMEBREW_PREFIX.join("Caskroom") + CASKFILE_EXTENSIONS = %w[json internal.json rb].freeze + + sig { returns(Pathname) } + def self.path + @path ||= T.let(HOMEBREW_PREFIX/"Caskroom", T.nilable(Pathname)) + end + + # Return all paths for installed casks. + sig { returns(T::Array[Pathname]) } + def self.paths + return [] unless path.exist? + + path.children.select { |p| p.directory? && !p.symlink? } + end + private_class_method :paths + + # Return all tokens for installed casks. + sig { returns(T::Array[String]) } + def self.tokens + paths.map { |path| path.basename.to_s } + end + + sig { returns(T::Boolean) } + def self.any_casks_installed? + paths.any? + end + + sig { params(token: String).returns(T::Boolean) } + def self.cask_installed?(token) + !cask_installed_version(token).nil? + end + + sig { params(token: String, old_tokens: T::Array[String]).returns(T.nilable(Pathname)) } + def self.cask_installed_caskfile(token, old_tokens: []) + # Check if the cask is installed with an old name. + [token, *old_tokens].map { |cask_token| token_from_full_token(cask_token) }.uniq.each do |cask_token| + caskroom_path = path/cask_token + next if !caskroom_path.directory? || caskroom_path.symlink? + + timestamped_path = Pathname.glob((caskroom_path/".metadata/*/*").to_s).max_by { |p| p.basename.to_s } + next unless timestamped_path + + caskfile = CASKFILE_EXTENSIONS.map { |ext| timestamped_path/"Casks/#{cask_token}.#{ext}" } + .find(&:exist?) + return caskfile if caskfile + end + + nil + end + + sig { params(token: String, old_tokens: T::Array[String]).returns(T.nilable(String)) } + def self.cask_installed_version(token, old_tokens: []) + return unless (caskfile = cask_installed_caskfile(token, old_tokens:)) + + caskfile.dirname.dirname.dirname.basename.to_s end - def ensure_caskroom_exists + # Return tokens for Caskroom directories missing expected installed metadata. + sig { returns(T::Array[String]) } + def self.corrupt_cask_dirs + paths.filter_map { |p| p.basename.to_s unless cask_with_metadata?(p) } + end + + sig { params(cask_path: Pathname).returns(T::Boolean) } + def self.cask_with_metadata?(cask_path) + cask_path.glob(".metadata/*/*/Casks/*.{rb,json}").any? + end + private_class_method :cask_with_metadata? + + sig { params(token: String).returns(String) } + def self.token_from_full_token(token) + _, _, cask_token = token.split("/", 3) + cask_token || token + end + private_class_method :token_from_full_token + + sig { void } + def self.ensure_caskroom_exists return if path.exist? sudo = !path.parent.writable? if sudo && !ENV.key?("SUDO_ASKPASS") && $stdout.tty? - ohai "Creating Caskroom at #{path}" - ohai "We'll set permissions properly so we won't need sudo in the future." + ohai "Creating Caskroom directory: #{path}", + "We'll set permissions properly so we won't need sudo in the future." end - SystemCommand.run("/bin/mkdir", args: ["-p", path], sudo: sudo) - SystemCommand.run("/bin/chmod", args: ["g+rwx", path], sudo: sudo) - SystemCommand.run("/usr/sbin/chown", args: [User.current, path], sudo: sudo) - SystemCommand.run("/usr/bin/chgrp", args: ["admin", path], sudo: sudo) + SystemCommand.run("mkdir", args: ["-p", path], sudo:) + SystemCommand.run("chmod", args: ["g+rwx", path], sudo:) + SystemCommand.run("chown", args: [User.current.to_s, path], sudo:) + + chgrp_path(path, sudo) unless caskroom_group_correct?(path) end - def casks - return [] unless path.exist? + sig { params(path: Pathname, sudo: T::Boolean).void } + def self.chgrp_path(path, sudo) + SystemCommand.run("chgrp", args: [expected_caskroom_group, path], sudo:) + end - Pathname.glob(path.join("*")).sort.select(&:directory?).map do |path| - token = path.basename.to_s + sig { params(path: Pathname).returns(T::Boolean) } + def self.caskroom_group_correct?(path) + group = Etc.getgrnam(expected_caskroom_group) + return false if group.nil? - if tap_path = CaskLoader.tap_paths(token).first - CaskLoader::FromTapPathLoader.new(tap_path).load - elsif caskroom_path = Pathname.glob(path.join(".metadata/*/*/*/*.rb")).first - CaskLoader::FromPathLoader.new(caskroom_path).load - else - CaskLoader.load(token) + path.stat.gid == group.gid + end + + sig { returns(String) } + def self.expected_caskroom_group + "admin" + end + + # Get all installed casks. + # + # @api internal + sig { params(config: T.nilable(Config)).returns(T::Array[Cask]) } + def self.casks(config: nil) + tokens.sort.filter_map do |token| + # This is nested so that the rescue can catch errors from both branches + begin + CaskLoader.load_prefer_installed(token, config:, warn: false) + rescue TapCaskAmbiguityError => e + e.loaders.fetch(0).load(config:) end - end + rescue Homebrew::UntrustedTapError + # If the tap is untrusted the only place we can load the cask from is the installed cask file, if it exists. + begin + CaskLoader::FromInstalledPathLoader.try_new(token, warn: false)&.load(config:) + rescue + nil + end + rescue + # Don't blow up because of a single unavailable cask. + nil + end.select(&:installed?) end end end + +require "extend/os/cask/caskroom" diff --git a/Library/Homebrew/cask/checkable.rb b/Library/Homebrew/cask/checkable.rb deleted file mode 100644 index 1e7a87464d2b6..0000000000000 --- a/Library/Homebrew/cask/checkable.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Cask - module Checkable - def errors - @errors ||= [] - end - - def warnings - @warnings ||= [] - end - - def add_error(message) - errors << message - end - - def add_warning(message) - warnings << message - end - - def errors? - errors.any? - end - - def warnings? - warnings.any? - end - - def result - if errors? - Formatter.error("failed") - elsif warnings? - Formatter.warning("warning") - else - Formatter.success("passed") - end - end - - def summary - summary = ["#{summary_header}: #{result}"] - - errors.each do |error| - summary << " #{Formatter.error("-")} #{error}" - end - - warnings.each do |warning| - summary << " #{Formatter.warning("-")} #{warning}" - end - - summary.join("\n") - end - end -end diff --git a/Library/Homebrew/cask/cmd.rb b/Library/Homebrew/cask/cmd.rb deleted file mode 100644 index 0405b19725c9f..0000000000000 --- a/Library/Homebrew/cask/cmd.rb +++ /dev/null @@ -1,261 +0,0 @@ -# frozen_string_literal: true - -require "optparse" -require "shellwords" - -require "extend/optparse" - -require "cask/config" - -require "cask/cmd/options" - -require "cask/cmd/abstract_command" -require "cask/cmd/--cache" -require "cask/cmd/audit" -require "cask/cmd/cat" -require "cask/cmd/create" -require "cask/cmd/doctor" -require "cask/cmd/edit" -require "cask/cmd/fetch" -require "cask/cmd/home" -require "cask/cmd/info" -require "cask/cmd/install" -require "cask/cmd/list" -require "cask/cmd/outdated" -require "cask/cmd/reinstall" -require "cask/cmd/style" -require "cask/cmd/uninstall" -require "cask/cmd/upgrade" -require "cask/cmd/zap" - -require "cask/cmd/abstract_internal_command" -require "cask/cmd/internal_help" -require "cask/cmd/internal_stanza" - -module Cask - class Cmd - ALIASES = { - "ls" => "list", - "homepage" => "home", - "-S" => "search", # verb starting with "-" is questionable - "up" => "update", - "instal" => "install", # gem does the same - "uninstal" => "uninstall", - "rm" => "uninstall", - "remove" => "uninstall", - "abv" => "info", - "dr" => "doctor", - }.freeze - - include Options - - option "--appdir=PATH", ->(value) { Config.global.appdir = value } - option "--colorpickerdir=PATH", ->(value) { Config.global.colorpickerdir = value } - option "--prefpanedir=PATH", ->(value) { Config.global.prefpanedir = value } - option "--qlplugindir=PATH", ->(value) { Config.global.qlplugindir = value } - option "--dictionarydir=PATH", ->(value) { Config.global.dictionarydir = value } - option "--fontdir=PATH", ->(value) { Config.global.fontdir = value } - option "--servicedir=PATH", ->(value) { Config.global.servicedir = value } - option "--input_methoddir=PATH", ->(value) { Config.global.input_methoddir = value } - option "--internet_plugindir=PATH", ->(value) { Config.global.internet_plugindir = value } - option "--audio_unit_plugindir=PATH", ->(value) { Config.global.audio_unit_plugindir = value } - option "--vst_plugindir=PATH", ->(value) { Config.global.vst_plugindir = value } - option "--vst3_plugindir=PATH", ->(value) { Config.global.vst3_plugindir = value } - option "--screen_saverdir=PATH", ->(value) { Config.global.screen_saverdir = value } - - option "--help", :help, false - - # handled in OS::Mac - option "--language a,b,c", ->(*) {} - - # override default handling of --version - option "--version", ->(*) { raise OptionParser::InvalidOption } - - def self.command_classes - @command_classes ||= constants.map(&method(:const_get)) - .select { |klass| klass.respond_to?(:run) } - .reject(&:abstract?) - .sort_by(&:command_name) - end - - def self.commands - @commands ||= command_classes.map(&:command_name) - end - - def self.lookup_command(command_name) - @lookup ||= Hash[commands.zip(command_classes)] - command_name = ALIASES.fetch(command_name, command_name) - @lookup.fetch(command_name, command_name) - end - - def self.run_command(command, *args) - return command.run(*args) if command.respond_to?(:run) - - tap_cmd_directories = Tap.cmd_directories - - path = PATH.new(tap_cmd_directories, ENV["HOMEBREW_PATH"]) - - external_ruby_cmd = tap_cmd_directories.map { |d| d/"brewcask-#{command}.rb" } - .find(&:file?) - external_ruby_cmd ||= which("brewcask-#{command}.rb", path) - - if external_ruby_cmd - require external_ruby_cmd - - klass = begin - const_get(command.to_s.capitalize.to_sym) - rescue NameError - # External command is a stand-alone Ruby script. - return - end - - return klass.run(*args) - end - - if external_command = which("brewcask-#{command}", path) - exec external_command, *ARGV[1..-1] - end - - NullCommand.new(command, *args).run - end - - def self.run(*args) - new(*args).run - end - - def initialize(*args) - @args = process_options(*args) - end - - def detect_command_and_arguments(*args) - command = args.find do |arg| - if self.class.commands.include?(arg) - true - else - break unless arg.start_with?("-") - end - end - - if index = args.index(command) - args.delete_at(index) - end - - [*command, *args] - end - - def run - command_name, *args = detect_command_and_arguments(*@args) - command = if help? - args.unshift(command_name) unless command_name.nil? - "help" - else - self.class.lookup_command(command_name) - end - - MacOS.full_version = ENV["MACOS_VERSION"] unless ENV["MACOS_VERSION"].nil? - - Tap.default_cask_tap.install unless Tap.default_cask_tap.installed? - self.class.run_command(command, *args) - rescue CaskError, MethodDeprecatedError, ArgumentError, OptionParser::InvalidOption => e - onoe e.message - $stderr.puts e.backtrace if ARGV.debug? - exit 1 - rescue StandardError, ScriptError, NoMemoryError => e - onoe e.message - $stderr.puts Utils.error_message_with_suggestions - $stderr.puts e.backtrace - exit 1 - end - - def self.nice_listing(cask_list) - cask_taps = {} - cask_list.each do |c| - user, repo, token = c.split "/" - repo.sub!(/^homebrew-/i, "") - cask_taps[token] ||= [] - cask_taps[token].push "#{user}/#{repo}" - end - list = [] - cask_taps.each do |token, taps| - if taps.length == 1 - list.push token - else - taps.each { |r| list.push [r, token].join "/" } - end - end - list.sort - end - - def process_options(*args) - exclude_regex = /^\-\-#{Regexp.union(*Config::DEFAULT_DIRS.keys.map(&Regexp.public_method(:escape)))}=/ - - all_args = Shellwords.shellsplit(ENV.fetch("HOMEBREW_CASK_OPTS", "")) - .reject { |arg| arg.match?(exclude_regex) } + args - - non_options = [] - - if idx = all_args.index("--") - non_options += all_args.drop(idx) - all_args = all_args.first(idx) - end - - remaining = all_args.select do |arg| - !process_arguments([arg]).empty? - rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::AmbiguousOption - true - end - - remaining + non_options - end - - class NullCommand - def initialize(command, *args) - @command = command - @args = args - end - - def run(*) - purpose - usage - - return if @command.nil? - - if @command == "help" - return if @args.empty? - - raise ArgumentError, "help does not take arguments." if @args.length - end - - raise ArgumentError, "Unknown Cask command: #{@command}" - end - - def purpose - puts <<~EOS - Homebrew Cask provides a friendly CLI workflow for the administration - of macOS applications distributed as binaries. - - EOS - end - - def usage - max_command_len = Cmd.commands.map(&:length).max - - puts "Commands:\n\n" - Cmd.command_classes.each do |klass| - next unless klass.visible - - puts " #{klass.command_name.ljust(max_command_len)} #{_help_for(klass)}" - end - puts %Q(\nSee also "man brew-cask") - end - - def help - "" - end - - def _help_for(klass) - klass.respond_to?(:help) ? klass.help : nil - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/--cache.rb b/Library/Homebrew/cask/cmd/--cache.rb deleted file mode 100644 index d3aaf8e027096..0000000000000 --- a/Library/Homebrew/cask/cmd/--cache.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require "cask/download" - -module Cask - class Cmd - class Cache < AbstractCommand - def self.command_name - "--cache" - end - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - casks.each do |cask| - puts Download.new(cask).downloader.cached_location - end - end - - def self.help - "display the file used to cache the Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/abstract_command.rb b/Library/Homebrew/cask/cmd/abstract_command.rb deleted file mode 100644 index b08a3ea897a5b..0000000000000 --- a/Library/Homebrew/cask/cmd/abstract_command.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require_relative "options" -require "search" - -module Cask - class Cmd - class AbstractCommand - include Options - include Homebrew::Search - - option "--[no-]binaries", :binaries, true - option "--debug", :debug, false - option "--verbose", :verbose, false - option "--outdated", :outdated_only, false - option "--require-sha", :require_sha, false - option "--[no-]quarantine", :quarantine, true - - def self.command_name - @command_name ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase - end - - def self.abstract? - name.split("::").last.match?(/^Abstract[^a-z]/) - end - - def self.visible - true - end - - def self.help - nil - end - - def self.run(*args) - new(*args).run - end - - attr_accessor :args - private :args= - - def initialize(*args) - @args = process_arguments(*args) - end - - private - - def casks(alternative: -> { [] }) - return @casks if defined?(@casks) - - casks = args.empty? ? alternative.call : args - @casks = casks.map { |cask| CaskLoader.load(cask) } - rescue CaskUnavailableError => e - reason = [e.reason, *suggestion_message(e.token)].join(" ") - raise e.class.new(e.token, reason) - end - - def suggestion_message(cask_token) - matches = search_casks(cask_token) - - if matches.one? - "Did you mean “#{matches.first}”?" - elsif !matches.empty? - "Did you mean one of these?\n#{Formatter.columns(matches.take(20))}" - end - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/abstract_internal_command.rb b/Library/Homebrew/cask/cmd/abstract_internal_command.rb deleted file mode 100644 index c07fe8b5be57e..0000000000000 --- a/Library/Homebrew/cask/cmd/abstract_internal_command.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class AbstractInternalCommand < AbstractCommand - def self.command_name - super.sub(/^internal_/i, "_") - end - - def self.visible - false - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/audit.rb b/Library/Homebrew/cask/cmd/audit.rb deleted file mode 100644 index f046b91df436c..0000000000000 --- a/Library/Homebrew/cask/cmd/audit.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Audit < AbstractCommand - option "--download", :download, false - option "--appcast", :appcast, false - option "--token-conflicts", :token_conflicts, false - - def self.help - "verifies installability of Casks" - end - - def run - failed_casks = casks(alternative: -> { Cask.to_a }) - .reject { |cask| audit(cask) } - - return if failed_casks.empty? - - raise CaskError, "audit failed for casks: #{failed_casks.join(" ")}" - end - - def audit(cask) - odebug "Auditing Cask #{cask}" - Auditor.audit(cask, audit_download: download?, - audit_appcast: appcast?, - check_token_conflicts: token_conflicts?, - quarantine: quarantine?) - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/cat.rb b/Library/Homebrew/cask/cmd/cat.rb deleted file mode 100644 index 6278522570067..0000000000000 --- a/Library/Homebrew/cask/cmd/cat.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Cat < AbstractCommand - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - casks.each do |cask| - puts File.open(cask.sourcefile_path, &:read) - end - end - - def self.help - "dump raw source of the given Cask to the standard output" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/create.rb b/Library/Homebrew/cask/cmd/create.rb deleted file mode 100644 index 68fb976da0aab..0000000000000 --- a/Library/Homebrew/cask/cmd/create.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Create < AbstractCommand - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - raise ArgumentError, "Only one Cask can be created at a time." if args.count > 1 - end - - def run - cask_token = args.first - cask_path = CaskLoader.path(cask_token) - raise CaskAlreadyCreatedError, cask_token if cask_path.exist? - - odebug "Creating Cask #{cask_token}" - File.open(cask_path, "w") do |f| - f.write self.class.template(cask_token) - end - - exec_editor cask_path - end - - def self.template(cask_token) - <<~RUBY - cask '#{cask_token}' do - version '' - sha256 '' - - url "https://" - name '' - homepage '' - - app '' - end - RUBY - end - - def self.help - "creates the given Cask and opens it in an editor" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/doctor.rb b/Library/Homebrew/cask/cmd/doctor.rb deleted file mode 100644 index dc6bead70e161..0000000000000 --- a/Library/Homebrew/cask/cmd/doctor.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -require "system_config" -require "cask/checkable" - -module Cask - class Cmd - class Doctor < AbstractCommand - include Checkable - - def initialize(*) - super - return if args.empty? - - raise ArgumentError, "#{self.class.command_name} does not take arguments." - end - - def success? - !(errors? || warnings?) - end - - def summary_header - "Cask's Doctor Checkup" - end - - def run - check_software_versions - check_xattr - check_quarantine_support - check_install_location - check_staging_location - check_taps - check_load_path - check_environment_variables - - puts summary unless success? - raise CaskError, "There are some problems with your setup." unless success? - end - - def check_software_versions - ohai "Homebrew Version", HOMEBREW_VERSION - ohai "macOS", MacOS.full_version - ohai "SIP", self.class.check_sip - ohai "Java", SystemConfig.describe_java - end - - # This could be done by calling into Homebrew, but the situation - # where `brew doctor` is needed is precisely the situation where such - # things are less dependable. - def check_install_location - ohai "Homebrew Cask Install Location" - - locations = Dir.glob(HOMEBREW_CELLAR.join("brew-cask", "*")).reverse - if locations.empty? - puts self.class.none_string - else - locations.map do |l| - add_error "Legacy install at #{l}. Run `brew uninstall --force brew-cask`." - puts l - end - end - end - - def check_staging_location - ohai "Homebrew Cask Staging Location" - - path = Caskroom.path - - if path.exist? && !path.writable? - add_error "The staging path #{user_tilde(path.to_s)} is not writable by the current user." - add_error "To fix, run \'sudo chown -R $(whoami):staff #{user_tilde(path.to_s)}'" - end - - puts user_tilde(path.to_s) - end - - def check_taps - default_tap = Tap.default_cask_tap - alt_taps = Tap.select { |t| t.cask_dir.exist? && t != default_tap } - - ohai "Homebrew Cask Taps:" - [default_tap, *alt_taps].each do |tap| - if tap.path.blank? - puts none_string - else - puts "#{tap.path} (#{cask_count_for_tap(tap)})" - end - end - end - - def check_load_path - ohai "Contents of $LOAD_PATH" - paths = $LOAD_PATH.map(&method(:user_tilde)) - - if paths.empty? - puts none_string - add_error "$LOAD_PATH is empty" - else - puts paths - end - end - - def check_environment_variables - ohai "Environment Variables" - - environment_variables = %w[ - RUBYLIB - RUBYOPT - RUBYPATH - RBENV_VERSION - CHRUBY_VERSION - GEM_HOME - GEM_PATH - BUNDLE_PATH - PATH - SHELL - HOMEBREW_CASK_OPTS - ] - - locale_variables = ENV.keys.grep(/^(?:LC_\S+|LANG|LANGUAGE)\Z/).sort - - (locale_variables + environment_variables).sort.each(&method(:render_env_var)) - end - - def check_xattr - ohai "xattr issues" - result = system_command "/usr/bin/xattr" - - if result.status.success? - puts none_string - elsif result.stderr.include? "ImportError: No module named pkg_resources" - result = system_command "/usr/bin/python", "--version" - - if result.stdout.include? "Python 2.7" - add_error "Your Python installation has a broken version of setuptools." - add_error "To fix, reinstall macOS or run 'sudo /usr/bin/python -m pip install -I setuptools'." - else - add_error "The system Python version is wrong." - add_error "To fix, run 'defaults write com.apple.versioner.python Version 2.7'." - end - elsif result.stderr.include? "pkg_resources.DistributionNotFound" - add_error "Your Python installation is unable to find xattr." - else - add_error "unknown xattr error: #{result.stderr.split("\n").last}" - end - end - - def check_quarantine_support - ohai "Gatekeeper support" - - case Quarantine.check_quarantine_support - when :quarantine_available - puts "Enabled" - when :xattr_broken - add_error "There's not a working version of xattr." - when :no_swift - add_error "Swift is not available on this system." - when :no_quarantine - add_error "This feature requires the macOS 10.10 SDK or higher." - else - onoe "Unknown support status" - end - end - - def user_tilde(path) - self.class.user_tilde(path) - end - - def cask_count_for_tap(tap) - self.class.cask_count_for_tap(tap) - end - - def none_string - self.class.none_string - end - - def render_env_var(var) - self.class.render_env_var(var) - end - - def self.check_sip - csrutil = "/usr/bin/csrutil" - return "N/A" unless File.executable?(csrutil) - - Open3.capture2(csrutil, "status") - .first - .gsub("This is an unsupported configuration, likely to break in " \ - "the future and leave your machine in an unknown state.", "") - .gsub("System Integrity Protection status: ", "") - .delete("\t\.") - .capitalize - .strip - end - - def self.locale_variables - ENV.keys.grep(/^(?:LC_\S+|LANG|LANGUAGE)\Z/).sort - end - - def self.none_string - "" - end - - def self.error_string(string = "Error") - Formatter.error("(#{string})") - end - - def self.alt_taps - Tap.select { |t| t.cask_dir.exist? && t != Tap.default_cask_tap } - end - - def self.cask_count_for_tap(tap) - cask_count = begin - tap.cask_files.count - rescue - add_error "Unable to read from Tap: #{tap.path}" - 0 - end - - "#{cask_count} #{"cask".pluralize(cask_count)}" - end - - def self.render_env_var(var) - return unless ENV.key?(var) - - var = %Q(#{var}="#{ENV[var]}") - puts user_tilde(var) - end - - def self.user_tilde(path) - path.gsub(ENV["HOME"], "~") - end - - def self.help - "checks for configuration issues" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/edit.rb b/Library/Homebrew/cask/cmd/edit.rb deleted file mode 100644 index ff5f7569c2990..0000000000000 --- a/Library/Homebrew/cask/cmd/edit.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Edit < AbstractCommand - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - raise ArgumentError, "Only one Cask can be edited at a time." if args.count > 1 - end - - def run - exec_editor cask_path - rescue CaskUnavailableError => e - reason = e.reason.empty? ? +"" : +"#{e.reason} " - reason.concat("Run #{Formatter.identifier("brew cask create #{e.token}")} to create a new Cask.") - raise e.class.new(e.token, reason.freeze) - end - - def cask_path - casks.first.sourcefile_path - rescue CaskInvalidError, CaskUnreadableError, MethodDeprecatedError - path = CaskLoader.path(args.first) - return path if path.file? - - raise - end - - def self.help - "edits the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/fetch.rb b/Library/Homebrew/cask/cmd/fetch.rb deleted file mode 100644 index cebf3d8d894df..0000000000000 --- a/Library/Homebrew/cask/cmd/fetch.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require "cask/download" - -module Cask - class Cmd - class Fetch < AbstractCommand - option "--force", :force, false - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - casks.each do |cask| - puts Installer.caveats(cask) - ohai "Downloading external files for Cask #{cask}" - downloaded_path = Download.new(cask, force: force?, quarantine: quarantine?).perform - Verify.all(cask, downloaded_path) - ohai "Success! Downloaded to -> #{downloaded_path}" - end - end - - def self.help - "downloads remote application files to local cache" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/home.rb b/Library/Homebrew/cask/cmd/home.rb deleted file mode 100644 index 16adc13a5d22a..0000000000000 --- a/Library/Homebrew/cask/cmd/home.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Home < AbstractCommand - def run - if casks.none? - odebug "Opening project homepage" - self.class.open_url "https://brew.sh/" - else - casks.each do |cask| - odebug "Opening homepage for Cask #{cask}" - self.class.open_url cask.homepage - end - end - end - - def self.open_url(url) - SystemCommand.run!(OS::PATH_OPEN, args: ["--", url]) - end - - def self.help - "opens the homepage of the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/info.rb b/Library/Homebrew/cask/cmd/info.rb deleted file mode 100644 index a09291bce0d95..0000000000000 --- a/Library/Homebrew/cask/cmd/info.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require "json" -require "cask/installer" - -module Cask - class Cmd - class Info < AbstractCommand - option "--json=VERSION", :json - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - if json == "v1" - puts JSON.generate(casks.map(&:to_h)) - else - casks.each_with_index do |cask, i| - puts unless i.zero? - odebug "Getting info for Cask #{cask}" - self.class.info(cask) - end - end - end - - def self.help - "displays information about the given Cask" - end - - def self.get_info(cask) - output = title_info(cask) + "\n" - output << Formatter.url(cask.homepage) + "\n" if cask.homepage - output << installation_info(cask) - repo = repo_info(cask) - output << repo + "\n" if repo - output << name_info(cask) - language = language_info(cask) - output << language if language - output << artifact_info(cask) + "\n" - caveats = Installer.caveats(cask) - output << caveats if caveats - output - end - - def self.info(cask) - puts get_info(cask) - end - - def self.title_info(cask) - title = "#{cask.token}: #{cask.version}" - title += " (auto_updates)" if cask.auto_updates - title - end - - def self.formatted_url(url) - "#{Tty.underline}#{url}#{Tty.reset}" - end - - def self.installation_info(cask) - return "Not installed\n" unless cask.installed? - - install_info = +"" - cask.versions.each do |version| - versioned_staged_path = cask.caskroom_path.join(version) - path_details = if versioned_staged_path.exist? - versioned_staged_path.abv - else - Formatter.error("does not exist") - end - install_info << "#{versioned_staged_path} (#{path_details})\n" - end - install_info.freeze - end - - def self.name_info(cask) - <<~EOS - #{ohai_title((cask.name.size > 1) ? "Names" : "Name")} - #{cask.name.empty? ? Formatter.error("None") : cask.name.join("\n")} - EOS - end - - def self.language_info(cask) - return if cask.languages.empty? - - <<~EOS - #{ohai_title("Languages")} - #{cask.languages.join(", ")} - EOS - end - - def self.repo_info(cask) - return if cask.tap.nil? - - url = if cask.tap.custom_remote? && !cask.tap.remote.nil? - cask.tap.remote - else - "#{cask.tap.default_remote}/blob/master/Casks/#{cask.token}.rb" - end - - "From: #{Formatter.url(url)}" - end - - def self.artifact_info(cask) - artifact_output = ohai_title("Artifacts").dup - cask.artifacts.each do |artifact| - next unless artifact.respond_to?(:install_phase) - next unless DSL::ORDINARY_ARTIFACT_CLASSES.include?(artifact.class) - - artifact_output << "\n" << artifact.to_s - end - artifact_output.freeze - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/install.rb b/Library/Homebrew/cask/cmd/install.rb deleted file mode 100644 index 5c82efc12b218..0000000000000 --- a/Library/Homebrew/cask/cmd/install.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Install < AbstractCommand - option "--force", :force, false - option "--skip-cask-deps", :skip_cask_deps, false - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - odie "Installing casks is supported only on macOS" unless OS.mac? - casks.each do |cask| - Installer.new(cask, binaries: binaries?, - verbose: verbose?, - force: force?, - skip_cask_deps: skip_cask_deps?, - require_sha: require_sha?, - quarantine: quarantine?).install - rescue CaskAlreadyInstalledError => e - opoo e.message - end - end - - def self.help - "installs the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/internal_help.rb b/Library/Homebrew/cask/cmd/internal_help.rb deleted file mode 100644 index 2ddac93dbe7f2..0000000000000 --- a/Library/Homebrew/cask/cmd/internal_help.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class InternalHelp < AbstractInternalCommand - def initialize(*) - super - return if args.empty? - - raise ArgumentError, "#{self.class.command_name} does not take arguments." - end - - def run - max_command_len = Cmd.commands.map(&:length).max - puts "Unstable Internal-use Commands:\n\n" - Cmd.command_classes.each do |klass| - next if klass.visible - - puts " #{klass.command_name.ljust(max_command_len)} #{self.class.help_for(klass)}" - end - puts "\n" - end - - def self.help_for(klass) - klass.respond_to?(:help) ? klass.help : nil - end - - def self.help - "print help strings for unstable internal-use commands" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/internal_stanza.rb b/Library/Homebrew/cask/cmd/internal_stanza.rb deleted file mode 100644 index 578e5f06f41a4..0000000000000 --- a/Library/Homebrew/cask/cmd/internal_stanza.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class InternalStanza < AbstractInternalCommand - # Syntax - # - # brew cask _stanza [ --quiet ] [ --table | --yaml ] [ ... ] - # - # If no tokens are given, then data for all Casks is returned. - # - # The pseudo-stanza "artifacts" is available. - # - # On failure, a blank line is returned on the standard output. - # - # Examples - # - # brew cask _stanza appcast --table - # brew cask _stanza app --table alfred google-chrome adium vagrant - # brew cask _stanza url --table alfred google-chrome adium vagrant - # brew cask _stanza version --table alfred google-chrome adium vagrant - # brew cask _stanza artifacts --table alfred google-chrome adium vagrant - # brew cask _stanza artifacts --table --yaml alfred google-chrome adium vagrant - # - - ARTIFACTS = - (DSL::ORDINARY_ARTIFACT_CLASSES.map(&:dsl_key) + - DSL::ARTIFACT_BLOCK_CLASSES.map(&:dsl_key)).freeze - - option "--table", :table, false - option "--quiet", :quiet, false - option "--yaml", :yaml, false - option "--inspect", :inspect, false - - attr_accessor :format - private :format, :format= - - attr_accessor :stanza - private :stanza, :stanza= - - def initialize(*) - super - raise ArgumentError, "No stanza given." if args.empty? - - @stanza = args.shift.to_sym - - @format = :to_yaml if yaml? - - return if DSL::DSL_METHODS.include?(stanza) - - raise ArgumentError, - <<~EOS - Unknown/unsupported stanza: '#{stanza}' - Check Cask reference for supported stanzas. - EOS - end - - def run - if ARTIFACTS.include?(stanza) - artifact_name = stanza - @stanza = :artifacts - end - - casks(alternative: -> { Cask.to_a }).each do |cask| - print "#{cask}\t" if table? - - begin - value = cask.send(stanza) - rescue - opoo "failure calling '#{stanza}' on Cask '#{cask}'" unless quiet? - puts "" - next - end - - if stanza == :artifacts - value = Hash[value.map { |v| [v.class.dsl_key, v.to_s] }] - value = value[artifact_name] if artifact_name - end - - if value.nil? || (value.respond_to?(:empty?) && value.empty?) - stanza_name = artifact_name || stanza - raise CaskError, "no such stanza '#{stanza_name}' on Cask '#{cask}'" - end - - if format - puts value.send(format) - elsif value.is_a?(Symbol) - puts value.inspect - else - puts value.to_s - end - end - end - - def self.help - "extract and render a specific stanza for the given Casks" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/list.rb b/Library/Homebrew/cask/cmd/list.rb deleted file mode 100644 index 08e9604e1fd0e..0000000000000 --- a/Library/Homebrew/cask/cmd/list.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class List < AbstractCommand - option "-1", :one, false - option "--versions", :versions, false - option "--full-name", :full_name, false - - option "-l", (lambda do |*| - one = true # rubocop:disable Lint/UselessAssignment - opoo "Option -l is obsolete! Implying option -1." - end) - - def run - args.any? ? list : list_installed - end - - def list - casks.each do |cask| - raise CaskNotInstalledError, cask unless cask.installed? - - if one? - puts cask.token - elsif versions? - puts self.class.format_versioned(cask) - else - cask = CaskLoader.load(cask.installed_caskfile) - self.class.list_artifacts(cask) - end - end - end - - def self.list_artifacts(cask) - cask.artifacts.group_by(&:class).each do |klass, artifacts| - next unless klass.respond_to?(:english_description) - - ohai klass.english_description, artifacts.map(&:summarize_installed) - end - end - - def list_installed - installed_casks = Caskroom.casks - - if one? - puts installed_casks.map(&:to_s) - elsif versions? - puts installed_casks.map(&self.class.method(:format_versioned)) - elsif full_name? - puts installed_casks.map(&:full_name).sort &tap_and_name_comparison - elsif !installed_casks.empty? - puts Formatter.columns(installed_casks.map(&:to_s)) - end - end - - def self.format_versioned(cask) - cask.to_s.concat(cask.versions.map(&:to_s).join(" ").prepend(" ")) - end - - def self.help - "with no args, lists installed Casks; given installed Casks, lists staged files" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/options.rb b/Library/Homebrew/cask/cmd/options.rb deleted file mode 100644 index 9dca4e3d7f799..0000000000000 --- a/Library/Homebrew/cask/cmd/options.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - module Options - def self.included(klass) - klass.extend(ClassMethods) - end - - module ClassMethods - def options - @options ||= {} - return @options unless superclass.respond_to?(:options) - - superclass.options.merge(@options) - end - - def option(name, method, default_value = nil) - @options ||= {} - @options[name] = method - - return if method.respond_to?(:call) - - define_method(:"#{method}=") do |value| - instance_variable_set(:"@#{method}", value) - end - - if [true, false].include?(default_value) - define_method(:"#{method}?") do - return default_value unless instance_variable_defined?(:"@#{method}") - - instance_variable_get(:"@#{method}") == true - end - else - define_method(:"#{method}") do - return default_value unless instance_variable_defined?(:"@#{method}") - - instance_variable_get(:"@#{method}") - end - end - end - end - - def process_arguments(*arguments) - parser = OptionParser.new do |opts| - next if self.class.options.nil? - - self.class.options.each do |option_name, option_method| - option_type = case option_name.split(/(\ |\=)/).last - when "PATH" - Pathname - when /\w+(,\w+)+/ - Array - end - - opts.on(option_name, *option_type) do |value| - if option_method.respond_to?(:call) - option_method.call(value) - else - send(:"#{option_method}=", value) - end - end - end - end - parser.parse(*arguments) - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/outdated.rb b/Library/Homebrew/cask/cmd/outdated.rb deleted file mode 100644 index f11bd6dd31325..0000000000000 --- a/Library/Homebrew/cask/cmd/outdated.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Outdated < AbstractCommand - option "--greedy", :greedy, false - option "--quiet", :quiet, false - - def initialize(*) - super - self.verbose = ($stdout.tty? || verbose?) && !quiet? - end - - def run - casks(alternative: -> { Caskroom.casks }).each do |cask| - odebug "Checking update info of Cask #{cask}" - self.class.list_if_outdated(cask, greedy?, verbose?) - end - end - - def self.list_if_outdated(cask, greedy, verbose) - return unless cask.outdated?(greedy) - - if verbose - outdated_versions = cask.outdated_versions(greedy) - outdated_info = "#{cask.token} (#{outdated_versions.join(", ")})" - current_version = cask.version.to_s - puts "#{outdated_info} != #{current_version}" - else - puts cask.token - end - end - - def self.help - "list the outdated installed Casks" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/reinstall.rb b/Library/Homebrew/cask/cmd/reinstall.rb deleted file mode 100644 index a8711bec88091..0000000000000 --- a/Library/Homebrew/cask/cmd/reinstall.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Reinstall < Install - def run - casks.each do |cask| - Installer.new(cask, binaries: binaries?, - verbose: verbose?, - force: force?, - skip_cask_deps: skip_cask_deps?, - require_sha: require_sha?, - quarantine: quarantine?).reinstall - end - end - - def self.help - "reinstalls the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/style.rb b/Library/Homebrew/cask/cmd/style.rb deleted file mode 100644 index 06dfc45f821af..0000000000000 --- a/Library/Homebrew/cask/cmd/style.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module Cask - class Cmd - class Style < AbstractCommand - def self.help - "checks Cask style using RuboCop" - end - - def self.rubocop(*paths, auto_correct: false, debug: false, json: false) - Homebrew.install_bundler_gems! - - cache_env = { "XDG_CACHE_HOME" => "#{HOMEBREW_CACHE}/style" } - hide_warnings = debug ? [] : [ENV["HOMEBREW_RUBY_PATH"], "-S"] - - args = [ - "--force-exclusion", - "--config", "#{HOMEBREW_LIBRARY}/.rubocop_cask.yml" - ] - - if json - args << "--format" << "json" - else - if auto_correct - args << "--auto-correct" - else - args << "--debug" if debug - args << "--parallel" - end - - args << "--format" << "simple" - args << "--color" if Tty.color? - end - - executable, *args = [*hide_warnings, "rubocop", *args, "--", *paths] - - result = Dir.mktmpdir do |tmpdir| - system_command executable, args: args, chdir: tmpdir, env: cache_env, - print_stdout: !json, print_stderr: !json - end - - result.assert_success! unless (0..1).cover?(result.exit_status) - - return JSON.parse(result.stdout) if json - - result - end - - option "--fix", :fix, false - - def run - result = self.class.rubocop(*cask_paths, auto_correct: fix?, debug: debug?) - raise CaskError, "Style check failed." unless result.status.success? - end - - def cask_paths - @cask_paths ||= if args.empty? - Tap.map(&:cask_dir).select(&:directory?).concat(test_cask_paths) - elsif args.any? { |file| File.exist?(file) } - args.map { |path| Pathname(path).expand_path } - else - casks.map(&:sourcefile_path) - end - end - - def test_cask_paths - [ - Pathname.new("#{HOMEBREW_LIBRARY}/Homebrew/test/support/fixtures/cask/Casks"), - Pathname.new("#{HOMEBREW_LIBRARY}/Homebrew/test/support/fixtures/third-party/Casks"), - ] - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/uninstall.rb b/Library/Homebrew/cask/cmd/uninstall.rb deleted file mode 100644 index dfa8bbfb77617..0000000000000 --- a/Library/Homebrew/cask/cmd/uninstall.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Uninstall < AbstractCommand - option "--force", :force, false - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - casks.each do |cask| - odebug "Uninstalling Cask #{cask}" - - raise CaskNotInstalledError, cask unless cask.installed? || force? - - if cask.installed? && !cask.installed_caskfile.nil? - # use the same cask file that was used for installation, if possible - cask = CaskLoader.load(cask.installed_caskfile) if cask.installed_caskfile.exist? - end - - Installer.new(cask, binaries: binaries?, verbose: verbose?, force: force?).uninstall - - next if (versions = cask.versions).empty? - - puts <<~EOS - #{cask} #{versions.to_sentence} #{"is".pluralize(versions.count)} still installed. - Remove #{(versions.count == 1) ? "it" : "them all"} with `brew cask uninstall --force #{cask}`. - EOS - end - end - - def self.help - "uninstalls the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/upgrade.rb b/Library/Homebrew/cask/cmd/upgrade.rb deleted file mode 100644 index da5e4f56cb736..0000000000000 --- a/Library/Homebrew/cask/cmd/upgrade.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -require "cask/config" - -module Cask - class Cmd - class Upgrade < AbstractCommand - option "--greedy", :greedy, false - option "--quiet", :quiet, false - option "--force", :force, false - option "--skip-cask-deps", :skip_cask_deps, false - option "--dry-run", :dry_run, false - - def initialize(*) - super - self.verbose = ($stdout.tty? || verbose?) && !quiet? - end - - def run - outdated_casks = casks(alternative: lambda { - Caskroom.casks.select do |cask| - cask.outdated?(greedy?) - end - }).select do |cask| - raise CaskNotInstalledError, cask unless cask.installed? || force? - - cask.outdated?(true) - end - - if outdated_casks.empty? - oh1 "No Casks to upgrade" - return - end - - ohai "Casks with `auto_updates` or `version :latest` will not be upgraded" if args.empty? && !greedy? - verb = dry_run? ? "Would upgrade" : "Upgrading" - oh1 "#{verb} #{outdated_casks.count} #{"outdated package".pluralize(outdated_casks.count)}:" - caught_exceptions = [] - - upgradable_casks = outdated_casks.map { |c| [CaskLoader.load(c.installed_caskfile), c] } - - puts upgradable_casks - .map { |(old_cask, new_cask)| "#{new_cask.full_name} #{old_cask.version} -> #{new_cask.version}" } - .join(", ") - return if dry_run? - - upgradable_casks.each do |(old_cask, new_cask)| - upgrade_cask(old_cask, new_cask) - rescue => e - caught_exceptions << e - next - end - - return if caught_exceptions.empty? - raise MultipleCaskErrors, caught_exceptions if caught_exceptions.count > 1 - raise caught_exceptions.first if caught_exceptions.count == 1 - end - - def upgrade_cask(old_cask, new_cask) - odebug "Started upgrade process for Cask #{old_cask}" - old_config = old_cask.config - - old_cask_installer = - Installer.new(old_cask, binaries: binaries?, - verbose: verbose?, - force: force?, - upgrade: true) - - new_cask.config = Config.global.merge(old_config) - - new_cask_installer = - Installer.new(new_cask, binaries: binaries?, - verbose: verbose?, - force: force?, - skip_cask_deps: skip_cask_deps?, - require_sha: require_sha?, - upgrade: true, - quarantine: quarantine?) - - started_upgrade = false - new_artifacts_installed = false - - begin - oh1 "Upgrading #{Formatter.identifier(old_cask)}" - - # Start new Cask's installation steps - new_cask_installer.check_conflicts - - puts new_cask_installer.caveats if new_cask_installer.caveats - - new_cask_installer.fetch - - # Move the old Cask's artifacts back to staging - old_cask_installer.start_upgrade - # And flag it so in case of error - started_upgrade = true - - # Install the new Cask - new_cask_installer.stage - - new_cask_installer.install_artifacts - new_artifacts_installed = true - - # If successful, wipe the old Cask from staging - old_cask_installer.finalize_upgrade - rescue => e - new_cask_installer.uninstall_artifacts if new_artifacts_installed - new_cask_installer.purge_versioned_files - old_cask_installer.revert_upgrade if started_upgrade - raise e - end - end - - def self.help - "upgrades all outdated casks" - end - end - end -end diff --git a/Library/Homebrew/cask/cmd/zap.rb b/Library/Homebrew/cask/cmd/zap.rb deleted file mode 100644 index 73a8b685810a5..0000000000000 --- a/Library/Homebrew/cask/cmd/zap.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Cask - class Cmd - class Zap < AbstractCommand - option "--force", :force, false - - def initialize(*) - super - raise CaskUnspecifiedError if args.empty? - end - - def run - casks.each do |cask| - odebug "Zapping Cask #{cask}" - - raise CaskNotInstalledError, cask unless cask.installed? || force? - - Installer.new(cask, verbose: verbose?, force: force?).zap - end - end - - def self.help - "zaps all files associated with the given Cask" - end - end - end -end diff --git a/Library/Homebrew/cask/config.rb b/Library/Homebrew/cask/config.rb index 41fba64514f22..534a89d540f38 100644 --- a/Library/Homebrew/cask/config.rb +++ b/Library/Homebrew/cask/config.rb @@ -1,122 +1,238 @@ +# typed: strict # frozen_string_literal: true require "json" -require "extend/hash_validator" -using HashValidator +require "lazy_object" +require "locale" +require "extend/hash/keys" module Cask + # Configuration for installing casks. + # + # @api internal class Config - DEFAULT_DIRS = { - appdir: "/Applications", - prefpanedir: "~/Library/PreferencePanes", - qlplugindir: "~/Library/QuickLook", - dictionarydir: "~/Library/Dictionaries", - fontdir: "~/Library/Fonts", - colorpickerdir: "~/Library/ColorPickers", - servicedir: "~/Library/Services", - input_methoddir: "~/Library/Input Methods", - internet_plugindir: "~/Library/Internet Plug-Ins", - audio_unit_plugindir: "~/Library/Audio/Plug-Ins/Components", - vst_plugindir: "~/Library/Audio/Plug-Ins/VST", - vst3_plugindir: "~/Library/Audio/Plug-Ins/VST3", - screen_saverdir: "~/Library/Screen Savers", - }.freeze - - def self.global - @global ||= new - end - - def self.clear - @global = nil - end - - def self.for_cask(cask) - if cask.config_path.exist? - from_file(cask.config_path) - else - global - end + ConfigHash = T.type_alias { T::Hash[Symbol, T.any(LazyObject, String, Pathname, T::Array[String])] } + DEFAULT_DIRS = T.let( + { + appdir: "/Applications", + appimagedir: "~/Applications", + keyboard_layoutdir: "/Library/Keyboard Layouts", + colorpickerdir: "~/Library/ColorPickers", + prefpanedir: "~/Library/PreferencePanes", + qlplugindir: "~/Library/QuickLook", + mdimporterdir: "~/Library/Spotlight", + dictionarydir: "~/Library/Dictionaries", + fontdir: "~/Library/Fonts", + servicedir: "~/Library/Services", + input_methoddir: "~/Library/Input Methods", + internet_plugindir: "~/Library/Internet Plug-Ins", + audio_unit_plugindir: "~/Library/Audio/Plug-Ins/Components", + vst_plugindir: "~/Library/Audio/Plug-Ins/VST", + vst3_plugindir: "~/Library/Audio/Plug-Ins/VST3", + screen_saverdir: "~/Library/Screen Savers", + }.freeze, + T::Hash[Symbol, String], + ) + + # runtime recursive evaluation forces the LazyObject to be evaluated + T::Sig::WithoutRuntime.sig { returns(T::Hash[Symbol, T.any(LazyObject, String)]) } + def self.defaults + { + languages: LazyObject.new { ::OS::Mac.languages }, + }.merge(DEFAULT_DIRS).freeze end - def self.from_file(path) - config = begin - JSON.parse(File.read(path)) - rescue JSON::ParserError => e - raise e, "Cannot parse #{path}: #{e}", e.backtrace - end + sig { params(args: Homebrew::CLI::Args).returns(T.attached_class) } + def self.from_args(args) + # FIXME: T.unsafe is a workaround for methods that are only defined when `cask_options` + # is invoked on the parser. (These could be captured by a DSL compiler instead.) + args = T.unsafe(args) + new(explicit: { + appdir: args.appdir, + appimagedir: args.appimagedir, + keyboard_layoutdir: args.keyboard_layoutdir, + colorpickerdir: args.colorpickerdir, + prefpanedir: args.prefpanedir, + qlplugindir: args.qlplugindir, + mdimporterdir: args.mdimporterdir, + dictionarydir: args.dictionarydir, + fontdir: args.fontdir, + servicedir: args.servicedir, + input_methoddir: args.input_methoddir, + internet_plugindir: args.internet_plugindir, + audio_unit_plugindir: args.audio_unit_plugindir, + vst_plugindir: args.vst_plugindir, + vst3_plugindir: args.vst3_plugindir, + screen_saverdir: args.screen_saverdir, + languages: args.language, + }.compact) + end + + sig { params(json: String, ignore_invalid_keys: T::Boolean).returns(T.attached_class) } + def self.from_json(json, ignore_invalid_keys: false) + config = JSON.parse(json, symbolize_names: true) new( - default: config.fetch("default", {}), - env: config.fetch("env", {}), - explicit: config.fetch("explicit", {}), + default: config.fetch(:default, {}), + env: config.fetch(:env, {}), + explicit: config.fetch(:explicit, {}), + ignore_invalid_keys:, ) end + # runtime recursive evaluation forces the LazyObject to be evaluated + T::Sig::WithoutRuntime.sig { params(config: ConfigHash).returns(ConfigHash) } def self.canonicalize(config) - config.map do |k, v| - key = k.to_sym + config.to_h do |k, v| + if DEFAULT_DIRS.key?(k) + raise TypeError, "Invalid path for default dir #{k}: #{v.inspect}" if v.is_a?(Array) - if DEFAULT_DIRS.key?(key) - [key, Pathname(v).expand_path] + [k, Pathname(v.to_s).expand_path] else - [key, v] + [k, v] end - end.to_h + end end + # Get the explicit configuration. + # + # @api internal + sig { returns(ConfigHash) } attr_accessor :explicit - def initialize(default: nil, env: nil, explicit: {}) - @default = self.class.canonicalize(default) if default - @env = self.class.canonicalize(env) if env - @explicit = self.class.canonicalize(explicit) + sig { + params( + default: T.nilable(ConfigHash), + env: T.nilable(ConfigHash), + explicit: ConfigHash, + ignore_invalid_keys: T::Boolean, + ).void + } + def initialize(default: nil, env: nil, explicit: {}, ignore_invalid_keys: false) + if default + @default = T.let( + self.class.canonicalize(self.class.defaults.merge(default)), + T.nilable(ConfigHash), + ) + end + if env + @env = T.let( + self.class.canonicalize(env), + T.nilable(ConfigHash), + ) + end + @explicit = T.let( + self.class.canonicalize(explicit), + ConfigHash, + ) + + if ignore_invalid_keys + @env&.delete_if { |key, _| self.class.defaults.keys.exclude?(key) } + @explicit.delete_if { |key, _| self.class.defaults.keys.exclude?(key) } + return + end - @env&.assert_valid_keys!(*DEFAULT_DIRS.keys) - @explicit.assert_valid_keys!(*DEFAULT_DIRS.keys) + @env&.assert_valid_keys(*self.class.defaults.keys) + @explicit.assert_valid_keys(*self.class.defaults.keys) end + # runtime recursive evaluation forces the LazyObject to be evaluated + T::Sig::WithoutRuntime.sig { returns(ConfigHash) } def default - @default ||= self.class.canonicalize(DEFAULT_DIRS) + @default ||= self.class.canonicalize(self.class.defaults) end + sig { returns(ConfigHash) } def env @env ||= self.class.canonicalize( - Shellwords.shellsplit(ENV.fetch("HOMEBREW_CASK_OPTS", "")) - .select { |arg| arg.include?("=") } - .map { |arg| arg.split("=", 2) } - .map { |(flag, value)| [flag.sub(/^\-\-/, ""), value] }, + Homebrew::EnvConfig.cask_opts + .select { |arg| arg.include?("=") } + .map { |arg| T.cast(arg.split("=", 2), [String, String]) } + .to_h do |(flag, value)| + key = flag.sub(/^--/, "") + # converts --language flag to :languages config key + if key == "language" + key = "languages" + value = value.split(",") + end + + [key.to_sym, value] + end, ) end + sig { returns(Pathname) } def binarydir - @binarydir ||= HOMEBREW_PREFIX/"bin" + @binarydir ||= T.let(HOMEBREW_PREFIX/"bin", T.nilable(Pathname)) end + sig { returns(Pathname) } def manpagedir - @manpagedir ||= HOMEBREW_PREFIX/"share/man" + @manpagedir ||= T.let(HOMEBREW_PREFIX/"share/man", T.nilable(Pathname)) end - DEFAULT_DIRS.keys.each do |dir| + sig { returns(Pathname) } + def bash_completion + @bash_completion ||= T.let(HOMEBREW_PREFIX/"etc/bash_completion.d", T.nilable(Pathname)) + end + + sig { returns(Pathname) } + def zsh_completion + @zsh_completion ||= T.let(HOMEBREW_PREFIX/"share/zsh/site-functions", T.nilable(Pathname)) + end + + sig { returns(Pathname) } + def fish_completion + @fish_completion ||= T.let(HOMEBREW_PREFIX/"share/fish/vendor_completions.d", T.nilable(Pathname)) + end + + sig { returns(T::Array[String]) } + def languages + [ + *explicit.fetch(:languages, []), + *env.fetch(:languages, []), + *default.fetch(:languages, []), + ].uniq.select do |lang| + # Ensure all languages are valid. + Locale.parse(lang) + true + rescue Locale::ParserError + false + end + end + + sig { params(languages: T::Array[String]).void } + def languages=(languages) + explicit[:languages] = languages + end + + DEFAULT_DIRS.each_key do |dir| define_method(dir) do + T.bind(self, Config) explicit.fetch(dir, env.fetch(dir, default.fetch(dir))) end define_method(:"#{dir}=") do |path| + T.bind(self, Config) explicit[dir] = Pathname(path).expand_path end end + sig { params(other: Config).returns(T.self_type) } def merge(other) self.class.new(explicit: other.explicit.merge(explicit)) end - def to_json(*args) + sig { params(options: T.untyped).returns(String) } + def to_json(*options) { - default: default, - env: env, - explicit: explicit, - }.to_json(*args) + default:, + env:, + explicit:, + }.to_json(*options) end end end + +require "extend/os/cask/config" diff --git a/Library/Homebrew/cask/denylist.rb b/Library/Homebrew/cask/denylist.rb new file mode 100644 index 0000000000000..d65584862e8cf --- /dev/null +++ b/Library/Homebrew/cask/denylist.rb @@ -0,0 +1,17 @@ +# typed: strict +# frozen_string_literal: true + +module Cask + # List of casks which are not allowed in official taps. + module Denylist + sig { params(name: String).returns(T.nilable(String)) } + def self.reason(name) + case name + when /^adobe-(after|illustrator|indesign|photoshop|premiere)/ + "Adobe casks were removed because they are too difficult to maintain." + when /^pharo$/ + "Pharo developers maintain their own tap." + end + end + end +end diff --git a/Library/Homebrew/cask/download.rb b/Library/Homebrew/cask/download.rb index 4bd4e4426a77d..0bcf36565cd7e 100644 --- a/Library/Homebrew/cask/download.rb +++ b/Library/Homebrew/cask/download.rb @@ -1,61 +1,169 @@ +# typed: strict # frozen_string_literal: true +require "downloadable" require "fileutils" require "cask/cache" require "cask/quarantine" -require "cask/verify" module Cask + # A download corresponding to a {Cask}. class Download + include Downloadable + + include Context + + sig { returns(::Cask::Cask) } attr_reader :cask - def initialize(cask, force: false, quarantine: nil) + sig { + params( + cask: ::Cask::Cask, + quarantine: T.nilable(T::Boolean), + require_sha: T::Boolean, + ).void + } + def initialize(cask, quarantine: nil, require_sha: false) + super() + @cask = cask - @force = force @quarantine = quarantine + @require_sha = require_sha end - def perform - clear_cache - fetch - quarantine - downloaded_path + sig { override.returns(T.nilable(::URL)) } + def url + return if (cask_url = cask.url).nil? + + @url ||= ::URL.new(cask_url.to_s, cask_url.specs) + end + + sig { override.returns(T.nilable(::Checksum)) } + def checksum + @checksum ||= cask.sha256 if cask.sha256 != :no_check + end + + sig { override.returns(T.nilable(Version)) } + def version + return if cask.version.nil? + + @version ||= Version.new(cask.version) end - def downloader - @downloader ||= begin - strategy = DownloadStrategyDetector.detect(cask.url.to_s, cask.url.using) - strategy.new(cask.url.to_s, cask.token, cask.version, cache: Cache.path, **cask.url.specs) + sig { + override + .params(quiet: T.nilable(T::Boolean), + verify_download_integrity: T::Boolean, + timeout: T.nilable(T.any(Integer, Float))) + .returns(Pathname) + } + def fetch(quiet: nil, verify_download_integrity: true, timeout: nil) + verify_has_sha if @require_sha + downloader.quiet! if quiet + + begin + super(verify_download_integrity: false, timeout:) + rescue DownloadError => e + error = CaskError.new("Download failed on Cask '#{cask}' with message: #{e.cause}") + error.set_backtrace e.backtrace + raise error end + + downloaded_path = cached_download + quarantine(downloaded_path) + self.verify_download_integrity(downloaded_path) if verify_download_integrity + downloaded_path end - private + sig { params(timeout: T.nilable(T.any(Float, Integer))).returns([T.nilable(Time), Integer]) } + def time_file_size(timeout: nil) + raise ArgumentError, "not supported for this download strategy" unless downloader.is_a?(CurlDownloadStrategy) + + T.cast(downloader, CurlDownloadStrategy).resolved_time_file_size(timeout:) + end + + sig { returns(Pathname) } + def basename + downloader.basename + end - attr_reader :force - attr_accessor :downloaded_path + sig { override.returns(T::Boolean) } + def downloaded_and_valid? + return false unless super - def clear_cache - downloader.clear_cache if force + quarantine(cached_download) + true end - def fetch - downloader.fetch - @downloaded_path = downloader.cached_location - rescue => e - error = CaskError.new("Download failed on Cask '#{cask}' with message: #{e}") - error.set_backtrace e.backtrace - raise error + sig { override.params(filename: Pathname).void } + def verify_download_integrity(filename) + if no_checksum_defined? && !official_cask_tap? + opoo "No checksum defined for cask '#{@cask}', skipping verification." + return + end + + super end - def quarantine + sig { override.returns(String) } + def download_queue_name = "#{cask.token} (#{version})" + + sig { override.returns(String) } + def download_queue_type = "Cask" + + private + + sig { void } + def verify_has_sha + return if @cask.sha256 != :no_check + + raise CaskError, <<~EOS + Cask '#{@cask}' does not have a sha256 checksum defined. + This means you have the #{Formatter.identifier("--require-sha")} option set, perhaps in your `$HOMEBREW_CASK_OPTS`. + EOS + end + + sig { params(path: Pathname).void } + def quarantine(path) return if @quarantine.nil? return unless Quarantine.available? if @quarantine - Quarantine.cask!(cask: @cask, download_path: @downloaded_path) + Quarantine.cask!(cask: @cask, download_path: path) else - Quarantine.release!(download_path: @downloaded_path) + Quarantine.release!(download_path: path) end end + + sig { returns(T::Boolean) } + def official_cask_tap? + tap = @cask.tap + return false if tap.blank? + + tap.official? + end + + sig { returns(T::Boolean) } + def no_checksum_defined? + @cask.sha256 == :no_check + end + + sig { override.returns(T::Boolean) } + def silence_checksum_missing_error? + no_checksum_defined? && official_cask_tap? + end + + sig { override.returns(T.nilable(::URL)) } + def determine_url + url + end + + sig { override.returns(Pathname) } + def cache + Cache.path + end + + sig { override.returns(String) } + def download_name = cask.token end end diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index 5090744e733df..9210cef51abed 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -1,14 +1,18 @@ +# typed: strict # frozen_string_literal: true +require "autobump_constants" require "locale" -require "lazy_object" +require "livecheck" +require "utils/output" +require "utils/path" require "cask/artifact" +require "cask/artifact_set" require "cask/caskroom" require "cask/exceptions" -require "cask/dsl/appcast" require "cask/dsl/base" require "cask/dsl/caveats" require "cask/dsl/conflicts_with" @@ -16,17 +20,26 @@ 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" require "cask/url" +require "cask/utils" + +require "on_system" module Cask + # Class representing the domain-specific language used for casks. class DSL + include ::Utils::Output::Mixin + include ::Utils::Path + ORDINARY_ARTIFACT_CLASSES = [ Artifact::Installer, Artifact::App, + Artifact::AppImage, Artifact::Artifact, Artifact::AudioUnitPlugin, Artifact::Binary, @@ -35,198 +48,605 @@ class DSL Artifact::Font, Artifact::InputMethod, Artifact::InternetPlugin, + Artifact::KeyboardLayout, Artifact::Manpage, Artifact::Pkg, Artifact::Prefpane, Artifact::Qlplugin, + Artifact::Mdimporter, Artifact::ScreenSaver, Artifact::Service, Artifact::StageOnly, Artifact::Suite, Artifact::VstPlugin, Artifact::Vst3Plugin, + Artifact::ZshCompletion, + Artifact::FishCompletion, + Artifact::BashCompletion, + Artifact::GeneratedCompletion, Artifact::Uninstall, Artifact::Zap, ].freeze - ACTIVATABLE_ARTIFACT_CLASSES = (ORDINARY_ARTIFACT_CLASSES - [Artifact::StageOnly]).freeze + ACTIVATABLE_ARTIFACT_CLASSES = T.let( + (ORDINARY_ARTIFACT_CLASSES - [Artifact::StageOnly]).freeze, + T::Array[T.class_of(Artifact::AbstractArtifact)], + ) ARTIFACT_BLOCK_CLASSES = [ Artifact::PreflightBlock, Artifact::PostflightBlock, ].freeze - DSL_METHODS = Set.new([ - :appcast, - :artifacts, - :auto_updates, - :caveats, - :conflicts_with, - :container, - :depends_on, - :homepage, - :language, - :languages, - :name, - :sha256, - :staged_path, - :url, - :version, - :appdir, - *ORDINARY_ARTIFACT_CLASSES.map(&:dsl_key), - *ACTIVATABLE_ARTIFACT_CLASSES.map(&:dsl_key), - *ARTIFACT_BLOCK_CLASSES.flat_map { |klass| [klass.dsl_key, klass.uninstall_dsl_key] }, - ]).freeze - - attr_reader :cask, :token + INSTALL_STEP_ARTIFACT_CLASSES = [ + Artifact::PreflightSteps, + Artifact::PostflightSteps, + Artifact::UninstallPreflightSteps, + Artifact::UninstallPostflightSteps, + ].freeze + InstallStepFlightBlockClasses = T.type_alias do + T::Hash[ + T.class_of(Artifact::AbstractInstallSteps), + [T.class_of(Artifact::AbstractFlightBlock), Symbol], + ] + end + + INSTALL_STEP_FLIGHT_BLOCK_CLASSES = T.let({ + Artifact::PreflightSteps => [Artifact::PreflightBlock, :preflight], + Artifact::PostflightSteps => [Artifact::PostflightBlock, :postflight], + Artifact::UninstallPreflightSteps => [Artifact::PreflightBlock, :uninstall_preflight], + Artifact::UninstallPostflightSteps => [Artifact::PostflightBlock, :uninstall_postflight], + }.freeze, InstallStepFlightBlockClasses) + + DSL_METHODS = T.let(Set.new([ + :arch, + :artifacts, + :auto_updates, + :caveats, + :conflicts_with, + :container, + :desc, + :depends_on, + :homepage, + :language, + :name, + :os, + :rename, + :sha256, + :staged_path, + :url, + :version, + :appdir, + :deprecate!, + :deprecated?, + :deprecation_date, + :deprecation_reason, + :deprecation_replacement_cask, + :deprecation_replacement_formula, + :deprecate_args, + :disable!, + :disabled?, + :disable_date, + :disable_reason, + :disable_replacement_cask, + :disable_replacement_formula, + :disable_args, + :livecheck, + :livecheck_defined?, + :no_autobump!, + :autobump?, + :no_autobump_message, + :on_system_blocks_exist?, + :on_os_blocks_exist?, + :on_system_block_min_os, + :depends_on_set_in_block?, + *ORDINARY_ARTIFACT_CLASSES.map(&:dsl_key), + *ACTIVATABLE_ARTIFACT_CLASSES.map(&:dsl_key), + *ARTIFACT_BLOCK_CLASSES.flat_map { |klass| [klass.dsl_key, klass.uninstall_dsl_key] }, + *INSTALL_STEP_ARTIFACT_CLASSES.map(&:dsl_key), + ]).freeze, T::Set[Symbol]) + + include OnSystem::MacOSAndLinux + + sig { returns(Cask) } + attr_reader :cask + + sig { returns(String) } + attr_reader :token + + sig { returns(T.nilable(T.any(String, Symbol))) } + attr_reader :no_autobump_message + + sig { returns(ArtifactSet) } + attr_reader :artifacts + + sig { returns(T.nilable(Date)) } + attr_reader :deprecation_date + + sig { returns(T.nilable(T.any(String, Symbol))) } + attr_reader :deprecation_reason + + sig { returns(T.nilable(String)) } + attr_reader :deprecation_replacement_cask + + sig { returns(T.nilable(String)) } + attr_reader :deprecation_replacement_formula + + sig { returns(T.nilable(T::Hash[Symbol, T.nilable(T.any(String, Symbol))])) } + attr_reader :deprecate_args + + sig { returns(T.nilable(Date)) } + attr_reader :disable_date + + sig { returns(T.nilable(T.any(String, Symbol))) } + attr_reader :disable_reason + + sig { returns(T.nilable(String)) } + attr_reader :disable_replacement_cask + + sig { returns(T.nilable(String)) } + attr_reader :disable_replacement_formula + + sig { returns(T.nilable(T::Hash[Symbol, T.nilable(T.any(String, Symbol))])) } + attr_reader :disable_args + + sig { returns(T.nilable(MacOSVersion)) } + attr_reader :on_system_block_min_os + + sig { params(cask: Cask).void } def initialize(cask) + # NOTE: `:"@#{stanza}"` variables set by `set_unique_stanza` must be + # initialized to `nil`. + @arch = T.let(nil, T.nilable(String)) + @arch_set_in_block = T.let(false, T::Boolean) + @artifacts = T.let(ArtifactSet.new, ArtifactSet) + @auto_updates = T.let(nil, T.nilable(T::Boolean)) + @auto_updates_set_in_block = T.let(false, T::Boolean) + @autobump = T.let(true, T::Boolean) + @called_in_on_system_block = T.let(false, T::Boolean) @cask = cask - @token = cask.token + @caveats = T.let(DSL::Caveats.new(cask), DSL::Caveats) + @conflicts_with = T.let(nil, T.nilable(DSL::ConflictsWith)) + @conflicts_with_set_in_block = T.let(false, T::Boolean) + @container = T.let(nil, T.nilable(DSL::Container)) + @container_set_in_block = T.let(false, T::Boolean) + @depends_on = T.let(DSL::DependsOn.new, DSL::DependsOn) + @depends_on_set_in_block = T.let(false, T::Boolean) + @deprecated = T.let(false, T::Boolean) + @deprecation_date = T.let(nil, T.nilable(Date)) + @deprecation_reason = T.let(nil, T.nilable(T.any(String, Symbol))) + @deprecation_replacement_cask = T.let(nil, T.nilable(String)) + @deprecation_replacement_formula = T.let(nil, T.nilable(String)) + @deprecate_args = T.let(nil, T.nilable(T::Hash[Symbol, T.nilable(T.any(String, Symbol))])) + @desc = T.let(nil, T.nilable(String)) + @desc_set_in_block = T.let(false, T::Boolean) + @disable_date = T.let(nil, T.nilable(Date)) + @disable_reason = T.let(nil, T.nilable(T.any(String, Symbol))) + @disable_replacement_cask = T.let(nil, T.nilable(String)) + @disable_replacement_formula = T.let(nil, T.nilable(String)) + @disable_args = T.let(nil, T.nilable(T::Hash[Symbol, T.nilable(T.any(String, Symbol))])) + @disabled = T.let(false, T::Boolean) + @homepage = T.let(nil, T.nilable(String)) + @homepage_set_in_block = T.let(false, T::Boolean) + @language_blocks = T.let({}, T::Hash[T::Array[String], Proc]) + @language_eval = T.let(nil, T.nilable(String)) + @livecheck = T.let(Livecheck.new(cask), Livecheck) + @livecheck_defined = T.let(false, T::Boolean) + @name = T.let([], T::Array[String]) + @no_autobump_defined = T.let(false, T::Boolean) + @no_autobump_message = T.let(nil, T.nilable(T.any(String, Symbol))) + @on_system_blocks_exist = T.let(false, T::Boolean) + @on_os_blocks_exist = T.let(false, T::Boolean) + @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_for_linux = T.let(false, T::Boolean) + @sha256_set_in_block = T.let(false, T::Boolean) + @staged_path = T.let(nil, T.nilable(Pathname)) + @token = T.let(cask.token, String) + @url = T.let(nil, T.nilable(URL)) + @url_set_in_block = T.let(false, T::Boolean) + @version = T.let(nil, T.nilable(DSL::Version)) + @version_set_in_block = T.let(false, T::Boolean) end + sig { returns(T::Boolean) } + def depends_on_set_in_block? = @depends_on_set_in_block + + sig { returns(T::Boolean) } + def deprecated? = @deprecated + + sig { returns(T::Boolean) } + def disabled? = @disabled + + sig { returns(T::Boolean) } + def livecheck_defined? = @livecheck_defined + + sig { returns(T::Boolean) } + def on_system_blocks_exist? = @on_system_blocks_exist + + sig { returns(T::Boolean) } + def on_os_blocks_exist? = @on_os_blocks_exist + + sig { returns(T::Boolean) } + def sha256_set_for_linux? = @sha256_set_for_linux + + # Specifies the cask's name. + # + # NOTE: Multiple names can be specified. + # + # ### Example + # + # ```ruby + # name "Visual Studio Code" + # ``` + # + # @api public + sig { params(args: T.any(String, T::Array[String])).returns(T::Array[String]) } def name(*args) - @name ||= [] return @name if args.empty? @name.concat(args.flatten) end - def set_unique_stanza(stanza, should_return) - return instance_variable_get("@#{stanza}") if should_return + # Describes the cask. + # + # ### Example + # + # ```ruby + # desc "Open-source code editor" + # ``` + # + # @api public + sig { params(description: T.nilable(String)).returns(T.nilable(String)) } + def desc(description = nil) + set_unique_stanza(:desc, description.nil?) { description } + end - if instance_variable_defined?("@#{stanza}") - raise CaskInvalidError.new(cask, "'#{stanza}' stanza may only appear once.") + # NOTE: Using `WithoutRuntime` to avoid Sorbet wrapping this method, + # which would interfere with `caller_locations` in methods like `url`. + T::Sig::WithoutRuntime.sig { + type_parameters(:U).params( + stanza: Symbol, + should_return: T::Boolean, + _block: T.proc.returns(T.all(BasicObject, T.type_parameter(:U))), + ).returns(T.type_parameter(:U)) + } + def set_unique_stanza(stanza, should_return, &_block) + return instance_variable_get(:"@#{stanza}") if should_return + + unless @cask.allow_reassignment + if !instance_variable_get(:"@#{stanza}").nil? && !@called_in_on_system_block + raise CaskInvalidError.new(cask, "'#{stanza}' stanza may only appear once.") + end + + if instance_variable_get(:"@#{stanza}_set_in_block") && @called_in_on_system_block + raise CaskInvalidError.new(cask, "'#{stanza}' stanza may only be overridden once.") + end end - instance_variable_set("@#{stanza}", yield) + instance_variable_set(:"@#{stanza}_set_in_block", true) if @called_in_on_system_block + instance_variable_set(:"@#{stanza}", yield) rescue CaskInvalidError raise rescue => e raise CaskInvalidError.new(cask, "'#{stanza}' stanza failed with: #{e}") end + # Sets the cask's homepage. + # + # ### Example + # + # ```ruby + # homepage "https://code.visualstudio.com/" + # ``` + # + # @api public + sig { params(homepage: T.nilable(String)).returns(T.nilable(String)) } def homepage(homepage = nil) set_unique_stanza(:homepage, homepage.nil?) { homepage } end + # Specifies language-specific values for the cask. + # + # @api public + sig { + params( + args: String, + default: T::Boolean, + block: T.nilable(T.proc.returns(String)), + ).returns(T.nilable(String)) + } def language(*args, default: false, &block) if args.empty? language_eval - elsif block_given? - @language_blocks ||= {} + elsif block @language_blocks[args] = block return unless default - unless @language_blocks.default.nil? + if !@cask.allow_reassignment && @language_blocks.default.present? raise CaskInvalidError.new(cask, "Only one default language may be defined.") end @language_blocks.default = block + nil else raise CaskInvalidError.new(cask, "No block given to language stanza.") end end + sig { returns(T.nilable(String)) } def language_eval - return @language if instance_variable_defined?(:@language) + return @language_eval unless @language_eval.nil? - return @language = nil if @language_blocks.nil? || @language_blocks.empty? + return @language_eval = nil if @language_blocks.empty? - raise CaskInvalidError.new(cask, "No default language specified.") if @language_blocks.default.nil? + if (language_blocks_default = @language_blocks.default).nil? + raise CaskInvalidError.new(cask, "No default language specified.") + end - locales = MacOS.languages - .map do |language| - Locale.parse(language) - rescue Locale::ParserError - nil - end - .compact + locales = cask.config.languages + .filter_map do |language| + Locale.parse(language) + rescue Locale::ParserError + nil + end locales.each do |locale| - key = locale.detect(@language_blocks.keys) - - next if key.nil? + key = T.cast(locale.detect(@language_blocks.keys), T.nilable(T::Array[String])) + next if key.nil? || (language_block = @language_blocks[key]).nil? - return @language = @language_blocks[key].call + return @language_eval = language_block.call end - @language = @language_blocks.default.call + @language_eval = language_blocks_default.call end + sig { returns(T::Array[String]) } def languages - return [] if @language_blocks.nil? - @language_blocks.keys.flatten end - def url(*args) - set_unique_stanza(:url, args.empty? && !block_given?) do - if block_given? - LazyObject.new { URL.new(*yield) } - else - URL.new(*args) - end + # Sets the cask's download URL. + # + # ### Example + # + # ```ruby + # url "https://update.code.visualstudio.com/#{version}/#{arch}/stable" + # ``` + # + # @api public + T::Sig::WithoutRuntime.sig { params(uri: T.nilable(T.any(URI::Generic, String)), options: T.untyped).returns(T.nilable(URL)) } + def url(uri = nil, **options) + caller_location = caller_locations.fetch(0) + return @url unless uri + + set_unique_stanza(:url, false) do + URL.new(uri, **options, caller_location:) end end - def appcast(*args) - set_unique_stanza(:appcast, args.empty?) { DSL::Appcast.new(*args) } + # Sets the cask's container type or nested container path. + # + # ### Examples + # + # The container is a nested disk image: + # + # ```ruby + # container nested: "orca-#{version}.dmg" + # ``` + # + # The container should not be unarchived: + # + # ```ruby + # container type: :naked + # ``` + # + # @api public + sig { params(nested: T.nilable(String), type: T.nilable(Symbol)).returns(T.nilable(DSL::Container)) } + def container(nested: nil, type: nil) + set_unique_stanza(:container, nested.nil? && type.nil?) do + DSL::Container.new(nested:, type:) + end end - def container(*args) - set_unique_stanza(:container, args.empty?) do - DSL::Container.new(*args) - 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(from, to) end + # Sets the cask's version. + # + # ### Example + # + # ```ruby + # version "1.88.1" + # ``` + # + # @see DSL::Version + # @api public + sig { params(arg: T.nilable(T.any(String, Symbol))).returns(T.nilable(DSL::Version)) } def version(arg = nil) set_unique_stanza(:version, arg.nil?) do if !arg.is_a?(String) && arg != :latest - raise CaskInvalidError.new(cask, "invalid 'version' value: '#{arg.inspect}'") + raise CaskInvalidError.new(cask, "invalid 'version' value: #{arg.inspect}") end + set_no_autobump(because: :latest_version) if arg == :latest && !no_autobump_defined? + DSL::Version.new(arg) end end - def sha256(arg = nil) - set_unique_stanza(:sha256, arg.nil?) do - if !arg.is_a?(String) && arg != :no_check - raise CaskInvalidError.new(cask, "invalid 'sha256' value: '#{arg.inspect}'") + # Sets the cask's download checksum. + # + # ### Example + # + # For universal or single-architecture downloads: + # + # ```ruby + # sha256 "7bdb497080ffafdfd8cc94d8c62b004af1be9599e865e5555e456e2681e150ca" + # ``` + # + # For architecture-dependent downloads: + # + # ```ruby + # sha256 arm: "7bdb497080ffafdfd8cc94d8c62b004af1be9599e865e5555e456e2681e150ca", + # x86_64: "b3c1c2442480a0219b9e05cf91d03385858c20f04b764ec08a3fa83d1b27e7b2" + # x86_64_linux: "1a2aee7f1ddc999993d4d7d42a150c5e602bc17281678050b8ed79a0500cc90f" + # arm64_linux: "bd766af7e692afceb727a6f88e24e6e68d9882aeb3e8348412f6c03d96537c75" + # ``` + # + # @api public + sig { + params( + arg: T.nilable(T.any(String, Symbol)), + arm: T.nilable(String), + intel: T.nilable(String), + x86_64: T.nilable(String), + x86_64_linux: T.nilable(String), + arm64_linux: T.nilable(String), + ).returns(T.nilable(T.any(Symbol, Checksum))) + } + def sha256(arg = nil, arm: nil, intel: nil, x86_64: nil, x86_64_linux: nil, arm64_linux: nil) + should_return = arg.nil? && arm.nil? && (intel.nil? || x86_64.nil?) && x86_64_linux.nil? && arm64_linux.nil? + + x86_64 ||= intel if intel.present? && x86_64.nil? + set_unique_stanza(:sha256, should_return) do + if arm.present? || x86_64.present? || x86_64_linux.present? || arm64_linux.present? + @on_system_blocks_exist = true end + @sha256_set_for_linux = true if x86_64_linux.present? || arm64_linux.present? + + val = arg || on_system_conditional( + macos: on_arch_conditional(arm:, intel: x86_64), + linux: on_arch_conditional(arm: arm64_linux, intel: x86_64_linux), + ) + case val + when :no_check + :no_check + when String + Checksum.new(val) + else + raise CaskInvalidError.new(cask, "invalid 'sha256' value: #{val.inspect}") + end + end + end - arg + # Sets the cask's architecture strings. + # + # ### Example + # + # ```ruby + # arch arm: "darwin-arm64", intel: "darwin" + # ``` + # + # @api public + sig { params(arm: T.nilable(String), intel: T.nilable(String)).returns(T.nilable(String)) } + def arch(arm: nil, intel: nil) + should_return = arm.nil? && intel.nil? + + set_unique_stanza(:arch, should_return) do + @on_system_blocks_exist = true + + on_arch_conditional(arm:, intel:) end end - # depends_on uses a load method so that multiple stanzas can be merged - def depends_on(*args) - @depends_on ||= DSL::DependsOn.new - return @depends_on if args.empty? + # Sets the cask's os strings. + # + # ### Example + # + # ```ruby + # os macos: "darwin", linux: "tux" + # ``` + # + # @api public + sig { + params( + macos: T.nilable(String), + linux: T.nilable(String), + ).returns(T.nilable(String)) + } + def os(macos: nil, linux: nil) + should_return = macos.nil? && linux.nil? + + set_unique_stanza(:os, should_return) do + @on_system_blocks_exist = true + @on_os_blocks_exist = true + + on_system_conditional(macos:, linux:) + end + end + + # Declare dependencies and requirements for a cask. + # + # NOTE: Multiple dependencies can be specified. + # + # @api public + sig { params(arg: T.nilable(Symbol), kwargs: T.untyped).returns(DSL::DependsOn) } + def depends_on(arg = nil, **kwargs) + @depends_on_set_in_block = true if @called_in_on_system_block + if arg == :macos + if kwargs.key?(:macos) || kwargs.key?(:maximum_macos) + raise CaskInvalidError.new(cask, "`depends_on :macos` cannot be combined with another macOS `depends_on`") + end + + kwargs[:macos] = :any + elsif arg == :linux + kwargs[:linux] = :any + elsif arg + raise CaskInvalidError.new(cask, "invalid 'depends_on' value: #{arg.inspect}") + end + return @depends_on if kwargs.empty? begin - @depends_on.load(*args) + @depends_on.load(kwargs, set_in_block: @called_in_on_system_block) rescue RuntimeError => e raise CaskInvalidError.new(cask, e) end @depends_on end - def conflicts_with(*args) - # TODO: remove this constraint, and instead merge multiple conflicts_with stanzas - set_unique_stanza(:conflicts_with, args.empty?) { DSL::ConflictsWith.new(*args) } - end - - def artifacts - @artifacts ||= SortedSet.new + # Declare conflicts that keep a cask from installing or working correctly. + # + # @api public + sig { params(kwargs: T.anything).returns(T.nilable(DSL::ConflictsWith)) } + def conflicts_with(**kwargs) + # TODO: Remove this constraint and instead merge multiple `conflicts_with` stanzas + set_unique_stanza(:conflicts_with, kwargs.empty?) { DSL::ConflictsWith.new(**kwargs) } end + sig { returns(Pathname) } def caskroom_path - @cask.caskroom_path + cask.caskroom_path end + # The staged location for this cask, including version number. + # + # @api public + sig { returns(Pathname) } def staged_path return @staged_path if @staged_path @@ -234,9 +654,17 @@ def staged_path @staged_path = caskroom_path.join(cask_version.to_s) end + # Provide the user with cask-specific information at install time. + # + # @api public + sig { + params( + strings: String, + block: T.nilable(T.proc.returns(T.nilable(T.any(Symbol, String)))), + ).returns(T.any(String, DSL::Caveats)) + } def caveats(*strings, &block) - @caveats ||= DSL::Caveats.new(cask) - if block_given? + if block @caveats.eval_caveats(&block) elsif strings.any? strings.each do |string| @@ -248,18 +676,146 @@ def caveats(*strings, &block) @caveats end + sig { returns(DSL::Caveats) } + def caveats_object = @caveats + + # Asserts that the cask artifacts auto-update. + # + # @api public + sig { params(auto_updates: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) } def auto_updates(auto_updates = nil) set_unique_stanza(:auto_updates, auto_updates.nil?) { auto_updates } end + # Automatically fetch the latest version of a cask from changelogs. + # + # @api public + sig { params(block: T.nilable(T.proc.void)).returns(Livecheck) } + def livecheck(&block) + return @livecheck unless block + + if !@cask.allow_reassignment && @livecheck_defined + raise CaskInvalidError.new(cask, "'livecheck' stanza may only appear once.") + end + + @livecheck_defined = true + @livecheck.instance_eval(&block) + set_no_autobump(because: :extract_plist) if @livecheck.strategy == :extract_plist && !no_autobump_defined? + @livecheck + end + + # Excludes the cask from autobump list. + # + # @api public + sig { params(because: T.any(String, Symbol)).void } + def no_autobump!(because:) + tap = @cask.tap + if tap && !tap.official? + raise CaskInvalidError.new(cask, "'no_autobump!' can only be used in official Homebrew taps.") + end + + set_no_autobump(because:) + end + + # Is the cask in autobump list? + sig { returns(T::Boolean) } + def autobump? + @autobump == true + end + + # Declare that a cask is no longer functional or supported. + # + # NOTE: A warning will be shown when trying to install this cask. + # + # @api public + sig { + params( + date: String, + because: T.any(String, Symbol), + replacement: T.nilable(String), + replacement_formula: T.nilable(String), + replacement_cask: T.nilable(String), + ).void + } + def deprecate!(date:, because:, replacement: nil, replacement_formula: nil, replacement_cask: nil) + if [replacement, replacement_formula, replacement_cask].filter_map(&:presence).length > 1 + raise ArgumentError, "more than one of replacement, replacement_formula and/or replacement_cask specified!" + end + + if replacement + odeprecated( + "deprecate!(:replacement)", + "deprecate!(:replacement_formula) or deprecate!(:replacement_cask)", + ) + end + + @deprecate_args = { date:, because:, replacement_formula:, replacement_cask: } + + @deprecation_date = Date.parse(date) + return if @deprecation_date > Date.today + + @deprecation_reason = because + @deprecation_replacement_formula = replacement_formula.presence || replacement + @deprecation_replacement_cask = replacement_cask.presence || replacement + @deprecated = true + end + + # Declare that a cask is no longer functional or supported. + # + # NOTE: An error will be thrown when trying to install this cask. + # + # @api public + sig { + params( + date: String, + because: T.any(String, Symbol), + replacement: T.nilable(String), + replacement_formula: T.nilable(String), + replacement_cask: T.nilable(String), + ).void + } + def disable!(date:, because:, replacement: nil, replacement_formula: nil, replacement_cask: nil) + if [replacement, replacement_formula, replacement_cask].filter_map(&:presence).length > 1 + raise ArgumentError, "more than one of replacement, replacement_formula and/or replacement_cask specified!" + end + + # odeprecate: remove this remapping when the :unsigned reason is removed + because = :fails_gatekeeper_check if because == :unsigned + + if replacement + odeprecated( + "disable!(:replacement)", + "disable!(:replacement_formula) or disable!(:replacement_cask)", + ) + end + + @disable_args = { date:, because:, replacement_formula:, replacement_cask: } + + @disable_date = Date.parse(date) + + if @disable_date > Date.today + @deprecation_reason = because + @deprecation_replacement_formula = replacement_formula.presence || replacement + @deprecation_replacement_cask = replacement_cask.presence || replacement + @deprecated = true + return + end + + @disable_reason = because + @disable_replacement_formula = replacement_formula.presence || replacement + @disable_replacement_cask = replacement_cask.presence || replacement + @disabled = true + end + ORDINARY_ARTIFACT_CLASSES.each do |klass| - define_method(klass.dsl_key) do |*args| + define_method(klass.dsl_key) do |*args, **kwargs| + T.bind(self, DSL) if [*artifacts.map(&:class), klass].include?(Artifact::StageOnly) && - (artifacts.map(&:class) & ACTIVATABLE_ARTIFACT_CLASSES).any? + artifacts.map(&:class).intersect?(ACTIVATABLE_ARTIFACT_CLASSES) raise CaskInvalidError.new(cask, "'stage_only' must be the only activatable artifact.") end - artifacts.add(klass.from_args(cask, *args)) + artifacts.add(klass.from_args(cask, *args, **kwargs)) rescue CaskInvalidError raise rescue => e @@ -270,26 +826,111 @@ def auto_updates(auto_updates = nil) ARTIFACT_BLOCK_CLASSES.each do |klass| [klass.dsl_key, klass.uninstall_dsl_key].each do |dsl_key| define_method(dsl_key) do |&block| - artifacts.add(klass.new(cask, dsl_key => block)) + T.bind(self, DSL) + if install_step_artifact_defined?(dsl_key) + warn_on_install_step_conflict(dsl_key, T.must(install_step_artifact_class(dsl_key)).dsl_key) + else + artifacts.add(klass.new(cask, dsl_key => block)) + end end end end - def method_missing(method, *) - if method - Utils.method_missing_message(method, token) - nil - else - super + INSTALL_STEP_ARTIFACT_CLASSES.each do |klass| + define_method(klass.dsl_key) do |steps = nil, **kwargs, &block| + T.bind(self, DSL) + steps = if block + Homebrew::InstallSteps::DSL.build(default_base: :staged_path, default_source_base: :staged_path, + default_target_base: :staged_path, &block) + else + Homebrew::InstallSteps::DSL.normalise_steps([kwargs[:steps] || steps].flatten.compact) + end + remove_conflicting_flight_blocks(klass) + artifacts.add(klass.new(cask, steps)) + end + end + + sig { params(dsl_key: Symbol).returns(T::Boolean) } + def install_step_artifact_defined?(dsl_key) + return false unless (klass = install_step_artifact_class(dsl_key)) + + artifacts.any?(klass) + end + + sig { params(dsl_key: Symbol).returns(T.nilable(T.class_of(Artifact::AbstractInstallSteps))) } + def install_step_artifact_class(dsl_key) + INSTALL_STEP_FLIGHT_BLOCK_CLASSES.find do |_step_class, (_block_class, block_dsl_key)| + block_dsl_key == dsl_key + end&.first + end + + sig { params(klass: T.class_of(Artifact::AbstractInstallSteps)).void } + def remove_conflicting_flight_blocks(klass) + flight_block_class, dsl_key = INSTALL_STEP_FLIGHT_BLOCK_CLASSES.fetch(klass) + conflicting_flight_blocks = artifacts.select do |artifact| + next false unless artifact.is_a?(flight_block_class) + + artifact.directives.key?(dsl_key) end + + conflicting_flight_blocks.each do |artifact| + warn_on_install_step_conflict(dsl_key, klass.dsl_key) + artifacts.delete(artifact) + end + end + + sig { params(dsl_key: Symbol, steps_key: Symbol).void } + def warn_on_install_step_conflict(dsl_key, steps_key) + opoo "#{token}: `#{dsl_key}` is ignored because `#{steps_key}` is defined!" end - def respond_to_missing?(*) - true + sig { override.params(method: Symbol, _args: T.anything).returns(T.noreturn) } + def method_missing(method, *_args) + raise NoMethodError, "undefined method '#{method}' for Cask '#{token}'" end + sig { override.params(_method_name: T.any(String, Symbol), _include_private: T::Boolean).returns(T::Boolean) } + def respond_to_missing?(_method_name, _include_private = false) + false + end + + sig { returns(T.nilable(MacOSVersion)) } + def os_version + nil + end + + # The directory `app`s are installed into. + # + # @api public + sig { returns(T.any(Pathname, String)) } def appdir + return HOMEBREW_CASK_APPDIR_PLACEHOLDER if Cask.generating_hash? + cask.config.appdir end + + private + + sig { returns(T::Boolean) } + def no_autobump_defined? + @no_autobump_defined + end + + sig { params(because: T.any(String, Symbol)).void } + def set_no_autobump(because:) + if because.is_a?(Symbol) && !NO_AUTOBUMP_REASONS_LIST.key?(because) + raise ArgumentError, "'because' argument should use valid symbol or a string!" + end + + if !@cask.allow_reassignment && no_autobump_defined? + raise CaskInvalidError.new(cask, "'no_autobump!' stanza may only appear once.") + end + + odisabled "no_autobump! because: :requires_manual_review" if because == :requires_manual_review + + @no_autobump_defined = true + @no_autobump_message = because + @autobump = false + end end end diff --git a/Library/Homebrew/cask/dsl/appcast.rb b/Library/Homebrew/cask/dsl/appcast.rb deleted file mode 100644 index 695152dec3d2f..0000000000000 --- a/Library/Homebrew/cask/dsl/appcast.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Cask - class DSL - class Appcast - attr_reader :uri, :parameters, :configuration - - def initialize(uri, **parameters) - @uri = URI(uri) - @parameters = parameters - @configuration = parameters[:configuration] if parameters.key?(:configuration) - end - - def to_yaml - [uri, parameters].to_yaml - end - - def to_s - uri.to_s - end - end - end -end diff --git a/Library/Homebrew/cask/dsl/base.rb b/Library/Homebrew/cask/dsl/base.rb index 2a14a1c8c55f3..92f755336e567 100644 --- a/Library/Homebrew/cask/dsl/base.rb +++ b/Library/Homebrew/cask/dsl/base.rb @@ -1,34 +1,42 @@ +# typed: strict # frozen_string_literal: true +require "utils/path" + module Cask class DSL + # Superclass for all stanzas which take a block. class Base extend Forwardable + include ::Utils::Path + + sig { returns(Cask) } + attr_reader :cask + + sig { returns(T.class_of(SystemCommand)) } + attr_reader :command + sig { params(cask: Cask, command: T.class_of(SystemCommand)).void } def initialize(cask, command = SystemCommand) @cask = cask @command = command end - def_delegators :@cask, :token, :version, :caskroom_path, :staged_path, :appdir, :language + def_delegators :@cask, :token, :version, :caskroom_path, :staged_path, :appdir, :language, :arch + sig { params(executable: String, options: T.untyped).returns(T.nilable(SystemCommand::Result)) } def system_command(executable, **options) @command.run!(executable, **options) end - def method_missing(method, *) - if method - underscored_class = self.class.name.gsub(/([[:lower:]])([[:upper:]][[:lower:]])/, '\1_\2').downcase - section = underscored_class.split("::").last - Utils.method_missing_message(method, @cask.to_s, section) - nil - else - super - end + sig { params(method: Symbol, _args: T.untyped).returns(T.noreturn) } + def method_missing(method, *_args) + raise NoMethodError, "undefined method '#{method}' for Cask '#{@cask}'" end - def respond_to_missing?(*) - true + sig { params(_method: Symbol, _include_private: T::Boolean).returns(T::Boolean) } + def respond_to_missing?(_method, _include_private = false) + false end end end diff --git a/Library/Homebrew/cask/dsl/caveats.rb b/Library/Homebrew/cask/dsl/caveats.rb index 2b635931a8ac4..1c9f8155bd936 100644 --- a/Library/Homebrew/cask/dsl/caveats.rb +++ b/Library/Homebrew/cask/dsl/caveats.rb @@ -1,42 +1,82 @@ +# typed: strict # frozen_string_literal: true -# Caveats DSL. Each method should handle output, following the -# convention of at least one trailing blank line so that the user -# can distinguish separate caveats. -# -# ( The return value of the last method in the block is also sent -# to the output by the caller, but that feature is only for the -# convenience of Cask authors. ) +require "cask/utils" + module Cask class DSL + # Class corresponding to the `caveats` stanza. + # + # Each method should handle output, following the + # convention of at least one trailing blank line so that the user + # can distinguish separate caveats. + # + # The return value of the last method in the block is also sent + # to the output by the caller, but that feature is only for the + # convenience of cask authors. class Caveats < Base + # Built-in caveats that have runtime conditions (arch, prefix, etc.) + # and should not be pre-serialized into the API JSON string. + CONDITIONAL_CAVEATS = [:requires_rosetta, :files_in_usr_local].freeze + + sig { params(args: T.anything).void } def initialize(*args) - super(*args) - @built_in_caveats = {} - @custom_caveats = [] + super + @built_in_caveats = T.let({}, T::Hash[T::Array[T.any(String, Symbol)], String]) + @custom_caveats = T.let([], T::Array[String]) + @discontinued = T.let(false, T::Boolean) + @invoked_caveats = T.let(Set.new, T::Set[Symbol]) end + sig { + params(name: Symbol, block: T.proc.bind(Caveats).void).void + } def self.caveat(name, &block) define_method(name) do |*args| + T.bind(self, Caveats) key = [name, *args] + invoked_caveats.add(name) text = instance_exec(*args, &block) - @built_in_caveats[key] = text if text + built_in_caveats[key] = text if text :built_in_caveat end end private_class_method :caveat + sig { returns(T::Boolean) } + def discontinued? = @discontinued + + sig { returns(String) } def to_s (@custom_caveats + @built_in_caveats.values).join("\n") end + # Returns caveats text excluding conditional built-in caveats. + # Used when serializing caveats for the JSON API so that conditional + # caveats (like requires_rosetta) are not pre-baked into the string. + sig { returns(String) } + def to_s_without_conditional + unconditional = @built_in_caveats.reject do |key, _| + name = key.first + name && CONDITIONAL_CAVEATS.include?(name) + end + (@custom_caveats + unconditional.values).join("\n") + end + + sig { params(name: Symbol).returns(T::Boolean) } + def invoked?(name) + @invoked_caveats.include?(name) + end + # Override `puts` to collect caveats. + sig { params(args: String).returns(Symbol) } def puts(*args) @custom_caveats += args :built_in_caveat end + sig { params(block: T.proc.returns(T.nilable(T.any(Symbol, String)))).void } def eval_caveats(&block) result = instance_eval(&block) return unless result @@ -46,28 +86,44 @@ def eval_caveats(&block) end caveat :kext do - next if MacOS.version < :high_sierra + next if MacOS.version < :sonoma <<~EOS - To install and/or use #{@cask} you may need to enable its kernel extension in: - System Preferences → Security & Privacy → General - For more information refer to vendor documentation or this Apple Technical Note: + #{cask} requires a kernel extension to work. + If the installation fails, retry after you enable it in: + System Settings → Privacy & Security + + For more information, refer to vendor documentation or this Apple Technical Note: #{Formatter.url("https://developer.apple.com/library/content/technotes/tn2459/_index.html")} EOS end + caveat :unsigned_accessibility do |access = "Accessibility"| + # access: the category in the privacy settings the app requires. + access = "Accessibility" if access.nil? + + <<~EOS + #{cask} is not signed and requires Accessibility access, + so you will need to re-grant Accessibility access every time the app is updated. + + Enable or re-enable it in: + #{::Cask::Utils.privacy_security_preference_pane(access)} + To re-enable, untick and retick #{cask}.app. + EOS + end + caveat :path_environment_variable do |path| <<~EOS - To use #{@cask}, you may need to add the #{path} directory - to your PATH environment variable, e.g. (for bash shell): + To use #{cask}, you may need to add the #{path} directory + to your PATH environment variable, e.g. (for Bash shell): export PATH=#{path}:"$PATH" EOS end caveat :zsh_path_helper do |path| <<~EOS - To use #{@cask}, zsh users may need to add the following line to their - ~/.zprofile. (Among other effects, #{path} will be added to the + To use #{cask}, zsh users may need to add the following line to their + ~/.zprofile. (Among other effects, #{path} will be added to the PATH environment variable): eval `/usr/libexec/path_helper -s` EOS @@ -77,7 +133,7 @@ def eval_caveats(&block) next unless HOMEBREW_PREFIX.to_s.downcase.start_with?("/usr/local") <<~EOS - Cask #{@cask} installs files under /usr/local. The presence of such + Cask #{cask} installs files under /usr/local. The presence of such files can cause warnings when running `brew doctor`, which is considered to be a bug in Homebrew Cask. EOS @@ -86,54 +142,68 @@ def eval_caveats(&block) caveat :depends_on_java do |java_version = :any| if java_version == :any <<~EOS - #{@cask} requires Java. You can install the latest version with: - brew cask install adoptopenjdk + #{cask} requires Java. You can install the latest version with: + brew install --cask temurin EOS - elsif java_version.include?("11") || java_version.include?("+") + elsif java_version.to_s.include?("+") <<~EOS - #{@cask} requires Java #{java_version}. You can install the latest version with: - brew cask install adoptopenjdk + #{cask} requires Java #{java_version}. You can install the latest version with: + brew install --cask temurin EOS else <<~EOS - #{@cask} requires Java #{java_version}. You can install it with: - brew cask install homebrew/cask-versions/adoptopenjdk#{java_version} + #{cask} requires Java #{java_version}. You can install it with: + brew install --cask temurin@#{java_version} EOS end end - caveat :logout do + caveat :requires_rosetta do + next if Homebrew::SimulateSystem.current_arch != :arm + <<~EOS - You must log out and log back in for the installation of #{@cask} to take effect. + #{cask} is built for Intel macOS and so requires Rosetta 2 to be installed. + You can install Rosetta 2 with: + softwareupdate --install-rosetta --agree-to-license + Note that it is very difficult to remove Rosetta 2 once it is installed. EOS end - caveat :reboot do + caveat :logout do <<~EOS - You must reboot for the installation of #{@cask} to take effect. + You must log out and log back in for the installation of #{cask} to take effect. EOS end - caveat :discontinued do + caveat :reboot do <<~EOS - #{@cask} has been officially discontinued upstream. - It may stop working correctly (or at all) in recent versions of macOS. + You must reboot for the installation of #{cask} to take effect. EOS end caveat :license do |web_page| <<~EOS - Installing #{@cask} means you have AGREED to the license at: + Installing #{cask} means you have AGREED to the license at: #{Formatter.url(web_page.to_s)} EOS end caveat :free_license do |web_page| <<~EOS - The vendor offers a free license for #{@cask} at: + The vendor offers a free license for #{cask} at: #{Formatter.url(web_page.to_s)} EOS end + + private + + # These attrs are required as a workaround for https://github.com/sorbet/sorbet/issues/8106 + + sig { returns(T::Set[Symbol]) } + attr_reader :invoked_caveats + + sig { returns(T::Hash[T::Array[T.any(String, Symbol)], String]) } + attr_reader :built_in_caveats end end end diff --git a/Library/Homebrew/cask/dsl/caveats.rbi b/Library/Homebrew/cask/dsl/caveats.rbi new file mode 100644 index 0000000000000..f9799bfcaa265 --- /dev/null +++ b/Library/Homebrew/cask/dsl/caveats.rbi @@ -0,0 +1,6 @@ +# typed: strict + +class Cask::DSL::Caveats + sig { returns(Symbol) } + def kext; end +end diff --git a/Library/Homebrew/cask/dsl/conflicts_with.rb b/Library/Homebrew/cask/dsl/conflicts_with.rb index abd94772265c6..a9b57d28e5ec2 100644 --- a/Library/Homebrew/cask/dsl/conflicts_with.rb +++ b/Library/Homebrew/cask/dsl/conflicts_with.rb @@ -1,30 +1,34 @@ +# typed: strict # frozen_string_literal: true -require "extend/hash_validator" -using HashValidator +require "delegate" +require "extend/hash/keys" +require "utils/output" module Cask class DSL - class ConflictsWith < DelegateClass(Hash) - VALID_KEYS = [ - :formula, - :cask, - :macos, - :arch, - :x11, - :java, - ].freeze + # Class corresponding to the `conflicts_with` stanza. + class ConflictsWith < SimpleDelegator + VALID_KEYS = [:cask].freeze - def initialize(**pairs) - pairs.assert_valid_keys!(*VALID_KEYS) + sig { params(options: T.anything).void } + def initialize(**options) + options.assert_valid_keys(*VALID_KEYS) - super(Hash[pairs.map { |k, v| [k, Set.new([*v])] }]) + conflicts = options.transform_values { |v| Set.new(Kernel.Array(v)) } + conflicts.default = Set.new - self.default = Set.new + super(conflicts) end + sig { returns(T::Hash[Symbol, T::Array[String]]) } + def to_h + __getobj__.transform_values(&:to_a) + end + + sig { params(generator: T.anything).returns(String) } def to_json(generator) - Hash[map { |k, v| [k, v.to_a] }].to_json(generator) + to_h.to_json(generator) end end end diff --git a/Library/Homebrew/cask/dsl/container.rb b/Library/Homebrew/cask/dsl/container.rb index 378e94ced22db..5d642de2279f4 100644 --- a/Library/Homebrew/cask/dsl/container.rb +++ b/Library/Homebrew/cask/dsl/container.rb @@ -1,25 +1,22 @@ +# typed: strict # frozen_string_literal: true require "unpack_strategy" module Cask class DSL + # Class corresponding to the `container` stanza. class Container - VALID_KEYS = Set.new([ - :type, - :nested, - ]).freeze + sig { returns(T.nilable(String)) } + attr_accessor :nested - attr_accessor(*VALID_KEYS) - attr_accessor :pairs + sig { returns(T.nilable(Symbol)) } + attr_accessor :type - def initialize(pairs = {}) - @pairs = pairs - pairs.each do |key, value| - raise "invalid container key: '#{key.inspect}'" unless VALID_KEYS.include?(key) - - send(:"#{key}=", value) - end + sig { params(nested: T.nilable(String), type: T.nilable(Symbol)).void } + def initialize(nested: nil, type: nil) + @nested = nested + @type = type return if type.nil? return unless UnpackStrategy.from_type(type).nil? @@ -27,13 +24,18 @@ def initialize(pairs = {}) raise "invalid container type: #{type.inspect}" end - def to_yaml - @pairs.to_yaml + sig { returns(T::Hash[Symbol, T.nilable(T.any(String, Symbol))]) } + def pairs + instance_variables.to_h { |ivar| [ivar[1..].to_sym, instance_variable_get(ivar)] }.compact end - def to_s - @pairs.inspect + sig { returns(String) } + def to_yaml + pairs.to_yaml end + + sig { returns(String) } + def to_s = pairs.inspect end end end diff --git a/Library/Homebrew/cask/dsl/depends_on.rb b/Library/Homebrew/cask/dsl/depends_on.rb index b5aa5ff7391d3..86aac01c9c533 100644 --- a/Library/Homebrew/cask/dsl/depends_on.rb +++ b/Library/Homebrew/cask/dsl/depends_on.rb @@ -1,68 +1,140 @@ +# typed: strict # frozen_string_literal: true +require "delegate" + +require "requirements/macos_requirement" +require "requirements/linux_requirement" +require "utils/output" + module Cask class DSL - class DependsOn < DelegateClass(Hash) - VALID_KEYS = Set.new([ - :formula, - :cask, - :macos, - :arch, - :x11, - :java, - ]).freeze - - VALID_ARCHES = { + # Class corresponding to the `depends_on` stanza. + class DependsOn < SimpleDelegator + include ::Utils::Output::Mixin + + VALID_KEYS = T.let(Set.new([ + :formula, + :cask, + :macos, + :maximum_macos, + :linux, + :arch, + ]).freeze, T::Set[Symbol]) + + VALID_ARCHES = T.let({ intel: { type: :intel, bits: 64 }, # specific x86_64: { type: :intel, bits: 64 }, - }.freeze + arm64: { type: :arm, bits: 64 }, + }.freeze, T::Hash[Symbol, T::Hash[Symbol, T.any(Symbol, Integer)]]) + + sig { returns(T.nilable(T::Array[T::Hash[Symbol, T.any(Symbol, Integer)]])) } + attr_reader :arch + + sig { returns(T.nilable(MacOSRequirement)) } + attr_reader :macos - attr_accessor :java - attr_reader :arch, :cask, :formula, :macos, :x11 + sig { returns(T.nilable(MacOSRequirement)) } + attr_reader :maximum_macos + sig { returns(T.nilable(LinuxRequirement)) } + attr_reader :linux + + sig { void } def initialize super({}) + @arch = T.let(nil, T.nilable(T::Array[T::Hash[Symbol, T.any(Symbol, Integer)]])) + @cask = T.let(nil, T.nilable(T::Array[String])) + @formula = T.let(nil, T.nilable(T::Array[String])) + @macos = T.let(nil, T.nilable(MacOSRequirement)) + @maximum_macos = T.let(nil, T.nilable(MacOSRequirement)) + @linux = T.let(nil, T.nilable(LinuxRequirement)) + @macos_bare_set_top_level = T.let(false, T::Boolean) + @macos_version_set_top_level = T.let(false, T::Boolean) + @maximum_macos_set_top_level = T.let(false, T::Boolean) + @linux_set_top_level = T.let(false, T::Boolean) + end + + sig { returns(T::Array[String]) } + def cask @cask ||= [] + end + + sig { returns(T::Array[String]) } + def formula @formula ||= [] end - def load(**pairs) + sig { + params( + pairs: T::Hash[Symbol, T.any(String, Symbol, T::Array[T.any(String, Symbol)])], + set_in_block: T::Boolean, + ).void + } + def load(pairs, set_in_block: false) pairs.each do |key, value| raise "invalid depends_on key: '#{key.inspect}'" unless VALID_KEYS.include?(key) - self[key] = send(:"#{key}=", *value) + previous_macos = @macos if key == :macos + __getobj__[key] = case key + when :macos, :maximum_macos + send(:"#{key}=", *value, set_in_block:) + else + send(:"#{key}=", *value) + end + record_os_requirement(key, set_in_block:) + next if key != :macos + next if value != :any + next unless previous_macos&.version_specified? + + @macos = previous_macos + __getobj__[key] = previous_macos end end + sig { params(args: String).returns(T::Array[String]) } def formula=(*args) - @formula.concat(args) + formula.concat(args) end + sig { params(args: String).returns(T::Array[String]) } def cask=(*args) - @cask.concat(args) + cask.concat(args) end - def macos=(*args) - raise "Only a single 'depends_on macos:' is allowed." if defined?(@macos) - - begin - @macos = if args.count > 1 - MacOSRequirement.new([args], comparator: "==") - elsif MacOS::Version::SYMBOLS.key?(args.first) - MacOSRequirement.new([args.first], comparator: "==") - elsif /^\s*(?<|>|[=<>]=)\s*:(?\S+)\s*$/ =~ args.first - MacOSRequirement.new([version.to_sym], comparator: comparator) - elsif /^\s*(?<|>|[=<>]=)\s*(?\S+)\s*$/ =~ args.first - MacOSRequirement.new([version], comparator: comparator) - else - MacOSRequirement.new([args.first], comparator: "==") - end - rescue - raise "invalid 'depends_on macos' value: #{args.first.inspect}" + sig { params(args: T.any(String, Symbol), set_in_block: T::Boolean).returns(T.nilable(MacOSRequirement)) } + def macos=(*args, set_in_block: false) + @macos = MacOSRequirement.parse(args, comparator: ">=") + rescue MacOSVersion::Error, TypeError => e + raise "invalid 'depends_on macos' value: #{e}" + end + + sig { params(args: T.any(String, Symbol), set_in_block: T::Boolean).returns(T.nilable(MacOSRequirement)) } + def maximum_macos=(*args, set_in_block: false) + raise "invalid 'depends_on maximum_macos' value: only a single macOS version is allowed" if args.count != 1 + + maximum_macos = begin + MacOSRequirement.parse(args, comparator: "<=") + rescue MacOSVersion::Error, TypeError => e + raise "invalid 'depends_on maximum_macos' value: #{e}" + end + if maximum_macos.comparator != "<=" + raise "invalid 'depends_on maximum_macos' value: must use the '<=' comparator" end + + @maximum_macos = maximum_macos + end + + sig { params(args: T.any(String, Symbol)).returns(T.nilable(LinuxRequirement)) } + def linux=(*args) + raise "Only a single 'depends_on linux' is allowed." if @linux + raise "invalid 'depends_on linux' value: #{args.first.inspect}" if args.first != :any + + @linux = LinuxRequirement.new end + sig { params(args: Symbol).returns(T::Array[T::Hash[Symbol, T.any(Symbol, Integer)]]) } def arch=(*args) @arch ||= [] arches = args.map do |elt| @@ -71,13 +143,75 @@ def arch=(*args) invalid_arches = arches - VALID_ARCHES.keys raise "invalid 'depends_on arch' values: #{invalid_arches.inspect}" unless invalid_arches.empty? - @arch.concat(arches.map { |arch| VALID_ARCHES[arch] }) + @arch.concat(arches.map { |arch| VALID_ARCHES.fetch(arch) }) + end + + sig { returns(T::Boolean) } + def empty? = T.let(__getobj__, T::Hash[Symbol, T.untyped]).empty? + + sig { returns(T::Boolean) } + def present? = !empty? + + sig { returns(T::Boolean) } + def requires_macos? + @macos_bare_set_top_level || @macos_version_set_top_level || @maximum_macos_set_top_level + end + + sig { returns(T::Boolean) } + def requires_linux? = @linux_set_top_level + + sig { params(key: Symbol, set_in_block: T::Boolean).void } + def record_os_requirement(key, set_in_block:) + case key + when :macos + macos = @macos + raise "invalid 'depends_on macos' value" unless macos + + record_macos_requirement(macos, set_in_block:) + when :maximum_macos + maximum_macos = @maximum_macos + raise "invalid 'depends_on maximum_macos' value" unless maximum_macos + + record_macos_requirement(maximum_macos, set_in_block:) + when :linux + return if set_in_block + raise "`depends_on :linux` cannot be combined with `depends_on macos:`" if requires_macos? + + @linux_set_top_level = true + end end - def x11=(arg) - raise "invalid 'depends_on x11' value: #{arg.inspect}" unless [true, false].include?(arg) + sig { params(requirement: MacOSRequirement, set_in_block: T::Boolean).void } + def record_macos_requirement(requirement, set_in_block:) + return if set_in_block - @x11 = arg + raise "`depends_on :linux` cannot be combined with `depends_on macos:`" if requires_linux? + + if !requirement.version_specified? + raise "`depends_on :macos` cannot be combined with another macOS `depends_on`" if @macos_bare_set_top_level + + if @macos_version_set_top_level || @maximum_macos_set_top_level + odeprecated "`depends_on :macos` with `depends_on macos:`" + end + + @macos_bare_set_top_level = true + elsif requirement.comparator == "<=" + odeprecated "`depends_on :macos` with `depends_on maximum_macos:`" if @macos_bare_set_top_level + + if @maximum_macos_set_top_level + raise "`depends_on maximum_macos:` cannot be combined with another macOS `depends_on`" + end + + @maximum_macos_set_top_level = true + else + odeprecated "`depends_on :macos` with `depends_on macos:`" if @macos_bare_set_top_level + + if @macos_version_set_top_level + raise "`depends_on macos:` cannot be combined with another macOS `depends_on`" + end + + @macos_version_set_top_level = true + end end end end diff --git a/Library/Homebrew/cask/dsl/depends_on.rbi b/Library/Homebrew/cask/dsl/depends_on.rbi new file mode 100644 index 0000000000000..92d6827c64637 --- /dev/null +++ b/Library/Homebrew/cask/dsl/depends_on.rbi @@ -0,0 +1,5 @@ +# typed: strict + +class Cask::DSL::DependsOn + include Kernel +end diff --git a/Library/Homebrew/cask/dsl/postflight.rb b/Library/Homebrew/cask/dsl/postflight.rb index 9e0b1f55b69bd..254a6873d2863 100644 --- a/Library/Homebrew/cask/dsl/postflight.rb +++ b/Library/Homebrew/cask/dsl/postflight.rb @@ -1,15 +1,13 @@ +# typed: strict # frozen_string_literal: true require "cask/staged" module Cask class DSL + # Class corresponding to the `postflight` stanza. class Postflight < Base include Staged - - def suppress_move_to_applications(options = {}) - # TODO: Remove from all casks because it is no longer needed - end end end end diff --git a/Library/Homebrew/cask/dsl/preflight.rb b/Library/Homebrew/cask/dsl/preflight.rb index 1cf1e03b56b97..abaf1fb9751c3 100644 --- a/Library/Homebrew/cask/dsl/preflight.rb +++ b/Library/Homebrew/cask/dsl/preflight.rb @@ -1,7 +1,11 @@ +# typed: strict # frozen_string_literal: true +require "cask/staged" + module Cask class DSL + # Class corresponding to the `preflight` stanza. class Preflight < Base include Staged end diff --git a/Library/Homebrew/cask/dsl/rename.rb b/Library/Homebrew/cask/dsl/rename.rb new file mode 100644 index 0000000000000..4873503097055 --- /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/dsl/uninstall_postflight.rb b/Library/Homebrew/cask/dsl/uninstall_postflight.rb index db83749a22b2b..1a3e38e86887a 100644 --- a/Library/Homebrew/cask/dsl/uninstall_postflight.rb +++ b/Library/Homebrew/cask/dsl/uninstall_postflight.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true module Cask class DSL + # Class corresponding to the `uninstall_postflight` stanza. class UninstallPostflight < Base end end diff --git a/Library/Homebrew/cask/dsl/uninstall_preflight.rb b/Library/Homebrew/cask/dsl/uninstall_preflight.rb index 25d8f1d0d5b2d..5280e638bdb77 100644 --- a/Library/Homebrew/cask/dsl/uninstall_preflight.rb +++ b/Library/Homebrew/cask/dsl/uninstall_preflight.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "cask/staged" module Cask class DSL + # Class corresponding to the `uninstall_preflight` stanza. class UninstallPreflight < Base include Staged end diff --git a/Library/Homebrew/cask/dsl/version.rb b/Library/Homebrew/cask/dsl/version.rb index 477360047e0fd..9a476c575234e 100644 --- a/Library/Homebrew/cask/dsl/version.rb +++ b/Library/Homebrew/cask/dsl/version.rb @@ -1,52 +1,62 @@ +# typed: strict # frozen_string_literal: true module Cask class DSL + # Class corresponding to the `version` stanza. class Version < ::String - DIVIDERS = { + DIVIDERS = T.let({ "." => :dots, "-" => :hyphens, "_" => :underscores, - }.freeze + }.freeze, T::Hash[String, Symbol]) - DIVIDER_REGEX = /(#{DIVIDERS.keys.map { |v| Regexp.quote(v) }.join('|')})/.freeze + DIVIDER_REGEX = /(#{DIVIDERS.keys.map { |v| Regexp.quote(v) }.join("|")})/ - MAJOR_MINOR_PATCH_REGEX = /^([^.,:]+)(?:\.([^.,:]+)(?:\.([^.,:]+))?)?/.freeze + MAJOR_MINOR_PATCH_REGEX = /^([^.,:]+)(?:.([^.,:]+)(?:.([^.,:]+))?)?/ - INVALID_CHARACTERS = /[^0-9a-zA-Z\.\,\:\-\_]/.freeze + INVALID_CHARACTERS = /[^0-9a-zA-Z.,:\-_+ ]/ class << self private + sig { params(divider: String).void } def define_divider_methods(divider) define_divider_deletion_method(divider) define_divider_conversion_methods(divider) end + sig { params(divider: String).void } def define_divider_deletion_method(divider) method_name = deletion_method_name(divider) define_method(method_name) do + T.bind(self, Version) version { delete(divider) } end end + sig { params(divider: String).returns(String) } def deletion_method_name(divider) "no_#{DIVIDERS[divider]}" end + sig { params(left_divider: String).void } def define_divider_conversion_methods(left_divider) (DIVIDERS.keys - [left_divider]).each do |right_divider| define_divider_conversion_method(left_divider, right_divider) end end + sig { params(left_divider: String, right_divider: String).void } def define_divider_conversion_method(left_divider, right_divider) method_name = conversion_method_name(left_divider, right_divider) define_method(method_name) do + T.bind(self, Version) version { gsub(left_divider, right_divider) } end end + sig { params(left_divider: String, right_divider: String).returns(String) } def conversion_method_name(left_divider, right_divider) "#{DIVIDERS[left_divider]}_to_#{DIVIDERS[right_divider]}" end @@ -56,81 +66,136 @@ def conversion_method_name(left_divider, right_divider) define_divider_methods(divider) end + sig { returns(T.nilable(T.any(String, Symbol))) } attr_reader :raw_version + sig { params(raw_version: T.nilable(T.any(String, Symbol))).void } def initialize(raw_version) @raw_version = raw_version super(raw_version.to_s) + + invalid = invalid_characters + raise TypeError, "#{raw_version} contains invalid characters: #{invalid.uniq.join}!" if invalid.present? end + sig { returns(T::Array[T.any(T::Array[String], String)]) } def invalid_characters - return [] if latest? + return [] if raw_version.blank? || latest? - raw_version.scan(INVALID_CHARACTERS) + raw_version.to_s.scan(INVALID_CHARACTERS) end + sig { returns(T::Boolean) } def unstable? return false if latest? s = downcase.delete(".").gsub(/[^a-z\d]+/, "-") return true if s.match?(/(\d+|\b)(alpha|beta|preview|rc|dev|canary|snapshot)(\d+|\b)/i) - return true if s.match?(/\A[a-z\d]+(\-\d+)*\-?(a|b|pre)(\d+|\b)/i) + return true if s.match?(/\A[a-z\d]+(-\d+)*-?(a|b|pre)(\d+|\b)/i) false end + sig { returns(T::Boolean) } def latest? to_s == "latest" end + # The major version. + # + # @api public + sig { returns(T.self_type) } def major version { slice(MAJOR_MINOR_PATCH_REGEX, 1) } end + # The minor version. + # + # @api public + sig { returns(T.self_type) } def minor version { slice(MAJOR_MINOR_PATCH_REGEX, 2) } end + # The patch version. + # + # @api public + sig { returns(T.self_type) } def patch version { slice(MAJOR_MINOR_PATCH_REGEX, 3) } end + # The major and minor version. + # + # @api public + sig { returns(T.self_type) } def major_minor version { [major, minor].reject(&:empty?).join(".") } end + # The major, minor and patch version. + # + # @api public + sig { returns(T.self_type) } def major_minor_patch version { [major, minor, patch].reject(&:empty?).join(".") } end + # The minor and patch version. + # + # @api public + sig { returns(T.self_type) } def minor_patch version { [minor, patch].reject(&:empty?).join(".") } end + # The comma separated values of the version as array. + # + # @api public + sig { returns(T::Array[Version]) } # Only top-level T.self_type is supported https://sorbet.org/docs/self-type + def csv + split(",").map { self.class.new(it) } + end + + # The version part before the first comma. + # + # @api public + sig { returns(T.self_type) } def before_comma version { split(",", 2).first } end + # The version part after the first comma. + # + # @api public + sig { returns(T.self_type) } def after_comma version { split(",", 2).second } end - def before_colon - version { split(":", 2).first } - end - - def after_colon - version { split(":", 2).second } - end - + # The version without any dividers. + # + # @see DIVIDER_REGEX + # @api public + sig { returns(T.self_type) } def no_dividers version { gsub(DIVIDER_REGEX, "") } end + # The version with the given record separator removed from the end. + # + # @see String#chomp + # @api public + sig { params(separator: String).returns(T.self_type) } + def chomp(separator = T.unsafe(nil)) + version { to_s.chomp(separator) } + end + private - def version + sig { params(_block: T.proc.returns(T.nilable(String))).returns(T.self_type) } + def version(&_block) return self if empty? || latest? self.class.new(yield) diff --git a/Library/Homebrew/cask/exceptions.rb b/Library/Homebrew/cask/exceptions.rb index 7a337034c97e3..9948542b64588 100644 --- a/Library/Homebrew/cask/exceptions.rb +++ b/Library/Homebrew/cask/exceptions.rb @@ -1,180 +1,217 @@ +# typed: strict # frozen_string_literal: true module Cask + # General cask error. class CaskError < RuntimeError; end + # Cask error containing multiple other errors. class MultipleCaskErrors < CaskError + sig { params(errors: T::Array[StandardError]).void } def initialize(errors) + super() + @errors = errors end + sig { returns(String) } def to_s <<~EOS Problems with multiple casks: - #{@errors.map(&:to_s).join("\n")} + #{@errors.join("\n")} EOS end end + # Abstract cask error containing a cask token. class AbstractCaskErrorWithToken < CaskError + sig { returns(String) } attr_reader :token + + sig { returns(String) } attr_reader :reason + sig { params(token: T.any(String, Symbol, Cask), reason: T.nilable(Object)).void } def initialize(token, reason = nil) - @token = token - @reason = reason.to_s + super() + + @token = T.let(token.to_s, String) + @reason = T.let(reason.to_s, String) end end + # Error when a cask is not installed. class CaskNotInstalledError < AbstractCaskErrorWithToken + sig { returns(String) } def to_s "Cask '#{token}' is not installed." end end + # Error when a cask cannot be installed. + class CaskCannotBeInstalledError < AbstractCaskErrorWithToken + sig { returns(String) } + attr_reader :message + + sig { params(token: T.any(String, Symbol, Cask), message: String).void } + def initialize(token, message) + super(token) + @message = message + end + + sig { returns(String) } + def to_s + "Cask '#{token}' has been #{message}" + end + end + + # Error when a cask conflicts with another cask. class CaskConflictError < AbstractCaskErrorWithToken + sig { returns(Cask) } attr_reader :conflicting_cask + sig { params(token: T.any(String, Symbol, Cask), conflicting_cask: Cask).void } def initialize(token, conflicting_cask) super(token) @conflicting_cask = conflicting_cask end + sig { returns(String) } def to_s "Cask '#{token}' conflicts with '#{conflicting_cask}'." end end + # Error when a cask is not available. class CaskUnavailableError < AbstractCaskErrorWithToken + sig { returns(String) } def to_s "Cask '#{token}' is unavailable#{reason.empty? ? "." : ": #{reason}"}" end end + # Error when a cask is unreadable. class CaskUnreadableError < CaskUnavailableError + sig { returns(String) } def to_s "Cask '#{token}' is unreadable#{reason.empty? ? "." : ": #{reason}"}" end end - class CaskAlreadyCreatedError < AbstractCaskErrorWithToken + # Error when a cask in a specific tap is not available. + class TapCaskUnavailableError < CaskUnavailableError + sig { returns(Tap) } + attr_reader :tap + + sig { params(tap: Tap, token: String).void } + def initialize(tap, token) + super("#{tap}/#{token}") + @tap = tap + end + + sig { returns(String) } def to_s - %Q(Cask '#{token}' already exists. Run #{Formatter.identifier("brew cask edit #{token}")} to edit it.) + s = super + unless tap.installed? + s += "\nThis command requires the tap #{tap}." + s += "\nIf you trust this tap, tap it explicitly and then try again:\n brew tap #{tap}" + end + s end end - class CaskAlreadyInstalledError < AbstractCaskErrorWithToken - def to_s - <<~EOS - Cask '#{token}' is already installed. + # Error when a cask with the same name is found in multiple taps. + class TapCaskAmbiguityError < CaskError + sig { returns(String) } + attr_reader :token + + sig { returns(T::Array[CaskLoader::FromNameLoader]) } + attr_reader :loaders + + sig { params(token: String, loaders: T::Array[CaskLoader::FromNameLoader]).void } + def initialize(token, loaders) + @token = token + @loaders = loaders - To re-install #{token}, run: - #{Formatter.identifier("brew cask reinstall #{token}")} + taps = loaders.map(&:tap) + casks = taps.map { |tap| "#{tap}/#{token}" } + cask_list = casks.sort.map { |f| "\n * #{f}" }.join + + super <<~EOS + Cask #{token} exists in multiple taps:#{cask_list} + + Please use the fully-qualified name (e.g. #{casks.first}) to refer to a specific Cask. EOS end end - class CaskX11DependencyError < AbstractCaskErrorWithToken + # Error when a cask already exists. + class CaskAlreadyCreatedError < AbstractCaskErrorWithToken + sig { returns(String) } def to_s - <<~EOS - Cask '#{token}' requires XQuartz/X11, which can be installed using Homebrew Cask by running: - #{Formatter.identifier("brew cask install xquartz")} - - or manually, by downloading the package from: - #{Formatter.url("https://www.xquartz.org/")} - EOS + %Q(Cask '#{token}' already exists. Run #{Formatter.identifier("brew edit --cask #{token}")} to edit it.) end end + # Error when there is a cyclic cask dependency. class CaskCyclicDependencyError < AbstractCaskErrorWithToken + sig { returns(String) } def to_s "Cask '#{token}' includes cyclic dependencies on other Casks#{reason.empty? ? "." : ": #{reason}"}" end end + # Error when a cask depends on itself. class CaskSelfReferencingDependencyError < CaskCyclicDependencyError + sig { returns(String) } def to_s "Cask '#{token}' depends on itself." end end + # Error when no cask is specified. class CaskUnspecifiedError < CaskError + sig { returns(String) } def to_s "This command requires a Cask token." end end + # Error when a cask is invalid. class CaskInvalidError < AbstractCaskErrorWithToken + sig { returns(String) } def to_s "Cask '#{token}' definition is invalid#{reason.empty? ? "." : ": #{reason}"}" end end + # Error when a cask token does not match the file name. class CaskTokenMismatchError < CaskInvalidError + sig { params(token: T.any(String, Symbol, Cask), header_token: String).void } def initialize(token, header_token) super(token, "Token '#{header_token}' in header line does not match the file name.") end end - class CaskSha256Error < AbstractCaskErrorWithToken - attr_reader :expected, :actual - - def initialize(token, expected = nil, actual = nil) - super(token) - @expected = expected - @actual = actual - end - end - - class CaskSha256MissingError < CaskSha256Error - def to_s - <<~EOS - Cask '#{token}' requires a checksum: - #{Formatter.identifier("sha256 '#{actual}'")} - EOS - end - end - - class CaskSha256MismatchError < CaskSha256Error + # Error during quarantining of a file. + class CaskQuarantineError < CaskError + sig { returns(T.any(String, Pathname)) } attr_reader :path - def initialize(token, expected, actual, path) - super(token, expected, actual) - @path = path - end - - def to_s - <<~EOS - Checksum for Cask '#{token}' does not match. - Expected: #{Formatter.success(expected.to_s)} - Actual: #{Formatter.error(actual.to_s)} - File: #{path} - To retry an incomplete download, remove the file above. - If the issue persists, visit: - #{Formatter.url("https://github.com/Homebrew/homebrew-cask/blob/master/doc/reporting_bugs/checksum_does_not_match_error.md")} - EOS - end - end - - class CaskNoShasumError < CaskSha256Error - def to_s - <<~EOS - Cask '#{token}' does not have a sha256 checksum defined and was not installed. - This means you have the #{Formatter.identifier("--require-sha")} option set, perhaps in your HOMEBREW_CASK_OPTS. - EOS - end - end - - class CaskQuarantineError < CaskError - attr_reader :path, :reason + sig { returns(String) } + attr_reader :reason + sig { params(path: T.any(String, Pathname), reason: String).void } def initialize(path, reason) + super() + @path = path @reason = reason end + sig { returns(String) } def to_s - s = +"Failed to quarantine #{path}." + s = "Failed to quarantine #{path}." unless reason.empty? s << " Here's the reason:\n" @@ -186,9 +223,11 @@ def to_s end end + # Error while propagating quarantine information to subdirectories. class CaskQuarantinePropagationError < CaskQuarantineError + sig { returns(String) } def to_s - s = +"Failed to quarantine one or more files within #{path}." + s = "Failed to quarantine one or more files within #{path}." unless reason.empty? s << " Here's the reason:\n" @@ -200,9 +239,11 @@ def to_s end end + # Error while removing quarantine information. class CaskQuarantineReleaseError < CaskQuarantineError + sig { returns(String) } def to_s - s = +"Failed to release #{path} from quarantine." + s = "Failed to release #{path} from quarantine." unless reason.empty? s << " Here's the reason:\n" diff --git a/Library/Homebrew/cask/info.rb b/Library/Homebrew/cask/info.rb new file mode 100644 index 0000000000000..2c18263b9dabc --- /dev/null +++ b/Library/Homebrew/cask/info.rb @@ -0,0 +1,216 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "cmd/info" +require "utils/output" + +module Cask + class Info + extend ::Utils::Output::Mixin + + sig { params(cask: Cask).returns(String) } + def self.get_info(cask) + require "cask/installer" + + installed = cask.installed? + output = "#{title_info(cask, installed:)}\n" + output << "#{cask.desc}\n" if cask.desc + output << "#{Formatter.url(cask.homepage)}\n" if cask.homepage + deprecate_disable = DeprecateDisable.message(cask) + if deprecate_disable.present? + deprecate_disable.tap { |message| message[0] = message[0].upcase } + output << "#{deprecate_disable}\n" + end + output << "#{installation_info(cask, installed:)}\n" + metadata = Homebrew::Cmd::Info.metadata_lines(cask) + output << "#{metadata.join("\n")}\n" if metadata.present? + repo = repo_info(cask) + output << "#{repo}\n" if repo + deps = deps_info(cask, mark_uninstalled: installed) + output << deps if deps + requirements = requirements_info(cask, mark_uninstalled: installed) + output << requirements if requirements + language = language_info(cask) + output << language if language + output << "#{artifact_info(cask)}\n" + caveats = Installer.caveats(cask) + output << caveats if caveats + output + end + + sig { params(cask: Cask, args: Homebrew::Cmd::Info::Args).void } + def self.info(cask, args:) + puts get_info(cask) + + return unless cask.tap&.core_cask_tap? + + require "utils/analytics" + ::Utils::Analytics.cask_output(cask, args:) + end + + sig { params(cask: Cask, installed: T::Boolean).returns(String) } + def self.title_info(cask, installed:) + name_with_status = if installed + pretty_installed(cask.token) + else + pretty_uninstalled(cask.token) + end + title = oh1_title(name_with_status).to_s + title += " (#{cask.name.join(", ")})" unless cask.name.empty? + title += ": #{cask.version}" + title += " (auto_updates)" if cask.auto_updates + title + end + + sig { params(cask: Cask, installed: T::Boolean).returns(String) } + def self.installation_info(cask, installed:) + return "Not installed" unless installed + return "No installed version" unless (installed_version = cask.installed_version).present? + + versioned_staged_path = cask.caskroom_path.join(installed_version) + tab = Tab.for_cask(cask) + + unless versioned_staged_path.exist? + return "#{Homebrew::Cmd::Info.installation_status(tab)}\n" \ + "#{versioned_staged_path} (#{Formatter.error("does not exist")})\n" + end + + path_details = versioned_staged_path.children.sum(&:disk_usage) + + info = [Homebrew::Cmd::Info.installation_status(tab)] + info << "#{versioned_staged_path} (#{Formatter.disk_usage_readable(path_details)})" + info << " #{tab}" if tab.tabfile&.exist? + info.join("\n") + end + + sig { params(cask: Cask, mark_uninstalled: T::Boolean).returns(T.nilable(String)) } + def self.deps_info(cask, mark_uninstalled: true) + depends_on = cask.depends_on + + formula_deps = Array(depends_on[:formula]).map do |dep| + name = dep.to_s + rack = HOMEBREW_CELLAR/::Utils.name_from_full_name(name) + decorate_dependency( + name, + installed: rack.directory? && !rack.subdirs.empty?, + mark_uninstalled:, + ) + end + + cask_deps = Array(depends_on[:cask]).map do |dep| + name = dep.to_s + decorate_dependency( + "#{name} (cask)", + installed: (Caskroom.path/name).directory?, + mark_uninstalled:, + ) + end + + all_deps = formula_deps + cask_deps + return if all_deps.empty? + + formula_dependencies = T.let(Set.new, T::Set[String]) + cask_dependencies = T.let(Set.new, T::Set[String]) + Homebrew::Cmd::Info.collect_cask_dependency_names(cask, formula_dependencies, cask_dependencies, + Set[cask.token]) + recursive_count = formula_dependencies.count + cask_dependencies.count + lines = T.let( + [ohai_title("Dependencies").to_s, "Required (#{all_deps.count}): #{all_deps.join(", ")}"], + T::Array[String], + ) + unless recursive_count.zero? + installed_count = formula_dependencies.count do |name| + rack = HOMEBREW_CELLAR/::Utils.name_from_full_name(name) + rack.directory? && !rack.subdirs.empty? + end + cask_dependencies.count do |name| + (Caskroom.path/name).directory? + end + lines << "Recursive Runtime (#{recursive_count}): " \ + "#{Homebrew::Cmd::Info.dependency_status_counts(installed_count, recursive_count)}" + end + + "#{lines.join("\n")}\n" + end + + sig { params(dep: String, installed: T::Boolean, mark_uninstalled: T::Boolean).returns(String) } + def self.decorate_dependency(dep, installed:, mark_uninstalled: true) + pretty_install_status(dep, installed:, mark_uninstalled:) + end + + sig { params(cask: Cask, mark_uninstalled: T::Boolean).returns(T.nilable(String)) } + def self.requirements_info(cask, mark_uninstalled: true) + require "cask_dependent" + + requirements = CaskDependent.new(cask).requirements.grep_v(CaskDependent::Requirement) + return if requirements.empty? + + supports_linux = cask.supports_linux? + output = "#{ohai_title("Requirements")}\n" + %w[build required recommended optional].each do |type| + reqs = case type + when "build" + requirements.select(&:build?) + when "required" + requirements.select(&:required?) + when "recommended" + requirements.select(&:recommended?) + when "optional" + requirements.select(&:optional?) + else + [] + end + next if reqs.empty? + + output << "#{type.capitalize}: #{reqs.map do |requirement| + requirement_s = if requirement.is_a?(MacOSRequirement) && !supports_linux + requirement.display_s.delete_suffix(" (or Linux)") + else + requirement.display_s + end + pretty_install_status( + requirement_s, + installed: requirement.satisfied?, + mark_uninstalled:, + ) + end.join(", ")}\n" + end + output + end + + sig { params(cask: Cask).returns(T.nilable(String)) } + def self.language_info(cask) + return if cask.languages.empty? + + <<~EOS + #{ohai_title("Languages")} + #{cask.languages.join(", ")} + EOS + end + + sig { params(cask: Cask).returns(T.nilable(String)) } + def self.repo_info(cask) + return unless (tap = cask.tap) + + url = if tap.custom_remote? && !tap.remote.nil? + tap.remote + else + "#{tap.default_remote}/blob/HEAD/#{tap.relative_cask_path(cask.token)}" + end + + "From: #{Formatter.url(url)}" + end + + sig { params(cask: Cask).returns(String) } + def self.artifact_info(cask) + artifact_output = ohai_title("Artifacts").dup + cask.artifacts.each do |artifact| + next unless artifact.respond_to?(:install_phase) + next unless DSL::ORDINARY_ARTIFACT_CLASSES.include?(artifact.class) + + artifact_output << "\n" << artifact.to_s + end + artifact_output.freeze + end + end +end diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index ca8088aa32e03..7a39f496540c7 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -1,90 +1,156 @@ +# typed: strict # frozen_string_literal: true require "formula_installer" require "unpack_strategy" +require "utils/topological_hash" +require "utils/analytics" +require "utils/output" -require "cask/topological_hash" +require "api/cask_download" require "cask/config" +require "cask/dsl" require "cask/download" -require "cask/staged" -require "cask/verify" +require "cask/migrator" require "cask/quarantine" - -require "cgi" +require "cask/tab" +require "trust" module Cask + # Installer for a {Cask}. class Installer - extend Predicable - # TODO: it is unwise for Cask::Staged to be a module, when we are - # dealing with both staged and unstaged Casks here. This should - # either be a class which is only sometimes instantiated, or there - # should be explicit checks on whether staged state is valid in - # every method. - include Staged - - def initialize(cask, command: SystemCommand, force: false, + extend ::Utils::Output::Mixin + include ::Utils::Output::Mixin + + sig { returns(::Cask::Cask) } + attr_reader :cask + + sig { + params( + cask: ::Cask::Cask, command: T.class_of(SystemCommand), force: T::Boolean, adopt: T::Boolean, + skip_cask_deps: T::Boolean, binaries: T::Boolean, verbose: T::Boolean, zap: T::Boolean, + require_sha: T::Boolean, upgrade: T::Boolean, reinstall: T::Boolean, + installed_on_request: T::Boolean, quarantine: T::Boolean, verify_download_integrity: T::Boolean, + quiet: T::Boolean, download_queue: Homebrew::DownloadQueue, defer_fetch: T::Boolean + ).void + } + def initialize(cask, command: SystemCommand, force: false, adopt: false, skip_cask_deps: false, binaries: true, verbose: false, - require_sha: false, upgrade: false, - installed_as_dependency: false, quarantine: true) + zap: false, require_sha: false, upgrade: false, reinstall: false, + installed_on_request: true, + quarantine: true, verify_download_integrity: true, quiet: false, + download_queue: Homebrew.default_download_queue, defer_fetch: false) @cask = cask @command = command @force = force + @adopt = adopt @skip_cask_deps = skip_cask_deps @binaries = binaries @verbose = verbose + @zap = zap @require_sha = require_sha - @reinstall = false + @reinstall = reinstall @upgrade = upgrade - @installed_as_dependency = installed_as_dependency + @installed_on_request = installed_on_request @quarantine = quarantine + @verify_download_integrity = verify_download_integrity + @quiet = quiet + @download_queue = download_queue + @defer_fetch = defer_fetch + @source_download = T.let(nil, T.nilable(Homebrew::API::SourceDownload)) + @default_uninstall_artifacts = T.let(nil, T.nilable(ArtifactSet)) + @ran_prelude_fetch = T.let(false, T::Boolean) + @ran_prelude = T.let(false, T::Boolean) + @cask_and_formula_dependencies = T.let(nil, T.nilable(T::Array[T.any(Formula, ::Cask::Cask)])) end - attr_predicate :binaries?, :force?, :skip_cask_deps?, :require_sha?, - :reinstall?, :upgrade?, :verbose?, :installed_as_dependency?, - :quarantine? + sig { returns(T::Boolean) } + def adopt? = @adopt + + sig { returns(T::Boolean) } + def binaries? = @binaries + + sig { returns(T::Boolean) } + def force? = @force + + sig { returns(T::Boolean) } + def installed_on_request? = @installed_on_request + + sig { returns(T::Boolean) } + def quarantine? = @quarantine + + sig { returns(T::Boolean) } + def quiet? = @quiet + + sig { returns(T::Boolean) } + def reinstall? = @reinstall + + sig { returns(T::Boolean) } + def require_sha? = @require_sha + + sig { returns(T::Boolean) } + def skip_cask_deps? = @skip_cask_deps + + sig { returns(T::Boolean) } + def upgrade? = @upgrade + + sig { returns(T::Boolean) } + def verbose? = @verbose + + sig { returns(T::Boolean) } + def zap? = @zap + sig { params(cask: ::Cask::Cask).returns(T.nilable(String)) } def self.caveats(cask) odebug "Printing caveats" caveats = cask.caveats return if caveats.empty? + Homebrew.messages.record_caveats(cask.token, caveats) + <<~EOS #{ohai_title "Caveats"} #{caveats} EOS end - def fetch + sig { params(quiet: T.nilable(T::Boolean), timeout: T.nilable(T.any(Integer, Float))).void } + def fetch(quiet: nil, timeout: nil) odebug "Cask::Installer#fetch" - verify_has_sha if require_sha? && !force? - download - verify + prelude + + download(quiet:, timeout:) unless @defer_fetch - satisfy_dependencies + satisfy_cask_and_formula_dependencies end + sig { void } def stage odebug "Cask::Installer#stage" Caskroom.ensure_caskroom_exists extract_primary_container + process_rename_operations save_caskfile rescue => e purge_versioned_files raise e end + sig { void } def install + start_time = Time.now odebug "Cask::Installer#install" - old_config = @cask.config + Migrator.migrate_if_needed(@cask) - raise CaskAlreadyInstalledError, @cask if @cask.installed? && !force? && !reinstall? && !upgrade? + old_config = @cask.config + predecessor = @cask if reinstall? && @cask.installed? - check_conflicts + prelude print caveats fetch @@ -93,27 +159,63 @@ def install backup if force? && @cask.staged_path.exist? && @cask.metadata_versioned_path.exist? oh1 "Installing Cask #{Formatter.identifier(@cask)}" - opoo "macOS's Gatekeeper has been disabled for this Cask" unless quarantine? + # GitHub Actions globally disables Gatekeeper. + unless quarantine? + opoo_outside_github_actions "--no-quarantine bypasses macOS’s Gatekeeper, reducing system security. " \ + "Do not use this flag unless you understand the risks." + end stage - @cask.config = Config.global.merge(old_config) + @cask.config = @cask.default_config.merge(old_config) - install_artifacts + install_artifacts(predecessor:) - ::Utils::Analytics.report_event("cask_install", @cask.token) unless @cask.tap&.private? + tab = Tab.create(@cask) + tab.installed_on_request = installed_on_request? + tab.write + + if (tap = @cask.tap) && tap.should_report_analytics? + ::Utils::Analytics.report_package_event(:cask_install, package_name: @cask.token, tap_name: tap.name, +on_request: true) + end purge_backed_up_versioned_files puts summary + end_time = Time.now + Homebrew.messages.package_installed(@cask.token, end_time - start_time) rescue restore_backup raise end + sig { void } + def check_deprecate_disable + deprecate_disable_type = DeprecateDisable.type(@cask) + return if deprecate_disable_type.nil? + + message = DeprecateDisable.message(@cask).to_s + message_full = "#{@cask.token} has been #{message}" + + case deprecate_disable_type + when :deprecated + opoo message_full + when :disabled + GitHub::Actions.puts_annotation_if_env_set!(:error, message) + raise CaskCannotBeInstalledError.new(@cask, message) + end + end + + sig { void } def check_conflicts return unless @cask.conflicts_with @cask.conflicts_with[:cask].each do |conflicting_cask| + if (conflicting_cask_tap_with_token = Tap.with_cask_token(conflicting_cask)) + conflicting_cask_tap, = conflicting_cask_tap_with_token + next unless conflicting_cask_tap.installed? + end + conflicting_cask = CaskLoader.load(conflicting_cask) raise CaskConflictError.new(@cask, conflicting_cask) if conflicting_cask.installed? rescue CaskUnavailableError @@ -121,115 +223,152 @@ def check_conflicts end end - def reinstall - odebug "Cask::Installer#reinstall" - @reinstall = true - install - end - + sig { void } def uninstall_existing_cask return unless @cask.installed? - # use the same cask file that was used for installation, if possible - installed_caskfile = @cask.installed_caskfile - installed_cask = installed_caskfile.exist? ? CaskLoader.load(installed_caskfile) : @cask - # Always force uninstallation, ignore method parameter - Installer.new(installed_cask, binaries: binaries?, verbose: verbose?, force: true, upgrade: upgrade?).uninstall + cask_installer = Installer.new(@cask, verbose: verbose?, force: true, upgrade: upgrade?, reinstall: true) + zap? ? cask_installer.zap : cask_installer.uninstall(successor: @cask) end + sig { returns(String) } def summary s = +"" - s << "#{Emoji.install_badge} " if Emoji.enabled? + s << "#{Homebrew::EnvConfig.install_badge} " unless Homebrew::EnvConfig.no_emoji? s << "#{@cask} was successfully #{upgrade? ? "upgraded" : "installed"}!" s.freeze end - def download - return @downloaded_path if @downloaded_path - - odebug "Downloading" - @downloaded_path = Download.new(@cask, force: false, quarantine: quarantine?).perform - odebug "Downloaded to -> #{@downloaded_path}" - @downloaded_path + sig { returns(Download) } + def downloader + @downloader ||= T.let( + (if @cask.loaded_from_internal_api? && !@cask.caskfile_only? + Homebrew::API::CaskDownload.download( + token: @cask.token, + cask_struct: Homebrew::API::Internal.cask_struct(@cask.token), + quarantine: quarantine?, + require_sha: require_sha? && !force?, + ) + end) || Download.new(@cask, quarantine: quarantine?, require_sha: require_sha? && !force?), + T.nilable(Download), + ) end - def verify_has_sha - odebug "Checking cask has checksum" - return unless @cask.sha256 == :no_check - - raise CaskNoShasumError, @cask.token + sig { params(quiet: T.nilable(T::Boolean), timeout: T.nilable(T.any(Integer, Float))).returns(Pathname) } + def download(quiet: nil, timeout: nil) + # Store cask download path in cask to prevent multiple downloads in a row when checking if it's outdated + @cask.download ||= downloader.fetch(quiet:, verify_download_integrity: @verify_download_integrity, + timeout:) end - def verify - Verify.all(@cask, @downloaded_path) + sig { returns(UnpackStrategy) } + def primary_container + @primary_container ||= T.let( + begin + downloaded_path = download(quiet: true) + UnpackStrategy.detect(downloaded_path, type: @cask.container&.type, merge_xattrs: true) + end, + T.nilable(UnpackStrategy), + ) end - def primary_container - @primary_container ||= begin - download - UnpackStrategy.detect(@downloaded_path, type: @cask.container&.type, merge_xattrs: true) - end + sig { returns(ArtifactSet) } + def artifacts + @default_uninstall_artifacts || @cask.artifacts end - def extract_primary_container + sig { params(to: Pathname).void } + def extract_primary_container(to: @cask.staged_path) odebug "Extracting primary container" - odebug "Using container class #{primary_container.class} for #{@downloaded_path}" + container = primary_container + raise "unexpected nil primary_container" unless container + + odebug "Using container class #{container.class} for #{container.path}" - basename = CGI.unescape(File.basename(@cask.url.path)) + basename = downloader.basename - if nested_container = @cask.container&.nested - Dir.mktmpdir do |tmpdir| + if (nested_container = @cask.container&.nested) + Dir.mktmpdir("cask-installer", HOMEBREW_TEMP) do |tmpdir| tmpdir = Pathname(tmpdir) - primary_container.extract(to: tmpdir, basename: basename, verbose: verbose?) + container.extract(to: tmpdir, basename:, verbose: verbose?) FileUtils.chmod_R "+rw", tmpdir/nested_container, force: true, verbose: verbose? UnpackStrategy.detect(tmpdir/nested_container, merge_xattrs: true) - .extract_nestedly(to: @cask.staged_path, verbose: verbose?) + .extract_nestedly(to:, verbose: verbose?) end else - primary_container.extract_nestedly(to: @cask.staged_path, basename: basename, verbose: verbose?) + container.extract_nestedly(to:, basename:, verbose: verbose?) end return unless quarantine? return unless Quarantine.available? - Quarantine.propagate(from: @downloaded_path, to: @cask.staged_path) + Quarantine.propagate(from: container.path, to:) end - def install_artifacts + 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 = [] odebug "Installing artifacts" - artifacts = @cask.artifacts - odebug "#{artifacts.length} artifact/s defined", artifacts artifacts.each do |artifact| next unless artifact.respond_to?(:install_phase) odebug "Installing artifact of class #{artifact.class}" - if artifact.is_a?(Artifact::Binary) - next unless binaries? - end + next if artifact.is_a?(Artifact::Binary) && !binaries? + + artifact = T.cast( + artifact, + T.any( + Artifact::AbstractFlightBlock, + Artifact::GeneratedCompletion, + Artifact::Installer, + Artifact::KeyboardLayout, + Artifact::Mdimporter, + Artifact::Moved, + Artifact::PostflightSteps, + Artifact::PreflightSteps, + Artifact::Pkg, + Artifact::Qlplugin, + Artifact::Symlinked, + ), + ) - artifact.install_phase(command: @command, verbose: verbose?, force: force?) + artifact.install_phase( + command: @command, verbose: verbose?, adopt: adopt?, auto_updates: @cask.auto_updates, + force: force?, predecessor: + ) already_installed_artifacts.unshift(artifact) end save_config_file + save_download_sha if @cask.version.latest? rescue => e begin - already_installed_artifacts.each do |artifact| - next unless artifact.respond_to?(:uninstall_phase) - - odebug "Reverting installation of artifact of class #{artifact.class}" - artifact.uninstall_phase(command: @command, verbose: verbose?, force: force?) - end + already_installed_artifacts&.each do |artifact| + if artifact.respond_to?(:uninstall_phase) + odebug "Reverting installation of artifact of class #{artifact.class}" + artifact.uninstall_phase(command: @command, verbose: verbose?, force: force?) + end - already_installed_artifacts.each do |artifact| next unless artifact.respond_to?(:post_uninstall_phase) odebug "Reverting installation of artifact of class #{artifact.class}" @@ -241,28 +380,33 @@ def install_artifacts end end - # TODO: move dependencies to a separate class, - # dependencies should also apply for `brew cask stage`, - # override dependencies with `--force` or perhaps `--force-deps` - def satisfy_dependencies - return unless @cask.depends_on + sig { void } + def check_requirements + check_stanza_os_requirements + check_macos_requirements + check_arch_requirements + end + + sig { void } + def check_stanza_os_requirements + return if @cask.supports_macos? - macos_dependencies - arch_dependencies - x11_dependencies - cask_and_formula_dependencies + raise CaskError, "#{@cask}: This cask requires Linux." end - def macos_dependencies - return unless @cask.depends_on.macos - return if @cask.depends_on.macos.satisfied? + sig { void } + def check_macos_requirements + macos_requirement = [@cask.depends_on.macos, @cask.depends_on.maximum_macos].compact.find { !it.satisfied? } + return unless macos_requirement - raise CaskError, @cask.depends_on.macos.message(type: :cask) + raise CaskError, "#{@cask}: #{macos_requirement.message(type: :cask)}" end - def arch_dependencies + sig { void } + def check_arch_requirements return if @cask.depends_on.arch.nil? + @current_arch = T.let(@current_arch, T.nilable(T::Hash[Symbol, T.untyped])) @current_arch ||= { type: Hardware::CPU.type, bits: Hardware::CPU.bits } return if @cask.depends_on.arch.any? do |arch| arch[:type] == @current_arch[:type] && @@ -270,52 +414,23 @@ def arch_dependencies end raise CaskError, - "Cask #{@cask} depends on hardware architecture being one of " \ - "[#{@cask.depends_on.arch.map(&:to_s).join(", ")}], " \ - "but you are running #{@current_arch}" - end - - def x11_dependencies - return unless @cask.depends_on.x11 - raise CaskX11DependencyError, @cask.token unless MacOS::X11.installed? + "#{@cask}: This cask depends on hardware architecture being one of " \ + "[#{@cask.depends_on.arch.join(", ")}], " \ + "but you are running #{@current_arch}." end - def graph_dependencies(cask_or_formula, acc = TopologicalHash.new) - return acc if acc.key?(cask_or_formula) - - if cask_or_formula.is_a?(Cask) - formula_deps = cask_or_formula.depends_on.formula.map { |f| Formula[f] } - cask_deps = cask_or_formula.depends_on.cask.map(&CaskLoader.public_method(:load)) - else - formula_deps = cask_or_formula.deps.reject(&:build?).map(&:to_formula) - cask_deps = cask_or_formula.requirements.map(&:cask).compact.map(&CaskLoader.public_method(:load)) - end - - acc[cask_or_formula] ||= [] - acc[cask_or_formula] += formula_deps - acc[cask_or_formula] += cask_deps - - formula_deps.each do |f| - graph_dependencies(f, acc) - end - - cask_deps.each do |c| - graph_dependencies(c, acc) - end - - acc - end - - def collect_cask_and_formula_dependencies + sig { returns(T::Array[T.any(Formula, ::Cask::Cask)]) } + def cask_and_formula_dependencies return @cask_and_formula_dependencies if @cask_and_formula_dependencies - graph = graph_dependencies(@cask) + graph = ::Utils::TopologicalHash.graph_package_dependencies(@cask) - raise CaskSelfReferencingDependencyError, cask.token if graph[@cask].include?(@cask) + raise CaskSelfReferencingDependencyError, @cask.token if graph.fetch(@cask).include?(@cask) - primary_container.dependencies.each do |dep| - graph_dependencies(dep, graph) - end + pc = primary_container + raise "unexpected nil primary_container" unless pc + + ::Utils::TopologicalHash.graph_package_dependencies(pc.dependencies, graph) begin @cask_and_formula_dependencies = graph.tsort - [@cask] @@ -326,113 +441,201 @@ def collect_cask_and_formula_dependencies end end + sig { returns(T::Array[T.any(Formula, ::Cask::Cask)]) } def missing_cask_and_formula_dependencies - collect_cask_and_formula_dependencies.reject do |cask_or_formula| - (cask_or_formula.respond_to?(:installed?) && cask_or_formula.installed?) || - (cask_or_formula.respond_to?(:any_version_installed?) && cask_or_formula.any_version_installed?) + cask_and_formula_dependencies.reject do |cask_or_formula| + case cask_or_formula + when Formula + cask_or_formula.any_version_installed? && cask_or_formula.optlinked? + when Cask + cask_or_formula.installed? + end end end - def cask_and_formula_dependencies - return if installed_as_dependency? + sig { void } + def satisfy_cask_and_formula_dependencies + return unless installed_on_request? - formulae_and_casks = collect_cask_and_formula_dependencies + formulae_and_casks = cask_and_formula_dependencies return if formulae_and_casks.empty? missing_formulae_and_casks = missing_cask_and_formula_dependencies if missing_formulae_and_casks.empty? - puts "All Formula dependencies satisfied." + puts "All dependencies satisfied." return end - ohai "Installing dependencies: #{missing_formulae_and_casks.map(&:to_s).join(", ")}" + ohai "Installing dependencies: #{missing_formulae_and_casks.join(", ")}" + cask_installers = T.let([], T::Array[Installer]) + formula_installers = T.let([], T::Array[FormulaInstaller]) + missing_formulae_and_casks.each do |cask_or_formula| if cask_or_formula.is_a?(Cask) if skip_cask_deps? - opoo "`--skip-cask-deps` is set; skipping installation of #{@cask}." + opoo "`--skip-cask-deps` is set; skipping installation of #{cask_or_formula}." next end - Installer.new( + cask_installers << Installer.new( cask_or_formula, - binaries: binaries?, - verbose: verbose?, - installed_as_dependency: true, - force: false, - ).install + adopt: adopt?, + binaries: binaries?, + force: false, + installed_on_request: false, + quarantine: quarantine?, + quiet: quiet?, + require_sha: require_sha?, + verbose: verbose?, + ) else - FormulaInstaller.new(cask_or_formula).yield_self do |fi| - fi.installed_as_dependency = true - fi.installed_on_request = false - fi.show_header = true - fi.verbose = verbose? - fi.prelude - fi.install - fi.finish - end + formula_installers << FormulaInstaller.new( + cask_or_formula, + **{ + show_header: true, + installed_on_request: false, + verbose: verbose?, + }.compact, + ) end end + + cask_installers.each(&:install) + return if formula_installers.blank? + + Homebrew::Install.perform_preinstall_checks_once + valid_formula_installers = Homebrew::Install.fetch_formulae(formula_installers) + valid_formula_installers.each do |formula_installer| + formula_installer.install + formula_installer.finish + end end + sig { returns(T.nilable(String)) } def caveats self.class.caveats(@cask) end + sig { returns(Pathname) } + def metadata_subdir + @metadata_subdir ||= T.let( + begin + msd = @cask.metadata_subdir("Casks", timestamp: :now, create: true) + raise "unexpected nil metadata_subdir" unless msd + + msd + end, + T.nilable(Pathname), + ) + end + + sig { void } def save_caskfile old_savedir = @cask.metadata_timestamped_path - return unless @cask.sourcefile_path + return if @cask.source.blank? + + if @cask.uninstall_flight_blocks? + (metadata_subdir/"#{@cask.token}.rb").write @cask.source.to_s + else + (metadata_subdir/"#{@cask.token}.json").write JSON.pretty_generate(@cask.to_installed_json_hash) + end - savedir = @cask.metadata_subdir("Casks", timestamp: :now, create: true) - FileUtils.copy @cask.sourcefile_path, savedir - old_savedir&.rmtree + FileUtils.rm_r(old_savedir) if old_savedir end + sig { void } def save_config_file @cask.config_path.atomic_write(@cask.config.to_json) end - def uninstall + sig { void } + def save_download_sha + return unless @cask.checksumable? + + @cask.download_sha_path.atomic_write(@cask.new_download_sha) + end + + sig { params(successor: T.nilable(Cask)).void } + def uninstall(successor: nil) + load_installed_caskfile! oh1 "Uninstalling Cask #{Formatter.identifier(@cask)}" - uninstall_artifacts(clear: true) - remove_config_file unless reinstall? || upgrade? + uninstall_artifacts(clear: true, successor:) + if !reinstall? && !upgrade? + remove_tabfile + remove_download_sha + remove_config_file + end purge_versioned_files purge_caskroom_path if force? end + sig { void } + def remove_tabfile + tabfile = @cask.tab.tabfile + FileUtils.rm_f tabfile if tabfile + @cask.config_path.parent.rmdir_if_possible + end + + sig { void } def remove_config_file FileUtils.rm_f @cask.config_path @cask.config_path.parent.rmdir_if_possible end - def start_upgrade - uninstall_artifacts + sig { void } + def remove_download_sha + FileUtils.rm_f @cask.download_sha_path + @cask.download_sha_path.parent.rmdir_if_possible + end + + sig { params(successor: T.nilable(Cask), quit: T::Boolean).void } + def start_upgrade(successor:, quit: true) + uninstall_artifacts(successor:, quit:) backup end + sig { void } def backup - @cask.staged_path.rename backup_path - @cask.metadata_versioned_path.rename backup_metadata_path + bp = backup_path + raise "unexpected nil backup_path" unless bp + + bmp = backup_metadata_path + raise "unexpected nil backup_metadata_path" unless bmp + + # The staged files may already be gone (e.g. an out-of-band cask update); + # skip the rename rather than raising and aborting the upgrade. + @cask.staged_path.rename bp.to_s if @cask.staged_path.exist? + @cask.metadata_versioned_path.rename bmp.to_s if @cask.metadata_versioned_path.exist? end + sig { void } def restore_backup - return unless backup_path.directory? && backup_metadata_path.directory? + bp = backup_path + return unless bp - Pathname.new(@cask.staged_path).rmtree if @cask.staged_path.exist? - Pathname.new(@cask.metadata_versioned_path).rmtree if @cask.metadata_versioned_path.exist? + bmp = backup_metadata_path + return unless bmp - backup_path.rename @cask.staged_path - backup_metadata_path.rename @cask.metadata_versioned_path + return if !bp.directory? || !bmp.directory? + + FileUtils.rm_r(@cask.staged_path) if @cask.staged_path.exist? + FileUtils.rm_r(@cask.metadata_versioned_path) if @cask.metadata_versioned_path.exist? + + bp.rename @cask.staged_path.to_s + bmp.rename @cask.metadata_versioned_path.to_s end - def revert_upgrade + sig { params(predecessor: Cask).void } + def revert_upgrade(predecessor:) opoo "Reverting upgrade for Cask #{@cask}" restore_backup - install_artifacts + install_artifacts(predecessor:) end + sig { void } def finalize_upgrade ohai "Purging files for version #{@cask.version} of Cask #{@cask}" @@ -441,33 +644,64 @@ def finalize_upgrade puts summary end - def uninstall_artifacts(clear: false) + sig { params(clear: T::Boolean, successor: T.nilable(Cask), quit: T::Boolean).void } + def uninstall_artifacts(clear: false, successor: nil, quit: true) odebug "Uninstalling artifacts" - artifacts = @cask.artifacts - - odebug "#{artifacts.length} artifact/s defined", artifacts + odebug "#{::Utils.pluralize("artifact", artifacts.length, include_count: true)} defined", artifacts artifacts.each do |artifact| - next unless artifact.respond_to?(:uninstall_phase) - - odebug "Uninstalling artifact of class #{artifact.class}" - artifact.uninstall_phase(command: @command, verbose: verbose?, skip: clear, force: force?, upgrade: upgrade?) - end + if artifact.respond_to?(:uninstall_phase) + artifact = T.cast( + artifact, + T.any( + Artifact::AbstractFlightBlock, + Artifact::GeneratedCompletion, + Artifact::KeyboardLayout, + Artifact::Moved, + Artifact::PostflightSteps, + Artifact::PreflightSteps, + Artifact::Qlplugin, + Artifact::Symlinked, + Artifact::Uninstall, + Artifact::UninstallPostflightSteps, + Artifact::UninstallPreflightSteps, + ), + ) + + odebug "Uninstalling artifact of class #{artifact.class}" + uninstall_options = { + command: @command, + verbose: verbose?, + skip: clear, + force: force?, + successor:, + upgrade: upgrade?, + reinstall: reinstall?, + } + uninstall_options[:quit] = quit if artifact.is_a?(Artifact::Uninstall) + artifact.uninstall_phase(**uninstall_options) + end - artifacts.each do |artifact| next unless artifact.respond_to?(:post_uninstall_phase) + artifact = T.cast(artifact, Artifact::Uninstall) + odebug "Post-uninstalling artifact of class #{artifact.class}" artifact.post_uninstall_phase( - command: @command, verbose: verbose?, skip: clear, force: force?, upgrade: upgrade?, + command: @command, + verbose: verbose?, + skip: clear, + force: force?, + successor:, ) end end + sig { void } def zap - ohai %Q(Implied "brew cask uninstall #{@cask}") + load_installed_caskfile! uninstall_artifacts - if (zap_stanzas = @cask.artifacts.select { |a| a.is_a?(Artifact::Zap) }).empty? + if (zap_stanzas = @cask.artifacts.grep(Artifact::Zap)).empty? opoo "No zap stanza present for Cask '#{@cask}'" else ohai "Dispatching zap stanza" @@ -479,35 +713,41 @@ def zap purge_caskroom_path end + sig { returns(T.nilable(Pathname)) } def backup_path return if @cask.staged_path.nil? Pathname("#{@cask.staged_path}.upgrading") end + sig { returns(T.nilable(Pathname)) } def backup_metadata_path return if @cask.metadata_versioned_path.nil? Pathname("#{@cask.metadata_versioned_path}.upgrading") end + sig { params(path: Pathname).void } def gain_permissions_remove(path) Utils.gain_permissions_remove(path, command: @command) end + sig { void } def purge_backed_up_versioned_files # versioned staged distribution - gain_permissions_remove(backup_path) if backup_path&.exist? + gain_permissions_remove(T.must(backup_path)) if backup_path&.exist? # Homebrew Cask metadata - return unless backup_metadata_path.directory? + bmp = backup_metadata_path + return unless bmp&.directory? - backup_metadata_path.children.each do |subdir| + bmp.children.each do |subdir| gain_permissions_remove(subdir) end - backup_metadata_path.rmdir_if_possible + bmp.rmdir_if_possible end + sig { void } def purge_versioned_files ohai "Purging files for version #{@cask.version} of Cask #{@cask}" @@ -522,15 +762,299 @@ def purge_versioned_files @cask.metadata_versioned_path.rmdir_if_possible end - @cask.metadata_master_container_path.rmdir_if_possible unless upgrade? + @cask.metadata_main_container_path.rmdir_if_possible unless upgrade? # toplevel staged distribution @cask.caskroom_path.rmdir_if_possible unless upgrade? + + # Remove symlinks for renamed casks if they are now broken. + @cask.old_tokens.each do |old_token| + old_caskroom_path = Caskroom.path/old_token + FileUtils.rm old_caskroom_path if old_caskroom_path.symlink? && !old_caskroom_path.exist? + end end + sig { void } def purge_caskroom_path odebug "Purging all staged versions of Cask #{@cask}" gain_permissions_remove(@cask.caskroom_path) end + + sig { params(cask_only: T::Boolean).void } + def forbidden_tap_check(cask_only: false) + return if Tap.allowed_taps.blank? && Tap.forbidden_taps.blank? + + owner = Homebrew::EnvConfig.forbidden_owner + owner_contact = if (contact = Homebrew::EnvConfig.forbidden_owner_contact.presence) + "\n#{contact}" + end + + # Check the cask itself before its dependencies, since dependency resolution + # for casks triggers a download via `primary_container`. + cask_tap = @cask.tap + if cask_tap.present? && (!cask_tap.allowed_by_env? || cask_tap.forbidden_by_env?) + cask_error_message = "The installation of #{@cask.full_name} has the tap #{cask_tap}\n" \ + "but #{owner} " + cask_error_message << "has not allowed this tap in `$HOMEBREW_ALLOWED_TAPS`" unless cask_tap.allowed_by_env? + cask_error_message << " and\n" if !cask_tap.allowed_by_env? && cask_tap.forbidden_by_env? + cask_error_message << "has forbidden this tap in `$HOMEBREW_FORBIDDEN_TAPS`" if cask_tap.forbidden_by_env? + cask_error_message << ".#{owner_contact}" + + raise CaskCannotBeInstalledError.new(@cask, cask_error_message) + end + + return if cask_only + return if skip_cask_deps? + + cask_and_formula_dependencies.each do |cask_or_formula| + dep_tap = cask_or_formula.tap + next if dep_tap.blank? || (dep_tap.allowed_by_env? && !dep_tap.forbidden_by_env?) + + dep_full_name = cask_or_formula.full_name + error_message = "The installation of #{@cask} has a dependency #{dep_full_name}\n" \ + "from the #{dep_tap} tap but #{owner} " + error_message << "has not allowed this tap in `$HOMEBREW_ALLOWED_TAPS`" unless dep_tap.allowed_by_env? + error_message << " and\n" if !dep_tap.allowed_by_env? && dep_tap.forbidden_by_env? + error_message << "has forbidden this tap in `$HOMEBREW_FORBIDDEN_TAPS`" if dep_tap.forbidden_by_env? + error_message << ".#{owner_contact}" + + raise CaskCannotBeInstalledError.new(@cask, error_message) + end + end + + sig { params(cask_only: T::Boolean).void } + def forbidden_cask_and_formula_check(cask_only: false) + forbid_casks = Homebrew::EnvConfig.forbid_casks? + forbidden_formulae = Set.new(Homebrew::EnvConfig.forbidden_formulae.to_s.split) + forbidden_casks = Set.new(Homebrew::EnvConfig.forbidden_casks.to_s.split) + return if !forbid_casks && forbidden_formulae.blank? && forbidden_casks.blank? + + owner = Homebrew::EnvConfig.forbidden_owner + owner_contact = if (contact = Homebrew::EnvConfig.forbidden_owner_contact.presence) + "\n#{contact}" + end + + cask_variable = if forbid_casks + "HOMEBREW_FORBID_CASKS" + elsif forbidden_casks.include?(@cask.token) || forbidden_casks.include?(@cask.full_name) + "HOMEBREW_FORBIDDEN_CASKS" + end + + if cask_variable + raise CaskCannotBeInstalledError.new(@cask, <<~EOS + forbidden for installation by #{owner} in `#{cask_variable}`.#{owner_contact} + EOS + ) + end + + return if cask_only + return if skip_cask_deps? + + cask_and_formula_dependencies.each do |dep_cask_or_formula| + dep_name, dep_type, variable = if dep_cask_or_formula.is_a?(Cask) && forbidden_casks.present? + dep_cask = dep_cask_or_formula + env_variable = "HOMEBREW_FORBIDDEN_CASKS" + dep_cask_name = if forbidden_casks.include?(dep_cask.token) + dep_cask.token + elsif forbidden_casks.include?(dep_cask.full_name) + dep_cask.full_name + end + [dep_cask_name, "cask", env_variable] + elsif dep_cask_or_formula.is_a?(Formula) && forbidden_formulae.present? + dep_formula = dep_cask_or_formula + formula_name = if forbidden_formulae.include?(dep_formula.name) + dep_formula.name + elsif dep_formula.tap.present? && + forbidden_formulae.include?(dep_formula.full_name) + dep_formula.full_name + end + [formula_name, "formula", "HOMEBREW_FORBIDDEN_FORMULAE"] + end + next if dep_name.blank? + + raise CaskCannotBeInstalledError.new(@cask, <<~EOS + has a dependency #{dep_name} but the + #{dep_name} #{dep_type} was forbidden for installation by #{owner} in `#{variable}`.#{owner_contact} + EOS + ) + end + end + + sig { void } + def forbidden_cask_artifacts_check + forbidden_artifacts = Set.new(Homebrew::EnvConfig.forbidden_cask_artifacts.to_s.split) + return if forbidden_artifacts.blank? + + owner = Homebrew::EnvConfig.forbidden_owner + owner_contact = if (contact = Homebrew::EnvConfig.forbidden_owner_contact.presence) + "\n#{contact}" + end + + artifacts.each do |artifact| + # Get the artifact class name (e.g., "Pkg", "Installer", "App") + artifact_name = artifact.class.name + next if artifact_name.nil? + + artifact_type = artifact_name.split("::").last&.downcase + next if artifact_type.nil? + + next unless forbidden_artifacts.include?(artifact_type) + + raise CaskCannotBeInstalledError.new(@cask, <<~EOS + contains a '#{artifact_type}' artifact, which is forbidden for installation by #{owner} in `HOMEBREW_FORBIDDEN_CASK_ARTIFACTS`.#{owner_contact} + EOS + ) + end + end + + sig { void } + def prelude + return if @ran_prelude + + check_prelude_requirements unless @ran_prelude_fetch + load_cask_from_source_api! if cask_from_source_api? + forbidden_tap_check + forbidden_cask_and_formula_check + forbidden_cask_artifacts_check + + @ran_prelude = true + end + + sig { returns(T::Boolean) } + def source_download_requires_pre_fetch? + cask_from_source_api? && @cask.languages.any? + end + + sig { params(download_queue: Homebrew::DownloadQueue).void } + def prelude_fetch(download_queue: @download_queue) + return unless (download = prelude_fetch_download) + + download_queue.enqueue(download) + end + + sig { returns(T.nilable(Homebrew::API::SourceDownload)) } + def prelude_fetch_download + return if @ran_prelude_fetch + + check_prelude_requirements + @ran_prelude_fetch = true + return unless source_download_requires_pre_fetch? + + if source_download.downloaded? + source_download.verify_download_integrity(source_download.cached_download) + source_download.downloader.create_symlink_to_cached_download(source_download.cached_download) + return + end + + source_download + end + + sig { void } + def enqueue_downloads + download_queue = @download_queue + prelude_fetch(download_queue:) unless @ran_prelude_fetch + + # FIXME: We need to load Cask source before enqueuing to support + # language-specific URLs, but this will block the main process. + if source_download_requires_pre_fetch? + load_cask_from_source_api! + elsif cask_from_source_api? + Homebrew::API::Cask.source_download(@cask, download_queue:, enqueue: true) + end + + forbidden_tap_check + forbidden_cask_and_formula_check + forbidden_cask_artifacts_check + + download_queue.enqueue(downloader) + end + + private + + sig { void } + def check_prelude_requirements + check_deprecate_disable + check_conflicts + check_requirements + # Run the cask-self forbidden checks before loading the caskfile from the + # Source API so a forbidden cask never triggers a network fetch. + forbidden_tap_check(cask_only: true) + forbidden_cask_and_formula_check(cask_only: true) + end + + sig { returns(Homebrew::API::SourceDownload) } + def source_download + @source_download ||= Homebrew::API::Cask.source_download_for(@cask) + end + + # load the same cask file that was used for installation, if possible + sig { void } + def load_installed_caskfile! + Migrator.migrate_if_needed(@cask) + + installed_caskfile = @cask.installed_caskfile + + if installed_caskfile&.exist? + tab = @cask.tab + tap = tab.tap + if installed_caskfile.extname == ".rb" && + Homebrew::EnvConfig.require_tap_trust? && + tap && + !Homebrew::Trust.trusted?(:cask, "#{tap.name}/#{@cask.token}") + opoo "Skipping loading untrusted Cask #{tap.name}/#{@cask.token}; uninstalling recorded artifacts only." + + dsl = DSL.new(@cask) + default_uninstall_artifact_keys = DSL::ACTIVATABLE_ARTIFACT_CLASSES.filter_map do |klass| + next if [Artifact::Uninstall, Artifact::Zap].include?(klass) + next if !klass.method_defined?(:uninstall_phase) && !klass.method_defined?(:post_uninstall_phase) + + klass.dsl_key + end.to_set + Array(tab.uninstall_artifacts).each do |artifact_entry| + next unless artifact_entry.is_a?(Hash) + + artifact_entry.each do |raw_key, raw_args| + dsl_key = raw_key.to_sym + next unless default_uninstall_artifact_keys.include?(dsl_key) + + args = Array(raw_args) + if args.last.is_a?(Hash) + dsl.public_send( + dsl_key, + *args[...-1], + **T.cast(args.last, T::Hash[T.any(Symbol, String), T.anything]).transform_keys(&:to_sym), + ) + else + dsl.public_send(dsl_key, *args) + end + end + end + @default_uninstall_artifacts = dsl.artifacts + return + end + + begin + @cask = CaskLoader.load_from_installed_caskfile(installed_caskfile) + return + rescue CaskInvalidError, CaskUnavailableError, MethodDeprecatedError + # could be caused by trying to load outdated or deleted caskfile + end + end + + load_cask_from_source_api! if cask_from_source_api? + # otherwise we default to the current cask + end + + sig { void } + def load_cask_from_source_api! + @cask = Homebrew::API::Cask.source_download_cask(@cask) + end + + sig { returns(T::Boolean) } + def cask_from_source_api? + @cask.loaded_from_api? && @cask.caskfile_only? + end end end + +require "extend/os/cask/installer" diff --git a/Library/Homebrew/cask/list.rb b/Library/Homebrew/cask/list.rb new file mode 100644 index 0000000000000..c9d43f5daedd7 --- /dev/null +++ b/Library/Homebrew/cask/list.rb @@ -0,0 +1,67 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/relocated" +require "utils/output" + +module Cask + module List + extend ::Utils::Output::Mixin + + TAP_AND_NAME_COMPARISON = T.let( + proc do |a, b| + if a.include?("/") && b.exclude?("/") + 1 + elsif a.exclude?("/") && b.include?("/") + -1 + else + a <=> b + end + end.freeze, + T.proc.params(a: String, b: String).returns(Integer), + ) + + sig { params(casks: Cask, one: T::Boolean, full_name: T::Boolean, versions: T::Boolean).void } + def self.list_casks(*casks, one: false, full_name: false, versions: false) + output = if casks.any? + casks.each do |cask| + raise CaskNotInstalledError, cask unless cask.installed? + end + else + Caskroom.casks + end + + if one + puts output.map(&:to_s) + elsif full_name + puts output.map(&:full_name).sort(&TAP_AND_NAME_COMPARISON) + elsif versions + puts output.map { format_versioned(it) } + elsif !output.empty? && casks.any? + output.map { list_artifacts(it) } + elsif !output.empty? + puts Formatter.columns(output.map(&:to_s)) + end + end + + sig { params(cask: Cask).void } + def self.list_artifacts(cask) + cask.artifacts.group_by(&:class).sort_by { |klass, _| klass.english_name }.each do |klass, artifacts| + next if [Artifact::Uninstall, Artifact::Zap].include? klass + + ohai klass.english_name + artifacts.each do |artifact| + puts artifact.summarize_installed if artifact.respond_to?(:summarize_installed) + next if artifact.respond_to?(:summarize_installed) + + puts artifact + end + end + end + + sig { params(cask: Cask).returns(String) } + def self.format_versioned(cask) + "#{cask}#{cask.installed_version&.prepend(" ")}" + end + end +end diff --git a/Library/Homebrew/cask/macos.rb b/Library/Homebrew/cask/macos.rb index 49e34caed87f4..e9274e12b9aa6 100644 --- a/Library/Homebrew/cask/macos.rb +++ b/Library/Homebrew/cask/macos.rb @@ -1,12 +1,9 @@ +# typed: strict # frozen_string_literal: true -require "os/mac/version" - module OS module Mac - module_function - - SYSTEM_DIRS = [ + SYSTEM_DIRS = T.let([ "/", "/Applications", "/Applications/Utilities", @@ -235,13 +232,13 @@ module Mac "/var/spool/mail", "/var/tmp", ] - .map(&method(:Pathname)) - .to_set - .freeze + .to_set { |path| ::Pathname.new(path) } + .freeze, T::Set[::Pathname]) + private_constant :SYSTEM_DIRS # TODO: There should be a way to specify a containing # directory under which nothing can be deleted. - UNDELETABLE_PATHS = [ + UNDELETABLE_PATHS = T.let([ "~/", "~/Applications", "~/Applications/.localized", @@ -376,17 +373,19 @@ module Mac "~/Library/Widgets", "~/Library/Workflows", ] - .map { |path| Pathname(path.sub(%r{^~(?=(/|$))}, Dir.home)).expand_path } - .to_set + .to_set { |path| ::Pathname.new(path.sub(%r{^~(?=(/|$))}, Dir.home)).expand_path } .union(SYSTEM_DIRS) - .freeze + .freeze, T::Set[::Pathname]) + private_constant :UNDELETABLE_PATHS - def system_dir?(dir) - SYSTEM_DIRS.include?(Pathname.new(dir).expand_path) + sig { params(dir: T.any(::Pathname, String)).returns(T::Boolean) } + def self.system_dir?(dir) + SYSTEM_DIRS.include?(::Pathname.new(dir).expand_path) end - def undeletable?(path) - UNDELETABLE_PATHS.include?(Pathname.new(path).expand_path) + sig { params(path: T.any(::Pathname, String)).returns(T::Boolean) } + def self.undeletable?(path) + UNDELETABLE_PATHS.include?(::Pathname.new(path).expand_path) end end end diff --git a/Library/Homebrew/cask/metadata.rb b/Library/Homebrew/cask/metadata.rb index b15fbdda394aa..7486104faa7fd 100644 --- a/Library/Homebrew/cask/metadata.rb +++ b/Library/Homebrew/cask/metadata.rb @@ -1,55 +1,87 @@ +# typed: strict # frozen_string_literal: true +require "utils/output" + module Cask + # Helper module for reading and writing cask metadata. module Metadata + extend T::Helpers + include ::Utils::Output::Mixin + METADATA_SUBDIR = ".metadata" TIMESTAMP_FORMAT = "%Y%m%d%H%M%S.%L" - def metadata_master_container_path - @metadata_master_container_path ||= caskroom_path.join(METADATA_SUBDIR) + requires_ancestor { Cask } + + sig { params(caskroom_path: Pathname).returns(Pathname) } + def metadata_main_container_path(caskroom_path: self.caskroom_path) + caskroom_path.join(METADATA_SUBDIR) end - def metadata_versioned_path(version: self.version) + sig { params(version: T.nilable(T.any(DSL::Version, String)), caskroom_path: Pathname).returns(Pathname) } + def metadata_versioned_path(version: self.version, caskroom_path: self.caskroom_path) cask_version = (version || :unknown).to_s raise CaskError, "Cannot create metadata path with empty version." if cask_version.empty? - metadata_master_container_path.join(cask_version) + metadata_main_container_path(caskroom_path:).join(cask_version) end - def metadata_timestamped_path(version: self.version, timestamp: :latest, create: false) - raise CaskError, "Cannot create metadata path when timestamp is :latest." if create && timestamp == :latest - - path = if timestamp == :latest - Pathname.glob(metadata_versioned_path(version: version).join("*")).max - else - timestamp = new_timestamp if timestamp == :now - metadata_versioned_path(version: version).join(timestamp) + sig { + params( + version: T.nilable(T.any(DSL::Version, String)), + timestamp: T.any(Symbol, String), + create: T::Boolean, + caskroom_path: Pathname, + ).returns(T.nilable(Pathname)) + } + def metadata_timestamped_path(version: self.version, timestamp: :latest, create: false, + caskroom_path: self.caskroom_path) + case timestamp + when :latest + raise CaskError, "Cannot create metadata path when timestamp is :latest." if create + + return Pathname.glob(metadata_versioned_path(version:, caskroom_path:).join("*")).max + when :now + timestamp = new_timestamp + when Symbol + raise CaskError, "Invalid timestamp symbol :#{timestamp}. Valid symbols are :latest and :now." end + path = metadata_versioned_path(version:, caskroom_path:).join(timestamp) + if create && !path.directory? - odebug "Creating metadata directory #{path}." + odebug "Creating metadata directory: #{path}" path.mkpath end path end - def metadata_subdir(leaf, version: self.version, timestamp: :latest, create: false) + sig { + params( + leaf: String, + version: T.nilable(T.any(DSL::Version, String)), + timestamp: T.any(Symbol, String), + create: T::Boolean, + caskroom_path: Pathname, + ).returns(T.nilable(Pathname)) + } + def metadata_subdir(leaf, version: self.version, timestamp: :latest, create: false, + caskroom_path: self.caskroom_path) raise CaskError, "Cannot create metadata subdir when timestamp is :latest." if create && timestamp == :latest + raise CaskError, "Cannot create metadata subdir for empty leaf." if !leaf.respond_to?(:empty?) || leaf.empty? - unless leaf.respond_to?(:empty?) && !leaf.empty? - raise CaskError, "Cannot create metadata subdir for empty leaf." - end - - parent = metadata_timestamped_path(version: version, timestamp: timestamp, create: create) + parent = metadata_timestamped_path(version:, timestamp:, create:, + caskroom_path:) return if parent.nil? subdir = parent.join(leaf) if create && !subdir.directory? - odebug "Creating metadata subdirectory #{subdir}." + odebug "Creating metadata subdirectory: #{subdir}" subdir.mkpath end @@ -58,6 +90,7 @@ def metadata_subdir(leaf, version: self.version, timestamp: :latest, create: fal private + sig { params(time: Time).returns(String) } def new_timestamp(time = Time.now) time.utc.strftime(TIMESTAMP_FORMAT) end diff --git a/Library/Homebrew/cask/migrator.rb b/Library/Homebrew/cask/migrator.rb new file mode 100644 index 0000000000000..c00dfc59b9c4a --- /dev/null +++ b/Library/Homebrew/cask/migrator.rb @@ -0,0 +1,103 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/inreplace" +require "utils/output" + +module Cask + class Migrator + include ::Utils::Output::Mixin + + sig { returns(Cask) } + attr_reader :old_cask, :new_cask + + sig { params(old_cask: Cask, new_cask: Cask).void } + def initialize(old_cask, new_cask) + raise CaskNotInstalledError, new_cask unless new_cask.installed? + + @old_cask = old_cask + @new_cask = new_cask + end + + sig { params(new_cask: Cask, dry_run: T::Boolean).void } + def self.migrate_if_needed(new_cask, dry_run: false) + old_tokens = new_cask.old_tokens + return if old_tokens.empty? + + return unless (installed_caskfile = new_cask.installed_caskfile) + + installed_token = installed_caskfile.basename(installed_caskfile.extname).to_s + return if new_cask.token == installed_token + + old_cask = Cask.new(installed_token) + migrator = new(old_cask, new_cask) + migrator.migrate(dry_run:) + end + + sig { params(dry_run: T::Boolean).void } + def migrate(dry_run: false) + old_token = old_cask.token + new_token = new_cask.token + + old_caskroom_path = old_cask.caskroom_path + new_caskroom_path = new_cask.caskroom_path + + old_caskfile = old_cask.installed_caskfile + return if old_caskfile.nil? + + old_installed_caskfile = old_caskfile.relative_path_from(old_caskroom_path) + new_installed_caskfile = old_installed_caskfile.dirname/old_installed_caskfile.basename.sub( + old_token, + new_token, + ) + + if dry_run + oh1 "Would migrate cask #{Formatter.identifier(old_token)} to #{Formatter.identifier(new_token)}" + + puts "cp -r #{old_caskroom_path} #{new_caskroom_path}" + puts "mv #{new_caskroom_path}/#{old_installed_caskfile} #{new_caskroom_path}/#{new_installed_caskfile}" + puts "rm -r #{old_caskroom_path}" + puts "ln -s #{new_caskroom_path.basename} #{old_caskroom_path}" + if (old_pin_path = old_cask.pin_path).symlink? && (pinned_version = old_cask.pinned_version) + new_pin_path = new_cask.pin_path + puts "rm #{old_pin_path}" + puts "ln -s #{(new_caskroom_path/pinned_version).relative_path_from(new_pin_path.dirname)} #{new_pin_path}" + end + else + oh1 "Migrating cask #{Formatter.identifier(old_token)} to #{Formatter.identifier(new_token)}" + + begin + FileUtils.cp_r old_caskroom_path, new_caskroom_path + FileUtils.mv new_caskroom_path/old_installed_caskfile, new_caskroom_path/new_installed_caskfile + self.class.replace_caskfile_token(new_caskroom_path/new_installed_caskfile, old_token, new_token) + rescue => e + FileUtils.rm_rf new_caskroom_path + raise e + end + + FileUtils.rm_r old_caskroom_path + FileUtils.ln_s new_caskroom_path.basename, old_caskroom_path + if old_cask.pin_path.symlink? && (pinned_version = old_cask.pinned_version) + begin + new_cask.pin_path.make_relative_symlink(new_caskroom_path/pinned_version) + old_cask.unpin + rescue => e + opoo "Failed to migrate cask pin from #{old_token} to #{new_token}: #{e}" + end + end + end + end + + sig { params(path: Pathname, old_token: String, new_token: String).void } + def self.replace_caskfile_token(path, old_token, new_token) + case path.extname + when ".rb" + ::Utils::Inreplace.inreplace path, /\A\s*cask\s+"#{Regexp.escape(old_token)}"/, "cask #{new_token.inspect}" + when ".json" + json = JSON.parse(path.read) + json["token"] = new_token + path.atomic_write json.to_json + end + end + end +end diff --git a/Library/Homebrew/cask/pkg.rb b/Library/Homebrew/cask/pkg.rb index f6c4a2444fa94..2f1241fda90a0 100644 --- a/Library/Homebrew/cask/pkg.rb +++ b/Library/Homebrew/cask/pkg.rb @@ -1,32 +1,40 @@ +# typed: strict # frozen_string_literal: true require "cask/macos" +require "utils/output" module Cask + # Helper class for uninstalling `.pkg` installers. class Pkg + include ::Utils::Output::Mixin + + sig { params(regexp: String, command: T.class_of(SystemCommand)).returns(T::Array[Pkg]) } def self.all_matching(regexp, command) command.run("/usr/sbin/pkgutil", args: ["--pkgs=#{regexp}"]).stdout.split("\n").map do |package_id| new(package_id.chomp, command) end end + sig { returns(String) } attr_reader :package_id + sig { params(package_id: String, command: T.class_of(SystemCommand)).void } def initialize(package_id, command = SystemCommand) @package_id = package_id @command = command end + sig { void } def uninstall unless pkgutil_bom_files.empty? odebug "Deleting pkg files" @command.run!( "/usr/bin/xargs", - args: [ - "-0", "--", "/bin/rm", "--" - ], - input: pkgutil_bom_files.join("\0"), - sudo: true, + args: ["-0", "--", "/bin/rm", "--"], + input: pkgutil_bom_files.join("\0"), + sudo: true, + sudo_as_root: true, ) end @@ -34,116 +42,99 @@ def uninstall odebug "Deleting pkg symlinks and special files" @command.run!( "/usr/bin/xargs", - args: [ - "-0", "--", "/bin/rm", "--" - ], - input: pkgutil_bom_specials.join("\0"), - sudo: true, + args: ["-0", "--", "/bin/rm", "--"], + input: pkgutil_bom_specials.join("\0"), + sudo: true, + sudo_as_root: true, ) end unless pkgutil_bom_dirs.empty? odebug "Deleting pkg directories" - deepest_path_first(pkgutil_bom_dirs).each do |dir| - with_full_permissions(dir) do - clean_broken_symlinks(dir) - clean_ds_store(dir) - rmdir(dir) - end - end + rmdir(deepest_path_first(pkgutil_bom_dirs)) end - if root.directory? && !MacOS.undeletable?(root) - clean_ds_store(root) - rmdir(root) - end + rmdir(root) unless MacOS.undeletable?(root) forget end + sig { void } def forget odebug "Unregistering pkg receipt (aka forgetting)" - @command.run!("/usr/sbin/pkgutil", args: ["--forget", package_id], sudo: true) + @command.run!( + "/usr/sbin/pkgutil", + args: ["--forget", package_id], + sudo: true, + sudo_as_root: true, + ) end + sig { returns(T::Array[Pathname]) } def pkgutil_bom_files - @pkgutil_bom_files ||= pkgutil_bom_all.select(&:file?) - pkgutil_bom_specials + @pkgutil_bom_files ||= T.let(pkgutil_bom_all.select(&:file?) - pkgutil_bom_specials, + T.nilable(T::Array[Pathname])) end + sig { returns(T::Array[Pathname]) } def pkgutil_bom_specials - @pkgutil_bom_specials ||= pkgutil_bom_all.select(&method(:special?)) + @pkgutil_bom_specials ||= T.let(pkgutil_bom_all.select { special?(it) }, T.nilable(T::Array[Pathname])) end + sig { returns(T::Array[Pathname]) } def pkgutil_bom_dirs - @pkgutil_bom_dirs ||= pkgutil_bom_all.select(&:directory?) - pkgutil_bom_specials + @pkgutil_bom_dirs ||= T.let(pkgutil_bom_all.select(&:directory?) - pkgutil_bom_specials, + T.nilable(T::Array[Pathname])) end + sig { returns(T::Array[Pathname]) } def pkgutil_bom_all - @pkgutil_bom_all ||= @command.run!("/usr/sbin/pkgutil", args: ["--files", package_id]) - .stdout - .split("\n") - .map { |path| root.join(path) } - .reject(&MacOS.public_method(:undeletable?)) + @pkgutil_bom_all ||= T.let( + @command.run!("/usr/sbin/pkgutil", args: ["--files", package_id]) + .stdout + .split("\n") + .map { |path| root.join(path) } + .reject { |path| MacOS.undeletable?(path) }, + T.nilable(T::Array[Pathname]), + ) end + sig { returns(Pathname) } def root - @root ||= Pathname.new(info.fetch("volume")).join(info.fetch("install-location")) + @root ||= T.let(Pathname.new(info.fetch("volume")).join(info.fetch("install-location")), T.nilable(Pathname)) end + sig { returns(T.untyped) } def info - @info ||= @command.run!("/usr/sbin/pkgutil", args: ["--pkg-info-plist", package_id]) - .plist + @info ||= T.let(@command.run!("/usr/sbin/pkgutil", args: ["--pkg-info-plist", package_id]).plist, T.untyped) end private + sig { params(path: Pathname).returns(T::Boolean) } def special?(path) path.symlink? || path.chardev? || path.blockdev? end - def rmdir(path) - return unless path.children.empty? + # Helper script to delete empty directories after deleting `.DS_Store` files and broken symlinks. + # Needed in order to execute all file operations with `sudo`. + RMDIR_SH = T.let((HOMEBREW_LIBRARY_PATH/"cask/utils/rmdir.sh").freeze, Pathname) + private_constant :RMDIR_SH - if path.symlink? - @command.run!("/bin/rm", args: ["-f", "--", path], sudo: true) - else - @command.run!("/bin/rmdir", args: ["--", path], sudo: true) - end - end - - def with_full_permissions(path) - original_mode = (path.stat.mode % 01000).to_s(8) - original_flags = @command.run!("/usr/bin/stat", args: ["-f", "%Of", "--", path]).stdout.chomp - - @command.run!("/bin/chmod", args: ["--", "777", path], sudo: true) - yield - ensure - if path.exist? # block may have removed dir - @command.run!("/bin/chmod", args: ["--", original_mode, path], sudo: true) - @command.run!("/usr/bin/chflags", args: ["--", original_flags, path], sudo: true) - end + sig { params(path: T.any(Pathname, T::Array[Pathname])).void } + def rmdir(path) + @command.run!( + "/usr/bin/xargs", + args: ["-0", "--", RMDIR_SH.to_s], + input: Array(path).join("\0"), + sudo: true, + sudo_as_root: true, + ) end + sig { params(paths: T::Array[Pathname]).returns(T::Array[Pathname]) } def deepest_path_first(paths) paths.sort_by { |path| -path.to_s.split(File::SEPARATOR).count } end - - def clean_ds_store(dir) - return unless (ds_store = dir.join(".DS_Store")).exist? - - @command.run!("/bin/rm", args: ["--", ds_store], sudo: true) - end - - # Some packages leave broken symlinks around; we clean them out before - # attempting to `rmdir` to prevent extra cruft from lying around. - def clean_broken_symlinks(dir) - dir.children.select(&method(:broken_symlink?)).each do |path| - @command.run!("/bin/rm", args: ["--", path], sudo: true) - end - end - - def broken_symlink?(path) - path.symlink? && !path.exist? - end end end diff --git a/Library/Homebrew/cask/quarantine.rb b/Library/Homebrew/cask/quarantine.rb index 583cab392471e..d55f775f02f6c 100644 --- a/Library/Homebrew/cask/quarantine.rb +++ b/Library/Homebrew/cask/quarantine.rb @@ -1,59 +1,117 @@ +# typed: strict # frozen_string_literal: true require "development_tools" +require "cask/exceptions" +require "system_command" +require "utils/output" + module Cask + # Helper module for quarantining files. module Quarantine - module_function + extend SystemCommand::Mixin + extend ::Utils::Output::Mixin - QUARANTINE_ATTRIBUTE = "com.apple.quarantine" + class SigningIdentity < T::Struct + const :identifier, T.nilable(String) + const :team_identifier, T.nilable(String) + end - QUARANTINE_SCRIPT = (HOMEBREW_LIBRARY_PATH/"cask/utils/quarantine.swift").freeze + QUARANTINE_ATTRIBUTE = "com.apple.quarantine" + USER_APPROVED_FLAG = 0x0040 + + QUARANTINE_SCRIPT = T.let((HOMEBREW_LIBRARY_PATH/"cask/utils/quarantine.swift").freeze, Pathname) + COPY_XATTRS_SCRIPT = T.let((HOMEBREW_LIBRARY_PATH/"cask/utils/copy-xattrs.swift").freeze, Pathname) + + sig { returns(T.nilable(T.any(String, Pathname))) } + def self.swift + @swift ||= T.let( + begin + # /usr/bin/swift (which runs via xcrun) adds `/usr/local/include` to the top of the include path, + # which allows really broken local setups to break our Swift usage here. Using the underlying + # Swift executable directly however (returned by `xcrun -find`) avoids this CPATH mess. + xcrun_swift = ::Utils.popen_read("/usr/bin/xcrun", "-find", "swift", err: :close).chomp + if $CHILD_STATUS.success? && File.executable?(xcrun_swift) + xcrun_swift + else + DevelopmentTools.locate("swift") + end + end, + T.nilable(T.any(String, Pathname)), + ) + end + private_class_method :swift - # @private - def swift - @swift ||= DevelopmentTools.locate("swift") + sig { returns(T.nilable(Pathname)) } + def self.xattr + @xattr ||= T.let(DevelopmentTools.locate("xattr"), T.nilable(Pathname)) end + private_class_method :xattr - # @private - def xattr - @xattr ||= DevelopmentTools.locate("xattr") + sig { returns(T::Array[String]) } + def self.swift_target_args + ["-target", "#{Hardware::CPU.arch}-apple-macosx#{MacOS.version}"] end + private_class_method :swift_target_args - def check_quarantine_support + sig { returns([Symbol, T.nilable(String)]) } + def self.check_quarantine_support odebug "Checking quarantine support" - if !system_command(xattr, print_stderr: false).success? - odebug "There's not a working version of xattr." + check_output = nil + status = if xattr.nil? || !system_command(T.must(xattr), args: ["-h"], print_stderr: false).success? + odebug "There's no working version of `xattr` on this system." :xattr_broken elsif swift.nil? odebug "Swift is not available on this system." :no_swift else - api_check = system_command(swift, - args: [QUARANTINE_SCRIPT], + s = swift + raise "unexpected nil swift" unless s + + api_check = system_command(s, + args: [*swift_target_args, QUARANTINE_SCRIPT], print_stderr: false) - case api_check.exit_status - when 5 - odebug "This feature requires the macOS 10.10 SDK or higher." - :no_quarantine + exit_status = api_check.exit_status + check_output = api_check.merged_output.to_s.strip + error_output = api_check.stderr.to_s.strip + + case exit_status when 2 odebug "Quarantine is available." :quarantine_available + when 1 + # Swift script ran but failed (likely due to CLT issues) + odebug "Swift quarantine script failed: #{error_output}" + if error_output.include?("does not exist") || error_output.include?("No such file") + :swift_broken_clt + elsif error_output.include?("compiler") || error_output.include?("SDK") + :swift_compilation_failed + else + :swift_runtime_error + end + when 127 + # Command not found or execution failed + odebug "Swift execution failed with exit status 127" + :swift_not_executable else - odebug "Unknown support status" - :unknown + odebug "Swift returned unexpected exit status: #{exit_status}" + :swift_unexpected_error end end + [status, check_output] end - def available? - @status ||= check_quarantine_support + sig { returns(T::Boolean) } + def self.available? + @quarantine_support ||= T.let(check_quarantine_support, T.nilable([Symbol, T.nilable(String)])) - @status == :quarantine_available + @quarantine_support[0] == :quarantine_available end - def detect(file) + sig { params(file: T.nilable(T.any(String, Pathname))).returns(T.nilable(T::Boolean)) } + def self.detect(file) return if file.nil? odebug "Verifying Gatekeeper status of #{file}" @@ -65,30 +123,68 @@ def detect(file) quarantine_status end - def status(file) - system_command(xattr, + sig { params(file: T.any(String, Pathname)).returns(String) } + def self.status(file) + raise "unexpected nil xattr" unless xattr + + system_command(T.must(xattr), args: ["-p", QUARANTINE_ATTRIBUTE, file], print_stderr: false).stdout.rstrip end - def toggle_no_translocation_bit(attribute) + sig { params(file: T.any(String, Pathname)).returns(T::Boolean) } + def self.user_approved?(file) + return false if xattr.nil? + + quarantine_status = status(file) + return false if quarantine_status.empty? + + quarantine_status.split(";").fetch(0).to_i(16).anybits?(USER_APPROVED_FLAG) + end + + sig { params(file: T.any(String, Pathname)).returns(T.nilable(SigningIdentity)) } + def self.signing_identity(file) + result = system_command("codesign", args: ["-dvvv", file], print_stderr: false) + return unless result.success? + + identifier = T.let(nil, T.nilable(String)) + team_identifier = T.let(nil, T.nilable(String)) + + result.merged_output.to_s.each_line do |line| + case line.chomp + when /\AIdentifier=(.+)\z/ + identifier = Regexp.last_match(1) + when /\ATeamIdentifier=(.+)\z/ + team_identifier = Regexp.last_match(1) + team_identifier = nil if team_identifier == "not set" + end + end + + SigningIdentity.new(identifier:, team_identifier:) + end + + sig { params(attribute: String).returns(String) } + def self.toggle_no_translocation_bit(attribute) fields = attribute.split(";") # Fields: status, epoch, download agent, event ID # Let's toggle the app translocation bit, bit 8 # http://www.openradar.me/radar?id=5022734169931776 - fields[0] = (fields[0].to_i(16) | 0x0100).to_s(16).rjust(4, "0") + fields[0] = (fields.fetch(0).to_i(16) | 0x0100).to_s(16).rjust(4, "0") fields.join(";") end - def release!(download_path: nil) - return unless detect(download_path) + sig { params(download_path: T.nilable(Pathname)).void } + def self.release!(download_path: nil) + return if !download_path || !detect(download_path) odebug "Releasing #{download_path} from quarantine" - quarantiner = system_command(xattr, + raise "unexpected nil xattr" unless xattr + + quarantiner = system_command(T.must(xattr), args: [ "-d", QUARANTINE_ATTRIBUTE, @@ -101,15 +197,19 @@ def release!(download_path: nil) raise CaskQuarantineReleaseError.new(download_path, quarantiner.stderr) end - def cask!(cask: nil, download_path: nil, action: true) + sig { params(cask: T.nilable(Cask), download_path: T.nilable(Pathname), action: T::Boolean).void } + def self.cask!(cask: nil, download_path: nil, action: true) return if cask.nil? || download_path.nil? return if detect(download_path) odebug "Quarantining #{download_path}" - quarantiner = system_command(swift, + raise "unexpected nil swift" unless swift + + quarantiner = system_command(T.must(swift), args: [ + *swift_target_args, QUARANTINE_SCRIPT, download_path, cask.url.to_s, @@ -127,7 +227,8 @@ def cask!(cask: nil, download_path: nil, action: true) end end - def propagate(from: nil, to: nil) + sig { params(from: T.nilable(Pathname), to: T.nilable(Pathname)).void } + def self.propagate(from: nil, to: nil) return if from.nil? || to.nil? raise CaskError, "#{from} was not quarantined properly." unless detect(from) @@ -142,17 +243,19 @@ def propagate(from: nil, to: nil) args: [ "-0", "--", - "/bin/chmod", + "chmod", "-h", "u+w", ], input: resolved_paths.join("\0")) + raise "unexpected nil xattr" unless xattr + quarantiner = system_command("/usr/bin/xargs", args: [ "-0", "--", - xattr, + T.must(xattr), "-w", QUARANTINE_ATTRIBUTE, quarantine_status, @@ -164,5 +267,92 @@ def propagate(from: nil, to: nil) raise CaskQuarantinePropagationError.new(to, quarantiner.stderr) end + + sig { params(from: Pathname, to: Pathname, command: T.class_of(SystemCommand)).void } + def self.copy_xattrs(from, to, command:) + odebug "Copying xattrs from #{from} to #{to}" + + raise "unexpected nil swift" unless swift + + command.run!( + T.must(swift), + args: [ + *swift_target_args, + COPY_XATTRS_SCRIPT, + from, + to, + ], + sudo: !to.writable?, + ) + end + + # Ensures that Homebrew has permission to update apps on macOS Ventura. + # This may be granted either through the App Management toggle or the Full Disk Access toggle. + # The system will only show a prompt for App Management, so we ask the user to grant that. + sig { params(app: Pathname, command: T.class_of(SystemCommand)).returns(T::Boolean) } + def self.app_management_permissions_granted?(app:, command:) + return true unless app.directory? + + # To get macOS to prompt the user for permissions, we need to actually attempt to + # modify a file in the app. + test_file = app/".homebrew-write-test" + + # We can't use app.writable? here because that conflates several access checks, + # including both file ownership and whether system permissions are granted. + # Here we just want to check whether sudo would be needed. + looks_writable_without_sudo = if app.owned? + app.lstat.mode.anybits?(0200) + elsif app.grpowned? + app.lstat.mode.anybits?(0020) + else + app.lstat.mode.anybits?(0002) + end + + if looks_writable_without_sudo + begin + File.write(test_file, "") + test_file.delete + return true + rescue Errno::EACCES, Errno::EPERM + # Using error handler below + end + else + begin + command.run!( + "touch", + args: [ + test_file, + ], + print_stderr: false, + sudo: true, + ) + command.run!( + "rm", + args: [ + test_file, + ], + print_stderr: false, + sudo: true, + ) + return true + rescue ErrorDuringExecution => e + # We only want to handle "touch" errors here; propagate "sudo" errors up + raise e unless e.stderr.include?("touch: #{test_file}: Operation not permitted") + end + end + + # Allow undocumented way to skip the prompt. + if ENV["HOMEBREW_NO_APP_MANAGEMENT_PERMISSIONS_PROMPT"] + opoo <<~EOF + Your terminal does not have App Management permissions, so Homebrew will delete and reinstall the app. + This may result in some configurations (like notification settings or location in the Dock/Launchpad) being lost. + To fix this, go to System Settings → Privacy & Security → App Management and add or enable your terminal. + EOF + end + + false + end end end + +require "extend/os/cask/quarantine" diff --git a/Library/Homebrew/cask/reinstall.rb b/Library/Homebrew/cask/reinstall.rb new file mode 100644 index 0000000000000..8b24d310978fe --- /dev/null +++ b/Library/Homebrew/cask/reinstall.rb @@ -0,0 +1,89 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/output" +require "install" + +module Cask + class Reinstall + extend ::Utils::Output::Mixin + + sig { + params( + casks: ::Cask::Cask, verbose: T::Boolean, force: T::Boolean, skip_cask_deps: T::Boolean, binaries: T::Boolean, + require_sha: T::Boolean, quarantine: T::Boolean, zap: T::Boolean, skip_prefetch: T::Boolean, + download_queue: T.nilable(Homebrew::DownloadQueue) + ).void + } + def self.reinstall_casks( + *casks, + verbose: false, + force: false, + skip_cask_deps: false, + binaries: false, + require_sha: false, + quarantine: false, + zap: false, + skip_prefetch: false, + download_queue: nil + ) + require "cask/installer" + + quarantine = true if quarantine.nil? + created_download_queue = T.let(false, T::Boolean) + if download_queue.nil? + if skip_prefetch + download_queue = Homebrew.default_download_queue + else + download_queue = Homebrew::DownloadQueue.new(pour: true) + created_download_queue = true + end + end + + cask_installers = T.let([], T::Array[Installer]) + begin + cask_installers = casks.map do |cask| + Installer.new( + cask, + binaries:, + verbose:, + force:, + skip_cask_deps:, + require_sha:, + reinstall: true, + quarantine:, + zap:, + download_queue:, + defer_fetch: true, + ) + end + + unless skip_prefetch + Homebrew::Install.enqueue_cask_installers(cask_installers, download_queue:) + oh1 "Fetching downloads for: #{casks.map { |cask| Formatter.identifier(cask.full_name) }.to_sentence}", + truncate: false + download_queue.fetch + end + ensure + download_queue.shutdown if created_download_queue + end + + exit 1 if Homebrew.failed? + + caught_exceptions = [] + + cask_installers.each do |installer| + installer.install + rescue => e + caught_exceptions << e + next + end + + return if caught_exceptions.empty? + + raise MultipleCaskErrors, caught_exceptions if caught_exceptions.count > 1 + + raise caught_exceptions.fetch(0) + end + end +end diff --git a/Library/Homebrew/cask/staged.rb b/Library/Homebrew/cask/staged.rb index b0216fc344276..d97e2e7d382e9 100644 --- a/Library/Homebrew/cask/staged.rb +++ b/Library/Homebrew/cask/staged.rb @@ -1,28 +1,60 @@ +# typed: strict # frozen_string_literal: true +require "cask/quarantine" require "utils/user" +require "utils/output" module Cask + # Helper functions for staged casks. module Staged + include ::Utils::Output::Mixin + extend T::Helpers + + requires_ancestor { ::Cask::DSL::Base } + + Paths = T.type_alias { T.any(String, Pathname, T::Array[T.any(String, Pathname)]) } + + sig { params(paths: Paths, permissions_str: String).void } def set_permissions(paths, permissions_str) full_paths = remove_nonexistent(paths) return if full_paths.empty? - @command.run!("/bin/chmod", args: ["-R", "--", permissions_str] + full_paths, - sudo: false) + command.run!("chmod", args: ["-R", "--", permissions_str, *full_paths], + sudo: false) end - def set_ownership(paths, user: User.current, group: "staff") + sig { params(paths: Paths, user: T.any(String, User), group: String).void } + def set_ownership(paths, user: T.must(User.current), group: "staff") full_paths = remove_nonexistent(paths) return if full_paths.empty? - ohai "Changing ownership of paths required by #{@cask}; your password may be necessary" - @command.run!("/usr/sbin/chown", args: ["-R", "--", "#{user}:#{group}"] + full_paths, - sudo: true) + # On macOS Ventura or later, modifying the contents of an app bundle + # requires App Management permissions, even when using `sudo`. Without + # them, every `chown` fails with `Operation not permitted`, so check + # upfront: this triggers the system permission prompt (which a plain + # `chown` does not) and allows giving the user an actionable error + # message instead of a wall of `chown` errors. + full_paths.each do |path| + next if Quarantine.app_management_permissions_granted?(app: path, command:) + + raise CaskError, <<~EOS + Cannot change the ownership of '#{path}' because your terminal does not have App Management permissions. + macOS prevents modifying apps without these permissions, even when using `sudo`. + To fix this, approve the permissions prompt (if one was just shown) or go to + System Settings → Privacy & Security → App Management and add or enable your terminal. + Then run this command again. + EOS + end + + ohai "Changing ownership of paths required by #{cask} with `sudo` (which may request your password)..." + command.run!("chown", args: ["-R", "--", "#{user}:#{group}", *full_paths], + sudo: true) end private + sig { params(paths: Paths).returns(T::Array[Pathname]) } def remove_nonexistent(paths) Array(paths).map { |p| Pathname(p).expand_path }.select(&:exist?) end diff --git a/Library/Homebrew/cask/tab.rb b/Library/Homebrew/cask/tab.rb new file mode 100644 index 0000000000000..96dff58749d0c --- /dev/null +++ b/Library/Homebrew/cask/tab.rb @@ -0,0 +1,140 @@ +# typed: strict +# frozen_string_literal: true + +require "tab" +require "utils/topological_hash" + +module Cask + class Tab < ::AbstractTab + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[T.any(Pathname, String), T.untyped] } } + # rubocop:enable Style/MutableConstant + + sig { returns(T.nilable(T::Boolean)) } + attr_accessor :uninstall_flight_blocks + + sig { returns(T.nilable(T::Array[T.untyped])) } + attr_accessor :uninstall_artifacts + + sig { params(attributes: T.any(T::Hash[String, T.untyped], T::Hash[Symbol, T.untyped])).void } + def initialize(attributes = {}) + @uninstall_flight_blocks = T.let(nil, T.nilable(T::Boolean)) + @uninstall_artifacts = T.let(nil, T.nilable(T::Array[T.untyped])) + + super + end + + # Instantiates a {Tab} for a new installation of a cask. + sig { override.params(formula_or_cask: T.any(Formula, Cask)).returns(T.attached_class) } + def self.create(formula_or_cask) + cask = T.cast(formula_or_cask, Cask) + tab = super + + tab.tabfile = cask.metadata_main_container_path/FILENAME + tab.uninstall_flight_blocks = cask.uninstall_flight_blocks? + tab.runtime_dependencies = Tab.runtime_deps_hash(cask) + tab.source["version"] = cask.version.to_s + tab.source["path"] = cask.sourcefile_path.to_s + tab.uninstall_artifacts = cask.artifacts_list(uninstall_only: true) + + tab + end + + # Returns a {Tab} for an already installed cask, + # or a fake one if the cask is not installed. + sig { params(cask: Cask).returns(T.attached_class) } + def self.for_cask(cask) + path = cask.metadata_main_container_path/FILENAME + + return from_file(path) if path.exist? + + tab = empty + tab.source = { + "path" => cask.sourcefile_path.to_s, + "tap" => cask.tap&.name, + "tap_git_head" => cask.tap_git_head, + "version" => cask.version.to_s, + } + tab.uninstall_artifacts = cask.artifacts_list(uninstall_only: true) + + tab + end + + sig { returns(T.attached_class) } + def self.empty + tab = super + tab.uninstall_flight_blocks = false + tab.uninstall_artifacts = [] + tab.source["version"] = nil + + tab + end + + sig { params(cask: Cask).returns(T::Hash[Symbol, T::Array[T::Hash[String, T.untyped]]]) } + def self.runtime_deps_hash(cask) + cask_and_formula_dep_graph = ::Utils::TopologicalHash.graph_package_dependencies(cask) + cask_deps, formula_deps = T.cast(cask_and_formula_dep_graph.values.flatten.uniq.partition do |dep| + dep.is_a?(Cask) + end, [T::Array[Cask], T::Array[Formula]]) + + runtime_deps = {} + + if cask_deps.any? + runtime_deps[:cask] = cask_deps.map do |dep| + { + "full_name" => dep.full_name, + "version" => dep.version.to_s, + "declared_directly" => cask.depends_on.cask.include?(dep.full_name), + } + end + end + + if formula_deps.any? + runtime_deps[:formula] = formula_deps.map do |dep| + formula_to_dep_hash(dep, cask.depends_on.formula) + end + end + + runtime_deps + end + + sig { returns(T.nilable(String)) } + def version + source["version"] + end + + sig { params(_args: T.untyped).returns(String) } + def to_json(*_args) + attributes = { + "homebrew_version" => homebrew_version, + "loaded_from_api" => loaded_from_api, + "loaded_from_internal_api" => loaded_from_internal_api, + "uninstall_flight_blocks" => uninstall_flight_blocks, + "installed_on_request" => installed_on_request, + "time" => time, + "runtime_dependencies" => runtime_dependencies, + "source" => source, + "arch" => arch, + "uninstall_artifacts" => uninstall_artifacts, + "built_on" => built_on, + } + + JSON.pretty_generate(attributes) + end + + sig { returns(String) } + def to_s + s = ["Installed"] + if loaded_from_internal_api + s << "using the internal formulae.brew.sh API" + elsif loaded_from_api + s << "using the formulae.brew.sh API" + end + if (t = time) + s << Time.at(t).strftime("on %Y-%m-%d at %H:%M:%S") + end + s.join(" ") + end + end +end diff --git a/Library/Homebrew/cask/topological_hash.rb b/Library/Homebrew/cask/topological_hash.rb deleted file mode 100644 index 919dc161f71de..0000000000000 --- a/Library/Homebrew/cask/topological_hash.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require "tsort" - -# a basic topologically sortable hashmap -module Cask - class TopologicalHash < Hash - include TSort - - alias tsort_each_node each_key - - def tsort_each_child(node, &block) - fetch(node).each(&block) - end - end -end diff --git a/Library/Homebrew/cask/uninstall.rb b/Library/Homebrew/cask/uninstall.rb new file mode 100644 index 0000000000000..bc12d48fd7c71 --- /dev/null +++ b/Library/Homebrew/cask/uninstall.rb @@ -0,0 +1,75 @@ +# typed: strict +# frozen_string_literal: true + +require "cask_dependent" +require "dependents_message" +require "utils/output" + +module Cask + class Uninstall + extend ::Utils::Output::Mixin + + sig { params(casks: ::Cask::Cask, binaries: T::Boolean, force: T::Boolean, verbose: T::Boolean).void } + def self.uninstall_casks(*casks, binaries: false, force: false, verbose: false) + require "cask/installer" + + caught_exceptions = [] + + casks.each do |cask| + odebug "Uninstalling Cask #{cask}" + + raise CaskNotInstalledError, cask if !cask.installed? && !force + + next unless unpin_for_removal?(cask, force:) + + Installer.new(cask, binaries:, force:, verbose:).uninstall + rescue => e + caught_exceptions << e + next + end + + return if caught_exceptions.empty? + + raise MultipleCaskErrors, caught_exceptions if caught_exceptions.count > 1 + + raise caught_exceptions.fetch(0) + end + + sig { params(cask: ::Cask::Cask, force: T::Boolean).returns(T::Boolean) } + def self.unpin_for_removal?(cask, force:) + return true unless cask.pinned? + + unless force + onoe "#{cask.full_name} is pinned. You must unpin it to uninstall." + return false + end + + cask.unpin + true + end + + sig { params(casks: ::Cask::Cask, named_args: T::Array[String]).void } + def self.check_dependent_casks(*casks, named_args: []) + dependents = [] + all_requireds = casks.map(&:token) + requireds = Set.new + caskroom = ::Cask::Caskroom.casks + + caskroom.each do |dependent| + next if all_requireds.include?(dependent.token) + + d = CaskDependent.new(dependent) + dependencies = d.recursive_requirements.filter_map { |r| r.cask if r.is_a?(CaskDependent::Requirement) } + found_dependents = dependencies.intersection(all_requireds) + next if found_dependents.empty? + + requireds += found_dependents + dependents << dependent.token + end + + return if dependents.empty? + + DependentsMessage.new(requireds.to_a, dependents, named_args:).output + end + end +end diff --git a/Library/Homebrew/cask/upgrade.rb b/Library/Homebrew/cask/upgrade.rb new file mode 100644 index 0000000000000..286f0e2fed291 --- /dev/null +++ b/Library/Homebrew/cask/upgrade.rb @@ -0,0 +1,479 @@ +# typed: strict +# frozen_string_literal: true + +require "env_config" +require "cask/config" +require "cask/quarantine" +require "deprecate_disable" +require "install" +require "upgrade" +require "utils/output" + +module Cask + class Upgrade + extend ::Utils::Output::Mixin + + sig { returns(T::Array[String]) } + def self.greedy_casks + if (upgrade_greedy_casks = Homebrew::EnvConfig.upgrade_greedy_casks.presence) + upgrade_greedy_casks.split + else + [] + end + end + + sig { + params( + casks: T::Array[Cask], + args: Homebrew::CLI::Args, + force: T.nilable(T::Boolean), + quiet: T.nilable(T::Boolean), + greedy: T.nilable(T::Boolean), + greedy_latest: T.nilable(T::Boolean), + greedy_auto_updates: T.nilable(T::Boolean), + summary_pinned: T.nilable(T::Array[String]), + summary_disabled: T.nilable(T::Array[String]), + ).returns(T::Array[Cask]) + } + def self.outdated_casks(casks, args:, force:, quiet:, + greedy: false, greedy_latest: false, greedy_auto_updates: false, + summary_pinned: nil, summary_disabled: nil) + greedy = true if Homebrew::EnvConfig.upgrade_greedy? + + outdated_casks = if casks.empty? + Caskroom.casks(config: Config.from_args(args)).select do |cask| + if cask.disabled? + summary_disabled&.push(cask.full_name) + opoo "Not upgrading #{cask.token}, it is #{DeprecateDisable.message(cask)}" unless quiet + next false + end + + cask_greedy = greedy || greedy_casks.include?(cask.token) + cask.outdated?(greedy: cask_greedy, greedy_latest:, + greedy_auto_updates:) + end + else + casks.select do |cask| + raise CaskNotInstalledError, cask if !cask.installed? && !force + + if cask.disabled? + summary_disabled&.push(cask.full_name) + opoo "Not upgrading #{cask.token}, it is #{DeprecateDisable.message(cask)}" unless quiet + next false + end + + if cask.outdated?(greedy: true) + true + elsif cask.version.latest? + opoo "Not upgrading #{cask.token}, the downloaded artifact has not changed" unless quiet + false + else + opoo "Not upgrading #{cask.token}, the latest version is already installed" unless quiet + false + end + end + end + + pinned_casks = outdated_casks.select(&:pinned?) + outdated_casks -= pinned_casks + summary_pinned&.concat(pinned_casks.map { |cask| "#{cask.full_name} #{cask.installed_version}" }) + + if pinned_casks.any? && (!quiet || casks.any?) + message = "Not upgrading #{pinned_casks.count} pinned #{::Utils.pluralize("package", pinned_casks.count)}:" + casks.any? ? ofail(message) : opoo(message) + $stderr.puts pinned_casks.map { |cask| "#{cask.full_name} #{cask.installed_version}" } * ", " unless quiet + end + + outdated_casks + end + + sig { params(cask_upgrades: T::Array[String], dry_run: T.nilable(T::Boolean)).void } + def self.show_upgrade_summary(cask_upgrades, dry_run: false) + return if cask_upgrades.empty? + + verb = dry_run ? "Would upgrade" : "Upgrading" + oh1 "#{verb} #{cask_upgrades.count} outdated #{::Utils.pluralize("package", cask_upgrades.count)}:" + puts Homebrew::Upgrade.format_upgrade_summary(cask_upgrades).join("\n") + end + + sig { + params( + casks: Cask, + args: Homebrew::CLI::Args, + force: T.nilable(T::Boolean), + greedy: T.nilable(T::Boolean), + greedy_latest: T.nilable(T::Boolean), + greedy_auto_updates: T.nilable(T::Boolean), + dry_run: T.nilable(T::Boolean), + skip_cask_deps: T.nilable(T::Boolean), + verbose: T.nilable(T::Boolean), + quiet: T.nilable(T::Boolean), + binaries: T.nilable(T::Boolean), + quarantine: T.nilable(T::Boolean), + require_sha: T.nilable(T::Boolean), + quit: T::Boolean, + skip_prefetch: T::Boolean, + show_upgrade_summary: T::Boolean, + download_queue: T.nilable(Homebrew::DownloadQueue), + summary_upgrades: T.nilable(T::Array[String]), + summary_pinned: T.nilable(T::Array[String]), + summary_deprecated: T.nilable(T::Array[String]), + summary_disabled: T.nilable(T::Array[String]), + ).returns(T::Boolean) + } + def self.upgrade_casks!( + *casks, + args:, + force: false, + greedy: false, + greedy_latest: false, + greedy_auto_updates: false, + dry_run: false, + skip_cask_deps: false, + verbose: false, + quiet: false, + binaries: nil, + quarantine: nil, + require_sha: nil, + quit: true, + skip_prefetch: false, + show_upgrade_summary: true, + download_queue: nil, + summary_upgrades: nil, + summary_pinned: nil, + summary_deprecated: nil, + summary_disabled: nil + ) + outdated_casks = + self.outdated_casks(casks, args:, greedy:, greedy_latest:, greedy_auto_updates:, force:, quiet:, + summary_pinned:, summary_disabled:) + + manual_installer_casks = outdated_casks.select do |cask| + cask.artifacts.any? do |artifact| + artifact.is_a?(Artifact::Installer) && artifact.manual_install + end + end + + if manual_installer_casks.present? + count = manual_installer_casks.count + ofail "Not upgrading #{count} `installer manual` #{::Utils.pluralize("cask", count)}." + puts manual_installer_casks.map(&:to_s) + outdated_casks -= manual_installer_casks + end + + return false if outdated_casks.empty? + + if !Homebrew::EnvConfig.no_env_hints? && casks.empty? && !greedy && greedy_casks.empty? + output_hint = false + if !greedy_auto_updates && outdated_casks.any?(&:auto_updates) + puts "Homebrew will now attempt to upgrade casks with `auto_updates true`." + puts "Disable this behaviour with `HOMEBREW_NO_UPGRADE_AUTO_UPDATES_CASKS=1`." + output_hint ||= true + end + if !greedy_auto_updates && !greedy_latest + puts "Some casks with `auto_updates true` or `version :latest` may still require `--greedy`," + puts "`HOMEBREW_UPGRADE_GREEDY` or `HOMEBREW_UPGRADE_GREEDY_CASKS` to be upgraded." + output_hint ||= true + end + if greedy_auto_updates && !greedy_latest + puts "Casks with `version :latest` will not be upgraded; pass `--greedy-latest` to upgrade them." + output_hint ||= true + end + if !greedy_auto_updates && greedy_latest + puts "Some casks with `auto_updates true` may still require `--greedy-auto-updates` to be upgraded." + output_hint ||= true + end + puts "Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`)." if output_hint + end + + upgradable_casks = outdated_casks.filter_map do |c| + loaded_cask = if c.installed? && (installed_caskfile = c.installed_caskfile) + begin + CaskLoader.load_from_installed_caskfile(installed_caskfile) + rescue CaskInvalidError, CaskUnavailableError, MethodDeprecatedError + nil + end + end + + if loaded_cask.nil? + opoo <<~EOS + The cask '#{c.token}' cannot be upgraded as-is. To fix this, run: + brew reinstall --cask --force #{c.token} + EOS + next + end + + [loaded_cask, c] + end + + return false if upgradable_casks.empty? + + cask_upgrades = upgradable_casks.map do |(old_cask, new_cask)| + "#{new_cask.full_name} #{old_cask.version} -> #{new_cask.version}" + end + summary_upgrades&.concat(cask_upgrades) if dry_run + summary_deprecated&.concat(upgradable_casks.filter_map do |(_, new_cask)| + new_cask.full_name if new_cask.deprecated? + end) + + created_download_queue = T.let(false, T::Boolean) + download_queue ||= if !dry_run && !skip_prefetch + created_download_queue = true + Homebrew::DownloadQueue.new(pour: true) + end + + if !dry_run && !skip_prefetch + prefetch_download_queue = download_queue || Homebrew.default_download_queue + begin + fetchable_casks = upgradable_casks.map(&:last) + fetchable_cask_installers = fetchable_casks.map do |cask| + # This is significantly easier given the weird difference in Sorbet signatures here. + # rubocop:disable Style/DoubleNegation + Installer.new(cask, binaries: !!binaries, verbose: !!verbose, force: !!force, + skip_cask_deps: !!skip_cask_deps, require_sha: !!require_sha, + upgrade: true, quarantine: quarantine != false, + download_queue: prefetch_download_queue, defer_fetch: true) + # rubocop:enable Style/DoubleNegation + end + + fetchable_casks_sentence = fetchable_casks.map { |cask| Formatter.identifier(cask.full_name) }.to_sentence + Homebrew::Install.enqueue_cask_installers(fetchable_cask_installers, + download_queue: prefetch_download_queue) + oh1 "Fetching downloads for: #{fetchable_casks_sentence}", truncate: false + prefetch_download_queue.fetch + ensure + prefetch_download_queue.shutdown if created_download_queue + end + end + + show_upgrade_summary(cask_upgrades, dry_run:) if show_upgrade_summary + return true if dry_run + + caught_exceptions = [] + + download_queue ||= Homebrew.default_download_queue + + upgradable_casks.each_with_index do |(old_cask, new_cask), index| + upgrade_cask( + old_cask, new_cask, + binaries:, force:, skip_cask_deps:, verbose:, + quarantine:, require_sha:, quit:, download_queue: + ) + summary_upgrades&.push(cask_upgrades.fetch(index)) + rescue => e + new_exception = e.exception("#{new_cask.full_name}: #{e}") + new_exception.set_backtrace(e.backtrace) + caught_exceptions << new_exception + next + end + + return true if caught_exceptions.empty? + raise MultipleCaskErrors, caught_exceptions if caught_exceptions.count > 1 + raise caught_exceptions.fetch(0) if caught_exceptions.one? + + false + end + + sig { + params( + old_cask: Cask, + new_cask: Cask, + old_signing_identities: T::Hash[String, T.nilable(Quarantine::SigningIdentity)], + old_user_approved: T::Hash[String, T::Boolean], + ).returns(Symbol) + } + def self.quarantine_release_decision(old_cask, new_cask, old_signing_identities, old_user_approved) + old_app_artifacts = old_cask.artifacts.grep(Artifact::App) + new_app_artifacts = new_cask.artifacts.grep(Artifact::App) + return :skip if old_app_artifacts.empty? || old_app_artifacts.length != new_app_artifacts.length + + approved = old_app_artifacts.each_with_index.select do |artifact, _index| + old_user_approved.fetch(artifact.target.to_s, false) + end + + signer_changed = approved.any? do |artifact, index| + old_identity = old_signing_identities[artifact.target.to_s] + new_identity = Quarantine.signing_identity(new_app_artifacts.fetch(index).target) + [ + [old_identity&.identifier, new_identity&.identifier], + [old_identity&.team_identifier, new_identity&.team_identifier], + ].any? do |old_value, new_value| + !old_value.nil? && !new_value.nil? && old_value != new_value + end + end + + return :signer_changed if signer_changed + return :unapproved if approved.length != old_app_artifacts.length + + :release + rescue + :skip + end + + sig { params(old_cask: Cask, new_cask: Cask).void } + def self.reopen_apps_after_upgrade(old_cask, new_cask) + bundle_ids = old_cask.artifacts + .grep(Artifact::Uninstall) + .flat_map(&:bundle_ids_to_reopen) + return if bundle_ids.empty? + + # Re-register newly installed apps with Launch Services before reopening + lsregister = Pathname( + "/System/Library/Frameworks/CoreServices.framework" \ + "/Frameworks/LaunchServices.framework/Support/lsregister", + ) + if lsregister.executable? + new_cask.artifacts.grep(Artifact::App).each do |artifact| + system(lsregister.to_s, "-f", artifact.target.to_s) if artifact.target.exist? + end + end + + ohai "Reopening #{bundle_ids.count} #{::Utils.pluralize("application", + bundle_ids.count)} closed during upgrade:" + bundle_ids.each do |bundle_id| + puts bundle_id + system("open", "-b", bundle_id) + end + end + private_class_method :reopen_apps_after_upgrade + + sig { + params( + old_cask: Cask, + new_cask: Cask, + binaries: T.nilable(T::Boolean), + force: T.nilable(T::Boolean), + quarantine: T.nilable(T::Boolean), + require_sha: T.nilable(T::Boolean), + quit: T::Boolean, + skip_cask_deps: T.nilable(T::Boolean), + verbose: T.nilable(T::Boolean), + download_queue: Homebrew::DownloadQueue, + ).void + } + def self.upgrade_cask( + old_cask, new_cask, + binaries:, force:, quarantine:, require_sha:, quit:, skip_cask_deps:, verbose:, download_queue: + ) + require "cask/installer" + + start_time = Time.now + odebug "Started upgrade process for Cask #{old_cask}" + old_config = old_cask.config + + old_options = { + binaries:, + verbose:, + force:, + upgrade: true, + }.compact + + old_cask_installer = + Installer.new(old_cask, **old_options) + old_tab = old_cask.tab + + new_cask.config = new_cask.default_config.merge(old_config) + + new_options = { + binaries:, + verbose:, + force:, + skip_cask_deps:, + require_sha:, + upgrade: true, + download_queue:, + }.compact + + new_cask_installer = + Installer.new(new_cask, **new_options, quarantine: quarantine != false, defer_fetch: true) + + started_upgrade = false + new_artifacts_installed = false + old_signing_identities = T.let({}, T::Hash[String, T.nilable(Quarantine::SigningIdentity)]) + old_user_approved = T.let({}, T::Hash[String, T::Boolean]) + + begin + oh1 "Upgrading #{Formatter.identifier(old_cask)}" + puts " #{old_cask.version} -> #{new_cask.version}" + + # Start new cask's installation steps + new_cask_installer.prelude + + if (caveats = new_cask_installer.caveats) + puts caveats + end + + new_cask_installer.fetch + + if quarantine.nil? + old_cask.artifacts.grep(Artifact::App).each do |artifact| + old_user_approved[artifact.target.to_s] = + if artifact.target.exist? + Quarantine.user_approved?(artifact.target) + else + false + end + old_signing_identities[artifact.target.to_s] = Quarantine.signing_identity(artifact.target) + end + end + + # Move the old cask's artifacts back to staging + old_cask_installer.start_upgrade(successor: new_cask, quit:) + # And flag it so in case of error + started_upgrade = true + + # Install the new cask + new_cask_installer.stage + + new_cask_installer.install_artifacts(predecessor: old_cask) + new_artifacts_installed = true + + if quarantine.nil? && Quarantine.available? + case quarantine_release_decision(old_cask, new_cask, old_signing_identities, old_user_approved) + when :release + new_cask.artifacts.grep(Artifact::App).each do |artifact| + Quarantine.release!(download_path: artifact.target) + end + when :signer_changed + opoo "#{new_cask.token}'s signer changed so macOS will prompt at next launch." + when :unapproved + message = "#{new_cask.token} wasn't quarantine approved so not approving now. " \ + "macOS will prompt at next launch." + if verbose + ohai message + else + odebug message + end + end + end + + # If successful, wipe the old cask from staging. + old_cask_installer.finalize_upgrade + + reopen_apps_after_upgrade(old_cask, new_cask) if quit + rescue => e + begin + new_cask_installer.uninstall_artifacts(successor: old_cask, quit:) if new_artifacts_installed + new_cask_installer.purge_versioned_files + old_cask_installer.revert_upgrade(predecessor: new_cask) if started_upgrade + rescue => rollback_error + opoo "Rolling back the failed upgrade of #{old_cask.token} also failed: " \ + "#{rollback_error.class}: #{rollback_error.message}" + if (rollback_backtrace = rollback_error.backtrace) + odebug "Rollback backtrace:", rollback_backtrace + end + end + raise e + end + + # Wait until rollback is no longer possible so failures keep the old + # receipt, while successful upgrades can load artifacts next time. + tab = Tab.create(new_cask) + tab.installed_on_request = old_tab.tabfile.nil? || old_tab.installed_on_request + tab.write + + end_time = Time.now + Homebrew.messages.package_installed(new_cask.token, end_time - start_time) + end + end +end diff --git a/Library/Homebrew/cask/url.rb b/Library/Homebrew/cask/url.rb index 329a1bfe06a7f..bbd6620500740 100644 --- a/Library/Homebrew/cask/url.rb +++ b/Library/Homebrew/cask/url.rb @@ -1,29 +1,124 @@ +# typed: strict # frozen_string_literal: true -class URL - ATTRIBUTES = [ - :using, - :tag, :branch, :revisions, :revision, - :trust_cert, :cookies, :referer, :user_agent, - :data - ].freeze +require "uri" +require "source_location" - attr_reader :uri, :specs - attr_reader(*ATTRIBUTES) +module Cask + # Class corresponding to the `url` stanza. + class URL + sig { returns(URI::Generic) } + attr_reader :uri - extend Forwardable - def_delegators :uri, :path, :scheme, :to_s + sig { returns(T.nilable(T::Hash[T.any(Symbol, String), String])) } + attr_reader :revisions - def initialize(uri, **options) - @uri = URI(uri) - @user_agent = :default + sig { returns(T.nilable(T::Boolean)) } + attr_reader :trust_cert - ATTRIBUTES.each do |attribute| - next unless options.key?(attribute) + sig { returns(T.nilable(T::Hash[String, String])) } + attr_reader :cookies, :data - instance_variable_set("@#{attribute}", options[attribute]) + sig { returns(T.nilable(T::Array[String])) } + attr_reader :header + + sig { returns(T.nilable(T.any(URI::Generic, String))) } + attr_reader :referer + + sig { returns(T::Hash[Symbol, T.untyped]) } + attr_reader :specs + + sig { returns(T.nilable(T.any(Symbol, String))) } + attr_reader :user_agent + + sig { returns(T.nilable(T.any(T::Class[AbstractDownloadStrategy], Symbol))) } + attr_reader :using + + sig { returns(T.nilable(String)) } + attr_reader :tag, :branch, :revision, :only_path, :verified + + extend Forwardable + + def_delegators :uri, :path, :scheme, :to_s + + # Creates a `url` stanza. + # + # @api public + sig { + params( + uri: T.any(URI::Generic, String), + verified: T.nilable(String), + using: T.nilable(T.any(T::Class[AbstractDownloadStrategy], Symbol)), + tag: T.nilable(String), + branch: T.nilable(String), + revisions: T.nilable(T::Hash[T.any(Symbol, String), String]), + revision: T.nilable(String), + trust_cert: T.nilable(T::Boolean), + cookies: T.nilable(T::Hash[T.any(String, Symbol), String]), + referer: T.nilable(T.any(URI::Generic, String)), + header: T.nilable(T.any(String, T::Array[String])), + user_agent: T.nilable(T.any(Symbol, String)), + data: T.nilable(T::Hash[String, String]), + only_path: T.nilable(String), + caller_location: Thread::Backtrace::Location, + ).void + } + def initialize( + uri, verified: nil, using: nil, tag: nil, branch: nil, revisions: nil, revision: nil, trust_cert: nil, + cookies: nil, referer: nil, header: nil, user_agent: nil, data: nil, only_path: nil, + caller_location: caller_locations.fetch(0) + ) + @uri = T.let(URI(uri), URI::Generic) + + header = Array(header) unless header.nil? + + specs = {} + specs[:verified] = @verified = T.let(verified, T.nilable(String)) + specs[:using] = @using = T.let(using, T.nilable(T.any(T::Class[AbstractDownloadStrategy], Symbol))) + specs[:tag] = @tag = T.let(tag, T.nilable(String)) + specs[:branch] = @branch = T.let(branch, T.nilable(String)) + specs[:revisions] = @revisions = T.let(revisions, T.nilable(T::Hash[T.any(Symbol, String), String])) + specs[:revision] = @revision = T.let(revision, T.nilable(String)) + specs[:trust_cert] = @trust_cert = T.let(trust_cert, T.nilable(T::Boolean)) + specs[:cookies] = + @cookies = T.let(cookies&.transform_keys(&:to_s), T.nilable(T::Hash[String, String])) + specs[:referer] = @referer = T.let(referer, T.nilable(T.any(URI::Generic, String))) + specs[:headers] = @header = T.let(header, T.nilable(T::Array[String])) + specs[:user_agent] = @user_agent = T.let(user_agent || :default, T.nilable(T.any(Symbol, String))) + specs[:data] = @data = T.let(data, T.nilable(T::Hash[String, String])) + specs[:only_path] = @only_path = T.let(only_path, T.nilable(String)) + + @specs = T.let(specs.compact, T::Hash[Symbol, T.untyped]) + @caller_location = caller_location end - @specs = options + sig { returns(Homebrew::SourceLocation) } + def location + Homebrew::SourceLocation.new(@caller_location.lineno, raw_url_line&.index("url")) + end + + sig { params(ignore_major_version: T::Boolean).returns(T::Boolean) } + def unversioned?(ignore_major_version: false) + interpolated_url = raw_url_line&.then { |line| line[/url\s+"([^"]+)"/, 1] } + + return false unless interpolated_url + + interpolated_url = interpolated_url.gsub(/\#{\s*arch\s*}/, "") + interpolated_url = interpolated_url.gsub(/\#{\s*version\s*\.major\s*}/, "") if ignore_major_version + + interpolated_url.exclude?('#{') + end + + private + + sig { returns(T.nilable(String)) } + def raw_url_line + return @raw_url_line if defined?(@raw_url_line) + + @raw_url_line = T.let(Pathname(T.must(@caller_location.path)) + .each_line + .drop(@caller_location.lineno - 1) + .first, T.nilable(String)) + end end end diff --git a/Library/Homebrew/cask/utils.rb b/Library/Homebrew/cask/utils.rb index a071c9c69374d..4f65049c92c22 100644 --- a/Library/Homebrew/cask/utils.rb +++ b/Library/Homebrew/cask/utils.rb @@ -1,31 +1,95 @@ +# typed: strict # frozen_string_literal: true require "utils/user" -require "yaml" require "open3" -require "stringio" - -BUG_REPORTS_URL = "https://github.com/Homebrew/homebrew-cask#reporting-bugs" +require "utils/output" module Cask + # Helper functions for various cask operations. module Utils + extend ::Utils::Output::Mixin + + BUG_REPORTS_URL = "https://github.com/Homebrew/homebrew-cask#reporting-bugs" + FULL_DISK_ACCESS_TCC_PATH = "~/Library/Application Support/com.apple.TCC" + + sig { params(access: String).returns(String) } + def self.privacy_security_preference_pane(access) + navigation_path = if MacOS.version >= :ventura + "System Settings → Privacy & Security" + else + "System Preferences → Security & Privacy → Privacy" + end + + "#{navigation_path} → #{access}" + end + + sig { returns(T::Boolean) } + def self.full_disk_access_enabled? + File.readable?(File.expand_path(FULL_DISK_ACCESS_TCC_PATH)) + end + + sig { params(path: Pathname, command: T.class_of(SystemCommand)).void } + def self.gain_permissions_mkpath(path, command: SystemCommand) + dir = path.ascend.find(&:directory?) + return if path == dir + + if dir&.writable? + path.mkpath + else + command.run!("mkdir", args: ["-p", "--", path], sudo: true, print_stderr: false) + end + end + + sig { params(path: Pathname, command: T.class_of(SystemCommand)).void } + def self.gain_permissions_rmdir(path, command: SystemCommand) + gain_permissions(path, [], command) do |p| + if p.parent.writable? + FileUtils.rmdir p + else + command.run!("rmdir", args: ["--", p], sudo: true, print_stderr: false) + end + end + end + + sig { params(path: Pathname, command: T.class_of(SystemCommand)).void } def self.gain_permissions_remove(path, command: SystemCommand) - if path.respond_to?(:rmtree) && path.exist? - gain_permissions(path, ["-R"], command) do |p| - if p.parent.writable? - p.rmtree + directory = false + permission_flags = if path.symlink? + ["-h"] + elsif path.directory? + directory = true + ["-R"] + elsif path.exist? + [] + else + # Nothing to remove. + return + end + + gain_permissions(path, permission_flags, command) do |p| + if p.parent.writable? + if directory + FileUtils.rm_r p else - command.run("/bin/rm", - args: ["-r", "-f", "--", p], - sudo: true) + FileUtils.rm_f p end + else + recursive_flag = directory ? ["-R"] : [] + command.run!("/bin/rm", args: recursive_flag + ["-f", "--", p], sudo: true, print_stderr: false) end - elsif File.symlink?(path) - gain_permissions(path, ["-h"], command, &FileUtils.method(:rm_f)) end end - def self.gain_permissions(path, command_args, command) + sig { + params( + path: Pathname, + command_args: T::Array[String], + command: T.class_of(SystemCommand), + _block: T.proc.params(path: Pathname).void, + ).void + } + def self.gain_permissions(path, command_args, command, &_block) tried_permissions = false tried_ownership = false begin @@ -33,18 +97,19 @@ def self.gain_permissions(path, command_args, command) rescue # in case of permissions problems unless tried_permissions + print_stderr = Context.current.debug? || Context.current.verbose? # TODO: Better handling for the case where path is a symlink. - # The -h and -R flags cannot be combined, and behavior is + # The `-h` and `-R` flags cannot be combined and behavior is # dependent on whether the file argument has a trailing - # slash. This should do the right thing, but is fragile. + # slash. This should do the right thing, but is fragile. command.run("/usr/bin/chflags", - must_succeed: false, + print_stderr:, args: command_args + ["--", "000", path]) - command.run("/bin/chmod", - must_succeed: false, + command.run("chmod", + print_stderr:, args: command_args + ["--", "u+rwx", path]) - command.run("/bin/chmod", - must_succeed: false, + command.run("chmod", + print_stderr:, args: command_args + ["-N", path]) tried_permissions = true retry # rmtree @@ -53,10 +118,10 @@ def self.gain_permissions(path, command_args, command) unless tried_ownership # in case of ownership problems # TODO: Further examine files to see if ownership is the problem - # before using sudo+chown + # before using `sudo` and `chown`. ohai "Using sudo to gain ownership of path '#{path}'" - command.run("/usr/sbin/chown", - args: command_args + ["--", User.current, path], + command.run("chown", + args: command_args + ["--", User.current.to_s, path], sudo: true) tried_ownership = true # retry chflags/chmod after chown @@ -68,24 +133,20 @@ def self.gain_permissions(path, command_args, command) end end + sig { params(path: Pathname).returns(T::Boolean) } def self.path_occupied?(path) - File.exist?(path) || File.symlink?(path) + path.exist? || path.symlink? end - def self.error_message_with_suggestions - <<~EOS - Follow the instructions here: - #{Formatter.url(BUG_REPORTS_URL)} - EOS - end - - def self.method_missing_message(method, token, section = nil) - poo = [] - poo << "Unexpected method '#{method}' called" - poo << "during #{section}" if section - poo << "on Cask #{token}." - - opoo(poo.join(" ") + "\n" + error_message_with_suggestions) + sig { params(name: String).returns(String) } + def self.token_from(name) + name.downcase + .gsub("+", "-plus-") + .gsub(/[ _·•]/, "-") + .gsub(/[^\w@-]/, "") + .gsub(/--+/, "-") + .delete_prefix("-") + .delete_suffix("-") end end end diff --git a/Library/Homebrew/cask/utils/copy-xattrs.swift b/Library/Homebrew/cask/utils/copy-xattrs.swift new file mode 100755 index 0000000000000..794242ed13974 --- /dev/null +++ b/Library/Homebrew/cask/utils/copy-xattrs.swift @@ -0,0 +1,80 @@ +#!/usr/bin/swift + +import Foundation + +struct SwiftErr: TextOutputStream { + public static var stream = SwiftErr() + + mutating func write(_ string: String) { + fputs(string, stderr) + } +} + +guard CommandLine.arguments.count >= 3 else { + print("Usage: swift copy-xattrs.swift ") + exit(2) +} + +CommandLine.arguments[2].withCString { destinationPath in + let destinationNamesLength = listxattr(destinationPath, nil, 0, 0) + if destinationNamesLength == -1 { + print("listxattr for destination failed: \(errno)", to: &SwiftErr.stream) + exit(1) + } + let destinationNamesBuffer = UnsafeMutablePointer.allocate(capacity: destinationNamesLength) + if listxattr(destinationPath, destinationNamesBuffer, destinationNamesLength, 0) != destinationNamesLength { + print("Attributes changed during system call", to: &SwiftErr.stream) + exit(1) + } + + var destinationNamesIndex = 0 + while destinationNamesIndex < destinationNamesLength { + let attribute = destinationNamesBuffer + destinationNamesIndex + + if removexattr(destinationPath, attribute, 0) != 0 { + print("removexattr for \(String(cString: attribute)) failed: \(errno)", to: &SwiftErr.stream) + exit(1) + } + + destinationNamesIndex += strlen(attribute) + 1 + } + destinationNamesBuffer.deallocate() + + CommandLine.arguments[1].withCString { sourcePath in + let sourceNamesLength = listxattr(sourcePath, nil, 0, 0) + if sourceNamesLength == -1 { + print("listxattr for source failed: \(errno)", to: &SwiftErr.stream) + exit(1) + } + let sourceNamesBuffer = UnsafeMutablePointer.allocate(capacity: sourceNamesLength) + if listxattr(sourcePath, sourceNamesBuffer, sourceNamesLength, 0) != sourceNamesLength { + print("Attributes changed during system call", to: &SwiftErr.stream) + exit(1) + } + + var sourceNamesIndex = 0 + while sourceNamesIndex < sourceNamesLength { + let attribute = sourceNamesBuffer + sourceNamesIndex + + let valueLength = getxattr(sourcePath, attribute, nil, 0, 0, 0) + if valueLength == -1 { + print("getxattr for \(String(cString: attribute)) failed: \(errno)", to: &SwiftErr.stream) + exit(1) + } + let valueBuffer = UnsafeMutablePointer.allocate(capacity: valueLength) + if getxattr(sourcePath, attribute, valueBuffer, valueLength, 0, 0) != valueLength { + print("Attributes changed during system call", to: &SwiftErr.stream) + exit(1) + } + + if setxattr(destinationPath, attribute, valueBuffer, valueLength, 0, 0) != 0 { + print("setxattr for \(String(cString: attribute)) failed: \(errno)", to: &SwiftErr.stream) + exit(1) + } + + valueBuffer.deallocate() + sourceNamesIndex += strlen(attribute) + 1 + } + sourceNamesBuffer.deallocate() + } +} diff --git a/Library/Homebrew/cask/utils/quarantine.swift b/Library/Homebrew/cask/utils/quarantine.swift index 603393589f3dd..55c6721ef11ee 100755 --- a/Library/Homebrew/cask/utils/quarantine.swift +++ b/Library/Homebrew/cask/utils/quarantine.swift @@ -2,46 +2,46 @@ import Foundation -struct swifterr: TextOutputStream { - public static var stream = swifterr() - mutating func write(_ string: String) { fputs(string, stderr) } +struct SwiftErr: TextOutputStream { + public static var stream = SwiftErr() + + mutating func write(_ string: String) { + fputs(string, stderr) + } } -if #available(macOS 10.10, *) { - if (CommandLine.arguments.count < 4) { +guard CommandLine.arguments.count >= 4 else { + print("Usage: swift quarantine.swift ") exit(2) - } - - let dataLocationUrl: NSURL = NSURL.init(fileURLWithPath: CommandLine.arguments[1]) +} - var errorBag: NSError? +var dataLocationURL = URL(fileURLWithPath: CommandLine.arguments[1]) - let quarantineProperties: [String: Any] = [ +let quarantineProperties: [String: Any] = [ kLSQuarantineAgentNameKey as String: "Homebrew Cask", kLSQuarantineTypeKey as String: kLSQuarantineTypeWebDownload, kLSQuarantineDataURLKey as String: CommandLine.arguments[2], kLSQuarantineOriginURLKey as String: CommandLine.arguments[3] - ] - - if (dataLocationUrl.checkResourceIsReachableAndReturnError(&errorBag)) { - do { - try dataLocationUrl.setResourceValue( - quarantineProperties as NSDictionary, - forKey: URLResourceKey.quarantinePropertiesKey - ) +] + +// Check for if the data location URL is reachable +do { + let isDataLocationURLReachable = try dataLocationURL.checkResourceIsReachable() + guard isDataLocationURLReachable else { + print("URL \(dataLocationURL.path) is not reachable. Not proceeding.", to: &SwiftErr.stream) + exit(1) } - catch { - print(error.localizedDescription, to: &swifterr.stream) - exit(1) - } - } - else { - print(errorBag!.localizedDescription, to: &swifterr.stream) - exit(3) - } - - exit(0) +} catch { + print(error.localizedDescription, to: &SwiftErr.stream) + exit(1) } -else { - exit(5) + +// Quarantine the file +do { + var resourceValues = URLResourceValues() + resourceValues.quarantineProperties = quarantineProperties + try dataLocationURL.setResourceValues(resourceValues) +} catch { + print(error.localizedDescription, to: &SwiftErr.stream) + exit(1) } diff --git a/Library/Homebrew/cask/utils/rmdir.sh b/Library/Homebrew/cask/utils/rmdir.sh new file mode 100755 index 0000000000000..cf47e6b3b7dcf --- /dev/null +++ b/Library/Homebrew/cask/utils/rmdir.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +# Try removing as many empty directories as possible with a single +# `rmdir` call to avoid or at least speed up the loop below. +if rmdir -- "${@}" &>/dev/null +then + exit +fi + +for path in "${@}" +do + symlink=true + [[ -L "${path}" ]] || symlink=false + + directory=false + if [[ -d "${path}" ]] + then + directory=true + + if [[ -e "${path}/.DS_Store" ]] + then + /bin/rm -- "${path}/.DS_Store" + fi + + # Some packages leave broken symlinks around; we clean them out before + # attempting to `rmdir` to prevent extra cruft from accumulating. + /usr/bin/find -f "${path}" -- -mindepth 1 -maxdepth 1 -type l ! -exec test -e {} \; -delete || true + elif ! ${symlink} && [[ ! -e "${path}" ]] + then + # Skip paths that don't exists and aren't a broken symlink. + continue + fi + + if ${symlink} + then + # Delete directory symlink. + /bin/rm -- "${path}" + elif ${directory} + then + # Delete directory if empty. + /usr/bin/find -f "${path}" -- -maxdepth 0 -type d -empty -exec rmdir -- {} \; + else + # Try `rmdir` anyways to show a proper error. + rmdir -- "${path}" + fi +done diff --git a/Library/Homebrew/cask/utils/trash.rb b/Library/Homebrew/cask/utils/trash.rb new file mode 100644 index 0000000000000..a80a858500e33 --- /dev/null +++ b/Library/Homebrew/cask/utils/trash.rb @@ -0,0 +1,122 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/utils" +require "fileutils" +require "system_command" +require "uri" + +module Cask + module Utils + module Trash + extend SystemCommand::Mixin + + sig { + params(paths: Pathname, command: T.nilable(T.class_of(SystemCommand))) + .returns([T::Array[String], T::Array[String]]) + } + def self.trash(*paths, command: nil) + swift_trash(*paths, command:) + end + + sig { + params(paths: Pathname, command: T.nilable(T.class_of(SystemCommand))) + .returns([T::Array[String], T::Array[String]]) + } + def self.swift_trash(*paths, command: nil) + return [[], []] if paths.empty? + + stdout = system_command(HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", + args: paths, + print_stderr: Homebrew::EnvConfig.developer?).stdout + + trashed, _, untrashable = stdout.partition("\n") + trashed = trashed.split(":") + untrashable = untrashable.split(":") + + trashed_with_permissions, untrashable = untrashable.partition do |path| + Utils.gain_permissions(Pathname(path), ["-R"], SystemCommand) do + system_command! HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", + args: [path], + print_stderr: Homebrew::EnvConfig.developer? + end + + true + rescue + false + end + + [trashed + trashed_with_permissions, untrashable] + end + + sig { params(paths: Pathname).returns([T::Array[String], T::Array[String]]) } + def self.freedesktop_trash(*paths) + return [[], []] if paths.empty? + + files_path = home_trash_path/"files" + info_path = home_trash_path/"info" + + files_path.mkpath + info_path.mkpath + + trashed, untrashable = paths.partition do |path| + trash_path(path, files_path:, info_path:) + true + rescue + false + end + + [trashed.map(&:to_s), untrashable.map(&:to_s)] + end + + sig { returns(Pathname) } + def self.home_trash_path + Pathname.new(ENV["XDG_DATA_HOME"].presence || "#{Dir.home}/.local/share")/"Trash" + end + private_class_method :home_trash_path + + sig { params(path: Pathname, files_path: Pathname, info_path: Pathname).void } + def self.trash_path(path, files_path:, info_path:) + basename = path.basename.to_s + deletion_date = Time.now.strftime("%Y-%m-%dT%H:%M:%S") + suffix = 0 + + Kernel.loop do + candidate = suffix.zero? ? basename : "#{basename}.#{suffix}" + target_path = files_path/candidate + target_info_path = info_path/"#{candidate}.trashinfo" + + if target_path.exist? || target_path.symlink? + suffix += 1 + next + end + + begin + File.open(target_info_path, File::WRONLY | File::CREAT | File::EXCL, 0600) do |file| + file.write <<~EOS + [Trash Info] + Path=#{URI::DEFAULT_PARSER.escape(path.to_s)} + DeletionDate=#{deletion_date} + EOS + end + rescue Errno::EEXIST + suffix += 1 + next + end + + begin + FileUtils.mv(path, target_path) + rescue + target_info_path.delete if target_info_path.exist? + Kernel.raise + end + + return + end + end + private_class_method :swift_trash, :freedesktop_trash, :trash_path + end + end +end + +require "extend/os/cask/utils/trash" diff --git a/Library/Homebrew/cask/utils/trash.swift b/Library/Homebrew/cask/utils/trash.swift index a55c0dacd93f5..cd380aeb1f6fd 100755 --- a/Library/Homebrew/cask/utils/trash.swift +++ b/Library/Homebrew/cask/utils/trash.swift @@ -2,30 +2,31 @@ import Foundation -extension FileHandle : TextOutputStream { - public func write(_ string: String) { - self.write(string.data(using: .utf8)!) - } -} - -var stderr = FileHandle.standardError - -let manager: FileManager = FileManager() +let manager = FileManager.default var success = true -for item in CommandLine.arguments[1...] { - do { - let path: URL = URL(fileURLWithPath: item) - var trashedPath: NSURL! - try manager.trashItem(at: path, resultingItemURL: &trashedPath) - print((trashedPath as URL).path, terminator: ":") - } catch { - print(item, terminator: ":", to: &stderr) - success = false - } +// The command line arguments given but without the script's name +let CMDLineArgs = Array(CommandLine.arguments.dropFirst()) + +var trashed: [String] = [] +var untrashable: [String] = [] +for item in CMDLineArgs { + do { + let url = URL(fileURLWithPath: item) + var trashedPath: NSURL! + try manager.trashItem(at: url, resultingItemURL: &trashedPath) + trashed.append((trashedPath as URL).path) + success = true + } catch { + untrashable.append(item) + success = false + } } +print(trashed.joined(separator: ":")) +print(untrashable.joined(separator: ":"), terminator: "") + guard success else { - exit(1) + exit(1) } diff --git a/Library/Homebrew/cask/verify.rb b/Library/Homebrew/cask/verify.rb deleted file mode 100644 index ca0c0ddeeaf6c..0000000000000 --- a/Library/Homebrew/cask/verify.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Cask - module Verify - module_function - - def all(cask, downloaded_path) - if cask.sha256 == :no_check - ohai "No SHA-256 checksum defined for Cask '#{cask}', skipping verification." - return - end - - ohai "Verifying SHA-256 checksum for Cask '#{cask}'." - - expected = cask.sha256 - computed = downloaded_path.sha256 - - raise CaskSha256MissingError.new(cask.token, expected, computed) if expected.nil? || expected.empty? - - return if expected == computed - - ohai "Note: Running `brew update` may fix SHA-256 checksum errors." - raise CaskSha256MismatchError.new(cask.token, expected, computed, downloaded_path) - end - end -end diff --git a/Library/Homebrew/cask_dependent.rb b/Library/Homebrew/cask_dependent.rb new file mode 100644 index 0000000000000..a960ceca2d4c1 --- /dev/null +++ b/Library/Homebrew/cask_dependent.rb @@ -0,0 +1,116 @@ +# typed: strict +# frozen_string_literal: true + +require "requirement" + +# An adapter for casks to provide dependency information in a formula-like interface. +class CaskDependent + # Defines a dependency on another cask + class Requirement < ::Requirement + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[String, T.untyped] } } + # rubocop:enable Style/MutableConstant + + satisfy(build_env: false) do + cask_token = cask + raise "unexpected nil cask" unless cask_token + + Cask::CaskLoader.load(cask_token).installed? + end + end + + sig { returns(Cask::Cask) } + attr_reader :cask + + sig { params(cask: Cask::Cask).void } + def initialize(cask) + @cask = cask + end + + sig { returns(String) } + def name + @cask.token + end + + sig { returns(String) } + def full_name + @cask.full_name + end + + sig { params(read_from_tab: T::Boolean, undeclared: T::Boolean).returns(T::Array[Dependency]) } + def runtime_dependencies(read_from_tab: true, undeclared: true) + deps.flat_map do |dep| + [dep, *dep.to_installed_formula.runtime_dependencies(read_from_tab:, undeclared:)] + end.uniq + end + + sig { returns(T::Array[Dependency]) } + def deps + @deps ||= T.let( + @cask.depends_on.formula.map do |f| + Dependency.new f + end, + T.nilable(T::Array[Dependency]), + ) + end + + sig { returns(T::Array[::Requirement]) } + def requirements + @requirements ||= T.let( + begin + requirements = [] + dsl_reqs = @cask.depends_on + + dsl_reqs.arch&.each do |arch| + arch = if arch[:bits] == 64 + if arch[:type] == :intel + :x86_64 + else + :"#{arch[:type]}64" + end + elsif arch[:type] == :intel && arch[:bits] == 32 + :i386 + else + arch[:type] + end + requirements << ArchRequirement.new([arch]) + end + dsl_reqs.cask.each do |cask_ref| + requirements << CaskDependent::Requirement.new([{ cask: cask_ref }]) + end + requirements << dsl_reqs.linux if dsl_reqs.linux + requirements << dsl_reqs.macos if dsl_reqs.macos + requirements << dsl_reqs.maximum_macos if dsl_reqs.maximum_macos + + requirements + end, + T.nilable(T::Array[::Requirement]), + ) + end + + sig { + params( + block: T.nilable(T.proc.params(arg0: T.any(Formula, CaskDependent, SoftwareSpec), + arg1: ::Dependency).returns(T.nilable(Symbol))), + ).returns(T::Array[::Dependency]) + } + def recursive_dependencies(&block) + Dependency.expand(self, &block) + end + + sig { + params( + block: T.nilable(T.proc.params(arg0: T.any(Formula, CaskDependent, SoftwareSpec), + arg1: ::Requirement).returns(T.nilable(Symbol))), + ).returns(Requirements) + } + def recursive_requirements(&block) + Requirement.expand(self, &block) + end + + sig { returns(T::Boolean) } + def any_version_installed? + @cask.installed? + end +end diff --git a/Library/Homebrew/caveats.rb b/Library/Homebrew/caveats.rb index 1b05d33ca9fd1..e855ad8790375 100644 --- a/Library/Homebrew/caveats.rb +++ b/Library/Homebrew/caveats.rb @@ -1,110 +1,234 @@ +# typed: strict # frozen_string_literal: true -require "language/python" +require "utils/service" +# A formula's caveats. class Caveats extend Forwardable - attr_reader :f + sig { returns(Formula) } + attr_reader :formula - def initialize(f) - @f = f + sig { params(formula: Formula).void } + def initialize(formula) + @formula = formula + @caveats = T.let(nil, T.nilable(String)) + @completions_and_elisp = T.let(nil, T.nilable(T::Array[String])) end + sig { returns(String) } def caveats - caveats = [] - begin - build = f.build - f.build = Tab.for_formula(f) - s = f.caveats.to_s - caveats << s.chomp + "\n" unless s.empty? - ensure - f.build = build + @caveats ||= begin + caveats = [] + build = formula.build + begin + formula.build = Tab.for_formula(formula) + string = formula.caveats.to_s + caveats << "#{string.chomp}\n" unless string.empty? + ensure + formula.build = build + end + caveats << keg_only_text + caveats << shadowed_path_text + caveats << service_caveats + caveats.compact.join("\n") end - caveats << keg_only_text - caveats << function_completion_caveats(:bash) - caveats << function_completion_caveats(:zsh) - caveats << function_completion_caveats(:fish) - caveats << plist_caveats - caveats << elisp_caveats - caveats.compact.join("\n") end - delegate [:empty?, :to_s] => :caveats + sig { returns(T::Boolean) } + def empty? + caveats.blank? && completions_and_elisp.blank? + end + + delegate [:to_s] => :caveats + + sig { returns(T::Array[String]) } + def completions_and_elisp + @completions_and_elisp ||= begin + valid_shells = [:bash, :zsh, :fish, :pwsh].freeze + current_shell = Utils::Shell.preferred || Utils::Shell.parent + shells = if current_shell.present? && + (shell_sym = current_shell.to_sym) && + valid_shells.include?(shell_sym) + [shell_sym] + else + valid_shells + end + completions_and_elisp = shells.map do |shell| + function_completion_caveats(shell) + end + completions_and_elisp << elisp_caveats + completions_and_elisp.compact + end + end + sig { params(skip_reason: T::Boolean).returns(T.nilable(String)) } def keg_only_text(skip_reason: false) - return unless f.keg_only? + return unless formula.keg_only? + return if formula.linked? s = if skip_reason "" else <<~EOS - #{f.name} is keg-only, which means it was not symlinked into #{HOMEBREW_PREFIX}, - because #{f.keg_only_reason.to_s.chomp}. + #{formula.name} is keg-only, which means it was not symlinked into #{HOMEBREW_PREFIX}, + because #{formula.keg_only_reason.to_s.chomp}. EOS end.dup - if f.bin.directory? || f.sbin.directory? + if formula.bin.directory? || formula.sbin.directory? s << <<~EOS - If you need to have #{f.name} first in your PATH run: + If you need to have #{formula.name} first in your PATH, run: EOS - s << " #{Utils::Shell.prepend_path_in_profile(f.opt_bin.to_s)}\n" if f.bin.directory? - s << " #{Utils::Shell.prepend_path_in_profile(f.opt_sbin.to_s)}\n" if f.sbin.directory? + s << " #{Utils::Shell.prepend_path_in_profile(formula.opt_bin.to_s)}\n" if formula.bin.directory? + s << " #{Utils::Shell.prepend_path_in_profile(formula.opt_sbin.to_s)}\n" if formula.sbin.directory? end - if f.lib.directory? || f.include.directory? + if formula.lib.directory? || formula.include.directory? s << <<~EOS - For compilers to find #{f.name} you may need to set: + For compilers to find #{formula.name} you may need to set: EOS - s << " #{Utils::Shell.export_value("LDFLAGS", "-L#{f.opt_lib}")}\n" if f.lib.directory? + s << " #{Utils::Shell.export_value("LDFLAGS", "-L#{formula.opt_lib}")}\n" if formula.lib.directory? - s << " #{Utils::Shell.export_value("CPPFLAGS", "-I#{f.opt_include}")}\n" if f.include.directory? + s << " #{Utils::Shell.export_value("CPPFLAGS", "-I#{formula.opt_include}")}\n" if formula.include.directory? - if which("pkg-config", ENV["HOMEBREW_PATH"]) && - ((f.lib/"pkgconfig").directory? || (f.share/"pkgconfig").directory?) + if which("pkgconf", ORIGINAL_PATHS) && + ((formula.lib/"pkgconfig").directory? || (formula.share/"pkgconfig").directory?) s << <<~EOS - For pkg-config to find #{f.name} you may need to set: + For pkgconf to find #{formula.name} you may need to set: EOS - if (f.lib/"pkgconfig").directory? - s << " #{Utils::Shell.export_value("PKG_CONFIG_PATH", "#{f.opt_lib}/pkgconfig")}\n" + if (formula.lib/"pkgconfig").directory? + s << " #{Utils::Shell.export_value("PKG_CONFIG_PATH", "#{formula.opt_lib}/pkgconfig")}\n" end - if (f.share/"pkgconfig").directory? - s << " #{Utils::Shell.export_value("PKG_CONFIG_PATH", "#{f.opt_share}/pkgconfig")}\n" + if (formula.share/"pkgconfig").directory? + s << " #{Utils::Shell.export_value("PKG_CONFIG_PATH", "#{formula.opt_share}/pkgconfig")}\n" end end + + if which("cmake", ORIGINAL_PATHS) && + ((formula.lib/"cmake").directory? || (formula.share/"cmake").directory?) + s << <<~EOS + + For cmake to find #{formula.name} you may need to set: + #{Utils::Shell.export_value("CMAKE_PREFIX_PATH", formula.opt_prefix.to_s)} + EOS + end + end + s << "\n" unless s.end_with?("\n") + s + end + + sig { returns(T.nilable(String)) } + def shadowed_path_text + return if Homebrew::EnvConfig.no_path_shadow_check? + return unless formula.any_version_installed? + + shadowed = shadowed_executables + shadowed = shadowed.select { |_, shadower| sibling_keg_name(shadower) } if formula.keg_only? && !formula.linked? + return if shadowed.empty? + + sibling, external = shadowed.sort_by(&:first).partition { |_, shadower| sibling_keg_name(shadower) } + blocks = [] + + if external.any? + lines = external.map { |name, shadower| " #{name} (shadowed by #{shadower})" } + blocks << <<~EOS + The following #{formula.name} executables are shadowed by other commands earlier in your PATH: + #{lines.join("\n")} + Running these by name will not invoke the version provided by Homebrew. + EOS + end + + if sibling.any? + lines = sibling.map do |name, shadower| + " #{name} (shadowed by #{shadower} from #{sibling_keg_name(shadower)})" + end + blocks << <<~EOS + The following #{formula.name} executables are shadowed by other linked Homebrew commands: + #{lines.join("\n")} + Running these by name will not invoke the version provided by this formula. + Run `brew link #{formula.name}` to switch the active version to this keg. + EOS end - s << "\n" + + s = blocks.join("\n").dup + unless Homebrew::EnvConfig.no_env_hints? + s << "Disable this behaviour by setting `HOMEBREW_NO_PATH_SHADOW_CHECK=1`.\n" + s << "Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).\n" + end + s end private + sig { params(shadower: Pathname).returns(T.nilable(String)) } + def sibling_keg_name(shadower) + target = shadower.realpath + return unless target.to_s.start_with?("#{HOMEBREW_CELLAR.realpath}/") + + name = target.relative_path_from(HOMEBREW_CELLAR.realpath).each_filename.first + return if name.nil? || name == formula.name + + family = [ + formula.unversioned_formula_name, + formula.name, + *formula.versioned_formulae_names, + ].compact + name if family.include?(name) + rescue Errno::ENOENT + nil + end + + sig { returns(T::Array[[String, Pathname]]) } + def shadowed_executables + [formula.opt_bin, formula.opt_sbin].flat_map do |dir| + next [] unless dir.directory? + + dir.children.filter_map do |child| + next if !child.file? || !child.executable? + + name = child.basename.to_s + found = which(name, ORIGINAL_PATHS) + next unless found + next if found.realpath == child.realpath + + [name, found] + rescue Errno::ENOENT + nil + end + end + end + + sig { returns(T.nilable(Keg)) } def keg - @keg ||= [f.prefix, f.opt_prefix, f.linked_keg].map do |d| + @keg ||= T.let([formula.prefix, formula.opt_prefix, formula.linked_keg].filter_map do |d| Keg.new(d.resolved_path) rescue nil - end.compact.first + end.first, T.nilable(Keg)) end + sig { params(shell: Symbol).returns(T.nilable(String)) } def function_completion_caveats(shell) - return unless keg - return unless which(shell.to_s, ENV["HOMEBREW_PATH"]) + return unless (keg = self.keg) + return unless which(shell.to_s, ORIGINAL_PATHS) completion_installed = keg.completion_installed?(shell) functions_installed = keg.functions_installed?(shell) - return unless completion_installed || functions_installed + return if !completion_installed && !functions_installed installed = [] installed << "completions" if completion_installed installed << "functions" if functions_installed - root_dir = f.keg_only? ? f.opt_prefix : HOMEBREW_PREFIX + root_dir = formula.keg_only? ? formula.opt_prefix : HOMEBREW_PREFIX case shell when :bash @@ -112,57 +236,73 @@ def function_completion_caveats(shell) Bash completion has been installed to: #{root_dir}/etc/bash_completion.d EOS + when :fish + fish_caveats = "fish #{installed.join(" and ")} have been installed to:" + fish_caveats << "\n #{root_dir}/share/fish/vendor_completions.d" if completion_installed + fish_caveats << "\n #{root_dir}/share/fish/vendor_functions.d" if functions_installed + fish_caveats.freeze when :zsh <<~EOS zsh #{installed.join(" and ")} have been installed to: #{root_dir}/share/zsh/site-functions EOS - when :fish - fish_caveats = +"fish #{installed.join(" and ")} have been installed to:" - fish_caveats << "\n #{root_dir}/share/fish/vendor_completions.d" if completion_installed - fish_caveats << "\n #{root_dir}/share/fish/vendor_functions.d" if functions_installed - fish_caveats.freeze + when :pwsh + <<~EOS + PowerShell completion has been installed to: + #{root_dir}/share/pwsh/completions + EOS end end + sig { returns(T.nilable(String)) } def elisp_caveats - return if f.keg_only? - return unless keg + return if formula.keg_only? + return unless (keg = self.keg) return unless keg.elisp_installed? <<~EOS Emacs Lisp files have been installed to: - #{HOMEBREW_PREFIX}/share/emacs/site-lisp/#{f.name} + #{HOMEBREW_PREFIX}/share/emacs/site-lisp/#{formula.name} EOS end - def plist_caveats - return unless f.plist_manual + sig { returns(T.nilable(String)) } + def service_caveats + return if !formula.service? && !Utils::Service.installed?(formula) && !keg&.plist_installed? + return if formula.service? && !formula.service.command? && !Utils::Service.installed?(formula) - # Default to brew services not being supported. macOS overrides this behavior. - <<~EOS - #{Formatter.warning("Warning:")} #{f.name} provides a launchd plist which can only be used on macOS! + s = [] + + # Brew services only works with these two tools + return <<~EOS if !Utils::Service.systemctl? && !Utils::Service.launchctl? && formula.service.command? + #{Formatter.warning("Warning:")} #{formula.name} provides a service which can only be used on macOS or systemd! You can manually execute the service instead with: - #{f.plist_manual} + #{formula.service.manual_command} EOS - end - def plist_path - destination = if f.plist_startup - "/Library/LaunchDaemons" + startup = formula.service.requires_root? + if Utils::Service.running?(formula) + s << "To restart #{formula.full_name} after an upgrade:" + s << " #{"sudo " if startup}brew services restart #{formula.full_name}" + elsif startup + s << "To start #{formula.full_name} now and restart at startup:" + s << " sudo brew services start #{formula.full_name}" else - "~/Library/LaunchAgents" + s << "To start #{formula.full_name} now and restart at login:" + s << " brew services start #{formula.full_name}" end - plist_filename = if f.plist - f.plist_path.basename - else - File.basename Dir["#{keg}/*.plist"].first + if formula.service.command? + s << "Or, if you don't want/need a background service you can just run:" + s << " #{formula.service.manual_command}" end - destination_path = Pathname.new(File.expand_path(destination)) - destination_path/plist_filename + # pbpaste is the system clipboard tool on macOS and fails with `tmux` by default + # check if this is being run under `tmux` to avoid failing + if ENV["HOMEBREW_TMUX"] && !quiet_system("/usr/bin/pbpaste") + s << "" << "WARNING: brew services will fail when run under tmux." + end + + "#{s.join("\n")}\n" unless s.empty? end end - -require "extend/os/caveats" diff --git a/Library/Homebrew/checksum.rb b/Library/Homebrew/checksum.rb index fe0960df4ac27..134207e1b6870 100644 --- a/Library/Homebrew/checksum.rb +++ b/Library/Homebrew/checksum.rb @@ -1,20 +1,34 @@ +# typed: strict # frozen_string_literal: true +# A formula's checksum. class Checksum extend Forwardable - attr_reader :hash_type, :hexdigest + sig { returns(String) } + attr_reader :hexdigest - TYPES = [:sha256].freeze + sig { params(hexdigest: String).void } + def initialize(hexdigest) + @hexdigest = T.let(hexdigest.downcase, String) + end - def initialize(hash_type, hexdigest) - @hash_type = hash_type - @hexdigest = hexdigest + sig { returns(String) } + def inspect + "#" end - delegate [:empty?, :to_s] => :@hexdigest + delegate [:empty?, :to_s, :length, :[]] => :@hexdigest + sig { params(other: T.anything).returns(T::Boolean) } def ==(other) - hash_type == other&.hash_type && hexdigest == other.hexdigest + case other + when String + to_s == other.downcase + when Checksum + hexdigest == other.hexdigest + else + false + end end end diff --git a/Library/Homebrew/cleaner.rb b/Library/Homebrew/cleaner.rb index 9ff56ce26ce77..4ae397036205b 100644 --- a/Library/Homebrew/cleaner.rb +++ b/Library/Homebrew/cleaner.rb @@ -1,39 +1,76 @@ +# typed: strict # frozen_string_literal: true +require "utils/output" + # Cleans a newly installed keg. # By default: # # * removes `.la` files +# * removes `.tbd` files # * removes `perllocal.pod` files # * removes `.packlist` files # * removes empty directories # * sets permissions on executables # * removes unresolved symlinks class Cleaner - # Create a cleaner for the given formula - def initialize(f) - @f = f + include Context + include Utils::Output::Mixin + + # Create a cleaner for the given formula. + sig { params(formula: Formula).void } + def initialize(formula) + @formula = formula end - # Clean the keg of formula @f + # Clean the keg of the formula. + sig { void } def clean ObserverPathnameExtension.reset_counts! - # Many formulae include 'lib/charset.alias', but it is not strictly needed - # and will conflict if more than one formula provides it - observe_file_removal @f.lib/"charset.alias" - - [@f.bin, @f.sbin, @f.lib].each { |d| clean_dir(d) if d.exist? } + # Many formulae include `lib/charset.alias`, but it is not strictly needed + # and will conflict if more than one formula provides it. + observe_file_removal @formula.lib/"charset.alias" + + [@formula.bin, @formula.sbin, @formula.lib].each { |dir| clean_dir(dir) if dir.exist? } + + # Get rid of any info `dir` files, so they don't conflict at the link stage. + # + # The `dir` files come in at least 3 locations: + # + # 1. `info/dir` + # 2. `info/#{name}/dir` + # 3. `info/#{arch}/dir` + # + # Of these 3 only `info/#{name}/dir` is safe to keep since the rest will + # conflict with other formulae because they use a shared location. + # + # See + # [cleaner: recursively delete info `dir`s][1], + # [emacs 28.1 bottle does not contain `dir` file][2] and + # [Keep `info/#{f.name}/dir` files in cleaner][3] + # for more info. + # + # [1]: https://github.com/Homebrew/brew/pull/11597 + # [2]: https://github.com/Homebrew/homebrew-core/issues/100190 + # [3]: https://github.com/Homebrew/brew/pull/13215 + @formula.info.glob("**/dir").each do |info_dir_file| + next unless info_dir_file.file? + next if info_dir_file == @formula.info/@formula.name/"dir" + next if @formula.skip_clean?(info_dir_file) + + observe_file_removal info_dir_file + end - # Get rid of any info 'dir' files, so they don't conflict at the link stage - info_dir_file = @f.info + "dir" - observe_file_removal info_dir_file if info_dir_file.file? && !@f.skip_clean?(info_dir_file) + rewrite_shebangs + clean_python_metadata prune end private + sig { params(path: Pathname).void } def observe_file_removal(path) path.extend(ObserverPathnameExtension).unlink if path.exist? end @@ -41,11 +78,12 @@ def observe_file_removal(path) # Removes any empty directories in the formula's prefix subtree # Keeps any empty directories protected by skip_clean # Removes any unresolved symlinks + sig { void } def prune dirs = [] symlinks = [] - @f.prefix.find do |path| - if path == @f.libexec || @f.skip_clean?(path) + @formula.prefix.find do |path| + if path == @formula.libexec || @formula.skip_clean?(path) Find.prune elsif path.symlink? symlinks << path @@ -58,7 +96,7 @@ def prune # actual files gets removed correctly. dirs.reverse_each do |d| if d.children.empty? - puts "rmdir: #{d} (empty)" if ARGV.verbose? + puts "rmdir: #{d} (empty)" if verbose? d.rmdir end end @@ -69,47 +107,48 @@ def prune end end + sig { params(path: Pathname).returns(T::Boolean) } def executable_path?(path) path.text_executable? || path.executable? end - # Clean a top-level (bin, sbin, lib) directory, recursively, by fixing file + # Both these files are completely unnecessary to package and cause + # pointless conflicts with other formulae. They are removed by Debian, + # Arch & MacPorts amongst other packagers as well. The files are + # created as part of installing any Perl module. + PERL_BASENAMES = T.let(Set.new(%w[perllocal.pod .packlist]).freeze, T::Set[String]) + private_constant :PERL_BASENAMES + + # Clean a top-level (`bin`, `sbin`, `lib`) directory, recursively, by fixing file # permissions and removing .la files, unless the files (or parent # directories) are protected by skip_clean. # - # bin and sbin should not have any subdirectories; if either do that is - # caught as an audit warning + # `bin` and `sbin` should not have any subdirectories; if either do that is + # caught as an audit warning. # - # lib may have a large directory tree (see Erlang for instance), and - # clean_dir applies cleaning rules to the entire tree - def clean_dir(d) - d.find do |path| + # `lib` may have a large directory tree (see Erlang for instance) and + # clean_dir applies cleaning rules to the entire tree. + sig { params(directory: Pathname).void } + def clean_dir(directory) + directory.find do |path| path.extend(ObserverPathnameExtension) - Find.prune if @f.skip_clean? path + Find.prune if @formula.skip_clean? path next if path.directory? - if path.extname == ".la" + if path.extname == ".la" || path.extname == ".tbd" || PERL_BASENAMES.include?(path.basename.to_s) path.unlink elsif path.symlink? # Skip it. - elsif path.basename.to_s == "perllocal.pod" - # Both this file & the .packlist one below are completely unnecessary - # to package & causes pointless conflict with other formulae. They are - # removed by Debian, Arch & MacPorts amongst other packagers as well. - # The files are created as part of installing any Perl module. - path.unlink - elsif path.basename.to_s == ".packlist" # Hidden file, not file extension! - path.unlink else - # Set permissions for executables and non-executables + # Set permissions for executables and non-executables. perms = if executable_path?(path) 0555 else 0444 end - if ARGV.debug? + if debug? old_perms = path.stat.mode & 0777 odebug "Fixing #{path} permissions from #{old_perms.to_s(8)} to #{perms.to_s(8)}" if perms != old_perms end @@ -117,6 +156,54 @@ def clean_dir(d) end end end + + sig { void } + def rewrite_shebangs + require "language/node" + require "language/perl" + require "utils/shebang" + + rewrites = [Language::Node::Shebang.method(:detected_node_shebang), + Language::Perl::Shebang.method(:detected_perl_shebang)].filter_map do |detector| + detector.call(@formula) + rescue ShebangDetectionError + nil + end + return if rewrites.empty? + + basepath = @formula.prefix.realpath + basepath.find do |path| + Find.prune if @formula.skip_clean? path + + next if path.directory? || path.symlink? + + rewrites.each { |rw| Utils::Shebang.rewrite_shebang rw, path } + end + end + + # Remove non-reproducible pip direct_url.json which records the /tmp build directory. + # Remove RECORD files to prevent changes to the installed Python package. + # Modify INSTALLER to provide information that files are managed by brew. + # + # @see https://packaging.python.org/en/latest/specifications/recording-installed-packages/ + sig { void } + def clean_python_metadata + basepath = @formula.prefix.realpath + basepath.find do |path| + Find.prune if @formula.skip_clean?(path) + + next if path.directory? || path.symlink? + next if path.parent.extname != ".dist-info" + + case path.basename.to_s + when "direct_url.json", "RECORD" + observe_file_removal path + when "INSTALLER" + odebug "Modifying #{path} contents from #{path.read.chomp} to brew" + path.atomic_write("brew\n") + end + end + end end require "extend/os/cleaner" diff --git a/Library/Homebrew/cleanup.rb b/Library/Homebrew/cleanup.rb index a0decc01b0e6d..2840b14eaeff1 100644 --- a/Library/Homebrew/cleanup.rb +++ b/Library/Homebrew/cleanup.rb @@ -1,179 +1,425 @@ +# typed: strict # frozen_string_literal: true require "utils/bottles" -require "utils/gems" +require "utils/output" +require "installed_dependents" +require "stringio" + require "formula" require "cask/cask_loader" -require "set" - -CLEANUP_DEFAULT_DAYS = 30 -CLEANUP_MAX_AGE_DAYS = 120 -module CleanupRefinement - refine Pathname do - def incomplete? - extname.end_with?(".incomplete") - end +module Homebrew + # Helper class for cleaning up the Homebrew cache. + class Cleanup + extend Utils::Output::Mixin + include Utils::Output::Mixin - def nested_cache? - directory? && %w[cargo_cache go_cache glide_home java_cache npm_cache gclient_cache].include?(basename.to_s) - end + CLEANUP_DEFAULT_DAYS = T.let(Homebrew::EnvConfig.cleanup_periodic_full_days.to_i.freeze, Integer) + GH_ACTIONS_ARTIFACT_CLEANUP_DAYS = 3 + private_constant :CLEANUP_DEFAULT_DAYS, :GH_ACTIONS_ARTIFACT_CLEANUP_DAYS - def go_cache_directory? - # Go makes its cache contents read-only to ensure cache integrity, - # which makes sense but is something we need to undo for cleanup. - directory? && %w[go_cache].include?(basename.to_s) - end + class << self + sig { params(pathname: Pathname).returns(T::Boolean) } + def incomplete?(pathname) + pathname.extname.end_with?(".incomplete") + end - def prune?(days) - return false unless days - return true if days.zero? + sig { params(pathname: Pathname).returns(T::Boolean) } + def nested_cache?(pathname) + pathname.directory? && %w[ + cargo_cache + go_cache + go_mod_cache + glide_home + java_cache + npm_cache + pip_cache + gclient_cache + ].include?(pathname.basename.to_s) + end - return true if symlink? && !exist? + sig { params(pathname: Pathname).returns(T::Boolean) } + def go_cache_directory?(pathname) + # Go makes its cache contents read-only to ensure cache integrity, + # which makes sense but is something we need to undo for cleanup. + pathname.directory? && %w[go_cache go_mod_cache].include?(pathname.basename.to_s) + end - mtime < days.days.ago && ctime < days.days.ago - end + sig { params(pathname: Pathname, days: T.nilable(Integer)).returns(T::Boolean) } + def prune?(pathname, days) + return false unless days + return true if days.zero? + return true if pathname.symlink? && !pathname.exist? - def stale?(scrub = false) - return false unless resolved_path.file? + days_ago = (DateTime.now - days).to_time + pathname.mtime < days_ago && pathname.ctime < days_ago + end - if dirname.basename.to_s == "Cask" - stale_cask?(scrub) - else - stale_formula?(scrub) + sig { params(entry: { path: Pathname, type: T.nilable(Symbol) }, scrub: T::Boolean).returns(T::Boolean) } + def stale?(entry, scrub: false) + pathname = entry[:path] + return false unless pathname.resolved_path.file? + + case entry[:type] + when :api_source + stale_api_source?(pathname, scrub) + when :cask + stale_cask?(pathname, scrub) + when :gh_actions_artifact + scrub || prune?(pathname, GH_ACTIONS_ARTIFACT_CLEANUP_DAYS) + else + stale_formula?(pathname, scrub) + end end - end - private + sig { params(pathname: Pathname, cask: Cask::Cask, name: String).returns(T::Boolean) } + def cask_cache_file_current?(pathname, cask, name) + pathname.basename.to_s.match?(/\A#{Regexp.escape(name)}--#{Regexp.escape(cask.version)}(?:\.|\z)/) + end - def stale_formula?(scrub) - return false unless HOMEBREW_CELLAR.directory? + sig { params(pathname: Pathname, cask: Cask::Cask, name: String, scrub: T::Boolean).returns(T::Boolean) } + def stale_cask_download?(pathname, cask, name, scrub:) + return true unless pathname.exist? + return true unless cask_cache_file_current?(pathname, cask, name) + return true if scrub && cask.installed_version != cask.version - version = if to_s.match?(Pathname::BOTTLE_EXTNAME_RX) - begin - Utils::Bottles.resolve_version(self) - rescue - nil + if cask.version.latest? + cleanup_threshold = (DateTime.now - CLEANUP_DEFAULT_DAYS).to_time + return pathname.mtime < cleanup_threshold && pathname.ctime < cleanup_threshold end + + false end - version ||= basename.to_s[/\A.*(?:\-\-.*?)*\-\-(.*?)#{Regexp.escape(extname)}\Z/, 1] - version ||= basename.to_s[/\A.*\-\-?(.*?)#{Regexp.escape(extname)}\Z/, 1] + private - return false unless version + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_api_source?(pathname, scrub) + return true if scrub - version = Version.new(version) + path_parts = pathname.each_filename.to_a + api_source_index = path_parts.rindex("api-source") + return false if api_source_index.nil? - return false unless formula_name = basename.to_s[/\A(.*?)(?:\-\-.*?)*\-\-?(?:#{Regexp.escape(version)})/, 1] + relative_path_parts = path_parts.drop(api_source_index + 1) + return false if relative_path_parts.length < 4 - formula = begin - Formulary.from_rack(HOMEBREW_CELLAR/formula_name) - rescue FormulaUnavailableError, TapFormulaAmbiguityError, TapFormulaWithOldnameAmbiguityError - return false - end + org = relative_path_parts.fetch(0) + repo = relative_path_parts.fetch(1) + git_head = relative_path_parts.fetch(2) + type = relative_path_parts.fetch(3) + basename = relative_path_parts.fetch(-1) + return false unless basename.end_with?(".rb") - resource_name = basename.to_s[/\A.*?\-\-(.*?)\-\-?(?:#{Regexp.escape(version)})/, 1] + name = "#{org}/#{repo}/#{File.basename(basename, ".rb")}" + package = case type + when "Cask" + begin + Cask::CaskLoader.load(name) + rescue Cask::CaskError + nil + end + when "Formula" + begin + Formulary.factory(name) + rescue FormulaUnavailableError + nil + end + end + return false if package.nil? && %w[Cask Formula].exclude?(type) + return true if package.nil? - if resource_name == "patch" - patch_hashes = formula.stable&.patches&.select(&:external?)&.map(&:resource)&.map(&:version) - return true unless patch_hashes&.include?(Checksum.new(:sha256, version.to_s)) - elsif resource_name && resource_version = formula.stable&.resources&.dig(resource_name)&.version - return true if resource_version != version - elsif version.is_a?(PkgVersion) - return true if formula.pkg_version > version - elsif formula.version > version - return true + package.tap_git_head != git_head end - return true if scrub && !formula.installed? + sig { params(formula: Formula).returns(T::Set[String]) } + def excluded_versions_from_cleanup(formula) + @excluded_versions_from_cleanup ||= T.let({}, T.nilable(T::Hash[String, T::Set[String]])) + @excluded_versions_from_cleanup[formula.name] ||= begin + eligible_kegs_for_cleanup = formula.eligible_kegs_for_cleanup(quiet: true) + Set.new((formula.installed_kegs - eligible_kegs_for_cleanup).map { |keg| keg.version.to_s }) + end + end - return true if Utils::Bottles.file_outdated?(formula, self) + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_formula?(pathname, scrub) + return false unless HOMEBREW_CELLAR.directory? - false - end + version = if HOMEBREW_BOTTLES_EXTNAME_REGEX.match?(to_s) + begin + Utils::Bottles.resolve_version(pathname).to_s + rescue + nil + end + end + basename_str = pathname.basename.to_s - def stale_cask?(scrub) - return false unless name = basename.to_s[/\A(.*?)\-\-/, 1] + version ||= basename_str[/\A.*(?:--.*?)*--(.*?)#{Regexp.escape(pathname.extname)}\Z/, 1] + version ||= basename_str[/\A.*--?(.*?)#{Regexp.escape(pathname.extname)}\Z/, 1] - cask = begin - Cask::CaskLoader.load(name) - rescue Cask::CaskError - return false - end + return false if version.blank? + + version = Version.new(version) - return true unless basename.to_s.match?(/\A#{Regexp.escape(name)}\-\-#{Regexp.escape(cask.version)}\b/) + unless (formula_name = basename_str[/\A(.*?)(?:--.*?)*--?(?:#{Regexp.escape(version.to_s)})/, 1]) + return false + end - return true if scrub && !cask.versions.include?(cask.version) + formula = begin + Formulary.from_rack(HOMEBREW_CELLAR/formula_name) + rescue Homebrew::UntrustedTapError + opoo "Skipping #{formula_name}: tap formula is not trusted" + nil + rescue FormulaUnavailableError, TapFormulaAmbiguityError + nil + end + + formula_excluded_versions_from_cleanup = nil + if formula.blank? && formula_name.delete_suffix!("_bottle_manifest") + formula = begin + Formulary.from_rack(HOMEBREW_CELLAR/formula_name) + rescue Homebrew::UntrustedTapError + opoo "Skipping #{formula_name}: tap formula is not trusted" + nil + rescue FormulaUnavailableError, TapFormulaAmbiguityError + nil + end + + return false if formula.blank? + + formula_excluded_versions_from_cleanup = excluded_versions_from_cleanup(formula) + return false if formula_excluded_versions_from_cleanup.include?(version.to_s) + + if pathname.to_s.include?("_bottle_manifest") + excluded_version = version.to_s + excluded_version.sub!(/-\d+$/, "") + return false if formula_excluded_versions_from_cleanup.include?(excluded_version) + end - if cask.version.latest? - return mtime < CLEANUP_DEFAULT_DAYS.days.ago && - ctime < CLEANUP_DEFAULT_DAYS.days.ago + # We can't determine an installed rebuild and parsing manifest version cannot be reliably done. + return false unless formula.latest_version_installed? + + return true if (bottle = formula.bottle).blank? + + resource_version = bottle.resource.version + return false unless resource_version + + return version != GitHubPackages.version_rebuild(resource_version, bottle.rebuild) + end + + return false if formula.blank? + + resource_name = basename_str[/\A.*?--(.*?)--?(?:#{Regexp.escape(version.to_s)})/, 1] + + stable = formula.stable + if resource_name == "patch" + patch_hashes = stable&.patches&.filter_map { T.cast(it, ExternalPatch).resource.version if it.external? } + return true unless patch_hashes&.include?(Checksum.new(version.to_s)) + elsif resource_name && stable && (resource_version = stable.resources[resource_name]&.version) + return true if resource_version != version + elsif (formula_excluded_versions_from_cleanup ||= excluded_versions_from_cleanup(formula).presence) && + formula_excluded_versions_from_cleanup.include?(version.to_s) + return false + elsif (formula.latest_version_installed? && formula.pkg_version.to_s != version) || + formula.pkg_version.to_s > version + return true + end + + return true if scrub && !formula.latest_version_installed? + return true if Utils::Bottles.file_outdated?(formula, pathname) + + false end - false + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_cask?(pathname, scrub) + basename = pathname.basename + return false unless (name = basename.to_s[/\A(.*?)--/, 1]) + + cask = begin + Cask::CaskLoader.load(name, warn: false) + rescue Cask::CaskError + nil + end + + return false if cask.blank? + + stale_cask_download?(pathname, cask, name, scrub:) + end end - end -end -using CleanupRefinement + PERIODIC_CLEAN_FILE = T.let((HOMEBREW_CACHE/".cleaned").freeze, Pathname) -module Homebrew - class Cleanup - extend Predicable + sig { returns(T::Array[String]) } + attr_reader :args + + sig { returns(Integer) } + attr_reader :days - PERIODIC_CLEAN_FILE = (HOMEBREW_CACHE/".cleaned").freeze + sig { returns(Pathname) } + attr_reader :cache - attr_predicate :dry_run?, :scrub? - attr_reader :args, :days, :cache + sig { returns(Integer) } attr_reader :disk_cleanup_size + sig { + params(args: String, dry_run: T::Boolean, scrub: T::Boolean, days: T.nilable(Integer), cache: Pathname).void + } def initialize(*args, dry_run: false, scrub: false, days: nil, cache: HOMEBREW_CACHE) - @disk_cleanup_size = 0 + @disk_cleanup_size = T.let(0, Integer) @args = args @dry_run = dry_run @scrub = scrub - @days = days || CLEANUP_MAX_AGE_DAYS + @prune = T.let(days.present?, T::Boolean) + @days = T.let(days || Homebrew::EnvConfig.cleanup_max_age_days.to_i, Integer) @cache = cache - @cleaned_up_paths = Set.new + @cleaned_up_paths = T.let(Set.new, T::Set[Pathname]) + end + + sig { returns(T::Boolean) } + def dry_run? = @dry_run + + sig { returns(T::Boolean) } + def prune? = @prune + + sig { returns(T::Boolean) } + def scrub? = @scrub + + sig { params(output: String, ohai: T::Boolean).returns(T::Boolean) } + def self.printed_dry_run_output?(output, ohai: false) + return false if output.blank? + + if ohai + ohai "Would `brew cleanup`" + else + puts "Would `brew cleanup`:" + end + print output + puts unless output.end_with?("\n") + true + end + + sig { params(args: String, formulae: T::Array[Formula]).returns(String) } + def self.dry_run_output(*args, formulae: []) + output = StringIO.new + old_stdout = $stdout + begin + $stdout = output + cleanup = Cleanup.new(*args, dry_run: true) + if formulae.empty? + cleanup.clean! + else + formulae.each { |formula| cleanup.cleanup_formula(formula) } + end + ensure + $stdout = old_stdout + end + output.string end - def self.install_formula_clean!(f) - return if ENV["HOMEBREW_NO_INSTALL_CLEANUP"] + sig { params(formulae: T::Array[Formula]).returns(T::Array[Formula]) } + def self.install_cleanup_formulae(formulae) + return [] if Homebrew::EnvConfig.no_install_cleanup? - cleanup = Cleanup.new - if cleanup.periodic_clean_due? - cleanup.periodic_clean! - elsif f.installed? - cleanup.cleanup_formula(f) + formulae.select do |formula| + formula.latest_version_installed? && !skip_clean_formula?(formula) end end - def periodic_clean_due? - return false if ENV["HOMEBREW_NO_INSTALL_CLEANUP"] - return true unless PERIODIC_CLEAN_FILE.exist? + sig { params(formula: Formula).void } + def self.install_formula_clean!(formula) + return if install_cleanup_formulae([formula]).blank? + + ohai "Running `brew cleanup #{formula}`..." + puts_no_install_cleanup_disable_message_if_not_already! + Cleanup.new.cleanup_formula(formula) + end + + sig { void } + def self.puts_no_install_cleanup_disable_message + return if Homebrew::EnvConfig.no_env_hints? + return if Homebrew::EnvConfig.no_install_cleanup? + + puts "Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`." + puts "Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`)." + end + + sig { void } + def self.puts_no_install_cleanup_disable_message_if_not_already! + return if @puts_no_install_cleanup_disable_message_if_not_already - PERIODIC_CLEAN_FILE.mtime < CLEANUP_DEFAULT_DAYS.days.ago + puts_no_install_cleanup_disable_message + @puts_no_install_cleanup_disable_message_if_not_already = T.let(true, T.nilable(TrueClass)) end - def periodic_clean! - return false unless periodic_clean_due? + sig { params(formula: Formula).returns(T::Boolean) } + def self.skip_clean_formula?(formula) + no_cleanup_formula = Homebrew::EnvConfig.no_cleanup_formulae + return false if no_cleanup_formula.blank? + + @skip_clean_formulae ||= T.let(no_cleanup_formula.split(","), T.nilable(T::Array[String])) + @skip_clean_formulae.include?(formula.name) || @skip_clean_formulae.intersect?(formula.aliases) + end + + sig { returns(T::Boolean) } + def self.periodic_clean_due? + return false if Homebrew::EnvConfig.no_install_cleanup? + + unless PERIODIC_CLEAN_FILE.exist? + HOMEBREW_CACHE.mkpath + FileUtils.touch PERIODIC_CLEAN_FILE + return false + end + + PERIODIC_CLEAN_FILE.mtime < (DateTime.now - CLEANUP_DEFAULT_DAYS).to_time + end + + sig { params(dry_run: T::Boolean).void } + def self.periodic_clean!(dry_run: false) + return if Homebrew::EnvConfig.no_install_cleanup? + return unless periodic_clean_due? + + if dry_run + oh1 "Would run `brew cleanup` which has not been run in the last #{CLEANUP_DEFAULT_DAYS} days" + else + oh1 "`brew cleanup` has not been run in the last #{CLEANUP_DEFAULT_DAYS} days, running now..." + end + + puts_no_install_cleanup_disable_message + return if dry_run - ohai "`brew cleanup` has not been run in #{CLEANUP_DEFAULT_DAYS} days, running now..." - clean!(quiet: true, periodic: true) + Cleanup.new.clean!(quiet: true, periodic: true) end + sig { params(quiet: T::Boolean, periodic: T::Boolean).void } def clean!(quiet: false, periodic: false) if args.empty? - Formula.installed.sort_by(&:name).each do |formula| - cleanup_formula(formula, quiet: quiet, ds_store: false) + Formula.installed + .sort_by(&:name) + .reject { |f| Cleanup.skip_clean_formula?(f) } + .each do |formula| + cleanup_formula(formula, quiet:, ds_store: false, cache_db: false) end + + if ENV["HOMEBREW_AUTOREMOVE"].present? + opoo "`$HOMEBREW_AUTOREMOVE` is now a no-op as it is the default behaviour. " \ + "Set `HOMEBREW_NO_AUTOREMOVE=1` to disable it." + end + Cleanup.autoremove(dry_run: dry_run?) unless Homebrew::EnvConfig.no_autoremove? + cleanup_cache + cleanup_empty_api_source_directories + cleanup_bootsnap cleanup_logs + cleanup_temp_cellar + cleanup_reinstall_kegs cleanup_lockfiles + cleanup_python_site_packages prune_prefix_symlinks_and_directories unless dry_run? - cleanup_old_cache_db + cleanup_cache_db rm_ds_store HOMEBREW_CACHE.mkpath FileUtils.touch PERIODIC_CLEAN_FILE @@ -190,7 +436,7 @@ def clean!(quiet: false, periodic: false) args.each do |arg| formula = begin Formulary.resolve(arg) - rescue FormulaUnavailableError, TapFormulaAmbiguityError, TapFormulaWithOldnameAmbiguityError + rescue FormulaUnavailableError, TapFormulaAmbiguityError nil end @@ -200,66 +446,160 @@ def clean!(quiet: false, periodic: false) nil end - cleanup_formula(formula) if formula + if formula && Cleanup.skip_clean_formula?(formula) + onoe "Refusing to clean #{formula} because it is listed in " \ + "#{Tty.bold}HOMEBREW_NO_CLEANUP_FORMULAE#{Tty.reset}!" + elsif formula + cleanup_formula(formula) + end cleanup_cask(cask) if cask end end end + sig { returns(T::Array[Keg]) } def unremovable_kegs - @unremovable_kegs ||= [] + @unremovable_kegs ||= T.let([], T.nilable(T::Array[Keg])) + end + + sig { + params(paths: T::Array[Pathname], type: T.nilable(Symbol)) + .returns(T::Array[{ path: Pathname, type: T.nilable(Symbol) }]) + } + def cache_entries(paths, type:) + paths.map { |path| { path:, type: } } + end + + sig { + params(paths: T::Array[Pathname], type: T.nilable(Symbol), cleanup_unreferenced: T::Boolean).void + } + def cleanup_cache_entries(paths, type:, cleanup_unreferenced: true) + cleanup_cache(cache_entries(paths, type:), cleanup_unreferenced:) end - def cleanup_formula(formula, quiet: false, ds_store: true) - formula.eligible_kegs_for_cleanup(quiet: quiet) - .each(&method(:cleanup_keg)) - cleanup_cache(Pathname.glob(cache/"#{formula.name}--*")) + sig { params(formula: Formula, quiet: T::Boolean, ds_store: T::Boolean, cache_db: T::Boolean).void } + def cleanup_formula(formula, quiet: false, ds_store: true, cache_db: true) + formula.eligible_kegs_for_cleanup(quiet:) + .each { |keg| cleanup_keg(keg) } + cleanup_cache_entries(Pathname.glob(cache/"#{formula.name}{_bottle_manifest,}--*"), type: nil) rm_ds_store([formula.rack]) if ds_store + cleanup_cache_db(formula.rack) if cache_db cleanup_lockfiles(FormulaLock.new(formula.name).path) end + sig { params(cask: Cask::Cask, ds_store: T::Boolean).void } def cleanup_cask(cask, ds_store: true) - cleanup_cache(Pathname.glob(cache/"Cask/#{cask.token}--*")) + cleanup_cache_entries(Pathname.glob(cache/"Cask/#{cask.token}--*"), type: :cask, cleanup_unreferenced: false) + cleanup_legacy_cask_downloads([cask]) + cleanup_unreferenced_downloads + rm_ds_store([cask.caskroom_path]) if ds_store cleanup_lockfiles(CaskLock.new(cask.token).path) end + # Added 2026-07-05 for legacy cask cache symlinks named after URL basenames. + # Remove after 2026-11-02, once the 120-day fallback stale-file sweep has + # had time to prune stale files created before cask downloads were token-named. + sig { params(casks: T::Array[Cask::Cask]).void } + def cleanup_legacy_cask_downloads(casks) + cask_cache = cache/"Cask" + return unless cask_cache.directory? + + cask_cache_paths = cask_cache.children.select { |path| path.file? || path.symlink? } + + casks.each do |cask| + next unless (url = cask.url) + + legacy_download_name = Utils.safe_filename(File.basename(url.to_s)) + next if legacy_download_name.blank? || legacy_download_name == cask.token + + cask_cache_paths.each do |path| + next unless path.basename.to_s.start_with?("#{legacy_download_name}--") + next if !self.class.stale_cask_download?(path, cask, legacy_download_name, scrub: scrub?) && + (!self.class.cask_cache_file_current?(path, cask, legacy_download_name) || + !(cask_cache/Utils.safe_filename("#{cask.token}--#{cask.version}#{path.extname}")).exist?) + + cleanup_path(path) { path.unlink } + end + end + end + + sig { params(keg: Keg).void } def cleanup_keg(keg) - cleanup_path(keg) { keg.uninstall } - rescue Errno::EACCES => e + cleanup_path(Pathname.new(keg)) { keg.uninstall(raise_failures: true) } + rescue Errno::EACCES, Errno::ENOTEMPTY => e opoo e.message unremovable_kegs << keg end + sig { void } def cleanup_logs return unless HOMEBREW_LOGS.directory? - logs_days = if days > CLEANUP_DEFAULT_DAYS - CLEANUP_DEFAULT_DAYS - else - days - end + logs_days = [days, CLEANUP_DEFAULT_DAYS].min HOMEBREW_LOGS.subdirs.each do |dir| - cleanup_path(dir) { dir.rmtree } if dir.prune?(logs_days) + cleanup_path(dir) { FileUtils.rm_r(dir) } if self.class.prune?(dir, logs_days) end end + sig { void } + def cleanup_temp_cellar + return unless HOMEBREW_TEMP_CELLAR.directory? + + HOMEBREW_TEMP_CELLAR.each_child do |child| + cleanup_path(child) { FileUtils.rm_r(child) } + end + end + + sig { void } + def cleanup_reinstall_kegs + return unless HOMEBREW_CELLAR.directory? + + HOMEBREW_CELLAR.glob("*/*.reinstall").each do |reinstall_keg| + cleanup_path(reinstall_keg) { FileUtils.rm_r(reinstall_keg) } + end + end + + sig { returns(T::Array[{ path: Pathname, type: T.nilable(Symbol) }]) } + def cache_files + files = cache.directory? ? cache.children : [] + cask_files = (cache/"Cask").directory? ? (cache/"Cask").children : [] + api_source_files = (cache/"api-source").glob("*/*/*/**/*").select { |path| path.file? || path.symlink? } + gh_actions_artifacts = (cache/"gh-actions-artifact").directory? ? (cache/"gh-actions-artifact").children : [] + + cache_entries(files, type: nil) + + cache_entries(cask_files, type: :cask) + + cache_entries(api_source_files, type: :api_source) + + cache_entries(gh_actions_artifacts, type: :gh_actions_artifact) + end + + sig { params(directory: Pathname).void } + def cleanup_empty_api_source_directories(directory = cache/"api-source") + return if dry_run? + return unless directory.directory? + + directory.each_child do |child| + next unless child.directory? + + cleanup_empty_api_source_directories(child) + child.rmdir if child.empty? + end + end + + sig { void } def cleanup_unreferenced_downloads return if dry_run? return unless (cache/"downloads").directory? downloads = (cache/"downloads").children - referenced_downloads = [cache, cache/"Cask"].select(&:directory?) - .flat_map(&:children) - .select(&:symlink?) - .map(&:resolved_path) + referenced_downloads = cache_files.map { |file| file[:path] }.select(&:symlink?).map(&:resolved_path) (downloads - referenced_downloads).each do |download| - if download.incomplete? + if self.class.incomplete?(download) begin - LockFile.new(download.basename).with_lock do + DownloadLock.new(download).with_lock do download.unlink end rescue OperationInProgressError @@ -274,17 +614,23 @@ def cleanup_unreferenced_downloads end end - def cleanup_cache(entries = nil) - entries ||= [cache, cache/"Cask"].select(&:directory?).flat_map(&:children) + sig { + params(entries: T.nilable(T::Array[{ path: Pathname, type: T.nilable(Symbol) }]), + cleanup_unreferenced: T::Boolean).void + } + def cleanup_cache(entries = nil, cleanup_unreferenced: true) + full_cache_cleanup = entries.nil? + entries ||= cache_files - entries.each do |path| + entries.each do |entry| + path = entry[:path] next if path == PERIODIC_CLEAN_FILE - FileUtils.chmod_R 0755, path if path.go_cache_directory? && !dry_run? - next cleanup_path(path) { path.unlink } if path.incomplete? - next cleanup_path(path) { FileUtils.rm_rf path } if path.nested_cache? + FileUtils.chmod_R 0755, path if self.class.go_cache_directory?(path) && !dry_run? + next cleanup_path(path) { path.unlink } if self.class.incomplete?(path) + next cleanup_path(path) { FileUtils.rm_rf path } if self.class.nested_cache?(path) - if path.prune?(days) + if self.class.prune?(path, days) if path.file? || path.symlink? cleanup_path(path) { path.unlink } elsif path.directory? && path.to_s.include?("--") @@ -293,27 +639,30 @@ def cleanup_cache(entries = nil) next end - next cleanup_path(path) { path.unlink } if path.stale?(scrub?) + # If we've specified --prune don't do the (expensive) .stale? check. + cleanup_path(path) { path.unlink } if !prune? && self.class.stale?(entry, scrub: scrub?) end - cleanup_unreferenced_downloads + cleanup_legacy_cask_downloads(Cask::Caskroom.casks) if full_cache_cleanup + cleanup_unreferenced_downloads if cleanup_unreferenced end - def cleanup_path(path) + sig { params(path: Pathname, _block: T.proc.void).void } + def cleanup_path(path, &_block) + return if !path.exist? && !path.symlink? return unless @cleaned_up_paths.add?(path) - disk_usage = path.disk_usage + @disk_cleanup_size += path.disk_usage if dry_run? puts "Would remove: #{path} (#{path.abv})" - @disk_cleanup_size += disk_usage else puts "Removing: #{path}... (#{path.abv})" yield - @disk_cleanup_size += disk_usage - path.disk_usage end end + sig { params(lockfiles: Pathname).void } def cleanup_lockfiles(*lockfiles) return if dry_run? @@ -321,106 +670,212 @@ def cleanup_lockfiles(*lockfiles) lockfiles.each do |file| next unless file.readable? - next unless file.open(File::RDWR).flock(File::LOCK_EX | File::LOCK_NB) - begin - file.unlink - ensure - file.open(File::RDWR).flock(File::LOCK_UN) if file.exist? + file.open(File::RDWR) do |lockfile| + next unless lockfile.flock(File::LOCK_EX | File::LOCK_NB) + + begin + file.unlink + ensure + lockfile.flock(File::LOCK_UN) if file.exist? + end end end end + sig { void } def cleanup_portable_ruby - system_ruby_version = - Utils.popen_read("/usr/bin/ruby", "-e", "puts RUBY_VERSION") - .chomp - use_system_ruby = ( - Gem::Version.new(system_ruby_version) >= Gem::Version.new(RUBY_VERSION) - ) && ENV["HOMEBREW_FORCE_VENDOR_RUBY"].nil? - vendor_path = HOMEBREW_LIBRARY/"Homebrew/vendor" - portable_ruby_version_file = vendor_path/"portable-ruby-version" - portable_ruby_version = if portable_ruby_version_file.exist? - portable_ruby_version_file.read - .chomp - end - - portable_ruby_path = vendor_path/"portable-ruby" - portable_ruby_glob = "#{portable_ruby_path}/*.*" - Pathname.glob(portable_ruby_glob).each do |path| - next if !use_system_ruby && portable_ruby_version == path.basename.to_s + vendor_dir = HOMEBREW_LIBRARY/"Homebrew/vendor" + portable_ruby_latest_version = (vendor_dir/"portable-ruby-version").read.chomp + + portable_rubies_to_remove = [] + Pathname.glob(vendor_dir/"portable-ruby/*.*").select(&:directory?).each do |path| + next if !use_system_ruby? && portable_ruby_latest_version == path.basename.to_s + + portable_rubies_to_remove << path + end + + return if portable_rubies_to_remove.empty? + + bundler_paths = (vendor_dir/"bundle/ruby").children.select do |child| + basename = child.basename.to_s + + next false if basename == ".homebrew_gem_groups" + next true unless child.directory? + [ + "#{Version.new(portable_ruby_latest_version).major_minor}.0", + RbConfig::CONFIG["ruby_version"], + ].uniq.exclude?(basename) + end + + bundler_paths.each do |bundler_path| if dry_run? - puts "Would remove: #{path} (#{path.abv})" + puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-nx", bundler_path).chomp else - FileUtils.rm_rf path + puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-ffqx", bundler_path).chomp end end - return unless Dir.glob(portable_ruby_glob).empty? - return unless portable_ruby_path.exist? + portable_rubies_to_remove.each do |portable_ruby| + cleanup_path(portable_ruby) { FileUtils.rm_r(portable_ruby) } + end + end - bundler_path = vendor_path/"bundle/ruby" - if dry_run? - puts "Would remove: #{bundler_path} (#{bundler_path.abv})" - puts "Would remove: #{portable_ruby_path} (#{portable_ruby_path.abv})" - else - FileUtils.rm_rf [bundler_path, portable_ruby_path] + sig { returns(T::Boolean) } + def use_system_ruby? + false + end + + sig { void } + def cleanup_bootsnap + bootsnap = cache/"bootsnap" + return unless bootsnap.directory? + + bootsnap.each_child do |subdir| + cleanup_path(subdir) { FileUtils.rm_r(subdir) } if subdir.basename.to_s != Homebrew::Bootsnap.key end end - def cleanup_old_cache_db + sig { params(rack: T.nilable(Pathname)).void } + def cleanup_cache_db(rack = nil) FileUtils.rm_rf [ cache/"desc_cache.json", cache/"linkage.db", cache/"linkage.db.db", ] + + CacheStoreDatabase.use(:linkage) do |db| + break unless db.created? + + db.each_key do |keg| + keg = T.cast(keg, String) + next if rack && !keg.start_with?("#{rack}/") + next if File.directory?(keg) + + LinkageCacheStore.new( + keg, + T.cast(db, CacheStoreDatabase[String, T::Hash[T.any(String, Symbol), T.anything]]), + ).delete! + end + end end + sig { params(dirs: T.nilable(T::Array[Pathname])).void } def rm_ds_store(dirs = nil) - dirs ||= begin - Keg::MUST_EXIST_DIRECTORIES + [ - HOMEBREW_PREFIX/"Caskroom", - ] - end + dirs ||= Keg.must_exist_directories + [ + HOMEBREW_PREFIX/"Caskroom", + ] dirs.select(&:directory?) .flat_map { |d| Pathname.glob("#{d}/**/.DS_Store") } - .each(&:unlink) + .each do |dir| + dir.unlink + rescue Errno::EACCES + # don't care if we can't delete a .DS_Store + nil + end + end + + sig { void } + def cleanup_python_site_packages + pyc_files = Hash.new { |h, k| h[k] = [] } + seen_non_pyc_file = Hash.new { |h, k| h[k] = false } + unused_pyc_files = [] + + HOMEBREW_PREFIX.glob("lib/python*/site-packages").each do |site_packages| + site_packages.each_child do |child| + next unless child.directory? + # TODO: Work out a sensible way to clean up `pip`'s, `setuptools`' and `wheel`'s + # `{dist,site}-info` directories. Alternatively, consider always removing + # all `-info` directories, because we may not be making use of them. + next if child.basename.to_s.end_with?("-info") + + # Clean up old *.pyc files in the top-level __pycache__. + if child.basename.to_s == "__pycache__" + child.find do |path| + next if path.extname != ".pyc" + next unless self.class.prune?(path, days) + + unused_pyc_files << path + end + + next + end + + # Look for directories that contain only *.pyc files. + child.find do |path| + next if path.directory? + + if path.extname == ".pyc" + pyc_files[child] << path + else + seen_non_pyc_file[child] = true + break + end + end + end + end + + unused_pyc_files += pyc_files.reject { |k,| seen_non_pyc_file[k] } + .values + .flatten + return if unused_pyc_files.blank? + + unused_pyc_files.each do |pyc| + cleanup_path(pyc) { pyc.unlink } + end end + sig { void } def prune_prefix_symlinks_and_directories ObserverPathnameExtension.reset_counts! dirs = [] + children_count = {} - Keg::MUST_EXIST_SUBDIRECTORIES.each do |dir| + Keg.must_exist_subdirectories.each do |dir| next unless dir.directory? dir.find do |path| path.extend(ObserverPathnameExtension) if path.symlink? unless path.resolved_path_exists? - if path.to_s.match?(Keg::INFOFILE_RX) - path.uninstall_info unless dry_run? - end + path.uninstall_info if path.to_s.match?(Keg::INFOFILE_RX) && !dry_run? if dry_run? puts "Would remove (broken link): #{path}" + children_count[path.dirname] -= 1 if children_count.key?(path.dirname) else path.unlink end end - elsif path.directory? && !Keg::MUST_EXIST_SUBDIRECTORIES.include?(path) + elsif path.directory? && Keg.must_exist_subdirectories.exclude?(path) dirs << path + children_count[path] = path.children.length if dry_run? end end end dirs.reverse_each do |d| - if dry_run? && d.children.empty? - puts "Would remove (empty directory): #{d}" - else + if !dry_run? d.rmdir_if_possible + elsif children_count[d].zero? + puts "Would remove (empty directory): #{d}" + children_count[d.dirname] -= 1 if children_count.key?(d.dirname) + end + end + + require "cask/caskroom" + if Cask::Caskroom.path.directory? + Cask::Caskroom.path.each_child do |path| + path.extend(ObserverPathnameExtension) + next if !path.symlink? || path.resolved_path_exists? + + if dry_run? + puts "Would remove (broken link): #{path}" + else + path.unlink + end end end @@ -433,5 +888,49 @@ def prune_prefix_symlinks_and_directories print "and #{d} directories " if d.positive? puts "from #{HOMEBREW_PREFIX}" end + + sig { params(dry_run: T::Boolean).void } + def self.autoremove(dry_run: false) + require "utils/autoremove" + require "cask/caskroom" + + # If this runs after install, uninstall, reinstall or upgrade, + # the cache of installed formulae may no longer be valid. + Formula.clear_cache unless dry_run + + formulae = Formula.installed + # Remove formulae listed in HOMEBREW_NO_CLEANUP_FORMULAE and their dependencies. + if Homebrew::EnvConfig.no_cleanup_formulae.present? + formulae -= formulae.select { skip_clean_formula?(it) } + .flat_map { |f| [f, *f.installed_runtime_formula_dependencies] } + end + casks = Cask::Caskroom.casks + + removable_formulae = Utils::Autoremove.removable_formulae(formulae, casks) + if (candidate_kegs = removable_formulae.filter_map(&:any_installed_keg).presence) && + (required_kegs, = InstalledDependents.find_some_installed_dependents(candidate_kegs)) && + (required_names = Set.new(required_kegs.map(&:name)).presence) + removable_formulae.reject! { |formula| required_names.include?(formula.name) } + end + + return if removable_formulae.blank? + + formulae_names = removable_formulae.map(&:full_name).sort + + verb = dry_run ? "Would autoremove" : "Autoremoving" + oh1 "#{verb} #{formulae_names.count} unneeded #{Utils.pluralize("formula", formulae_names.count)}:" + puts formulae_names.join("\n") + return if dry_run + + require "uninstall" + + kegs_by_rack = removable_formulae.filter_map(&:any_installed_keg).group_by(&:rack) + Uninstall.uninstall_kegs(kegs_by_rack) + + # The installed formula cache will be invalid after uninstalling. + Formula.clear_cache + end end end + +require "extend/os/cleanup" diff --git a/Library/Homebrew/cli/args.rb b/Library/Homebrew/cli/args.rb index f494047c376a6..fd42e84ea33a3 100644 --- a/Library/Homebrew/cli/args.rb +++ b/Library/Homebrew/cli/args.rb @@ -1,176 +1,191 @@ +# typed: strict # frozen_string_literal: true -require "ostruct" - module Homebrew module CLI - class Args < OpenStruct - attr_reader :processed_options, :args_parsed - # undefine tap to allow --tap argument - undef tap - - def initialize(argv:) - super - @argv = argv - @args_parsed = false - @processed_options = [] - end - + class Args + # Represents a processed option. The array elements are: + # 0: short option name (e.g. "-d") + # 1: long option name (e.g. "--debug") + # 2: option description (e.g. "Print debugging information") + # 3: whether the option is hidden + OptionsType = T.type_alias { T::Array[[T.nilable(String), T.nilable(String), String, T::Boolean]] } + + sig { returns(T::Array[String]) } + attr_reader :options_only, :flags_only, :remaining + + sig { void } + def initialize + require "cli/named_args" + + @cli_args = T.let(nil, T.nilable(T::Array[String])) + @processed_options = T.let([], OptionsType) + @options_only = T.let([], T::Array[String]) + @flags_only = T.let([], T::Array[String]) + @cask_options = T.let(false, T::Boolean) + @table = T.let({}, T::Hash[Symbol, T.untyped]) + + # Can set these because they will be overwritten by freeze_named_args! + # (whereas other values below will only be overwritten if passed). + @named = T.let(NamedArgs.new(parent: self), T.nilable(NamedArgs)) + @remaining = T.let([], T::Array[String]) + end + + sig { params(remaining_args: T::Array[T.any(T::Array[String], String)]).void } + def freeze_remaining_args!(remaining_args) = @remaining.replace(remaining_args).freeze + + sig { params(named_args: T::Array[String], cask_options: T::Boolean, without_api: T::Boolean).void } + def freeze_named_args!(named_args, cask_options:, without_api:) + @named = T.let( + NamedArgs.new( + *named_args.freeze, + cask_options:, + flags: flags_only, + force_bottle: @table[:force_bottle?] || false, + override_spec: @table[:HEAD?] ? :head : nil, + parent: self, + without_api:, + ), + T.nilable(NamedArgs), + ) + end + + sig { params(name: Symbol, value: T.untyped).void } + def set_arg(name, value) + @table[name] = value + end + + sig { override.params(_blk: T.nilable(T.proc.params(x: T.untyped).void)).returns(T.untyped) } + def tap(&_blk) + return super if block_given? # Object#tap + + @table[:tap] + end + + sig { params(processed_options: OptionsType).void } def freeze_processed_options!(processed_options) + # Reset cache values reliant on processed_options + @cli_args = nil + @processed_options += processed_options @processed_options.freeze - @args_parsed = true - end - def option_to_name(option) - option.sub(/\A--?/, "") - .tr("-", "_") + @options_only = cli_args.select { it.start_with?("-") }.freeze + @flags_only = cli_args.select { it.start_with?("--") }.freeze end - def cli_args - return @cli_args if @cli_args - - @cli_args = [] - processed_options.each do |short, long| - option = long || short - switch = "#{option_to_name(option)}?".to_sym - flag = option_to_name(option).to_sym - if @table[switch] == true || @table[flag] == true - @cli_args << option - elsif @table[flag].instance_of? String - @cli_args << option + "=" + @table[flag] - elsif @table[flag].instance_of? Array - @cli_args << option + "=" + @table[flag].join(",") - end - end - @cli_args + sig { returns(NamedArgs) } + def named + require "formula" + T.must(@named) end - def options_only - @options_only ||= cli_args.select { |arg| arg.start_with?("-") } - end + sig { returns(T::Boolean) } + def no_named? = named.empty? - def flags_only - @flags_only ||= cli_args.select { |arg| arg.start_with?("--") } + sig { returns(T::Array[String]) } + def build_from_source_formulae + if @table[:build_from_source?] || @table[:HEAD?] || @table[:build_bottle?] + named.to_formulae.map(&:full_name) + else + [] + end end - def passthrough - options_only - CLI::Parser.global_options.values.map(&:first).flatten + sig { returns(T::Array[String]) } + def include_test_formulae + if @table[:include_test?] + named.to_formulae.map(&:full_name) + else + [] + end end - def named - return [] if remaining.nil? - - remaining - end + sig { params(name: String).returns(T.nilable(String)) } + def value(name) + arg_prefix = "--#{name}=" + flag_with_value = flags_only.find { |arg| arg.start_with?(arg_prefix) } + return unless flag_with_value - def formulae - require "formula" - @formulae ||= (downcased_unique_named - casks).map do |name| - if name.include?("/") || File.exist?(name) - Formulary.factory(name, spec) - else - Formulary.find_with_priority(name, spec) - end - end.uniq(&:name) + flag_with_value.delete_prefix(arg_prefix) end - def resolved_formulae - require "formula" - @resolved_formulae ||= (downcased_unique_named - casks).map do |name| - Formulary.resolve(name, spec: spec(nil)) - end.uniq(&:name) + sig { returns(Context::ContextStruct) } + def context + Context::ContextStruct.new(debug: debug?, quiet: quiet?, verbose: verbose?) end - def casks - @casks ||= downcased_unique_named.grep HOMEBREW_CASK_TAP_CASK_REGEX + sig { returns(T.nilable(Symbol)) } + def only_formula_or_cask + if @table[:formula?] && !@table[:cask?] + :formula + elsif @table[:cask?] && !@table[:formula?] + :cask + end end - def kegs - require "keg" - require "formula" - require "missing_formula" - @kegs ||= downcased_unique_named.map do |name| - raise UsageError if name.empty? - - rack = Formulary.to_rack(name.downcase) + sig { returns(T::Array[[Symbol, Symbol]]) } + def os_arch_combinations + skip_invalid_combinations = false - dirs = rack.directory? ? rack.subdirs : [] + # `--all-platforms` is equivalent to `--os=all --arch=all`. + all_platforms = @table[:all_platforms?] - if dirs.empty? - if (reason = Homebrew::MissingFormula.suggest_command(name, "uninstall")) - $stderr.puts reason - end - raise NoSuchKegError, rack.basename - end + os_sym = all_platforms ? :all : @table[:os]&.to_sym + oses = case os_sym + when nil + [SimulateSystem.current_os] + when :all + skip_invalid_combinations = true - linked_keg_ref = HOMEBREW_LINKED_KEGS/rack.basename - opt_prefix = HOMEBREW_PREFIX/"opt/#{rack.basename}" - - begin - if opt_prefix.symlink? && opt_prefix.directory? - Keg.new(opt_prefix.resolved_path) - elsif linked_keg_ref.symlink? && linked_keg_ref.directory? - Keg.new(linked_keg_ref.resolved_path) - elsif dirs.length == 1 - Keg.new(dirs.first) - else - f = if name.include?("/") || File.exist?(name) - Formulary.factory(name) - else - Formulary.from_rack(rack) - end - - unless (prefix = f.installed_prefix).directory? - raise MultipleVersionsInstalledError, rack.basename - end - - Keg.new(prefix) - end - rescue FormulaUnavailableError - raise <<~EOS - Multiple kegs installed to #{rack} - However we don't know which one you refer to. - Please delete (with rm -rf!) all but one and then try again. - EOS - end + OnSystem::ALL_OS_OPTIONS + else + [os_sym] end - end - private - - def downcased_unique_named - # Only lowercase names, not paths, bottle filenames or URLs - arguments = if args_parsed - remaining + arch_sym = all_platforms ? :all : @table[:arch]&.to_sym + arches = case arch_sym + when nil + [SimulateSystem.current_arch] + when :all + skip_invalid_combinations = true + OnSystem::ARCH_OPTIONS else - cmdline_args.reject { |arg| arg.start_with?("-") } + [arch_sym] end - arguments.map do |arg| - if arg.include?("/") || arg.end_with?(".tar.gz") || File.exist?(arg) - arg + + oses.product(arches).select do |os, arch| + if skip_invalid_combinations + bottle_tag = Utils::Bottles::Tag.new(system: os, arch:) + bottle_tag.valid_combination? else - arg.downcase + true end - end.uniq + end end - def head - (args_parsed && HEAD?) || cmdline_args.include?("--HEAD") - end + private - def devel - (args_parsed && devel?) || cmdline_args.include?("--devel") + sig { params(option: String).returns(String) } + def option_to_name(option) + option.sub(/\A--?/, "") + .tr("-", "_") end - def spec(default = :stable) - if head - :head - elsif devel - :devel - else - default - end + sig { returns(T::Array[String]) } + def cli_args + @cli_args ||= @processed_options.filter_map do |short, long| + option = T.must(long || short) + switch = :"#{option_to_name(option)}?" + flag = option_to_name(option).to_sym + if @table[switch] == true || @table[flag] == true + option + elsif @table[flag].instance_of? String + "#{option}=#{@table[flag]}" + elsif @table[flag].instance_of? Array + "#{option}=#{@table[flag].join(",")}" + end + end.freeze end end end diff --git a/Library/Homebrew/cli/args.rbi b/Library/Homebrew/cli/args.rbi new file mode 100644 index 0000000000000..7c161fd39becc --- /dev/null +++ b/Library/Homebrew/cli/args.rbi @@ -0,0 +1,19 @@ +# typed: strict + +# This file contains global args as defined in `Homebrew::CLI::Parser.global_options` +# `Command`-specific args are defined in the commands themselves, with type signatures +# generated by the `Tapioca::Compilers::Args` compiler. + +class Homebrew::CLI::Args + sig { returns(T::Boolean) } + def debug?; end + + sig { returns(T::Boolean) } + def help?; end + + sig { returns(T::Boolean) } + def quiet?; end + + sig { returns(T::Boolean) } + def verbose?; end +end diff --git a/Library/Homebrew/cli/error.rb b/Library/Homebrew/cli/error.rb new file mode 100644 index 0000000000000..42e4e9596efd7 --- /dev/null +++ b/Library/Homebrew/cli/error.rb @@ -0,0 +1,73 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/formatter" + +module Homebrew + module CLI + class OptionConstraintError < UsageError + sig { params(arg1: String, arg2: String, missing: T::Boolean).void } + def initialize(arg1, arg2, missing: false) + message = if missing + "`#{arg2}` cannot be passed without `#{arg1}`." + else + "`#{arg1}` and `#{arg2}` should be passed together." + end + super message + end + end + + class OptionConflictError < UsageError + sig { params(args: T::Array[String]).void } + def initialize(args) + args_list = args.map { Formatter.option(it) }.join(" and ") + super "Options #{args_list} are mutually exclusive." + end + end + + class InvalidConstraintError < UsageError + sig { params(arg1: String, arg2: String).void } + def initialize(arg1, arg2) + super "`#{arg1}` and `#{arg2}` cannot be mutually exclusive and mutually dependent simultaneously." + end + end + + class MaxNamedArgumentsError < UsageError + sig { params(maximum: Integer, types: T::Array[Symbol]).void } + def initialize(maximum, types: []) + super case maximum + when 0 + "This command does not take named arguments." + else + types << :named if types.empty? + arg_types = types.map { |type| type.to_s.tr("_", " ") } + .to_sentence two_words_connector: " or ", last_word_connector: " or " + + "This command does not take more than #{maximum} #{arg_types} #{Utils.pluralize("argument", maximum)}." + end + end + end + + class MinNamedArgumentsError < UsageError + sig { params(minimum: Integer, types: T::Array[Symbol]).void } + def initialize(minimum, types: []) + types << :named if types.empty? + arg_types = types.map { |type| type.to_s.tr("_", " ") } + .to_sentence two_words_connector: " or ", last_word_connector: " or " + + super "This command requires at least #{minimum} #{arg_types} #{Utils.pluralize("argument", minimum)}." + end + end + + class NumberOfNamedArgumentsError < UsageError + sig { params(minimum: Integer, types: T::Array[Symbol]).void } + def initialize(minimum, types: []) + types << :named if types.empty? + arg_types = types.map { |type| type.to_s.tr("_", " ") } + .to_sentence two_words_connector: " or ", last_word_connector: " or " + + super "This command requires exactly #{minimum} #{arg_types} #{Utils.pluralize("argument", minimum)}." + end + end + end +end diff --git a/Library/Homebrew/cli/named_args.rb b/Library/Homebrew/cli/named_args.rb new file mode 100644 index 0000000000000..88e2b54324d82 --- /dev/null +++ b/Library/Homebrew/cli/named_args.rb @@ -0,0 +1,642 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/caskroom" +require "cli/args" +require "utils" +require "utils/output" + +module Homebrew + module CLI + # Helper class for loading formulae/casks from named arguments. + class NamedArgs < Array + include Utils::Output::Mixin + extend T::Generic + + Elem = type_member(:out) { { fixed: String } } + + sig { returns(Args) } + attr_reader :parent + + sig { + params( + args: String, + parent: Args, + override_spec: T.nilable(Symbol), + force_bottle: T::Boolean, + flags: T::Array[String], + cask_options: T::Boolean, + without_api: T::Boolean, + ).void + } + def initialize( + *args, + parent: Args.new, + override_spec: nil, + force_bottle: false, + flags: [], + cask_options: false, + without_api: false + ) + super(args) + + @override_spec = override_spec + @force_bottle = force_bottle + @flags = flags + @cask_options = cask_options + @without_api = without_api + @parent = parent + end + + sig { returns(T::Array[Cask::Cask]) } + def to_casks + @to_casks ||= T.let( + to_formulae_and_casks(only: :cask).freeze, T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)]) + ) + T.cast(@to_casks, T::Array[Cask::Cask]) + end + + sig { returns(T::Array[Formula]) } + def to_formulae + @to_formulae ||= T.let( + to_formulae_and_casks(only: :formula).freeze, T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)]) + ) + T.cast(@to_formulae, T::Array[Formula]) + end + + # Convert named arguments to {Formula} or {Cask} objects. + # If both a formula and cask with the same name exist, returns + # the formula and prints a warning unless `only` is specified. + sig { + params( + only: T.nilable(Symbol), + ignore_unavailable: T::Boolean, + method: T.nilable(Symbol), + uniq: T::Boolean, + warn: T::Boolean, + ).returns(T::Array[T.any(Formula, Keg, Cask::Cask)]) + } + def to_formulae_and_casks( + only: parent.only_formula_or_cask, ignore_unavailable: false, method: nil, uniq: true, warn: false + ) + @to_formulae_and_casks ||= T.let( + {}, T.nilable(T::Hash[T.nilable(Symbol), T::Array[T.any(Formula, Keg, Cask::Cask)]]) + ) + @to_formulae_and_casks[only] ||= downcased_unique_named.flat_map do |name| + load_formula_or_cask(name, only:, method:, warn:) + rescue FormulaUnreadableError, FormulaClassUnavailableError, + TapFormulaUnreadableError, TapFormulaClassUnavailableError, + Cask::CaskUnreadableError + # Need to rescue before `*UnavailableError` (superclass of this) + # The formula/cask was found, but there's a problem with its implementation + raise + rescue NoSuchKegError, FormulaUnavailableError, Cask::CaskUnavailableError, FormulaOrCaskUnavailableError + ignore_unavailable ? [] : raise + end.freeze + + if uniq + @to_formulae_and_casks.fetch(only).uniq.freeze + else + @to_formulae_and_casks.fetch(only) + end + end + + sig { + params(only: T.nilable(Symbol), method: T.nilable(Symbol)) + .returns([T::Array[T.any(Formula, Keg)], T::Array[Cask::Cask]]) + } + def to_formulae_to_casks(only: parent.only_formula_or_cask, method: nil) + @to_formulae_to_casks ||= T.let( + {}, T.nilable(T::Hash[[T.nilable(Symbol), T.nilable(Symbol)], + [T::Array[T.any(Formula, Keg)], T::Array[Cask::Cask]]]) + ) + @to_formulae_to_casks[[method, only]] = + T.cast( + to_formulae_and_casks(only:, method:).partition { |o| o.is_a?(Formula) || o.is_a?(Keg) } + .map(&:freeze).freeze, + [T::Array[T.any(Formula, Keg)], T::Array[Cask::Cask]], + ) + end + + # Returns formulae and casks after validating that a tap is present for each of them. + sig { returns(T::Array[T.any(Formula, Keg, Cask::Cask)]) } + def to_formulae_and_casks_with_taps + formulae_and_casks_with_taps, formulae_and_casks_without_taps = + to_formulae_and_casks.partition do |formula_or_cask| + T.cast(formula_or_cask, T.any(Formula, Cask::Cask)).tap&.installed? + end + + return formulae_and_casks_with_taps if formulae_and_casks_without_taps.empty? + + types = [] + types << "formulae" if formulae_and_casks_without_taps.any?(Formula) + types << "casks" if formulae_and_casks_without_taps.any?(Cask::Cask) + + odie <<~ERROR + These #{types.join(" and ")} are not in any locally installed taps! + + #{formulae_and_casks_without_taps.sort_by(&:to_s).join("\n ")} + + You may need to run `brew tap` to install additional taps. + ERROR + end + + sig { + params(only: T.nilable(Symbol), method: T.nilable(Symbol), uniq: T::Boolean) + .returns(T::Array[T.any(Formula, Keg, Cask::Cask, T::Array[Keg], + FormulaOrCaskUnavailableError, NoSuchKegError)]) + } + def to_formulae_and_casks_and_unavailable(only: parent.only_formula_or_cask, method: nil, uniq: true) + @to_formulae_casks_unknowns ||= T.let( + {}, + T.nilable(T::Hash[ + [T.nilable(Symbol), T::Boolean], + T::Array[T.any(Formula, Keg, Cask::Cask, T::Array[Keg], + FormulaOrCaskUnavailableError, NoSuchKegError)], + ]), + ) + items = downcased_unique_named.map do |name| + load_formula_or_cask(name, only:, method:) + rescue FormulaOrCaskUnavailableError, NoSuchKegError => e + e + end + items = items.uniq if uniq + @to_formulae_casks_unknowns[[method, uniq]] = items.freeze + end + + sig { params(uniq: T::Boolean).returns(T::Array[Formula]) } + def to_resolved_formulae(uniq: true) + @to_resolved_formulae ||= T.let( + to_formulae_and_casks(only: :formula, method: :resolve, uniq:).freeze, + T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)]), + ) + T.cast(@to_resolved_formulae, T::Array[Formula]) + end + + sig { params(only: T.nilable(Symbol)).returns([T::Array[Formula], T::Array[Cask::Cask]]) } + def to_resolved_formulae_to_casks(only: parent.only_formula_or_cask) + T.cast(to_formulae_to_casks(only:, method: :resolve), [T::Array[Formula], T::Array[Cask::Cask]]) + end + + LOCAL_PATH_REGEX = %r{^/|[.]|/$} + TAP_NAME_REGEX = %r{^[^./]+/[^./]+$} + private_constant :LOCAL_PATH_REGEX, :TAP_NAME_REGEX + + # Keep existing paths and try to convert others to tap, formula or cask paths. + # If a cask and formula with the same name exist, includes both their paths + # unless `only` is specified. + sig { params(only: T.nilable(Symbol), recurse_tap: T::Boolean).returns(T::Array[Pathname]) } + def to_paths(only: parent.only_formula_or_cask, recurse_tap: false) + @to_paths ||= T.let({}, T.nilable(T::Hash[T.nilable(Symbol), T::Array[Pathname]])) + @to_paths[only] ||= Homebrew.with_no_api_env_if_needed(@without_api) do + downcased_unique_named.flat_map do |name| + path = Pathname(name).expand_path + if only.nil? && name.match?(LOCAL_PATH_REGEX) && path.exist? + path + elsif name.match?(TAP_NAME_REGEX) + tap = Tap.fetch(name) + + if recurse_tap + next tap.formula_files if only == :formula + next tap.cask_files if only == :cask + end + + tap.path + else + next Formulary.path(name) if only == :formula + next Cask::CaskLoader.path(name) if only == :cask + + formula_path = Formulary.path(name) + cask_path = Cask::CaskLoader.path(name) + + paths = [] + + if formula_path.exist? || + (!Homebrew::EnvConfig.no_install_from_api? && + !CoreTap.instance.installed? && + Homebrew::API.formula_name?(path.basename.to_s)) + paths << formula_path + end + if cask_path.exist? || + (!Homebrew::EnvConfig.no_install_from_api? && + !CoreCaskTap.instance.installed? && + Homebrew::API.cask_token?(path.basename.to_s)) + paths << cask_path + end + + paths.empty? ? path : paths + end + end.uniq.freeze + end + end + + sig { returns(T::Array[Keg]) } + def to_default_kegs + require "missing_formula" + + @to_default_kegs ||= T.let(begin + to_formulae_and_casks(only: :formula, method: :default_kegs).freeze + rescue NoSuchKegError => e + if (reason = MissingFormula.suggest_command(e.name, "uninstall")) + $stderr.puts reason + end + raise e + end, T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)])) + T.cast(@to_default_kegs, T::Array[Keg]) + end + + sig { returns(T::Array[Keg]) } + def to_latest_kegs + require "missing_formula" + + @to_latest_kegs ||= T.let(begin + to_formulae_and_casks(only: :formula, method: :latest_kegs).freeze + rescue NoSuchKegError => e + if (reason = MissingFormula.suggest_command(e.name, "uninstall")) + $stderr.puts reason + end + raise e + end, T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)])) + T.cast(@to_latest_kegs, T::Array[Keg]) + end + + sig { returns(T::Array[Keg]) } + def to_kegs + require "missing_formula" + + @to_kegs ||= T.let(begin + to_formulae_and_casks(only: :formula, method: :kegs).freeze + rescue NoSuchKegError => e + if (reason = MissingFormula.suggest_command(e.name, "uninstall")) + $stderr.puts reason + end + raise e + end, T.nilable(T::Array[T.any(Formula, Keg, Cask::Cask)])) + T.cast(@to_kegs, T::Array[Keg]) + end + + sig { + params(only: T.nilable(Symbol), ignore_unavailable: T::Boolean, all_kegs: T.nilable(T::Boolean)) + .returns([T::Array[Keg], T::Array[Cask::Cask]]) + } + def to_kegs_to_casks(only: parent.only_formula_or_cask, ignore_unavailable: false, all_kegs: nil) + method = all_kegs ? :kegs : :default_kegs + key = [method, only, ignore_unavailable] + + @to_kegs_to_casks ||= T.let( + {}, + T.nilable( + T::Hash[ + [T.nilable(Symbol), T.nilable(Symbol), T::Boolean], + [T::Array[Keg], T::Array[Cask::Cask]], + ], + ), + ) + @to_kegs_to_casks[key] ||= T.cast( + to_formulae_and_casks(only:, ignore_unavailable:, method:) + .partition { |o| o.is_a?(Keg) } + .map(&:freeze).freeze, + [T::Array[Keg], T::Array[Cask::Cask]], + ) + end + + sig { returns(T::Array[Tap]) } + def to_taps + @to_taps ||= T.let(downcased_unique_named.map { |name| Tap.fetch name }.uniq.freeze, T.nilable(T::Array[Tap])) + end + + sig { returns(T::Array[Tap]) } + def to_installed_taps + @to_installed_taps ||= T.let(to_taps.each do |tap| + raise TapUnavailableError, tap.name unless tap.installed? + end.uniq.freeze, T.nilable(T::Array[Tap])) + end + + sig { returns(T::Array[String]) } + def homebrew_tap_cask_names + downcased_unique_named.grep(HOMEBREW_CASK_TAP_CASK_REGEX) + end + + sig { returns(T::Array[String]) } + def downcased_unique_named + # Only lowercase names, not paths, bottle filenames or URLs + map do |arg| + if arg.include?("/") || arg.end_with?(".tar.gz") || File.exist?(arg) + arg + else + arg.downcase + end + end.uniq + end + + private + + sig { + params(name: String, only: T.nilable(Symbol), method: T.nilable(Symbol), warn: T::Boolean) + .returns(T.any(Formula, Keg, Cask::Cask, T::Array[Keg])) + } + def load_formula_or_cask(name, only: nil, method: nil, warn: false) + Homebrew.with_no_api_env_if_needed(@without_api) do + unreadable_error = nil + + formula_or_kegs = if only != :cask + begin + case method + when nil, :factory + Formulary.factory(name, *@override_spec, warn:, force_bottle: @force_bottle, flags: @flags) + when :resolve + resolve_formula(name) + when :latest_kegs + resolve_latest_keg(name) + when :default_kegs + resolve_default_keg(name) + when :kegs + _, kegs = resolve_kegs(name) + kegs + else + raise + end + rescue FormulaUnreadableError, FormulaClassUnavailableError, + TapFormulaUnreadableError, TapFormulaClassUnavailableError, + FormulaSpecificationError => e + # Need to rescue before `FormulaUnavailableError` (superclass of this) + # The formula was found, but there's a problem with its implementation + unreadable_error ||= e + nil + rescue NoSuchKegError, FormulaUnavailableError => e + raise e if only == :formula + + nil + end + end + + if only == :formula + return formula_or_kegs if formula_or_kegs + elsif formula_or_kegs && (!formula_or_kegs.is_a?(Formula) || formula_or_kegs.tap&.core_tap?) + warn_if_cask_conflicts(name, "formula") + return formula_or_kegs + else + want_keg_like_cask = [:latest_kegs, :default_kegs, :kegs].include?(method) + + cask = begin + config = Cask::Config.from_args(@parent) if @cask_options + options = { warn: }.compact + untrusted_installed_cask = (load_untrusted_installed_cask(name, config:) if want_keg_like_cask) + candidate_cask = untrusted_installed_cask || Cask::CaskLoader.load(name, config:, **options) + skip_installed_caskfile_load = if untrusted_installed_cask + !untrusted_installed_cask.loaded_from_api? + else + false + end + + if unreadable_error + onoe <<~EOS + Failed to load formula: #{name} + #{unreadable_error} + EOS + opoo "Treating #{name} as a cask." + end + + # If we're trying to get a keg-like Cask, do our best to use the same cask + # file that was used for installation, if possible. + if want_keg_like_cask && !skip_installed_caskfile_load && + (installed_caskfile = candidate_cask.installed_caskfile) && + installed_caskfile.exist? + cask = Cask::CaskLoader.load_from_installed_caskfile(installed_caskfile) + + requested_tap, requested_token = Tap.with_cask_token(name) + if requested_tap && requested_token + installed_cask_tap = cask.tab.tap + + if installed_cask_tap && installed_cask_tap != requested_tap + raise Cask::TapCaskUnavailableError.new(requested_tap, requested_token) + end + end + + cask + else + candidate_cask + end + rescue Homebrew::UntrustedTapError + raise unless want_keg_like_cask + + raise unless (untrusted_installed_cask = load_untrusted_installed_cask(name, config:)) + + untrusted_installed_cask + rescue Cask::CaskUnreadableError, Cask::CaskInvalidError => e + # If we're trying to get a keg-like Cask, do our best to handle it + # not being readable and return something that can be used. + if want_keg_like_cask + cask_version = Cask::Caskroom.cask_installed_version(name) + Cask::Cask.new(name, config:) do + version cask_version if cask_version + end + else + # Need to rescue before `CaskUnavailableError` (superclass of this) + # The cask was found, but there's a problem with its implementation + unreadable_error ||= e + nil + end + rescue Cask::CaskUnavailableError => e + raise e if only == :cask + + nil + end + + # Prioritise formulae unless it's a core tap cask (we already prioritised core tap formulae above) + if formula_or_kegs && !cask&.tap&.core_cask_tap? + if cask || unreadable_error + onoe <<~EOS if unreadable_error + Failed to load cask: #{name} + #{unreadable_error} + EOS + opoo package_conflicts_message(name, "formula", cask) unless Context.current.quiet? + end + return formula_or_kegs + elsif cask + if formula_or_kegs && !Context.current.quiet? + opoo package_conflicts_message(name, "cask", formula_or_kegs) + end + return cask + end + end + + raise unreadable_error if unreadable_error + + downcased_name = name.downcase + if (tap_name = Utils.tap_from_full_name(downcased_name)) + raise TapFormulaOrCaskUnavailableError.new(Tap.fetch(tap_name), + Utils.name_from_full_name(downcased_name)) + end + + raise NoSuchKegError, name if resolve_formula(name) + end + end + + sig { + params(name: String, config: T.nilable(Cask::Config)) + .returns(T.nilable(Cask::Cask)) + } + def load_untrusted_installed_cask(name, config: nil) + return unless Homebrew::EnvConfig.require_tap_trust? + + require "trust" + + requested_tap, token = Tap.with_cask_token(name) || [nil, name] + token = ::Utils.name_from_full_name(token) + installed_cask = Cask::Cask.new(token, config:) + installed_caskfile = installed_cask.installed_caskfile + return unless installed_caskfile&.exist? + + installed_tap = installed_cask.tab.tap + return unless installed_tap + return if requested_tap && requested_tap != installed_tap + return if Homebrew::Trust.trusted?(:cask, "#{installed_tap.name}/#{token}") + + if installed_caskfile.extname == ".json" + return Cask::CaskLoader.load_from_installed_caskfile(installed_caskfile, + config:) + end + + return unless (cask_version = Cask::Caskroom.cask_installed_version(token)) + + Cask::Cask.new(token, tap: installed_tap, config:) do + version cask_version + end + end + + sig { params(name: String).returns(Formula) } + def resolve_formula(name) + Formulary.resolve(name, spec: @override_spec, force_bottle: @force_bottle, flags: @flags) + end + + sig { params(name: String).returns([Pathname, T::Array[Keg]]) } + def resolve_kegs(name) + raise UsageError if name.blank? + + require "keg" + + rack = Formulary.to_rack(name.downcase) + + kegs = rack.directory? ? rack.subdirs.map { |d| Keg.new(d) } : [] + + requested_tap, requested_formula = Tap.with_formula_name(name) + if requested_tap && requested_formula + kegs = kegs.select do |keg| + keg.tab.tap == requested_tap + end + + raise NoSuchKegError.new(requested_formula, tap: requested_tap) if kegs.none? + end + + raise NoSuchKegError, name if kegs.none? + + [rack, kegs] + end + + sig { params(name: String).returns(Keg) } + def resolve_latest_keg(name) + _, kegs = resolve_kegs(name) + + # Return keg if it is the only installed keg + return kegs.fetch(0) if kegs.length == 1 + + stable_kegs = kegs.reject { |keg| keg.version.head? } + + latest_keg = if stable_kegs.empty? + kegs.max_by do |keg| + [keg.tab.source_modified_time, keg.version.revision] + end + else + stable_kegs.max_by(&:scheme_and_version) + end + T.must(latest_keg) + end + + sig { params(name: String).returns(Keg) } + def resolve_default_keg(name) + rack, kegs = resolve_kegs(name) + + linked_keg_ref = HOMEBREW_LINKED_KEGS/rack.basename + opt_prefix = HOMEBREW_PREFIX/"opt/#{rack.basename}" + + begin + return Keg.new(opt_prefix.resolved_path) if opt_prefix.symlink? && opt_prefix.directory? + return Keg.new(linked_keg_ref.resolved_path) if linked_keg_ref.symlink? && linked_keg_ref.directory? + return kegs.fetch(0) if kegs.length == 1 + + f = if name.include?("/") || File.exist?(name) + Formulary.factory(name) + else + Formulary.from_rack(rack) + end + + unless (prefix = f.latest_installed_prefix).directory? + raise MultipleVersionsInstalledError, <<~EOS + #{rack.basename} has multiple installed versions + Run `brew uninstall --force #{rack.basename}` to remove all versions. + EOS + end + + Keg.new(prefix) + rescue FormulaUnavailableError + raise MultipleVersionsInstalledError, <<~EOS + Multiple kegs installed to #{rack} + However we don't know which one you refer to. + Please delete (with `rm -rf`!) all but one and then try again. + EOS + end + end + + sig { + params( + ref: String, loaded_type: String, + package: T.nilable(T.any(T::Array[T.any(Formula, Keg)], Cask::Cask, Formula, Keg)) + ).returns(String) + } + def package_conflicts_message(ref, loaded_type, package) + message = "Treating #{ref} as a #{loaded_type}." + case package + when Formula, Keg, Array + message += " For the formula, " + if package.is_a?(Formula) && (tap = package.tap) + message += "use #{tap.name}/#{package.name} or " + end + message += "specify the `--formula` flag. To silence this message, use the `--cask` flag." + when Cask::Cask + message += " For the cask, " + if (tap = package.tap) + message += "use #{tap.name}/#{package.token} or " + end + message += "specify the `--cask` flag. To silence this message, use the `--formula` flag." + end + message.freeze + end + + sig { params(ref: String, loaded_type: String).void } + def warn_if_cask_conflicts(ref, loaded_type) + available = true + cask = begin + Cask::CaskLoader.load(ref, warn: false) + rescue Cask::CaskUnreadableError => e + # Need to rescue before `CaskUnavailableError` (superclass of this) + # The cask was found, but there's a problem with its implementation + onoe <<~EOS + Failed to load cask: #{ref} + #{e} + EOS + nil + rescue Cask::CaskUnavailableError + # No ref conflict with a cask, do nothing + available = false + nil + end + return unless available + return if Context.current.quiet? + return if cask&.old_tokens&.include?(ref) + + opoo package_conflicts_message(ref, loaded_type, cask) + end + end + end +end diff --git a/Library/Homebrew/cli/parser.rb b/Library/Homebrew/cli/parser.rb index e43620a0cfb3b..3c386fadb998a 100644 --- a/Library/Homebrew/cli/parser.rb +++ b/Library/Homebrew/cli/parser.rb @@ -1,119 +1,397 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" +require "env_config" +require "cask/config" require "cli/args" +require "cli/error" +require "commands" +require "extend/ENV/sensitive" require "optparse" -require "set" - -COMMAND_DESC_WIDTH = 80 -OPTION_DESC_WIDTH = 43 +require "utils/tty" +require "utils/formatter" +require "utils/output" module Homebrew module CLI class Parser - attr_reader :processed_options, :hide_from_man_page + include Utils::Output::Mixin + + ArgType = T.type_alias { T.nilable(T.any(Symbol, T::Array[String], T::Array[Symbol])) } + HIDDEN_DESC_PLACEHOLDER = "@@HIDDEN@@" + SYMBOL_TO_USAGE_MAPPING = T.let({ + service: "", + text_or_regex: "|`/``/`", + url: "", + }.freeze, T::Hash[Symbol, String]) + private_constant :ArgType, :HIDDEN_DESC_PLACEHOLDER, :SYMBOL_TO_USAGE_MAPPING + + class Subcommand < T::Struct + const :name, String + const :aliases, T::Array[String], default: [] + const :alias_options, T::Hash[String, String], default: {} + prop :description, T.nilable(String), default: nil + prop :usage_banner, T.nilable(String), default: nil + const :default, T::Boolean, default: false + prop :named_args_type, ArgType, default: nil + prop :max_named_args, T.nilable(Integer), default: nil + prop :min_named_args, T.nilable(Integer), default: nil + prop :named_args_without_api, T::Boolean, default: false + const :hidden, T::Boolean, default: false + const :replacement, T.nilable(T.any(String, Symbol)), default: nil + const :odeprecated, T::Boolean, default: false + const :odisabled, T::Boolean, default: false + end + + sig { returns(Args) } + attr_reader :args + + sig { returns(Args::OptionsType) } + attr_reader :processed_options + + sig { returns(T::Boolean) } + attr_reader :hide_from_man_page + + sig { returns(T::Array[Subcommand]) } + attr_reader :subcommands - def self.parse(args = ARGV, &block) - new(args, &block).parse(args) + sig { params(cmd_path: Pathname).returns(T.nilable(CLI::Parser)) } + def self.from_cmd_path(cmd_path) + cmd_args_method_name = Commands.args_method_name(cmd_path) + cmd_name = cmd_args_method_name.to_s.delete_suffix("_args").tr("_", "-") + + begin + if ENV.clear_sensitive_environment! { Homebrew.require?(cmd_path) } + cmd = Homebrew::AbstractCommand.command(cmd_name) + if cmd + cmd.parser + else + # FIXME: remove once commands are all subclasses of `AbstractCommand`: + Homebrew.send(cmd_args_method_name) + end + end + rescue NoMethodError => e + raise if e.name.to_sym != cmd_args_method_name + + nil + end end + sig { returns(T::Array[[Symbol, String, { description: String }]]) } + def self.global_cask_options + [ + [:flag, "--appdir=", { + description: "Target location for Applications " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:appdir]}`).", + }], + [:flag, "--appimagedir=", { + description: "Target location for AppImages " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:appimagedir]}`).", + }], + [:flag, "--keyboard-layoutdir=", { + description: "Target location for Keyboard Layouts " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:keyboard_layoutdir]}`).", + }], + [:flag, "--colorpickerdir=", { + description: "Target location for Color Pickers " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:colorpickerdir]}`).", + }], + [:flag, "--prefpanedir=", { + description: "Target location for Preference Panes " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:prefpanedir]}`).", + }], + [:flag, "--qlplugindir=", { + description: "Target location for Quick Look Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:qlplugindir]}`).", + }], + [:flag, "--mdimporterdir=", { + description: "Target location for Spotlight Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:mdimporterdir]}`).", + }], + [:flag, "--dictionarydir=", { + description: "Target location for Dictionaries " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:dictionarydir]}`).", + }], + [:flag, "--fontdir=", { + description: "Target location for Fonts " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:fontdir]}`).", + }], + [:flag, "--servicedir=", { + description: "Target location for Services " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:servicedir]}`).", + }], + [:flag, "--input-methoddir=", { + description: "Target location for Input Methods " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:input_methoddir]}`).", + }], + [:flag, "--internet-plugindir=", { + description: "Target location for Internet Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:internet_plugindir]}`).", + }], + [:flag, "--audio-unit-plugindir=", { + description: "Target location for Audio Unit Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:audio_unit_plugindir]}`).", + }], + [:flag, "--vst-plugindir=", { + description: "Target location for VST Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:vst_plugindir]}`).", + }], + [:flag, "--vst3-plugindir=", { + description: "Target location for VST3 Plugins " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:vst3_plugindir]}`).", + }], + [:flag, "--screen-saverdir=", { + description: "Target location for Screen Savers " \ + "(default: `#{Cask::Config::DEFAULT_DIRS[:screen_saverdir]}`).", + }], + [:comma_array, "--language", { + description: "Comma-separated list of language codes to prefer for cask installation. " \ + "The first matching language is used, otherwise it reverts to the cask's " \ + "default language. The default value is the language of your system.", + }], + ] + end + + sig { returns(T::Array[[String, String, String]]) } def self.global_options - { - quiet: [["-q", "--quiet"], :quiet, "Suppress any warnings."], - verbose: [["-v", "--verbose"], :verbose, "Make some output more verbose."], - debug: [["-d", "--debug"], :debug, "Display any debugging information."], - force: [["-f", "--force"], :force, "Override warnings and enable potentially unsafe operations."], - } + [ + ["-d", "--debug", "Display any debugging information."], + ["-q", "--quiet", "Make some output more quiet."], + ["-v", "--verbose", "Make some output more verbose."], + ["-h", "--help", "Show this message."], + ] end - def initialize(args = ARGV, &block) - @parser = OptionParser.new - @args = Homebrew::CLI::Args.new(argv: ARGV_WITHOUT_MONKEY_PATCHING) - @args[:remaining] = [] - @args[:cmdline_args] = args.dup - @constraints = [] - @conflicts = [] - @switch_sources = {} - @processed_options = [] - @max_named_args = nil - @hide_from_man_page = false - instance_eval(&block) - post_initialize + sig { params(option: String).returns(String) } + def self.option_to_name(option) + option.sub(/\A--?(\[no-\])?/, "").tr("-", "_").delete("=") end - def post_initialize - @parser.on_tail("-h", "--help", "Show this message.") do - puts generate_help_text - exit 0 + sig { + params(cmd: T.class_of(Homebrew::AbstractCommand), block: T.nilable(T.proc.bind(Parser).void)).void + } + def initialize(cmd, &block) + @parser = T.let(OptionParser.new, OptionParser) + @parser.summary_indent = " " + # Disable default handling of `--version` switch. + @parser.base.long.delete("version") + # Disable default handling of `--help` switch. + @parser.base.long.delete("help") + + @args = T.let((cmd.args_class || Args).new, Args) + + @command_name = T.let(cmd.command_name, String) + @is_dev_cmd = T.let(cmd.dev_cmd?, T::Boolean) + + @constraints = T.let([], T::Array[[String, String, T.nilable(T::Array[String])]]) + @conflicts = T.let([], T::Array[T::Array[String]]) + @switch_sources = T.let({}, T::Hash[String, Symbol]) + @option_sources = T.let({}, T::Hash[String, Symbol]) + @option_subcommands = T.let({}, T::Hash[String, T::Array[String]]) + @option_types = T.let({}, T::Hash[String, Symbol]) + @processed_options = T.let([], Args::OptionsType) + @processed_option_summaries = T.let([], T::Array[[T.untyped, T::Boolean]]) + @processed_options_by_subcommand = T.let({}, T::Hash[T.nilable(String), Args::OptionsType]) + @processed_option_summaries_by_subcommand = + T.let({}, T::Hash[T.nilable(String), T::Array[[T.untyped, T::Boolean]]]) + @non_global_processed_options = T.let([], T::Array[[String, ArgType]]) + @named_args_type = T.let(nil, T.nilable(ArgType)) + @max_named_args = T.let(nil, T.nilable(Integer)) + @min_named_args = T.let(nil, T.nilable(Integer)) + @named_args_without_api = T.let(false, T::Boolean) + @description = T.let(nil, T.nilable(String)) + @usage_banner = T.let(nil, T.nilable(String)) + @hide_from_man_page = T.let(false, T::Boolean) + @formula_options = T.let(false, T::Boolean) + @cask_options = T.let(false, T::Boolean) + @subcommands = T.let([], T::Array[Subcommand]) + @current_subcommands = T.let(nil, T.nilable(T::Array[String])) + + self.class.global_options.each do |short, long, desc| + switch short, long, description: desc, env: option_to_name(long), method: :on_tail end + + instance_eval(&block) if block + + generate_banner end - def switch(*names, description: nil, env: nil, required_for: nil, depends_on: nil) + sig { + params(names: String, description: T.nilable(String), env: T.untyped, + depends_on: T.nilable(String), method: Symbol, + hidden: T::Boolean, replacement: T.nilable(T.any(String, FalseClass)), + odeprecated: T::Boolean, odisabled: T::Boolean, + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def switch(*names, description: nil, env: nil, + depends_on: nil, method: :on, + hidden: false, replacement: nil, + odeprecated: false, odisabled: false, + subcommands: nil) global_switch = names.first.is_a?(Symbol) - names, env, default_description = common_switch(*names) if global_switch - if description.nil? && global_switch - description = default_description - elsif description.nil? - description = option_to_description(*names) + return if global_switch + + hidden = true if odisabled || odeprecated + + description = option_description(description, *names, hidden:) + env, counterpart = env + if env + env_hidden = Homebrew::EnvConfig.hidden?(Homebrew::EnvConfig::ENVS.fetch(:"HOMEBREW_#{env.upcase}", + {})) end - process_option(*names, description) - @parser.on(*names, *wrap_option_desc(description)) do - enable_switch(*names, from: :args) + if env && @non_global_processed_options.any? && !hidden && !env_hidden + affix = if counterpart + " and `#{counterpart}` is passed." + else + "." + end + description += " Enabled by default if `$HOMEBREW_#{env.upcase}` is set#{affix}" + end + process_option(*names, description, type: :switch, hidden:, subcommands:) unless odisabled + + @parser.public_send(method, *names, *wrap_option_desc(description)) do |value| + # This odeprecated should stick around indefinitely. + replacement_string = replacement if replacement + if odeprecated || odisabled + odeprecated "the `#{names.first}` switch", replacement_string, disable: odisabled + end + value = true if names.none? { |name| name.start_with?("--[no-]") } + + set_switch(*names, value:, from: :args) end names.each do |name| - set_constraints(name, required_for: required_for, depends_on: depends_on) + set_constraints(name, depends_on:, subcommands:) end - enable_switch(*names, from: :env) if !env.nil? && !ENV["HOMEBREW_#{env.to_s.upcase}"].nil? + env_value = value_for_env(env) + value = env_value&.present? + set_switch(*names, value:, from: :env) unless value.nil? end alias switch_option switch + sig { params(text: T.nilable(String)).returns(T.nilable(String)) } + def description(text = nil) + return @description if text.blank? + + @description = text.chomp + end + + sig { params(text: String).void } def usage_banner(text) - @parser.banner = Formatter.wrap("#{text}\n", COMMAND_DESC_WIDTH) + banner, description = text.chomp.split("\n\n", 2) + + if @current_subcommands.present? + subcommand_description = description + if subcommand_description.blank? + usage_banner_lines = text.chomp.lines + subcommand_description = usage_banner_lines.drop(1).join if usage_banner_lines.size > 1 + end + + @current_subcommands.each do |subcommand_name| + subcommand = subcommand_for_name(subcommand_name) + raise ArgumentError, "unknown subcommand: #{subcommand_name}" if subcommand.nil? + + subcommand.usage_banner = text.chomp + next if subcommand.description.present? || subcommand_description.blank? + + subcommand.description = subcommand_description.lines.first&.chomp + end + return + end + + @usage_banner = banner + @description = description end - def usage_banner_text - @parser.banner + sig { returns(T.nilable(String)) } + def usage_banner_text = @parser.banner + + sig { returns(T.nilable(String)) } + def root_usage_banner_text = @usage_banner + + sig { returns(ArgType) } + def named_args_type + return @named_args_type if @subcommands.empty? + + subcommand_names end - def comma_array(name, description: nil) - description = option_to_description(name) if description.nil? - process_option(name, description) + sig { + params(name: String, description: T.nilable(String), hidden: T::Boolean, + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def comma_array(name, description: nil, hidden: false, subcommands: nil) + name = name.chomp "=" + description = option_description(description, name, hidden:) + process_option(name, description, type: :comma_array, hidden:, subcommands:) @parser.on(name, OptionParser::REQUIRED_ARGUMENT, Array, *wrap_option_desc(description)) do |list| - @args[option_to_name(name)] = list + option_name = option_to_name(name) + @option_sources[option_name] = :args + @option_types[option_name] ||= :comma_array + set_args_method(option_name.to_sym, list) end end - def flag(*names, description: nil, required_for: nil, depends_on: nil) - if names.any? { |name| name.end_with? "=" } - required = OptionParser::REQUIRED_ARGUMENT + sig { + params(names: String, description: T.nilable(String), replacement: T.nilable(T.any(Symbol, String)), + depends_on: T.nilable(String), hidden: T::Boolean, odeprecated: T::Boolean, odisabled: T::Boolean, + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def flag(*names, description: nil, replacement: nil, depends_on: nil, hidden: false, odeprecated: false, + odisabled: false, subcommands: nil) + required, flag_type = if names.any? { |name| name.end_with? "=" } + [OptionParser::REQUIRED_ARGUMENT, :required_flag] else - required = OptionParser::OPTIONAL_ARGUMENT + [OptionParser::OPTIONAL_ARGUMENT, :optional_flag] end names.map! { |name| name.chomp "=" } - description = option_to_description(*names) if description.nil? - process_option(*names, description) + hidden = true if odeprecated || odisabled + description = option_description(description, *names, hidden:) + if odisabled + description += " (disabled#{"; replaced by #{replacement}" if replacement.present?})" + else + process_option(*names, description, type: flag_type, hidden:, subcommands:) + end @parser.on(*names, *wrap_option_desc(description), required) do |option_value| + # This odisabled should stick around indefinitely. + odeprecated "the `#{names.first}` flag", replacement, disable: odisabled if odeprecated || odisabled names.each do |name| - @args[option_to_name(name)] = option_value + option_name = option_to_name(name) + @option_sources[option_name] = :args + @option_types[option_name] ||= flag_type + set_args_method(option_name.to_sym, option_value) end end names.each do |name| - set_constraints(name, required_for: required_for, depends_on: depends_on) + set_constraints(name, depends_on:, subcommands:) end end + sig { params(name: Symbol, value: T.untyped).void } + def set_args_method(name, value) + @args.set_arg(name, value) + return if @args.respond_to?(name) + + @args.define_singleton_method(name) do + # We cannot reference the ivar directly due to https://github.com/sorbet/sorbet/issues/8106 + instance_variable_get(:@table).fetch(name) + end + end + + sig { params(options: String).returns(T::Array[T::Array[String]]) } def conflicts(*options) + return @conflicts if options.empty? + @conflicts << options.map { |option| option_to_name(option) } end - def option_to_name(option) - option.sub(/\A--?/, "") - .tr("-", "_") - .delete("=") - end + sig { params(option: String).returns(String) } + def option_to_name(option) = self.class.option_to_name(option) + sig { params(name: String).returns(String) } def name_to_option(name) if name.length == 1 "-#{name}" @@ -122,124 +400,495 @@ def name_to_option(name) end end + sig { params(names: String).returns(T.nilable(String)) } def option_to_description(*names) names.map { |name| name.to_s.sub(/\A--?/, "").tr("-", " ") }.max end - def summary - @parser.to_s + sig { params(description: T.nilable(String), names: String, hidden: T::Boolean).returns(String) } + def option_description(description, *names, hidden: false) + return HIDDEN_DESC_PLACEHOLDER if hidden + return description if description.present? + + option_to_description(*names) end - def parse(cmdline_args = ARGV) + sig { + params(argv: T::Array[String], ignore_invalid_options: T::Boolean) + .returns([T::Array[String], T::Array[String]]) + } + def parse_remaining(argv, ignore_invalid_options: false) + i = 0 + remaining = [] + + argv, non_options = split_non_options(argv) + allow_commands = Array(@named_args_type).include?(:command) + + while i < argv.count + begin + begin + arg = argv[i] + + remaining << arg unless @parser.parse([arg]).empty? + rescue OptionParser::MissingArgument + raise if i + 1 >= argv.count + + args = argv[i..(i + 1)] + @parser.parse(args) + i += 1 + end + rescue OptionParser::InvalidOption + if ignore_invalid_options || (allow_commands && arg && Commands.path(arg)) + remaining << arg + else + $stderr.puts generate_help_text + raise + end + end + + i += 1 + end + + [remaining, non_options] + end + + sig { params(argv: T::Array[String], ignore_invalid_options: T::Boolean).returns(Args) } + def parse(argv = ARGV.freeze, ignore_invalid_options: false) raise "Arguments were already parsed!" if @args_parsed - begin - remaining_args = @parser.parse(cmdline_args) - rescue OptionParser::InvalidOption => e - $stderr.puts generate_help_text - raise e - end - check_constraint_violations - check_named_args(remaining_args) - @args[:remaining] = remaining_args + # If we accept formula options, but the command isn't scoped only + # to casks, parse once allowing invalid options so we can get the + # remaining list containing formula names. + if @formula_options && !only_casks?(argv) + remaining, non_options = parse_remaining(argv, ignore_invalid_options: true) + + argv = [*remaining, "--", *non_options] + + formulae(argv).each do |f| + next if f.options.empty? + + f.options.each do |o| + name = o.flag + description = "`#{f.name}`: #{o.description}" + if name.end_with? "=" + flag(name, description:) + else + switch name, description: + end + + conflicts "--cask", name + end + end + end + + remaining, non_options = parse_remaining(argv, ignore_invalid_options:) + + named_args = if ignore_invalid_options + [] + else + remaining + non_options + end + + unless ignore_invalid_options + if @subcommands.present? && named_args.present? + subcommand_arg = named_args.fetch(0) + if (subcommand = subcommand_for_name(subcommand_arg)) && subcommand.alias_options.key?(subcommand_arg) + set_switch(subcommand.alias_options.fetch(subcommand_arg), value: true, from: :args) + end + end + unless @is_dev_cmd + set_default_options + validate_options + end + check_constraint_violations(named_args) + check_named_args(named_args) + check_subcommand_violations(named_args) + end + + if @subcommands.present? + parsed_subcommand = subcommand_name(named_args) + # This odeprecated should stick around indefinitely. + if parsed_subcommand && (subcommand = subcommand_for_name(parsed_subcommand)) && + (subcommand.odeprecated || subcommand.odisabled) + odeprecated "the `#{subcommand.name}` subcommand", subcommand.replacement, + disable: subcommand.odisabled + end + set_args_method(:subcommand, parsed_subcommand) + named_args = if parsed_subcommand && named_args.present? + named_args.drop(1) + else + [] + end + end + @args.freeze_named_args!(named_args, cask_options: @cask_options, without_api: @named_args_without_api) + remaining_args = if non_options.empty? + remaining + else + [*remaining, "--", non_options] + end + @args.freeze_remaining_args!(remaining_args) @args.freeze_processed_options!(@processed_options) - Homebrew.args = @args - cmdline_args.freeze - @args_parsed = true - @parser + @args.freeze + + @args_parsed = T.let(true, T.nilable(TrueClass)) + + if !ignore_invalid_options && @args.help? + puts generate_help_text(remaining_args: @subcommands.present? ? remaining : nil) + exit + end + + @args end - def global_option?(name, desc) - Homebrew::CLI::Parser.global_options.key?(name.to_sym) && - Homebrew::CLI::Parser.global_options[name.to_sym].last == desc + sig { void } + def set_default_options; end + + sig { void } + def validate_options; end + + sig { params(remaining_args: T.nilable(T::Array[String])).returns(String) } + def generate_help_text(remaining_args: nil) + help_text = if remaining_args.nil? || @subcommands.empty? + @parser.to_s + elsif (subcommand = remaining_args.filter_map do |arg| + subcommand_for_name(arg) unless arg.start_with?("-") + end.first) + parts = T.let([], T::Array[String]) + parts << (subcommand.usage_banner || "`#{@command_name} #{subcommand.name}`").sub(/\A`brew /, "`") + option_summaries = option_summaries_text(subcommand.name) + parts << option_summaries if option_summaries.present? + parts.join("\n\n") + else + parts = T.let([], T::Array[String]) + usage_banner = @usage_banner + description = @description + parts << usage_banner if usage_banner.present? + parts << description if description.present? + + subcommand_lines = @subcommands.filter_map do |subcommand| + next if subcommand.hidden + + subcommand_summary = if (usage_banner = subcommand.usage_banner) + usage_banner.lines.drop(1).map(&:strip).find(&:present?) + end + subcommand_summary ||= subcommand.description + if subcommand_summary.present? + " `#{subcommand.name}`: #{subcommand_summary}" + else + " `#{subcommand.name}`" + end + end + parts << "Subcommands:\n#{subcommand_lines.join("\n")}" + + option_summaries = option_summaries_text(nil) + parts << option_summaries if option_summaries.present? + parts.join("\n\n") + end + + Formatter.format_help_text(help_text, width: Formatter::COMMAND_DESC_WIDTH) + .gsub(/\n.*?@@HIDDEN@@.*?(?=\n)/, "") + .sub(/^/, "#{Tty.bold}Usage: brew#{Tty.reset} ") + .gsub(/`(.*?)`/m, "#{Tty.bold}\\1#{Tty.reset}") + .gsub(%r{<([^\s]+?://[^\s]+?)>}) { |url| Formatter.url(url) } + .gsub(/\*(.*?)\*|<(.*?)>/m) do |underlined| + underlined[1...-1].to_s.gsub(/^(\s*)(.*?)$/, "\\1#{Tty.underline}\\2#{Tty.reset}") + end end - def generate_help_text - @parser.to_s.sub(/^/, "#{Tty.bold}Usage: brew#{Tty.reset} ") - .gsub(/`(.*?)`/m, "#{Tty.bold}\\1#{Tty.reset}") - .gsub(%r{<([^\s]+?://[^\s]+?)>}) { |url| Formatter.url(url) } - .gsub(/<(.*?)>/m, "#{Tty.underline}\\1#{Tty.reset}") - .gsub(/\*(.*?)\*/m, "#{Tty.underline}\\1#{Tty.reset}") + sig { void } + def cask_options + self.class.global_cask_options.each do |args| + options = T.cast(args.pop, T::Hash[Symbol, String]) + send(*args, **options) + conflicts "--formula", args[1] + end + @cask_options = true end + sig { void } def formula_options - @args.formulae.each do |f| - next if f.options.empty? - - f.options.each do |o| - name = o.flag - description = "`#{f.name}`: #{o.description}" - if name.end_with? "=" - flag name, description: description - else - switch name, description: description + @formula_options = true + end + + sig { + params( + type: ArgType, + number: T.nilable(Integer), + min: T.nilable(Integer), + max: T.nilable(Integer), + without_api: T::Boolean, + ).void + } + def named_args(type = nil, number: nil, min: nil, max: nil, without_api: false) + raise ArgumentError, "Do not specify both `number` and `min` or `max`" if number && (min || max) + + if type == :none && (number || min || max) + raise ArgumentError, "Do not specify both `number`, `min` or `max` with `named_args :none`" + end + + if @current_subcommands.present? + @current_subcommands.each do |subcommand_name| + subcommand = subcommand_for_name(subcommand_name) + raise ArgumentError, "unknown subcommand: #{subcommand_name}" if subcommand.nil? + + subcommand.named_args_type = type + if type == :none + subcommand.max_named_args = 0 + elsif number + subcommand.min_named_args = subcommand.max_named_args = number + elsif min || max + subcommand.min_named_args = min + subcommand.max_named_args = max end + subcommand.named_args_without_api = without_api end + return end - rescue FormulaUnavailableError - [] - end - def max_named(count) - @max_named_args = count + @named_args_type = type + + if type == :none + @max_named_args = 0 + elsif number + @min_named_args = @max_named_args = number + elsif min || max + @min_named_args = min + @max_named_args = max + end + + @named_args_without_api = without_api end + sig { void } def hide_from_man_page! @hide_from_man_page = true end + sig { + params( + name: String, + aliases: T::Array[String], + alias_options: T::Hash[String, String], + description: T.nilable(String), + default: T::Boolean, + hidden: T::Boolean, + replacement: T.nilable(T.any(String, Symbol)), + odeprecated: T::Boolean, + odisabled: T::Boolean, + block: T.nilable(T.proc.bind(Parser).void), + ).void + } + def subcommand(name, aliases: [], alias_options: {}, description: nil, default: false, hidden: false, + replacement: nil, odeprecated: false, odisabled: false, &block) + previous_subcommands = @current_subcommands + hidden = true if odeprecated || odisabled + + @subcommands << Subcommand.new( + name:, + aliases: aliases | alias_options.keys, + alias_options:, + description:, + default:, + hidden:, + replacement:, + odeprecated:, + odisabled:, + ) + + @current_subcommands = [name] + instance_eval(&block) if block + ensure + @current_subcommands = previous_subcommands + end + + sig { returns(T::Array[String]) } + def subcommand_names = @subcommands.map(&:name) + + sig { returns(T.nilable(Subcommand)) } + def default_subcommand = @subcommands.find(&:default) + + sig { params(name: String).returns(T.nilable(Subcommand)) } + def subcommand_for_name(name) + @subcommands.find { |subcommand| subcommand.name == name || subcommand.aliases.include?(name) } + end + + sig { params(subcommand_name: T.nilable(String)).returns(Args::OptionsType) } + def processed_options_for_subcommand(subcommand_name) + subcommand = if subcommand_name + subcommand_for_name(subcommand_name) + else + default_subcommand + end + canonical_subcommand = subcommand&.name + + root_options = @processed_options_by_subcommand.fetch(nil, []) + return root_options if canonical_subcommand.nil? + + root_options + @processed_options_by_subcommand.fetch(canonical_subcommand, []) + end + + sig { returns(Args::OptionsType) } + def processed_options_for_root_command + @processed_options_by_subcommand.fetch(nil, []) + end + + sig { params(subcommand_name: String).returns(ArgType) } + def named_args_type_for_subcommand(subcommand_name) + subcommand_for_name(subcommand_name)&.named_args_type + end + + sig { params(named_args: T::Array[String]).returns(T.nilable(String)) } + def subcommand_name(named_args) + subcommand = if named_args.empty? + default_subcommand + else + subcommand_for_name(named_args.fetch(0)) + end + + subcommand&.name + end + + sig { params(option: String).returns(T::Array[String]) } + def subcommands_for_option(option) + @option_subcommands.fetch(option_to_name(option), []) + end + private - def enable_switch(*names, from:) - names.each do |name| - @switch_sources[option_to_name(name)] = from - @args["#{option_to_name(name)}?"] = true + sig { returns(String) } + def generate_usage_banner + command_names = ["`#{@command_name}`"] + aliases_to_skip = %w[instal uninstal] + command_names += Commands::HOMEBREW_INTERNAL_COMMAND_ALIASES.filter_map do |command_alias, command| + next if aliases_to_skip.include? command_alias + + "`#{command_alias}`" if command == @command_name + end.sort + + options = if @non_global_processed_options.empty? + "" + elsif @non_global_processed_options.count > 2 + " []" + else + required_argument_types = [:required_flag, :comma_array] + @non_global_processed_options.map do |option, type| + next " [`#{option}=`]" if required_argument_types.include? type + + " [`#{option}`]" + end.join + end + + named_args = "" + if @named_args_type.present? && @named_args_type != :none + arg_type = if @named_args_type.is_a? Array + types = @named_args_type.filter_map do |type| + next unless type.is_a? Symbol + next SYMBOL_TO_USAGE_MAPPING[type] if SYMBOL_TO_USAGE_MAPPING.key?(type) + + "<#{type}>" + end + types << "" if @named_args_type.any?(String) + types.join("|") + elsif SYMBOL_TO_USAGE_MAPPING.key? @named_args_type + SYMBOL_TO_USAGE_MAPPING[@named_args_type] + else + "<#{@named_args_type}>" + end + + named_args = if @min_named_args.nil? && @max_named_args == 1 + " [#{arg_type}]" + elsif @min_named_args.nil? + " [#{arg_type} ...]" + elsif @min_named_args == 1 && @max_named_args == 1 + " #{arg_type}" + elsif @min_named_args == 1 + " #{arg_type} [...]" + else + " #{arg_type} ..." + end end + + "#{command_names.join(", ")}#{options}#{named_args}" end - def disable_switch(*names) + sig { returns(String) } + def generate_banner + @usage_banner ||= generate_usage_banner + + @parser.banner = <<~BANNER + #{@usage_banner} + + #{usage_description_text} + + BANNER + end + + sig { params(names: String, value: T.untyped, from: Symbol).void } + def set_switch(*names, value:, from:) names.each do |name| - @args.delete_field("#{option_to_name(name)}?") + option_name = option_to_name(name) + @switch_sources[option_name] = from + @option_sources[option_name] = from + @option_types[option_name] ||= :switch + set_args_method(:"#{option_name}?", value) end end - # These are common/global switches accessible throughout Homebrew - def common_switch(name) - Homebrew::CLI::Parser.global_options.fetch(name, name) + sig { params(args: String).void } + def disable_switch(*args) + args.each do |name| + result = if name.start_with?("--[no-]") + nil + else + false + end + set_args_method(:"#{option_to_name(name)}?", result) + end end + sig { params(name: String).returns(T::Boolean) } def option_passed?(name) - @args.respond_to?(name) || @args.respond_to?("#{name}?") + [name.to_sym, :"#{name}?"].any? do |method| + @args.public_send(method) if @args.respond_to?(method) + end end + sig { params(desc: String).returns(T::Array[String]) } def wrap_option_desc(desc) - Formatter.wrap(desc, OPTION_DESC_WIDTH).split("\n") + Formatter.format_help_text(desc, width: Formatter::OPTION_DESC_WIDTH).split("\n") end - def set_constraints(name, depends_on:, required_for:) - secondary = option_to_name(name) - unless required_for.nil? - primary = option_to_name(required_for) - @constraints << [primary, secondary, :mandatory] - end - + sig { + params(name: String, depends_on: T.nilable(String), + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def set_constraints(name, depends_on:, subcommands:) return if depends_on.nil? primary = option_to_name(depends_on) - @constraints << [primary, secondary, :optional] + secondary = option_to_name(name) + @constraints << [primary, secondary, effective_subcommands(subcommands)] end - def check_constraints - @constraints.each do |primary, secondary, constraint_type| + sig { params(args: T::Array[String]).void } + def check_constraints(args) + subcommand = subcommand_name(args) + @constraints.each do |primary, secondary, subcommands| + next if subcommands.present? && (subcommand.nil? || subcommands.exclude?(subcommand)) + primary_passed = option_passed?(primary) secondary_passed = option_passed?(secondary) - if :mandatory.equal?(constraint_type) && primary_passed && !secondary_passed - raise OptionConstraintError.new(primary, secondary) - end - raise OptionConstraintError.new(primary, secondary, missing: true) if secondary_passed && !primary_passed + + next if !secondary_passed || (primary_passed && secondary_passed) + + primary = name_to_option(primary) + secondary = name_to_option(secondary) + + raise OptionConstraintError.new(primary, secondary, missing: true) end end + sig { void } def check_conflicts @conflicts.each do |mutually_exclusive_options_group| violations = mutually_exclusive_options_group.select do |option| @@ -253,15 +902,16 @@ def check_conflicts end select_cli_arg = violations.count - env_var_options.count == 1 - raise OptionConflictError, violations.map(&method(:name_to_option)) unless select_cli_arg + raise OptionConflictError, violations.map { name_to_option(it) } unless select_cli_arg - env_var_options.each(&method(:disable_switch)) + env_var_options.each { disable_switch(it) } end end + sig { void } def check_invalid_constraints @conflicts.each do |mutually_exclusive_options_group| - @constraints.each do |p, s| + @constraints.each do |p, s, _subcommands| next unless Set[p, s].subset?(Set[*mutually_exclusive_options_group]) raise InvalidConstraintError.new(p, s) @@ -269,58 +919,246 @@ def check_invalid_constraints end end - def check_constraint_violations + sig { params(args: T::Array[String]).void } + def check_constraint_violations(args) check_invalid_constraints check_conflicts - check_constraints + check_constraints(args) end + sig { params(args: T::Array[String], type: ArgType, min: T.nilable(Integer), max: T.nilable(Integer)).void } + def check_named_args_count(args, type, min, max) + types = Array(type).filter_map do |type| + next type if type.is_a? Symbol + + :subcommand + end.uniq + + exception = if min && max && min == max && args.size != max + NumberOfNamedArgumentsError.new(min, types:) + elsif min && args.size < min + MinNamedArgumentsError.new(min, types:) + elsif max && args.size > max + MaxNamedArgumentsError.new(max, types:) + end + + raise exception if exception + end + + sig { params(args: T::Array[String]).void } def check_named_args(args) - raise NamedArgumentsError, @max_named_args if !@max_named_args.nil? && args.size > @max_named_args + check_named_args_count(args, @named_args_type, @min_named_args, @max_named_args) + end + + sig { params(args: T::Array[String]).void } + def check_subcommand_violations(args) + return if @subcommands.empty? + + subcommand = if args.empty? + default_subcommand + else + subcommand_for_name(args.fetch(0)) + end + raise UsageError, "unknown subcommand: `#{args.first}`" if subcommand.nil? && args.present? + return if subcommand.nil? + + subcommand_args = if args.empty? + args + else + args.drop(1) + end + check_named_args_count(subcommand_args, subcommand.named_args_type, subcommand.min_named_args, + subcommand.max_named_args) + @option_sources.each do |option, source| + next if source == :env + next if option_allowed_for_subcommand?(option, subcommand.name) + + option_type = @option_types.fetch(option) + option_type_name = if option_type == :switch + "switch" + else + "flag" + end + raise UsageError, "The `#{subcommand.name}` subcommand does not accept the `#{name_to_option(option)}` " \ + "#{option_type_name}." + end + end + + sig { returns(String) } + def usage_description_text + parts = T.let([], T::Array[String]) + parts << @description if @description.present? + @subcommands.each do |subcommand| + usage_banner = subcommand.usage_banner + parts << usage_banner if usage_banner.present? + end + parts.join("\n\n") + end + + sig { params(subcommand_name: T.nilable(String)).returns(String) } + def option_summaries_text(subcommand_name) + lines = T.let([], T::Array[String]) + short_options = T.let({}, T::Hash[String, T::Boolean]) + long_options = T.let({}, T::Hash[String, T::Boolean]) + + processed_option_summaries = @processed_option_summaries_by_subcommand.fetch(nil, []) + if subcommand_name.present? + processed_option_summaries += @processed_option_summaries_by_subcommand.fetch(subcommand_name, []) + end + + processed_option_summaries.each do |option, hidden| + next if hidden + + option.summarize(short_options, long_options, @parser.summary_width, @parser.summary_width - 1, + @parser.summary_indent) { |line| lines << line } + end + + lines.join("\n") + end + + sig { params(option: String, subcommand_name: T.nilable(String)).returns(T::Boolean) } + def option_allowed_for_subcommand?(option, subcommand_name) + subcommands = @option_subcommands[option] + return true if subcommands.blank? + + subcommand_name.present? && subcommands.include?(subcommand_name) + end + + sig { params(subcommands: T.nilable(T.any(String, T::Array[String]))).returns(T.nilable(T::Array[String])) } + def effective_subcommands(subcommands) + return @current_subcommands if subcommands.nil? + + Array(subcommands).map do |subcommand| + subcommand_for_name(subcommand)&.name || subcommand + end + end + + sig { + params(option_names: T::Array[String], type: Symbol, + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def record_option_metadata(option_names, type:, subcommands:) + option_names.each do |name| + option_name = option_to_name(name) + @option_types[option_name] = type + # Global options are accepted everywhere, so a subcommand block + # re-declaring one (e.g. for a custom description) must not constrain it. + next if global_option?(name) + + effective_subcommands = effective_subcommands(subcommands) + next if effective_subcommands.blank? + + @option_subcommands[option_name] = (@option_subcommands[option_name] || []) | + effective_subcommands + end + end + + sig { params(name: String).returns(T::Boolean) } + def global_option?(name) + option_name = option_to_name(name) + self.class.global_options.any? do |short, long, _desc| + option_to_name(short) == option_name || option_to_name(long) == option_name + end end - def process_option(*args) + sig { + params(args: String, type: Symbol, hidden: T::Boolean, + subcommands: T.nilable(T.any(String, T::Array[String]))).void + } + def process_option(*args, type:, hidden: false, subcommands: nil) option, = @parser.make_switch(args) - @processed_options << [option.short.first, option.long.first, option.arg, option.desc.first] + @processed_options.reject! { |existing| existing.second == option.long.first } if option.long.first.present? + if option.long.first.present? + @processed_option_summaries.reject! do |existing,| + existing.long.first == option.long.first + end + end + @processed_options << [option.short.first, option.long.first, option.desc.first, hidden] + @processed_option_summaries << [option, hidden] + + display_subcommands = effective_subcommands(subcommands) + subcommand_names = T.let([], T::Array[T.nilable(String)]) + if display_subcommands.blank? + subcommand_names << nil + else + subcommand_names.concat(display_subcommands) + end + + subcommand_names.each do |subcommand_name| + processed_options = @processed_options_by_subcommand[subcommand_name] ||= [] + processed_options.reject! { |existing| existing.second == option.long.first } if option.long.first.present? + processed_options << [option.short.first, option.long.first, option.desc.first, hidden] + + processed_option_summaries = @processed_option_summaries_by_subcommand[subcommand_name] ||= [] + if option.long.first.present? + processed_option_summaries.reject! { |existing,| existing.long.first == option.long.first } + end + processed_option_summaries << [option, hidden] + end + + args.pop # last argument is the description + record_option_metadata(args, type:, subcommands:) + if type == :switch + disable_switch(*args) + else + args.each do |name| + set_args_method(option_to_name(name).to_sym, nil) + end + end + + return if hidden + return if self.class.global_options.include? [option.short.first, option.long.first, option.desc.first] + + @non_global_processed_options << [option.long.first || option.short.first, type] end - end - class OptionConstraintError < RuntimeError - def initialize(arg1, arg2, missing: false) - message = if !missing - "`#{arg1}` and `#{arg2}` should be passed together." + sig { params(argv: T::Array[String]).returns([T::Array[String], T::Array[String]]) } + def split_non_options(argv) + if (sep = argv.index("--")) + [argv.take(sep), argv.drop(sep + 1)] else - "`#{arg2}` cannot be passed without `#{arg1}`." + [argv, []] end - super message end - end - class OptionConflictError < RuntimeError - def initialize(args) - args_list = args.map(&Formatter.public_method(:option)) - .join(" and ") - super "Options #{args_list} are mutually exclusive." + sig { params(argv: T::Array[String]).returns(T::Array[Formula]) } + def formulae(argv) + argv, non_options = split_non_options(argv) + + named_args = argv.reject { |arg| arg.start_with?("-") } + non_options + spec = if argv.include?("--HEAD") + :head + else + :stable + end + + # Only lowercase names, not paths, bottle filenames or URLs + named_args.filter_map do |arg| + next if arg.match?(HOMEBREW_CASK_TAP_CASK_REGEX) + + begin + Formulary.factory(arg, spec, flags: argv.select { |a| a.start_with?("--") }) + rescue FormulaUnavailableError, FormulaSpecificationError + nil + end + end.uniq(&:name) end - end - class InvalidConstraintError < RuntimeError - def initialize(arg1, arg2) - super "`#{arg1}` and `#{arg2}` cannot be mutually exclusive and mutually dependent simultaneously." + sig { params(argv: T::Array[String]).returns(T::Boolean) } + def only_casks?(argv) + argv.include?("--casks") || argv.include?("--cask") end - end - class NamedArgumentsError < UsageError - def initialize(maximum) - message = case maximum - when 0 - "This command does not take named arguments." - when 1 - "This command does not take multiple named arguments." + sig { params(env: T.nilable(T.any(String, Symbol))).returns(T.untyped) } + def value_for_env(env) + return if env.blank? + + method_name = :"#{env}?" + if Homebrew::EnvConfig.respond_to?(method_name) + Homebrew::EnvConfig.public_send(method_name) else - "This command does not take more than #{maximum} named arguments." + ENV.fetch("HOMEBREW_#{env.upcase}", nil) end - super message end end end diff --git a/Library/Homebrew/cmake/trap_fetchcontent_provider.cmake b/Library/Homebrew/cmake/trap_fetchcontent_provider.cmake new file mode 100644 index 0000000000000..9eba3eb8e65c1 --- /dev/null +++ b/Library/Homebrew/cmake/trap_fetchcontent_provider.cmake @@ -0,0 +1,18 @@ +# Dependency providers were introduced in CMake 3.24. We don't set cmake_minimum_required here because that would +# propagate to downstream projects, which may break projects that rely on deprecated CMake behavior. Since the build +# is using brewed CMake, we can assume that the CMake version in use is at least 3.24. + +option(HOMEBREW_ALLOW_FETCHCONTENT "Allow FetchContent to be used in Homebrew builds" OFF) + +if (HOMEBREW_ALLOW_FETCHCONTENT) + return() +endif() + +macro(trap_fetchcontent_provider method depName) + message(FATAL_ERROR "Refusing to populate dependency '${depName}' with FetchContent while building in Homebrew, please use a formula dependency or add a resource to the formula.") +endmacro() + +cmake_language( + SET_DEPENDENCY_PROVIDER trap_fetchcontent_provider + SUPPORTED_METHODS FETCHCONTENT_MAKEAVAILABLE_SERIAL +) diff --git a/Library/Homebrew/cmd/--cache.rb b/Library/Homebrew/cmd/--cache.rb index 5e72c776ccfab..49ff0a44624fe 100644 --- a/Library/Homebrew/cmd/--cache.rb +++ b/Library/Homebrew/cmd/--cache.rb @@ -1,41 +1,123 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "fetch" -require "cli/parser" +require "cask/download" module Homebrew - module_function - - def __cache_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--cache` [] [] - - Display Homebrew's download cache. See also `HOMEBREW_CACHE`. - - If is provided, display the file or directory used to cache . - EOS - switch "-s", "--build-from-source", - description: "Show the cache file used when building from source." - switch "--force-bottle", - description: "Show the cache file used when pouring a bottle." - conflicts "--build-from-source", "--force-bottle" - end - end + module Cmd + class Cache < AbstractCommand + include Fetch + + sig { override.returns(String) } + def self.command_name = "--cache" + + cmd_args do + description <<~EOS + Display Homebrew's download cache. See also `$HOMEBREW_CACHE`. + + If a or is provided, display the file or directory used to cache it. + EOS + flag "--os=", + description: "Show cache file for the given operating system. " \ + "(Pass `all` to show cache files for all operating systems.)" + flag "--arch=", + description: "Show cache file for the given CPU architecture. " \ + "(Pass `all` to show cache files for all architectures.)" + switch "-s", "--build-from-source", + description: "Show the cache file used when building from source." + switch "--force-bottle", + description: "Show the cache file used when pouring a bottle." + flag "--bottle-tag=", + description: "Show the cache file used when pouring a bottle for the given tag." + switch "--HEAD", + description: "Show the cache file used when building from HEAD." + switch "--formula", "--formulae", + description: "Only show cache files for formulae." + switch "--cask", "--casks", + description: "Only show cache files for casks." + + conflicts "--build-from-source", "--force-bottle", "--bottle-tag", "--HEAD", "--cask" + conflicts "--formula", "--cask" + conflicts "--os", "--bottle-tag" + conflicts "--arch", "--bottle-tag" + + named_args [:formula, :cask] + end + + sig { override.void } + def run + if args.no_named? + puts HOMEBREW_CACHE + return + end + + formulae_or_casks = T.cast( + args.named.to_formulae_and_casks, + T::Array[T.any(Formula, Cask::Cask)], + ) + os_arch_combinations = args.os_arch_combinations - def __cache - __cache_args.parse + formulae_or_casks.each do |formula_or_cask| + ref = formula_or_cask.reloadable_ref - if ARGV.named.empty? - puts HOMEBREW_CACHE - else - Homebrew.args.formulae.each do |f| - if Fetch.fetch_bottle?(f) - puts f.bottle.cached_download + case formula_or_cask + when Formula + os_arch_combinations.each do |os, arch| + SimulateSystem.with(os:, arch:) do + print_formula_cache(Formulary.factory(ref), os:, arch:) + end + end + when Cask::Cask + os_arch_combinations.each do |os, arch| + next if os == :linux + + SimulateSystem.with(os:, arch:) do + print_cask_cache(Cask::CaskLoader.load(ref)) + end + end + end + end + end + + private + + sig { params(formula: Formula, os: Symbol, arch: Symbol).void } + def print_formula_cache(formula, os:, arch:) + if fetch_bottle?( + formula, + force_bottle: args.force_bottle?, + bottle_tag: args.bottle_tag&.to_sym, + build_from_source_formulae: args.build_from_source_formulae, + os: args.os&.to_sym, + arch: args.arch&.to_sym, + ) + bottle_tag = Utils::Bottles::Tag.from_arg(args.bottle_tag&.to_sym, os:, arch:) + + bottle = formula.bottle_for_tag(bottle_tag) + + if bottle.nil? + opoo "Bottle for tag #{bottle_tag.to_sym.inspect} is unavailable." + return + end + + puts bottle.cached_download + elsif args.HEAD? + if (head = formula.head) + puts head.cached_download + else + opoo "No head is defined for #{formula.full_name}." + end else - puts f.cached_download + puts formula.cached_download end end + + sig { params(cask: Cask::Cask).void } + def print_cask_cache(cask) + puts Cask::Download.new(cask).downloader.cached_location + end end end end diff --git a/Library/Homebrew/cmd/--caskroom.rb b/Library/Homebrew/cmd/--caskroom.rb new file mode 100644 index 0000000000000..a9debf878aec2 --- /dev/null +++ b/Library/Homebrew/cmd/--caskroom.rb @@ -0,0 +1,35 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Caskroom < AbstractCommand + sig { override.returns(String) } + def self.command_name = "--caskroom" + + cmd_args do + description <<~EOS + Display Homebrew's Caskroom path. + + If is provided, display the location in the Caskroom where + would be installed, without any sort of versioned directory as the last path. + EOS + + named_args :cask + end + + sig { override.void } + def run + if args.named.to_casks.blank? + puts Cask::Caskroom.path + else + args.named.to_casks.each do |cask| + puts "#{Cask::Caskroom.path}/#{cask.token}" + end + end + end + end + end +end diff --git a/Library/Homebrew/cmd/--cellar.rb b/Library/Homebrew/cmd/--cellar.rb index e12fcc5655cf0..aa7abafc65f0f 100644 --- a/Library/Homebrew/cmd/--cellar.rb +++ b/Library/Homebrew/cmd/--cellar.rb @@ -1,31 +1,34 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - module_function + module Cmd + class Cellar < AbstractCommand + sig { override.returns(String) } + def self.command_name = "--cellar" - def __cellar_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--cellar` [] + cmd_args do + description <<~EOS + Display Homebrew's Cellar path. *Default:* `$(brew --prefix)/Cellar`, or if + that directory doesn't exist, `$(brew --repository)/Cellar`. - Display Homebrew's Cellar path. *Default:* `$(brew --prefix)/Cellar`, or if - that directory doesn't exist, `$(brew --repository)/Cellar`. + If is provided, display the location in the Cellar where + would be installed, without any sort of versioned directory as the last path. + EOS - If is provided, display the location in the cellar where - would be installed, without any sort of versioned directory as the last path. - EOS - end - end - - def __cellar - __cellar_args.parse + named_args :formula + end - if Homebrew.args.named.blank? - puts HOMEBREW_CELLAR - else - puts Homebrew.args.resolved_formulae.map(&:rack) + sig { override.void } + def run + if args.no_named? + puts HOMEBREW_CELLAR + else + puts args.named.to_resolved_formulae.map(&:rack) + end + end end end end diff --git a/Library/Homebrew/cmd/--env.rb b/Library/Homebrew/cmd/--env.rb index 58cd433b0eda4..90f505942d744 100644 --- a/Library/Homebrew/cmd/--env.rb +++ b/Library/Homebrew/cmd/--env.rb @@ -1,56 +1,56 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "extend/ENV" require "build_environment" require "utils/shell" -require "cli/parser" module Homebrew - module_function - - def __env_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--env` [] [] - - Summarise Homebrew's build environment as a plain list. - - If the command's output is sent through a pipe and no shell is specified, - the list is formatted for export to `bash`(1) unless `--plain` is passed. - EOS - flag "--shell=", - description: "Generate a list of environment variables for the specified shell, " \ - "or `--shell=auto` to detect the current shell." - switch "--plain", - description: "Generate plain output even when piped." - end - end - - def __env - __env_args.parse - - ENV.activate_extensions! - ENV.deps = Homebrew.args.formulae if superenv? - ENV.setup_build_environment - ENV.universal_binary if ARGV.build_universal? - - shell = if args.plain? - nil - elsif args.shell.nil? - # legacy behavior - :bash unless $stdout.tty? - elsif args.shell == "auto" - Utils::Shell.parent || Utils::Shell.preferred - elsif args.shell - Utils::Shell.from_path(args.shell) - end + module Cmd + class Env < AbstractCommand + sig { override.returns(String) } + def self.command_name = "--env" + + cmd_args do + description <<~EOS + Summarise Homebrew's build environment as a plain list. + + If the command's output is sent through a pipe and no shell is specified, + the list is formatted for export to `bash`(1) unless `--plain` is passed. + EOS + flag "--shell=", + description: "Generate a list of environment variables for the specified shell, " \ + "or `--shell=auto` to detect the current shell." + switch "--plain", + description: "Generate plain output even when piped." + + named_args :formula + end - env_keys = build_env_keys(ENV) - if shell.nil? - dump_build_env ENV - else - env_keys.each do |key| - puts Utils::Shell.export_value(key, ENV[key], shell) + sig { override.void } + def run + ENV.activate_extensions! + ENV.deps = args.named.to_formulae if superenv?(nil) + ENV.setup_build_environment + + shell = if args.plain? + nil + elsif args.shell.nil? + :bash unless $stdout.tty? + elsif args.shell == "auto" + Utils::Shell.parent || Utils::Shell.preferred + elsif args.shell + Utils::Shell.from_path(T.must(args.shell)) + end + + if shell.nil? + BuildEnvironment.dump ENV.to_h + else + BuildEnvironment.keys(ENV.to_h).each do |key| + puts Utils::Shell.export_value(key, ENV.fetch(key), shell) + end + end end end end diff --git a/Library/Homebrew/cmd/--prefix.rb b/Library/Homebrew/cmd/--prefix.rb index 4d8655fb6a437..8250794527df2 100644 --- a/Library/Homebrew/cmd/--prefix.rb +++ b/Library/Homebrew/cmd/--prefix.rb @@ -1,33 +1,116 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "fileutils" module Homebrew - module_function + module Cmd + class Prefix < AbstractCommand + include FileUtils - def __prefix_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--prefix` [] + UNBREWED_EXCLUDE_FILES = %w[.DS_Store].freeze + UNBREWED_EXCLUDE_PATHS = %w[ + */.keepme + .github/* + bin/brew + completions/zsh/_brew + docs/* + lib/gdk-pixbuf-2.0/* + lib/gio/* + lib/node_modules/* + lib/python[23].[0-9]/* + lib/python3.[0-9][0-9]/* + lib/pypy/* + lib/pypy3/* + lib/ruby/gems/[12].* + lib/ruby/site_ruby/[12].* + lib/ruby/vendor_ruby/[12].* + manpages/brew.1 + share/pypy/* + share/pypy3/* + share/info/dir + share/man/whatis + share/mime/* + texlive/* + ].freeze - Display Homebrew's install path. *Default:* `/usr/local` on macOS and - `/home/linuxbrew/.linuxbrew` on Linux. + sig { override.returns(String) } + def self.command_name = "--prefix" - If is provided, display the location in the cellar where - is or would be installed. - EOS - end - end + cmd_args do + description <<~EOS + Display Homebrew's install path. *Default:* + + - macOS ARM: `#{HOMEBREW_MACOS_ARM_DEFAULT_PREFIX}` + - macOS Intel: `#{HOMEBREW_DEFAULT_PREFIX}` + - Linux: `#{HOMEBREW_LINUX_DEFAULT_PREFIX}` + + If is provided, display the location where is or would be installed. + EOS + switch "--unbrewed", + description: "List files in Homebrew's prefix not installed by Homebrew." + switch "--installed", + description: "Outputs nothing and returns a failing status code if is not installed." + + conflicts "--unbrewed", "--installed" + + named_args :formula + end + + sig { override.void } + def run + raise UsageError, "`--installed` requires a formula argument." if args.installed? && args.no_named? + + if args.unbrewed? + raise UsageError, "`--unbrewed` does not take a formula argument." unless args.no_named? + + list_unbrewed + elsif args.no_named? + puts HOMEBREW_PREFIX + else + formulae = args.named.to_resolved_formulae + prefixes = formulae.filter_map do |f| + next nil if args.installed? && !f.opt_prefix.exist? + + # this case will be short-circuited by brew.sh logic for a single formula + f.opt_prefix + end + puts prefixes + if args.installed? + missing_formulae = formulae.reject(&:optlinked?) + .map(&:name) + return if missing_formulae.blank? + + raise NotAKegError, <<~EOS + The following formulae are not installed: + #{missing_formulae.join(" ")} + EOS + end + end + end + + private + + sig { void } + def list_unbrewed + dirs = HOMEBREW_PREFIX.subdirs.map { |dir| dir.basename.to_s } + dirs -= %w[Library Cellar Caskroom .git] + + # Exclude cache, logs and repository, if they are located under the prefix. + [HOMEBREW_CACHE, HOMEBREW_LOGS, HOMEBREW_REPOSITORY].each do |dir| + dirs.delete dir.relative_path_from(HOMEBREW_PREFIX).to_s + end + dirs.delete "etc" + dirs.delete "var" - def __prefix - __prefix_args.parse + arguments = dirs.sort + %w[-type f (] + arguments.concat UNBREWED_EXCLUDE_FILES.flat_map { |f| %W[! -name #{f}] } + arguments.concat UNBREWED_EXCLUDE_PATHS.flat_map { |d| %W[! -path #{d}] } + arguments.push ")" - if Homebrew.args.named.blank? - puts HOMEBREW_PREFIX - else - puts Homebrew.args.resolved_formulae.map { |f| - f.opt_prefix.exist? ? f.opt_prefix : f.installed_prefix - } + cd(HOMEBREW_PREFIX) { safe_system("find", *arguments) } + end end end end diff --git a/Library/Homebrew/cmd/--repository.rb b/Library/Homebrew/cmd/--repository.rb index 3440e25c73e22..c5ac522a13f6d 100644 --- a/Library/Homebrew/cmd/--repository.rb +++ b/Library/Homebrew/cmd/--repository.rb @@ -1,29 +1,26 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "shell_command" module Homebrew - module_function + module Cmd + class Repository < AbstractCommand + include ShellCommand - def __repository_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--repository`, `--repo` [`/`] + sig { override.returns(String) } + def self.command_name = "--repository" - Display where Homebrew's `.git` directory is located. + cmd_args do + description <<~EOS + Display where Homebrew's Git repository is located. - If `/` are provided, display where tap `/`'s directory is located. - EOS - end - end - - def __repository - __repository_args.parse + If `/` are provided, display where tap `/`'s directory is located. + EOS - if ARGV.named.empty? - puts HOMEBREW_REPOSITORY - else - puts ARGV.named.map { |tap| Tap.fetch(tap).path } + named_args :tap + end end end end diff --git a/Library/Homebrew/cmd/--repository.sh b/Library/Homebrew/cmd/--repository.sh new file mode 100644 index 0000000000000..81a4ffef11a6c --- /dev/null +++ b/Library/Homebrew/cmd/--repository.sh @@ -0,0 +1,48 @@ +# Documentation defined in Library/Homebrew/cmd/--repository.rb + +# HOMEBREW_REPOSITORY, HOMEBREW_LIBRARY are set by brew.sh +# shellcheck disable=SC2154 + +tap_path() { + local tap="$1" + local user + local repo + local part + + if [[ "${tap}" != *"/"* ]] + then + odie "Invalid tap name: ${tap}" + fi + + user="$(echo "${tap%%/*}" | tr '[:upper:]' '[:lower:]')" + repo="$(echo "${tap#*/}" | tr '[:upper:]' '[:lower:]')" + + for part in "${user}" "${repo}" + do + if [[ -z "${part}" || "${part}" == *"/"* ]] + then + odie "Invalid tap name: ${tap}" + fi + done + + repo="${repo#@(home|linux)brew-}" + echo "${HOMEBREW_LIBRARY}/Taps/${user}/homebrew-${repo}" +} + +homebrew---repository() { + local tap + + if [[ "$#" -eq 0 ]] + then + echo "${HOMEBREW_REPOSITORY}" + return + fi + + ( + shopt -s extglob + for tap in "$@" + do + tap_path "${tap}" + done + ) +} diff --git a/Library/Homebrew/cmd/--taps.rb b/Library/Homebrew/cmd/--taps.rb new file mode 100644 index 0000000000000..4d1ea6a0affd6 --- /dev/null +++ b/Library/Homebrew/cmd/--taps.rb @@ -0,0 +1,22 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Taps < AbstractCommand + include ShellCommand + + sig { override.returns(String) } + def self.command_name = "--taps" + + cmd_args do + description <<~EOS + Display the path to Homebrew’s Taps directory. + EOS + end + end + end +end diff --git a/Library/Homebrew/cmd/--taps.sh b/Library/Homebrew/cmd/--taps.sh new file mode 100644 index 0000000000000..37ee25611ac3e --- /dev/null +++ b/Library/Homebrew/cmd/--taps.sh @@ -0,0 +1,8 @@ +# Documentation defined in Library/Homebrew/cmd/--taps.rb + +# HOMEBREW_LIBRARY is set by brew.sh +# shellcheck disable=SC2154 + +homebrew---taps() { + echo "${HOMEBREW_LIBRARY}/Taps" +} diff --git a/Library/Homebrew/cmd/--version.rb b/Library/Homebrew/cmd/--version.rb index 037a8cc752285..bcaca65d2c879 100644 --- a/Library/Homebrew/cmd/--version.rb +++ b/Library/Homebrew/cmd/--version.rb @@ -1,27 +1,23 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "shell_command" module Homebrew - module_function + module Cmd + class Version < AbstractCommand + include ShellCommand - def __version_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `--version` + sig { override.returns(String) } + def self.command_name = "--version" - Print the version numbers of Homebrew, Homebrew/homebrew-core and Homebrew/homebrew-cask - (if tapped) to standard output. - EOS - max_named 0 + cmd_args do + description <<~EOS + Print the version numbers of Homebrew, Homebrew/homebrew-core and + Homebrew/homebrew-cask (if tapped) to standard output. + EOS + end end end - - def __version - __version_args.parse - - puts "Homebrew #{HOMEBREW_VERSION}" - puts "#{CoreTap.instance.full_name} #{CoreTap.instance.version_string}" - puts "#{Tap.default_cask_tap.full_name} #{Tap.default_cask_tap.version_string}" if Tap.default_cask_tap.installed? - end end diff --git a/Library/Homebrew/cmd/--version.sh b/Library/Homebrew/cmd/--version.sh new file mode 100644 index 0000000000000..2e9b349e0ac1c --- /dev/null +++ b/Library/Homebrew/cmd/--version.sh @@ -0,0 +1,36 @@ +# Documentation defined in Library/Homebrew/cmd/--version.rb + +# HOMEBREW_CORE_REPOSITORY, HOMEBREW_CASK_REPOSITORY, HOMEBREW_VERSION are set by brew.sh +# shellcheck disable=SC2154 +version_string() { + local repo="$1" + if ! [[ -d "${repo}" ]] + then + echo "N/A" + return + fi + + local git_revision_and_date + git_revision_and_date="$(git -C "${repo}" log -1 --format='%h %cd' --date=short HEAD 2>/dev/null)" + if [[ -z "${git_revision_and_date}" ]] + then + echo "(no Git repository)" + return + fi + + echo "(git revision ${git_revision_and_date%% *}; last commit ${git_revision_and_date#* })" +} + +homebrew-version() { + echo "Homebrew ${HOMEBREW_VERSION}" + + if [[ -n "${HOMEBREW_NO_INSTALL_FROM_API}" || -d "${HOMEBREW_CORE_REPOSITORY}" ]] + then + echo "Homebrew/homebrew-core $(version_string "${HOMEBREW_CORE_REPOSITORY}")" + fi + + if [[ -d "${HOMEBREW_CASK_REPOSITORY}" ]] + then + echo "Homebrew/homebrew-cask $(version_string "${HOMEBREW_CASK_REPOSITORY}")" + fi +} diff --git a/Library/Homebrew/cmd/alias.rb b/Library/Homebrew/cmd/alias.rb new file mode 100755 index 0000000000000..35a9a546e9a5b --- /dev/null +++ b/Library/Homebrew/cmd/alias.rb @@ -0,0 +1,48 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "aliases/aliases" + +module Homebrew + module Cmd + class Alias < AbstractCommand + cmd_args do + usage_banner "`alias` [`--edit`] [|=]" + description <<~EOS + Show an alias's command. If no alias is given, print the whole list. + EOS + switch "--edit", + description: "Edit aliases in a text editor. Either one or all aliases may be opened at once. " \ + "If the given alias doesn't exist it'll be pre-populated with a template." + + named_args max: 1 + end + + sig { override.void } + def run + name = args.named.first + name, command = name.split("=", 2) if name.present? + + Aliases.init + + if name.nil? + if args.edit? + Aliases.edit_all + else + Aliases.show + end + elsif command.nil? + if args.edit? + Aliases.edit name + else + Aliases.show name + end + else + Aliases.add name, command + Aliases.edit name if args.edit? + end + end + end + end +end diff --git a/Library/Homebrew/cmd/analytics.rb b/Library/Homebrew/cmd/analytics.rb index 6423caa753a3e..0f5af895b868d 100644 --- a/Library/Homebrew/cmd/analytics.rb +++ b/Library/Homebrew/cmd/analytics.rb @@ -1,47 +1,28 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - module_function + module Cmd + class Analytics < AbstractCommand + require "analytics/subcommand" - def analytics_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `analytics` [] + cmd_args do + usage_banner <<~EOS + `analytics` [] - If `on` or `off` is passed, turn Homebrew's analytics on or off respectively. + Control Homebrew's anonymous aggregate user behaviour analytics. + Read more at . + EOS - If `state` is passed, display the current anonymous user behaviour analytics state. - Read more at . - - If `regenerate-uuid` is passed, regenerate the UUID used in Homebrew's analytics. - EOS - switch :verbose - switch :debug - max_named 1 - end - end - - def analytics - analytics_args.parse + Homebrew::AbstractSubcommand.define_all(self, command: Homebrew::Cmd::Analytics) + end - case args.remaining.first - when nil, "state" - if Utils::Analytics.disabled? - puts "Analytics are disabled." - else - puts "Analytics are enabled." - puts "UUID: #{Utils::Analytics.uuid}" if Utils::Analytics.uuid.present? + sig { override.void } + def run + Homebrew::Cmd::Analytics.dispatch(args) end - when "on" - Utils::Analytics.enable! - when "off" - Utils::Analytics.disable! - when "regenerate-uuid" - Utils::Analytics.regenerate_uuid! - else - raise UsageError, "Unknown subcommand." end end end diff --git a/Library/Homebrew/cmd/as-console-user.rb b/Library/Homebrew/cmd/as-console-user.rb new file mode 100644 index 0000000000000..7ae6f1dd1a008 --- /dev/null +++ b/Library/Homebrew/cmd/as-console-user.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class AsConsoleUser < AbstractCommand + include ShellCommand + + cmd_args do + usage_banner <<~EOS + `as-console-user` [ ...] + + Run a Homebrew command as the active macOS console user. + + This is intended for MDM, Munki and Jamf workflows where `brew` is + invoked as root but Homebrew operations should run as the logged-in + console user. The nested command is always dispatched through + `HOMEBREW_BREW_FILE`. + EOS + + named_args min: 1 + end + end + end +end diff --git a/Library/Homebrew/cmd/as-console-user.sh b/Library/Homebrew/cmd/as-console-user.sh new file mode 100644 index 0000000000000..c6d1f7487fc7f --- /dev/null +++ b/Library/Homebrew/cmd/as-console-user.sh @@ -0,0 +1,54 @@ +# Documentation defined in Library/Homebrew/cmd/as-console-user.rb + +# `HOMEBREW_*` variables are set by brew.sh before sourcing this command. +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" + +homebrew-as-console-user() { + while [[ "$#" -gt 0 ]] + do + if homebrew-command-help as-console-user "$1" + then + return $? + fi + if homebrew-command-common-option "$1" + then + shift + continue + fi + break + done + + homebrew-command-enable-debug + + if [[ "$#" -eq 0 ]] + then + brew help as-console-user + return 1 + fi + + [[ -n "${HOMEBREW_MACOS}" ]] || odie "\`brew as-console-user\` is only supported on macOS." + + # `HOMEBREW_LIBRARY` is set by brew.sh, so ShellCheck cannot follow it. + # shellcheck disable=SC1091 + source "${HOMEBREW_LIBRARY}/Homebrew/utils/macos_user.sh" + + local console_user + console_user="$(homebrew-console-user)" || odie "No supported macOS console user is logged in." + + local console_home + console_home="$(homebrew-user-home "${console_user}")" || + odie "Could not determine home directory for console user: ${console_user}" + + ( + cd "${console_home}" &>/dev/null || odie "Failed to cd to ${console_home}!" + + sudo -H -u "${console_user}" /usr/bin/env -i \ + "HOME=${console_home}" \ + "USER=${console_user}" \ + "LOGNAME=${console_user}" \ + "PWD=${console_home}" \ + "PATH=/usr/bin:/bin:/usr/sbin:/sbin" \ + "${HOMEBREW_BREW_FILE}" "$@" + ) +} diff --git a/Library/Homebrew/cmd/autoremove.rb b/Library/Homebrew/cmd/autoremove.rb new file mode 100644 index 0000000000000..45f22bde1f816 --- /dev/null +++ b/Library/Homebrew/cmd/autoremove.rb @@ -0,0 +1,26 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "cleanup" + +module Homebrew + module Cmd + class Autoremove < AbstractCommand + cmd_args do + description <<~EOS + Uninstall formulae that were only installed as a dependency of another formula and are now no longer needed. + EOS + switch "-n", "--dry-run", + description: "List what would be uninstalled, but do not actually uninstall anything." + + named_args :none + end + + sig { override.void } + def run + Cleanup.autoremove(dry_run: args.dry_run?) + end + end + end +end diff --git a/Library/Homebrew/cmd/bundle.rb b/Library/Homebrew/cmd/bundle.rb new file mode 100755 index 0000000000000..1bbb13625c3be --- /dev/null +++ b/Library/Homebrew/cmd/bundle.rb @@ -0,0 +1,69 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "bundle/dsl" +require "bundle/extensions" + +module Homebrew + module Cmd + class Bundle < AbstractCommand + require "bundle/subcommand" + + BUNDLE_EXTENSIONS = T.let(Homebrew::Bundle.extensions.dup.freeze, T::Array[T.class_of(Homebrew::Bundle::Extension)]) + BUNDLE_SOURCES_DESCRIPTION = T.let( + [ + "Homebrew", + "Homebrew Cask", + *BUNDLE_EXTENSIONS.map(&:banner_name), + ].to_sentence.freeze, + String, + ) + + cmd_args do + usage_banner <<~EOS + `bundle` [] + + Bundler for non-Ruby dependencies from #{BUNDLE_SOURCES_DESCRIPTION}. + + Note: Flatpak support is only available on Linux. + EOS + flag "--file=", + description: "Read from or write to the `Brewfile` from this location. " \ + "Use `--file=-` to pipe to stdin/stdout." + switch "-g", "--global", + description: "Read from or write to the `Brewfile` from `$HOMEBREW_BUNDLE_FILE_GLOBAL` " \ + "(if set), `${XDG_CONFIG_HOME}/homebrew/Brewfile` " \ + "(if `$XDG_CONFIG_HOME` is set), `~/.homebrew/Brewfile` or `~/.Brewfile` otherwise." + + Homebrew::AbstractSubcommand.define_all(self, command: Homebrew::Cmd::Bundle) + + [ + [%w[--formula --formulae --brews], %w[--no-formula --no-formulae --no-brews], "--no-brew"], + [%w[--cask --casks], %w[--no-cask --no-casks], "--no-cask"], + [%w[--tap --taps], %w[--no-tap --no-taps], "--no-tap"], + ].each do |enabled_flags, disabled_flags, env_disabled_flag| + type = env_disabled_flag.delete_prefix("--no-") + enabled_flags.product([*disabled_flags, "--no-cleanup-#{type}", "--no-dump-#{type}"]) do |enabled_flag, + disabled_flag| + conflicts enabled_flag, disabled_flag + end + end + BUNDLE_EXTENSIONS.select(&:dump_disable_supported?).each do |extension| + conflicts "--#{extension.flag}", "--no-#{extension.flag}" + conflicts "--#{extension.flag}", "--no-cleanup-#{extension.flag}" + conflicts "--#{extension.flag}", "--no-dump-#{extension.flag}" + end + conflicts "--file", "--global" + end + + sig { override.void } + def run + # Keep this inside `run` to keep --help fast. + require "bundle" + + Homebrew::Cmd::Bundle.dispatch(args, extensions: BUNDLE_EXTENSIONS) + end + end + end +end diff --git a/Library/Homebrew/cmd/cask.rb b/Library/Homebrew/cmd/cask.rb deleted file mode 100644 index 82f0ffcd0066a..0000000000000 --- a/Library/Homebrew/cmd/cask.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require "cask/all" - -module Homebrew - module_function - - def cask - Cask::Cmd.run(*ARGV) - end -end diff --git a/Library/Homebrew/cmd/casks.rb b/Library/Homebrew/cmd/casks.rb new file mode 100644 index 0000000000000..2759fb2893513 --- /dev/null +++ b/Library/Homebrew/cmd/casks.rb @@ -0,0 +1,22 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Casks < AbstractCommand + # Used when the Bash implementation falls back to Ruby for tap trust filtering. + cmd_args do + description "List all locally installable casks including short names." + end + + sig { override.void } + def run + require "cask/cask" + + puts Cask::Cask.all(eval_all: true).flat_map { |cask| [cask.full_name, cask.token] }.uniq.sort + end + end + end +end diff --git a/Library/Homebrew/cmd/casks.sh b/Library/Homebrew/cmd/casks.sh new file mode 100644 index 0000000000000..b337764b1a215 --- /dev/null +++ b/Library/Homebrew/cmd/casks.sh @@ -0,0 +1,37 @@ +# Documentation defined in Library/Homebrew/cmd/casks.rb + +# HOMEBREW_LIBRARY is set in bin/brew +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/items.sh" + +homebrew-casks() { + local find_include_filter='*/Casks/*\.rb' + local sed_filter='s|/Casks/(.+/)?|/|' + local grep_filter='^homebrew/cask' + + if homebrew-tap-trust-required + then + if homebrew-trusted-items-with-api-names "${find_include_filter}" '^\b$' \ + "trustedcasks" "homebrew/cask" "Casks" "cask_names.txt" + then + return + fi + opoo "jq is unavailable; falling back to Ruby to apply tap trust." + HOMEBREW_FORCE_RUBY_COMMAND=1 "${HOMEBREW_BREW_FILE}" casks + return + fi + + # HOMEBREW_CACHE is set by brew.sh + # shellcheck disable=SC2154 + if [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" && + -f "${HOMEBREW_CACHE}/api/cask_names.txt" ]] + then + { + cat "${HOMEBREW_CACHE}/api/cask_names.txt" + echo + homebrew-items "${find_include_filter}" '.*/homebrew/homebrew-cask/.*' "${sed_filter}" "${grep_filter}" + } | sort -uf + else + homebrew-items "${find_include_filter}" '^\b$' "${sed_filter}" "${grep_filter}" + fi +} diff --git a/Library/Homebrew/cmd/cat.rb b/Library/Homebrew/cmd/cat.rb deleted file mode 100644 index 92f492e16613a..0000000000000 --- a/Library/Homebrew/cmd/cat.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require "cli/parser" - -module Homebrew - module_function - - def cat_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `cat` - - Display the source of . - EOS - max_named 1 - end - end - - def cat - cat_args.parse - # do not "fix" this to support multiple arguments, the output would be - # unparsable; if the user wants to cat multiple formula they can call - # `brew cat` multiple times. - formulae = Homebrew.args.formulae - raise FormulaUnspecifiedError if formulae.empty? - - cd HOMEBREW_REPOSITORY - pager = if ENV["HOMEBREW_BAT"].nil? - "cat" - else - "#{HOMEBREW_PREFIX}/bin/bat" - end - safe_system pager, formulae.first.path, *Homebrew.args.passthrough - end -end diff --git a/Library/Homebrew/cmd/cleanup.rb b/Library/Homebrew/cmd/cleanup.rb index 4e690e8f9f4e9..adb331f918ef1 100644 --- a/Library/Homebrew/cmd/cleanup.rb +++ b/Library/Homebrew/cmd/cleanup.rb @@ -1,60 +1,72 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "cleanup" -require "cli/parser" module Homebrew - module_function - - def cleanup_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `cleanup` [] [|] - - Remove stale lock files and outdated downloads for all formulae and casks, - and remove old versions of installed formulae. If arguments are specified, - only do this for the given formulae and casks. - EOS - flag "--prune=", - description: "Remove all cache files older than specified ." - switch "-n", "--dry-run", - description: "Show what would be removed, but do not actually remove anything." - switch "-s", - description: "Scrub the cache, including downloads for even the latest versions. "\ - "Note downloads for any installed formulae or casks will still not be deleted. "\ - "If you want to delete those too: `rm -rf \"$(brew --cache)\"`" - switch "--prune-prefix", - description: "Only prune the symlinks and directories from the prefix and remove no other files." - switch :verbose - switch :debug - end - end + module Cmd + class CleanupCmd < AbstractCommand + cmd_args do + days = Homebrew::EnvConfig::ENVS[:HOMEBREW_CLEANUP_MAX_AGE_DAYS]&.dig(:default) + description <<~EOS + Remove stale lock files and outdated downloads for all formulae and casks, + and remove old versions of installed formulae. If arguments are specified, + only do this for the given formulae and casks. Removes all downloads more than + #{days} days old. This can be adjusted with `$HOMEBREW_CLEANUP_MAX_AGE_DAYS`. + EOS + flag "--prune=", + description: "Remove all cache files older than specified . " \ + "If you want to remove everything, use `--prune=all`." + switch "-n", "--dry-run", + description: "Show what would be removed, but do not actually remove anything." + switch "-s", "--scrub", + description: "Scrub the cache, including downloads for even the latest versions. " \ + "Note that downloads for any installed formulae or casks will still not be deleted. " \ + "If you want to delete those too: `rm -rf \"$(brew --cache)\"`" + switch "--prune-prefix", + description: "Only prune the symlinks and directories from the prefix and remove no other files." - def cleanup - cleanup_args.parse + named_args [:formula, :cask] + end - cleanup = Cleanup.new(*args.remaining, dry_run: args.dry_run?, scrub: args.s?, days: args.prune&.to_i) - if args.prune_prefix? - cleanup.prune_prefix_symlinks_and_directories - return - end + sig { override.void } + def run + days = args.prune.presence&.then do |prune| + case prune + when /\A\d+\Z/ + prune.to_i + when "all" + 0 + else + raise UsageError, "`--prune` expects an integer or `all`." + end + end - cleanup.clean! + cleanup = Cleanup.new(*args.named, dry_run: args.dry_run?, scrub: args.s?, days:) + if args.prune_prefix? + cleanup.prune_prefix_symlinks_and_directories + return + end - unless cleanup.disk_cleanup_size.zero? - disk_space = disk_usage_readable(cleanup.disk_cleanup_size) - if args.dry_run? - ohai "This operation would free approximately #{disk_space} of disk space." - else - ohai "This operation has freed approximately #{disk_space} of disk space." - end - end + cleanup.clean!(quiet: args.quiet?, periodic: false) + + unless cleanup.disk_cleanup_size.zero? + disk_space = Formatter.disk_usage_readable(cleanup.disk_cleanup_size) + if args.dry_run? + ohai "This operation would free approximately #{disk_space} of disk space." + else + ohai "This operation has freed approximately #{disk_space} of disk space." + end + end - return if cleanup.unremovable_kegs.empty? + return if cleanup.unremovable_kegs.empty? - ofail <<~EOS - Could not cleanup old kegs! Fix your permissions on: - #{cleanup.unremovable_kegs.join "\n "} - EOS + ofail <<~EOS + Could not cleanup old kegs! Fix your permissions on: + #{cleanup.unremovable_kegs.join "\n "} + EOS + end + end end end diff --git a/Library/Homebrew/cmd/command-not-found-init.rb b/Library/Homebrew/cmd/command-not-found-init.rb new file mode 100755 index 0000000000000..1c15ce9ddeb28 --- /dev/null +++ b/Library/Homebrew/cmd/command-not-found-init.rb @@ -0,0 +1,79 @@ +# typed: strict +# frozen_string_literal: true + +# License: MIT +# The license text can be found in Library/Homebrew/command-not-found/LICENSE + +require "abstract_command" +require "utils/shell" + +module Homebrew + module Cmd + class CommandNotFoundInit < AbstractCommand + cmd_args do + description <<~EOS + Print instructions for setting up the command-not-found hook for your shell. + If the output is not to a tty, print the appropriate handler script for your shell. + + For more information, see: + https://docs.brew.sh/Command-Not-Found + EOS + named_args :none + end + + sig { override.void } + def run + if $stdout.tty? + help + else + init + end + end + + sig { returns(T.nilable(Symbol)) } + def shell + Utils::Shell.parent || Utils::Shell.preferred + end + + sig { void } + def init + case shell + when :bash, :zsh + puts File.read(File.expand_path("#{File.dirname(__FILE__)}/../command-not-found/handler.sh")) + when :fish + puts File.read(File.expand_path("#{File.dirname(__FILE__)}/../command-not-found/handler.fish")) + else + raise "Unsupported shell type #{shell}" + end + end + + sig { void } + def help + case shell + when :bash, :zsh + puts <<~EOS + # To enable command-not-found + # Add the following lines to ~/.#{shell}rc + + HOMEBREW_COMMAND_NOT_FOUND_HANDLER="$(brew --repository)/Library/Homebrew/command-not-found/handler.sh" + if [ -f "$HOMEBREW_COMMAND_NOT_FOUND_HANDLER" ]; then + source "$HOMEBREW_COMMAND_NOT_FOUND_HANDLER"; + fi + EOS + when :fish + puts <<~EOS + # To enable command-not-found + # Add the following line to ~/.config/fish/config.fish + + set HOMEBREW_COMMAND_NOT_FOUND_HANDLER (brew --repository)/Library/Homebrew/command-not-found/handler.fish + if test -f $HOMEBREW_COMMAND_NOT_FOUND_HANDLER + source $HOMEBREW_COMMAND_NOT_FOUND_HANDLER + end + EOS + else + raise "Unsupported shell type #{shell}" + end + end + end + end +end diff --git a/Library/Homebrew/cmd/command.rb b/Library/Homebrew/cmd/command.rb index a9db2cf6c3a9c..8b4753e933c11 100644 --- a/Library/Homebrew/cmd/command.rb +++ b/Library/Homebrew/cmd/command.rb @@ -1,37 +1,28 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "commands" -require "cli/parser" module Homebrew - module_function - - def command_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `command` - - Display the path to the file being used when invoking `brew` . - EOS - switch :verbose - switch :debug - end - end - - def command - command_args.parse - - raise UsageError, "This command requires a command argument" if args.remaining.empty? - - args.remaining.each do |c| - cmd = HOMEBREW_INTERNAL_COMMAND_ALIASES.fetch(c, c) - path = Commands.path(cmd) - cmd_paths = PATH.new(ENV["PATH"]).append(Tap.cmd_directories) unless path - path ||= which("brew-#{cmd}", cmd_paths) - path ||= which("brew-#{cmd}.rb", cmd_paths) - - odie "Unknown command: #{cmd}" unless path - puts path + module Cmd + class Command < AbstractCommand + cmd_args do + description <<~EOS + Display the path to the file being used when invoking `brew` . + EOS + + named_args :command, min: 1 + end + + sig { override.void } + def run + args.named.each do |cmd| + path = Commands.path(cmd) + odie "Unknown command: brew #{cmd}" unless path + puts path + end + end end end end diff --git a/Library/Homebrew/cmd/commands.rb b/Library/Homebrew/cmd/commands.rb index a4c551ab86cf6..6ec4cf889e6f3 100644 --- a/Library/Homebrew/cmd/commands.rb +++ b/Library/Homebrew/cmd/commands.rb @@ -1,80 +1,46 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - module_function - - def commands_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `commands` [] - - Show lists of built-in and external commands. - EOS - switch :quiet, - description: "List only the names of commands without category headers." - switch "--include-aliases", - depends_on: "--quiet", - description: "Include aliases of internal commands." - switch :verbose - switch :debug - max_named 0 - end - end - - def commands - commands_args.parse - - if args.quiet? - cmds = internal_commands - cmds += external_commands - cmds += internal_developer_commands - cmds += HOMEBREW_INTERNAL_COMMAND_ALIASES.keys if args.include_aliases? - puts Formatter.columns(cmds.sort) - return - end - - # Find commands in Homebrew/cmd - ohai "Built-in commands", Formatter.columns(internal_commands.sort) - - # Find commands in Homebrew/dev-cmd - puts - ohai "Built-in developer commands", Formatter.columns(internal_developer_commands.sort) + module Cmd + class CommandsCmd < AbstractCommand + cmd_args do + description <<~EOS + Show lists of built-in and external commands. + EOS + switch "-q", "--quiet", + description: "List only the names of commands without category headers." + switch "--include-aliases", + depends_on: "--quiet", + description: "Include aliases of internal commands." + + named_args :none + end - exts = external_commands - return if exts.empty? + sig { override.void } + def run + if args.quiet? + puts Formatter.columns(Commands.commands(aliases: args.include_aliases?)) + return + end - # Find commands in the PATH - puts - ohai "External commands", Formatter.columns(exts) - end + prepend_separator = T.let(false, T::Boolean) - def internal_commands - find_internal_commands HOMEBREW_LIBRARY_PATH/"cmd" - end - - def internal_developer_commands - find_internal_commands HOMEBREW_LIBRARY_PATH/"dev-cmd" - end + { + "Built-in commands" => Commands.internal_commands, + "Built-in developer commands" => Commands.internal_developer_commands, + "External commands" => Commands.external_commands, + }.each do |title, commands| + next if commands.blank? - def external_commands - cmd_paths = PATH.new(ENV["PATH"]).append(Tap.cmd_directories) - cmd_paths.each_with_object([]) do |path, cmds| - Dir["#{path}/brew-*"].each do |file| - next unless File.executable?(file) + puts if prepend_separator + ohai title, Formatter.columns(commands) - cmd = File.basename(file, ".rb")[5..-1] - next if cmd.include?(".") - - cmds << cmd + prepend_separator ||= true + end end - end.sort - end - - def find_internal_commands(directory) - Pathname.glob(directory/"*") - .select(&:file?) - .map { |f| f.basename.to_s.sub(/\.(?:rb|sh)$/, "") } + end end end diff --git a/Library/Homebrew/cmd/completions.rb b/Library/Homebrew/cmd/completions.rb new file mode 100644 index 0000000000000..c51af1bf7d25f --- /dev/null +++ b/Library/Homebrew/cmd/completions.rb @@ -0,0 +1,29 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "completions" + +module Homebrew + module Cmd + class CompletionsCmd < AbstractCommand + require "completions/subcommand" + + cmd_args do + usage_banner <<~EOS + `completions` [] + + Control whether Homebrew automatically links external tap shell completion files. + Read more at . + EOS + + Homebrew::AbstractSubcommand.define_all(self, command: Homebrew::Cmd::CompletionsCmd) + end + + sig { override.void } + def run + Homebrew::Cmd::CompletionsCmd.dispatch(args) + end + end + end +end diff --git a/Library/Homebrew/cmd/config.rb b/Library/Homebrew/cmd/config.rb index a95ab42f6fcb2..515139b7ee637 100644 --- a/Library/Homebrew/cmd/config.rb +++ b/Library/Homebrew/cmd/config.rb @@ -1,28 +1,25 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "system_config" -require "cli/parser" module Homebrew - module_function + module Cmd + class Config < AbstractCommand + cmd_args do + description <<~EOS + Show Homebrew and system configuration info useful for debugging. If you file + a bug report, you will be required to provide this information. + EOS - def config_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `config` + named_args :none + end - Show Homebrew and system configuration info useful for debugging. If you file - a bug report, you will be required to provide this information. - EOS - switch :verbose - switch :debug - max_named 0 + sig { override.void } + def run + SystemConfig.dump_verbose_config + end end end - - def config - config_args.parse - - SystemConfig.dump_verbose_config - end end diff --git a/Library/Homebrew/cmd/deps.rb b/Library/Homebrew/cmd/deps.rb index b7f21155bdc36..c524490d349df 100644 --- a/Library/Homebrew/cmd/deps.rb +++ b/Library/Homebrew/cmd/deps.rb @@ -1,212 +1,436 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "ostruct" -require "cli/parser" +require "cask/caskroom" +require "dependencies_helpers" module Homebrew - module_function - - def deps_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `deps` [] [] - - Show dependencies for . Additional options specific to - may be appended to the command. When given multiple formula arguments, - show the intersection of dependencies for each formula. - EOS - switch "-n", - description: "Sort dependencies in topological order." - switch "--1", - description: "Only show dependencies one level down, instead of recursing." - switch "--union", - description: "Show the union of dependencies for multiple , instead of the intersection." - switch "--full-name", - description: "List dependencies by their full name." - switch "--include-build", - description: "Include `:build` dependencies for ." - switch "--include-optional", - description: "Include `:optional` dependencies for ." - switch "--include-test", - description: "Include `:test` dependencies for (non-recursive)." - switch "--skip-recommended", - description: "Skip `:recommended` dependencies for ." - switch "--include-requirements", - description: "Include requirements in addition to dependencies for ." - switch "--tree", - description: "Show dependencies as a tree. When given multiple formula arguments, "\ - "show individual trees for each formula." - switch "--annotate", - description: "Mark any build, test, optional, or recommended dependencies as "\ - "such in the output." - switch "--installed", - description: "List dependencies for formulae that are currently installed. If is "\ - "specified, list only its dependencies that are currently installed." - switch "--all", - description: "List dependencies for all available formulae." - switch "--for-each", - description: "Switch into the mode used by the `--all` option, but only list dependencies "\ - "for each provided , one formula per line. This is used for "\ - "debugging the `--installed`/`--all` display mode." - switch :verbose - switch :debug - conflicts "--installed", "--all" - formula_options - end - end - - def deps - deps_args.parse - - Formulary.enable_factory_cache! - - recursive = !args.send("1?") - - if args.tree? - if args.installed? - puts_deps_tree Formula.installed.sort, recursive - else - raise FormulaUnspecifiedError if Homebrew.args.remaining.empty? - - puts_deps_tree Homebrew.args.formulae, recursive + module Cmd + class Deps < AbstractCommand + include DependenciesHelpers + + class DepsCombineMode < T::Enum + enums do + # enum values are not mutable, and calling .freeze on them breaks Sorbet + # rubocop:disable Style/MutableConstant + Intersection = new + Union = new + # rubocop:enable Style/MutableConstant + end end - return - elsif args.all? - puts_deps Formula.sort, recursive - return - elsif !Homebrew.args.remaining.empty? && args.for_each? - puts_deps Homebrew.args.formulae, recursive - return - end - - installed = args.installed? || ARGV.formulae.all?(&:opt_or_installed_prefix_keg) - - @use_runtime_dependencies = installed && recursive && - !args.include_build? && - !args.include_test? && - !args.include_optional? && - !args.skip_recommended? - if Homebrew.args.remaining.empty? - raise FormulaUnspecifiedError unless args.installed? - - puts_deps Formula.installed.sort, recursive - return - end - - all_deps = deps_for_formulae(Homebrew.args.formulae, recursive, &(args.union? ? :| : :&)) - all_deps = condense_requirements(all_deps) - all_deps.select!(&:installed?) if args.installed? - all_deps.map!(&method(:dep_display_name)) - all_deps.uniq! - all_deps.sort! unless args.n? - puts all_deps - end - - def condense_requirements(deps) - return deps if args.include_requirements? + cmd_args do + description <<~EOS + Show dependencies for . When given multiple formula arguments, + show the intersection of dependencies for each formula. By default, `deps` + shows all required and recommended dependencies. + + If any version of each formula argument is installed and no other options + are passed, this command displays their actual runtime dependencies (similar + to `brew linkage`), which may differ from a formula's declared dependencies. + + *Note:* `--missing` and `--skip-recommended` have precedence over `--include-*`. + EOS + switch "-n", "--topological", + description: "Sort dependencies in topological order." + switch "-1", "--direct", "--declared", "--1", + description: "Show only the direct dependencies declared in the formula." + switch "--union", + description: "Show the union of dependencies for multiple , instead of the intersection." + switch "--full-name", + description: "List dependencies by their full name." + switch "--include-implicit", + description: "Include implicit dependencies used to download and unpack source files." + switch "--include-build", + description: "Include `:build` dependencies for ." + switch "--include-optional", + description: "Include `:optional` dependencies for ." + switch "--include-test", + description: "Include `:test` dependencies for (non-recursive unless `--graph` or `--tree`)." + switch "--skip-recommended", + description: "Skip `:recommended` dependencies for ." + switch "--include-requirements", + description: "Include requirements in addition to dependencies for ." + switch "--tree", + description: "Show dependencies as a tree. When given multiple formula arguments, " \ + "show individual trees for each formula." + switch "--prune", + depends_on: "--tree", + description: "Prune parts of tree already seen." + switch "--graph", + description: "Show dependencies as a directed graph." + switch "--dot", + depends_on: "--graph", + description: "Show text-based graph description in DOT format." + switch "--annotate", + description: "Mark any build, test, implicit, optional, or recommended dependencies as " \ + "such in the output." + switch "--installed", + description: "List dependencies for formulae that are currently installed. If is " \ + "specified, list only its dependencies that are currently installed." + switch "--missing", + description: "Show only missing dependencies." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to list " \ + "their dependencies.", + env: :eval_all, + odeprecated: true + switch "--for-each", + description: "Switch into the mode used when evaluating all formulae and casks, but only list " \ + "dependencies for each provided , one formula per line." + switch "--HEAD", + description: "Show dependencies for HEAD version instead of stable version." + flag "--os=", + description: "Show dependencies for the given operating system." + flag "--arch=", + description: "Show dependencies for the given CPU architecture." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--tree", "--graph" + conflicts "--installed", "--missing" + conflicts "--installed", "--eval-all" + conflicts "--formula", "--cask" + formula_options + + named_args [:formula, :cask] + end - deps.select { |dep| dep.is_a? Dependency } - end + sig { override.params(argv: T::Array[String]).void } + def initialize(argv = ARGV.freeze) + super + @use_runtime_dependencies = T.let(true, T::Boolean) + end - def dep_display_name(dep) - str = if dep.is_a? Requirement - if args.include_requirements? - ":#{dep.display_s}" - else - # This shouldn't happen, but we'll put something here to help debugging - "::#{dep.name}" - end - elsif args.full_name? - dep.to_formula.full_name - else - dep.name - end + sig { override.void } + def run + raise UsageError, "`brew deps --os=all` is not supported." if args.os == "all" + raise UsageError, "`brew deps --arch=all` is not supported." if args.arch == "all" + + os, arch = args.os_arch_combinations.fetch(0) + eval_all = args.eval_all? + eval_all ||= args.no_named? && !args.installed? && Homebrew::EnvConfig.tap_trust_configured? + + Formulary.enable_factory_cache! + + SimulateSystem.with(os:, arch:) do + installed = args.installed? || dependents(args.named.to_formulae_and_casks).all?(&:any_version_installed?) + unless installed + not_using_runtime_dependencies_reason = if args.installed? + "not all the named formulae were installed" + else + "`--installed` was not passed" + end + + @use_runtime_dependencies = false + end + + %w[direct tree graph HEAD skip_recommended missing + include_implicit include_build include_test include_optional].each do |arg| + next unless args.public_send("#{arg}?") + + not_using_runtime_dependencies_reason = "--#{arg.tr("_", "-")} was passed" + + @use_runtime_dependencies = false + end + + %w[os arch].each do |arg| + next if args.public_send(arg).nil? + + not_using_runtime_dependencies_reason = "--#{arg.tr("_", "-")} was passed" + + @use_runtime_dependencies = false + end + + if !@use_runtime_dependencies && !Homebrew::EnvConfig.no_env_hints? + opoo <<~EOS + `brew deps` is not the actual runtime dependencies because #{not_using_runtime_dependencies_reason}! + This means dependencies may differ from a formula's declared dependencies. + Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`). + EOS + end + + recursive = !args.direct? + + if args.tree? || args.graph? + dependents = if args.named.present? + sorted_dependents(args.named.to_formulae_and_casks) + elsif args.installed? + case args.only_formula_or_cask + when :formula + sorted_dependents(Formula.installed) + when :cask + sorted_dependents(Cask::Caskroom.casks) + else + sorted_dependents(Formula.installed + Cask::Caskroom.casks) + end + else + raise FormulaUnspecifiedError + end + + if args.graph? + dot_code = dot_code(dependents, recursive:) + if args.dot? + puts dot_code + else + exec_browser "https://dreampuf.github.io/GraphvizOnline/##{ERB::Util.url_encode(dot_code)}" + end + return + end + + puts_deps_tree(dependents, recursive:) + return + elsif eval_all + puts_deps(sorted_dependents( + Formula.all(eval_all:) + Cask::Cask.all(eval_all:), + ), recursive:) + return + elsif !args.no_named? && args.for_each? + puts_deps(sorted_dependents(args.named.to_formulae_and_casks), recursive:) + return + end + + if args.no_named? + raise FormulaUnspecifiedError unless args.installed? + + sorted_dependents_formulae_and_casks = case args.only_formula_or_cask + when :formula + sorted_dependents(Formula.installed) + when :cask + sorted_dependents(Cask::Caskroom.casks) + else + sorted_dependents(Formula.installed + Cask::Caskroom.casks) + end + puts_deps(sorted_dependents_formulae_and_casks, recursive:) + return + end + + dependents = dependents(args.named.to_formulae_and_casks) + check_head_spec(dependents) if args.HEAD? + + deps_combine_mode = args.union? ? DepsCombineMode::Union : DepsCombineMode::Intersection + all_deps = deps_for_dependents(dependents, deps_combine_mode:, recursive:) + condense_requirements(all_deps) + all_deps.map! { dep_display_name(it) } + all_deps.uniq! + all_deps.sort! unless args.topological? + puts all_deps + end + end - if args.annotate? - str = "#{str} " if args.tree? - str = "#{str} [build]" if dep.build? - str = "#{str} [test]" if dep.test? - str = "#{str} [optional]" if dep.optional? - str = "#{str} [recommended]" if dep.recommended? - end + private - str - end + sig { + params(formulae_or_casks: T::Array[T.any(Formula, Keg, Cask::Cask)]) + .returns(T::Array[T.any(Formula, CaskDependent)]) + } + def sorted_dependents(formulae_or_casks) + dependents(formulae_or_casks).sort_by(&:name) + end - def deps_for_formula(f, recursive = false) - includes, ignores = argv_includes_ignores(ARGV) + sig { params(deps: T::Array[T.any(Dependency, Requirement)]).void } + def condense_requirements(deps) + deps.select! { |dep| dep.is_a?(Dependency) } unless args.include_requirements? + deps.select! { |dep| dep.is_a?(Requirement) || dep.installed? } if args.installed? + end - deps = f.runtime_dependencies if @use_runtime_dependencies + sig { params(dep: T.any(Requirement, Dependency)).returns(String) } + def dep_display_name(dep) + str = if dep.is_a? Requirement + if args.include_requirements? + ":#{dep.display_s}" + else + # This shouldn't happen, but we'll put something here to help debugging + "::#{dep.name}" + end + elsif args.full_name? + dep.to_formula.full_name + else + dep.name + end + + if args.annotate? + str = "#{str} " if args.tree? + str = "#{str} [build]" if dep.build? + str = "#{str} [test]" if dep.test? + str = "#{str} [optional]" if dep.optional? + str = "#{str} [recommended]" if dep.recommended? + str = "#{str} [implicit]" if dep.implicit? + end + + str + end - if recursive - deps ||= recursive_includes(Dependency, f, includes, ignores) - reqs = recursive_includes(Requirement, f, includes, ignores) - else - deps ||= reject_ignores(f.deps, ignores, includes) - reqs = reject_ignores(f.requirements, ignores, includes) - end + sig { + params(dependency: T.any(Formula, CaskDependent), recursive: T::Boolean) + .returns(T::Array[T.any(Dependency, Requirement)]) + } + def deps_for_dependent(dependency, recursive: false) + includes, ignores = args_includes_ignores(args) - deps + reqs.to_a - end + deps = dependency.runtime_dependencies if @use_runtime_dependencies - def deps_for_formulae(formulae, recursive = false, &block) - formulae.map { |f| deps_for_formula(f, recursive) }.reduce(&block) - end + if recursive + deps ||= recursive_dep_includes(dependency, includes, ignores) + reqs = args.include_requirements? ? recursive_req_includes(dependency, includes, ignores) : Requirements.new + else + deps ||= select_includes(dependency.deps, ignores, includes) + reqs = select_includes(dependency.requirements, ignores, includes) + end - def puts_deps(formulae, recursive = false) - formulae.each do |f| - deps = deps_for_formula(f, recursive) - deps = condense_requirements(deps) - deps.sort_by!(&:name) - deps.map!(&method(:dep_display_name)) - puts "#{f.full_name}: #{deps.join(" ")}" - end - end + deps + reqs.to_a + end - def puts_deps_tree(formulae, recursive = false) - formulae.each do |f| - puts f.full_name - @dep_stack = [] - recursive_deps_tree(f, "", recursive) - puts - end - end + sig { + params( + dependents: T::Array[T.any(Formula, CaskDependent)], + deps_combine_mode: DepsCombineMode, + recursive: T::Boolean, + ).returns(T::Array[T.any(Dependency, Requirement)]) + } + def deps_for_dependents(dependents, deps_combine_mode:, recursive:) + symbol = (deps_combine_mode == DepsCombineMode::Intersection) ? :& : :| + dependents.map { deps_for_dependent(it, recursive:) }.reduce(symbol) + end - def recursive_deps_tree(f, prefix, recursive) - includes, ignores = argv_includes_ignores(ARGV) - deps = reject_ignores(f.deps, ignores, includes) - reqs = reject_ignores(f.requirements, ignores, includes) - dependables = reqs + deps + sig { params(dependents: T::Array[T.any(Formula, CaskDependent)]).void } + def check_head_spec(dependents) + headless = dependents.select { it.is_a?(Formula) && it.active_spec_sym != :head } + .to_sentence two_words_connector: " or ", last_word_connector: " or " + opoo "No head spec for #{headless}, using stable spec instead" unless headless.empty? + end - max = dependables.length - 1 - @dep_stack.push f.name - dependables.each_with_index do |dep, i| - next if !args.include_requirements? && dep.is_a?(Requirement) + sig { params(dependents: T::Array[T.any(Formula, CaskDependent)], recursive: T::Boolean).void } + def puts_deps(dependents, recursive: false) + check_head_spec(dependents) if args.HEAD? + dependents.each do |dependent| + deps = deps_for_dependent(dependent, recursive:) + condense_requirements(deps) + deps.sort_by!(&:name) + deps.map! { dep_display_name(it) } + puts "#{dependent.full_name}: #{deps.join(" ")}" + end + end - tree_lines = if i == max - "└──" - else - "├──" + sig { params(dependents: T::Array[T.any(Formula, CaskDependent)], recursive: T::Boolean).returns(String) } + def dot_code(dependents, recursive:) + dep_graph = {} + dependents.each { graph_deps(it, dep_graph:, recursive:) } + + dot_code = dep_graph.map do |d, deps| + deps.map do |dep| + attributes = [] + attributes << "style = dotted" if dep.build? + attributes << "arrowhead = empty" if dep.test? + if dep.optional? + attributes << "color = red" + elsif dep.recommended? + attributes << "color = green" + end + comment = " # #{dep.tags.map(&:inspect).join(", ")}" if dep.tags.any? + " \"#{d.name}\" -> \"#{dep}\"#{" [#{attributes.join(", ")}]" if attributes.any?}#{comment}" + end + end.flatten.join("\n") + "digraph {\n#{dot_code}\n}" end - display_s = "#{tree_lines} #{dep_display_name(dep)}" - is_circular = @dep_stack.include?(dep.name) - display_s = "#{display_s} (CIRCULAR DEPENDENCY)" if is_circular - puts "#{prefix}#{display_s}" + sig { + params( + formula: T.any(Formula, CaskDependent), + dep_graph: T::Hash[T.any(Formula, CaskDependent), T::Array[T.any(Dependency, Requirement)]], + recursive: T::Boolean, + ).void + } + def graph_deps(formula, dep_graph:, recursive:) + return if dep_graph.key?(formula) + + dependables = dependables(formula) + dep_graph[formula] = dependables + return unless recursive + + dependables.each do |dep| + next unless dep.is_a? Dependency + + graph_deps(Formulary.factory(dep.name), + dep_graph:, + recursive: true) + end + end - next if !recursive || is_circular + sig { params(dependents: T::Array[T.any(Formula, CaskDependent)], recursive: T::Boolean).void } + def puts_deps_tree(dependents, recursive: false) + check_head_spec(dependents) if args.HEAD? + dependents.each do |d| + puts d.full_name + recursive_deps_tree(d, deps_seen: {}, prefix: "", recursive:) + puts + end + end - prefix_addition = if i == max - " " - else - "│ " + sig { params(formula: T.any(Formula, CaskDependent)).returns(T::Array[T.any(Dependency, Requirement)]) } + def dependables(formula) + includes, ignores = args_includes_ignores(args) + deps = @use_runtime_dependencies ? formula.runtime_dependencies : formula.deps + deps = select_includes(deps, ignores, includes) + reqs = select_includes(formula.requirements, ignores, includes) if args.include_requirements? + reqs ||= [] + reqs + deps end - recursive_deps_tree(Formulary.factory(dep.name), prefix + prefix_addition, true) if dep.is_a? Dependency + sig { + params( + formula: T.any(Formula, CaskDependent), + deps_seen: T::Hash[String, T::Boolean], + prefix: String, recursive: T::Boolean + ).void + } + def recursive_deps_tree(formula, deps_seen:, prefix:, recursive:) + dependables = dependables(formula) + max = dependables.length - 1 + deps_seen[formula.name] = true + dependables.each_with_index do |dep, i| + tree_lines = if i == max + "└──" + else + "├──" + end + + display_s = "#{tree_lines} #{dep_display_name(dep)}" + + # Detect circular dependencies and consider them a failure if present. + is_circular = deps_seen.fetch(dep.name, false) + pruned = args.prune? && deps_seen.include?(dep.name) + if is_circular + display_s = "#{display_s} (CIRCULAR DEPENDENCY)" + Homebrew.failed = true + elsif pruned + display_s = "#{display_s} (PRUNED)" + end + + puts "#{prefix}#{display_s}" + + next if !recursive || is_circular || pruned + + prefix_addition = if i == max + " " + else + "│ " + end + + next unless dep.is_a? Dependency + + recursive_deps_tree(Formulary.factory(dep.name), + deps_seen:, + prefix: prefix + prefix_addition, + recursive: true) + end + + deps_seen[formula.name] = false + end end - - @dep_stack.pop end end diff --git a/Library/Homebrew/cmd/desc.rb b/Library/Homebrew/cmd/desc.rb index 1e8293be21e9e..98ddafed66b4c 100644 --- a/Library/Homebrew/cmd/desc.rb +++ b/Library/Homebrew/cmd/desc.rb @@ -1,62 +1,78 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "descriptions" require "search" require "description_cache_store" -require "cli/parser" module Homebrew - module_function - - extend Search - - def desc_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `desc` [] (|`/``/`|) - - Display 's name and one-line description. - Formula descriptions are cached; the cache is created on the - first search, making that search slower than subsequent ones. - EOS - flag "-s", "--search=", - description: "Search both names and descriptions for . If is flanked by "\ - "slashes, it is interpreted as a regular expression." - flag "-n", "--name=", - description: "Search just names for . If is flanked by slashes, it is "\ - "interpreted as a regular expression." - flag "-d", "--description=", - description: "Search just descriptions for . If is flanked by slashes, "\ - "it is interpreted as a regular expression." - switch :verbose - conflicts "--search=", "--name=", "--description=" - end - end + module Cmd + class Desc < AbstractCommand + cmd_args do + description <<~EOS + Display 's name and one-line description. + The cache is created on the first search, making that search slower than subsequent ones. + EOS + switch "-s", "--search", + description: "Search both names and descriptions for . If is flanked by " \ + "slashes, it is interpreted as a regular expression." + switch "-n", "--name", + description: "Search just names for . If is flanked by slashes, it is " \ + "interpreted as a regular expression." + switch "-d", "--description", + description: "Search just descriptions for . If is flanked by slashes, " \ + "it is interpreted as a regular expression." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to search their " \ + "descriptions.", + env: :eval_all, + odeprecated: true + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--search", "--name", "--description" - def desc - desc_args.parse - - search_type = [] - search_type << :either if args.search - search_type << :name if args.name - search_type << :desc if args.description - odie "You must provide a search term." if search_type.present? && ARGV.named.empty? - - results = if search_type.empty? - raise FormulaUnspecifiedError if ARGV.named.empty? - - desc = {} - Homebrew.args.formulae.each { |f| desc[f.full_name] = f.desc } - Descriptions.new(desc) - else - arg = ARGV.named.join(" ") - string_or_regex = query_regexp(arg) - CacheStoreDatabase.use(:descriptions) do |db| - cache_store = DescriptionCacheStore.new(db) - Descriptions.search(string_or_regex, search_type.first, cache_store) + named_args [:formula, :cask, :text_or_regex], min: 1 end - end - results.print + sig { override.void } + def run + search_type = if args.search? + Descriptions::SearchField::Either + elsif args.name? + Descriptions::SearchField::Name + elsif args.description? + Descriptions::SearchField::Description + end + + if search_type + if !args.eval_all? && !Homebrew::EnvConfig.tap_trust_configured? && Homebrew::EnvConfig.no_install_from_api? + raise UsageError, + "`brew desc --search` needs `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` set!" + end + + query = args.named.join(" ") + string_or_regex = Search.query_regexp(query) + return Search.search_descriptions(string_or_regex, args, search_type:) + end + + desc = {} + args.named.to_formulae_and_casks.each do |formula_or_cask| + case formula_or_cask + when Formula + desc[formula_or_cask.full_name] = formula_or_cask.desc + when Cask::Cask + desc[formula_or_cask.full_name] = [formula_or_cask.name.join(", "), formula_or_cask.desc.presence] + else + raise TypeError, "Unsupported formula_or_cask type: #{formula_or_cask.class}" + end + end + Descriptions.new(desc).print + end + end end end diff --git a/Library/Homebrew/cmd/developer.rb b/Library/Homebrew/cmd/developer.rb new file mode 100644 index 0000000000000..7699e317cefab --- /dev/null +++ b/Library/Homebrew/cmd/developer.rb @@ -0,0 +1,29 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Developer < AbstractCommand + require "developer/subcommand" + + cmd_args do + usage_banner <<~EOS + `developer` [] + + Control Homebrew's developer mode. When developer mode is enabled, + `brew update` will update Homebrew to the latest commit on the `main` + branch instead of the latest stable version along with some other behaviour changes. + EOS + + Homebrew::AbstractSubcommand.define_all(self, command: Homebrew::Cmd::Developer) + end + + sig { override.void } + def run + Homebrew::Cmd::Developer.dispatch(args) + end + end + end +end diff --git a/Library/Homebrew/cmd/diy.rb b/Library/Homebrew/cmd/diy.rb deleted file mode 100644 index 51a7ff4c26413..0000000000000 --- a/Library/Homebrew/cmd/diy.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -require "formula" -require "cli/parser" - -module Homebrew - module_function - - def diy_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `diy` [] - - Automatically determine the installation prefix for non-Homebrew software. - Using the output from this command, you can install your own software into - the Cellar and then link it into Homebrew's prefix with `brew link`. - EOS - flag "--name=", - description: "Explicitly set the of the package being installed." - flag "--version=", - description: "Explicitly set the of the package being installed." - switch :verbose - switch :debug - max_named 0 - end - end - - def diy - diy_args.parse - - path = Pathname.getwd - - version = args.version || detect_version(path) - name = args.name || detect_name(path, version) - - prefix = HOMEBREW_CELLAR/name/version - - if File.file? "CMakeLists.txt" - puts "-DCMAKE_INSTALL_PREFIX=#{prefix}" - elsif File.file? "configure" - puts "--prefix=#{prefix}" - elsif File.file? "meson.build" - puts "-Dprefix=#{prefix}" - else - raise "Couldn't determine build system. You can manually put files into #{prefix}" - end - end - - def detect_version(path) - version = path.version.to_s - raise "Couldn't determine version, set it with --version=" if version.empty? - - version - end - - def detect_name(path, version) - basename = path.basename.to_s - detected_name = basename[/(.*?)-?#{Regexp.escape(version)}/, 1] || basename - canonical_name = Formulary.canonical_name(detected_name) - - odie <<~EOS if detected_name != canonical_name - The detected name #{detected_name.inspect} exists in Homebrew as an alias - of #{canonical_name.inspect}. Consider using the canonical name instead: - brew diy --name=#{canonical_name} - - To continue using the detected name, pass it explicitly: - brew diy --name=#{detected_name} - EOS - - detected_name - end -end diff --git a/Library/Homebrew/cmd/docs.rb b/Library/Homebrew/cmd/docs.rb new file mode 100644 index 0000000000000..9d5cedac9272a --- /dev/null +++ b/Library/Homebrew/cmd/docs.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Docs < AbstractCommand + cmd_args do + description <<~EOS + Open Homebrew's online documentation at <#{HOMEBREW_DOCS_WWW}> in a browser. + EOS + end + + sig { override.void } + def run + exec_browser HOMEBREW_DOCS_WWW + end + end + end +end diff --git a/Library/Homebrew/cmd/doctor.rb b/Library/Homebrew/cmd/doctor.rb index 884c14a23db53..a5a8256d3cb60 100644 --- a/Library/Homebrew/cmd/doctor.rb +++ b/Library/Homebrew/cmd/doctor.rb @@ -1,80 +1,80 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "diagnostic" -require "cli/parser" +require "cask/caskroom" module Homebrew - module_function + module Cmd + class Doctor < AbstractCommand + cmd_args do + description <<~EOS + Check your system for potential problems. Will exit with a non-zero status + if any potential problems are found. - def doctor_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `doctor` [] + Please note that these warnings are just used to help the Homebrew maintainers + with debugging if you file an issue. If everything you use Homebrew for + is working fine: please don't worry or file an issue; just ignore this. + EOS + switch "--list-checks", + description: "List all audit methods, which can be run individually " \ + "if provided as arguments." + switch "-D", "--audit-debug", + description: "Enable debugging and profiling of audit methods." - Check your system for potential problems. Will exit with a non-zero status - if any potential problems are found. Please note that these warnings are just - used to help the Homebrew maintainers with debugging if you file an issue. If - everything you use Homebrew for is working fine: please don't worry or file - an issue; just ignore this. - EOS - switch "--list-checks", - description: "List all audit methods, which can be run individually "\ - "if provided as arguments." - switch "-D", "--audit-debug", - description: "Enable debugging and profiling of audit methods." - switch :verbose - switch :debug - end - end + named_args :diagnostic_check + end - def doctor - doctor_args.parse + sig { override.void } + def run + Homebrew.inject_dump_stats!(Diagnostic::Checks, /^check_*/) if args.audit_debug? - inject_dump_stats!(Diagnostic::Checks, /^check_*/) if args.audit_debug? + checks = Diagnostic::Checks.new(verbose: args.verbose?) - checks = Diagnostic::Checks.new + if args.list_checks? + puts checks.all + return + end - if args.list_checks? - puts checks.all.sort - exit - end + if args.no_named? + slow_checks = %w[ + check_for_broken_symlinks + check_missing_deps + ] + methods = (checks.all - slow_checks) + slow_checks + methods -= checks.cask_checks unless Cask::Caskroom.any_casks_installed? + else + methods = args.named + end - if ARGV.named.empty? - slow_checks = %w[ - check_for_broken_symlinks - check_missing_deps - ] - methods = (checks.all.sort - slow_checks) + slow_checks - else - methods = ARGV.named - end + first_warning = T.let(true, T::Boolean) + methods.each do |method| + $stderr.puts Formatter.headline("Checking #{method}", color: :magenta) if args.debug? + unless checks.respond_to?(method) + ofail "No check available by the name: #{method}" + next + end - first_warning = true - methods.each do |method| - $stderr.puts Formatter.headline("Checking #{method}", color: :magenta) if args.debug? - unless checks.respond_to?(method) - Homebrew.failed = true - puts "No check available by the name: #{method}" - next - end + out = checks.send(method) + next if out.blank? - out = checks.send(method) - next if out.nil? || out.empty? + if first_warning && !args.quiet? + $stderr.puts <<~EOS + #{Tty.bold}Please note that these warnings are just used to help the Homebrew maintainers + with debugging if you file an issue. If everything you use Homebrew for is + working fine: please don't worry or file an issue; just ignore this. Thanks!#{Tty.reset} + EOS + end - if first_warning - $stderr.puts <<~EOS - #{Tty.bold}Please note that these warnings are just used to help the Homebrew maintainers - with debugging if you file an issue. If everything you use Homebrew for is - working fine: please don't worry or file an issue; just ignore this. Thanks!#{Tty.reset} - EOS - end + $stderr.puts + opoo out + Homebrew.failed = true + first_warning = false + end - $stderr.puts - opoo out - Homebrew.failed = true - first_warning = false + puts "Your system is ready to brew." if !Homebrew.failed? && !args.quiet? + end end - - puts "Your system is ready to brew." unless Homebrew.failed? end end diff --git a/Library/Homebrew/cmd/exec.rb b/Library/Homebrew/cmd/exec.rb new file mode 100644 index 0000000000000..01980b2ee5e8f --- /dev/null +++ b/Library/Homebrew/cmd/exec.rb @@ -0,0 +1,46 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Exec < AbstractCommand + include ShellCommand + + cmd_args do + usage_banner <<~EOS + `exec`, `x` [`--formulae=`] [`--sandbox=`] [`--deny-network`] [`--`] [ ...] + + Run in an environment populated by Homebrew formulae. + + If `--formulae` is passed, Homebrew installs those comma-separated + formulae if needed, prepends their executable directories and those of + their dependencies to `PATH` and runs . This allows + to be a script path such as `./script.sh`. + + If `--formulae` is omitted, Homebrew finds a formula that provides + , installs it if needed and runs that executable. + + Example: `brew exec --formulae=jq,yq -- ./script.sh` + + Scripts can also use a shebang on systems with `env -S`: + `#!/usr/bin/env -S brew exec --formulae=jq,yq --` + EOS + + comma_array "--formulae", + description: "Comma-separated formulae to install and add to `PATH` before running " \ + "." + flag "--sandbox=", + description: "Run in Homebrew's sandbox, allowing writes to and Homebrew's " \ + "temporary and cache directories." + switch "--deny-network", + description: "Deny network access from inside the sandbox.", + depends_on: "--sandbox=" + + named_args min: 1 + end + end + end +end diff --git a/Library/Homebrew/cmd/exec.sh b/Library/Homebrew/cmd/exec.sh new file mode 100644 index 0000000000000..c7e3ee00ff6a4 --- /dev/null +++ b/Library/Homebrew/cmd/exec.sh @@ -0,0 +1,301 @@ +# Documentation defined in Library/Homebrew/cmd/exec.rb + +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/executables.sh" + +exec-formula-name() { + local formula="$1" + echo "${formula##*/}" +} + +exec-latest-keg() { + local formula_name + formula_name="$(exec-formula-name "$1")" + + # `opt/` is Homebrew's active-version pointer. Prefer it over + # sorting Cellar directories; plain lexicographic sorting gets versions like + # `2.10` and `2.9` wrong. + local opt_prefix + opt_prefix="${HOMEBREW_PREFIX}/opt/${formula_name}" + if [[ -d "${opt_prefix}" ]] + then + local opt_keg + opt_keg="$(cd "${opt_prefix}" &>/dev/null && pwd -P)" || return 1 + echo "${opt_keg}" + return + fi + + local cellar="${HOMEBREW_CELLAR}/${formula_name}" + [[ -d "${cellar}" ]] || return 1 + + local keg + local -a installed_kegs=() + for keg in "${cellar}"/* + do + [[ -d "${keg}" ]] && installed_kegs+=("${keg}") + done + + [[ "${#installed_kegs[@]}" -eq 1 ]] || return 1 + + echo "${installed_kegs[0]}" +} + +exec-formula-installed() { + exec-latest-keg "$1" &>/dev/null +} + +exec-add-path() { + # Bash arrays cannot be de-duplicated directly, so keep the PATH fragments + # ordered and skip entries we have already seen. + local path="$1" + [[ -d "${path}" ]] || return + + local entry + for entry in "${exec_path_entries[@]}" + do + [[ "${entry}" == "${path}" ]] && return + done + + exec_path_entries+=("${path}") +} + +exec-add-formula-paths() { + # `exec_path_entries` is declared local in `homebrew-exec`. Bash uses dynamic + # scoping for `local`, so this helper can append to that caller-local array. + local formula="$1" + local formula_name keg + formula_name="$(exec-formula-name "${formula}")" + + exec-add-path "${HOMEBREW_PREFIX}/opt/${formula_name}/bin" + exec-add-path "${HOMEBREW_PREFIX}/opt/${formula_name}/sbin" + + if keg="$(exec-latest-keg "${formula}")" + then + exec-add-path "${keg}/bin" + exec-add-path "${keg}/sbin" + fi +} + +homebrew-exec() { + local formulae=() + local formulae_arg="" + local formulae_seen=0 + local sandbox_path="" + local sandbox_seen=0 + local deny_network=0 + + while [[ "$#" -gt 0 ]] + do + if homebrew-command-help exec "$1" + then + return $? + fi + if homebrew-command-common-option "$1" + then + shift + continue + fi + + case "$1" in + --formulae=*) + formulae_arg="${1#--formulae=}" + formulae_seen=1 + shift + ;; + --formulae) + shift + [[ "$#" -gt 0 && "$1" != -* ]] || odie "\`--formulae\` requires a comma-separated formula list." + formulae_arg="$1" + formulae_seen=1 + shift + ;; + --sandbox=*) + sandbox_path="${1#--sandbox=}" + sandbox_seen=1 + shift + ;; + --sandbox) + shift + [[ "$#" -gt 0 && "$1" != -* ]] || odie "\`--sandbox\` requires a writable path." + sandbox_path="$1" + sandbox_seen=1 + shift + ;; + --deny-network) + deny_network=1 + shift + ;; + --) + shift + break + ;; + --*) + echo "Unknown option: $1" >&2 + "${HOMEBREW_BREW_FILE}" help exec + return 1 + ;; + *) + break + ;; + esac + done + + [[ "${sandbox_seen}" -eq 0 || -n "${sandbox_path}" ]] || odie "\`--sandbox\` requires a writable path." + [[ "${sandbox_seen}" -eq 1 || "${deny_network}" -eq 0 ]] || odie "\`--deny-network\` requires \`--sandbox\`." + + homebrew-command-enable-debug + + if [[ "${formulae_seen}" -eq 1 ]] + then + [[ -n "${formulae_arg}" ]] || odie "\`--formulae\` requires a comma-separated formula list." + IFS=',' read -r -a formulae <<<"${formulae_arg}" + local formula formula_index + for formula_index in "${!formulae[@]}" + do + formula="${formulae[formula_index]}" + formula="${formula#"${formula%%[![:space:]]*}"}" + formula="${formula%"${formula##*[![:space:]]}"}" + [[ -n "${formula}" ]] || odie "\`--formulae\` entries must not be empty." + formulae[formula_index]="${formula}" + done + fi + + local executable="${1:-}" + if [[ -z "${executable}" ]] + then + "${HOMEBREW_BREW_FILE}" help exec + return 1 + fi + shift + + local provider_lookup=0 + if [[ "${#formulae[@]}" -eq 0 ]] + then + [[ "${executable}" != */* ]] || odie "Executable name must not contain path separators without \`--formulae\`." + + provider_lookup=1 + ensure_executables_file >&2 + + local -a matching_formulae=() + local selected_formula formula + selected_formula="" + # Some executables are provided by multiple formulae. Prefer an already + # installed provider to avoid unnecessary installs; otherwise use the first + # provider listed by the database. + while read -r formula + do + matching_formulae+=("${formula}") + + if [[ -z "${selected_formula}" ]] && exec-formula-installed "${formula}" + then + selected_formula="${formula}" + fi + done < <(formulae_containing_executable "${executable}") + + [[ "${#matching_formulae[@]}" -gt 0 ]] || odie "No Homebrew formula found for \`${executable}\`." + + if [[ -n "${selected_formula}" ]] + then + formulae=("${selected_formula}") + else + formulae=("${matching_formulae[0]}") + fi + fi + + local formula + for formula in "${formulae[@]}" + do + if ! exec-formula-installed "${formula}" + then + if [[ "${provider_lookup}" -eq 1 ]] + then + ohai "Installing \`${formula}\` because it provides \`${executable}\`." >&2 + else + ohai "Installing \`${formula}\`." >&2 + fi + "${HOMEBREW_BREW_FILE}" install --formula "${formula}" >&2 || return + fi + done + + local executable_path="${executable}" + if [[ "${provider_lookup}" -eq 1 ]] + then + local candidate formula_name keg + executable_path="" + for formula in "${formulae[@]}" + do + formula_name="$(exec-formula-name "${formula}")" + local -a executable_candidate_paths=( + "${HOMEBREW_PREFIX}/opt/${formula_name}/bin/${executable}" + "${HOMEBREW_PREFIX}/opt/${formula_name}/sbin/${executable}" + ) + + # Do not use `HOMEBREW_PREFIX`/bin or sbin here. A different provider may + # be linked there with the same executable name. + # `break 2` leaves both this candidate loop and the surrounding formula + # loop as soon as a path has been narrowed down. + for candidate in "${executable_candidate_paths[@]}" + do + if [[ -f "${candidate}" && -x "${candidate}" ]] + then + executable_path="${candidate}" + break 2 + fi + done + + if keg="$(exec-latest-keg "${formula}")" + then + for candidate in "${keg}/bin/${executable}" "${keg}/sbin/${executable}" + do + if [[ -f "${candidate}" && -x "${candidate}" ]] + then + executable_path="${candidate}" + break 2 + fi + done + fi + done + + [[ -n "${executable_path}" ]] || odie "\`${executable}\` was not found in formulae: ${formulae[*]}." + fi + + local -a exec_path_entries=() + local dependency exec_path path_index + for formula in "${formulae[@]}" + do + exec-add-formula-paths "${formula}" + done + + for formula in "${formulae[@]}" + do + # Process substitution keeps the `while` loop in this shell process. + # A pipeline would run the loop in a subshell on Bash, losing array changes. + while read -r dependency + do + [[ -n "${dependency}" ]] && exec-add-formula-paths "${dependency}" + done < <("${HOMEBREW_BREW_FILE}" deps --topological --formula "${formula}" 2>/dev/null) + done + + exec_path="${HOMEBREW_PATH:-${PATH}}" + # Entries are collected in PATH priority order. Prepend them in reverse so the + # first collected directory remains first in the final PATH. + for ((path_index = ${#exec_path_entries[@]} - 1; path_index >= 0; path_index--)) + do + exec_path="${exec_path_entries[path_index]}:${exec_path}" + done + + PATH="${exec_path}" + export PATH + # Replace the shell with the target command so signals and exit status behave + # as if the executable had been run directly. + if [[ -n "${sandbox_path}" ]] + then + local -a sandbox_args=("sandbox-exec") + [[ "${deny_network}" -eq 1 ]] && sandbox_args+=("--deny-network") + sandbox_args+=("${sandbox_path}" "--" "${executable_path}" "$@") + exec "${HOMEBREW_BREW_FILE}" "${sandbox_args[@]}" + fi + + exec "${executable_path}" "$@" +} diff --git a/Library/Homebrew/cmd/fetch.rb b/Library/Homebrew/cmd/fetch.rb index ee91163281fe8..d7952a3b47f1a 100644 --- a/Library/Homebrew/cmd/fetch.rb +++ b/Library/Homebrew/cmd/fetch.rb @@ -1,148 +1,366 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "fetch" -require "cli/parser" +require "api/cask_download" +require "api/formula_bottle" +require "cask/config" +require "cask/download" +require "download_queue" module Homebrew - module_function - - def fetch_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `fetch` [] - - Download a bottle (if available) or source packages for . - For tarballs, also print SHA-256 checksums. - EOS - switch "--HEAD", - description: "Fetch HEAD version instead of stable version." - switch "--devel", - description: "Fetch development version instead of stable version." - switch :force, - description: "Remove a previously cached version and re-fetch." - switch :verbose, - description: "Do a verbose VCS checkout, if the URL represents a VCS. This is useful for "\ - "seeing if an existing VCS cache has been updated." - switch "--retry", - description: "Retry if downloading fails or re-download if the checksum of a previously cached "\ - "version no longer matches." - switch "--deps", - description: "Also download dependencies for any listed ." - switch "-s", "--build-from-source", - description: "Download source packages rather than a bottle." - switch "--build-bottle", - description: "Download source packages (for eventual bottling) rather than a bottle." - switch "--force-bottle", - description: "Download a bottle if it exists for the current or newest version of macOS, "\ - "even if it would not be used during installation." - switch :debug - conflicts "--devel", "--HEAD" - conflicts "--build-from-source", "--build-bottle", "--force-bottle" - end - end + module Cmd + class FetchCmd < AbstractCommand + include Fetch - def fetch - fetch_args.parse + FETCH_MAX_TRIES = 5 - raise FormulaUnspecifiedError if ARGV.named.empty? + cmd_args do + description <<~EOS + Download a bottle (if available) or source packages for e + and binaries for s. For files, also print SHA-256 checksums. + EOS + flag "--os=", + description: "Download for the given operating system. " \ + "(Pass `all` to download for all operating systems.)" + flag "--arch=", + description: "Download for the given CPU architecture. " \ + "(Pass `all` to download for all architectures.)" + switch "--all-platforms", + description: "Download for every supported operating system and architecture, plus each " \ + "language for s, fetching each distinct URL once." + flag "--bottle-tag=", + description: "Download a bottle for given tag." + switch "--HEAD", + description: "Fetch HEAD version instead of stable version." + switch "-f", "--force", + description: "Remove a previously cached version and re-fetch." + switch "-v", "--verbose", + description: "Do a verbose VCS checkout, if the URL represents a VCS. This is useful for " \ + "seeing if an existing VCS cache has been updated." + switch "--retry", + description: "Retry if downloading fails or re-download if the checksum of a previously cached " \ + "version no longer matches. Tries at most #{FETCH_MAX_TRIES} times with " \ + "exponential backoff." + switch "--deps", + description: "Also download dependencies for any listed ." + switch "-s", "--build-from-source", + description: "Download source packages rather than a bottle." + switch "--build-bottle", + description: "Download source packages (for eventual bottling) rather than a bottle." + switch "--force-bottle", + description: "Download a bottle if it exists for the current or newest version of macOS, " \ + "even if it would not be used during installation." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - if args.deps? - bucket = [] - Homebrew.args.formulae.each do |f| - bucket << f - bucket.concat f.recursive_dependencies.map(&:to_formula) + conflicts "--build-from-source", "--build-bottle", "--force-bottle", "--bottle-tag" + conflicts "--cask", "--HEAD" + conflicts "--cask", "--deps" + conflicts "--cask", "-s" + conflicts "--cask", "--build-bottle" + conflicts "--cask", "--force-bottle" + conflicts "--cask", "--bottle-tag" + conflicts "--formula", "--cask" + conflicts "--os", "--bottle-tag" + conflicts "--arch", "--bottle-tag" + conflicts "--all-platforms", "--os" + conflicts "--all-platforms", "--arch" + conflicts "--all-platforms", "--bottle-tag" + + named_args [:formula, :cask], min: 1 end - bucket.uniq! - else - bucket = Homebrew.args.formulae - end - puts "Fetching: #{bucket * ", "}" if bucket.size > 1 - bucket.each do |f| - f.print_tap_action verb: "Fetching" - - fetched_bottle = false - if Fetch.fetch_bottle?(f) - begin - fetch_formula(f.bottle) - rescue Interrupt - raise - rescue => e - raise if ARGV.homebrew_developer? - - fetched_bottle = false - onoe e.message - opoo "Bottle fetch failed: fetching the source." + sig { override.void } + def run + Formulary.enable_factory_cache! + + if enqueue_api_formula_bottles? || enqueue_api_cask_downloads? + download_queue.fetch + return + end + + bucket = if args.deps? + args.named.to_formulae_and_casks.flat_map do |formula_or_cask| + case formula_or_cask + when Formula + formula = formula_or_cask + [formula, *formula.recursive_dependencies.map(&:to_formula)] + else + formula_or_cask + end + end else - fetched_bottle = true + args.named.to_formulae_and_casks + end.uniq + + os_arch_combinations = args.os_arch_combinations + + puts "Fetching: #{bucket * ", "}" if bucket.size > 1 + bucket.each do |formula_or_cask| + case formula_or_cask + when Formula + formula = formula_or_cask + ref = formula.reloadable_ref + + os_arch_combinations.each do |os, arch| + SimulateSystem.with(os:, arch:) do + formula = Formulary.factory(ref, args.HEAD? ? :head : :stable) + + formula.print_tap_action verb: "Fetching" + + fetched_bottle = false + if fetch_bottle?( + formula, + force_bottle: args.force_bottle?, + bottle_tag: args.bottle_tag&.to_sym, + build_from_source_formulae: args.build_from_source_formulae, + os: args.os&.to_sym, + arch: args.arch&.to_sym, + ) + begin + formula.clear_cache if args.force? + + bottle_tag = Utils::Bottles::Tag.from_arg(args.bottle_tag&.to_sym, os:, arch:) + + bottle = formula.bottle_for_tag(bottle_tag) + + if bottle.nil? + opoo "Bottle for tag #{bottle_tag.to_sym.inspect} is unavailable." + next + end + + if (manifest_resource = bottle.github_packages_manifest_resource) + download_queue.enqueue(manifest_resource) + end + download_queue.enqueue(bottle) + rescue Interrupt + raise + rescue => e + raise if Homebrew::EnvConfig.developer? + + fetched_bottle = false + onoe e.message + opoo "Bottle fetch failed, fetching the source instead." + else + fetched_bottle = true + end + end + + next if fetched_bottle + + if (resource = formula.resource) + download_queue.enqueue(resource) + end + + formula.enqueue_resources_and_patches(download_queue:) + end + end + when Cask::Cask + cask_downloads(formula_or_cask).each { |download| download_queue.enqueue(download) } + else + odie "Invalid formula or cask: #{formula_or_cask}" + end end + + download_queue.fetch + ensure + download_queue.shutdown end - next if fetched_bottle + private + + sig { returns(T::Boolean) } + def enqueue_api_formula_bottles? + return false unless api_fetchable? + return false if args.only_formula_or_cask == :cask + return false if args.deps? || args.HEAD? + return false if args.build_from_source? || args.build_bottle? + return false if args.bottle_tag.present? + + names = api_fetch_names( + regex: HOMEBREW_DEFAULT_TAP_FORMULA_REGEX, + capture: :name, + hashes: Homebrew::API::Internal.formula_hashes, + aliases: Homebrew::API::Internal.formula_aliases, + renames: Homebrew::API::Internal.formula_renames, + ) + return false if names.nil? - fetch_formula(f) + bottles = T.let([], T::Array[[String, Bottle]]) + bottle_tag = Utils::Bottles.tag + names.each do |name| + formula_struct = Homebrew::API::Internal.formula_struct(name) + return false if formula_struct.pour_bottle? - f.resources.each do |r| - fetch_resource(r) - r.patches.each { |p| fetch_patch(p) if p.external? } + bottle = Homebrew::API::FormulaBottle.bottle(name:, formula_struct:, bottle_tag:) + return false if bottle.nil? + return false if !args.force_bottle? && !bottle.compatible_locations? + + bottles << [name, bottle] + end + + puts "Fetching: #{names * ", "}" if names.size > 1 + bottles.each do |name, bottle| + ohai "Fetching #{name} from #{CoreTap.instance}" + bottle.clear_cache if args.force? + + if (manifest_resource = bottle.github_packages_manifest_resource) + download_queue.enqueue(manifest_resource) + end + download_queue.enqueue(bottle) + end + true end - f.patchlist.each { |p| fetch_patch(p) if p.external? } - end - end + sig { returns(T::Boolean) } + def enqueue_api_cask_downloads? + return false unless api_fetchable? + return false if args.only_formula_or_cask != :cask - def fetch_resource(r) - puts "Resource: #{r.name}" - fetch_fetchable r - rescue ChecksumMismatchError => e - retry if retry_fetch? r - opoo "Resource #{r.name} reports different #{e.hash_type}: #{e.expected}" - end + tokens = api_fetch_names( + regex: HOMEBREW_DEFAULT_TAP_CASK_REGEX, + capture: :token, + hashes: Homebrew::API::Internal.cask_hashes, + aliases: {}, + renames: Homebrew::API::Internal.cask_renames, + ) + return false if tokens.nil? - def fetch_formula(f) - fetch_fetchable f - rescue ChecksumMismatchError => e - retry if retry_fetch? f - opoo "Formula reports different #{e.hash_type}: #{e.expected}" - end + downloads = T.let([], T::Array[[String, Cask::Download]]) + tokens.each do |token| + download = Homebrew::API::CaskDownload.download( + token:, + cask_struct: Homebrew::API::Internal.cask_struct(token), + quarantine: true, + require_sha: Homebrew::EnvConfig.cask_opts_require_sha?, + ) + return false if download.nil? - def fetch_patch(p) - fetch_fetchable p - rescue ChecksumMismatchError => e - Homebrew.failed = true - opoo "Patch reports different #{e.hash_type}: #{e.expected}" - end + downloads << [token, download] + end - def retry_fetch?(f) - @fetch_failed ||= Set.new - if args.retry? && @fetch_failed.add?(f) - ohai "Retrying download" - f.clear_cache - true - else - Homebrew.failed = true - false - end - end + puts "Fetching: #{tokens * ", "}" if tokens.size > 1 + downloads.each do |token, download| + ohai "Fetching #{token} from #{CoreCaskTap.instance}" + download_queue.enqueue(download) + end + true + end - def fetch_fetchable(f) - f.clear_cache if args.force? + sig { returns(T::Boolean) } + def api_fetchable? + return false if Homebrew::EnvConfig.no_install_from_api? + return false if args.all_platforms? || args.os.present? || args.arch.present? + return false if ENV["HOMEBREW_TEST_GENERIC_OS"].present? - already_fetched = f.cached_download.exist? + true + end - begin - download = f.fetch(verify_download_integrity: false) - rescue DownloadError - retry if retry_fetch? f - raise - end + sig { + params( + regex: Regexp, + capture: Symbol, + hashes: T::Hash[String, T::Hash[String, T.untyped]], + aliases: T::Hash[String, String], + renames: T::Hash[String, String], + ).returns(T.nilable(T::Array[String])) + } + def api_fetch_names(regex:, capture:, hashes:, aliases:, renames:) + requested_names = args.named.downcased_unique_named + names = T.let(requested_names.filter_map do |requested_name| + name = requested_name[regex, capture] + next if name.blank? + + name = name.downcase + name = aliases.fetch(name, name) + name = renames.fetch(name, name) + next unless hashes.key?(name) + + name + end, T::Array[String]) + return if names.length != requested_names.length + + names + end + + sig { params(cask: Cask::Cask).returns(T::Array[Cask::Download]) } + def cask_downloads(cask) + ref = cask.reloadable_ref - return unless download.file? + if args.all_platforms? && cask.loaded_from_api? + opoo "Cask #{cask} was loaded from the API; cannot fetch all operating system and " \ + "architecture variants. Set `HOMEBREW_NO_INSTALL_FROM_API=1` to fetch them all." + end + + # With `--all-platforms`, a cask without `on_system` blocks resolves + # identically everywhere, so one combination covers the whole matrix. + cask_combinations = args.os_arch_combinations + cask_combinations = cask_combinations.first(1) if args.all_platforms? && !cask.on_system_blocks_exist? + + downloads = T.let([], T::Array[Cask::Download]) + enqueued_urls = Set.new + + cask_combinations.each do |os, arch| + SimulateSystem.with(os:, arch:) do + loaded_cask = begin + Cask::CaskLoader.load(ref) + rescue Cask::CaskInvalidError, Cask::CaskUnreadableError + raise unless cask.on_system_blocks_exist? + end + if loaded_cask.nil? + opoo "Cask #{cask} is not supported on os #{os} and arch #{arch}" + next + end - puts "Downloaded to: #{download}" unless already_fetched - puts Checksum::TYPES.map { |t| "#{t.to_s.upcase}: #{download.send(t)}" } + languages = (loaded_cask.languages if args.all_platforms?) + languages = [nil] if languages.blank? - f.verify_download_integrity(download) + languages.each do |language| + localized_cask = loaded_cask + if language + # Reload per language: `Cask::Download` reads `sha256`/`url` + # lazily, so each download needs its own cask instance. + localized_cask = Cask::CaskLoader.load(ref) + localized_cask.config = localized_cask.config.merge( + Cask::Config.new(explicit: { languages: [language] }), + ) + end + + if localized_cask.url.nil? || localized_cask.sha256.nil? + opoo "Cask #{cask} is not supported on os #{os} and arch #{arch}" + next + end + + next unless enqueued_urls.add?(localized_cask.url.to_s) + + downloads << Cask::Download.new( + localized_cask, + quarantine: true, + require_sha: Homebrew::EnvConfig.cask_opts_require_sha?, + ) + end + end + end + + downloads + end + + sig { returns(Integer) } + def retries + @retries ||= T.let(args.retry? ? FETCH_MAX_TRIES : 1, T.nilable(Integer)) + end + + sig { returns(DownloadQueue) } + def download_queue + @download_queue ||= T.let(begin + DownloadQueue.new(retries:, force: args.force?) + end, T.nilable(DownloadQueue)) + end + end end end diff --git a/Library/Homebrew/cmd/formulae.rb b/Library/Homebrew/cmd/formulae.rb new file mode 100644 index 0000000000000..9239a5dca3752 --- /dev/null +++ b/Library/Homebrew/cmd/formulae.rb @@ -0,0 +1,22 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Formulae < AbstractCommand + # Used when the Bash implementation falls back to Ruby for tap trust filtering. + cmd_args do + description "List all locally installable formulae including short names." + end + + sig { override.void } + def run + require "formula" + + puts Formula.all(eval_all: true).flat_map { |formula| [formula.full_name, formula.name] }.uniq.sort + end + end + end +end diff --git a/Library/Homebrew/cmd/formulae.sh b/Library/Homebrew/cmd/formulae.sh new file mode 100644 index 0000000000000..f9b6530a09eb6 --- /dev/null +++ b/Library/Homebrew/cmd/formulae.sh @@ -0,0 +1,37 @@ +# Documentation defined in Library/Homebrew/cmd/formulae.rb + +# HOMEBREW_LIBRARY is set by bin/brew +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/items.sh" + +homebrew-formulae() { + local find_include_filter='*\.rb' + local sed_filter='s|/Formula/(.+/)?|/|' + local grep_filter='^homebrew/core' + + if homebrew-tap-trust-required + then + if homebrew-trusted-items-with-api-names "${find_include_filter}" '.*Casks(/.*|$)' \ + "trustedformulae" "homebrew/core" "Formula" "formula_names.txt" + then + return + fi + opoo "jq is unavailable; falling back to Ruby to apply tap trust." + HOMEBREW_FORCE_RUBY_COMMAND=1 "${HOMEBREW_BREW_FILE}" formulae + return + fi + + # HOMEBREW_CACHE is set by brew.sh + # shellcheck disable=SC2154 + if [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" && + -f "${HOMEBREW_CACHE}/api/formula_names.txt" ]] + then + { + cat "${HOMEBREW_CACHE}/api/formula_names.txt" + echo + homebrew-items "${find_include_filter}" '.*Casks(/.*|$)|.*/homebrew/homebrew-core/.*' "${sed_filter}" "${grep_filter}" + } | sort -uf + else + homebrew-items "${find_include_filter}" '.*Casks(/.*|$)' "${sed_filter}" "${grep_filter}" + fi +} diff --git a/Library/Homebrew/cmd/gist-logs.rb b/Library/Homebrew/cmd/gist-logs.rb index a62e42045149f..8e22c9fdaf146 100644 --- a/Library/Homebrew/cmd/gist-logs.rb +++ b/Library/Homebrew/cmd/gist-logs.rb @@ -1,151 +1,166 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "install" require "system_config" require "stringio" require "socket" -require "cli/parser" module Homebrew - module_function - - def gist_logs_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `gist-logs` [] - - Upload logs for a failed build of to a new Gist. Presents an - error message if no logs are found. - EOS - switch "--with-hostname", - description: "Include the hostname in the Gist." - switch "-n", "--new-issue", - description: "Automatically create a new issue in the appropriate GitHub repository "\ - "after creating the Gist." - switch "-p", "--private", - description: "The Gist will be marked private and will not appear in listings but will "\ - "be accessible with its link." - switch :verbose - switch :debug - max_named 1 - end - end - - def gistify_logs(f) - gist_logs_args.parse - - files = load_logs(f.logs) - build_time = f.logs.ctime - timestamp = build_time.strftime("%Y-%m-%d_%H-%M-%S") - - s = StringIO.new - SystemConfig.dump_verbose_config s - # Dummy summary file, asciibetically first, to control display title of gist - files["# #{f.name} - #{timestamp}.txt"] = { content: brief_build_info(f) } - files["00.config.out"] = { content: s.string } - files["00.doctor.out"] = { content: Utils.popen_read("#{HOMEBREW_PREFIX}/bin/brew", "doctor", err: :out) } - unless f.core_formula? - tap = <<~EOS - Formula: #{f.name} - Tap: #{f.tap} - Path: #{f.path} - EOS - files["00.tap.out"] = { content: tap } - end - - if GitHub.api_credentials_type == :none - puts <<~EOS - You can create a new personal access token: - #{GitHub::ALL_SCOPES_URL} - #{Utils::Shell.set_variable_in_profile("HOMEBREW_GITHUB_API_TOKEN", "your_token_here")} - - EOS - login! - end - - # Description formatted to work well as page title when viewing gist - if f.core_formula? - descr = "#{f.name} on #{OS_VERSION} - Homebrew build logs" - else - descr = "#{f.name} (#{f.full_name}) on #{OS_VERSION} - Homebrew build logs" - end - url = create_gist(files, descr) + module Cmd + class GistLogs < AbstractCommand + include Install + + cmd_args do + description <<~EOS + Upload logs for a failed build of to a new Gist. Presents an + error message if no logs are found. + EOS + switch "--with-hostname", + description: "Include the hostname in the Gist.", + odeprecated: true + switch "-n", "--new-issue", + description: "Automatically create a new issue in the appropriate GitHub repository " \ + "after creating the Gist." + switch "-p", "--private", + description: "The Gist will be marked private and will not appear in listings but will " \ + "be accessible with its link." + + named_args :formula, number: 1 + end - url = create_issue(f.tap, "#{f.name} failed to build on #{MacOS.full_version}", url) if args.new_issue? + sig { override.void } + def run + Install.perform_preinstall_checks_once(all_fatal: true) + Install.perform_build_from_source_checks(all_fatal: true) + return unless (formula = args.named.to_resolved_formulae.first) - puts url if url - end + gistify_logs(formula) + end - def brief_build_info(f) - build_time_str = f.logs.ctime.strftime("%Y-%m-%d %H:%M:%S") - s = +<<~EOS - Homebrew build logs for #{f.full_name} on #{OS_VERSION} - EOS - if args.with_hostname? - hostname = Socket.gethostname - s << "Host: #{hostname}\n" - end - s << "Build date: #{build_time_str}\n" - s.freeze - end + # Truncates a text string to fit within a byte size constraint, + # preserving character encoding validity. The returned string will + # be not much longer than the specified max_bytes, though the exact + # shortfall or overrun may vary. + sig { params(str: String, max_bytes: Integer, options: T::Hash[Symbol, T.untyped]).returns(String) } + def self.truncate_text_to_approximate_size(str, max_bytes, options = {}) + front_weight = options.fetch(:front_weight, 0.5) + raise "opts[:front_weight] must be between 0.0 and 1.0" if front_weight < 0.0 || front_weight > 1.0 + return str if str.bytesize <= max_bytes + + glue = "\n[...snip...]\n" + max_bytes_in = [max_bytes - glue.bytesize, 1].max + bytes = str.dup.force_encoding("BINARY") + glue_bytes = glue.encode("BINARY") + n_front_bytes = (max_bytes_in * front_weight).floor + n_back_bytes = max_bytes_in - n_front_bytes + if n_front_bytes.zero? + front = bytes[1..0] + back = bytes[-max_bytes_in..] + elsif n_back_bytes.zero? + front = bytes[0..(max_bytes_in - 1)] + back = bytes[1..0] + else + front = bytes[0..(n_front_bytes - 1)] + back = bytes[-n_back_bytes..] + end + out = T.must(front) + glue_bytes + T.must(back) + out.force_encoding("UTF-8") + out.encode!("UTF-16", invalid: :replace) + out.encode!("UTF-8") + out + end - # Causes some terminals to display secure password entry indicators - def noecho_gets - system "stty -echo" - result = $stdin.gets - system "stty echo" - puts - result - end + private + + sig { params(formula: Formula).void } + def gistify_logs(formula) + files = load_logs(formula.logs) + build_time = formula.logs.ctime + timestamp = build_time.strftime("%Y-%m-%d_%H-%M-%S") + + s = StringIO.new + SystemConfig.dump_verbose_config s + # Dummy summary file, asciibetically first, to control display title of gist + files["# #{formula.name} - #{timestamp}.txt"] = { + content: brief_build_info(formula, with_hostname: args.with_hostname?), + } + files["00.config.out"] = { content: s.string } + files["00.doctor.out"] = { content: Utils.popen_read("#{HOMEBREW_PREFIX}/bin/brew", "doctor", err: :out) } + unless formula.core_formula? + tap = <<~EOS + Formula: #{formula.name} + Tap: #{formula.tap} + Path: #{formula.path} + EOS + files["00.tap.out"] = { content: tap } + end + + if GitHub::API.credentials_type == :none + odie "`brew gist-logs` requires `$HOMEBREW_GITHUB_API_TOKEN` to be set!" + end + + # Description formatted to work well as page title when viewing gist + descr = if formula.core_formula? + "#{formula.name} on #{OS_VERSION} - Homebrew build logs" + else + "#{formula.name} (#{formula.full_name}) on #{OS_VERSION} - Homebrew build logs" + end + + begin + url = GitHub.create_gist(files, descr, private: args.private?) + rescue GitHub::API::HTTPNotFoundError + odie <<~EOS + Your GitHub API token likely doesn't have the `gist` scope. + #{GitHub.pat_blurb(GitHub::CREATE_GIST_SCOPES)} + EOS + end + + if args.new_issue? + tap = formula.tap + odie "Formula #{formula.name} is not associated with a tap!" unless tap + url = GitHub.create_issue(tap.full_name, "#{formula.name} failed to build on #{OS_VERSION}", url) + end + + puts url if url + end - def login! - print "GitHub User: " - ENV["HOMEBREW_GITHUB_API_USERNAME"] = $stdin.gets.chomp - print "Password: " - ENV["HOMEBREW_GITHUB_API_PASSWORD"] = noecho_gets.chomp - puts - end + sig { params(formula: Formula, with_hostname: T::Boolean).returns(String) } + def brief_build_info(formula, with_hostname:) + build_time_string = formula.logs.ctime.strftime("%Y-%m-%d %H:%M:%S") + string = <<~EOS + Homebrew build logs for #{formula.full_name} on #{OS_VERSION} + EOS + if with_hostname + hostname = Socket.gethostname + string << "Host: #{hostname}\n" + end + string << "Build date: #{build_time_string}\n" + string.freeze + end - def load_logs(dir) - logs = {} - if dir.exist? - dir.children.sort.each do |file| - contents = file.size? ? file.read : "empty log" - # small enough to avoid GitHub "unicorn" page-load-timeout errors - max_file_size = 1_000_000 - contents = truncate_text_to_approximate_size(contents, max_file_size, front_weight: 0.2) - logs[file.basename.to_s] = { content: contents } + sig { params(dir: Pathname, basedir: Pathname).returns(T::Hash[String, { content: String }]) } + def load_logs(dir, basedir = dir) + logs = {} + if dir.exist? + dir.children.sort.each do |file| + if file.directory? + logs.merge! load_logs(file, basedir) + else + contents = file.size? ? file.read : "empty log" + # small enough to avoid GitHub "unicorn" page-load-timeout errors + max_file_size = 1_000_000 + contents = GistLogs.truncate_text_to_approximate_size(contents, max_file_size, front_weight: 0.2) + logs[file.relative_path_from(basedir).to_s.tr("/", ":")] = { content: contents } + end + end + end + odie "No logs." if logs.empty? + + logs end end - raise "No logs." if logs.empty? - - logs - end - - def create_private? - args.private? - end - - def create_gist(files, description) - url = "https://api.github.com/gists" - data = { "public" => !create_private?, "files" => files, "description" => description } - scopes = GitHub::CREATE_GIST_SCOPES - GitHub.open_api(url, data: data, scopes: scopes)["html_url"] - end - - def create_issue(repo, title, body) - url = "https://api.github.com/repos/#{repo}/issues" - data = { "title" => title, "body" => body } - scopes = GitHub::CREATE_ISSUE_FORK_OR_PR_SCOPES - GitHub.open_api(url, data: data, scopes: scopes)["html_url"] - end - - def gist_logs - raise FormulaUnspecifiedError if Homebrew.args.resolved_formulae.length != 1 - - Install.perform_preinstall_checks(all_fatal: true) - Install.perform_build_from_source_checks(all_fatal: true) - gistify_logs(Homebrew.args.resolved_formulae.first) end end diff --git a/Library/Homebrew/cmd/help.rb b/Library/Homebrew/cmd/help.rb index d75c424e1cdb3..7481d89d4663c 100644 --- a/Library/Homebrew/cmd/help.rb +++ b/Library/Homebrew/cmd/help.rb @@ -1,9 +1,25 @@ +# typed: strong # frozen_string_literal: true +require "abstract_command" require "help" module Homebrew - def help(cmd = nil, flags = {}) - Help.help(cmd, flags) + module Cmd + class HelpCmd < AbstractCommand + cmd_args do + description <<~EOS + Outputs the usage instructions for `brew` . + Equivalent to `brew --help` . + EOS + + named_args [:command] + end + + sig { override.void } + def run + Help.help + end + end end end diff --git a/Library/Homebrew/cmd/home.rb b/Library/Homebrew/cmd/home.rb index 98099c090a8e0..59ac2b6c5c573 100644 --- a/Library/Homebrew/cmd/home.rb +++ b/Library/Homebrew/cmd/home.rb @@ -1,29 +1,54 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "formula" module Homebrew - module_function + module Cmd + class Home < AbstractCommand + cmd_args do + description <<~EOS + Open a or 's homepage in a browser, or open + Homebrew's own homepage if no argument is provided. + EOS + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - def home_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `home` [] + conflicts "--formula", "--cask" - Open 's homepage in a browser, or open Homebrew's own homepage - if no formula is provided. - EOS - switch :debug - end - end + named_args [:formula, :cask] + end + + sig { override.void } + def run + if args.no_named? + exec_browser HOMEBREW_WWW + return + end + + # to_formulae_and_casks is typed to possibly return Kegs (but won't without explicitly asking) + formulae_or_casks = T.cast(args.named.to_formulae_and_casks, T::Array[T.any(Formula, Cask::Cask)]) + homepages = formulae_or_casks.map do |formula_or_cask| + puts "Opening homepage for #{name_of(formula_or_cask)}" + formula_or_cask.homepage + end + + exec_browser(*homepages) + end - def home - home_args.parse + private - if args.remaining.empty? - exec_browser HOMEBREW_WWW - else - exec_browser(*Homebrew.args.formulae.map(&:homepage)) + sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(String) } + def name_of(formula_or_cask) + if formula_or_cask.is_a? Formula + "Formula #{formula_or_cask.name}" + else + "Cask #{formula_or_cask.token}" + end + end end end end diff --git a/Library/Homebrew/cmd/info.rb b/Library/Homebrew/cmd/info.rb index 30e0c3f3b786c..f586513df1139 100644 --- a/Library/Homebrew/cmd/info.rb +++ b/Library/Homebrew/cmd/info.rb @@ -1,270 +1,1025 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "missing_formula" require "caveats" -require "cli/parser" require "options" require "formula" +require "formula_pin" require "keg" require "tab" require "json" +require "cask/cask_loader" +require "utils/spdx" +require "deprecate_disable" +require "api" module Homebrew - module_function - - VALID_DAYS = %w[30 90 365].freeze - VALID_FORMULA_CATEGORIES = %w[install install-on-request build-error].freeze - VALID_CATEGORIES = (VALID_FORMULA_CATEGORIES + %w[cask-install os-version]).freeze - - def info_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `info` [] [] - - Display brief statistics for your Homebrew installation. - - If is provided, show summary of information about . - EOS - switch "--analytics", - description: "List global Homebrew analytics data or, if specified, installation and "\ - "build error data for (provided neither `HOMEBREW_NO_ANALYTICS` "\ - "nor `HOMEBREW_NO_GITHUB_API` are set)." - flag "--days", - depends_on: "--analytics", - description: "How many days of analytics data to retrieve. "\ - "The value for must be `30`, `90` or `365`. The default is `30`." - flag "--category", - depends_on: "--analytics", - description: "Which type of analytics data to retrieve. "\ - "The value for must be `install`, `install-on-request` or `build-error`; "\ - "`cask-install` or `os-version` may be specified if is not. "\ - "The default is `install`." - switch "--github", - description: "Open the GitHub source page for in a browser. "\ - "To view formula history locally: `brew log -p` " - flag "--json", - description: "Print a JSON representation of . Currently the default and only accepted "\ - "value for is `v1`. See the docs for examples of using the JSON "\ - "output: " - switch "--installed", - depends_on: "--json", - description: "Print JSON of formulae that are currently installed." - switch "--all", - depends_on: "--json", - description: "Print JSON of all available formulae." - switch :verbose, - description: "Show more verbose analytics data for ." - switch :debug - conflicts "--installed", "--all" - end - end + module Cmd + class Info < AbstractCommand + class NameSize < T::Struct + const :name, String + const :size, Integer + end + private_constant :NameSize - def info - info_args.parse + VALID_DAYS = %w[30 90 365].freeze + VALID_FORMULA_CATEGORIES = %w[install install-on-request build-error].freeze + VALID_CATEGORIES = T.let((VALID_FORMULA_CATEGORIES + %w[cask-install os-version]).freeze, T::Array[String]) - if args.days.present? - raise UsageError, "--days must be one of #{VALID_DAYS.join(", ")}" unless VALID_DAYS.include?(args.days) - end + cmd_args do + description <<~EOS + Display brief statistics for your Homebrew installation. + If a or is provided, show summary of information about it. + EOS + switch "--analytics", + description: "List global Homebrew analytics data or, if specified, installation and " \ + "build error data for (provided neither `$HOMEBREW_NO_ANALYTICS` " \ + "nor `$HOMEBREW_NO_GITHUB_API` are set)." + flag "--days=", + depends_on: "--analytics", + description: "How many days of analytics data to retrieve. " \ + "The value for must be `30`, `90` or `365`. The default is `30`." + flag "--category=", + depends_on: "--analytics", + description: "Which type of analytics data to retrieve. " \ + "The value for must be `install`, `install-on-request` or `build-error`; " \ + "`cask-install` or `os-version` may be specified if is not. " \ + "The default is `install`." + switch "--github-packages-downloads", + description: "Scrape GitHub Packages download counts from HTML for a core formula.", + hidden: true + switch "--github", + description: "Open the GitHub source page for and in a browser. " \ + "To view the history locally: `brew log -p` or " + switch "--fetch-manifest", + description: "Fetch GitHub Packages manifest for extra information when is not installed.", + odeprecated: true + flag "--json", + description: "Print a JSON representation. Currently the default value for is `v1`." \ + "`v1` is valid for only. `v2` is valid for both and ." \ + "See the docs for examples of using the JSON output: " + switch "--installed", + description: "Output a human-readable inventory of installed formulae and casks. If `--json` is " \ + "passed, print JSON for installed formulae and, with `--json=v2`, installed casks." + switch "--eval-all", + depends_on: "--json", + description: "Evaluate all available formulae and casks, whether installed or not, to print their " \ + "JSON.", + env: :eval_all, + odeprecated: true + switch "--variations", + depends_on: "--json", + description: "Include the variations hash in each formula's JSON output." + switch "-v", "--verbose", + description: "Show more verbose data for , or full information with `--installed`." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + switch "--sizes", + description: "Show the size of installed formulae and casks." - if args.category.present? - if Homebrew.args.named.present? && !VALID_FORMULA_CATEGORIES.include?(args.category) - raise UsageError, "--category must be one of #{VALID_FORMULA_CATEGORIES.join(", ")} when querying formulae" + conflicts "--installed", "--eval-all" + conflicts "--formula", "--cask" + conflicts "--fetch-manifest", "--cask" + conflicts "--fetch-manifest", "--json" + + named_args [:formula, :cask] end - unless VALID_CATEGORIES.include?(args.category) - raise UsageError, "--category must be one of #{VALID_CATEGORIES.join(", ")}" + sig { override.void } + def run + if args.sizes? + if args.no_named? + print_sizes + else + formulae, casks = args.named.to_formulae_to_casks + formulae = T.cast(formulae, T::Array[Formula]) + print_sizes(formulae:, casks:) + end + elsif args.analytics? + if args.days.present? && VALID_DAYS.exclude?(args.days) + raise UsageError, "`--days` must be one of #{VALID_DAYS.join(", ")}." + end + + if args.category.present? + if args.named.present? && VALID_FORMULA_CATEGORIES.exclude?(args.category) + raise UsageError, + "`--category` must be one of #{VALID_FORMULA_CATEGORIES.join(", ")} when querying formulae." + end + + unless VALID_CATEGORIES.include?(args.category) + raise UsageError, "`--category` must be one of #{VALID_CATEGORIES.join(", ")}." + end + end + + print_analytics + elsif (json = args.json) + eval_all = args.eval_all? + eval_all ||= args.no_named? && !args.installed? && Homebrew::EnvConfig.tap_trust_configured? + print_json(json, eval_all) + elsif args.installed? + T.let([ + *(args.cask? ? [] : Formula.installed.sort), + *(args.formula? ? [] : Cask::Caskroom.casks.sort_by(&:full_name)), + ], T::Array[T.any(Formula, Cask::Cask)]).each_with_index do |formula_or_cask, i| + puts unless i.zero? + + info_formula_or_cask(formula_or_cask, quiet: !args.verbose?) + end + elsif args.github? + raise FormulaOrCaskUnspecifiedError if args.no_named? + + exec_browser(*args.named.to_formulae_and_casks.map do |formula_keg_or_cask| + formula_or_cask = T.cast(formula_keg_or_cask, T.any(Formula, Cask::Cask)) + github_info(formula_or_cask) + end) + elsif args.no_named? + print_statistics + else + print_info(quiet: args.quiet?) + end end - end - if args.json - raise UsageError, "Invalid JSON version: #{args.json}" unless ["v1", true].include? args.json - if !(args.all? || args.installed?) && Homebrew.args.named.blank? - raise UsageError, "This command's option requires a formula argument" + sig { params(remote: String, path: String).returns(String) } + def github_remote_path(remote, path) + if remote =~ %r{^(?:https?://|git(?:@|://))github\.com[:/](.+)/(.+?)(?:\.git)?$} + "https://github.com/#{Regexp.last_match(1)}/#{Regexp.last_match(2)}/blob/HEAD/#{path}" + else + "#{remote}/#{path}" + end end - print_json - elsif args.github? - raise UsageError, "This command's option requires a formula argument" if Homebrew.args.named.blank? + sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Array[String]) } + def self.metadata_lines(formula_or_cask) + return [] unless $stdout.tty? - exec_browser(*Homebrew.args.formulae.map { |f| github_info(f) }) - else - print_info - end - end + case formula_or_cask + when Formula + formula_metadata_lines(formula_or_cask) + when Cask::Cask + if formula_or_cask.pinned? + pinned = "Pinned: #{formula_or_cask.pinned_version}" + if (pinned_time = pin_path_mtime(formula_or_cask.pin_path)) + pinned << " on #{formatted_time(pinned_time)}" + end + [pinned] + else + [] + end + else + T.absurd(formula_or_cask) + end + end + + sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Array[String]) } + def self.requirements_lines(formula_or_cask) + return [] unless $stdout.tty? + + case formula_or_cask + when Formula + [] + when Cask::Cask + cask_requirements_lines(formula_or_cask) + else + T.absurd(formula_or_cask) + end + end + + sig { params(tab: T.any(Tab, Cask::Tab)).returns(String) } + def self.installation_status(tab) + # TODO: Deprecate reading `installed_as_dependency`; `installed_on_request` + # is the only state we need to render install intent. + tab.installed_on_request ? "Installed (on request)" : "Installed (as dependency)" + end + + sig { params(tab: T.any(Tab, Cask::Tab)).returns(String) } + def self.installation_reason(tab) + return "-" unless tab.installed_on_request_present? + + tab.installed_on_request ? "on request" : "dependency" + end + + sig { params(version: String, tab: T.any(Tab, Cask::Tab)).returns(String) } + def self.installation_summary(version, tab) + reason = installation_reason(tab) + + "Installed: #{version}#{" (#{reason})" if reason != "-"}" + end + + sig { params(requirement: Requirement).returns(T::Boolean) } + def self.requirement_for_other_os?(requirement) + requirement.instance_of?(MacOSRequirement) || requirement.instance_of?(LinuxRequirement) + end + + sig { params(installed_count: Integer, total_count: Integer).returns(String) } + def self.dependency_status_counts(installed_count, total_count) + missing_count = total_count - installed_count + return "all installed #{Formatter.success("✔")}" if missing_count.zero? + + "#{installed_count} installed #{Formatter.success("✔")}, " \ + "#{missing_count} missing #{Formatter.error("✘")}" + end + + sig { params(full_name: String, name: String).returns(T::Array[String]) } + def self.installed_dependent_names(full_name, name) + Formula.racks.filter_map do |rack| + keg = Keg.from_rack(rack) + next unless keg + + tab_path = keg/AbstractTab::FILENAME + next unless tab_path.file? + + # Fast path: skip JSON parsing when the formula name + # does not appear anywhere in the raw receipt. + content = File.read(tab_path) + next unless content.include?(name) + + tab_deps = Tab.from_file_content(content, tab_path).runtime_dependencies + next unless tab_deps + + dependent = tab_deps.any? do |dep| + dep_full_name = T.cast(dep, T::Hash[String, T.untyped])["full_name"] + dep_full_name == full_name || dep_full_name&.then { Utils.name_from_full_name(it) } == name + end + keg.name if dependent + end.sort.uniq + end + + sig { params(formula: Formula).returns(T::Array[String]) } + def self.formula_metadata_lines(formula) + metadata = T.let([], T::Array[String]) + if formula.pinned? + pinned = "Pinned: #{formula.pinned_version}" + if (pinned_time = pin_path_mtime(FormulaPin.new(formula).path)) + pinned << " on #{formatted_time(pinned_time)}" + end + metadata << pinned + end + + if !formula.any_version_installed? && + formula_installs_from_source?(formula) && + formula.requirements.none? { |requirement| requirement_for_other_os?(requirement) } + metadata << "Installs from source: yes" + end + metadata + end + + sig { params(cask: Cask::Cask).returns(T::Array[String]) } + def self.cask_requirements_lines(cask) + macos_requirements = [cask.depends_on.macos, cask.depends_on.maximum_macos].compact + requirement = if macos_requirements.present? + requirement = macos_requirements.filter_map do |macos_requirement| + macos_requirement.display_s.delete_suffix(" (or Linux)").delete_prefix("macOS").strip.presence + end + requirement = requirement.present? ? "macOS #{requirement.join(", ")}" : "macOS" + requirement += " or Linux" if cask.supports_linux? + requirement + elsif cask.supports_macos? && cask.supports_linux? + "macOS or Linux" + elsif cask.supports_macos? + "macOS" + elsif cask.supports_linux? + "Linux" + end + + requirement ? [requirement] : [] + end + + sig { params(time: T.any(Integer, Time)).returns(String) } + def self.formatted_time(time) + time = Time.at(time) if time.is_a?(Integer) + + time.strftime("%Y-%m-%d at %H:%M:%S") + end + + sig { params(pin_path: Pathname).returns(T.nilable(Time)) } + def self.pin_path_mtime(pin_path) + pin_path.lstat.mtime if pin_path.symlink? || pin_path.exist? + rescue Errno::ENOENT + nil + end + + sig { params(formula: T.untyped).returns(T::Boolean) } + def self.formula_installs_from_source?(formula) + return true if formula.stable.blank? && formula.head.present? + return false if formula.stable.blank? + + !formula.stable.bottled? || !formula.pour_bottle? + end + + sig { + params(cask: T.untyped, formula_dependencies: T::Set[String], cask_dependencies: T::Set[String], + visited_casks: T::Set[String]).void + } + def self.collect_cask_dependency_names(cask, formula_dependencies, cask_dependencies, visited_casks) + cask.depends_on.formula.each do |name| + dep_name = name.to_s + formula_dependencies << dep_name + rack = HOMEBREW_CELLAR/Utils.name_from_full_name(dep_name) + next unless rack.directory? + + keg = Keg.from_rack(rack) + next unless keg + + tab_deps = Tab.for_keg(keg).runtime_dependencies + tab_deps&.each do |runtime_dep| + dep_full_name = T.cast(runtime_dep, T::Hash[String, T.untyped])["full_name"] + formula_dependencies << dep_full_name if dep_full_name + end + end + + cask.depends_on.cask.each do |name| + token = name.to_s + next if visited_casks.include?(token) + + cask_dependencies << token + visited_casks << token + begin + dependency = Cask::CaskLoader.load(token) + collect_cask_dependency_names(dependency, formula_dependencies, cask_dependencies, visited_casks) + rescue Cask::CaskUnavailableError + next + end + end + end + + private_class_method :formula_metadata_lines, :formatted_time, :pin_path_mtime, + :formula_installs_from_source?, :cask_requirements_lines + + private + + sig { void } + def print_statistics + return unless HOMEBREW_CELLAR.exist? - def print_info - if Homebrew.args.named.blank? - if args.analytics? - Utils::Analytics.output - elsif HOMEBREW_CELLAR.exist? count = Formula.racks.length - puts "#{count} #{"keg".pluralize(count)}, #{HOMEBREW_CELLAR.dup.abv}" - end - else - Homebrew.args.named.each_with_index do |f, i| - puts unless i.zero? - begin - formula = if f.include?("/") || File.exist?(f) - Formulary.factory(f) + puts "#{Utils.pluralize("keg", count, include_count: true)}, #{HOMEBREW_CELLAR.dup.abv}" + end + + sig { void } + def print_analytics + if args.no_named? + Utils::Analytics.output(args:) + return + end + + args.named.to_formulae_and_casks_and_unavailable.each_with_index do |obj, i| + puts unless i.zero? + + case obj + when Formula + Utils::Analytics.formula_output(obj, args:) if obj.core_formula? + when Cask::Cask + Utils::Analytics.cask_output(obj, args:) if obj.tap&.core_cask_tap? + when FormulaOrCaskUnavailableError + Utils::Analytics.output(filter: obj.name, args:) else - Formulary.find_with_priority(f) + raise end - if args.analytics? - Utils::Analytics.formula_output(formula) + end + end + + sig { params(quiet: T::Boolean).void } + def print_info(quiet: false) + objects = args.named.to_formulae_and_casks_and_unavailable(uniq: false) + user_qualified = args.named.downcased_unique_named.map { |name| name.include?("/") } + + resolved = user_qualified.zip(objects).map do |qualified, obj| + if obj.is_a?(Formula) + display_resolution(obj, user_qualified: qualified) else - info_formula(formula) + [obj, nil] end - rescue FormulaUnavailableError => e - if args.analytics? - Utils::Analytics.output(filter: f) - next + end + + unique_by_display_name(resolved).each_with_index do |(obj, shadowed_by), i| + puts unless i.zero? + + if obj.is_a?(FormulaOrCaskUnavailableError) + # The formula/cask could not be found + ofail obj.message + # No formula with this name, try a missing formula lookup + if (reason = MissingFormula.reason(obj.name, show_info: true)) + $stderr.puts reason + end + else + info_formula_or_cask(obj, quiet:, shadowed_by:) end - ofail e.message - # No formula with this name, try a missing formula lookup - if (reason = MissingFormula.reason(f, show_info: true)) - $stderr.puts reason + end + end + + sig { + params(resolved: T::Array[[T.untyped, T.nilable(Tap)]]).returns(T::Array[[T.untyped, T.nilable(Tap)]]) + } + def unique_by_display_name(resolved) + resolved.uniq do |obj, _shadowed_by| + case obj + when Formula, Cask::Cask then obj.full_name + else obj end end end - end - end - def print_json - ff = if args.all? - Formula.sort - elsif args.installed? - Formula.installed.sort - else - Homebrew.args.formulae - end - json = ff.map(&:to_hash) - puts JSON.generate(json) - end + sig { params(formula: Formula, user_qualified: T::Boolean).returns([Formula, T.nilable(Tap)]) } + def display_resolution(formula, user_qualified:) + return [formula, nil] if user_qualified - def github_remote_path(remote, path) - if remote =~ %r{^(?:https?://|git(?:@|://))github\.com[:/](.+)/(.+?)(?:\.git)?$} - "https://github.com/#{Regexp.last_match(1)}/#{Regexp.last_match(2)}/blob/master/#{path}" - else - "#{remote}/#{path}" - end - end + installed_resolution(formula) + end - def github_info(f) - if f.tap - if remote = f.tap.remote - path = f.path.relative_path_from(f.tap.path) - github_remote_path(remote, path) - else - f.path + sig { params(formula_or_cask: T.any(Formula, Cask::Cask), qualified_inputs: T::Set[String]).returns(T::Boolean) } + def formula_qualified_by_user?(formula_or_cask, qualified_inputs) + return false if qualified_inputs.empty? + + names = T.let([formula_or_cask.full_name.downcase], T::Array[String]) + if (tap = formula_or_cask.tap) + names << "#{tap.name.downcase}/#{Utils.name_or_token(formula_or_cask).downcase}" + end + names.any? { |n| qualified_inputs.include?(n) } end - else - f.path - end - end - def info_formula(f) - specs = [] + sig { params(formula_or_cask: T.any(Formula, Cask::Cask), quiet: T::Boolean, shadowed_by: T.nilable(Tap)).void } + def info_formula_or_cask(formula_or_cask, quiet:, shadowed_by: nil) + case formula_or_cask + when Formula + if quiet + info_formula_summary(formula_or_cask) + else + info_formula(formula_or_cask, shadowed_by:) + end + when Cask::Cask + if quiet + info_cask_summary(formula_or_cask) + else + info_cask(formula_or_cask) + end + end + end - if stable = f.stable - s = "stable #{stable.version}" - s += " (bottled)" if stable.bottled? - specs << s - end + sig { params(formula: Formula).returns([Formula, T.nilable(Tap)]) } + def installed_resolution(formula) + keg = formula.installed_kegs.last + return [formula, nil] if keg.nil? - if devel = f.devel - specs << "devel #{devel.version}" - end + installed_tap = keg.tab.tap + return [formula, nil] if installed_tap.nil? || installed_tap == formula.tap - specs << "HEAD" if f.head - - attrs = [] - attrs << "pinned at #{f.pinned_version}" if f.pinned? - attrs << "keg-only" if f.keg_only? - - puts "#{f.full_name}: #{specs * ", "}#{" [#{attrs * ", "}]" unless attrs.empty?}" - puts f.desc if f.desc - puts Formatter.url(f.homepage) if f.homepage - - conflicts = f.conflicts.map do |c| - reason = " (because #{c.reason})" if c.reason - "#{c.name}#{reason}" - end.sort! - unless conflicts.empty? - puts <<~EOS - Conflicts with: - #{conflicts.join("\n ")} - EOS - end + [Formulary.factory("#{installed_tap}/#{keg.name}"), formula.tap] + rescue FormulaUnavailableError, TapFormulaAmbiguityError + [formula, nil] + end - kegs = f.installed_kegs - heads, versioned = kegs.partition { |k| k.version.head? } - kegs = [ - *heads.sort_by { |k| -Tab.for_keg(k).time.to_i }, - *versioned.sort_by(&:version), - ] - if kegs.empty? - puts "Not installed" - else - kegs.each do |keg| - puts "#{keg} (#{keg.abv})#{" *" if keg.linked?}" - tab = Tab.for_keg(keg).to_s - puts " #{tab}" unless tab.empty? + sig { params(formula: Formula).returns(T.nilable(Formula)) } + def shadowing_installed_formula(formula) + installed_formula, shadowed_by = installed_resolution(formula) + installed_formula if shadowed_by end - end - puts "From: #{Formatter.url(github_info(f))}" + sig { params(formula: Formula, qualified_inputs: T::Set[String]).returns(Formula) } + def swap_to_installed_formula(formula, qualified_inputs) + return formula if formula_qualified_by_user?(formula, qualified_inputs) - unless f.deps.empty? - ohai "Dependencies" - %w[build required recommended optional].map do |type| - deps = f.deps.send(type).uniq - puts "#{type.capitalize}: #{decorate_dependencies deps}" unless deps.empty? + installed_resolution(formula).first end - end - unless f.requirements.to_a.empty? - ohai "Requirements" - %w[build required recommended optional].map do |type| - reqs = f.requirements.select(&:"#{type}?") - next if reqs.to_a.empty? + sig { params(version: T.any(T::Boolean, String)).returns(Symbol) } + def json_version(version) + version_hash = { + true => :default, + "v1" => :v1, + "v2" => :v2, + } - puts "#{type.capitalize}: #{decorate_requirements(reqs)}" + raise UsageError, "invalid JSON version: #{version}" unless version_hash.include?(version) + + version_hash[version] end - end - if !f.options.empty? || f.head || f.devel - ohai "Options" - Homebrew.dump_options_for_formula f - end + sig { params(json: T.any(T::Boolean, String), eval_all: T::Boolean).void } + def print_json(json, eval_all) + raise FormulaOrCaskUnspecifiedError if !(eval_all || args.installed?) && args.no_named? - caveats = Caveats.new(f) - ohai "Caveats", caveats.to_s unless caveats.empty? + qualified_inputs = args.named.select { |name| name.include?("/") }.to_set - Utils::Analytics.formula_output(f) - end + json = case json_version(json) + when :v1, :default + raise UsageError, "Cannot specify `--cask` when using `--json=v1`!" if args.cask? + + formulae = if eval_all + Formula.all(eval_all:).sort + elsif args.installed? + Formula.installed.sort + else + args.named.to_formulae.map { |f| swap_to_installed_formula(f, qualified_inputs) } + end + + if args.variations? + formulae.map(&:to_hash_with_variations) + else + formulae.map(&:to_hash) + end + when :v2 + formulae, casks = T.let( + if eval_all + formulae = [] if args.cask? + formulae ||= Formula.all(eval_all:).sort + casks = [] if args.formula? + casks ||= Cask::Cask.all(eval_all:).sort_by(&:full_name) + [formulae, casks] + elsif args.installed? + formulae = [] if args.cask? + formulae ||= Formula.installed.sort + casks = [] if args.formula? + casks ||= Cask::Caskroom.casks.sort_by(&:full_name) + [formulae, casks] + else + named_formulae, named_casks = T.cast( + args.named.to_formulae_to_casks, [T::Array[Formula], T::Array[Cask::Cask]] + ) + [named_formulae.map { |f| swap_to_installed_formula(f, qualified_inputs) }, named_casks] + end, [T::Array[Formula], T::Array[Cask::Cask]] + ) + + if args.variations? + { + "formulae" => formulae.map(&:to_hash_with_variations), + "casks" => casks.map(&:to_hash_with_variations), + } + else + { + "formulae" => formulae.map(&:to_hash), + "casks" => casks.map(&:to_h), + } + end + else + raise + end - def decorate_dependencies(dependencies) - deps_status = dependencies.map do |dep| - if dep.satisfied?([]) - pretty_installed(dep_display_s(dep)) - else - pretty_uninstalled(dep_display_s(dep)) + puts JSON.pretty_generate(json) end - end - deps_status.join(", ") - end - def decorate_requirements(requirements) - req_status = requirements.map do |req| - req_s = req.display_s - req.satisfied? ? pretty_installed(req_s) : pretty_uninstalled(req_s) - end - req_status.join(", ") - end + sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(String) } + def github_info(formula_or_cask) + tap = T.let(nil, T.nilable(Tap)) + path = case formula_or_cask + when Formula + formula = formula_or_cask + tap = formula.tap + return formula.path.to_s if tap.blank? || tap.remote.blank? + # The formula file may live outside the tap (e.g. loaded from a keg's + # `.brew/` directory after the formula was removed from its tap), in + # which case there is no meaningful upstream URL to link to. + return formula.path.to_s unless formula.path.to_s.start_with?("#{tap.path}/") + + formula.path.relative_path_from(tap.path) + when Cask::Cask + cask = formula_or_cask + tap = cask.tap + return cask.sourcefile_path.to_s if tap.blank? || tap.remote.blank? + + sourcefile_path = cask.sourcefile_path + if !sourcefile_path || sourcefile_path.extname != ".rb" + return "#{tap.default_remote}/blob/HEAD/#{tap.relative_cask_path(cask.token)}" + end + + sourcefile_path.relative_path_from(tap.path) + end + + remote = tap.remote + raise "unexpected nil tap.remote" unless remote + + github_remote_path(remote, path.to_s) + end + + sig { params(name: String, description: T.nilable(String), installed: T::Boolean).returns(String) } + def info_summary_title(name, description, installed:) + name = pretty_installed(name) if installed + + "#{name}#{": #{description}" if description.present?}" + end + + sig { params(formula: Formula).void } + def info_formula_summary(formula) + kegs = formula.installed_kegs + tab = Tab.for_formula(formula) + version = kegs.sort_by(&:scheme_and_version) + .map { |keg| keg.version.to_s } + .join(", ") + version = "-" if version.blank? + + puts oh1_title(info_summary_title(formula.full_name, formula.desc, installed: kegs.any?)) + if kegs.empty? + puts "Formula from #{github_info(formula)}" + puts "Not installed" + else + puts "Formula from #{formula.tap&.name || + T.cast(tab.source["tap"], T.nilable(String)) || + T.cast(tab.source["path"], T.nilable(String)) || + github_info(formula)}" + puts self.class.installation_summary(version, tab) + end + end + + sig { params(formula: Formula, shadowed_by: T.nilable(Tap)).void } + def info_formula(formula, shadowed_by: nil) + specs = T.let([], T::Array[String]) + + if (stable = formula.stable) + string = "stable #{stable.version}" + string += " (bottled)" if stable.bottled? && formula.pour_bottle? + specs << string + end + + specs << "HEAD" if formula.head + + attrs = [] + attrs << "keg-only" if formula.keg_only? + + shadowing_formula = shadowing_installed_formula(formula) + kegs = shadowing_formula ? [] : formula.installed_kegs + installed = kegs.any? + outdated = installed && formula.outdated? + missing_libraries, missing_library_deps = formula.missing_library_linkage if installed + missing_libraries ||= [] + missing_library_deps ||= Set.new + if outdated && (upgrade_version = specs.first.presence) + installed_version = formula.linked_version || + kegs.max_by(&:scheme_and_version)&.version + specs[0] = "#{installed_version} → #{upgrade_version}" + end + title_name = if shadowing_formula && (formula_tap = formula.tap) + "#{formula_tap}/#{formula.name}" + elsif shadowed_by + formula.name + else + formula.full_name + end + name_with_status = pretty_install_status( + title_name, + warning: missing_libraries.present?, + installed:, + outdated:, + deprecated: formula.deprecated?, + disabled: formula.disabled?, + bold: true, + ) + + puts "#{oh1_title(name_with_status)}: #{specs * ", "}#{" [#{attrs * ", "}]" unless attrs.empty?}" + if shadowed_by + puts Formatter.warning( + "`#{formula.name}` shadows `#{shadowed_by.name}/#{formula.name}`.", + label: "Warning", + ) + end + puts formula.desc if formula.desc + puts Formatter.url(formula.homepage) if formula.homepage + puts "Aliases: #{formula.aliases.join(", ")}" if formula.aliases.any? + puts "Old Names: #{formula.oldnames.join(", ")}" if formula.oldnames.any? + + deprecate_disable_info_string = DeprecateDisable.message(formula) + if deprecate_disable_info_string.present? + deprecate_disable_info_string.tap { |info_string| info_string[0] = info_string[0].upcase } + puts deprecate_disable_info_string + end + + conflicts = formula.conflicts.filter_map do |conflict| + resolved = begin + Formulary.factory(conflict.name) + rescue FormulaUnavailableError + nil + end + next if resolved && resolved.full_name == formula.full_name + + conflict_name = resolved&.full_name || conflict.name + reason = " (because #{conflict.reason})" if conflict.reason + "#{conflict_name}#{reason}" + end.sort! + unless conflicts.empty? + puts <<~EOS + Conflicts with: + #{conflicts.join("\n ")} + EOS + end + + heads, versioned = kegs.partition { |keg| keg.version.head? } + kegs = [ + *heads.sort_by { |keg| -keg.tab.time.to_i }, + *versioned.sort_by(&:scheme_and_version), + ] + if kegs.empty? + puts "Not installed" + if (bottle = formula.bottle) + begin + bottle.fetch_tab(quiet: !args.debug?) if args.fetch_manifest? || args.verbose? + bottle_size = bottle.bottle_size + installed_size = bottle.installed_size + puts "Bottle Size: #{Formatter.disk_usage_readable(bottle_size)}" if bottle_size + puts "Installed Size: #{Formatter.disk_usage_readable(installed_size)}" if installed_size + rescue RuntimeError => e + odebug e + end + end + else + puts self.class.installation_status(Tab.for_formula(formula)) + end + + puts "From: #{Formatter.url(github_info(formula))}" + formula_tap = formula.tap + puts "Tap: #{formula_tap.name}" if formula_tap && !formula_tap.official? + + puts "License: #{SPDX.license_expression_to_string formula.license}" if formula.license.present? + metadata = self.class.metadata_lines(formula) + puts metadata if metadata.present? + + installed_lines = installed_section_lines(shadowing_formula || formula, verbose: args.verbose?) + unless installed_lines.empty? + ohai(args.verbose? ? "Installed Kegs and Versions" : "Installed Versions") + installed_lines.each { |line| puts line } + end + + tab_runtime_deps = kegs.last&.runtime_dependencies + installed_dependents = if $stdout.tty? && kegs.any? + self.class.installed_dependent_names(formula.full_name, formula.name) + else + [].freeze + end + dependency_lines = %w[build required recommended optional].filter_map do |type| + next if type == "build" && + (kegs.all? { |keg| keg.tab.poured_from_bottle } || + (kegs.empty? && + (formula.requirements.any? { |requirement| self.class.requirement_for_other_os?(requirement) } || + (stable.present? ? stable.bottled? && formula.pour_bottle? : formula.head.blank?)))) + + deps = formula.deps.send(type).uniq + next if deps.empty? + + tab_deps = (kegs.any? && type != "build") ? tab_runtime_deps : nil + "#{type.capitalize} (#{deps.count}): " \ + "#{decorate_dependencies(deps, tab_runtime_deps: tab_deps, mark_uninstalled: kegs.any?, + missing_library_deps:)}" + end + if dependency_lines.present? || tab_runtime_deps.present? || installed_dependents.any? + ohai "Dependencies" + puts dependency_lines + missing_library_names = missing_libraries.map { |lib| File.basename(lib) }.uniq + if missing_library_names.present? + decorated = missing_library_names.map { |lib| pretty_uninstalled(lib, bold: false) }.join(", ") + puts "Missing libraries (#{missing_library_names.count}): #{decorated}" + end + if tab_runtime_deps.present? + installed_count = tab_runtime_deps.count do |dep| + dep_name = dep["full_name"]&.then { Utils.name_from_full_name(it) } + next false unless dep_name + + rack = HOMEBREW_CELLAR/dep_name + rack.directory? && !rack.subdirs.empty? + end + puts "Recursive Runtime (#{tab_runtime_deps.count}): " \ + "#{self.class.dependency_status_counts(installed_count, tab_runtime_deps.count)}" + end + if installed_dependents.any? + if args.verbose? + puts "Dependents (#{installed_dependents.count}): #{installed_dependents.join(", ")}" + else + puts "Dependents: #{installed_dependents.count}" + end + end + end + + unless formula.requirements.to_a.empty? + ohai "Requirements" + %w[build required recommended optional].map do |type| + reqs = formula.requirements.select(&:"#{type}?") + next if reqs.to_a.empty? + + puts "#{type.capitalize}: #{decorate_requirements(reqs, mark_uninstalled: kegs.any?)}" + end + end + + if !formula.options.empty? || formula.head + ohai "Options" + Options.dump_for_formula formula + end + + if args.verbose? + binaries_keg = kegs.find(&:linked?) || kegs.last + binaries = if binaries_keg + binary_files = [binaries_keg/"bin", binaries_keg/"sbin"].select(&:directory?).flat_map do |dir| + dir.children.select { |child| child.file? && child.executable? } + end + binary_files.map { |path| path.basename.to_s } + elsif (path_exec_files = formula.bottle&.path_exec_files) + path_exec_files.map { |path| File.basename(path) } + end + if binaries.present? + binaries = binaries.sort.uniq + ohai "Binaries", Formatter.columns(binaries) + end + end + + caveats = Caveats.new(formula) + if (caveats_string = caveats.to_s.presence) + ohai "Caveats", caveats_string + end + + return unless formula.core_formula? + + Utils::Analytics.formula_output(formula, args:) + end + + sig { params(formula: Formula, verbose: T::Boolean).returns(T::Array[String]) } + def installed_section_lines(formula, verbose: false) + siblings = formula.versioned_formulae + parent = if (parent_name = formula.unversioned_formula_name) + begin + Formulary.factory(parent_name) + rescue FormulaUnavailableError + nil + end + end + related = [formula, parent, *siblings].compact.uniq(&:full_name) + installed = related.select { |f| f.installed_kegs.any? } + return [] if installed.empty? + + ordered = installed.sort_by do |other| + newest_keg = other.installed_kegs.max_by(&:scheme_and_version) + newest_keg ? newest_keg.scheme_and_version : other.pkg_version + end.reverse + with_kegs = ordered.flat_map do |other| + heads, versioned = other.installed_kegs.partition { |keg| keg.version.head? } + ordered_kegs = [ + *heads.sort_by { |keg| -keg.tab.time.to_i }, + *versioned.sort_by(&:scheme_and_version).reverse, + ] + ordered_kegs.each_with_index.map { |keg, index| [other, keg, index.zero?] } + end + with_kegs = with_kegs.select { |_other, keg, newest| newest || keg.linked? } unless verbose + rows = with_kegs.map do |other, keg, newest| + name_status = pretty_install_status(other.full_name, installed: true, outdated: other.outdated?) + version = keg.version.to_s + latest = other.pkg_version.to_s + version = "#{version} → #{latest}" if newest && other.outdated? && latest != version + linked_marker = keg.linked? ? "[Linked]" : "" + [name_status, version, "(#{keg.abv})", linked_marker, keg] + end + name_width = rows.map { |r| Tty.strip_ansi(r[0]).length }.max || 0 + version_width = rows.map { |r| r[1].length }.max || 0 + size_width = rows.map { |r| r[2].length }.max || 0 + rows.flat_map do |name_status, version, size, linked_marker, keg| + padded_name = name_status + (" " * (name_width - Tty.strip_ansi(name_status).length)) + padded_size = linked_marker.empty? ? size : size.ljust(size_width) + line = "#{padded_name} #{version.ljust(version_width)} #{padded_size}" \ + "#{" #{linked_marker}" unless linked_marker.empty?}" + next [line] unless verbose + + tab_string = keg.tab.to_s + tab_string.empty? ? [line] : [line, " #{tab_string}"] + end + end + + sig { + params(dependencies: T::Array[Dependency], + tab_runtime_deps: T.nilable(T::Array[T::Hash[String, T.untyped]]), + mark_uninstalled: T::Boolean, + missing_library_deps: T::Set[String]).returns(String) + } + def decorate_dependencies(dependencies, tab_runtime_deps: nil, mark_uninstalled: true, + missing_library_deps: Set.new) + dependencies.map do |dep| + display = dep_display_s(dep) + full_name = tab_runtime_deps&.find do |d| + name = d["full_name"] + name == dep.name || name&.then { Utils.name_from_full_name(it) } == dep.name + end&.fetch("full_name") || dep.name + rack = HOMEBREW_CELLAR/Utils.name_from_full_name(full_name) + installed = T.let(rack.directory? && !rack.subdirs.empty?, T::Boolean) + formula = begin + dep.to_formula + rescue FormulaUnavailableError, TapFormulaAmbiguityError + nil + end + installed ||= formula.any_version_installed? if !installed && formula + outdated = T.let(installed && formula&.outdated? == true, T::Boolean) + warning = missing_library_deps.include?(Utils.name_from_full_name(dep.name)) + pretty_install_status(display, warning:, installed:, outdated:, mark_uninstalled:, bold: true) + end.join(", ") + end + + sig { params(requirements: T::Array[Requirement], mark_uninstalled: T::Boolean).returns(String) } + def decorate_requirements(requirements, mark_uninstalled: true) + req_status = requirements.map do |req| + req_s = req.display_s + pretty_install_status(req_s, installed: req.satisfied?, mark_uninstalled:, bold: true) + end + req_status.join(", ") + end - def dep_display_s(dep) - return dep.name if dep.option_tags.empty? + sig { params(dep: Dependency).returns(String) } + def dep_display_s(dep) + return dep.name if dep.option_tags.empty? - "#{dep.name} #{dep.option_tags.map { |o| "--#{o}" }.join(" ")}" + "#{dep.name} #{dep.option_tags.map { |o| "--#{o}" }.join(" ")}" + end + + sig { params(cask: Cask::Cask).void } + def info_cask(cask) + require "cask/info" + + Cask::Info.info(cask, args:) + end + + sig { params(cask: Cask::Cask).void } + def info_cask_summary(cask) + installed_version = cask.installed_version + installed = installed_version.present? + tab = Cask::Tab.for_cask(cask) + + puts oh1_title(info_summary_title( + cask.full_name, + cask.desc.presence&.then do |desc| + "#{if cask.name.present? + "(#{cask.name.join(", ")}) " + end}#{desc}" + end, + installed:, + )) + if installed + puts "Cask from #{T.cast(cask.tap, T.nilable(Tap))&.name || + T.cast(tab.source["tap"], T.nilable(String)) || + cask.sourcefile_path&.to_s || + T.cast(tab.source["path"], T.nilable(String)) || + github_info(cask)}" + puts self.class.installation_summary(installed_version, tab) + else + puts "Cask from #{github_info(cask)}" + puts "Not installed" + end + end + + sig { params(title: String, items: T::Array[NameSize]).void } + def print_sizes_table(title, items) + return if items.blank? + + ohai title + + total_size = items.sum(&:size) + total_size_str = Formatter.disk_usage_readable(total_size) + + name_width = (items.map { |item| item.name.length } + [5]).max + size_width = (items.map do |item| + Formatter.disk_usage_readable(item.size).length + end + [total_size_str.length]).max + + items.each do |item| + puts format("%-#{name_width}s %#{size_width}s", item.name, + Formatter.disk_usage_readable(item.size)) + end + + puts format("%-#{name_width}s %#{size_width}s", "Total", total_size_str) + end + + sig { params(formulae: T::Array[Formula], casks: T::Array[Cask::Cask]).void } + def print_sizes(formulae: [], casks: []) + if formulae.blank? && + (args.formulae? || (!args.casks? && args.no_named?)) + formulae = Formula.installed + end + + if casks.blank? && + (args.casks? || (!args.formulae? && args.no_named?)) + casks = Cask::Caskroom.casks + end + + unless args.casks? + formula_sizes = formulae.map do |formula| + kegs = formula.installed_kegs + size = kegs.sum(&:disk_usage) + NameSize.new(name: formula.full_name, size:) + end + formula_sizes.sort_by! { |f| -f.size } + print_sizes_table("Formulae sizes:", formula_sizes) + end + + return if casks.blank? || args.formulae? + + cask_sizes = casks.filter_map do |cask| + installed_version = cask.installed_version + next unless installed_version.present? + + versioned_staged_path = cask.caskroom_path.join(installed_version) + next unless versioned_staged_path.exist? + + size = versioned_staged_path.children.sum(&:disk_usage) + NameSize.new(name: cask.full_name, size:) + end + cask_sizes.sort_by! { |c| -c.size } + print_sizes_table("Casks sizes:", cask_sizes) + end + end end end + +require "extend/os/cmd/info" diff --git a/Library/Homebrew/cmd/install.rb b/Library/Homebrew/cmd/install.rb index 82f21b1751947..89b7df85907ca 100644 --- a/Library/Homebrew/cmd/install.rb +++ b/Library/Homebrew/cmd/install.rb @@ -1,339 +1,533 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" +require "cask/config" +require "cask/installer" +require "cask/upgrade" + +require "cask_dependent" require "missing_formula" require "formula_installer" require "development_tools" require "install" -require "search" require "cleanup" -require "cli/parser" +require "upgrade" +require "trust" module Homebrew - module_function - - extend Search - - def install_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `install` [] - - Install . Additional options specific to may be appended to the command. - - Unless `HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for the - installed formulae or, every 30 days, for all formulae. - EOS - switch :debug, - description: "If brewing fails, open an interactive debugging session with access to IRB "\ - "or a shell inside the temporary build directory." - flag "--env=", - description: "If `std` is passed, use the standard build environment instead of superenv. "\ - "If `super` is passed, use superenv even if the formula specifies the "\ - "standard build environment." - switch "--ignore-dependencies", - description: "An unsupported Homebrew development flag to skip installing any dependencies of "\ - "any kind. If the dependencies are not already present, the formula will have issues. "\ - "If you're not developing Homebrew, consider adjusting your PATH rather than "\ - "using this flag." - switch "--only-dependencies", - description: "Install the dependencies with specified options but do not install the "\ - "formula itself." - flag "--cc=", - description: "Attempt to compile using the specified , which should be the "\ - "name of the compiler's executable, e.g. `gcc-7` for GCC 7. "\ - "In order to use LLVM's clang, specify `llvm_clang`. To use the "\ - "Apple-provided clang, specify `clang`. This option will only accept "\ - "compilers that are provided by Homebrew or bundled with macOS. "\ - "Please do not file issues if you encounter errors while using this option." - switch "-s", "--build-from-source", - description: "Compile from source even if a bottle is provided. "\ - "Dependencies will still be installed from bottles if they are available." - switch "--force-bottle", - description: "Install from a bottle if it exists for the current or newest version of "\ - "macOS, even if it would not normally be used for installation." - switch "--include-test", - description: "Install testing dependencies required to run `brew test` ." - switch "--devel", - description: "If defines it, install the development version." - switch "--HEAD", - description: "If defines it, install the HEAD version, aka. master, trunk, unstable." - switch "--fetch-HEAD", - description: "Fetch the upstream repository to detect if the HEAD installation of the "\ - "formula is outdated. Otherwise, the repository's HEAD will only be checked for "\ - "updates when a new stable or development version has been released." - switch "--keep-tmp", - description: "Retain the temporary files created during installation." - switch "--build-bottle", - description: "Prepare the formula for eventual bottling during installation, skipping any "\ - "post-install steps." - flag "--bottle-arch=", - depends_on: "--build-bottle", - description: "Optimise bottles for the specified architecture rather than the oldest "\ - "architecture supported by the version of macOS the bottles are built on." - switch :force, - description: "Install without checking for previously installed keg-only or "\ - "non-migrated versions." - switch :verbose, - description: "Print the verification and postinstall steps." - switch "--display-times", - env: :display_install_times, - description: "Print install times for each formula at the end of the run." - switch "-i", "--interactive", - description: "Download and patch , then open a shell. This allows the user to "\ - "run `./configure --help` and otherwise determine how to turn the software "\ - "package into a Homebrew package." - switch "-g", "--git", - description: "Create a Git repository, useful for creating patches to the software." - conflicts "--ignore-dependencies", "--only-dependencies" - conflicts "--devel", "--HEAD" - conflicts "--build-from-source", "--build-bottle", "--force-bottle" - formula_options - end - end + module Cmd + class InstallCmd < AbstractCommand + cmd_args do + description <<~EOS + Install a or . Additional options specific to a may be + appended to the command. - def install - install_args.parse + Unless `$HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK` is set, `brew upgrade` or `brew reinstall` will be run for + outdated dependents and dependents with broken linkage, respectively. - Homebrew.args.named.each do |name| - next if File.exist?(name) - next if name !~ HOMEBREW_TAP_FORMULA_REGEX && name !~ HOMEBREW_CASK_TAP_CASK_REGEX + Unless `$HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for + the installed formulae or, every 30 days, for all formulae. - tap = Tap.fetch(Regexp.last_match(1), Regexp.last_match(2)) - tap.install unless tap.installed? - end + Unless `$HOMEBREW_NO_INSTALL_UPGRADE` is set, `brew install` will upgrade if it + is already installed but outdated. + EOS + switch "-d", "--debug", + description: "If brewing fails, open an interactive debugging session with access to IRB " \ + "or a shell inside the temporary build directory." + switch "--display-times", + description: "Print install times for each package at the end of the run.", + env: :display_install_times + switch "-f", "--force", + description: "Install formulae without checking for previously installed keg-only or " \ + "non-migrated versions. When installing casks, overwrite existing files " \ + "(binaries and symlinks are excluded, unless originally from the same cask)." + switch "-v", "--verbose", + description: "Print the verification and post-install steps." + switch "-n", "--dry-run", + description: "Show what would be installed, but do not actually install anything." + switch "--no-ask", "--yes", "-y", + description: "Do not ask for confirmation before downloading and installing. Ask mode is the default.", + env: :no_ask + switch "--ask", + description: "Ask for confirmation before downloading and installing. " \ + "Print the same plan as `--dry-run` before prompting. Only prompts if the plan " \ + "includes dependencies or dependants; if the requested formulae or casks are the " \ + "only things to install, it only prints the plan. The confirmation prompt is " \ + "skipped without a TTY. This is the default unless `$HOMEBREW_NO_ASK` is set.", + env: :ask, + replacement: "the default behaviour", + odeprecated: true + [ + [:switch, "--formula", "--formulae", { + description: "Treat all named arguments as formulae.", + }], + [:flag, "--env=", { + description: "Disabled other than for internal Homebrew use.", + hidden: true, + }], + [:switch, "--ignore-dependencies", { + description: "An unsupported Homebrew development option to skip installing any dependencies of any " \ + "kind. If the dependencies are not already present, the formula will have issues. If " \ + "you're not developing Homebrew, consider adjusting your PATH rather than using this " \ + "option.", + }], + [:switch, "--only-dependencies", { + description: "Install the dependencies with specified options but do not install the " \ + "formula itself.", + }], + [:flag, "--cc=", { + description: "Attempt to compile using the specified , which should be the name of the " \ + "compiler's executable, e.g. `gcc-9` for GCC 9. In order to use LLVM's clang, specify " \ + "`llvm_clang`. To use the Apple-provided clang, specify `clang`. This option will only " \ + "accept compilers that are provided by Homebrew or bundled with macOS. Please do not " \ + "file issues if you encounter errors while using this option.", + }], + [:switch, "-s", "--build-from-source", { + description: "Compile from source even if a bottle is provided. " \ + "Dependencies will still be installed from bottles if they are available.", + }], + [:switch, "--force-bottle", { + description: "Install from a bottle if it exists for the current or newest version of " \ + "macOS, even if it would not normally be used for installation.", + }], + [:switch, "--include-test", { + description: "Install testing dependencies required to run `brew test` .", + }], + [:switch, "--HEAD", { + description: "If defines it, install the HEAD version, aka. main, trunk, unstable, master.", + }], + [:switch, "--fetch-HEAD", { + description: "Fetch the upstream repository to detect if the HEAD installation of the " \ + "formula is outdated. Otherwise, the repository's HEAD will only be checked for " \ + "updates when a new stable or development version has been released.", + }], + [:switch, "--keep-tmp", { + description: "Retain the temporary files created during installation.", + }], + [:switch, "--debug-symbols", { + depends_on: "--build-from-source", + description: "Generate debug symbols on build. Source will be retained in a cache directory.", + }], + [:switch, "--build-bottle", { + description: "Prepare the formula for eventual bottling during installation, skipping any " \ + "post-install steps.", + }], + [:switch, "--skip-post-install", { + description: "Install but skip any post-install steps.", + }], + [:switch, "--skip-link", { + description: "Install but skip linking the keg into the prefix.", + }], + [:switch, "--as-dependency", { + description: "Install but mark as installed as a dependency and not installed on request.", + }], + [:flag, "--bottle-arch=", { + depends_on: "--build-bottle", + description: "Optimise bottles for the specified architecture rather than the oldest " \ + "architecture supported by the version of macOS the bottles are built on.", + }], + [:switch, "-i", "--interactive", { + description: "Download and patch , then open a shell. This allows the user to " \ + "run `./configure --help` and otherwise determine how to turn the software " \ + "package into a Homebrew package.", + }], + [:switch, "-g", "--git", { + description: "Create a Git repository, useful for creating patches to the software.", + }], + [:switch, "--overwrite", { + description: "Delete files that already exist in the prefix while linking.", + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--cask", args.last + end + formula_options + [ + [:switch, "--cask", "--casks", { + description: "Treat all named arguments as casks.", + }], + [:switch, "--[no-]binaries", { + description: "Disable/enable linking of helper executables (default: enabled).", + env: :cask_opts_binaries, + }], + [:switch, "--require-sha", { + description: "Require all casks to have a checksum.", + env: :cask_opts_require_sha, + }], + [:switch, "--adopt", { + description: "Adopt existing artifacts in the destination that are identical to those being installed. " \ + "Cannot be combined with `--force`.", + }], + [:switch, "--skip-cask-deps", { + description: "Skip installing cask dependencies.", + }], + [:switch, "--zap", { + description: "For use with `brew reinstall --cask`. Remove all files associated with a cask. " \ + "*May remove files which are shared between applications.*", + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--formula", args.last + end + cask_options - raise FormulaUnspecifiedError if args.remaining.empty? + conflicts "--ignore-dependencies", "--only-dependencies" + conflicts "--ask", "--no-ask" + conflicts "--build-from-source", "--build-bottle", "--force-bottle" + conflicts "--adopt", "--force" - if args.ignore_dependencies? - opoo <<~EOS - #{Tty.bold}--ignore-dependencies is an unsupported Homebrew developer flag!#{Tty.reset} - Adjust your PATH to put any preferred versions of applications earlier in the - PATH rather than using this unsupported flag! + named_args [:formula, :cask], min: 1 + end - EOS - end + sig { override.void } + def run + if args.env.present? + # Can't use `replacement: false` because `install_args` are used by + # `build.rb`. Instead, `hide_from_man_page` and don't do anything with + # this argument here. + # This odisabled should stick around indefinitely. + odisabled "`brew install --env`", "`env :std` in specific formula files" + end - formulae = [] + args.named.each do |name| + if (tap_with_name = Tap.with_formula_name(name)) + tap, = tap_with_name + elsif (tap_with_token = Tap.with_cask_token(name)) + tap, = tap_with_token + end - unless ARGV.casks.empty? - cask_args = [] - cask_args << "--force" if args.force? - cask_args << "--debug" if args.debug? - cask_args << "--verbose" if args.verbose? + tap&.ensure_installed! + end + Homebrew::Trust.trust_fully_qualified_items!(args.named, type: args.only_formula_or_cask) - ARGV.casks.each do |c| - ohai "brew cask install #{c} #{cask_args.join " "}" - system("#{HOMEBREW_PREFIX}/bin/brew", "cask", "install", c, *cask_args) - end - end + if args.ignore_dependencies? + opoo <<~EOS + #{Tty.bold}`--ignore-dependencies` is an unsupported Homebrew developer option!#{Tty.reset} + Adjust your PATH to put any preferred versions of applications earlier in the + PATH rather than using this unsupported option! - # if the user's flags will prevent bottle only-installations when no - # developer tools are available, we need to stop them early on - FormulaInstaller.prevent_build_flags unless DevelopmentTools.installed? + EOS + end - Homebrew.args.formulae.each do |f| - # head-only without --HEAD is an error - if !Homebrew.args.HEAD? && f.stable.nil? && f.devel.nil? - raise <<~EOS - #{f.full_name} is a head-only formula - Install with `brew install --HEAD #{f.full_name}` - EOS - end + formulae, casks = T.cast( + args.named.to_formulae_and_casks(warn: false).partition { it.is_a?(Formula) }, + [T::Array[Formula], T::Array[Cask::Cask]], + ) + ask = !args.no_ask? + + installed_casks = T.let([], T::Array[Cask::Cask]) + new_casks = T.let([], T::Array[Cask::Cask]) + upgrade_casks = T.let([], T::Array[Cask::Cask]) + fetch_casks = T.let([], T::Array[Cask::Cask]) + if casks.any? + if args.dry_run? + Install.print_dry_run_casks(casks, skip_cask_deps: args.skip_cask_deps?, include_installed: false) + return + end + + require "cask/installer" + + installed_casks, new_casks = casks.partition(&:installed?) + + fetch_casks = if Homebrew::EnvConfig.no_install_upgrade? + new_casks + else + upgrade_casks = Cask::Upgrade.outdated_casks(casks, args:, force: true, quiet: true) + new_casks | upgrade_casks + end + Install.ask_casks fetch_casks, skip_cask_deps: args.skip_cask_deps? if ask + end - # devel-only without --devel is an error - if !args.devel? && f.stable.nil? && f.head.nil? - raise <<~EOS - #{f.full_name} is a devel-only formula - Install with `brew install --devel #{f.full_name}` - EOS - end + if Homebrew::EnvConfig.verify_attestations? + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) + end - if !(args.HEAD? || args.devel?) && f.stable.nil? - raise "#{f.full_name} has no stable download, please choose --devel or --HEAD" - end + # if the user's flags will prevent bottle only-installations when no + # developer tools are available, we need to stop them early on + build_flags = [] + unless DevelopmentTools.installed? + build_flags << "--HEAD" if args.HEAD? + build_flags << "--build-bottle" if args.build_bottle? + build_flags << "--build-from-source" if args.build_from_source? - # --HEAD, fail with no head defined - raise "No head is defined for #{f.full_name}" if args.head? && f.head.nil? + raise BuildFlagsError.new(build_flags, bottled: formulae.all?(&:bottled?)) if build_flags.present? + end - # --devel, fail with no devel defined - raise "No devel block is defined for #{f.full_name}" if args.devel? && f.devel.nil? + if build_flags.present? && !Homebrew::EnvConfig.developer? + opoo "building from source is not supported!" + puts "You're on your own. Failures are expected so don't create any issues, please!" + end - installed_head_version = f.latest_head_version - if installed_head_version && - !f.head_version_outdated?(installed_head_version, fetch_head: args.fetch_HEAD?) - new_head_installed = true - end - prefix_installed = f.prefix.exist? && !f.prefix.children.empty? - - if f.keg_only? && f.any_version_installed? && f.optlinked? && !args.force? - # keg-only install is only possible when no other version is - # linked to opt, because installing without any warnings can break - # dependencies. Therefore before performing other checks we need to be - # sure --force flag is passed. - if f.outdated? - optlinked_version = Keg.for(f.opt_prefix).version - onoe <<~EOS - #{f.full_name} #{optlinked_version} is already installed - To upgrade to #{f.version}, run `brew upgrade #{f.name}` - EOS - elsif args.only_dependencies? - formulae << f - else - opoo <<~EOS - #{f.full_name} #{f.pkg_version} is already installed and up-to-date - To reinstall #{f.pkg_version}, run `brew reinstall #{f.name}` - EOS + installed_formulae = formulae.select do |f| + Install.install_formula?( + f, + head: args.HEAD?, + fetch_head: args.fetch_HEAD?, + only_dependencies: args.only_dependencies?, + force: args.force?, + quiet: args.quiet?, + skip_link: args.skip_link?, + overwrite: args.overwrite?, + ) + end + + return if formulae.any? && installed_formulae.empty? && casks.empty? + + Install.perform_preinstall_checks_once + Install.check_cc_argv(args.cc) + + formulae_installer = Install.formula_installers( + installed_formulae, + installed_on_request: !args.as_dependency?, + build_bottle: args.build_bottle?, + force_bottle: args.force_bottle?, + bottle_arch: args.bottle_arch, + ignore_deps: args.ignore_dependencies?, + only_deps: args.only_dependencies?, + include_test_formulae: args.include_test_formulae, + build_from_source_formulae: args.build_from_source_formulae, + cc: args.cc, + git: args.git?, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + overwrite: args.overwrite?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + dry_run: args.dry_run?, + skip_post_install: args.skip_post_install?, + skip_link: args.skip_link?, + ) + + shared_download_queue = T.let(nil, T.nilable(Homebrew::DownloadQueue)) + if !ask && !args.dry_run? && formulae_installer.any? + shared_download_queue = Homebrew::DownloadQueue.new(pour: true) + formulae_installer = Install.prelude_fetch_formulae(formulae_installer, + download_queue: shared_download_queue) end - elsif (args.HEAD? && new_head_installed) || prefix_installed - # After we're sure that --force flag is passed for linked to opt - # keg-only we need to be sure that the version we're attempting to - # install is not already installed. - - installed_version = if args.HEAD? - f.latest_head_version - else - f.pkg_version + + dependants = begin + Upgrade.dependants( + installed_formulae, + flags: args.flags_only, + ask: ask, + installed_on_request: !args.as_dependency?, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + dry_run: args.dry_run?, + ) + # Ensure the early download queue is shut down on interrupts. + rescue Exception # rubocop:disable Lint/RescueException + shared_download_queue&.shutdown + raise end - msg = "#{f.full_name} #{installed_version} is already installed" - linked_not_equals_installed = f.linked_version != installed_version - if f.linked? && linked_not_equals_installed - msg = <<~EOS - #{msg} - The currently linked version is #{f.linked_version} - You can use `brew switch #{f} #{installed_version}` to link this version. - EOS - elsif !f.linked? || f.keg_only? - msg = <<~EOS - #{msg}, it's just not linked - You can use `brew link #{f}` to link this version. - EOS - elsif args.only_dependencies? - msg = nil - formulae << f - else - msg = <<~EOS - #{msg} and up-to-date - To reinstall #{f.pkg_version}, run `brew reinstall #{f.name}` - EOS + # Main block: if asking the user is enabled, show dry-run information. + if ask + Install.ask_formulae( + formulae_installer, + dependants, + flags: args.flags_only, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) end - opoo msg if msg - elsif !f.any_version_installed? && old_formula = f.old_installed_formulae.first - msg = "#{old_formula.full_name} #{old_formula.installed_version} already installed" - if !old_formula.linked? && !old_formula.keg_only? - msg = <<~EOS - #{msg}, it's just not linked. - You can use `brew link #{old_formula.full_name}` to link this version. - EOS + + if !args.dry_run? && (formulae_installer.any? || fetch_casks.any?) + download_queue = T.let(shared_download_queue || Homebrew::DownloadQueue.new(pour: true), + Homebrew::DownloadQueue) + shared_download_queue = nil + begin + Cask::Upgrade.show_upgrade_summary( + upgrade_casks.map { |cask| "#{cask.full_name} #{cask.installed_version} -> #{cask.version}" }, + ) + Install.show_combined_fetch_downloads_heading( + formula_names: formulae_installer.map { |fi| fi.formula.name }, + cask_names: fetch_casks.map(&:full_name), + ) + + formulae_installer = Install.enqueue_formulae(formulae_installer, download_queue:) + + if fetch_casks.any? + fetch_cask_installers = fetch_casks.map do |cask| + Cask::Installer.new( + cask, + reinstall: true, + binaries: args.binaries?, + verbose: args.verbose?, + force: args.force?, + skip_cask_deps: args.skip_cask_deps?, + require_sha: args.require_sha?, + zap: args.zap?, + download_queue:, + defer_fetch: true, + ) + end + + Install.enqueue_cask_installers(fetch_cask_installers, download_queue:) + end + + download_queue.fetch + ensure + download_queue.shutdown + end + end + shared_download_queue&.shutdown + + exit 1 if Homebrew.failed? + + Install.install_formulae(formulae_installer, + dry_run: args.dry_run?, + verbose: args.verbose?) + + Upgrade.upgrade_dependents( + dependants, installed_formulae, + flags: args.flags_only, + dry_run: args.dry_run?, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose? + ) + + if casks.any? + begin + new_casks.each do |cask| + Cask::Installer.new( + cask, + adopt: args.adopt?, + binaries: args.binaries?, + defer_fetch: fetch_casks.include?(cask), + force: args.force?, + quiet: args.quiet?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + verbose: args.verbose?, + ).install + end + rescue => e + ofail e + end + + if !Homebrew::EnvConfig.no_install_upgrade? && installed_casks.any? + begin + Cask::Upgrade.upgrade_casks!( + *installed_casks, + force: args.force?, + dry_run: args.dry_run?, + binaries: args.binaries?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + verbose: args.verbose?, + quiet: args.quiet?, + skip_prefetch: true, + show_upgrade_summary: false, + args:, + ) + rescue => e + ofail e + end + end end - opoo msg - elsif f.migration_needed? && !args.force? - # Check if the formula we try to install is the same as installed - # but not migrated one. If --force is passed then install anyway. - opoo <<~EOS - #{f.oldname} is already installed, it's just not migrated - You can migrate this formula with `brew migrate #{f}` - Or you can force install it with `brew install #{f} --force` - EOS - else - # If none of the above is true and the formula is linked, then - # FormulaInstaller will handle this case. - formulae << f - end - # Even if we don't install this formula mark it as no longer just - # installed as a dependency. - next unless f.opt_prefix.directory? + Cleanup.periodic_clean!(dry_run: args.dry_run?) + + Homebrew.messages.display_messages(display_times: args.display_times?) + rescue FormulaUnreadableError, FormulaClassUnavailableError, + TapFormulaUnreadableError, TapFormulaClassUnavailableError => e + require "utils/backtrace" + + # Need to rescue before `FormulaUnavailableError` (superclass of this) + # is handled, as searching for a formula doesn't make sense here (the + # formula was found, but there's a problem with its implementation). + $stderr.puts Utils::Backtrace.clean(e) if Homebrew::EnvConfig.developer? + ofail e.message + rescue FormulaOrCaskUnavailableError, Cask::CaskUnavailableError => e + Homebrew.failed = true + + # formula name or cask token + name = case e + when FormulaOrCaskUnavailableError then e.name + when Cask::CaskUnavailableError then e.token + else T.absurd(e) + end - keg = Keg.new(f.opt_prefix.resolved_path) - tab = Tab.for_keg(keg) - unless tab.installed_on_request - tab.installed_on_request = true - tab.write - end - end + if name == "updog" + ofail "What's updog?" + return + end + + opoo e - return if formulae.empty? + reason = MissingFormula.reason(name, silent: true) + if !args.cask? && reason + $stderr.puts reason + return + end - Install.perform_preinstall_checks + # We don't seem to get good search results when the tap is specified + # so we might as well return early. + return if name.include?("/") - formulae.each do |f| - Migrator.migrate_if_needed(f) - install_formula(f) - Cleanup.install_formula_clean!(f) - end - Homebrew.messages.display_messages - rescue FormulaUnreadableError, FormulaClassUnavailableError, - TapFormulaUnreadableError, TapFormulaClassUnavailableError => e - # Need to rescue before `FormulaUnavailableError` (superclass of this) - # is handled, as searching for a formula doesn't make sense here (the - # formula was found, but there's a problem with its implementation). - ofail e.message - rescue FormulaUnavailableError => e - if e.name == "updog" - ofail "What's updog?" - return - end + require "search" - ofail e.message - if (reason = MissingFormula.reason(e.name)) - $stderr.puts reason - return - end + package_types = [] + package_types << "formulae" unless args.cask? + package_types << "casks" unless args.formula? - ohai "Searching for similarly named formulae..." - formulae_search_results = search_formulae(e.name) - case formulae_search_results.length - when 0 - ofail "No similarly named formulae found." - when 1 - puts "This similarly named formula was found:" - puts formulae_search_results - puts "To install it, run:\n brew install #{formulae_search_results.first}" - else - puts "These similarly named formulae were found:" - puts Formatter.columns(formulae_search_results) - puts "To install one of them, run (for example):\n brew install #{formulae_search_results.first}" - end + ohai "Searching for similarly named #{package_types.join(" and ")}..." - # Do not search taps if the formula name is qualified - return if e.name.include?("/") - - ohai "Searching taps..." - taps_search_results = search_taps(e.name)[:formulae] - case taps_search_results.length - when 0 - ofail "No formulae found in taps." - when 1 - puts "This formula was found in a tap:" - puts taps_search_results - puts "To install it, run:\n brew install #{taps_search_results.first}" - else - puts "These formulae were found in taps:" - puts Formatter.columns(taps_search_results) - puts "To install one of them, run (for example):\n brew install #{taps_search_results.first}" - end - end + # Don't treat formula/cask name as a regex + string_or_regex = name + all_formulae, all_casks = Search.search_names(string_or_regex, args) + + if all_formulae.any? + ohai "Formulae", Formatter.columns(all_formulae) + first_formula = all_formulae.first.to_s + puts <<~EOS - def install_formula(f) - f.print_tap_action - build_options = f.build - - fi = FormulaInstaller.new(f) - fi.options = build_options.used_options - fi.ignore_deps = args.ignore_dependencies? - fi.only_deps = args.only_dependencies? - fi.build_bottle = args.build_bottle? - fi.interactive = args.interactive? - fi.git = args.git? - fi.prelude - fi.install - fi.finish - rescue FormulaInstallationAlreadyAttemptedError - # We already attempted to install f as part of the dependency tree of - # another formula. In that case, don't generate an error, just move on. - nil - rescue CannotInstallFormulaError => e - ofail e.message + To install #{first_formula}, run: + brew install #{first_formula} + EOS + end + puts if all_formulae.any? && all_casks.any? + if all_casks.any? + ohai "Casks", Formatter.columns(all_casks) + first_cask = all_casks.first.to_s + puts <<~EOS + + To install #{first_cask}, run: + brew install --cask #{first_cask} + EOS + end + return if all_formulae.any? || all_casks.any? + + odie "No #{package_types.join(" or ")} found for #{name}." + end + end end end diff --git a/Library/Homebrew/cmd/leaves.rb b/Library/Homebrew/cmd/leaves.rb index be4fcdf559131..59a546ef7d112 100644 --- a/Library/Homebrew/cmd/leaves.rb +++ b/Library/Homebrew/cmd/leaves.rb @@ -1,30 +1,69 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "tab" -require "cli/parser" +require "cask_dependent" module Homebrew - module_function + module Cmd + class Leaves < AbstractCommand + cmd_args do + description <<~EOS + List installed formulae that are not dependencies of another installed formula or cask. + EOS + switch "-r", "--installed-on-request", + description: "Only list leaves that were manually installed." + switch "-p", "--installed-as-dependency", + description: "Only list leaves that were installed as dependencies." - def leaves_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `leaves` + conflicts "--installed-on-request", "--installed-as-dependency" - List installed formulae that are not dependencies of another installed formula. - EOS - switch :debug - max_named 0 - end - end + named_args :none + end + + sig { override.void } + def run + installed = Formula.installed + + # Build a set of dependency names from tab data to avoid loading full Formula objects + # via Formulary.resolve for each dependency (which is expensive I/O). + formula_dep_names = installed.flat_map do |f| + if (tab_deps = f.any_installed_keg&.runtime_dependencies) + tab_deps.filter_map do |dep| + full_name = dep["full_name"] + next unless full_name + + Utils.name_from_full_name(full_name) + end + else + # Fallback for installations without tab runtime_dependencies. + f.installed_runtime_formula_dependencies.map(&:name) + end + end - def leaves - leaves_args.parse + # Add direct cask formula dependency names; their transitive deps are already in dep_names. + cask_dep_names = Cask::Caskroom.casks.flat_map do |cask| + CaskDependent.new(cask).deps.map { |dep| Utils.name_from_full_name(dep.name) } + end - installed = Formula.installed.sort - deps_of_installed = installed.flat_map(&:runtime_formula_dependencies) - leaves = installed.map(&:full_name) - deps_of_installed.map(&:full_name) - leaves.each(&method(:puts)) + dep_names = T.let((formula_dep_names + cask_dep_names).to_set, T::Set[String]) + + leaves_list = installed.reject { |f| dep_names.intersect?(f.possible_names) } + leaves_list.select! { |leaf| installed_on_request?(leaf) } if args.installed_on_request? + leaves_list.reject! { |leaf| installed_on_request?(leaf) } if args.installed_as_dependency? + + leaves_list.map(&:full_name) + .sort + .each { |leaf| puts(leaf) } + end + + private + + sig { params(formula: Formula).returns(T::Boolean) } + def installed_on_request?(formula) + formula.any_installed_keg&.tab&.installed_on_request == true + end + end end end diff --git a/Library/Homebrew/cmd/link.rb b/Library/Homebrew/cmd/link.rb index 28e29331f466b..fab8a1b3a7e17 100644 --- a/Library/Homebrew/cmd/link.rb +++ b/Library/Homebrew/cmd/link.rb @@ -1,117 +1,144 @@ +# typed: strict # frozen_string_literal: true -require "ostruct" +require "abstract_command" require "caveats" -require "cli/parser" +require "unlink" module Homebrew - module_function - - def link_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `link`, `ln` [] - - Symlink all of 's installed files into Homebrew's prefix. This - is done automatically when you install formulae but can be useful for DIY - installations. - EOS - switch "--overwrite", - description: "Delete files that already exist in the prefix while linking." - switch "-n", "--dry-run", - description: "List files which would be linked or deleted by "\ - "`brew link --overwrite` without actually linking or deleting any files." - switch :force, - description: "Allow keg-only formulae to be linked." - switch :verbose - switch :debug - end - end - - def link - link_args.parse - - raise KegUnspecifiedError if ARGV.named.empty? - - mode = OpenStruct.new + module Cmd + class Link < AbstractCommand + cmd_args do + description <<~EOS + Symlink all of 's installed files into Homebrew's prefix. + This is done automatically when you install formulae but can be useful + for manual installations. + EOS + switch "--overwrite", + description: "Delete files that already exist in the prefix while linking." + switch "-n", "--dry-run", + description: "List files which would be linked or deleted by " \ + "`brew link --overwrite` without actually linking or deleting any files." + switch "-f", "--force", + description: "Allow keg-only formulae to be linked." + switch "--HEAD", + description: "Link the HEAD version of the formula if it is installed." + + named_args :installed_formula, min: 1 + end - mode.overwrite = true if args.overwrite? - mode.dry_run = true if args.dry_run? + sig { override.void } + def run + options = { + overwrite: args.overwrite?, + dry_run: args.dry_run?, + verbose: args.verbose?, + } - Homebrew.args.kegs.each do |keg| - keg_only = Formulary.keg_only?(keg.rack) + kegs = if args.HEAD? + args.named.to_kegs.group_by(&:name).filter_map do |name, resolved_kegs| + head_keg = resolved_kegs.find { |keg| keg.version.head? } + next head_keg if head_keg.present? - if keg.linked? - opoo "Already linked: #{keg}" - name_and_flag = if keg_only - "--force #{keg.name}" - else - keg.name - end - puts <<~EOS - To relink: - brew unlink #{keg.name} && brew link #{name_and_flag} - EOS - next - end + opoo <<~EOS + No HEAD keg installed for #{name} + To install, run: + brew install --HEAD #{name} + EOS - if mode.dry_run - if mode.overwrite - puts "Would remove:" + nil + end else - puts "Would link:" + args.named.to_latest_kegs end - keg.link(mode) - puts_keg_only_path_message(keg) if keg_only - next - end - if keg_only - if Homebrew.default_prefix? - f = keg.to_formula - if f.keg_only_reason.reason == :provided_by_macos - caveats = Caveats.new(f) - opoo <<~EOS - Refusing to link macOS-provided software: #{keg.name} - #{caveats.keg_only_text(skip_reason: true).strip} + kegs.freeze.each do |keg| + keg_only = Formulary.keg_only?(keg.rack) + formula = begin + keg.to_formula + rescue FormulaUnavailableError + # Not all kegs may belong to current formulae + nil + end + versioned_keg_only_formula = formula.present? && formula.keg_only_reason&.versioned_formula? + + if keg.linked? + opoo "Already linked: #{keg}" + name_and_flag = +"" + name_and_flag << "--HEAD " if args.HEAD? + name_and_flag << "--force " if keg_only && !versioned_keg_only_formula + name_and_flag << keg.name + puts <<~EOS + To relink, run: + brew unlink #{keg.name} && brew link #{name_and_flag} EOS next end - end - unless args.force? - opoo "#{keg.name} is keg-only and must be linked with --force" - puts_keg_only_path_message(keg) - next + if args.dry_run? + if args.overwrite? + puts "Would remove:" + else + puts "Would link:" + end + keg.link(**options) + puts_keg_only_path_message(keg) if keg_only && !versioned_keg_only_formula + next + end + + if keg_only + if HOMEBREW_PREFIX.to_s == HOMEBREW_DEFAULT_PREFIX && formula.present? && + formula.keg_only_reason.by_macos? + caveats = Caveats.new(formula) + opoo <<~EOS + Refusing to link macOS provided/shadowed software: #{keg.name} + #{T.must(caveats.keg_only_text(skip_reason: true)).strip} + EOS + next + end + + if !args.force? && (formula.nil? || !formula.keg_only_reason.versioned_formula?) + opoo "#{keg.name} is keg-only and must be linked with `--force`." + puts_keg_only_path_message(keg) + next + end + end + + Unlink.unlink_link_overwrite_formulae(formula, verbose: args.verbose?) if formula + + keg.lock do + print "Linking #{keg}... " + puts if args.verbose? + + begin + n = keg.link(**options) + rescue Keg::LinkError + puts + raise + else + puts "#{n} symlinks created." + end + + if keg_only && !versioned_keg_only_formula && !Homebrew::EnvConfig.developer? + puts_keg_only_path_message(keg) + end + end end end - keg.lock do - print "Linking #{keg}... " - puts if args.verbose? + private - begin - n = keg.link(mode) - rescue Keg::LinkError - puts - raise - else - puts "#{n} symlinks created" - end + sig { params(keg: Keg).void } + def puts_keg_only_path_message(keg) + bin = keg/"bin" + sbin = keg/"sbin" + return if !bin.directory? && !sbin.directory? - puts_keg_only_path_message(keg) if keg_only && !ARGV.homebrew_developer? + opt = HOMEBREW_PREFIX/"opt/#{keg.name}" + puts "\nIf you need to have this software first in your PATH instead consider running:" + puts " #{Utils::Shell.prepend_path_in_profile(opt/"bin")}" if bin.directory? + puts " #{Utils::Shell.prepend_path_in_profile(opt/"sbin")}" if sbin.directory? end end end - - def puts_keg_only_path_message(keg) - bin = keg/"bin" - sbin = keg/"sbin" - return if !bin.directory? && !sbin.directory? - - opt = HOMEBREW_PREFIX/"opt/#{keg.name}" - puts "\nIf you need to have this software first in your PATH instead consider running:" - puts " #{Utils::Shell.prepend_path_in_profile(opt/"bin")}" if bin.directory? - puts " #{Utils::Shell.prepend_path_in_profile(opt/"sbin")}" if sbin.directory? - end end diff --git a/Library/Homebrew/cmd/list.rb b/Library/Homebrew/cmd/list.rb index 252cd7f75195b..d954700b0c7ca 100644 --- a/Library/Homebrew/cmd/list.rb +++ b/Library/Homebrew/cmd/list.rb @@ -1,214 +1,385 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "metafiles" require "formula" require "cli/parser" +require "cask/list" +require "system_command" +require "tab" module Homebrew - module_function - - def list_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `list`, `ls` [] [] - - List all installed formulae. - - If is provided, summarise the paths within its current keg. - EOS - switch "--full-name", - description: "Print formulae with fully-qualified names. If `--full-name` is not "\ - "passed, other options (i.e. `-1`, `-l`, `-r` and `-t`) are passed to `ls`(1) "\ - "which produces the actual output." - switch "--unbrewed", - description: "List files in Homebrew's prefix not installed by Homebrew." - switch "--versions", - description: "Show the version number for installed formulae, or only the specified "\ - "formulae if are provided." - switch "--multiple", - depends_on: "--versions", - description: "Only show formulae with multiple versions installed." - switch "--pinned", - description: "Show the versions of pinned formulae, or only the specified (pinned) "\ - "formulae if are provided. See also `pin`, `unpin`." - # passed through to ls - switch "-1", - description: "Force output to be one entry per line. " \ - "This is the default when output is not to a terminal." - switch "-l", - description: "List in long format. If the output is to a terminal, "\ - "a total sum for all the file sizes is printed before the long listing." - switch "-r", - description: "Reverse the order of the sort to list the oldest entries first." - switch "-t", - description: "Sort by time modified, listing most recently modified first." - switch :verbose - switch :debug - end - end - - def list - list_args.parse + module Cmd + class List < AbstractCommand + include SystemCommand::Mixin - return list_unbrewed if args.unbrewed? + cmd_args do + description <<~EOS + List all installed formulae and casks. + If is provided, summarise the paths within its current keg. + If is provided, list its artifacts. + EOS + switch "--formula", "--formulae", + description: "List only formulae, or treat all named arguments as formulae." + switch "--cask", "--casks", + description: "List only casks, or treat all named arguments as casks." + switch "--full-name", + description: "Print formulae with fully-qualified names. Unless `--full-name`, `--versions` " \ + "or `--pinned` are passed, other options (i.e. `-1`, `-l`, `-r` and `-t`) are " \ + "passed to `ls`(1) which produces the actual output." + switch "--versions", + description: "Show the version number for installed formulae, or only the specified " \ + "formulae if are provided." + switch "--json", + description: "Output installed formulae and casks with versions, linked and opt-linked formula " \ + "versions and pinned versions as JSON using the fast Bash command path. Requires " \ + "`--versions`, no named arguments and `jq`." + switch "--multiple", + description: "Only show formulae with multiple versions installed. Implies `--versions`." + switch "--pinned", + description: "List only pinned packages, or only the specified (pinned) packages if or " \ + " are provided. See also `pin`, `unpin`." + switch "--installed-on-request", + description: "List the formulae installed on request." + switch "--no-installed-on-request", + description: "List the formulae not installed on request (i.e. installed as dependencies)." + switch "--installed-as-dependency", + description: "List the formulae installed as dependencies.", + odeprecated: true, + replacement: "--no-installed-on-request" + switch "--poured-from-bottle", + description: "List the formulae installed from a bottle." + switch "--built-from-source", + description: "List the formulae compiled from source." - # Unbrewed uses the PREFIX, which will exist - # Things below use the CELLAR, which doesn't until the first formula is installed. - unless HOMEBREW_CELLAR.exist? - raise NoSuchKegError, Hombrew.args.named.first if Homebrew.args.named.present? + # passed through to ls + switch "-1", + description: "Force output to be one entry per line. " \ + "This is the default when output is not to a terminal." + switch "-l", + description: "List formulae and/or casks in long format. " \ + "Has no effect when a formula or cask name is passed as an argument." + switch "-r", + description: "Reverse the order of formula and/or cask sorting to list the oldest entries first. " \ + "Has no effect when a formula or cask name is passed as an argument." + switch "-t", + description: "Sort formulae and/or casks by time modified, listing most recently modified first. " \ + "Has no effect when a formula or cask name is passed as an argument." - return - end + conflicts "--formula", "--cask" + conflicts "--multiple", "--cask" + conflicts "--pinned", "--multiple" + ["--installed-on-request", "--no-installed-on-request", "--installed-as-dependency", + "--poured-from-bottle", "--built-from-source"].each do |flag| + conflicts "--cask", flag + conflicts "--versions", flag + conflicts "--multiple", flag + conflicts "--pinned", flag + conflicts "-l", flag + end + ["-1", "-l", "-r", "-t"].each do |flag| + conflicts "--versions", flag + conflicts "--multiple", flag + conflicts "--pinned", flag + end + ["--versions", "--multiple", "--pinned", "-l", "-r", "-t"].each do |flag| + conflicts "--full-name", flag + end - if args.pinned? || args.versions? - filtered_list - elsif Homebrew.args.named.blank? - if args.full_name? - full_names = Formula.installed.map(&:full_name).sort(&tap_and_name_comparison) - return if full_names.empty? - - puts Formatter.columns(full_names) - else - ENV["CLICOLOR"] = nil - safe_system "ls", *Homebrew.args.passthrough << HOMEBREW_CELLAR + named_args [:installed_formula, :installed_cask] end - elsif args.verbose? || !$stdout.tty? - system_command! "find", args: Homebrew.args.kegs.map(&:to_s) + %w[-not -type d -print], print_stdout: true - else - Homebrew.args.kegs.each { |keg| PrettyListing.new keg } - end - end - UNBREWED_EXCLUDE_FILES = %w[.DS_Store].freeze - UNBREWED_EXCLUDE_PATHS = %w[ - .github/* - bin/brew - completions/zsh/_brew - docs/* - lib/gdk-pixbuf-2.0/* - lib/gio/* - lib/node_modules/* - lib/python[23].[0-9]/* - lib/pypy/* - lib/pypy3/* - lib/ruby/gems/[12].* - lib/ruby/site_ruby/[12].* - lib/ruby/vendor_ruby/[12].* - manpages/brew.1 - manpages/brew-cask.1 - share/pypy/* - share/pypy3/* - share/info/dir - share/man/whatis - ].freeze - - def list_unbrewed - dirs = HOMEBREW_PREFIX.subdirs.map { |dir| dir.basename.to_s } - dirs -= %w[Library Cellar .git] - - # Exclude cache, logs, and repository, if they are located under the prefix. - [HOMEBREW_CACHE, HOMEBREW_LOGS, HOMEBREW_REPOSITORY].each do |dir| - dirs.delete dir.relative_path_from(HOMEBREW_PREFIX).to_s - end - dirs.delete "etc" - dirs.delete "var" + sig { override.void } + def run + if args.json? + raise UsageError, "`brew list --json` requires `--versions`." unless args.versions? + raise UsageError, "`brew list --versions --json` does not support named arguments." unless args.no_named? - arguments = dirs.sort + %w[-type f (] - arguments.concat UNBREWED_EXCLUDE_FILES.flat_map { |f| %W[! -name #{f}] } - arguments.concat UNBREWED_EXCLUDE_PATHS.flat_map { |d| %W[! -path #{d}] } - arguments.concat %w[)] + raise UsageError, "`brew list --versions --json` is only supported by the fast Bash path with `jq`." + end - cd HOMEBREW_PREFIX - safe_system "find", *arguments - end + installed_as_dependency = args.no_installed_on_request? || args.installed_as_dependency? - def filtered_list - names = if Homebrew.args.named.blank? - Formula.racks - else - racks = Homebrew.args.named.map { |n| Formulary.to_rack(n) } - racks.select do |rack| - Homebrew.failed = true unless rack.exist? - rack.exist? - end - end - if args.pinned? - pinned_versions = {} - names.sort.each do |d| - keg_pin = (HOMEBREW_PINNED_KEGS/d.basename.to_s) - pinned_versions[d] = keg_pin.readlink.basename.to_s if keg_pin.exist? || keg_pin.symlink? + if args.full_name? && + !(args.installed_on_request? || installed_as_dependency || + args.poured_from_bottle? || args.built_from_source?) + unless args.cask? + formula_names = args.no_named? ? Formula.installed : args.named.to_resolved_formulae + full_formula_names = formula_names.map(&:full_name).sort(&Cask::List::TAP_AND_NAME_COMPARISON) + full_formula_names = Formatter.columns(full_formula_names) unless args.public_send(:"1?") + puts full_formula_names if full_formula_names.present? + end + if args.cask? || (!args.formula? && args.no_named?) + cask_names = if args.no_named? + Cask::Caskroom.casks + else + args.named.to_formulae_and_casks(only: :cask, method: :resolve) + end + # The cast is because `Keg`` does not define `full_name` + full_cask_names = T.cast(cask_names, T::Array[T.any(Formula, Cask::Cask)]) + .map(&:full_name).sort(&Cask::List::TAP_AND_NAME_COMPARISON) + full_cask_names = Formatter.columns(full_cask_names) unless args.public_send(:"1?") + puts full_cask_names if full_cask_names.present? + end + elsif args.pinned? + pinned = if args.no_named? + entries = T.let([], T::Array[String]) + unless args.cask? + Formula.racks.each do |rack| + entry = pinned_formula_entry(rack.basename.to_s) + entries << entry if entry + end + end + + if !args.formula? && Cask::Caskroom.path.directory? + Cask::Caskroom.path.children.reject(&:file?).each do |path| + entry = pinned_cask_entry(path.basename.to_s) + entries << entry if entry + end + end + entries + else + args.named.filter_map do |name| + entry = T.let(nil, T.nilable(String)) + package_found = T.let(false, T::Boolean) + package_name = T.let(nil, T.nilable(String)) + + unless args.cask? + rack = Formulary.to_rack(name) + if rack.exist? + package_found = true + package_name = rack.basename.to_s + entry ||= pinned_formula_entry(rack.basename.to_s) + end + end + + unless args.formula? + token = ::Utils.name_from_full_name(name).to_s + caskroom_path = Cask::Caskroom.path/token + if caskroom_path.exist? || caskroom_path.symlink? + package_found = true + package_name ||= token + entry ||= pinned_cask_entry(token) + end + end + + if package_found && entry.nil? + opoo "#{package_name || name} not pinned" + elsif !package_found + Homebrew.failed = true + end + entry + end + end + + puts pinned.sort(&Cask::List::TAP_AND_NAME_COMPARISON) + elsif args.versions? || args.multiple? + filtered_list unless args.cask? + list_casks if args.cask? || (!args.formula? && !args.multiple? && args.no_named?) + elsif args.installed_on_request? || + installed_as_dependency || + args.poured_from_bottle? || + args.built_from_source? + flags = [] + flags << "`--installed-on-request`" if args.installed_on_request? + flags << "`--no-installed-on-request`" if installed_as_dependency + flags << "`--poured-from-bottle`" if args.poured_from_bottle? + flags << "`--built-from-source`" if args.built_from_source? + + raise UsageError, "Cannot use #{flags.join(", ")} with formula arguments." unless args.no_named? + + formulae = if args.t? + # See https://ruby-doc.org/3.2/Kernel.html#method-i-test + Formula.installed.sort_by { |formula| T.cast(test("M", formula.rack.to_s), Time) }.reverse! + elsif args.full_name? + Formula.installed.sort { |a, b| Cask::List::TAP_AND_NAME_COMPARISON.call(a.full_name, b.full_name) } + else + Formula.installed.sort + end + formulae.reverse! if args.r? + formulae.each do |formula| + tab = Tab.for_formula(formula) + + statuses = [] + statuses << "installed on request" if args.installed_on_request? && tab.installed_on_request + statuses << "installed as dependency" if installed_as_dependency && !tab.installed_on_request + statuses << "poured from bottle" if args.poured_from_bottle? && tab.poured_from_bottle + statuses << "built from source" if args.built_from_source? && !tab.poured_from_bottle + next if statuses.empty? + + name = args.full_name? ? formula.full_name : formula.name + if flags.count > 1 + puts "#{name}: #{statuses.join(", ")}" + else + puts name + end + end + elsif args.no_named? + ENV["CLICOLOR"] = nil + + ls_args = [] + ls_args << "-1" if args.public_send(:"1?") + ls_args << "-l" if args.l? + ls_args << "-r" if args.r? + ls_args << "-t" if args.t? + + if !args.cask? && HOMEBREW_CELLAR.exist? && HOMEBREW_CELLAR.children.any? + ohai "Formulae" if $stdout.tty? && !args.formula? + system_command! "ls", args: [*ls_args, HOMEBREW_CELLAR], print_stdout: true + puts if $stdout.tty? && !args.formula? + end + if !args.formula? && Cask::Caskroom.any_casks_installed? + ohai "Casks" if $stdout.tty? && !args.cask? + system_command! "ls", args: [*ls_args, Cask::Caskroom.path], print_stdout: true + end + else + kegs, casks = args.named.to_kegs_to_casks + + if args.verbose? || !$stdout.tty? + find_args = %w[-not -type d -not -name .DS_Store -print] + system_command! "find", args: kegs.map(&:to_s) + find_args, print_stdout: true if kegs.present? + system_command! "find", args: casks.map(&:caskroom_path) + find_args, print_stdout: true if casks.present? + else + kegs.each { |keg| PrettyListing.new keg } if kegs.present? + Cask::List.list_casks(*casks, one: args.public_send(:"1?")) if casks.present? + end + end end - pinned_versions.each do |d, version| - puts d.basename.to_s.concat(args.versions? ? " #{version}" : "") + + private + + sig { params(name: String).returns(T.nilable(String)) } + def pinned_formula_entry(name) + pin_path = HOMEBREW_PINNED_KEGS/name + return unless pin_path.symlink? + + "#{name}#{" #{pin_path.readlink.basename}" if args.versions?}" end - else # --versions without --pinned - names.sort.each do |d| - versions = d.subdirs.map { |pn| pn.basename.to_s } - next if args.multiple? && versions.length < 2 - puts "#{d.basename} #{versions * " "}" + sig { params(token: String).returns(T.nilable(String)) } + def pinned_cask_entry(token) + pin_path = HOMEBREW_PINNED_CASKS/token + return if !pin_path.symlink? || !pin_path.exist? + + "#{token}#{" #{pin_path.resolved_path.basename}" if args.versions?}" end - end - end -end -class PrettyListing - def initialize(path) - Pathname.new(path).children.sort_by { |p| p.to_s.downcase }.each do |pn| - case pn.basename.to_s - when "bin", "sbin" - pn.find { |pnn| puts pnn unless pnn.directory? } - when "lib" - print_dir pn do |pnn| - # dylibs have multiple symlinks and we don't care about them - (pnn.extname == ".dylib" || pnn.extname == ".pc") && !pnn.symlink? + sig { void } + def filtered_list + names = if args.no_named? + Formula.racks + else + racks = args.named.map { |n| Formulary.to_rack(n) } + racks.select do |rack| + Homebrew.failed = true unless rack.exist? + rack.exist? + end end - when ".brew" - next # Ignore .brew - else - if pn.directory? - if pn.symlink? - puts "#{pn} -> #{pn.readlink}" - else - print_dir pn + names.sort.each do |d| + versions = d.subdirs.map { |pn| pn.basename.to_s } + next if args.multiple? && versions.length < 2 + + puts "#{d.basename} #{versions * " "}" + end + end + + sig { void } + def list_casks + casks = if args.no_named? + cask_paths = Cask::Caskroom.path.children.reject(&:file?).map do |path| + if path.symlink? + real_path = path.realpath + real_path.basename.to_s + else + path.basename.to_s + end + end.uniq.sort + cask_paths.map { |name| Cask::CaskLoader.load(name) } + else + filtered_args = args.named.dup.delete_if do |n| + Homebrew.failed = true unless Cask::Caskroom.path.join(n).exist? + !Cask::Caskroom.path.join(n).exist? end - elsif Metafiles.list?(pn.basename.to_s) - puts pn + # NamedAargs subclasses array + T.cast(filtered_args, Homebrew::CLI::NamedArgs).to_formulae_and_casks(only: :cask) end + return if casks.blank? + + Cask::List.list_casks( + *casks, + one: args.public_send(:"1?"), + full_name: args.full_name?, + versions: args.versions?, + ) end end - end - def print_dir(root) - dirs = [] - remaining_root_files = [] - other = "" - - root.children.sort.each do |pn| - if pn.directory? - dirs << pn - elsif block_given? && yield(pn) - puts pn - other = "other " - else - remaining_root_files << pn unless pn.basename.to_s == ".DS_Store" + class PrettyListing + sig { params(path: T.any(String, Pathname, Keg)).void } + def initialize(path) + valid_lib_extensions = [".cps", ".dylib", ".pc"] + Pathname.new(path).children.sort_by { |p| p.to_s.downcase }.each do |pn| + case pn.basename.to_s + when "bin", "sbin" + pn.find { |pnn| puts pnn unless pnn.directory? } + when "lib" + print_dir pn do |pnn| + # dylibs have multiple symlinks and we don't care about them + valid_lib_extensions.include?(pnn.extname) && !pnn.symlink? + end + when ".brew" + next # Ignore .brew + else + if pn.directory? + if pn.symlink? + puts "#{pn} -> #{pn.readlink}" + else + print_dir pn + end + elsif Metafiles.list?(pn.basename.to_s) + puts pn + end + end + end end - end - dirs.each do |d| - files = [] - d.find { |pn| files << pn unless pn.directory? } - print_remaining_files files, d - end + private - print_remaining_files remaining_root_files, root, other - end + sig { params(root: Pathname, block: T.nilable(T.proc.params(arg0: Pathname).returns(T::Boolean))).void } + def print_dir(root, &block) + dirs = [] + remaining_root_files = [] + other = "" - def print_remaining_files(files, root, other = "") - if files.length == 1 - puts files - elsif files.length > 1 - puts "#{root}/ (#{files.length} #{other}files)" + root.children.sort.each do |pn| + if pn.directory? + dirs << pn + elsif block && yield(pn) + puts pn + other = "other " + elsif pn.basename.to_s != ".DS_Store" + remaining_root_files << pn + end + end + + dirs.each do |d| + files = [] + d.find { |pn| files << pn unless pn.directory? } + print_remaining_files files, d + end + + print_remaining_files remaining_root_files, root, other + end + + sig { params(files: T::Array[Pathname], root: Pathname, other: String).void } + def print_remaining_files(files, root, other = "") + if files.length == 1 + puts files + elsif files.length > 1 + puts "#{root}/ (#{files.length} #{other}files)" + end + end end end end diff --git a/Library/Homebrew/cmd/log.rb b/Library/Homebrew/cmd/log.rb index 89b75da8747c7..9cb9e302dee1f 100644 --- a/Library/Homebrew/cmd/log.rb +++ b/Library/Homebrew/cmd/log.rb @@ -1,65 +1,89 @@ +# typed: strict # frozen_string_literal: true -require "formula" -require "cli/parser" +require "abstract_command" +require "fileutils" module Homebrew - module_function + module Cmd + class Log < AbstractCommand + include FileUtils - def log_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `log` [] [] + cmd_args do + description <<~EOS + Show the `git log` for or , or show the log for the Homebrew repository + if no formula or cask is provided. + EOS + switch "-p", "-u", "--patch", + description: "Also print patch from commit." + switch "--stat", + description: "Also print diffstat from commit." + switch "--oneline", + description: "Print only one line per commit." + switch "-1", + description: "Print only one commit." + flag "-n", "--max-count=", + description: "Print only a specified number of commits." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - Show the `git log` for , or show the log for the Homebrew repository - if no formula is provided. - EOS - switch "-p", "-u", "--patch", - description: "Also print patch from commit." - switch "--stat", - description: "Also print diffstat from commit." - switch "--oneline", - description: "Print only one line per commit." - flag "-1", "--max-count", - description: "Print only one or a specified number of commits." - max_named 1 - end - end + conflicts "-1", "--max-count" + conflicts "--formula", "--cask" - def log - log_args.parse + named_args [:formula, :cask], max: 1, without_api: true + end - if ARGV.named.empty? - git_log HOMEBREW_REPOSITORY - else - path = Formulary.path(ARGV.named.first) - tap = Tap.from_path(path) - git_log path.dirname, path, tap - end - end + sig { override.void } + def run + # As this command is simplifying user-run commands then let's just use a + # user path, too. + ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s - def git_log(cd_dir, path = nil, tap = nil) - cd cd_dir - repo = Utils.popen_read("git rev-parse --show-toplevel").chomp - if tap - name = tap.to_s - git_cd = "$(brew --repo #{tap})" - elsif cd_dir == HOMEBREW_REPOSITORY - name = "Homebrew/brew" - git_cd = "$(brew --repo)" - else - name, git_cd = cd_dir - end + if args.no_named? + git_log(HOMEBREW_REPOSITORY) + else + path = args.named.to_paths.fetch(0) + tap = Tap.from_path(path) + git_log path.dirname, path, tap + end + end + + private + + sig { params(cd_dir: Pathname, path: T.nilable(Pathname), tap: T.nilable(Tap)).void } + def git_log(cd_dir, path = nil, tap = nil) + cd cd_dir do + repo = Utils.popen_read("git", "rev-parse", "--show-toplevel").chomp + if tap + name = tap.to_s + git_cd = "$(brew --repo #{tap})" + elsif cd_dir == HOMEBREW_REPOSITORY + name = "Homebrew/brew" + git_cd = "$(brew --repo)" + else + name, git_cd = cd_dir + end + + if File.exist? "#{repo}/.git/shallow" + opoo <<~EOS + #{name} is a shallow clone so only partial output will be shown. + To get a full clone, run: + git -C "#{git_cd}" fetch --unshallow + EOS + end - if File.exist? "#{repo}/.git/shallow" - opoo <<~EOS - #{name} is a shallow clone so only partial output will be shown. - To get a full clone run: - git -C "#{git_cd}" fetch --unshallow - EOS + git_args = [] + git_args << "--patch" if args.patch? + git_args << "--stat" if args.stat? + git_args << "--oneline" if args.oneline? + git_args << "-1" if args.public_send(:"1?") + git_args << "--max-count" << args.max_count if args.max_count + git_args += ["--follow", "--", path] if path&.file? + system "git", "log", *git_args + end + end end - args = Homebrew.args.options_only - args += ["--follow", "--", path] unless path.nil? - system "git", "log", *args end end diff --git a/Library/Homebrew/cmd/mcp-server.rb b/Library/Homebrew/cmd/mcp-server.rb new file mode 100644 index 0000000000000..9a31e2a3e458a --- /dev/null +++ b/Library/Homebrew/cmd/mcp-server.rb @@ -0,0 +1,23 @@ +# typed: strong +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class McpServerCmd < AbstractCommand + # This is a shell command as MCP servers need a faster startup time + # than a normal Homebrew Ruby command allows. + include ShellCommand + + cmd_args do + description <<~EOS + Starts the Homebrew MCP (Model Context Protocol) server. + EOS + switch "-d", "--debug", description: "Enable debug logging to stderr." + switch "--ping", description: "Start the server, act as if receiving a ping and then exit.", hidden: true + end + end + end +end diff --git a/Library/Homebrew/cmd/mcp-server.sh b/Library/Homebrew/cmd/mcp-server.sh new file mode 100644 index 0000000000000..692eec5ed4f40 --- /dev/null +++ b/Library/Homebrew/cmd/mcp-server.sh @@ -0,0 +1,14 @@ +# Documentation defined in Library/Homebrew/cmd/mcp-server.rb + +# This is a shell command as MCP servers need a faster startup time +# than a normal Homebrew Ruby command allows. + +# HOMEBREW_LIBRARY is set by brew.sh +# HOMEBREW_BREW_FILE is set by extend/ENV/super.rb +# shellcheck disable=SC2154 +homebrew-mcp-server() { + source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" + setup-ruby-path + export HOMEBREW_VERSION + "${HOMEBREW_RUBY_PATH}" "-r${HOMEBREW_LIBRARY}/Homebrew/mcp_server.rb" -e "Homebrew::McpServer.new.run" "$@" +} diff --git a/Library/Homebrew/cmd/migrate.rb b/Library/Homebrew/cmd/migrate.rb index d5b6b7308de4b..7f24aa820e848 100644 --- a/Library/Homebrew/cmd/migrate.rb +++ b/Library/Homebrew/cmd/migrate.rb @@ -1,42 +1,44 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "migrator" -require "cli/parser" +require "cask/migrator" module Homebrew - module_function - - def migrate_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `migrate` [] - - Migrate renamed packages to new names, where are old names of - packages. - EOS - switch :force, - description: "Treat installed and provided as if they are from "\ - "the same taps and migrate them anyway." - switch :verbose - switch :debug - end - end - - def migrate - migrate_args.parse - - raise FormulaUnspecifiedError if Homebrew.args.named.blank? + module Cmd + class Migrate < AbstractCommand + cmd_args do + description <<~EOS + Migrate renamed packages to new names, where are old names of + packages. + EOS + switch "-f", "--force", + description: "Treat installed and provided as if they are from " \ + "the same taps and migrate them anyway." + switch "-n", "--dry-run", + description: "Show what would be migrated, but do not actually migrate anything." + switch "--formula", "--formulae", + description: "Only migrate formulae." + switch "--cask", "--casks", + description: "Only migrate casks." + + conflicts "--formula", "--cask" + + named_args [:installed_formula, :installed_cask], min: 1 + end - Homebrew.args.resolved_formulae.each do |f| - if f.oldname - unless (rack = HOMEBREW_CELLAR/f.oldname).exist? && !rack.subdirs.empty? - raise NoSuchKegError, f.oldname + sig { override.void } + def run + args.named.to_formulae_and_casks(warn: false).each do |formula_or_cask| + case formula_or_cask + when Formula + Migrator.migrate_if_needed(formula_or_cask, force: args.force?, dry_run: args.dry_run?) + when Cask::Cask + Cask::Migrator.migrate_if_needed(formula_or_cask, dry_run: args.dry_run?) + end end - raise "#{rack} is a symlink" if rack.symlink? end - - migrator = Migrator.new(f) - migrator.migrate end end end diff --git a/Library/Homebrew/cmd/missing.rb b/Library/Homebrew/cmd/missing.rb index 4b51ed3439882..2f58fd87bcdc6 100644 --- a/Library/Homebrew/cmd/missing.rb +++ b/Library/Homebrew/cmd/missing.rb @@ -1,48 +1,51 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "tab" -require "diagnostic" -require "cli/parser" +require "cask/caskroom" +require "missing" module Homebrew - module_function - - def missing_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `missing` [] [] - - Check the given kegs for missing dependencies. If no are - provided, check all kegs. Will exit with a non-zero status if any kegs are found - to be missing dependencies. - EOS - comma_array "--hide", - description: "Act as if none of the specified are installed. should be "\ - "a comma-separated list of formulae." - switch :verbose - switch :debug - end - end - - def missing - missing_args.parse - - return unless HOMEBREW_CELLAR.exist? - - ff = if Homebrew.args.named.blank? - Formula.installed.sort - else - Homebrew.args.resolved_formulae.sort - end - - ff.each do |f| - missing = f.missing_dependencies(hide: args.hide) - next if missing.empty? - - Homebrew.failed = true - print "#{f}: " if ff.size > 1 - puts missing.join(" ") + module Cmd + class Missing < AbstractCommand + cmd_args do + description <<~EOS + Check the given kegs and installations for missing dependencies. + If no or are provided, check all kegs and casks. Will exit with + a non-zero status if any kegs or casks are found to be missing dependencies. + EOS + comma_array "--hide", + description: "Act as if none of the specified are installed. should be " \ + "a comma-separated list of formulae or casks." + + named_args [:formula, :cask] + end + + sig { override.void } + def run + return if !HOMEBREW_CELLAR.exist? && !Cask::Caskroom.path.exist? + + formulae, casks = if args.no_named? + [Formula.installed, Cask::Caskroom.casks] + else + args.named.to_resolved_formulae_to_casks + end + formulae = formulae.sort + casks = casks.sort_by(&:full_name) + hide = args.hide || [] + package_count = formulae.size + casks.size + missing_deps = Homebrew::Missing.deps(formulae, casks, hide) + + (formulae + casks).each do |formula_or_cask| + missing = missing_deps[formula_or_cask.full_name] + next if missing.blank? + + Homebrew.failed = true + print "#{formula_or_cask}: " if package_count > 1 + puts missing.join(" ") + end + end end end end diff --git a/Library/Homebrew/cmd/nodenv-sync.rb b/Library/Homebrew/cmd/nodenv-sync.rb new file mode 100644 index 0000000000000..55090e3083825 --- /dev/null +++ b/Library/Homebrew/cmd/nodenv-sync.rb @@ -0,0 +1,82 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "fileutils" +require "keg" + +module Homebrew + module Cmd + class NodenvSync < AbstractCommand + cmd_args do + description <<~EOS + Create symlinks for Homebrew's installed NodeJS versions in `~/.nodenv/versions`. + + Note that older version symlinks will also be created so e.g. NodeJS 19.1.0 will + also be symlinked to 19.0.0. + EOS + + named_args :none + end + + sig { override.void } + def run + nodenv_root = Pathname(ENV.fetch("HOMEBREW_NODENV_ROOT", Pathname(Dir.home)/".nodenv")) + + # Don't run multiple times at once. + nodenv_sync_running = nodenv_root/".nodenv_sync_running" + return if nodenv_sync_running.exist? + + begin + nodenv_versions = nodenv_root/"versions" + nodenv_versions.mkpath + FileUtils.touch nodenv_sync_running + + HOMEBREW_CELLAR.glob("node{,@*}") + .flat_map(&:children) + .each { |path| link_nodenv_versions(path, nodenv_versions) } + + nodenv_versions.children + .select(&:symlink?) + .reject(&:exist?) + .each { |path| FileUtils.rm_f path } + ensure + nodenv_sync_running.unlink if nodenv_sync_running.exist? + end + end + + private + + sig { params(path: Pathname, nodenv_versions: Pathname).void } + def link_nodenv_versions(path, nodenv_versions) + nodenv_versions.mkpath + + version = Keg.new(path).version + major_version = version.major.to_i + minor_version = version.minor.to_i + patch_version = version.patch.to_i + + minor_version_range, patch_version_range = if Homebrew::EnvConfig.env_sync_strict? + # Only create symlinks for the exact installed patch version. + # e.g. 23.9.0 => 23.9.0 + [[minor_version], [patch_version]] + else + # Create folder symlinks for all patch versions to the latest patch version + # e.g. 23.9.0 => 23.10.1 + [0..minor_version, 0..patch_version] + end + + minor_version_range.each do |minor| + patch_version_range.each do |patch| + link_path = nodenv_versions/"#{major_version}.#{minor}.#{patch}" + # Don't clobber existing user installations. + next if link_path.exist? && !link_path.symlink? + + FileUtils.rm_f link_path + FileUtils.ln_s path, link_path + end + end + end + end + end +end diff --git a/Library/Homebrew/cmd/options.rb b/Library/Homebrew/cmd/options.rb index c42f9f06618e7..5ca644368b2df 100644 --- a/Library/Homebrew/cmd/options.rb +++ b/Library/Homebrew/cmd/options.rb @@ -1,54 +1,78 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "options" -require "cli/parser" module Homebrew - module_function - - def options_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `options` [] [] - - Show install options specific to . - EOS - switch "--compact", - description: "Show all options on a single line separated by spaces." - switch "--installed", - description: "Show options for formulae that are currently installed." - switch "--all", - description: "Show options for all available formulae." - switch :debug - conflicts "--installed", "--all" - end - end + module Cmd + class OptionsCmd < AbstractCommand + cmd_args do + description <<~EOS + Show install options specific to . + EOS + switch "--compact", + description: "Show all options on a single line separated by spaces." + switch "--installed", + description: "Show options for formulae that are currently installed." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to show their " \ + "options.", + env: :eval_all, + odeprecated: true + flag "--command=", + description: "Show options for the specified .", + odeprecated: true - def options - options_args.parse + conflicts "--command", "--installed", "--eval-all" - if args.all? - puts_options Formula.to_a.sort - elsif args.installed? - puts_options Formula.installed.sort - else - raise FormulaUnspecifiedError if args.remaining.empty? + named_args :formula + end - puts_options Homebrew.args.formulae - end - end + sig { override.void } + def run + eval_all = args.eval_all? + eval_all ||= args.no_named? && Homebrew::EnvConfig.tap_trust_configured? + + if eval_all + puts_options(Formula.all(eval_all:).sort) + elsif args.installed? + puts_options(Formula.installed.sort) + elsif args.command.present? + cmd_options = Commands.command_options(T.must(args.command)) + odie "Unknown command: brew #{args.command}" if cmd_options.nil? + + if args.compact? + puts cmd_options.sort.map(&:first) * " " + else + cmd_options.sort.each { |option, desc| puts "#{option}\n\t#{desc}" } + puts + end + elsif args.no_named? + raise UsageError, + "`brew options` needs a formula, `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` set!" + else + puts_options args.named.to_formulae + end + end + + private - def puts_options(formulae) - formulae.each do |f| - next if f.options.empty? + sig { params(formulae: T::Array[Formula]).void } + def puts_options(formulae) + formulae.each do |f| + next if f.options.empty? - if args.compact? - puts f.options.as_flags.sort * " " - else - puts f.full_name if formulae.length > 1 - dump_options_for_formula f - puts + if args.compact? + puts f.options.as_flags.sort * " " + else + puts f.full_name if formulae.length > 1 + Options.dump_for_formula f + puts + end + end end end end diff --git a/Library/Homebrew/cmd/outdated.rb b/Library/Homebrew/cmd/outdated.rb index c27713a236900..c3f0daaa70c80 100644 --- a/Library/Homebrew/cmd/outdated.rb +++ b/Library/Homebrew/cmd/outdated.rb @@ -1,113 +1,291 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "keg" -require "cli/parser" +require "cask/caskroom" +require "api" +require "minimum_version" module Homebrew - module_function - - def outdated_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `outdated` [] [] - - List installed formulae that have an updated version available. By default, version - information is displayed in interactive shells, and suppressed otherwise. - EOS - switch :quiet, - description: "List only the names of outdated kegs (takes precedence over `--verbose`)." - switch :verbose, - description: "Include detailed version information." - flag "--json", - description: "Print output in JSON format. Currently the default and only accepted "\ - "value for is `v1`. See the docs for examples of using the JSON "\ - "output: " - switch "--fetch-HEAD", - description: "Fetch the upstream repository to detect if the HEAD installation of the "\ - "formula is outdated. Otherwise, the repository's HEAD will only be checked for "\ - "updates when a new stable or development version has been released." - switch :debug - conflicts "--quiet", "--verbose", "--json" - end - end + module Cmd + class Outdated < AbstractCommand + cmd_args do + description <<~EOS + List installed casks and formulae that have an updated version available. By default, version + information is displayed in interactive shells and suppressed otherwise. + EOS + switch "-q", "--quiet", + description: "List only the names of outdated kegs (takes precedence over `--verbose`)." + switch "-v", "--verbose", + description: "Include detailed version information." + switch "--formula", "--formulae", + description: "List only outdated formulae." + switch "--cask", "--casks", + description: "List only outdated casks." + flag "--json", + description: "Print output in JSON format. There are two versions: `v1` and `v2`. " \ + "`v1` is deprecated and is currently the default if no version is specified. " \ + "`v2` prints outdated formulae and casks." + flag "--minimum-version=", "--min-version=", + description: "Only list a named formula or cask with an installed version below the given " \ + "minimum version." + switch "--fetch-HEAD", + description: "Fetch the upstream repository to detect if the HEAD installation of the " \ + "formula is outdated. Otherwise, the repository's HEAD will only be checked for " \ + "updates when a new stable or development version has been released." + switch "-g", "--greedy", + description: "Also include outdated casks with `version :latest` and `auto_updates true` " \ + "casks that would otherwise be skipped.", + env: :upgrade_greedy + switch "--greedy-latest", + description: "Also include outdated casks including those with `version :latest`." + switch "--greedy-auto-updates", + description: "Also include outdated `auto_updates true` casks that would otherwise be skipped." - def outdated - outdated_args.parse + conflicts "--quiet", "--verbose", "--json" + conflicts "--formula", "--cask" - formulae = if Homebrew.args.resolved_formulae.blank? - Formula.installed - else - Homebrew.args.resolved_formulae - end - if args.json - raise UsageError, "Invalid JSON version: #{args.json}" unless ["v1", true].include? args.json + named_args [:formula, :cask] + end - outdated = print_outdated_json(formulae) - else - outdated = print_outdated(formulae) - end - Homebrew.failed = Homebrew.args.resolved_formulae.present? && !outdated.empty? - end + sig { override.void } + def run + raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if + minimum_version.present? && args.named.length != 1 + + case json_version(args.json) + when :v1 + odie "`brew outdated --json=v1` is no longer supported. Use brew outdated --json=v2 instead." + when :v2, :default + formulae, casks = if args.formula? + [outdated_formulae, []] + elsif args.cask? + [[], outdated_casks] + else + outdated_formulae_casks + end + + json = { + formulae: json_info(formulae), + casks: json_info(casks), + } + # json v2.8.1 is inconsistent it how it renders empty arrays, + # so we use `[]` for consistency: + puts JSON.pretty_generate(json).gsub(/\[\n\n\s*\]/, "[]") + + outdated = formulae + casks + else + outdated = if args.formula? + outdated_formulae + elsif args.cask? + outdated_casks + else + outdated_formulae_casks.flatten + end + + print_outdated(outdated) + end + + Homebrew.failed = args.named.present? && outdated.present? + end + + private + + sig { params(formulae_or_casks: T::Array[T.any(Formula, Cask::Cask)]).void } + def print_outdated(formulae_or_casks) + formulae_or_casks.each do |formula_or_cask| + if formula_or_cask.is_a?(Formula) + f = formula_or_cask + + if verbose? + outdated_kegs = formula_outdated_kegs(f) + latest_formula = f.latest_formula + + current_version = if minimum_version.present? + minimum_version + elsif f.alias_changed? && !latest_formula.latest_version_installed? + "#{latest_formula.name} (#{latest_formula.pkg_version})" + elsif f.head? + latest_head_version = f.latest_head_pkg_version(fetch_head: args.fetch_HEAD?) + if outdated_kegs.any? { |k| k.version.to_s == latest_head_version.to_s } + # There is a newer HEAD but the version number has not changed. + "latest HEAD" + else + latest_head_version.to_s + end + else + latest_formula.pkg_version.to_s + end + + outdated_versions = outdated_kegs.group_by { |keg| Formulary.from_keg(keg).full_name } + .sort_by { |full_name, _kegs| full_name } + .map do |full_name, kegs| + "#{full_name} (#{kegs.map(&:version).join(", ")})" + end.join(", ") + + pinned_version = " [pinned at #{f.pinned_version}]" if f.pinned? + + puts "#{outdated_versions} < #{current_version}#{pinned_version}" + else + puts f.full_installed_specified_name + end + else + c = formula_or_cask + + if minimum_version.present? + if verbose? + pinned_version = " [pinned at #{c.pinned_version}]" if c.pinned? + + puts "#{c.token} (#{c.installed_version}) < #{minimum_version}#{pinned_version}" + else + puts c.token + end + else + puts c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), verbose?, + false, args.greedy_latest?, args.greedy_auto_updates?) + end + end + end + end - def print_outdated(formulae) - verbose = ($stdout.tty? || args.verbose?) && !args.quiet? - fetch_head = args.fetch_HEAD? + sig { + params( + formulae_or_casks: T::Array[T.any(Formula, Cask::Cask)], + ).returns(T::Array[T::Hash[Symbol, T.untyped]]) + } + def json_info(formulae_or_casks) + formulae_or_casks.map do |formula_or_cask| + if formula_or_cask.is_a?(Formula) + f = formula_or_cask - outdated_formulae = formulae.select { |f| f.outdated?(fetch_head: fetch_head) } - .sort + outdated_versions = formula_outdated_kegs(f).map(&:version) + current_version = if minimum_version.present? + minimum_version + elsif f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s } + "HEAD" + else + f.pkg_version.to_s + end - outdated_formulae.each do |f| - if verbose - outdated_kegs = f.outdated_kegs(fetch_head: fetch_head) + { name: f.full_name, + installed_versions: outdated_versions.map(&:to_s), + current_version:, + pinned: f.pinned?, + pinned_version: f.pinned_version } + else + c = formula_or_cask - current_version = if f.alias_changed? - latest = f.latest_formula - "#{latest.name} (#{latest.pkg_version})" - elsif f.head? && outdated_kegs.any? { |k| k.version.to_s == f.pkg_version.to_s } - # There is a newer HEAD but the version number has not changed. - "latest HEAD" + if minimum_version.present? + { name: c.token, + installed_versions: [T.must(c.installed_version)], + current_version: T.must(minimum_version), + pinned: c.pinned?, + pinned_version: c.pinned_version } + else + T.cast( + c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), + verbose?, true, args.greedy_latest?, args.greedy_auto_updates?), + T::Hash[Symbol, T.untyped], + ) + end + end + end + end + + sig { returns(T::Boolean) } + def verbose? + ($stdout.tty? || Context.current.verbose?) && !Context.current.quiet? + end + + sig { params(version: T.nilable(T.any(TrueClass, String))).returns(T.nilable(Symbol)) } + def json_version(version) + version_hash = { + nil => nil, + true => :default, + "v1" => :v1, + "v2" => :v2, + } + version_hash.fetch(version) { raise UsageError, "invalid JSON version: #{version}" } + end + + sig { returns(T.nilable(String)) } + def minimum_version = args.minimum_version || args.min_version + + sig { returns(T::Array[Formula]) } + def outdated_formulae + T.cast( + select_outdated(args.named.to_resolved_formulae.presence || Formula.installed).sort, + T::Array[Formula], + ) + end + + sig { returns(T::Array[Cask::Cask]) } + def outdated_casks + outdated = if args.named.present? + select_outdated(args.named.to_casks) else - f.pkg_version.to_s + select_outdated(Cask::Caskroom.casks) + end + + T.cast(outdated, T::Array[Cask::Cask]) + end + + sig { returns([T::Array[T.any(Formula, Cask::Cask)], T::Array[T.any(Formula, Cask::Cask)]]) } + def outdated_formulae_casks + formulae, casks = args.named.to_resolved_formulae_to_casks + + if formulae.blank? && casks.blank? + formulae = Formula.installed + casks = Cask::Caskroom.casks end - outdated_versions = outdated_kegs - .group_by { |keg| Formulary.from_keg(keg).full_name } - .sort_by { |full_name, _kegs| full_name } - .map do |full_name, kegs| - "#{full_name} (#{kegs.map(&:version).join(", ")})" - end.join(", ") + [select_outdated(formulae).sort, select_outdated(casks)] + end + + sig { + params(formulae_or_casks: T::Array[T.any(Formula, Cask::Cask)]).returns(T::Array[T.any(Formula, Cask::Cask)]) + } + def select_outdated(formulae_or_casks) + formulae_or_casks.select do |formula_or_cask| + if formula_or_cask.is_a?(Formula) + if minimum_version.present? + formula_outdated_kegs(formula_or_cask).present? + else + formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?) + end + else + if minimum_version.present? + next MinimumVersion.cask_installed_below?(formula_or_cask, T.must(minimum_version)) + end - pinned_version = " [pinned at #{f.pinned_version}]" if f.pinned? + cask_greedy = upgrade_greedy_cask?(args.greedy?, formula_or_cask) - puts "#{outdated_versions} < #{current_version}#{pinned_version}" - else - puts f.full_installed_specified_name + formula_or_cask.outdated?(greedy: cask_greedy, + greedy_latest: args.greedy_latest?, + greedy_auto_updates: args.greedy_auto_updates?) + end + end end - end - end - def print_outdated_json(formulae) - json = [] - fetch_head = args.fetch_HEAD? - outdated_formulae = formulae.select { |f| f.outdated?(fetch_head: fetch_head) } - - outdated = outdated_formulae.each do |f| - outdated_versions = f.outdated_kegs(fetch_head: fetch_head).map(&:version) - current_version = if f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s } - "HEAD" - else - f.pkg_version.to_s + sig { params(formula: Formula).returns(T::Array[Keg]) } + def formula_outdated_kegs(formula) + MinimumVersion.formula_outdated_kegs(formula, minimum_version, fetch_head: args.fetch_HEAD?) end - json << { name: f.full_name, - installed_versions: outdated_versions.map(&:to_s), - current_version: current_version, - pinned: f.pinned?, - pinned_version: f.pinned_version } - end - puts JSON.generate(json) + sig { params(greedy: T::Boolean, cask: Cask::Cask).returns(T::Boolean) } + def upgrade_greedy_cask?(greedy, cask) + return true if greedy - outdated + @greedy_list ||= T.let( + begin + upgrade_greedy_casks = Homebrew::EnvConfig.upgrade_greedy_casks.presence + upgrade_greedy_casks&.split || [] + end, T.nilable(T::Array[String]) + ) + + @greedy_list.include?(cask.token) + end + end end end diff --git a/Library/Homebrew/cmd/pin.rb b/Library/Homebrew/cmd/pin.rb index 76cc6130c2645..94ad62facd47f 100644 --- a/Library/Homebrew/cmd/pin.rb +++ b/Library/Homebrew/cmd/pin.rb @@ -1,35 +1,50 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "cli/parser" +require "cask/cask" module Homebrew - module_function + module Cmd + class Pin < AbstractCommand + cmd_args do + description <<~EOS + Pin the specified package, preventing it from being upgraded when + issuing the `brew upgrade` or command. See also `unpin`. - def pin_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `pin` + *Note:* Other packages which depend on newer versions of a pinned formula + might not install or run correctly. + Pinned casks with `auto_updates true` may update themselves outside Homebrew. + EOS - Pin the specified , preventing them from being upgraded when - issuing the `brew upgrade` command. See also `unpin`. - EOS - switch :debug - end - end + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - def pin - pin_args.parse + conflicts "--formula", "--cask" + + named_args [:installed_formula, :installed_cask], min: 1 + end - raise FormulaUnspecifiedError if args.remaining.empty? + sig { override.void } + def run + formulae, casks = args.named.to_resolved_formulae_to_casks - Homebrew.args.resolved_formulae.each do |f| - if f.pinned? - opoo "#{f.name} already pinned" - elsif !f.pinnable? - onoe "#{f.name} not installed" - else - f.pin + (formulae + casks).each do |package| + if package.pinned? + opoo "#{package.full_name} already pinned" + elsif !package.pinnable? + ofail "#{package.full_name} not installed" + else + package.pin + if package.is_a?(Cask::Cask) && package.auto_updates + opoo "#{package.full_name} has `auto_updates true` and may update itself outside Homebrew despite " \ + "being pinned." + end + end + end end end end diff --git a/Library/Homebrew/cmd/postinstall.rb b/Library/Homebrew/cmd/postinstall.rb index dcecd0f9378e3..24854397544ba 100644 --- a/Library/Homebrew/cmd/postinstall.rb +++ b/Library/Homebrew/cmd/postinstall.rb @@ -1,34 +1,37 @@ +# typed: strict # frozen_string_literal: true -require "sandbox" +require "abstract_command" require "formula_installer" -require "cli/parser" module Homebrew - module_function + module Cmd + class Postinstall < AbstractCommand + cmd_args do + description <<~EOS + Rerun the post-install steps for . + EOS - def postinstall_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `postinstall` + named_args :installed_formula, min: 1 + end - Rerun the post-install steps for . - EOS - switch :force - switch :verbose - switch :debug - end - end - - def postinstall - postinstall_args.parse - - raise KegUnspecifiedError if args.remaining.empty? + sig { override.void } + def run + args.named.to_resolved_formulae.each do |f| + ohai "Postinstalling #{f}" + f.install_etc_var + post_install_steps_defined = f.post_install_steps_defined? + post_install_defined = f.post_install_defined? - Homebrew.args.resolved_formulae.each do |f| - ohai "Postinstalling #{f}" - fi = FormulaInstaller.new(f) - fi.post_install + f.run_post_install_steps if post_install_steps_defined + if post_install_defined + fi = FormulaInstaller.new(f, **{ debug: args.debug?, quiet: args.quiet?, verbose: args.verbose? }.compact) + fi.post_install + elsif !post_install_steps_defined + opoo "#{f}: no `post_install` method was defined in the formula!" + end + end + end end end end diff --git a/Library/Homebrew/cmd/pyenv-sync.rb b/Library/Homebrew/cmd/pyenv-sync.rb new file mode 100644 index 0000000000000..e85921318b6d6 --- /dev/null +++ b/Library/Homebrew/cmd/pyenv-sync.rb @@ -0,0 +1,101 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "fileutils" +require "keg" + +module Homebrew + module Cmd + class PyenvSync < AbstractCommand + cmd_args do + description <<~EOS + Create symlinks for Homebrew's installed Python versions in `~/.pyenv/versions`. + + Note that older patch version symlinks will be created and linked to the minor + version so e.g. Python 3.11.0 will also be symlinked to 3.11.3. + EOS + + named_args :none + end + + sig { override.void } + def run + pyenv_root = Pathname(ENV.fetch("HOMEBREW_PYENV_ROOT", Pathname(Dir.home)/".pyenv")) + + # Don't run multiple times at once. + pyenv_sync_running = pyenv_root/".pyenv_sync_running" + return if pyenv_sync_running.exist? + + begin + pyenv_versions = pyenv_root/"versions" + pyenv_versions.mkpath + FileUtils.touch pyenv_sync_running + HOMEBREW_CELLAR.glob("python{,@*}") + .flat_map(&:children) + .each { |path| link_pyenv_versions(path, pyenv_versions) } + + pyenv_versions.children + .select(&:symlink?) + .reject(&:exist?) + .each { |path| FileUtils.rm_f path } + ensure + pyenv_sync_running.unlink if pyenv_sync_running.exist? + end + end + + private + + sig { params(path: Pathname, pyenv_versions: Pathname).void } + def link_pyenv_versions(path, pyenv_versions) + pyenv_versions.mkpath + + version = Keg.new(path).version + major_version = version.major.to_i + minor_version = version.minor.to_i + patch_version = version.patch.to_i + + patch_version_range = if Homebrew::EnvConfig.env_sync_strict? + # Only create symlinks for the exact installed patch version. + # e.g. 3.11.0 => 3.11.0 + [patch_version] + else + # Create folder symlinks for all patch versions to the latest patch version + # e.g. 3.11.0 => 3.11.3 + 0..patch_version + end + + patch_version_range.each do |patch| + link_path = pyenv_versions/"#{major_version}.#{minor_version}.#{patch}" + + # Don't clobber existing user installations. + next if link_path.exist? && !link_path.symlink? + + FileUtils.rm_f link_path + FileUtils.ln_s path, link_path + + # Create an unversioned symlinks + # This is what pyenv expects to find in ~/.pyenv/versions/___/bin'. + # Without this, `python3`, `pip3` do not exist and pyenv falls back to system Python. + # (eg. python3 -> python3.11, pip3 -> pip3.11) + executables = %w[python3 pip3 wheel3 idle3 pydoc3] + executables.each do |executable| + major_link_path = link_path/"bin/#{executable}" + + # Don't clobber existing user installations. + next if major_link_path.exist? && !major_link_path.symlink? + + executable_link_path = link_path/"bin/#{executable}.#{minor_version}" + FileUtils.rm_f major_link_path + + begin + FileUtils.ln_s executable_link_path, major_link_path + rescue => e + opoo "Failed to link #{executable_link_path} to #{major_link_path}: #{e}" + end + end + end + end + end + end +end diff --git a/Library/Homebrew/cmd/rbenv-sync.rb b/Library/Homebrew/cmd/rbenv-sync.rb new file mode 100644 index 0000000000000..facc72e76cb80 --- /dev/null +++ b/Library/Homebrew/cmd/rbenv-sync.rb @@ -0,0 +1,80 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "fileutils" +require "keg" + +module Homebrew + module Cmd + class RbenvSync < AbstractCommand + cmd_args do + description <<~EOS + Create symlinks for Homebrew's installed Ruby versions in `~/.rbenv/versions`. + + Note that older version symlinks will also be created so e.g. Ruby 3.2.1 will + also be symlinked to 3.2.0. + EOS + + named_args :none + end + + sig { override.void } + def run + rbenv_root = Pathname(ENV.fetch("HOMEBREW_RBENV_ROOT", Pathname(Dir.home)/".rbenv")) + + # Don't run multiple times at once. + rbenv_sync_running = rbenv_root/".rbenv_sync_running" + return if rbenv_sync_running.exist? + + begin + rbenv_versions = rbenv_root/"versions" + rbenv_versions.mkpath + FileUtils.touch rbenv_sync_running + + HOMEBREW_CELLAR.glob("ruby{,@*}") + .flat_map(&:children) + .each { |path| link_rbenv_versions(path, rbenv_versions) } + + rbenv_versions.children + .select(&:symlink?) + .reject(&:exist?) + .each { |path| FileUtils.rm_f path } + ensure + rbenv_sync_running.unlink if rbenv_sync_running.exist? + end + end + + private + + sig { params(path: Pathname, rbenv_versions: Pathname).void } + def link_rbenv_versions(path, rbenv_versions) + rbenv_versions.mkpath + + version = Keg.new(path).version + major_version = version.major.to_i + minor_version = version.minor.to_i + patch_version = version.patch.to_i + + patch_version_range = if Homebrew::EnvConfig.env_sync_strict? + # Only create symlinks for the exact installed patch version. + # e.g. 3.4.0 => 3.4.0 + [patch_version] + else + # Create folder symlinks for all patch versions to the latest patch version + # e.g. 3.4.0 => 3.4.2 + 0..patch_version + end + + patch_version_range.each do |patch| + link_path = rbenv_versions/"#{major_version}.#{minor_version}.#{patch}" + # Don't clobber existing user installations. + next if link_path.exist? && !link_path.symlink? + + FileUtils.rm_f link_path + FileUtils.ln_s path, link_path + end + end + end + end +end diff --git a/Library/Homebrew/cmd/readall.rb b/Library/Homebrew/cmd/readall.rb index fcece51bffd31..c34974aa75c23 100644 --- a/Library/Homebrew/cmd/readall.rb +++ b/Library/Homebrew/cmd/readall.rb @@ -1,48 +1,73 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "readall" -require "cli/parser" +require "env_config" module Homebrew - module_function - - def readall_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `readall` [] [] - - Import all formulae from the specified , or from all installed taps if none is provided. - This can be useful for debugging issues across all formulae when making - significant changes to `formula.rb`, testing the performance of loading - all formulae or checking if any current formulae have Ruby issues. - EOS - switch "--aliases", - description: "Verify any alias symlinks in each tap." - switch "--syntax", - description: "Syntax-check all of Homebrew's Ruby files." - switch :verbose - switch :debug - end - end + module Cmd + class ReadallCmd < AbstractCommand + cmd_args do + description <<~EOS + Import all items from the specified , or from all installed taps if none is provided. + This can be useful for debugging issues across all items when making + significant changes to `formula.rb`, testing the performance of loading + all items or checking if any current formulae/casks have Ruby issues. + EOS + flag "--os=", + description: "Read using the given operating system. (Pass `all` to simulate all operating systems.)" + flag "--arch=", + description: "Read using the given CPU architecture. (Pass `all` to simulate all architectures.)" + switch "--aliases", + description: "Verify any alias symlinks in each tap." + switch "--syntax", + description: "Syntax-check all of Homebrew's Ruby files (if no is passed)." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not.", + env: :eval_all, + odeprecated: true + switch "--no-simulate", + description: "Don't simulate other system configurations when checking formulae and casks." - def readall - readall_args.parse + named_args :tap + end - if args.syntax? - scan_files = "#{HOMEBREW_LIBRARY_PATH}/**/*.rb" - ruby_files = Dir.glob(scan_files).reject { |file| file =~ %r{/(vendor|cask)/} } + sig { override.void } + def run + Homebrew.with_no_api_env do + if args.syntax? && args.no_named? + scan_files = "#{HOMEBREW_LIBRARY_PATH}/**/*.rb" + ruby_files = Dir.glob(scan_files).grep_v(%r{/(vendor)/}) - Homebrew.failed = true unless Readall.valid_ruby_syntax?(ruby_files) - end + Homebrew.failed = true unless Readall.valid_ruby_syntax?(ruby_files) + end - options = { aliases: args.aliases? } - taps = if ARGV.named.empty? - Tap - else - ARGV.named.map { |t| Tap.fetch(t) } - end - taps.each do |tap| - Homebrew.failed = true unless Readall.valid_tap?(tap, options) + options = { + aliases: args.aliases?, + no_simulate: args.no_simulate?, + } + options[:os_arch_combinations] = args.os_arch_combinations if args.os || args.arch + + eval_all = args.eval_all? + eval_all ||= args.no_named? && Homebrew::EnvConfig.tap_trust_configured? + taps = if args.no_named? + unless eval_all + raise UsageError, + "`brew readall` needs a tap, `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` set!" + end + + Tap.installed + else + args.named.to_installed_taps + end + + taps.each do |tap| + Homebrew.failed = true unless Readall.valid_tap?(tap, **options) + end + end + end end end end diff --git a/Library/Homebrew/cmd/reinstall.rb b/Library/Homebrew/cmd/reinstall.rb index 6b0f6ea588850..2dfbd9d1aa624 100644 --- a/Library/Homebrew/cmd/reinstall.rb +++ b/Library/Homebrew/cmd/reinstall.rb @@ -1,67 +1,341 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula_installer" require "development_tools" require "messages" +require "install" require "reinstall" -require "cli/parser" require "cleanup" +require "cask/utils" +require "cask/reinstall" +require "upgrade" +require "api" +require "trust" module Homebrew - module_function - - def reinstall_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `reinstall` [] - - Uninstall and then install using the same options it was originally - installed with, plus any appended brew formula options. - - Unless `HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for the - reinstalled formulae or, every 30 days, for all formulae. - EOS - switch :debug, - description: "If brewing fails, open an interactive debugging session with access to IRB "\ - "or a shell inside the temporary build directory." - switch "-s", "--build-from-source", - description: "Compile from source even if a bottle is available." - switch "--force-bottle", - description: "Install from a bottle if it exists for the current or newest version of "\ - "macOS, even if it would not normally be used for installation." - switch "--keep-tmp", - description: "Retain the temporary files created during installation." - switch :force, - description: "Install without checking for previously installed keg-only or "\ - "non-migrated versions." - switch :verbose, - description: "Print the verification and postinstall steps." - switch "--display-times", - env: :display_install_times, - description: "Print install times for each formula at the end of the run." - conflicts "--build-from-source", "--force-bottle" - formula_options - end - end + module Cmd + class Reinstall < AbstractCommand + cmd_args do + description <<~EOS + Uninstall and then reinstall a or using the same options it was + originally installed with, plus any appended options specific to a . + + Unless `$HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK` is set, `brew upgrade` or `brew reinstall` will be run for + outdated dependents and dependents with broken linkage, respectively. + + Unless `$HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for the + reinstalled formulae or, every 30 days, for all formulae. + EOS + switch "-d", "--debug", + description: "If brewing fails, open an interactive debugging session with access to IRB " \ + "or a shell inside the temporary build directory." + switch "--display-times", + description: "Print install times for each package at the end of the run.", + env: :display_install_times + switch "-f", "--force", + description: "Install without checking for previously installed keg-only or " \ + "non-migrated versions." + switch "-v", "--verbose", + description: "Print the verification and post-install steps." + switch "--no-ask", "--yes", "-y", + description: "Do not ask for confirmation before downloading and reinstalling. " \ + "Ask mode is the default.", + env: :no_ask + switch "--ask", + description: "Ask for confirmation before downloading and reinstalling. " \ + "Print what would be reinstalled before prompting. Only prompts if the plan " \ + "includes dependencies or dependants; if the requested formulae or casks are the " \ + "only things to reinstall, it only prints the plan. The confirmation prompt is " \ + "skipped without a TTY. This is the default unless `$HOMEBREW_NO_ASK` is set.", + env: :ask, + replacement: "the default behaviour", + odeprecated: true + [ + [:switch, "--formula", "--formulae", { + description: "Treat all named arguments as formulae.", + }], + [:switch, "-s", "--build-from-source", { + description: "Compile from source even if a bottle is available.", + }], + [:switch, "-i", "--interactive", { + description: "Download and patch , then open a shell. This allows the user to " \ + "run `./configure --help` and otherwise determine how to turn the software " \ + "package into a Homebrew package.", + }], + [:switch, "--force-bottle", { + description: "Install from a bottle if it exists for the current or newest version of " \ + "macOS, even if it would not normally be used for installation.", + }], + [:switch, "--keep-tmp", { + description: "Retain the temporary files created during installation.", + }], + [:switch, "--debug-symbols", { + depends_on: "--build-from-source", + description: "Generate debug symbols on build. Source will be retained in a cache directory.", + }], + [:switch, "-g", "--git", { + description: "Create a Git repository, useful for creating patches to the software.", + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--cask", args.last + end + formula_options + [ + [:switch, "--cask", "--casks", { + description: "Treat all named arguments as casks.", + }], + [:switch, "--[no-]binaries", { + description: "Disable/enable linking of helper executables (default: enabled).", + env: :cask_opts_binaries, + }], + [:switch, "--require-sha", { + description: "Require all casks to have a checksum.", + env: :cask_opts_require_sha, + }], + [:switch, "--adopt", { + description: "Adopt existing artifacts in the destination that are identical to those being installed. " \ + "Cannot be combined with `--force`.", + }], + [:switch, "--skip-cask-deps", { + description: "Skip installing cask dependencies.", + }], + [:switch, "--zap", { + description: "For use with `brew reinstall --cask`. Remove all files associated with a cask. " \ + "*May remove files which are shared between applications.*", + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--formula", args.last + end + cask_options + + conflicts "--build-from-source", "--force-bottle" + conflicts "--ask", "--no-ask" + + named_args [:formula, :cask], min: 1 + end + + sig { override.void } + def run + formulae = T.let([], T::Array[Formula]) + casks = T.let([], T::Array[Cask::Cask]) + unavailable_errors = T.let( + [], + T::Array[T.any(FormulaOrCaskUnavailableError, NoSuchKegError)], + ) + Homebrew::Trust.trust_fully_qualified_items!(args.named, type: args.only_formula_or_cask) + ask = !args.no_ask? + + args.named.to_formulae_and_casks_and_unavailable(method: :resolve).each do |item| + case item + when FormulaOrCaskUnavailableError, NoSuchKegError + unavailable_errors << item + when Formula + formulae << item + when Cask::Cask + casks << item + end + end + + if args.build_from_source? + unless DevelopmentTools.installed? + raise BuildFlagsError.new(["--build-from-source"], bottled: formulae.all?(&:bottled?)) + end + + unless Homebrew::EnvConfig.developer? + opoo "building from source is not supported!" + puts "You're on your own. Failures are expected so don't create any issues, please!" + end + end + + if Homebrew::EnvConfig.verify_attestations? + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) + end + casks = casks.filter_map do |cask| + if cask.pinned? + onoe "#{cask.full_name} is pinned. You must unpin it to reinstall." + next + end + cask + end + shared_download_queue = T.let(nil, T.nilable(Homebrew::DownloadQueue)) + casks_prefetched = T.let(false, T::Boolean) + + Install.ask_casks casks, action: "reinstallation", skip_cask_deps: args.skip_cask_deps? if ask + + unless formulae.empty? + Install.perform_preinstall_checks_once + + reinstall_contexts = formulae.filter_map do |formula| + if formula.pinned? + onoe "#{formula.full_name} is pinned. You must unpin it to reinstall." + next + end + Migrator.migrate_if_needed(formula, force: args.force?) + Homebrew::Reinstall.build_install_context( + formula.latest_formula, + flags: args.flags_only, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + git: args.git?, + ) + end + + formulae_installers = reinstall_contexts.map(&:formula_installer) + if !ask && formulae_installers.any? + download_queue = Homebrew::DownloadQueue.new(pour: true) + shared_download_queue = download_queue + formulae_installers = Install.prelude_fetch_formulae(formulae_installers, download_queue:) + end + + dependants = begin + Upgrade.dependants( + formulae, + flags: args.flags_only, + ask: ask, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) + # Ensure the early download queue is shut down on interrupts. + rescue Exception # rubocop:disable Lint/RescueException + shared_download_queue&.shutdown + raise + end + + # Main block: if asking the user is enabled, show dry-run information. + if ask + Install.ask_formulae( + formulae_installers, + dependants, + action: "reinstallation", + flags: args.flags_only, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) + end + + valid_formula_installers = if casks.any? + shared_download_queue ||= Homebrew::DownloadQueue.new(pour: true) + download_queue = shared_download_queue + begin + Install.show_combined_fetch_downloads_heading( + formula_names: formulae_installers.map { |fi| fi.formula.name }, + cask_names: casks.map(&:full_name), + ) + + valid_formula_installers = Install.enqueue_formulae(formulae_installers, + download_queue:) + + require "cask/installer" + fetch_cask_installers = casks.map do |cask| + Cask::Installer.new( + cask, + binaries: args.binaries?, + verbose: args.verbose?, + force: args.force?, + skip_cask_deps: args.skip_cask_deps?, + require_sha: args.require_sha?, + reinstall: true, + zap: args.zap?, + download_queue:, + defer_fetch: true, + ) + end + Install.enqueue_cask_installers(fetch_cask_installers, download_queue:) + download_queue.fetch + casks_prefetched = true + valid_formula_installers + ensure + download_queue.shutdown + end + elsif shared_download_queue + download_queue = shared_download_queue + begin + Install.fetch_formulae(formulae_installers, + download_queue:, + shutdown_download_queue: false) + ensure + download_queue.shutdown + end + else + Install.fetch_formulae(formulae_installers) + end + + exit 1 if Homebrew.failed? + + reinstall_contexts.each do |reinstall_context| + next unless valid_formula_installers.include?(reinstall_context.formula_installer) + + Homebrew::Reinstall.reinstall_formula(reinstall_context) + Cleanup.install_formula_clean!(reinstall_context.formula) + end - def reinstall - reinstall_args.parse + Upgrade.upgrade_dependents( + dependants, formulae, + flags: args.flags_only, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose? + ) + end - raise FormulaUnspecifiedError if args.remaining.empty? + if casks.any? + begin + Cask::Reinstall.reinstall_casks( + *casks, + binaries: args.binaries?, + verbose: args.verbose?, + force: args.force?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + quarantine: true, + zap: args.zap?, + skip_prefetch: casks_prefetched, + download_queue: nil, + ) + rescue => e + ofail e + end + end - FormulaInstaller.prevent_build_flags unless DevelopmentTools.installed? + unavailable_errors.each { |e| ofail e } - Install.perform_preinstall_checks + Cleanup.periodic_clean! - Homebrew.args.resolved_formulae.each do |f| - if f.pinned? - onoe "#{f.full_name} is pinned. You must unpin it to reinstall." - next + Homebrew.messages.display_messages(display_times: args.display_times?) end - Migrator.migrate_if_needed(f) - reinstall_formula(f) - Cleanup.install_formula_clean!(f) end - Homebrew.messages.display_messages end end diff --git a/Library/Homebrew/cmd/sandbox-exec.rb b/Library/Homebrew/cmd/sandbox-exec.rb new file mode 100644 index 0000000000000..2c856cdbfad1e --- /dev/null +++ b/Library/Homebrew/cmd/sandbox-exec.rb @@ -0,0 +1,39 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "sandbox" + +module Homebrew + module Cmd + class SandboxExec < AbstractCommand + cmd_args do + usage_banner <<~EOS + `sandbox-exec` [`--deny-network`] [`--`] [ ...] + + Run in Homebrew's sandbox, allowing writes to and + Homebrew's temporary and cache directories. + + Example: `brew sandbox-exec . -- make test` + EOS + + switch "--deny-network", + description: "Deny network access from inside the sandbox." + + named_args min: 2 + end + + sig { override.void } + def run + writable_path = args.named.first + raise UsageError, "`sandbox-exec` requires a writable path." unless writable_path + + Sandbox.run_command( + *args.named.drop(1), + writable_path:, + deny_network: args.deny_network?, + ) + end + end + end +end diff --git a/Library/Homebrew/cmd/search.rb b/Library/Homebrew/cmd/search.rb index fee4468a2d9d7..07f72aa6dc433 100644 --- a/Library/Homebrew/cmd/search.rb +++ b/Library/Homebrew/cmd/search.rb @@ -1,134 +1,174 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "missing_formula" -require "descriptions" -require "cli/parser" require "search" module Homebrew - module_function - - extend Search - - PACKAGE_MANAGERS = { - macports: ->(query) { "https://www.macports.org/ports.php?by=name&substr=#{query}" }, - fink: ->(query) { "http://pdb.finkproject.org/pdb/browse.php?summary=#{query}" }, - opensuse: ->(query) { "https://software.opensuse.org/search?q=#{query}" }, - fedora: ->(query) { "https://apps.fedoraproject.org/packages/s/#{query}" }, - debian: lambda { |query| - "https://packages.debian.org/search?keywords=#{query}&searchon=names&suite=all§ion=all" - }, - ubuntu: lambda { |query| - "https://packages.ubuntu.com/search?keywords=#{query}&searchon=names&suite=all§ion=all" - }, - }.freeze - - def search_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `search` [] [|`/``/`] - - Perform a substring search of cask tokens and formula names for . If - is flanked by slashes, it is interpreted as a regular expression. - The search for is extended online to `homebrew/core` and `homebrew/cask`. - - If no is provided, list all locally available formulae (including tapped ones). - No online search is performed. - EOS - switch "--casks", - description: "List all locally available casks (including tapped ones). "\ - "No online search is performed." - switch "--desc", - description: "Search for formulae with a description matching and casks with "\ - "a name matching ." - - package_manager_switches = PACKAGE_MANAGERS.keys.map { |name| "--#{name}" } - package_manager_switches.each do |s| - switch s, - description: "Search for in the given package manager's list." + module Cmd + class SearchCmd < AbstractCommand + PACKAGE_MANAGERS = T.let({ + alpine: ->(query) { "https://pkgs.alpinelinux.org/packages?name=#{query}" }, + repology: ->(query) { "https://repology.org/projects/?search=#{query}" }, + macports: ->(query) { "https://ports.macports.org/search/?q=#{query}" }, + fink: ->(query) { "https://pdb.finkproject.org/pdb/browse.php?summary=#{query}" }, + opensuse: ->(query) { "https://software.opensuse.org/search?q=#{query}" }, + fedora: ->(query) { "https://packages.fedoraproject.org/search?query=#{query}" }, + archlinux: ->(query) { "https://archlinux.org/packages/?q=#{query}" }, + debian: lambda { |query| + "https://packages.debian.org/search?keywords=#{query}&searchon=names&suite=all§ion=all" + }, + ubuntu: lambda { |query| + "https://packages.ubuntu.com/search?keywords=#{query}&searchon=names&suite=all§ion=all" + }, + }.freeze, T::Hash[Symbol, T.proc.params(query: String).returns(String)]) + + cmd_args do + description <<~EOS + Perform a substring search of cask tokens and formula names for . If + is flanked by slashes, it is interpreted as a regular expression. + EOS + switch "--formula", "--formulae", + description: "Search for formulae." + switch "--cask", "--casks", + description: "Search for casks." + switch "--desc", + description: "Search for formulae with a description matching and casks with " \ + "a name or description matching ." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to search their " \ + "descriptions.", + env: :eval_all, + odeprecated: true + switch "--pull-request", + description: "Search for GitHub pull requests containing ." + switch "--open", + depends_on: "--pull-request", + description: "Search for only open GitHub pull requests." + switch "--closed", + depends_on: "--pull-request", + description: "Search for only closed GitHub pull requests." + package_manager_switches = PACKAGE_MANAGERS.keys.map { |name| "--#{name}" } + package_manager_switches.each do |s| + switch s, + description: "Search for in the given database." + end + + conflicts "--desc", "--pull-request" + conflicts "--open", "--closed" + conflicts(*package_manager_switches) + + named_args :text_or_regex, min: 1 end - switch :verbose - switch :debug - conflicts(*package_manager_switches) - end - end - def search - search_args.parse + sig { override.void } + def run + return if search_package_manager! - if package_manager = PACKAGE_MANAGERS.find { |name,| args[:"#{name}?"] } - _, url = package_manager - exec_browser url.call(URI.encode_www_form_component(args.remaining.join(" "))) - return - end + query = args.named.join(" ") + string_or_regex = Search.query_regexp(query) - if args.remaining.empty? - if args.casks? - puts Formatter.columns(Cask::Cask.to_a.map(&:full_name).sort) - else - puts Formatter.columns(Formula.full_names.sort) + if args.desc? + if !args.eval_all? && !Homebrew::EnvConfig.tap_trust_configured? && Homebrew::EnvConfig.no_install_from_api? + raise UsageError, + "`brew search --desc` needs `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` set!" + end + + Search.search_descriptions(string_or_regex, args) + elsif args.pull_request? + search_pull_requests(query) + else + formulae, casks = Search.search_names(string_or_regex, args) + print_results(formulae, casks, query) + end + + puts "Use `brew desc` to list packages with a short description." if args.verbose? + + print_regex_help end - return - end + private - query = args.remaining.join(" ") - string_or_regex = query_regexp(query) + sig { void } + def print_regex_help + return unless $stdout.tty? - if args.desc? - search_descriptions(string_or_regex) - else - remote_results = search_taps(query, silent: true) + metacharacters = %w[\\ | ( ) [ ] { } ^ $ * + ?].freeze + return unless metacharacters.any? do |char| + args.named.any? do |arg| + arg.include?(char) && !arg.start_with?("/") + end + end - local_formulae = search_formulae(string_or_regex) - remote_formulae = remote_results[:formulae] - all_formulae = local_formulae + remote_formulae + opoo <<~EOS + Did you mean to perform a regular expression search? + Surround your query with /slashes/ to search locally by regex. + EOS + end - local_casks = search_casks(string_or_regex) - remote_casks = remote_results[:casks] - all_casks = local_casks + remote_casks + sig { returns(T::Boolean) } + def search_package_manager! + package_manager = PACKAGE_MANAGERS.find { |name,| args.public_send(:"#{name}?") } + return false if package_manager.nil? - if all_formulae.any? - ohai "Formulae" - puts Formatter.columns(all_formulae) + _, url = package_manager + exec_browser url.call(URI.encode_www_form_component(args.named.join(" "))) + true end - if all_casks.any? - puts if all_formulae.any? - ohai "Casks" - puts Formatter.columns(all_casks) + sig { params(query: String).void } + def search_pull_requests(query) + only = if args.open? && !args.closed? + "open" + elsif args.closed? && !args.open? + "closed" + end + + GitHub.print_pull_requests_matching(query, only) end - if $stdout.tty? - count = all_formulae.count + all_casks.count + sig { params(all_formulae: T::Array[String], all_casks: T::Array[String], query: String).void } + def print_results(all_formulae, all_casks, query) + count = all_formulae.size + all_casks.size - if (reason = MissingFormula.reason(query, silent: true)) && !local_casks.include?(query) - if count.positive? - puts - puts "If you meant #{query.inspect} specifically:" + if all_formulae.any? + if $stdout.tty? + ohai "Formulae", Formatter.columns(all_formulae) + else + puts all_formulae + end + end + puts if all_formulae.any? && all_casks.any? + if all_casks.any? + if $stdout.tty? + ohai "Casks", Formatter.columns(all_casks) + else + puts all_casks end - puts reason - elsif count.zero? - puts "No formula or cask found for #{query.inspect}." - GitHub.print_pull_requests_matching(query) end + + print_missing_formula_help(query, count.positive?) if all_casks.exclude?(query) + + odie "No formulae or casks found for #{query.inspect}." if count.zero? end - end - return unless $stdout.tty? - return if args.remaining.empty? + sig { params(query: String, found_matches: T::Boolean).void } + def print_missing_formula_help(query, found_matches) + return unless $stdout.tty? + return if query.match?(Search::QUERY_REGEX) - metacharacters = %w[\\ | ( ) [ ] { } ^ $ * + ?].freeze - return unless metacharacters.any? do |char| - args.remaining.any? do |arg| - arg.include?(char) && !arg.start_with?("/") + reason = MissingFormula.reason(query, silent: true) + return if reason.nil? + + if found_matches + puts + puts "If you meant #{query.inspect} specifically:" + end + puts reason end end - - opoo <<~EOS - Did you mean to perform a regular expression search? - Surround your query with /slashes/ to search locally by regex. - EOS end end diff --git a/Library/Homebrew/cmd/services.rb b/Library/Homebrew/cmd/services.rb new file mode 100644 index 0000000000000..a9ab1996b6f6e --- /dev/null +++ b/Library/Homebrew/cmd/services.rb @@ -0,0 +1,36 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" + +module Homebrew + module Cmd + class Services < AbstractCommand + require "services/subcommand" + + cmd_args do + usage_banner <<~EOS + `services` [] + + Manage background services with macOS' `launchctl`(1) daemon manager or + Linux's `systemctl`(1) service manager. + + If `sudo` is passed, operate on `/Library/LaunchDaemons` or `/usr/lib/systemd/system` (started at boot). + Otherwise, operate on `~/Library/LaunchAgents` or `~/.config/systemd/user` (started at login). + EOS + flag "--sudo-service-user=", + description: "When run as root on macOS, run the service(s) as this user." + + Homebrew::AbstractSubcommand.define_all(self, command: Homebrew::Cmd::Services) + + conflicts "--all", "--file" + conflicts "--max-wait", "--no-wait" + end + + sig { override.void } + def run + Homebrew::Cmd::Services.dispatch(args) + end + end + end +end diff --git a/Library/Homebrew/cmd/setup-ruby.rb b/Library/Homebrew/cmd/setup-ruby.rb new file mode 100644 index 0000000000000..161d3e22ff0a4 --- /dev/null +++ b/Library/Homebrew/cmd/setup-ruby.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class SetupRuby < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Installs and configures Homebrew's Ruby. If `command` is passed, it will only run Bundler if necessary for that command. + EOS + + named_args :command + end + end + end +end diff --git a/Library/Homebrew/cmd/setup-ruby.sh b/Library/Homebrew/cmd/setup-ruby.sh new file mode 100644 index 0000000000000..ae8b740aab76c --- /dev/null +++ b/Library/Homebrew/cmd/setup-ruby.sh @@ -0,0 +1,38 @@ +# Documentation defined in Library/Homebrew/cmd/setup-ruby.rb + +# HOMEBREW_LIBRARY is set by brew.sh +# HOMEBREW_BREW_FILE is set by extend/ENV/super.rb +# shellcheck disable=SC2154 +homebrew-setup-ruby() { + source "${HOMEBREW_LIBRARY}/Homebrew/utils.sh" + source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" + setup-ruby-path + + if [[ -z "${HOMEBREW_DEVELOPER}" ]] + then + return + fi + + # Avoid running Bundler if the command doesn't need it. + local command="$1" + if [[ -n "${command}" ]] + then + source "${HOMEBREW_LIBRARY}/Homebrew/command_path.sh" + + command_path="$(homebrew-command-path "${command}")" + if [[ -n "${command_path}" ]] + then + if [[ "${command_path}" != *"/dev-cmd/"* ]] + then + return + elif ! grep -q "Homebrew.install_bundler_gems\!" "${command_path}" + then + return + fi + fi + fi + + setup-gem-home-bundle-gemfile + + ensure-bundle-dependencies +} diff --git a/Library/Homebrew/cmd/setup-sandbox.rb b/Library/Homebrew/cmd/setup-sandbox.rb new file mode 100644 index 0000000000000..ace0d09277fab --- /dev/null +++ b/Library/Homebrew/cmd/setup-sandbox.rb @@ -0,0 +1,20 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class SetupSandbox < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Run any necessary commands to setup the Homebrew sandbox. + Must be run with `sudo`. Currently a no-op on non-Linux. + EOS + end + end + end +end diff --git a/Library/Homebrew/cmd/setup-sandbox.sh b/Library/Homebrew/cmd/setup-sandbox.sh new file mode 100644 index 0000000000000..87c764528b24d --- /dev/null +++ b/Library/Homebrew/cmd/setup-sandbox.sh @@ -0,0 +1,53 @@ +# Documentation defined in Library/Homebrew/cmd/setup-sandbox.rb + +# This Bubblewrap installation mirrors the package manager approaches in +# https://github.com/Homebrew/install and the Homebrew formula fallback in +# `ensure_sandbox_installed!` in Library/Homebrew/extend/os/linux/sandbox.rb. + +# `sudo` strips `GITHUB_ACTIONS`, so also detect the runner via `/proc/1/cgroup` +# like `check-run-command-as-root` in Library/Homebrew/brew.sh does. +homebrew-on-github-actions() { + [[ -n "${GITHUB_ACTIONS}" ]] && return 0 + grep -q "actions_job" /proc/1/cgroup &>/dev/null +} + +homebrew-setup-sandbox() { + # The sandbox sysctls and Bubblewrap are Linux-only. + [[ -z "${HOMEBREW_LINUX}" ]] && return 0 + + if homebrew-on-github-actions && ! command -v bwrap &>/dev/null + then + if command -v apt-get &>/dev/null + then + apt-get install --yes bubblewrap + elif command -v dnf &>/dev/null + then + dnf install --assumeyes bubblewrap + elif command -v yum &>/dev/null + then + yum install --assumeyes bubblewrap + elif command -v pacman &>/dev/null + then + pacman --sync --noconfirm bubblewrap + elif command -v apk &>/dev/null + then + apk add bubblewrap + fi + fi + + # These settings mirror SANDBOX_SYSCTL_SETTINGS in + # Library/Homebrew/extend/os/linux/sandbox.rb; keep both in sync. + if [[ $(sysctl -n "kernel.unprivileged_userns_clone" || echo 0) != "1" ]] + then + sysctl -w kernel.unprivileged_userns_clone=1 + fi + if [[ $(sysctl -n "user.max_user_namespaces" || echo 0) -lt 28633 ]] + then + sysctl -w user.max_user_namespaces=28633 + fi + + if [[ $(sysctl -n "kernel.apparmor_restrict_unprivileged_userns" || echo 0) != "0" ]] + then + sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + fi +} diff --git a/Library/Homebrew/cmd/sh.rb b/Library/Homebrew/cmd/sh.rb deleted file mode 100644 index 00f0860965791..0000000000000 --- a/Library/Homebrew/cmd/sh.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "extend/ENV" -require "formula" -require "cli/parser" - -module Homebrew - module_function - - def sh_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `sh` [] - - Start a Homebrew build environment shell. Uses our years-battle-hardened - Homebrew build logic to help your `./configure && make && make install` - or even your `gem install` succeed. Especially handy if you run Homebrew - in an Xcode-only configuration since it adds tools like `make` to your `PATH` - which build systems would not find otherwise. - EOS - flag "--env=", - description: "Use the standard `PATH` instead of superenv's when `std` is passed." - switch :verbose - switch :debug - max_named 0 - end - end - - def sh - sh_args.parse - - ENV.activate_extensions! - - if superenv? - ENV.set_x11_env_if_installed - ENV.deps = Formula.installed.select { |f| f.keg_only? && f.opt_prefix.directory? } - end - ENV.setup_build_environment - if superenv? - # superenv stopped adding brew's bin but generally users will want it - ENV["PATH"] = PATH.new(ENV["PATH"]).insert(1, HOMEBREW_PREFIX/"bin") - end - if ENV["SHELL"].include?("zsh") - ENV["PS1"] = "brew %B%F{green}~%f%b$ " - else - ENV["PS1"] = 'brew \[\033[1;32m\]\w\[\033[0m\]$ ' - end - ENV["VERBOSE"] = "1" - puts <<~EOS - Your shell has been configured to use Homebrew's build environment; - this should help you build stuff. Notably though, the system versions of - gem and pip will ignore our configuration and insist on using the - environment they were built under (mostly). Sadly, scons will also - ignore our configuration. - When done, type `exit`. - EOS - $stdout.flush - safe_system ENV["SHELL"] - end -end diff --git a/Library/Homebrew/cmd/shellenv.rb b/Library/Homebrew/cmd/shellenv.rb new file mode 100644 index 0000000000000..25bb61aef386a --- /dev/null +++ b/Library/Homebrew/cmd/shellenv.rb @@ -0,0 +1,35 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Shellenv < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Valid shells: bash|csh|fish|pwsh|sh|tcsh|zsh + + Print export statements. When run in a shell, this installation of Homebrew will be added to your + `$PATH`, `$MANPATH`, and `$INFOPATH`. + + The variables `$HOMEBREW_PREFIX`, `$HOMEBREW_CELLAR` and `$HOMEBREW_REPOSITORY` are also exported to avoid + querying them multiple times. + To help guarantee idempotence, this command produces no output when Homebrew's `bin` and `sbin` directories + are first and second respectively in your `$PATH`. Consider adding evaluation of this command's output to + your dotfiles (e.g. `~/.bash_profile` or ~/.zprofile` on macOS and ~/.bashrc` or ~/.zshrc` on Linux) + with e.g.: + `eval "$(brew shellenv zsh)"` or `eval "$(brew shellenv bash)"` + + The shell should be specified explicitly with a supported shell name parameter but will be detected + automatically if not provided (but this may not be correct). Unknown shells will output POSIX exports. + EOS + + named_args :shell + end + end + end +end diff --git a/Library/Homebrew/cmd/shellenv.sh b/Library/Homebrew/cmd/shellenv.sh index a4b043d574113..e727a5c00e22c 100644 --- a/Library/Homebrew/cmd/shellenv.sh +++ b/Library/Homebrew/cmd/shellenv.sh @@ -1,35 +1,96 @@ -#: * `shellenv` -#: -#: Print export statements. When run in a shell, this installation of Homebrew will be added to your `PATH`, `MANPATH`, and `INFOPATH`. -#: -#: The variables `HOMEBREW_PREFIX`, `HOMEBREW_CELLAR` and `HOMEBREW_REPOSITORY` are also exported to avoid querying them multiple times. -#: Consider adding evaluation of this command's output to your dotfiles (e.g. `~/.profile` or `~/.zprofile`) with: `eval $(brew shellenv)` +# Documentation defined in Library/Homebrew/cmd/shellenv.rb +# HOMEBREW_CELLAR and HOMEBREW_PREFIX are set by extend/ENV/super.rb +# HOMEBREW_REPOSITORY is set by bin/brew +# Leading colon in MANPATH prepends default man dirs to search path in Linux and macOS. +# Please do not submit PRs to remove it! +# shellcheck disable=SC2154 homebrew-shellenv() { - case "$SHELL" in - */fish) - echo "set -gx HOMEBREW_PREFIX \"$HOMEBREW_PREFIX\";" - echo "set -gx HOMEBREW_CELLAR \"$HOMEBREW_CELLAR\";" - echo "set -gx HOMEBREW_REPOSITORY \"$HOMEBREW_REPOSITORY\";" - echo "set -g fish_user_paths \"$HOMEBREW_PREFIX/bin\" \"$HOMEBREW_PREFIX/sbin\" \$fish_user_paths;" - echo "set -q MANPATH; or set MANPATH ''; set -gx MANPATH \"$HOMEBREW_PREFIX/share/man\" \$MANPATH;" - echo "set -q INFOPATH; or set INFOPATH ''; set -gx INFOPATH \"$HOMEBREW_PREFIX/share/info\" \$INFOPATH;" + if [[ "${HOMEBREW_PATH%%:"${HOMEBREW_PREFIX}"/sbin*}" == "${HOMEBREW_PREFIX}/bin" ]] + then + return + fi + + # Use specified shell name parameter, if available. + HOMEBREW_SHELL_NAME="${1:-}" + + # Use the parent process name, if possible. + # This is known to fail under some sandboxes. + if [[ -z "${HOMEBREW_SHELL_NAME}" ]] + then + HOMEBREW_SHELL_NAME="$(/bin/ps -p "${PPID}" -c -o comm= 2>/dev/null)" + fi + + # Fall back to the (login) shell name from the environment. + if [[ -z "${HOMEBREW_SHELL_NAME}" ]] + then + HOMEBREW_SHELL_NAME="${SHELL##*/}" + fi + + if [[ -n "${HOMEBREW_MACOS}" ]] && + [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "140000" ]] && + [[ -x /usr/libexec/path_helper ]] + then + HOMEBREW_PATHS_FILE="${HOMEBREW_PREFIX}/etc/paths" + + if [[ ! -f "${HOMEBREW_PATHS_FILE}" ]] + then + printf '%s/bin\n%s/sbin\n' "${HOMEBREW_PREFIX}" "${HOMEBREW_PREFIX}" 2>/dev/null >"${HOMEBREW_PATHS_FILE}" + fi + + if [[ -r "${HOMEBREW_PATHS_FILE}" ]] + then + PATH_HELPER_ROOT="${HOMEBREW_PREFIX}" + fi + fi + + case "${HOMEBREW_SHELL_NAME}" in + fish | -fish) + echo "set --global --export HOMEBREW_PREFIX \"${HOMEBREW_PREFIX}\";" + echo "set --global --export HOMEBREW_CELLAR \"${HOMEBREW_CELLAR}\";" + echo "set --global --export HOMEBREW_REPOSITORY \"${HOMEBREW_REPOSITORY}\";" + echo "fish_add_path --global --move --path \"${HOMEBREW_PREFIX}/bin\" \"${HOMEBREW_PREFIX}/sbin\";" + echo "if test -n \"\$MANPATH[1]\"; set --global --export MANPATH '' \$MANPATH; end;" + echo "if not contains \"${HOMEBREW_PREFIX}/share/info\" \$INFOPATH; set --global --export INFOPATH \"${HOMEBREW_PREFIX}/share/info\" \$INFOPATH; end;" + ;; + csh | -csh | tcsh | -tcsh) + echo "setenv HOMEBREW_PREFIX ${HOMEBREW_PREFIX};" + echo "setenv HOMEBREW_CELLAR ${HOMEBREW_CELLAR};" + echo "setenv HOMEBREW_REPOSITORY ${HOMEBREW_REPOSITORY};" + if [[ -n "${PATH_HELPER_ROOT}" ]] + then + echo "eval \`/usr/bin/env PATH_HELPER_ROOT=\"${PATH_HELPER_ROOT}\" /usr/libexec/path_helper -c\`;" + else + echo "setenv PATH ${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:\$PATH;" + fi + echo "test \${?MANPATH} -eq 1 && setenv MANPATH :\${MANPATH};" + echo "setenv INFOPATH ${HOMEBREW_PREFIX}/share/info\`test \${?INFOPATH} -eq 1 && echo :\${INFOPATH}\`;" ;; - */csh|*/tcsh) - echo "setenv HOMEBREW_PREFIX $HOMEBREW_PREFIX;" - echo "setenv HOMEBREW_CELLAR $HOMEBREW_CELLAR;" - echo "setenv HOMEBREW_REPOSITORY $HOMEBREW_REPOSITORY;" - echo "setenv PATH $HOMEBREW_PREFIX/bin:$HOMEBREW_PREFIX/sbin:\$PATH;" - echo "setenv MANPATH $HOMEBREW_PREFIX/share/man:\$MANPATH;" - echo "setenv INFOPATH $HOMEBREW_PREFIX/share/info:\$INFOPATH;" + pwsh | -pwsh | pwsh-preview | -pwsh-preview) + echo "[System.Environment]::SetEnvironmentVariable('HOMEBREW_PREFIX','${HOMEBREW_PREFIX}',[System.EnvironmentVariableTarget]::Process)" + echo "[System.Environment]::SetEnvironmentVariable('HOMEBREW_CELLAR','${HOMEBREW_CELLAR}',[System.EnvironmentVariableTarget]::Process)" + echo "[System.Environment]::SetEnvironmentVariable('HOMEBREW_REPOSITORY','${HOMEBREW_REPOSITORY}',[System.EnvironmentVariableTarget]::Process)" + echo "[System.Environment]::SetEnvironmentVariable('PATH',\$('${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:'+\$ENV:PATH),[System.EnvironmentVariableTarget]::Process)" + echo "[System.Environment]::SetEnvironmentVariable('MANPATH',\$('${HOMEBREW_PREFIX}/share/man'+\$(if(\${ENV:MANPATH}){':'+\${ENV:MANPATH}})+':'),[System.EnvironmentVariableTarget]::Process)" + echo "[System.Environment]::SetEnvironmentVariable('INFOPATH',\$('${HOMEBREW_PREFIX}/share/info'+\$(if(\${ENV:INFOPATH}){':'+\${ENV:INFOPATH}})),[System.EnvironmentVariableTarget]::Process)" ;; *) - echo "export HOMEBREW_PREFIX=\"$HOMEBREW_PREFIX\";" - echo "export HOMEBREW_CELLAR=\"$HOMEBREW_CELLAR\";" - echo "export HOMEBREW_REPOSITORY=\"$HOMEBREW_REPOSITORY\";" - echo "export PATH=\"$HOMEBREW_PREFIX/bin:$HOMEBREW_PREFIX/sbin\${PATH+:\$PATH}\";" - echo "export MANPATH=\"$HOMEBREW_PREFIX/share/man\${MANPATH+:\$MANPATH}:\";" - echo "export INFOPATH=\"$HOMEBREW_PREFIX/share/info\${INFOPATH+:\$INFOPATH}\";" + echo "export HOMEBREW_PREFIX=\"${HOMEBREW_PREFIX}\";" + echo "export HOMEBREW_CELLAR=\"${HOMEBREW_CELLAR}\";" + echo "export HOMEBREW_REPOSITORY=\"${HOMEBREW_REPOSITORY}\";" + if [[ "${HOMEBREW_SHELL_NAME}" == "zsh" ]] || [[ "${HOMEBREW_SHELL_NAME}" == "-zsh" ]] + then + echo "fpath[1,0]=\"${HOMEBREW_PREFIX}/share/zsh/site-functions\";" + echo "export FPATH;" + fi + if [[ -n "${PATH_HELPER_ROOT}" ]] + then + echo "eval \"\$(/usr/bin/env PATH_HELPER_ROOT=\"${PATH_HELPER_ROOT}\" /usr/libexec/path_helper -s)\"" + else + echo "export PATH=\"${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin\${PATH+:\$PATH}\";" + fi + echo "[ -z \"\${MANPATH-}\" ] || export MANPATH=\":\${MANPATH#:}\";" + echo "export INFOPATH=\"${HOMEBREW_PREFIX}/share/info:\${INFOPATH:-}\";" ;; esac } diff --git a/Library/Homebrew/cmd/source.rb b/Library/Homebrew/cmd/source.rb new file mode 100644 index 0000000000000..56da5ee6800bb --- /dev/null +++ b/Library/Homebrew/cmd/source.rb @@ -0,0 +1,187 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" +require "utils/curl" + +module Homebrew + module Cmd + class Source < AbstractCommand + cmd_args do + description <<~EOS + Open a 's source repository in a browser, or open + Homebrew's own repository if no argument is provided. + + The repository URL is determined from the formula's head URL, + stable URL, or homepage. Supports GitHub, GitLab, Bitbucket, Codeberg and + SourceHut repositories. + EOS + + named_args :formula + end + + sig { override.void } + def run + if args.no_named? + exec_browser "https://github.com/Homebrew/brew" + return + end + + formulae = args.named.to_formulae + repo_urls = formulae.filter_map do |formula| + repo_url = extract_repo_url(formula) + if repo_url + puts "Opening repository for #{formula.name}" + repo_url + else + opoo "Could not determine repository URL for #{formula.name}" + nil + end + end + + return if repo_urls.empty? + + exec_browser(*repo_urls) + end + + private + + sig { params(formula: Formula).returns(T.nilable(String)) } + def extract_repo_url(formula) + urls_to_check = [ + formula.head&.url, + formula.stable&.url, + formula.homepage, + ] + + urls_to_check.each do |url| + next if url.nil? + + repo_url = url_to_repo(url) + return repo_url if repo_url + end + + nil + end + + sig { params(url: String).returns(T.nilable(String)) } + def url_to_repo(url) + github_repo_url(url) || + gitlab_repo_url(url) || + bitbucket_repo_url(url) || + codeberg_repo_url(url) || + sourcehut_repo_url(url) || + pypi_repo_url(url) + end + + sig { params(url: String).returns(T.nilable(String)) } + def github_repo_url(url) + regex = %r{ + https?://github\.com/ + (?[\w.-]+)/ + (?[\w.-]+) + (?:/.*)? + }x + match = url.match(regex) + return unless match + + user = match[:user] + repo = match[:repo]&.delete_suffix(".git") + "https://github.com/#{user}/#{repo}" + end + + sig { params(url: String).returns(T.nilable(String)) } + def gitlab_repo_url(url) + regex = %r{ + https?://gitlab\.com/ + (?(?:[\w.-]+/)*?[\w.-]+) + (?:/-/|\.git|/archive/) + }x + match = url.match(regex) + return unless match + + path = match[:path]&.delete_suffix(".git") + "https://gitlab.com/#{path}" + end + + sig { params(url: String).returns(T.nilable(String)) } + def bitbucket_repo_url(url) + regex = %r{ + https?://bitbucket\.org/ + (?[\w.-]+)/ + (?[\w.-]+) + (?:/.*)? + }x + match = url.match(regex) + return unless match + + user = match[:user] + repo = match[:repo]&.delete_suffix(".git") + "https://bitbucket.org/#{user}/#{repo}" + end + + sig { params(url: String).returns(T.nilable(String)) } + def codeberg_repo_url(url) + regex = %r{ + https?://codeberg\.org/ + (?[\w.-]+)/ + (?[\w.-]+) + (?:/.*)? + }x + match = url.match(regex) + return unless match + + user = match[:user] + repo = match[:repo]&.delete_suffix(".git") + "https://codeberg.org/#{user}/#{repo}" + end + + sig { params(url: String).returns(T.nilable(String)) } + def sourcehut_repo_url(url) + regex = %r{ + https?://(?:git\.)?sr\.ht/ + ~(?[\w.-]+)/ + (?[\w.-]+) + (?:/.*)? + }x + match = url.match(regex) + return unless match + + user = match[:user] + repo = match[:repo]&.delete_suffix(".git") + "https://sr.ht/~#{user}/#{repo}" + end + + sig { params(url: String).returns(T.nilable(String)) } + def pypi_repo_url(url) + regex = %r{ + https?://files\.pythonhosted\.org + /packages + (?:/[^/]+)+ + /(?.+)- + .*? + (?:\.tar\.[a-z0-9]+|\.[a-z0-9]+) + }x + match = url.match(regex) + return unless match + + package_name = match[:package_name] + return unless package_name + + api_url = "https://pypi.org/pypi/#{package_name.gsub(/%20|_/, "-")}/json" + curl_args = Utils::Curl.curl_args(show_error: false, retries: 2) + stdout, _, status = Utils::Curl.curl_output(*curl_args, api_url) + + return unless status.success? + + project_urls = JSON.parse(stdout).dig("info", "project_urls")&.transform_keys(&:downcase) + + project_urls["repository"] || project_urls["source"] || + url_to_repo(project_urls.fetch("homepage", "")) # Homepages often link to source repositories + rescue JSON::ParserError + nil + end + end + end +end diff --git a/Library/Homebrew/cmd/style.rb b/Library/Homebrew/cmd/style.rb deleted file mode 100644 index 65f17ba504f53..0000000000000 --- a/Library/Homebrew/cmd/style.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require "json" -require "open3" -require "style" -require "cli/parser" - -module Homebrew - module_function - - def style_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `style` [] [||] - - Check formulae or files for conformance to Homebrew style guidelines. - - Lists of , and may not be combined. If none are - provided, `style` will run style checks on the whole Homebrew library, - including core code and all formulae. - EOS - switch "--fix", - description: "Fix style violations automatically using RuboCop's auto-correct feature." - switch "--display-cop-names", - description: "Include the RuboCop cop name for each violation in the output." - comma_array "--only-cops", - description: "Specify a comma-separated list to check for violations of only the "\ - "listed RuboCop cops." - comma_array "--except-cops", - description: "Specify a comma-separated list to skip checking for violations of the "\ - "listed RuboCop cops." - switch :verbose - switch :debug - conflicts "--only-cops", "--except-cops" - end - end - - def style - style_args.parse - - target = if Homebrew.args.named.blank? - nil - elsif Homebrew.args.named.any? { |file| File.exist? file } - Homebrew.args.named - elsif Homebrew.args.named.any? { |tap| tap.count("/") == 1 } - Homebrew.args.named.map { |tap| Tap.fetch(tap).path } - else - Homebrew.args.formulae.map(&:path) - end - - only_cops = args.only_cops - except_cops = args.except_cops - - options = { fix: args.fix? } - if only_cops - options[:only_cops] = only_cops - elsif except_cops - options[:except_cops] = except_cops - elsif only_cops.nil? && except_cops.nil? - options[:except_cops] = %w[FormulaAudit - FormulaAuditStrict - NewFormulaAudit] - end - - Homebrew.failed = !Style.check_style_and_print(target, options) - end -end diff --git a/Library/Homebrew/cmd/switch.rb b/Library/Homebrew/cmd/switch.rb deleted file mode 100644 index 9d458f58b0c4c..0000000000000 --- a/Library/Homebrew/cmd/switch.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require "formula" -require "keg" -require "cli/parser" - -module Homebrew - module_function - - def switch_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `switch` - - Symlink all of the specified of 's installation into Homebrew's prefix. - EOS - switch :verbose - switch :debug - max_named 2 - end - end - - def switch - switch_args.parse - - raise FormulaUnspecifiedError if args.remaining.empty? - - name = args.remaining.first - rack = Formulary.to_rack(name) - - odie "#{name} not found in the Cellar." unless rack.directory? - - versions = rack.subdirs - .map { |d| Keg.new(d).version } - .sort - .join(", ") - version = args.remaining.second - raise UsageError, "Specify one of #{name}'s installed versions: #{versions}" unless version - - odie <<~EOS unless (rack/version).directory? - #{name} does not have a version \"#{version}\" in the Cellar. - #{name}'s installed versions: #{versions} - EOS - - # Unlink all existing versions - rack.subdirs.each do |v| - keg = Keg.new(v) - puts "Cleaning #{keg}" - keg.unlink - end - - keg = Keg.new(rack/version) - - # Link new version, if not keg-only - if Formulary.keg_only?(rack) - keg.optlink - puts "Opt link created for #{keg}" - else - puts "#{keg.link} links created for #{keg}" - end - end -end diff --git a/Library/Homebrew/cmd/tab.rb b/Library/Homebrew/cmd/tab.rb new file mode 100644 index 0000000000000..c6bdd1c61cb60 --- /dev/null +++ b/Library/Homebrew/cmd/tab.rb @@ -0,0 +1,95 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" +require "tab" + +module Homebrew + module Cmd + class TabCmd < AbstractCommand + cmd_args do + description <<~EOS + Edit tab information for installed formulae or casks. + + This can be useful when you want to control whether an installed + formula should be removed by `brew autoremove`. + To prevent removal, mark the formula as installed on request; + to allow removal, mark the formula as not installed on request. + EOS + switch "--installed-on-request", + description: "Mark or as installed on request." + switch "--no-installed-on-request", + description: "Mark or as not installed on request." + switch "--formula", "--formulae", + description: "Only mark formulae." + switch "--cask", "--casks", + description: "Only mark casks." + + conflicts "--formula", "--cask" + conflicts "--installed-on-request", "--no-installed-on-request" + + named_args [:installed_formula, :installed_cask], min: 1 + end + + sig { override.void } + def run + installed_on_request = if args.installed_on_request? + true + elsif args.no_installed_on_request? + false + end + raise UsageError, "No marking option specified." if installed_on_request.nil? + + formulae, casks = T.cast(args.named.to_formulae_to_casks, [T::Array[Formula], T::Array[Cask::Cask]]) + packages = formulae + casks + not_installed = packages.reject(&:any_version_installed?) + if not_installed.any? + names = not_installed.map(&:to_s) + is_or_are = (names.length == 1) ? "is" : "are" + odie "#{names.to_sentence} #{is_or_are} not installed." + end + + packages.each do |formula_or_cask| + update_tab formula_or_cask, installed_on_request: + end + end + + private + + sig { params(formula_or_cask: T.any(Formula, Cask::Cask), installed_on_request: T::Boolean).void } + def update_tab(formula_or_cask, installed_on_request:) + name, tab, created_tab = if formula_or_cask.is_a?(Formula) + [formula_or_cask.name, Tab.for_formula(formula_or_cask), false] + else + cask = formula_or_cask + cask_tab = cask.tab + cask_tabfile = cask_tab.tabfile + if cask_tabfile&.exist? + [cask.token, cask_tab, false] + else + [cask.token, Cask::Tab.create(cask), true] + end + end + + tabfile = tab.tabfile + if !created_tab && !tabfile&.exist? + raise ArgumentError, + "Tab file for #{name} does not exist." + end + + installed_on_request_str = "#{"not " unless installed_on_request}installed on request" + if (tab.installed_on_request && installed_on_request) || + (!tab.installed_on_request && !installed_on_request) + tab.write if created_tab + ohai "#{name} is already marked as #{installed_on_request_str}." + return + end + + tab.installed_on_request = installed_on_request + tab.write + ohai "#{name} is now marked as #{installed_on_request_str}." + end + end + end +end diff --git a/Library/Homebrew/cmd/tap-info.rb b/Library/Homebrew/cmd/tap-info.rb index d71e273b8d1c6..9d4e0ed94b487 100644 --- a/Library/Homebrew/cmd/tap-info.rb +++ b/Library/Homebrew/cmd/tap-info.rb @@ -1,93 +1,197 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - module_function - - def tap_info_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `tap-info` [] [] - - Show detailed information about one or more s. - - If no names are provided, display brief statistics for all installed taps. - EOS - switch "--installed", - description: "Show information on each installed tap." - flag "--json", - description: "Print a JSON representation of . Currently the default and only accepted "\ - "value for is `v1`. See the docs for examples of using the JSON "\ - "output: " - switch :debug - end - end - - def tap_info - tap_info_args.parse + module Cmd + class TapInfo < AbstractCommand + cmd_args do + description <<~EOS + Show detailed information about one or more s. + If no names are provided, display brief statistics for all installed taps. + EOS + switch "--installed", + description: "Show information on each installed tap." + flag "--json", + description: "Print a JSON representation of . Currently the default and only accepted " \ + "value for is `v1`. See the docs for examples of using the JSON " \ + "output: " - if args.installed? - taps = Tap - else - taps = Homebrew.args.named.sort.map do |name| - Tap.fetch(name) + named_args :tap end - end - if args.json - raise UsageError, "Invalid JSON version: #{args.json}" unless ["v1", true].include? args.json + sig { override.void } + def run + require "tap" - print_tap_json(taps.sort_by(&:to_s)) - else - print_tap_info(taps.sort_by(&:to_s)) - end - end + taps = if args.installed? + Tap + else + args.named.to_taps + end - def print_tap_info(taps) - if taps.none? - tap_count = 0 - formula_count = 0 - command_count = 0 - pinned_count = 0 - private_count = 0 - Tap.each do |tap| - tap_count += 1 - formula_count += tap.formula_files.size - command_count += tap.command_files.size - pinned_count += 1 if tap.pinned? - private_count += 1 if tap.private? + if args.json + raise UsageError, "invalid JSON version: #{args.json}" unless ["v1", true].include? args.json + + print_tap_json(taps.sort_by(&:to_s)) + else + print_tap_info(taps.sort_by(&:to_s)) + end end - info = "#{tap_count} #{"tap".pluralize(tap_count)}" - info += ", #{pinned_count} pinned" - info += ", #{private_count} private" - info += ", #{formula_count} #{"formula".pluralize(formula_count)}" - info += ", #{command_count} #{"command".pluralize(command_count)}" - info += ", #{Tap::TAP_DIRECTORY.dup.abv}" if Tap::TAP_DIRECTORY.directory? - puts info - else - taps.each_with_index do |tap, i| - puts unless i.zero? - info = "#{tap}: " - if tap.installed? - info += tap.pinned? ? "pinned" : "unpinned" - info += ", private" if tap.private? - info += if (contents = tap.contents).empty? - ", no commands/casks/formulae" - else - ", #{contents.join(", ")}" + + private + + sig { params(taps: T::Array[Tap]).void } + def print_tap_info(taps) + if taps.none? + tap_count = 0 + formula_count = 0 + command_count = 0 + private_count = 0 + Tap.installed.each do |tap| + tap_count += 1 + formula_count += tap.formula_files.size + command_count += tap.command_files.size + private_count += 1 if tap.private? end - info += "\n#{tap.path} (#{tap.path.abv})" - info += "\nFrom: #{tap.remote.nil? ? "N/A" : tap.remote}" + info = Utils.pluralize("tap", tap_count, include_count: true) + info += ", #{private_count} private" + info += ", #{Utils.pluralize("formula", formula_count, include_count: true)}" + info += ", #{Utils.pluralize("command", command_count, include_count: true)}" + info += ", #{HOMEBREW_TAP_DIRECTORY.dup.abv}" if HOMEBREW_TAP_DIRECTORY.directory? + puts info else - info += "Not installed" + info = "" + default_branches = %w[main master].freeze + + taps.each_with_index do |tap, i| + puts unless i.zero? + info = "#{tap}: " + if tap.installed? + info += "Installed" + if Homebrew::EnvConfig.require_tap_trust? + require "trust" + info += "\n#{Homebrew::Trust.trusted_tap?(tap) ? "Trusted" : "Untrusted"}" + end + info += if (contents = tap.contents).blank? + "\nNo commands/casks/formulae" + else + "\n#{contents.join(", ")}" + end + info += "\nPrivate" if tap.private? + info += "\n#{tap.path} (#{tap.path.abv})" + info += "\nFrom: #{tap.remote.presence || "N/A"}" + info += "\norigin: #{tap.remote}" if tap.remote != tap.default_remote + info += "\nHEAD: #{tap.git_head || "(none)"}" + info += "\nlast commit: #{tap.git_last_commit || "never"}" + info += "\nbranch: #{tap.git_branch || "(none)"}" if default_branches.exclude?(tap.git_branch) + puts info + print_tap_listings(tap) + else + info += "Not installed" + Homebrew.failed = true + puts info + end + end end - puts info end - end - end - def print_tap_json(taps) - puts JSON.generate(taps.map(&:to_hash)) + LISTING_LIMIT = 30 + private_constant :LISTING_LIMIT + + sig { params(tap: Tap).void } + def print_tap_listings(tap) + commands = tap.command_files + .map { |path| path.basename(path.extname).to_s.delete_prefix("brew-") } + .sort + installed_formula_names = Formula.installed_formula_names.to_set + installed_cask_tokens = Cask::Caskroom.tokens.to_set + formula_names = tap.formula_names.map { |name| Utils.name_from_full_name(name) }.sort + cask_tokens = tap.cask_tokens.map { |token| Utils.name_from_full_name(token) }.sort + installed_formulae = formula_names.select { |name| installed_formula_names.include?(name) } + installed_casks = cask_tokens.select { |token| installed_cask_tokens.include?(token) } + + if commands.any? + ohai "Commands" + puts commands.join(", ") + end + + min_width = (formula_names + cask_tokens).map { |n| Tty.strip_ansi(pretty_uninstalled(n)).length }.max || 0 + print_section(tap, "Formulae", formula_names, installed_formulae, min_width:) do |name| + decorate_formula(tap, name, installed: installed_formula_names.include?(name)) + end + print_section(tap, "Casks", cask_tokens, installed_casks, min_width:) do |token| + decorate_cask(tap, token, installed: installed_cask_tokens.include?(token)) + end + end + + sig { + params( + tap: Tap, + label: String, + all: T::Array[String], + installed: T::Array[String], + min_width: Integer, + block: T.proc.params(name: String).returns(String), + ).void + } + def print_section(tap, label, all, installed, min_width:, &block) + return if all.none? + + if all.size <= LISTING_LIMIT + ohai label, Formatter.columns(all.map(&block), min_width:) + elsif installed.any? + ohai label + opoo "Tap has more than #{LISTING_LIMIT} #{label.downcase}; showing only installed entries." + puts Formatter.columns(installed.map(&block), min_width:) + else + ohai label + opoo "Tap has more than #{LISTING_LIMIT} #{label.downcase} and none are installed." + puts "See: #{tap.remote}" if tap.remote.present? + end + end + + sig { params(tap: Tap, name: String, installed: T::Boolean).returns(String) } + def decorate_formula(tap, name, installed:) + formula = Formulary.factory("#{tap.name}/#{name}") + pretty_install_status( + name, + installed:, + outdated: installed && formula.outdated?, + deprecated: formula.deprecated?, + disabled: formula.disabled?, + mark_uninstalled: false, + ) + rescue + pretty_install_status(name, installed:, mark_uninstalled: false) + end + + sig { params(tap: Tap, token: String, installed: T::Boolean).returns(String) } + def decorate_cask(tap, token, installed:) + cask = Cask::CaskLoader.load("#{tap.name}/#{token}") + pretty_install_status( + token, + installed:, + outdated: installed && cask.outdated?, + deprecated: cask.deprecated?, + disabled: cask.disabled?, + mark_uninstalled: false, + ) + rescue + pretty_install_status(token, installed:, mark_uninstalled: false) + end + + sig { params(taps: T::Array[Tap]).void } + def print_tap_json(taps) + # Tap#to_hash shells out to Git and queries the GitHub API, so + # generate the hashes concurrently to avoid serial subprocess and + # network waits. `Thread#value` re-raises if one fails, but the other + # taps' read-only queries may still run to completion first. + hashes = taps.map { |tap| Thread.new { tap.to_hash } }.map(&:value) + + puts JSON.pretty_generate(hashes) + end + end end end diff --git a/Library/Homebrew/cmd/tap-pin.rb b/Library/Homebrew/cmd/tap-pin.rb deleted file mode 100644 index 97397a7ef5df0..0000000000000 --- a/Library/Homebrew/cmd/tap-pin.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require "cli/parser" - -module Homebrew - module_function - - def tap_pin_args - Homebrew::CLI::Parser.new do - hide_from_man_page! - end - end - - def tap_pin - odisabled "brew tap-pin user/tap", - "fully-scoped user/tap/formula naming" - end -end diff --git a/Library/Homebrew/cmd/tap-unpin.rb b/Library/Homebrew/cmd/tap-unpin.rb deleted file mode 100644 index 07132ec62990f..0000000000000 --- a/Library/Homebrew/cmd/tap-unpin.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require "cli/parser" - -module Homebrew - module_function - - def tap_unpin_args - Homebrew::CLI::Parser.new do - hide_from_man_page! - end - end - - def tap_unpin - odisabled "brew tap-pin user/tap", - "fully-scoped user/tap/formula naming" - end -end diff --git a/Library/Homebrew/cmd/tap.rb b/Library/Homebrew/cmd/tap.rb index 656dc20768853..8813e7b16b43a 100644 --- a/Library/Homebrew/cmd/tap.rb +++ b/Library/Homebrew/cmd/tap.rb @@ -1,76 +1,68 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "tap" module Homebrew - module_function + module Cmd + class TapCmd < AbstractCommand + cmd_args do + usage_banner "`tap` [] [`/`] []" + description <<~EOS + Tap a formula repository. + If no arguments are provided, list all installed taps. - def tap_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `tap` [] `/` [] + With unspecified, tap a formula repository from GitHub using HTTPS. + Since so many taps are hosted on GitHub, this command is a shortcut for + `brew tap` `/` `https://github.com/``/homebrew-`. - Tap a formula repository. + With specified, tap a formula repository from anywhere, using + any transport protocol that `git`(1) handles. The one-argument form of `tap` + simplifies but also limits. This two-argument command makes no + assumptions, so taps can be cloned from places other than GitHub and + using protocols other than HTTPS, e.g. SSH, git, HTTP, FTP(S), rsync. + EOS + switch "--custom-remote", + description: "Install or change a tap with a custom remote. Useful for mirrors." + switch "--repair", + description: "Add missing symlinks to tap manpages and shell completions. Correct git remote " \ + "refs for any taps where upstream HEAD branch has been renamed." + switch "--eval-all", + description: "Evaluate all available formulae, casks and aliases in the new tap to check their " \ + "validity.", + env: :eval_all, + odeprecated: true + switch "-f", "--force", + description: "Force install core taps even under API mode." - If no arguments are provided, list all installed taps. - - With unspecified, tap a formula repository from GitHub using HTTPS. - Since so many taps are hosted on GitHub, this command is a shortcut for - `brew tap` `/` `https://github.com/``/homebrew-`. - - With specified, tap a formula repository from anywhere, using - any transport protocol that `git`(1) handles. The one-argument form of `tap` - simplifies but also limits. This two-argument command makes no - assumptions, so taps can be cloned from places other than GitHub and - using protocols other than HTTPS, e.g. SSH, GIT, HTTP, FTP(S), RSYNC. - EOS - switch "--full", - description: "Use a full clone when tapping a repository. By default, the repository is "\ - "cloned as a shallow copy (`--depth=1`). To convert a shallow copy to a "\ - "full copy, you can retap by passing `--full` without first untapping." - switch "--force-auto-update", - description: "Auto-update tap even if it is not hosted on GitHub. By default, only taps "\ - "hosted on GitHub are auto-updated (for performance reasons)." - switch "--repair", - description: "Migrate tapped formulae from symlink-based to directory-based structure." - switch "--list-pinned", - description: "List all pinned taps." - switch "-q", "--quieter", - description: "Suppress any warnings." - switch :debug - max_named 2 - end - end - - def tap - tap_args.parse + named_args :tap, max: 2 + end - if args.repair? - Tap.each(&:link_completions_and_manpages) - elsif args.list_pinned? - puts Tap.select(&:pinned?).map(&:name) - elsif ARGV.named.empty? - puts Tap.names - else - tap = Tap.fetch(ARGV.named.first) - begin - tap.install clone_target: ARGV.named.second, - force_auto_update: force_auto_update?, - full_clone: full_clone?, - quiet: args.quieter? - rescue TapRemoteMismatchError => e - odie e - rescue TapAlreadyTappedError, TapAlreadyUnshallowError # rubocop:disable Lint/SuppressedException + sig { override.void } + def run + if args.repair? + Tap.installed.each do |tap| + tap.link_completions_and_manpages + tap.fix_remote_configuration + end + elsif args.no_named? + puts Tap.installed.sort_by(&:name) + else + begin + tap = Tap.fetch(args.named.fetch(0)) + tap.install clone_target: args.named.second, + custom_remote: args.custom_remote?, + quiet: args.quiet?, + verify: args.eval_all?, + force: args.force? + rescue Tap::InvalidNameError, TapRemoteMismatchError, TapNoCustomRemoteError => e + odie e + rescue TapAlreadyTappedError + nil + end + end end end end - - def full_clone? - args.full? || ARGV.homebrew_developer? - end - - def force_auto_update? - # if no relevant flag is present, return nil, meaning "no change" - true if args.force_auto_update? - end end diff --git a/Library/Homebrew/cmd/trust.rb b/Library/Homebrew/cmd/trust.rb new file mode 100644 index 0000000000000..978a4d0e433fb --- /dev/null +++ b/Library/Homebrew/cmd/trust.rb @@ -0,0 +1,114 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "json" +require "trust" + +module Homebrew + module Cmd + class Trust < AbstractCommand + VALID_TYPES = [:tap, :formula, :cask, :command].freeze + + cmd_args do + description <<~EOS + Trust non-official tap formulae, casks or commands so Homebrew may load them when + `$HOMEBREW_REQUIRE_TAP_TRUST` is set. + Trusted entries are stored in `${XDG_CONFIG_HOME}/homebrew/trust.json` if + `$XDG_CONFIG_HOME` is set or `~/.homebrew/trust.json` otherwise. + EOS + switch "--tap", "--taps", + description: "Trust the named tap." + switch "--formula", "--formulae", + description: "Trust the named formula." + switch "--cask", "--casks", + description: "Trust the named cask." + switch "--command", "--commands", + description: "Trust the named external command." + flag "--json=", + description: "Print trusted entries as JSON. A number is required. " \ + "The only accepted value for is `v1`." + + conflicts "--tap", "--formula", "--cask", "--command" + + named_args :target + end + + sig { override.void } + def run + if args.json + raise UsageError, "invalid JSON version: #{args.json}" if args.json != "v1" + raise UsageError, "`--json=v1` requires no named arguments." if args.named.present? + + print_json + return + end + + if args.no_named? + puts "All official taps and commands are trusted." + printed = T.let(false, T::Boolean) + types.each do |type| + values = Homebrew::Trust.trusted_entries(type) + next if values.empty? + + label = Utils.pluralize(type.to_s, 2) + puts "Trusted #{label}:" + values.each { |value| puts " #{value}" } + printed = true + end + + puts "No trusted taps, formulae, casks or commands." unless printed + return + end + + args.named.each do |name| + type, trust_name = Homebrew::Trust.target(name, type: selected_type) + if type == :tap && !Tap.remote_reference?(trust_name) && Tap.fetch(trust_name).official? + puts "Official tap #{trust_name} is always trusted." + next + end + + action = Homebrew::Trust.trust!(type, trust_name) ? "Trusted" : "Already trusted" + + puts "#{action} #{type}: #{trust_name}" + end + end + + private + + sig { void } + def print_json + if (type = selected_type) + puts JSON.pretty_generate(Homebrew::Trust.trusted_entries(type)) + return + end + + json = T.let({}, T::Hash[String, T::Array[String]]) + + types.each do |type| + key = Utils.pluralize(type.to_s, 2) + json[key] = Homebrew::Trust.trusted_entries(type) + end + + puts JSON.pretty_generate(json) + end + + sig { returns(T::Array[Symbol]) } + def types + type = selected_type + return [type] if type + + VALID_TYPES + end + + sig { returns(T.nilable(Symbol)) } + def selected_type + return :tap if args.tap? + return :formula if args.formula? + return :cask if args.cask? + + :command if args.command? || args.commands? + end + end + end +end diff --git a/Library/Homebrew/cmd/unalias.rb b/Library/Homebrew/cmd/unalias.rb new file mode 100755 index 0000000000000..4c426d2078527 --- /dev/null +++ b/Library/Homebrew/cmd/unalias.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "aliases/aliases" + +module Homebrew + module Cmd + class Unalias < AbstractCommand + cmd_args do + description <<~EOS + Remove aliases. + EOS + + named_args :alias, min: 1 + end + + sig { override.void } + def run + Aliases.init + args.named.each { |a| Aliases.remove a } + end + end + end +end diff --git a/Library/Homebrew/cmd/uninstall.rb b/Library/Homebrew/cmd/uninstall.rb index 0f4900069ff2d..6ae5b6e3462db 100644 --- a/Library/Homebrew/cmd/uninstall.rb +++ b/Library/Homebrew/cmd/uninstall.rb @@ -1,169 +1,139 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "keg" require "formula" require "diagnostic" require "migrator" -require "cli/parser" +require "cask/cask_loader" +require "cask/exceptions" +require "cask/installer" +require "cask/uninstall" +require "uninstall" +require "trust" module Homebrew - module_function - - def uninstall_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `uninstall`, `rm`, `remove` [] - - Uninstall . - EOS - switch :force, - description: "Delete all installed versions of ." - switch "--ignore-dependencies", - description: "Don't fail uninstall, even if is a dependency of any installed "\ - "formulae." - switch :debug - end - end + module Cmd + class UninstallCmd < AbstractCommand + cmd_args do + description <<~EOS + Uninstall a or . + EOS + switch "-f", "--force", + description: "Delete all installed versions of . Uninstall even if is not " \ + "installed, overwrite existing files and ignore errors when removing files." + switch "--zap", + description: "Remove all files associated with a . " \ + "*May remove files which are shared between applications.*" + switch "--ignore-dependencies", + description: "Don't fail uninstall, even if is a dependency of any installed " \ + "formulae." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--formula", "--cask" + conflicts "--formula", "--zap" + + named_args [:installed_formula, :installed_cask], min: 1 + end + + sig { override.void } + def run + method = args.force? ? :kegs : :default_kegs + results = args.named.to_formulae_and_casks_and_unavailable(method:) + + unavailable_errors = T.let([], T::Array[T.any(FormulaOrCaskUnavailableError, NoSuchKegError)]) + all_kegs = T.let([], T::Array[Keg]) + casks = T.let([], T::Array[Cask::Cask]) + trusted_items_to_remove = T.let([], T::Array[[Symbol, String]]) + + results.each do |item| + case item + when FormulaOrCaskUnavailableError, NoSuchKegError + unavailable_errors << item + when Cask::Cask + casks << item + trusted_items_to_remove << [:cask, item.full_name] + when Keg + all_kegs << item + single_keg_tap = item.tab.tap + trusted_items_to_remove << [:formula, "#{single_keg_tap.name}/#{item.name}"] if single_keg_tap + when Array + all_kegs += item + item.each do |keg| + array_keg_tap = keg.tab.tap + trusted_items_to_remove << [:formula, "#{array_keg_tap.name}/#{keg.name}"] if array_keg_tap + end + end + end - def uninstall - uninstall_args.parse + return if all_kegs.blank? && casks.blank? && unavailable_errors.blank? - raise KegUnspecifiedError if args.remaining.empty? + kegs_by_rack = all_kegs.group_by(&:rack) - kegs_by_rack = if args.force? - Hash[ARGV.named.map do |name| - rack = Formulary.to_rack(name) - next unless rack.directory? + Uninstall.uninstall_kegs( + kegs_by_rack, + casks:, + force: args.force?, + ignore_dependencies: args.ignore_dependencies?, + named_args: args.named, + ) - [rack, rack.subdirs.map { |d| Keg.new(d) }] - end] - else - Homebrew.args.kegs.group_by(&:rack) - end + Cask::Uninstall.check_dependent_casks(*casks, named_args: args.named) unless args.ignore_dependencies? - handle_unsatisfied_dependents(kegs_by_rack) - return if Homebrew.failed? + return if Homebrew.failed? - kegs_by_rack.each do |rack, kegs| - if args.force? - name = rack.basename + begin + if args.zap? + caught_exceptions = [] - if rack.directory? - puts "Uninstalling #{name}... (#{rack.abv})" - kegs.each do |keg| - keg.unlink - keg.uninstall - end - end + casks.each do |cask| + odebug "Zapping Cask #{cask}" - rm_pin rack - else - kegs.each do |keg| - begin - f = Formulary.from_rack(rack) - if f.pinned? - onoe "#{f.full_name} is pinned. You must unpin it to uninstall." + raise Cask::CaskNotInstalledError, cask if !cask.installed? && !args.force? + + next unless Cask::Uninstall.unpin_for_removal?(cask, force: args.force?) + + Cask::Installer.new(cask, verbose: args.verbose?, force: args.force?).zap + rescue => e + caught_exceptions << e next end - rescue - nil - end - keg.lock do - puts "Uninstalling #{keg}... (#{keg.abv})" - keg.unlink - keg.uninstall - rack = keg.rack - rm_pin rack - - if rack.directory? - versions = rack.subdirs.map(&:basename) - puts "#{keg.name} #{versions.to_sentence} #{"is".pluralize(versions.count)} still installed." - puts "Run `brew uninstall --force #{keg.name}` to remove all versions." + if caught_exceptions.count > 1 + raise Cask::MultipleCaskErrors, caught_exceptions + elsif caught_exceptions.one? + raise caught_exceptions.fetch(0) end + else + Cask::Uninstall.uninstall_casks( + *casks, + verbose: args.verbose?, + force: args.force?, + ) end + rescue => e + ofail e end - end - end - rescue MultipleVersionsInstalledError => e - ofail e - puts "Run `brew uninstall --force #{e.name}` to remove all versions." - ensure - # If we delete Cellar/newname, then Cellar/oldname symlink - # can become broken and we have to remove it. - if HOMEBREW_CELLAR.directory? - HOMEBREW_CELLAR.children.each do |rack| - rack.unlink if rack.symlink? && !rack.resolved_path_exists? - end - end - end - - def handle_unsatisfied_dependents(kegs_by_rack) - return if args.ignore_dependencies? - - all_kegs = kegs_by_rack.values.flatten(1) - check_for_dependents all_kegs - rescue MethodDeprecatedError - # Silently ignore deprecations when uninstalling. - nil - end - - def check_for_dependents(kegs) - return false unless result = Keg.find_some_installed_dependents(kegs) - - if ARGV.homebrew_developer? - DeveloperDependentsMessage.new(*result).output - else - NondeveloperDependentsMessage.new(*result).output - end - - true - end - - class DependentsMessage - attr_reader :reqs, :deps - - def initialize(requireds, dependents) - @reqs = requireds - @deps = dependents - end - - protected - def sample_command - "brew uninstall --ignore-dependencies #{ARGV.named.join(" ")}" - end + trusted_items_to_remove.uniq.each do |type, name| + next unless (tap_name = Utils.tap_from_full_name(name)) + next if Homebrew::Trust.trusted?(:tap, tap_name) - def are_required_by_deps - "#{"is".pluralize(reqs.count)} required by #{deps.to_sentence}, " \ - "which #{"is".pluralize(deps.count)} currently installed" - end - end + Homebrew::Trust.untrust!(type, name) + end - class DeveloperDependentsMessage < DependentsMessage - def output - opoo <<~EOS - #{reqs.to_sentence} #{are_required_by_deps}. - You can silence this warning with: - #{sample_command} - EOS - end - end + if ENV["HOMEBREW_AUTOREMOVE"].present? + opoo "`$HOMEBREW_AUTOREMOVE` is now a no-op as it is the default behaviour. " \ + "Set `HOMEBREW_NO_AUTOREMOVE=1` to disable it." + end + Cleanup.autoremove unless Homebrew::EnvConfig.no_autoremove? - class NondeveloperDependentsMessage < DependentsMessage - def output - ofail <<~EOS - Refusing to uninstall #{reqs.to_sentence} - because #{"it".pluralize(reqs.count)} #{are_required_by_deps}. - You can override this and force removal with: - #{sample_command} - EOS + unavailable_errors.each { |e| ofail e } unless args.force? + end end end - - def rm_pin(rack) - Formulary.from_rack(rack).unpin - rescue - nil - end end diff --git a/Library/Homebrew/cmd/unlink.rb b/Library/Homebrew/cmd/unlink.rb index 98f9b3b26c406..cb6dcff4a1f45 100644 --- a/Library/Homebrew/cmd/unlink.rb +++ b/Library/Homebrew/cmd/unlink.rb @@ -1,47 +1,38 @@ +# typed: strict # frozen_string_literal: true -require "ostruct" -require "cli/parser" +require "abstract_command" +require "unlink" module Homebrew - module_function - - def unlink_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `unlink` [] - - Remove symlinks for from Homebrew's prefix. This can be useful - for temporarily disabling a formula: - `brew unlink` `&&` `&& brew link` - EOS - switch "-n", "--dry-run", - description: "List files which would be unlinked without actually unlinking or "\ - "deleting any files." - switch :verbose - switch :debug - end - end - - def unlink - unlink_args.parse - - raise KegUnspecifiedError if args.remaining.empty? + module Cmd + class UnlinkCmd < AbstractCommand + cmd_args do + description <<~EOS + Remove symlinks for from Homebrew's prefix. This can be useful + for temporarily disabling a formula: + `brew unlink` `&&` `&& brew link` + EOS + switch "-n", "--dry-run", + description: "List files which would be unlinked without actually unlinking or " \ + "deleting any files." + + named_args :installed_formula, min: 1 + end - mode = OpenStruct.new - mode.dry_run = true if args.dry_run? + sig { override.void } + def run + options = { dry_run: args.dry_run?, verbose: args.verbose? } - Homebrew.args.kegs.each do |keg| - if mode.dry_run - puts "Would remove:" - keg.unlink(mode) - next - end + args.named.to_default_kegs.each do |keg| + if args.dry_run? + puts "Would remove:" + keg.unlink(**options) + next + end - keg.lock do - print "Unlinking #{keg}... " - puts if args.verbose? - puts "#{keg.unlink(mode)} symlinks removed" + Unlink.unlink(keg, dry_run: args.dry_run?, verbose: args.verbose?) + end end end end diff --git a/Library/Homebrew/cmd/unpack.rb b/Library/Homebrew/cmd/unpack.rb deleted file mode 100644 index 522fc5a891ad5..0000000000000 --- a/Library/Homebrew/cmd/unpack.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -require "stringio" -require "formula" -require "cli/parser" - -module Homebrew - module_function - - def unpack_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `unpack` [] - - Unpack the source files for into subdirectories of the current - working directory. - EOS - flag "--destdir=", - description: "Create subdirectories in the directory named by instead." - switch "--patch", - description: "Patches for will be applied to the unpacked source." - switch "-g", "--git", - description: "Initialise a Git repository in the unpacked source. This is useful for creating "\ - "patches for the software." - switch :force - switch :verbose - switch :debug - conflicts "--git", "--patch" - end - end - - def unpack - unpack_args.parse - - formulae = Homebrew.args.formulae - raise FormulaUnspecifiedError if formulae.empty? - - if dir = args.destdir - unpack_dir = Pathname.new(dir).expand_path - unpack_dir.mkpath - else - unpack_dir = Pathname.pwd - end - - raise "Cannot write to #{unpack_dir}" unless unpack_dir.writable_real? - - formulae.each do |f| - stage_dir = unpack_dir/"#{f.name}-#{f.version}" - - if stage_dir.exist? - raise "Destination #{stage_dir} already exists!" unless args.force? - - rm_rf stage_dir - end - - oh1 "Unpacking #{Formatter.identifier(f.full_name)} to: #{stage_dir}" - - ENV["VERBOSE"] = "1" # show messages about tar - f.brew do - f.patch if args.patch? - cp_r getwd, stage_dir, preserve: true - end - ENV["VERBOSE"] = nil - - next unless args.git? - - ohai "Setting up git repository" - cd stage_dir - system "git", "init", "-q" - system "git", "add", "-A" - system "git", "commit", "-q", "-m", "brew-unpack" - end - end -end diff --git a/Library/Homebrew/cmd/unpin.rb b/Library/Homebrew/cmd/unpin.rb index 6bd17b27f29a7..d4a802f1365e2 100644 --- a/Library/Homebrew/cmd/unpin.rb +++ b/Library/Homebrew/cmd/unpin.rb @@ -1,36 +1,52 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "cli/parser" +require "cask/cask" module Homebrew - module_function - - def unpin_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `unpin` - - Unpin , allowing them to be upgraded by `brew upgrade` . - See also `pin`. - EOS - switch :verbose - switch :debug - end - end + module Cmd + class Unpin < AbstractCommand + cmd_args do + description <<~EOS + Unpin the specified package, allowing it to be upgraded by `brew upgrade` or . + See also `pin`. + EOS + + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--formula", "--cask" + + named_args [:installed_formula, :installed_cask], min: 1 + end - def unpin - unpin_args.parse + sig { override.void } + def run + formulae, casks = args.named.to_resolved_formulae_to_casks - raise FormulaUnspecifiedError if args.remaining.empty? + formulae.each do |formula| + if formula.pinned? + formula.unpin + elsif !formula.pinnable? + onoe "#{formula.full_name} not installed" + else + opoo "#{formula.full_name} not pinned" + end + end - Homebrew.args.resolved_formulae.each do |f| - if f.pinned? - f.unpin - elsif !f.pinnable? - onoe "#{f.name} not installed" - else - opoo "#{f.name} not pinned" + casks.each do |cask| + if cask.pinned? || cask.pin_path.symlink? + cask.unpin + elsif !cask.pinnable? + onoe "#{cask.full_name} not installed" + else + opoo "#{cask.full_name} not pinned" + end + end end end end diff --git a/Library/Homebrew/cmd/untap.rb b/Library/Homebrew/cmd/untap.rb index 5dd45a2bc1e24..fc8c2ccdcb997 100644 --- a/Library/Homebrew/cmd/untap.rb +++ b/Library/Homebrew/cmd/untap.rb @@ -1,31 +1,108 @@ +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "utils" module Homebrew - module_function + module Cmd + class Untap < AbstractCommand + cmd_args do + description <<~EOS + Remove a tapped formula repository. + EOS + switch "-f", "--force", + description: "Untap even if formulae or casks from this tap are currently installed." - def untap_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `untap` + named_args :tap, min: 1 + end - Remove a tapped formula repository. - EOS - switch :debug - end - end + sig { override.void } + def run + taps = begin + args.named.to_installed_taps + rescue Tap::InvalidNameError => e + odie e.message + end + + taps.each do |tap| + if tap.core_tap? && Homebrew::EnvConfig.no_install_from_api? + ofail "Untapping #{tap} is not allowed" + next + end + + if Homebrew::EnvConfig.no_install_from_api? || (!tap.core_tap? && !tap.core_cask_tap?) + installed_tap_formulae = installed_formulae_for(tap:) + installed_tap_casks = installed_casks_for(tap:) + + if installed_tap_formulae.present? || installed_tap_casks.present? + installed_names = (installed_tap_formulae + installed_tap_casks.map(&:token)).join("\n") + if args.force? || Homebrew::EnvConfig.developer? + opoo <<~EOS + Untapping #{tap} even though it contains the following installed formulae or casks: + #{installed_names} + EOS + else + ofail <<~EOS + Refusing to untap #{tap} because it contains the following installed formulae or casks: + #{installed_names} + EOS + next + end + end + end + + tap.uninstall manual: true + end + end + + # All installed formulae currently available in a tap by formula full name. + sig { params(tap: Tap).returns(T::Array[Formula]) } + def installed_formulae_for(tap:) + tap.formula_names.filter_map do |formula_name| + next unless installed_formulae_names.include?(Utils.name_from_full_name(formula_name)) + + formula = begin + Formulary.factory(formula_name) + rescue FormulaUnavailableError, FormulaSpecificationError + # Don't blow up because of a single unavailable or invalid formula. + next + end + + # Can't use Formula#any_version_installed? because it doesn't consider + # taps correctly. + formula if formula.installed_kegs.any? { |keg| keg.tab.tap == tap } + end + end + + # All installed casks currently available in a tap by cask full name. + sig { params(tap: Tap).returns(T::Array[Cask::Cask]) } + def installed_casks_for(tap:) + tap.cask_tokens.filter_map do |cask_token| + next unless installed_cask_tokens.include?(Utils.name_from_full_name(cask_token)) + + cask = begin + Cask::CaskLoader.load(cask_token) + rescue Cask::CaskUnavailableError, MethodDeprecatedError + # Don't blow up because of a single unavailable cask or a deprecated method. + next + end - def untap - untap_args.parse + cask if cask.installed? + end + end - raise UsageError, "This command requires a tap argument from `brew tap`'s list" if args.remaining.empty? + private - ARGV.named.each do |tapname| - tap = Tap.fetch(tapname) - odie "Untapping #{tap} is not allowed" if tap.core_tap? + sig { returns(T::Set[String]) } + def installed_formulae_names + @installed_formulae_names ||= T.let(Formula.installed_formula_names.to_set.freeze, T.nilable(T::Set[String])) + end - tap.uninstall + sig { returns(T::Set[String]) } + def installed_cask_tokens + @installed_cask_tokens ||= T.let(Cask::Caskroom.tokens.to_set.freeze, T.nilable(T::Set[String])) + end end end end diff --git a/Library/Homebrew/cmd/untrust.rb b/Library/Homebrew/cmd/untrust.rb new file mode 100644 index 0000000000000..a5b8da1ba95e7 --- /dev/null +++ b/Library/Homebrew/cmd/untrust.rb @@ -0,0 +1,114 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "trust" + +module Homebrew + module Cmd + class Untrust < AbstractCommand + cmd_args do + description <<~EOS + Stop trusting non-official tap formulae, casks or commands. + Trusted entries are stored in `${XDG_CONFIG_HOME}/homebrew/trust.json` if + `$XDG_CONFIG_HOME` is set or `~/.homebrew/trust.json` otherwise. + EOS + switch "--tap", + description: "Untrust the named tap." + switch "--formula", "--formulae", + description: "Untrust the named formula." + switch "--cask", "--casks", + description: "Untrust the named cask." + switch "--command", "--commands", + description: "Untrust the named external command." + + conflicts "--tap", "--formula", "--cask", "--command" + + named_args :target + end + + sig { override.void } + def run + if args.no_named? + types = selected_type ? [selected_type] : [:tap, :formula, :cask, :command] + printed = T.let(false, T::Boolean) + types.each do |type| + values = Homebrew::Trust.untrusted_taps.flat_map do |tap| + case type + when :tap + [tap.name] + when :formula + tap.formula_files.filter_map do |file| + name = file.basename(file.extname).to_s + full_name = "#{tap.name}/#{name}" + full_name unless Homebrew::Trust.trusted?(:formula, full_name) + end + when :cask + tap.cask_files.filter_map do |file| + name = file.basename(file.extname).to_s + full_name = "#{tap.name}/#{name}" + full_name unless Homebrew::Trust.trusted?(:cask, full_name) + end + when :command + tap.command_files.filter_map do |file| + name = file.basename(file.extname).to_s.delete_prefix("brew-") + full_name = "#{tap.name}/#{name}" + full_name unless Homebrew::Trust.trusted?(:command, full_name) + end + else + raise "Unsupported trust type: #{type}" + end + end.sort + next if values.empty? + + label = case type + when :tap then "taps" + when :formula then "formulae" + when :cask then "casks" + when :command then "commands" + else raise "Unsupported trust type: #{type}" + end + puts "Untrusted #{label}:" + values.each { |value| puts " #{value}" } + printed = true + end + + puts "No untrusted taps, formulae, casks or commands." unless printed + return + end + + args.named.each do |name| + item_types = [:formula, :cask, :command] + type, trust_name = Homebrew::Trust.target(name, type: selected_type, include_existing: true) + if type == :tap && !Tap.remote_reference?(trust_name) && Tap.fetch(trust_name).official? + puts "Official tap #{trust_name} is always trusted." + next + end + + removed = Homebrew::Trust.untrust!(type, trust_name) + if type == :tap + item_types.each do |item_type| + Homebrew::Trust.trusted_entries(item_type).each do |entry| + removed = true if entry.start_with?("#{trust_name}/") && Homebrew::Trust.untrust!(item_type, entry) + end + end + end + action = removed ? "Untrusted" : "Not trusted" + + puts "#{action} #{type}: #{trust_name}" + end + end + + private + + sig { returns(T.nilable(Symbol)) } + def selected_type + return :tap if args.tap? + return :formula if args.formula? + return :cask if args.cask? + + :command if args.command? || args.commands? + end + end + end +end diff --git a/Library/Homebrew/cmd/update-if-needed.rb b/Library/Homebrew/cmd/update-if-needed.rb new file mode 100644 index 0000000000000..ee7a1e4c12d7e --- /dev/null +++ b/Library/Homebrew/cmd/update-if-needed.rb @@ -0,0 +1,23 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class UpdateIfNeeded < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Runs `brew update --auto-update` only if needed. + This is a good replacement for `brew update` in scripts where you want + the no-op case to be both possible and really fast. + EOS + + named_args :none + end + end + end +end diff --git a/Library/Homebrew/cmd/update-if-needed.sh b/Library/Homebrew/cmd/update-if-needed.sh new file mode 100644 index 0000000000000..4d26e79c612c7 --- /dev/null +++ b/Library/Homebrew/cmd/update-if-needed.sh @@ -0,0 +1,6 @@ +# Documentation defined in Library/Homebrew/cmd/update-if-needed.rb + +homebrew-update-if-needed() { + export HOMEBREW_AUTO_UPDATE_COMMAND="1" + auto-update "$@" +} diff --git a/Library/Homebrew/cmd/update-report.rb b/Library/Homebrew/cmd/update-report.rb index f247e034dbbda..193cd32ea52cc 100644 --- a/Library/Homebrew/cmd/update-report.rb +++ b/Library/Homebrew/cmd/update-report.rb @@ -1,453 +1,418 @@ +# typed: strict # frozen_string_literal: true -require "formula_versions" +require "abstract_command" require "migrator" require "formulary" +require "cask/cask_loader" +require "cask/migrator" require "descriptions" require "cleanup" require "description_cache_store" -require "cli/parser" +require "settings" +require "reinstall" +require "version" module Homebrew - module_function - - def update_preinstall_header - @update_preinstall_header ||= begin - ohai "Auto-updated Homebrew!" if ARGV.include?("--preinstall") - true - end - end + module Cmd + class UpdateReport < AbstractCommand + cmd_args do + description <<~EOS + The Ruby implementation of `brew update`. Never called manually. + EOS + switch "--auto-update", "--preinstall", + description: "Run in 'auto-update' mode (faster, less output)." + switch "-f", "--force", + description: "Treat installed and updated formulae as if they are from " \ + "the same taps and migrate them anyway." - def update_report_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `update-report` - - The Ruby implementation of `brew update`. Never called manually. - EOS - switch "--preinstall", - description: "Run in 'auto-update' mode (faster, less output)." - switch :force - switch :quiet - switch :verbose - switch :debug - hide_from_man_page! - end - end + hide_from_man_page! + end - def update_report - update_report_args.parse + sig { override.void } + def run + return output_update_report if $stdout.tty? - if !Utils::Analytics.messages_displayed? && - !Utils::Analytics.disabled? && - !Utils::Analytics.no_message_output? + redirect_stdout($stderr) do + output_update_report + end + end - ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1" - # Use the shell's audible bell. - print "\a" + private - # Use an extra newline and bold to avoid this being missed. - ohai "Homebrew has enabled anonymous aggregate formulae and cask analytics." - puts <<~EOS - #{Tty.bold}Read the analytics documentation (and how to opt-out) here: - #{Formatter.url("https://docs.brew.sh/Analytics")}#{Tty.reset} + sig { void } + def auto_update_header + @auto_update_header ||= T.let(begin + ohai "Auto-updated Homebrew!" if args.auto_update? + true + end, T.nilable(T::Boolean)) + end - EOS + sig { void } + def output_update_report + if ENV["HOMEBREW_ADDITIONAL_GOOGLE_ANALYTICS_ID"].present? + opoo "HOMEBREW_ADDITIONAL_GOOGLE_ANALYTICS_ID is now a no-op so can be unset." + puts "All Homebrew Google Analytics code and data was destroyed." + end - # Consider the messages possibly missed if not a TTY. - Utils::Analytics.messages_displayed! if $stdout.tty? - end + if ENV["HOMEBREW_NO_GOOGLE_ANALYTICS"].present? + opoo "HOMEBREW_NO_GOOGLE_ANALYTICS is now a no-op so can be unset." + puts "All Homebrew Google Analytics code and data was destroyed." + end - HOMEBREW_REPOSITORY.cd do - donation_message_displayed = - Utils.popen_read("git", "config", "--get", "homebrew.donationmessage").chomp == "true" - unless donation_message_displayed - ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:" - puts " #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n" + unless args.quiet? + analytics_message + donation_message + install_from_api_message + end - # Consider the message possibly missed if not a TTY. - safe_system "git", "config", "--replace-all", "homebrew.donationmessage", "true" if $stdout.tty? - end - end + if (redirected_remotes_file = ENV.fetch("HOMEBREW_REDIRECTED_REMOTES_FILE", nil)).present? + redirected_remotes_path = Pathname(redirected_remotes_file) + if redirected_remotes_path.file? + begin + denied_redirects = [] + redirected_remotes_path.each_line do |line| + tap_path, redirected_remote = line.chomp.split("\t", 2) + next if tap_path.blank? || redirected_remote.blank? + next unless (tap = Tap.from_path(tap_path)) + + old_repository_var_suffix = tap.repository_var_suffix + begin + tap.apply_redirected_remote!(redirected_remote, quiet: args.quiet?) + rescue TapRedirectNotAllowedError => e + # update.sh may already have merged redirected content, so roll back every denied tap. + before_revision = ENV.fetch("HOMEBREW_UPDATE_BEFORE#{old_repository_var_suffix}", nil) + if before_revision.present? && tap.installed? + git_args = ["-C", tap.path.to_s] + safe_system "git", *git_args, "reset", "--hard", "-q", before_revision + branch = Utils.popen_read("git", *git_args, "symbolic-ref", "--short", "-q", "HEAD").chomp + branch = branch.presence || tap.git_repository.origin_branch_name + if branch.present? + safe_system "git", *git_args, "update-ref", "refs/remotes/origin/#{branch}", before_revision + end + end + denied_redirects << e.message + next + end + new_repository_var_suffix = tap.repository_var_suffix + next if old_repository_var_suffix == new_repository_var_suffix + + ["HOMEBREW_UPDATE_BEFORE", "HOMEBREW_UPDATE_AFTER"].each do |prefix| + old_var = "#{prefix}#{old_repository_var_suffix}" + old_value = ENV.fetch(old_var, nil) + next if old_value.blank? + + ENV["#{prefix}#{new_repository_var_suffix}"] ||= old_value + end + end + odie denied_redirects.join("\n\n") if denied_redirects.any? + ensure + redirected_remotes_path.unlink if redirected_remotes_path.exist? + end + end + end + tap_or_untap_core_taps_if_necessary - install_core_tap_if_necessary + updated = false + new_tag = nil - hub = ReporterHub.new - updated = false + initial_revision = ENV["HOMEBREW_UPDATE_BEFORE"].to_s + current_revision = ENV["HOMEBREW_UPDATE_AFTER"].to_s + odie "update-report should not be called directly!" if initial_revision.empty? || current_revision.empty? - initial_revision = ENV["HOMEBREW_UPDATE_BEFORE"].to_s - current_revision = ENV["HOMEBREW_UPDATE_AFTER"].to_s - odie "update-report should not be called directly!" if initial_revision.empty? || current_revision.empty? + if initial_revision != current_revision + auto_update_header - if initial_revision != current_revision - update_preinstall_header - puts "Updated Homebrew from #{shorten_revision(initial_revision)} to #{shorten_revision(current_revision)}." - updated = true - end + updated = true - updated_taps = [] - Tap.each do |tap| - next unless tap.git? + old_tag = Settings.read "latesttag" - begin - reporter = Reporter.new(tap) - rescue Reporter::ReporterRevisionUnsetError => e - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer? - next - end - if reporter.updated? - updated_taps << tap.name - hub.add(reporter) - end - end + new_tag = Utils.popen_read( + "git", "-C", HOMEBREW_REPOSITORY, "tag", "--list", "--sort=-version:refname", "*.*" + ).lines.fetch(0).chomp - unless updated_taps.empty? - update_preinstall_header - puts "Updated #{updated_taps.count} #{"tap".pluralize(updated_taps.count)} (#{updated_taps.to_sentence})." - updated = true - end + Settings.write "latesttag", new_tag if new_tag != old_tag - if !updated - puts "Already up-to-date." if !ARGV.include?("--preinstall") && !ENV["HOMEBREW_UPDATE_FAILED"] - else - if hub.empty? - puts "No changes to formulae." - else - hub.dump - hub.reporters.each(&:migrate_tap_migration) - hub.reporters.each(&:migrate_formula_rename) - CacheStoreDatabase.use(:descriptions) do |db| - DescriptionCacheStore.new(db) - .update_from_report!(hub) + if new_tag == old_tag + ohai "Updated Homebrew from #{shorten_revision(initial_revision)} " \ + "to #{shorten_revision(current_revision)}." + elsif old_tag.blank? + ohai "Updated Homebrew from #{shorten_revision(initial_revision)} " \ + "to #{new_tag} (#{shorten_revision(current_revision)})." + else + ohai "Updated Homebrew from #{old_tag} (#{shorten_revision(initial_revision)}) " \ + "to #{new_tag} (#{shorten_revision(current_revision)})." + end end - end - puts if ARGV.include?("--preinstall") - end - link_completions_manpages_and_docs - Tap.each(&:link_completions_and_manpages) + # Check if we can parse the JSON and do any Ruby-side follow-up. + Homebrew::API.write_names_and_aliases unless Homebrew::EnvConfig.no_install_from_api? - Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"] - end + Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"] + return if Homebrew::EnvConfig.disable_load_formula? - def shorten_revision(revision) - Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp - end + migrate_gcc_dependents_if_needed - def install_core_tap_if_necessary - return if ENV["HOMEBREW_UPDATE_TEST"] + hub = ReporterHub.new - core_tap = CoreTap.instance - return if core_tap.installed? + updated_taps = [] + Tap.installed.each do |tap| + next if !tap.git? || tap.git_repository.origin_url.nil? + next if (tap.core_tap? || tap.core_cask_tap?) && !Homebrew::EnvConfig.no_install_from_api? - CoreTap.ensure_installed! - revision = core_tap.git_head - ENV["HOMEBREW_UPDATE_BEFORE_HOMEBREW_HOMEBREW_CORE"] = revision - ENV["HOMEBREW_UPDATE_AFTER_HOMEBREW_HOMEBREW_CORE"] = revision - end - - def link_completions_manpages_and_docs(repository = HOMEBREW_REPOSITORY) - command = "brew update" - Utils::Link.link_completions(repository, command) - Utils::Link.link_manpages(repository, command) - Utils::Link.link_docs(repository, command) - rescue => e - ofail <<~EOS - Failed to link all completions, docs and manpages: - #{e} - EOS - end -end - -class Reporter - class ReporterRevisionUnsetError < RuntimeError - def initialize(var_name) - super "#{var_name} is unset!" - end - end + begin + reporter = Reporter.new(tap) + rescue Reporter::ReporterRevisionUnsetError => e + if Homebrew::EnvConfig.developer? + require "utils/backtrace" + onoe "#{e.message}\n#{Utils::Backtrace.clean(e)&.join("\n")}" + end + next + end + if reporter.updated? + updated_taps << tap.name + hub.add(reporter, auto_update: args.auto_update?) + end + end - attr_reader :tap, :initial_revision, :current_revision + # If we're installing from the API: we cannot use Git to check for # + # differences in packages so instead use {formula,cask}_names.txt to do so. + # The first time this runs: we won't yet have a base state + # ({formula,cask}_names.before.txt) to compare against so we don't output a + # anything and just copy the files for next time. + unless Homebrew::EnvConfig.no_install_from_api? + api_cache = Homebrew::API::HOMEBREW_CACHE_API + core_tap = CoreTap.instance + cask_tap = CoreCaskTap.instance + [ + [:formula, core_tap, core_tap.formula_dir], + [:cask, cask_tap, cask_tap.cask_dir], + ].each do |type, tap, dir| + names_txt = api_cache/"#{type}_names.txt" + next unless names_txt.exist? + + names_before_txt = api_cache/"#{type}_names.before.txt" + if names_before_txt.exist? + reporter = Reporter.new( + tap, + api_names_txt: names_txt, + api_names_before_txt: names_before_txt, + api_dir_prefix: dir, + ) + if reporter.updated? + updated_taps << tap.name + hub.add(reporter, auto_update: args.auto_update?) + end + else + FileUtils.cp names_txt, names_before_txt + end + end + end - def initialize(tap) - @tap = tap + unless updated_taps.empty? + auto_update_header + puts "Updated #{Utils.pluralize("tap", updated_taps.count, + include_count: true)} (#{updated_taps.to_sentence})." + updated = true + end - initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{tap.repo_var}" - @initial_revision = ENV[initial_revision_var].to_s - raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty? + if updated + if hub.empty? + puts no_changes_message unless args.quiet? + else + if ENV.fetch("HOMEBREW_UPDATE_REPORT_ONLY_INSTALLED", false) + opoo "HOMEBREW_UPDATE_REPORT_ONLY_INSTALLED is now the default behaviour, " \ + "so you can unset it from your environment." + end + + hub.dump(auto_update: args.auto_update?) unless args.quiet? + hub.reporters.each(&:migrate_tap_migration) + hub.reporters.each(&:migrate_cask_rename) + hub.reporters.each { |r| r.migrate_formula_rename(force: args.force?, verbose: args.verbose?) } + + CacheStoreDatabase.use(:descriptions) do |db| + DescriptionCacheStore.new(T.cast(db, CacheStoreDatabase[String, T.anything])) + .update_from_report!(hub) + end + CacheStoreDatabase.use(:cask_descriptions) do |db| + CaskDescriptionCacheStore.new(T.cast(db, CacheStoreDatabase[String, T.anything])) + .update_from_report!(hub) + end + end + puts if args.auto_update? + elsif !args.auto_update? && !ENV["HOMEBREW_UPDATE_FAILED"] + puts "Already up-to-date." unless args.quiet? + end - current_revision_var = "HOMEBREW_UPDATE_AFTER#{tap.repo_var}" - @current_revision = ENV[current_revision_var].to_s - raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty? - end + Homebrew::Reinstall.reinstall_pkgconf_if_needed! - def report - return @report if @report + Commands.rebuild_commands_completion_list + link_completions_manpages_and_docs + Tap.installed.each(&:link_completions_and_manpages) - @report = Hash.new { |h, k| h[k] = [] } - return @report unless updated? + failed_fetch_dirs = ENV["HOMEBREW_MISSING_REMOTE_REF_DIRS"]&.split("\n") + if failed_fetch_dirs.present? + failed_fetch_taps = failed_fetch_dirs.map { |dir| Tap.from_path(dir) } - diff.each_line do |line| - status, *paths = line.split - src = Pathname.new paths.first - dst = Pathname.new paths.last + ofail <<~EOS + Some taps failed to update! + The following taps can not read their remote branches: + #{failed_fetch_taps.join("\n ")} + This is happening because the remote branch was renamed or deleted. + Reset taps to point to the correct remote branches by running `brew tap --repair` + EOS + end - next unless dst.extname == ".rb" + return if new_tag.blank? || new_tag == old_tag || args.quiet? - if paths.any? { |p| tap.cask_file?(p) } - # Currently only need to handle Cask deletion/migration. - if status == "D" - # Have a dedicated report array for deleted casks. - @report[:DC] << tap.formula_file_to_name(src) - end - end + puts - next unless paths.any? { |p| tap.formula_file?(p) } - - case status - when "A", "D" - full_name = tap.formula_file_to_name(src) - name = full_name.split("/").last - new_tap = tap.tap_migrations[name] - @report[status.to_sym] << full_name unless new_tap - when "M" - begin - formula = Formulary.factory(tap.path/src) - new_version = formula.pkg_version - old_version = FormulaVersions.new(formula).formula_at_revision(@initial_revision, &:pkg_version) - next if new_version == old_version - rescue FormulaUnavailableError - # Don't care if the formula isn't available right now. - nil - rescue Exception => e # rubocop:disable Lint/RescueException - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer? + new_version = ::Version.new(new_tag) + if new_version.major_minor > ::Version.new(old_tag || "0").major_minor + puts <<~EOS + The #{new_version.major_minor}.0 release notes are available on the Homebrew Blog: + #{Formatter.url("https://brew.sh/blog/#{new_version.major_minor}.0")} + EOS end - @report[:M] << tap.formula_file_to_name(src) - when /^R\d{0,3}/ - src_full_name = tap.formula_file_to_name(src) - dst_full_name = tap.formula_file_to_name(dst) - # Don't report formulae that are moved within a tap but not renamed - next if src_full_name == dst_full_name - - @report[:D] << src_full_name - @report[:A] << dst_full_name - end - end - renamed_formulae = Set.new - @report[:D].each do |old_full_name| - old_name = old_full_name.split("/").last - new_name = tap.formula_renames[old_name] - next unless new_name + return if new_version.patch.to_i.zero? - if tap.core_tap? - new_full_name = new_name - else - new_full_name = "#{tap}/#{new_name}" + puts <<~EOS + The #{new_tag} changelog can be found at: + #{Formatter.url("https://github.com/Homebrew/brew/releases/tag/#{new_tag}")} + EOS end - renamed_formulae << [old_full_name, new_full_name] if @report[:A].include? new_full_name - end - - @report[:A].each do |new_full_name| - new_name = new_full_name.split("/").last - old_name = tap.formula_renames.key(new_name) - next unless old_name - - if tap.core_tap? - old_full_name = old_name - else - old_full_name = "#{tap}/#{old_name}" + sig { returns(String) } + def no_changes_message + "No changes to formulae or casks." end - renamed_formulae << [old_full_name, new_full_name] - end - - unless renamed_formulae.empty? - @report[:A] -= renamed_formulae.map(&:last) - @report[:D] -= renamed_formulae.map(&:first) - @report[:R] = renamed_formulae.to_a - end - - @report - end - - def updated? - initial_revision != current_revision - end - - def migrate_tap_migration - (report[:D] + report[:DC]).each do |full_name| - name = full_name.split("/").last - new_tap_name = tap.tap_migrations[name] - next if new_tap_name.nil? # skip if not in tap_migrations list. - - new_tap_user, new_tap_repo, new_tap_new_name = new_tap_name.split("/") - new_name = if new_tap_new_name - new_full_name = new_tap_new_name - new_tap_name = "#{new_tap_user}/#{new_tap_repo}" - new_tap_new_name - else - new_full_name = "#{new_tap_name}/#{name}" - name + sig { params(revision: String).returns(String) } + def shorten_revision(revision) + Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp end - # This means it is a Cask - if report[:DC].include? full_name - next unless (HOMEBREW_PREFIX/"Caskroom"/new_name).exist? + sig { void } + def tap_or_untap_core_taps_if_necessary + return if ENV["HOMEBREW_UPDATE_TEST"] - new_tap = Tap.fetch(new_tap_name) - new_tap.install unless new_tap.installed? - ohai "#{name} has been moved to Homebrew.", <<~EOS - To uninstall the cask run: - brew cask uninstall --force #{name} - EOS - next if (HOMEBREW_CELLAR/new_name.split("/").last).directory? + if Homebrew::EnvConfig.no_install_from_api? + return if Homebrew::EnvConfig.automatically_set_no_install_from_api? - ohai "Installing #{new_name}..." - system HOMEBREW_BREW_FILE, "install", new_full_name - begin - unless Formulary.factory(new_full_name).keg_only? - system HOMEBREW_BREW_FILE, "link", new_full_name, "--overwrite" - end - rescue Exception => e # rubocop:disable Lint/RescueException - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer? - end - next - end + core_tap = CoreTap.instance + return if core_tap.installed? - next unless (dir = HOMEBREW_CELLAR/name).exist? # skip if formula is not installed. - - tabs = dir.subdirs.map { |d| Tab.for_keg(Keg.new(d)) } - next unless tabs.first.tap == tap # skip if installed formula is not from this tap. - - new_tap = Tap.fetch(new_tap_name) - # For formulae migrated to cask: Auto-install cask or provide install instructions. - if new_tap_name.start_with?("homebrew/cask") - if new_tap.installed? && (HOMEBREW_PREFIX/"Caskroom").directory? - ohai "#{name} has been moved to Homebrew Cask." - ohai "brew unlink #{name}" - system HOMEBREW_BREW_FILE, "unlink", name - ohai "brew cleanup" - system HOMEBREW_BREW_FILE, "cleanup" - ohai "brew cask install #{new_name}" - system HOMEBREW_BREW_FILE, "cask", "install", new_name - ohai <<~EOS - #{name} has been moved to Homebrew Cask. - The existing keg has been unlinked. - Please uninstall the formula when convenient by running: - brew uninstall --force #{name} - EOS + core_tap.ensure_installed! + revision = CoreTap.instance.git_head + ENV["HOMEBREW_UPDATE_BEFORE_HOMEBREW_HOMEBREW_CORE"] = revision + ENV["HOMEBREW_UPDATE_AFTER_HOMEBREW_HOMEBREW_CORE"] = revision else - ohai "#{name} has been moved to Homebrew Cask.", <<~EOS - To uninstall the formula and install the cask run: - brew uninstall --force #{name} - brew tap #{new_tap_name} - brew cask install #{new_name} - EOS + return if Homebrew::EnvConfig.developer? || ENV["HOMEBREW_DEV_CMD_RUN"] + return if ENV["HOMEBREW_GITHUB_HOSTED_RUNNER"] || ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"] + return if (HOMEBREW_PREFIX/".homebrewdocker").exist? + + tap_output_header_printed = T.let(false, T::Boolean) + default_branches = %w[main master].freeze + [CoreTap.instance, CoreCaskTap.instance].each do |tap| + next unless tap.installed? + + if default_branches.include?(tap.git_branch) && + (Date.parse(T.must(tap.git_repository.last_commit_date)) <= Date.today.prev_month) + ohai "#{tap.name} is old and unneeded, untapping to save space..." + tap.uninstall + else + unless tap_output_header_printed + puts "Installing from the API is now the default behaviour!" + puts "You can save space and time by running:" + tap_output_header_printed = true + end + puts " brew untap #{tap.name}" + end + end end - else - new_tap.install unless new_tap.installed? - # update tap for each Tab - tabs.each { |tab| tab.tap = new_tap } - tabs.each(&:write) end - end - end - def migrate_formula_rename - Formula.installed.each do |formula| - next unless Migrator.needs_migration?(formula) - - oldname = formula.oldname - oldname_rack = HOMEBREW_CELLAR/oldname - - if oldname_rack.subdirs.empty? - oldname_rack.rmdir_if_possible - next + sig { params(repository: Pathname).void } + def link_completions_manpages_and_docs(repository = HOMEBREW_REPOSITORY) + command = "brew update" + Utils::Link.link_completions(repository, command) + Utils::Link.link_manpages(repository, command) + Utils::Link.link_docs(repository, command) + rescue => e + ofail <<~EOS + Failed to link all completions, docs and manpages: + #{e} + EOS end - new_name = tap.formula_renames[oldname] - next unless new_name - - new_full_name = "#{tap}/#{new_name}" - - begin - f = Formulary.factory(new_full_name) - rescue Exception => e # rubocop:disable Lint/RescueException - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer? - next + sig { void } + def migrate_gcc_dependents_if_needed + # do nothing end - Migrator.migrate_if_needed(f) - end - end + sig { void } + def analytics_message + return if Utils::Analytics.messages_displayed? + return if Utils::Analytics.no_message_output? + + if Utils::Analytics.disabled? && !Utils::Analytics.influx_message_displayed? + ohai "Homebrew's analytics have entirely moved to our InfluxDB instance in the EU." + puts "We gather less data than before and have destroyed all Google Analytics data:" + puts " #{Formatter.url("https://docs.brew.sh/Analytics")}#{Tty.reset}" + puts "Please reconsider re-enabling analytics to help our volunteer maintainers with:" + puts " brew analytics on" + elsif !Utils::Analytics.disabled? + ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1" + # Use the shell's audible bell. + print "\a" + + # Use an extra newline and bold to avoid this being missed. + ohai "Homebrew collects anonymous analytics." + puts <<~EOS + #{Tty.bold}Read the analytics documentation (and how to opt-out) here: + #{Formatter.url("https://docs.brew.sh/Analytics")}#{Tty.reset} + No analytics have been recorded yet (nor will be during this `brew` run). - private - - def diff - Utils.popen_read( - "git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR", - "-M85%", initial_revision, current_revision - ) - end -end - -class ReporterHub - extend Forwardable + EOS + end - attr_reader :reporters + # Consider the messages possibly missed if not a TTY. + Utils::Analytics.messages_displayed! if $stdout.tty? + end - def initialize - @hash = {} - @reporters = [] - end + sig { void } + def donation_message + return if Settings.read("donationmessage") == "true" - def select_formula(key) - @hash.fetch(key, []) - end + ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:" + puts " #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n\n" - def add(reporter) - @reporters << reporter - report = reporter.report.delete_if { |_k, v| v.empty? } - @hash.update(report) { |_key, oldval, newval| oldval.concat(newval) } - end + # Consider the message possibly missed if not a TTY. + Settings.write "donationmessage", true if $stdout.tty? + end - delegate empty?: :@hash + sig { void } + def install_from_api_message + return if Settings.read("installfromapimessage") == "true" - def dump - # Key Legend: Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R) + no_install_from_api_set = Homebrew::EnvConfig.no_install_from_api? && + !Homebrew::EnvConfig.automatically_set_no_install_from_api? + return unless no_install_from_api_set - dump_formula_report :A, "New Formulae" - dump_formula_report :M, "Updated Formulae" - dump_formula_report :R, "Renamed Formulae" - dump_formula_report :D, "Deleted Formulae" - end + ohai "You have `$HOMEBREW_NO_INSTALL_FROM_API` set" + puts "Homebrew >=4.1.0 is dramatically faster and less error-prone when installing" + puts "from the JSON API. Please consider unsetting `$HOMEBREW_NO_INSTALL_FROM_API`." + puts "This message will only be printed once." + puts "\n\n" - private - - def dump_formula_report(key, title) - formulae = select_formula(key).sort.map do |name, new_name| - # Format list items of renamed formulae - case key - when :R - name = pretty_installed(name) if installed?(name) - new_name = pretty_installed(new_name) if installed?(new_name) - "#{name} -> #{new_name}" - when :A - name unless installed?(name) - else - installed?(name) ? pretty_installed(name) : name + # Consider the message possibly missed if not a TTY. + Settings.write "installfromapimessage", true if $stdout.tty? end - end.compact - - return if formulae.empty? - - # Dump formula list. - ohai title - puts Formatter.columns(formulae.sort) - end - - def installed?(formula) - (HOMEBREW_CELLAR/formula.split("/").last).directory? + end end end + +require "extend/os/cmd/update-report" +require "cmd/update_report/reporter" +require "cmd/update_report/reporter_hub" diff --git a/Library/Homebrew/cmd/update-reset.rb b/Library/Homebrew/cmd/update-reset.rb new file mode 100644 index 0000000000000..ca304d5625eb9 --- /dev/null +++ b/Library/Homebrew/cmd/update-reset.rb @@ -0,0 +1,23 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class UpdateReset < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Fetch and reset Homebrew and all tap repositories (or any specified ) using `git`(1) to their latest `origin/HEAD`. + + *Note:* this will destroy all your uncommitted or committed changes. + EOS + + named_args :repository + end + end + end +end diff --git a/Library/Homebrew/cmd/update-reset.sh b/Library/Homebrew/cmd/update-reset.sh index 2b8de794a8705..9dda890544538 100644 --- a/Library/Homebrew/cmd/update-reset.sh +++ b/Library/Homebrew/cmd/update-reset.sh @@ -1,47 +1,97 @@ -#: * `update-reset` [] -#: -#: Fetch and reset Homebrew and all tap repositories (or any specified ) using `git`(1) to their latest `origin/master`. -#: -#: *Note:* this will destroy all your uncommitted or committed changes. +# Documentation defined in Library/Homebrew/cmd/update-reset.rb + +# HOMEBREW_LIBRARY is set by bin/brew +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" + +# Replaces the function in Library/Homebrew/brew.sh to cache the Git executable to provide +# speedup when using Git repeatedly and prevent errors if the shim changes mid-update. +git() { + if [[ -z "${GIT_EXECUTABLE}" ]] + then + # HOMEBREW_LIBRARY is set by bin/brew + # shellcheck disable=SC2154 + GIT_EXECUTABLE="$("${HOMEBREW_LIBRARY}/Homebrew/shims/shared/git" --homebrew=print-path)" + if [[ -z "${GIT_EXECUTABLE}" ]] + then + odie "Can't find a working Git!" + fi + fi + "${GIT_EXECUTABLE}" "$@" +} homebrew-update-reset() { + local option local DIR local -a REPOS=() for option in "$@" do - case "$option" in - -\?|-h|--help|--usage) brew help update-reset; exit $? ;; - --debug) HOMEBREW_DEBUG=1 ;; - -*) - [[ "$option" = *d* ]] && HOMEBREW_DEBUG=1 - ;; + if homebrew-command-help update-reset "${option}" + then + exit $? + fi + if homebrew-command-common-option "${option}" + then + continue + fi + + case "${option}" in + -*) homebrew-command-common-short-options "${option}" ;; *) - REPOS+=("$option") + if [[ -d "${option}/.git" ]] + then + REPOS+=("${option}") + else + onoe "${option} is not a Git repository!" + brew help update-reset + exit 1 + fi ;; esac done - if [[ -n "$HOMEBREW_DEBUG" ]] - then - set -x - fi + homebrew-command-enable-debug if [[ -z "${REPOS[*]}" ]] then - REPOS+=("$HOMEBREW_REPOSITORY" "$HOMEBREW_LIBRARY"/Taps/*/*) + # HOMEBREW_REPOSITORY is set by bin/brew + # shellcheck disable=SC2154 + REPOS+=("${HOMEBREW_REPOSITORY}" "${HOMEBREW_LIBRARY}"/Taps/*/*) fi for DIR in "${REPOS[@]}" do - [[ -d "$DIR/.git" ]] || continue - cd "$DIR" || continue - ohai "Fetching $DIR..." - git fetch --force --tags origin + [[ -d "${DIR}/.git" ]] || continue + if ! git -C "${DIR}" config --local --get remote.origin.url &>/dev/null + then + opoo "No remote 'origin' in ${DIR}, skipping update and reset!" + continue + fi + git -C "${DIR}" config --bool core.autocrlf false + git -C "${DIR}" config --bool core.symlinks true + ohai "Fetching ${DIR}..." + git -C "${DIR}" fetch --force --tags origin + git -C "${DIR}" remote set-head origin --auto >/dev/null echo - ohai "Resetting $DIR..." - git checkout --force -B master origin/master + ohai "Resetting ${DIR}..." + # HOMEBREW_* variables here may all be set by bin/brew or the user + # shellcheck disable=SC2154 + if [[ "${DIR}" == "${HOMEBREW_REPOSITORY}" && + (-n "${HOMEBREW_UPDATE_TO_TAG}" || + (-z "${HOMEBREW_DEVELOPER}" && -z "${HOMEBREW_DEV_CMD_RUN}")) ]] + then + local latest_git_tag + latest_git_tag="$(git -C "${DIR}" tag --list --sort="-version:refname" | head -n1)" + + git -C "${DIR}" checkout --force -B stable "refs/tags/${latest_git_tag}" + else + head="$(git -C "${DIR}" symbolic-ref refs/remotes/origin/HEAD)" + head="${head#refs/remotes/origin/}" + git -C "${DIR}" checkout --force -B "${head}" origin/HEAD + fi + rm -rf "${DIR}/.git/describe-cache" echo done } diff --git a/Library/Homebrew/cmd/update.rb b/Library/Homebrew/cmd/update.rb new file mode 100644 index 0000000000000..1106202b77b93 --- /dev/null +++ b/Library/Homebrew/cmd/update.rb @@ -0,0 +1,32 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Update < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Fetch the newest version of Homebrew and all formulae from GitHub using `git`(1) and perform any necessary migrations. + EOS + switch "--merge", + description: "Use `git merge` to apply updates (rather than `git rebase`).", + odeprecated: true + switch "--auto-update", + description: "Run on auto-updates (e.g. before `brew install`). Skips some slower steps." + switch "-f", "--force", + description: "Always do a slower, full update check (even if unnecessary)." + switch "-q", "--quiet", + description: "Make some output more quiet." + switch "-v", "--verbose", + description: "Print the directories checked and `git` operations performed." + switch "-d", "--debug", + description: "Display a trace of all shell commands as they are executed." + end + end + end +end diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index cf8aa8879b634..a9f355833a7e4 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -1,84 +1,165 @@ -#: * `update`, `up` [] -#: -#: Fetch the newest version of Homebrew and all formulae from GitHub using `git`(1) and perform any necessary migrations. -#: -#: --merge Use `git merge` to apply updates (rather than `git rebase`). -#: -f, --force Always do a slower, full update check (even if unnecessary). -#: -v, --verbose Print the directories checked and `git` operations performed. -#: -d, --debug Display a trace of all shell commands as they are executed. -#: -h, --help Show this message. - -# Don't need shellcheck to follow this `source`. -# shellcheck disable=SC1090 -source "$HOMEBREW_LIBRARY/Homebrew/utils/lock.sh" - -# Replaces the function in Library/Homebrew/brew.sh to cache the Git executable to -# provide speedup when using Git repeatedly (as update.sh does). +# Documentation defined in Library/Homebrew/cmd/update.rb + +# HOMEBREW_API_DOMAIN, HOMEBREW_CURLRC, HOMEBREW_DEBUG, HOMEBREW_DEVELOPER, HOMEBREW_GIT_EMAIL, HOMEBREW_GIT_NAME, +# HOMEBREW_GITHUB_API_TOKEN, HOMEBREW_NO_ENV_HINTS, HOMEBREW_NO_INSTALL_CLEANUP, HOMEBREW_NO_INSTALL_FROM_API, +# HOMEBREW_UPDATE_TO_TAG are from the user environment +# HOMEBREW_LIBRARY, HOMEBREW_PREFIX, HOMEBREW_REPOSITORY are set by bin/brew +# HOMEBREW_API_DEFAULT_DOMAIN, HOMEBREW_AUTO_UPDATE_CASK_TAP, HOMEBREW_AUTO_UPDATE_CORE_TAP, +# HOMEBREW_AUTO_UPDATE_SECS, HOMEBREW_BREW_DEFAULT_GIT_REMOTE, HOMEBREW_BREW_GIT_REMOTE, HOMEBREW_CACHE, +# HOMEBREW_CASK_REPOSITORY, HOMEBREW_CELLAR, HOMEBREW_CORE_DEFAULT_GIT_REMOTE, HOMEBREW_CORE_GIT_REMOTE, +# HOMEBREW_CORE_REPOSITORY, HOMEBREW_CURL, HOMEBREW_DEV_CMD_RUN, HOMEBREW_FORCE_BREWED_CA_CERTIFICATES, +# HOMEBREW_FORCE_BREWED_CURL, HOMEBREW_FORCE_BREWED_GIT, +# HOMEBREW_SYSTEM_CURL_TOO_OLD, HOMEBREW_USER_AGENT_CURL are set by brew.sh +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/api.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" + +macos_version_name() { + # NOTE: Changes to this list must match `SYMBOLS` in `macos_version.rb`. + if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "270000" ]] + then + echo "golden_gate" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "260000" ]] + then + echo "tahoe" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "150000" ]] + then + echo "sequoia" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "140000" ]] + then + echo "sonoma" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "130000" ]] + then + echo "ventura" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "120000" ]] + then + echo "monterey" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "110000" ]] + then + # odisabled: remove support for Big Sur and macOS x86_64 September (or later) 2027 + echo "big_sur" + elif [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -ge "101500" ]] + then + # odisabled: remove support for Catalina September (or later) 2026 + echo "catalina" + fi +} + +bottle_tag() { + if [[ -n "${HOMEBREW_MACOS}" && "${HOMEBREW_PHYSICAL_PROCESSOR}" == "x86_64" ]] + then + macos_version_name + elif [[ -n "${HOMEBREW_MACOS}" ]] + then + echo "${HOMEBREW_PHYSICAL_PROCESSOR}_$(macos_version_name)" + elif [[ -n "${HOMEBREW_LINUX}" ]] + then + echo "${HOMEBREW_PHYSICAL_PROCESSOR}_linux" + fi +} + +# Replaces the function in Library/Homebrew/brew.sh to cache the Curl/Git executable to +# provide speedup when using Curl/Git repeatedly (as update.sh does). +curl() { + if [[ -z "${CURL_EXECUTABLE}" ]] + then + CURL_EXECUTABLE="$("${HOMEBREW_LIBRARY}/Homebrew/shims/shared/curl" --homebrew=print-path)" + if [[ -z "${CURL_EXECUTABLE}" ]] + then + odie "Can't find a working Curl!" + fi + fi + "${CURL_EXECUTABLE}" "$@" +} + git() { - if [[ -z "$GIT_EXECUTABLE" ]] + if [[ -z "${GIT_EXECUTABLE}" ]] then - GIT_EXECUTABLE="$("$HOMEBREW_LIBRARY/Homebrew/shims/scm/git" --homebrew=print-path)" + GIT_EXECUTABLE="$("${HOMEBREW_LIBRARY}/Homebrew/shims/shared/git" --homebrew=print-path)" + if [[ -z "${GIT_EXECUTABLE}" ]] + then + odie "Can't find a working Git!" + fi fi - "$GIT_EXECUTABLE" "$@" + "${GIT_EXECUTABLE}" "$@" } git_init_if_necessary() { - safe_cd "$HOMEBREW_REPOSITORY" + safe_cd "${HOMEBREW_REPOSITORY}" if [[ ! -d ".git" ]] then set -e trap '{ rm -rf .git; exit 1; }' EXIT git init git config --bool core.autocrlf false - git config remote.origin.url "$HOMEBREW_BREW_GIT_REMOTE" + git config --bool core.symlinks true + if [[ "${HOMEBREW_BREW_DEFAULT_GIT_REMOTE}" != "${HOMEBREW_BREW_GIT_REMOTE}" ]] + then + echo "HOMEBREW_BREW_GIT_REMOTE set: using ${HOMEBREW_BREW_GIT_REMOTE} as the Homebrew/brew Git remote." + fi + git config remote.origin.url "${HOMEBREW_BREW_GIT_REMOTE}" git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" - latest_tag="$(git ls-remote --tags --refs -q origin | tail -n1 | cut -f2)" - git fetch --force origin --shallow-since="$latest_tag" - git reset --hard origin/master + git config fetch.prune true + git fetch --force --tags origin + git remote set-head origin --auto >/dev/null + git reset --hard origin/HEAD SKIP_FETCH_BREW_REPOSITORY=1 set +e trap - EXIT fi - [[ -d "$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-core" ]] || return - safe_cd "$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-core" + [[ -d "${HOMEBREW_CORE_REPOSITORY}" ]] || return + safe_cd "${HOMEBREW_CORE_REPOSITORY}" if [[ ! -d ".git" ]] then set -e trap '{ rm -rf .git; exit 1; }' EXIT git init git config --bool core.autocrlf false - git config remote.origin.url "$HOMEBREW_CORE_GIT_REMOTE" + git config --bool core.symlinks true + if [[ "${HOMEBREW_CORE_DEFAULT_GIT_REMOTE}" != "${HOMEBREW_CORE_GIT_REMOTE}" ]] + then + echo "HOMEBREW_CORE_GIT_REMOTE set: using ${HOMEBREW_CORE_GIT_REMOTE} as the Homebrew/homebrew-core Git remote." + fi + git config remote.origin.url "${HOMEBREW_CORE_GIT_REMOTE}" git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" - git fetch --force --depth=1 origin refs/heads/master:refs/remotes/origin/master - git reset --hard origin/master + git config fetch.prune true + git fetch --force origin + git remote set-head origin --auto >/dev/null + git reset --hard origin/HEAD SKIP_FETCH_CORE_REPOSITORY=1 set +e trap - EXIT fi } -repo_var() { - local repo_var +repository_var_suffix() { + local repository_directory="${1}" + local repository_var_suffix - repo_var="$1" - if [[ "$repo_var" = "$HOMEBREW_REPOSITORY" ]] + if [[ "${repository_directory}" == "${HOMEBREW_REPOSITORY}" ]] then - repo_var="" + repository_var_suffix="" else - repo_var="${repo_var#"$HOMEBREW_LIBRARY/Taps"}" - repo_var="$(echo -n "$repo_var" | tr -C "A-Za-z0-9" "_" | tr "[:lower:]" "[:upper:]")" + repository_var_suffix="${repository_directory#"${HOMEBREW_LIBRARY}/Taps"}" + repository_var_suffix="$(echo -n "${repository_var_suffix}" | tr -C "A-Za-z0-9" "_" | tr "[:lower:]" "[:upper:]")" fi - echo "$repo_var" + echo "${repository_var_suffix}" } upstream_branch() { local upstream_branch upstream_branch="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null)" + if [[ -z "${upstream_branch}" ]] + then + git remote set-head origin --auto >/dev/null + upstream_branch="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null)" + fi upstream_branch="${upstream_branch#refs/remotes/origin/}" - [[ -z "$upstream_branch" ]] && upstream_branch="master" - echo "$upstream_branch" + [[ -z "${upstream_branch}" ]] && upstream_branch="main" + echo "${upstream_branch}" } read_current_revision() { @@ -86,10 +167,10 @@ read_current_revision() { } pop_stash() { - [[ -z "$STASHED" ]] && return - if [[ -n "$HOMEBREW_VERBOSE" ]] + [[ -z "${STASHED}" ]] && return + if [[ -n "${HOMEBREW_VERBOSE}" ]] then - echo "Restoring your stashed changes to $DIR..." + echo "Restoring your stashed changes to ${DIR}..." git stash pop else git stash pop "${QUIET_ARGS[@]}" 1>/dev/null @@ -98,26 +179,26 @@ pop_stash() { } pop_stash_message() { - [[ -z "$STASHED" ]] && return - echo "To restore the stashed changes to $DIR run:" - echo " 'cd $DIR && git stash pop'" + [[ -z "${STASHED}" ]] && return + echo "To restore the stashed changes to ${DIR}, run:" + echo " cd ${DIR} && git stash pop" unset STASHED } reset_on_interrupt() { - if [[ "$INITIAL_BRANCH" != "$UPSTREAM_BRANCH" && -n "$INITIAL_BRANCH" ]] + if [[ "${INITIAL_BRANCH}" != "${UPSTREAM_BRANCH}" && -n "${INITIAL_BRANCH}" ]] then - git checkout "$INITIAL_BRANCH" "${QUIET_ARGS[@]}" + git checkout "${INITIAL_BRANCH}" "${QUIET_ARGS[@]}" fi - if [[ -n "$INITIAL_REVISION" ]] + if [[ -n "${INITIAL_REVISION}" ]] then git rebase --abort &>/dev/null git merge --abort &>/dev/null - git reset --hard "$INITIAL_REVISION" "${QUIET_ARGS[@]}" + git reset --hard "${INITIAL_REVISION}" "${QUIET_ARGS[@]}" fi - if [[ -n "$HOMEBREW_NO_UPDATE_CLEANUP" ]] + if [[ -n "${HOMEBREW_NO_UPDATE_CLEANUP}" ]] then pop_stash else @@ -137,28 +218,28 @@ simulate_from_current_branch() { local CURRENT_REVISION DIR="$1" - cd "$DIR" || return + cd "${DIR}" || return TAP_VAR="$2" UPSTREAM_BRANCH="$3" CURRENT_REVISION="$4" - INITIAL_REVISION="$(git rev-parse -q --verify "$UPSTREAM_BRANCH")" - export HOMEBREW_UPDATE_BEFORE"$TAP_VAR"="$INITIAL_REVISION" - export HOMEBREW_UPDATE_AFTER"$TAP_VAR"="$CURRENT_REVISION" - if [[ "$INITIAL_REVISION" != "$CURRENT_REVISION" ]] + INITIAL_REVISION="$(git rev-parse -q --verify "${UPSTREAM_BRANCH}")" + export HOMEBREW_UPDATE_BEFORE"${TAP_VAR}"="${INITIAL_REVISION}" + export HOMEBREW_UPDATE_AFTER"${TAP_VAR}"="${CURRENT_REVISION}" + if [[ "${INITIAL_REVISION}" != "${CURRENT_REVISION}" ]] then HOMEBREW_UPDATED="1" fi - if ! git merge-base --is-ancestor "$INITIAL_REVISION" "$CURRENT_REVISION" + if ! git merge-base --is-ancestor "${INITIAL_REVISION}" "${CURRENT_REVISION}" then - odie "Your $DIR HEAD is not a descendant of $UPSTREAM_BRANCH!" + odie "Your ${DIR} HEAD is not a descendant of ${UPSTREAM_BRANCH}!" fi } merge_or_rebase() { - if [[ -n "$HOMEBREW_VERBOSE" ]] + if [[ -n "${HOMEBREW_VERBOSE}" ]] then - echo "Updating $DIR..." + echo "Updating ${DIR}..." fi local DIR @@ -166,48 +247,49 @@ merge_or_rebase() { local UPSTREAM_BRANCH DIR="$1" - cd "$DIR" || return + cd "${DIR}" || return TAP_VAR="$2" UPSTREAM_BRANCH="$3" unset STASHED trap reset_on_interrupt SIGINT - if [[ "$DIR" = "$HOMEBREW_REPOSITORY" && -n "$HOMEBREW_UPDATE_TO_TAG" ]] + if [[ "${DIR}" == "${HOMEBREW_REPOSITORY}" && -n "${HOMEBREW_UPDATE_TO_TAG}" ]] then - UPSTREAM_TAG="$(git tag --list | - sort --field-separator=. --key=1,1nr -k 2,2nr -k 3,3nr | - grep --max-count=1 '^[0-9]*\.[0-9]*\.[0-9]*$')" + UPSTREAM_TAG="$( + git tag --list --sort=-version:refname | + grep -m 1 '^[0-9]*\.[0-9]*\.[0-9]*$' + )" else UPSTREAM_TAG="" fi - if [ -n "$UPSTREAM_TAG" ] + if [[ -n "${UPSTREAM_TAG}" ]] then - REMOTE_REF="refs/tags/$UPSTREAM_TAG" + REMOTE_REF="refs/tags/${UPSTREAM_TAG}" UPSTREAM_BRANCH="stable" else - REMOTE_REF="origin/$UPSTREAM_BRANCH" + REMOTE_REF="origin/${UPSTREAM_BRANCH}" fi if [[ -n "$(git status --untracked-files=all --porcelain 2>/dev/null)" ]] then - if [[ -n "$HOMEBREW_VERBOSE" ]] + if [[ -n "${HOMEBREW_VERBOSE}" ]] then - echo "Stashing uncommitted changes to $DIR..." + echo "Stashing uncommitted changes to ${DIR}..." fi git merge --abort &>/dev/null git rebase --abort &>/dev/null git reset --mixed "${QUIET_ARGS[@]}" if ! git -c "user.email=brew-update@localhost" \ - -c "user.name=brew update" \ - stash save --include-untracked "${QUIET_ARGS[@]}" + -c "user.name=brew update" \ + stash save --include-untracked "${QUIET_ARGS[@]}" then odie </dev/null + if [[ -z "${UPSTREAM_TAG}" ]] && + git merge-base --is-ancestor "${UPSTREAM_BRANCH}" "${REMOTE_REF}" &>/dev/null then - git checkout --force "$UPSTREAM_BRANCH" "${QUIET_ARGS[@]}" + git checkout --force "${UPSTREAM_BRANCH}" "${QUIET_ARGS[@]}" else - if [[ -n "$UPSTREAM_TAG" && "$UPSTREAM_BRANCH" != "master" ]] + if [[ -n "${UPSTREAM_TAG}" && "${UPSTREAM_BRANCH}" != "master" && "${UPSTREAM_BRANCH}" != "main" ]] && + [[ "${INITIAL_BRANCH}" != "master" && "${INITIAL_BRANCH}" != "main" ]] then - git checkout --force -B "master" "origin/master" "${QUIET_ARGS[@]}" + local detected_upstream_branch + detected_upstream_branch="$(upstream_branch)" + git branch --force "${detected_upstream_branch}" "origin/${detected_upstream_branch}" "${QUIET_ARGS[@]}" fi - git checkout --force -B "$UPSTREAM_BRANCH" "$REMOTE_REF" "${QUIET_ARGS[@]}" + git checkout --force -B "${UPSTREAM_BRANCH}" "${REMOTE_REF}" "${QUIET_ARGS[@]}" fi fi INITIAL_REVISION="$(read_current_revision)" - export HOMEBREW_UPDATE_BEFORE"$TAP_VAR"="$INITIAL_REVISION" + export HOMEBREW_UPDATE_BEFORE"${TAP_VAR}"="${INITIAL_REVISION}" # ensure we don't munge line endings on checkout - git config core.autocrlf false + git config --bool core.autocrlf false + + # make sure symlinks are saved as-is + git config --bool core.symlinks true - if [[ -z "$HOMEBREW_MERGE" ]] + if [[ -z "${HOMEBREW_MERGE}" ]] then # Work around bug where git rebase --quiet is not quiet - if [[ -z "$HOMEBREW_VERBOSE" ]] + if [[ -z "${HOMEBREW_VERBOSE}" ]] then - git rebase "$REMOTE_REF" >/dev/null + git rebase "${QUIET_ARGS[@]}" "${REMOTE_REF}" >/dev/null else - git rebase "${QUIET_ARGS[@]}" "$REMOTE_REF" + git rebase "${QUIET_ARGS[@]}" "${REMOTE_REF}" fi else - git merge --no-edit --ff "${QUIET_ARGS[@]}" "$REMOTE_REF" \ + git merge --no-edit --ff "${QUIET_ARGS[@]}" "${REMOTE_REF}" \ --strategy=recursive \ --strategy-option=ours \ --strategy-option=ignore-all-space fi CURRENT_REVISION="$(read_current_revision)" - export HOMEBREW_UPDATE_AFTER"$TAP_VAR"="$CURRENT_REVISION" + export HOMEBREW_UPDATE_AFTER"${TAP_VAR}"="${CURRENT_REVISION}" - if [[ "$INITIAL_REVISION" != "$CURRENT_REVISION" ]] + if [[ "${INITIAL_REVISION}" != "${CURRENT_REVISION}" ]] then HOMEBREW_UPDATED="1" fi trap '' SIGINT - if [[ -n "$HOMEBREW_NO_UPDATE_CLEANUP" ]] + if [[ -n "${HOMEBREW_NO_UPDATE_CLEANUP}" ]] then - if [[ "$INITIAL_BRANCH" != "$UPSTREAM_BRANCH" && -n "$INITIAL_BRANCH" && - ! "$INITIAL_BRANCH" =~ ^v[0-9]+\.[0-9]+\.[0-9]|stable$ ]] + if [[ -z "${MAIN_MIGRATION_REQUIRED}" && "${INITIAL_BRANCH}" != "${UPSTREAM_BRANCH}" && -n "${INITIAL_BRANCH}" ]] && + [[ ! "${INITIAL_BRANCH}" =~ ^v[0-9]+\.[0-9]+\.[0-9]|stable$ ]] then - git checkout "$INITIAL_BRANCH" "${QUIET_ARGS[@]}" + git checkout "${INITIAL_BRANCH}" "${QUIET_ARGS[@]}" fi pop_stash @@ -279,47 +375,158 @@ EOS pop_stash_message fi + if [[ -n "${MAIN_MIGRATION_REQUIRED}" ]] + then + if [[ -n "$(git config branch.main.remote 2>/dev/null || true)" ]] + then + git branch -d "${QUIET_ARGS[@]}" master + else + opoo "$( + cat <>"${update_failed_file}" + fi +} + homebrew-update() { + local arg local option local DIR local UPSTREAM_BRANCH for option in "$@" do - case "$option" in - -\?|-h|--help|--usage) brew help update; exit $? ;; - --verbose) HOMEBREW_VERBOSE=1 ;; - --debug) HOMEBREW_DEBUG=1 ;; - --merge) HOMEBREW_MERGE=1 ;; - --force) HOMEBREW_UPDATE_FORCE=1 ;; - --simulate-from-current-branch) HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH=1 ;; - --preinstall) export HOMEBREW_UPDATE_PREINSTALL=1 ;; - --*) ;; + if homebrew-command-help update "${option}" + then + exit $? + fi + if homebrew-command-common-option "${option}" + then + continue + fi + + case "${option}" in + --merge) + shift + HOMEBREW_MERGE=1 + ;; + --force) HOMEBREW_UPDATE_FORCE=1 ;; + --simulate-from-current-branch) + shift + HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH=1 + ;; + --auto-update) export HOMEBREW_UPDATE_AUTO=1 ;; + --*) + onoe "Unknown option: ${option}" + brew help update + exit 1 + ;; -*) - [[ "$option" = *v* ]] && HOMEBREW_VERBOSE=1 - [[ "$option" = *d* ]] && HOMEBREW_DEBUG=1 - [[ "$option" = *f* ]] && HOMEBREW_UPDATE_FORCE=1 + homebrew-command-common-short-options "${option}" + [[ "${option}" == *f* ]] && HOMEBREW_UPDATE_FORCE=1 ;; *) - odie </dev/null || - [[ -n "$HOMEBREW_FORCE_BREWED_GIT" && - ! -x "$HOMEBREW_PREFIX/opt/git/bin/git" ]] + [[ -n "${HOMEBREW_FORCE_BREWED_GIT}" && ! -x "${HOMEBREW_PREFIX}/opt/git/bin/git" ]] then - # we cannot install brewed git if homebrew/core is unavailable. - [[ -d "$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-core" ]] && brew install git - unset GIT_EXECUTABLE - if ! git --version &>/dev/null + # we cannot install a Homebrew Git if homebrew/core is unavailable. + if [[ -z "${HOMEBREW_CORE_AVAILABLE}" ]] || ! brew install git then - odie "Git must be installed and in your PATH!" + odie "'git' must be installed and in your PATH!" fi + + setup_git fi - export GIT_TERMINAL_PROMPT="0" - export GIT_SSH_COMMAND="ssh -oBatchMode=yes" - if [[ -z "$HOMEBREW_VERBOSE" ]] + [[ -f "${HOMEBREW_CORE_REPOSITORY}/.git/shallow" ]] && HOMEBREW_CORE_SHALLOW=1 + [[ -f "${HOMEBREW_CASK_REPOSITORY}/.git/shallow" ]] && HOMEBREW_CASK_SHALLOW=1 + if [[ -n "${HOMEBREW_CORE_SHALLOW}" && -n "${HOMEBREW_CASK_SHALLOW}" ]] then - QUIET_ARGS=(-q) + SHALLOW_COMMAND_PHRASE="These commands" + SHALLOW_REPO_PHRASE="repositories" else - QUIET_ARGS=() + SHALLOW_COMMAND_PHRASE="This command" + SHALLOW_REPO_PHRASE="repository" fi - if [[ -z "$HOMEBREW_CURLRC" ]] + if [[ -n "${HOMEBREW_CORE_SHALLOW}" || -n "${HOMEBREW_CASK_SHALLOW}" ]] then - CURL_DISABLE_CURLRC_ARGS=(-q) + odie </dev/null + then + opoo "No remote 'origin' in ${DIR}, skipping update!" + continue + fi + + if [[ -n "${HOMEBREW_VERBOSE}" ]] then - echo "Checking if we need to fetch $DIR..." + echo "Checking if we need to fetch ${DIR}..." fi - TAP_VAR="$(repo_var "$DIR")" + TAP_VAR="$(repository_var_suffix "${DIR}")" UPSTREAM_BRANCH_DIR="$(upstream_branch)" - declare UPSTREAM_BRANCH"$TAP_VAR"="$UPSTREAM_BRANCH_DIR" - declare PREFETCH_REVISION"$TAP_VAR"="$(git rev-parse -q --verify refs/remotes/origin/"$UPSTREAM_BRANCH_DIR")" + declare UPSTREAM_BRANCH"${TAP_VAR}"="${UPSTREAM_BRANCH_DIR}" + declare PREFETCH_REVISION"${TAP_VAR}"="$(git rev-parse -q --verify refs/remotes/origin/"${UPSTREAM_BRANCH_DIR}")" + + if [[ -n "${GITHUB_ACTIONS}" && -n "${HOMEBREW_UPDATE_SKIP_BREW}" && "${DIR}" == "${HOMEBREW_REPOSITORY}" ]] + then + continue + fi # Force a full update if we don't have any tags. - if [[ "$DIR" = "$HOMEBREW_REPOSITORY" && -z "$(git tag --list)" ]] + if [[ "${DIR}" == "${HOMEBREW_REPOSITORY}" && -z "$(git tag --list)" ]] then HOMEBREW_UPDATE_FORCE=1 fi - if [[ -z "$HOMEBREW_UPDATE_FORCE" ]] + if [[ -z "${HOMEBREW_UPDATE_FORCE}" ]] then - [[ -n "$SKIP_FETCH_BREW_REPOSITORY" && "$DIR" = "$HOMEBREW_REPOSITORY" ]] && continue - [[ -n "$SKIP_FETCH_CORE_REPOSITORY" && "$DIR" = "$HOMEBREW_LIBRARY/Taps/homebrew/homebrew-core" ]] && continue + [[ -n "${SKIP_FETCH_BREW_REPOSITORY}" && "${DIR}" == "${HOMEBREW_REPOSITORY}" ]] && continue + [[ -n "${SKIP_FETCH_CORE_REPOSITORY}" && "${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" ]] && continue fi - # The upstream repository's default branch may not be master; + if [[ -z "${UPDATING_MESSAGE_SHOWN}" ]] + then + if [[ -n "${HOMEBREW_UPDATE_AUTO}" ]] + then + # Outputting a command but don't want to run it, hence single quotes. + # shellcheck disable=SC2016 + ohai 'Auto-updating Homebrew...' >&2 + if [[ -z "${HOMEBREW_NO_ENV_HINTS}" && -z "${HOMEBREW_AUTO_UPDATE_SECS}" ]] + then + # shellcheck disable=SC2016 + echo 'Adjust how often this is run with `$HOMEBREW_AUTO_UPDATE_SECS` or disable with' >&2 + # shellcheck disable=SC2016 + echo '`$HOMEBREW_NO_AUTO_UPDATE=1`. Hide these hints with `$HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).' >&2 + fi + else + ohai 'Updating Homebrew...' >&2 + fi + UPDATING_MESSAGE_SHOWN=1 + fi + + # The upstream repository's default branch may not be main or master; # check refs/remotes/origin/HEAD to see what the default - # origin branch name is, and use that. If not set, fall back to "master". + # origin branch name is, and use that. If not set, fall back to "main". # the refspec ensures that the default upstream branch gets updated ( UPSTREAM_REPOSITORY_URL="$(git config remote.origin.url)" - if [[ "$UPSTREAM_REPOSITORY_URL" = "https://github.com/"* ]] + unset UPSTREAM_REPOSITORY + unset UPSTREAM_REPOSITORY_TOKEN + + # HOMEBREW_UPDATE_FORCE and HOMEBREW_UPDATE_AUTO aren't modified here so ignore subshell warning. + # shellcheck disable=SC2030 + if [[ "${UPSTREAM_REPOSITORY_URL}" == "https://github.com/"* ]] then UPSTREAM_REPOSITORY="${UPSTREAM_REPOSITORY_URL#https://github.com/}" UPSTREAM_REPOSITORY="${UPSTREAM_REPOSITORY%.git}" + elif [[ "${DIR}" != "${HOMEBREW_REPOSITORY}" ]] && + [[ "${UPSTREAM_REPOSITORY_URL}" =~ https://([[:alnum:]_:]+)@github.com/(.*)$ ]] + then + UPSTREAM_REPOSITORY="${BASH_REMATCH[2]%.git}" + UPSTREAM_REPOSITORY_TOKEN="${BASH_REMATCH[1]#*:}" + fi + + MAIN_MIGRATION_REQUIRED= + if [[ "${UPSTREAM_BRANCH_DIR}" == "master" && + ("${DIR}" == "${HOMEBREW_REPOSITORY}" || "${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" || "${DIR}" == "${HOMEBREW_CASK_REPOSITORY}") ]] + then + # Migrate master to main for Homebrew/brew, homebrew-core or homebrew-cask + MAIN_MIGRATION_REQUIRED=1 + UPSTREAM_BRANCH_DIR="main" + declare UPSTREAM_BRANCH"${TAP_VAR}"="main" + fi + + if [[ -n "${UPSTREAM_REPOSITORY}" ]] && + [[ -z "${HOMEBREW_UPDATE_FORCE}" || "${DIR}" == "${HOMEBREW_LIBRARY}"/Taps/*/* ]] + then + # UPSTREAM_REPOSITORY_TOKEN is parsed (if exists) from UPSTREAM_REPOSITORY_URL + # HOMEBREW_GITHUB_API_TOKEN is optionally defined in the user environment. + # shellcheck disable=SC2153 + if [[ -n "${UPSTREAM_REPOSITORY_TOKEN}" ]] + then + CURL_GITHUB_API_ARGS=("--header" "Authorization: token ${UPSTREAM_REPOSITORY_TOKEN}") + elif [[ -n "${HOMEBREW_GITHUB_API_TOKEN}" ]] + then + CURL_GITHUB_API_ARGS=("--header" "Authorization: token ${HOMEBREW_GITHUB_API_TOKEN}") + else + CURL_GITHUB_API_ARGS=() + fi - if [[ "$DIR" = "$HOMEBREW_REPOSITORY" && -n "$HOMEBREW_UPDATE_TO_TAG" ]] + if [[ "${DIR}" == "${HOMEBREW_REPOSITORY}" && -n "${HOMEBREW_UPDATE_TO_TAG}" ]] then # Only try to `git fetch` when the upstream tags have changed # (so the API does not return 304: unmodified). GITHUB_API_ETAG="$(sed -n 's/^ETag: "\([a-f0-9]\{32\}\)".*/\1/p' ".git/GITHUB_HEADERS" 2>/dev/null)" - GITHUB_API_ACCEPT="application/vnd.github.v3+json" + GITHUB_API_ACCEPT="application/vnd.github+json" GITHUB_API_ENDPOINT="tags" else # Only try to `git fetch` when the upstream branch is at a different SHA # (so the API does not return 304: unmodified). - GITHUB_API_ETAG="$(git rev-parse "refs/remotes/origin/$UPSTREAM_BRANCH_DIR")" - GITHUB_API_ACCEPT="application/vnd.github.v3.sha" - GITHUB_API_ENDPOINT="commits/$UPSTREAM_BRANCH_DIR" + GITHUB_API_ETAG="$(git rev-parse "refs/remotes/origin/${UPSTREAM_BRANCH_DIR}")" + GITHUB_API_ACCEPT="application/vnd.github.sha" + GITHUB_API_ENDPOINT="commits/${UPSTREAM_BRANCH_DIR}" fi - UPSTREAM_SHA_HTTP_CODE="$("$HOMEBREW_CURL" \ - "${CURL_DISABLE_CURLRC_ARGS[@]}" \ - --silent --max-time 3 \ - --location --output /dev/null --write-out "%{http_code}" \ - --dump-header "$DIR/.git/GITHUB_HEADERS" \ - --user-agent "$HOMEBREW_USER_AGENT_CURL" \ - --header "Accept: $GITHUB_API_ACCEPT" \ - --header "If-None-Match: \"$GITHUB_API_ETAG\"" \ - "https://api.github.com/repos/$UPSTREAM_REPOSITORY/$GITHUB_API_ENDPOINT")" + local upstream_api_url="https://api.github.com/repos/${UPSTREAM_REPOSITORY}/${GITHUB_API_ENDPOINT}" + + # HOMEBREW_CURL is set by brew.sh (and isn't misspelt here) + # shellcheck disable=SC2153 + UPSTREAM_SHA_HTTP_RESPONSE="$( + curl \ + "${CURL_DISABLE_CURLRC_ARGS[@]}" \ + "${CURL_GITHUB_API_ARGS[@]}" \ + --silent --max-time 3 \ + --location --no-remote-time --output /dev/null --write-out "%{http_code} %{url_effective}" \ + --dump-header "${DIR}/.git/GITHUB_HEADERS" \ + --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ + --header "X-GitHub-Api-Version:2022-11-28" \ + --header "Accept: ${GITHUB_API_ACCEPT}" \ + --header "If-None-Match: \"${GITHUB_API_ETAG}\"" \ + "${upstream_api_url}" + )" + UPSTREAM_SHA_HTTP_CODE="${UPSTREAM_SHA_HTTP_RESPONSE%% *}" + UPSTREAM_SHA_HTTP_EFFECTIVE_URL="${UPSTREAM_SHA_HTTP_RESPONSE#* }" + + if [[ "${DIR}" == "${HOMEBREW_LIBRARY}"/Taps/*/* ]] && + [[ -n "${UPSTREAM_SHA_HTTP_EFFECTIVE_URL}" ]] && + [[ "${UPSTREAM_SHA_HTTP_EFFECTIVE_URL}" != "${upstream_api_url}" ]] + then + redirected_remote="$( + curl \ + "${CURL_DISABLE_CURLRC_ARGS[@]}" \ + "${CURL_GITHUB_API_ARGS[@]}" \ + --silent --max-time 3 \ + --location --no-remote-time \ + --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ + --header "X-GitHub-Api-Version:2022-11-28" \ + --header "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${UPSTREAM_REPOSITORY}" | + sed -n 's/^[[:space:]]*"clone_url": "\(.*\)",$/\1/p' | + head -n1 + )" + if [[ -n "${redirected_remote}" ]] + then + printf "%s\t%s\n" "${DIR}" "${redirected_remote}" >>"${redirected_remotes_file}" + fi + fi # Touch FETCH_HEAD to confirm we've checked for an update. - [[ -f "$DIR/.git/FETCH_HEAD" ]] && touch "$DIR/.git/FETCH_HEAD" - [[ -z "$HOMEBREW_UPDATE_FORCE" ]] && [[ "$UPSTREAM_SHA_HTTP_CODE" = "304" ]] && exit - elif [[ -n "$HOMEBREW_UPDATE_PREINSTALL" ]] - then - FORCE_AUTO_UPDATE="$(git config homebrew.forceautoupdate 2>/dev/null || echo "false")" - if [[ "$FORCE_AUTO_UPDATE" != "true" ]] + [[ -f "${DIR}/.git/FETCH_HEAD" ]] && touch "${DIR}/.git/FETCH_HEAD" + if [[ -z "${HOMEBREW_UPDATE_FORCE}" ]] && + [[ "${UPSTREAM_SHA_HTTP_CODE}" == "304" ]] && + [[ "${DIR}" != "${HOMEBREW_LIBRARY}"/Taps/*/* || + "${UPSTREAM_SHA_HTTP_EFFECTIVE_URL}" == "${upstream_api_url}" ]] then - # Don't try to do a `git fetch` that may take longer than expected. exit fi fi - if [[ -n "$HOMEBREW_VERBOSE" ]] + # HOMEBREW_VERBOSE isn't modified here so ignore subshell warning. + # shellcheck disable=SC2030 + if [[ -n "${HOMEBREW_VERBOSE}" ]] then - echo "Fetching $DIR..." + echo "Fetching ${DIR}..." fi - if [[ -n "$HOMEBREW_UPDATE_PREINSTALL" ]] + local tmp_failure_file="${DIR}/.git/TMP_FETCH_FAILURES" + rm -f "${tmp_failure_file}" + + if ! git fetch --tags --force "${QUIET_ARGS[@]}" origin \ + "refs/heads/${UPSTREAM_BRANCH_DIR}:refs/remotes/origin/${UPSTREAM_BRANCH_DIR}" 2>>"${tmp_failure_file}" then - git fetch --tags --force "${QUIET_ARGS[@]}" origin \ - "refs/heads/$UPSTREAM_BRANCH_DIR:refs/remotes/origin/$UPSTREAM_BRANCH_DIR" 2>/dev/null - else - if ! git fetch --tags --force "${QUIET_ARGS[@]}" origin \ - "refs/heads/$UPSTREAM_BRANCH_DIR:refs/remotes/origin/$UPSTREAM_BRANCH_DIR" + if [[ -f "${tmp_failure_file}" ]] then - if [[ "$UPSTREAM_SHA_HTTP_CODE" = "404" ]] + local git_errors + git_errors="$(cat "${tmp_failure_file}")" + # Attempt migration from master to main branch. + if [[ "${git_errors}" == *"couldn't find remote ref refs/heads/master"* ]] + then + if git fetch --tags --force "${QUIET_ARGS[@]}" origin \ + "refs/heads/main:refs/remotes/origin/main" 2>>"${tmp_failure_file}" + then + rm -f "${DIR}/.git/refs/remotes/origin/HEAD" "${DIR}/.git/refs/remotes/origin/master" + UPSTREAM_BRANCH_DIR="$(upstream_branch)" + declare UPSTREAM_BRANCH"${TAP_VAR}"="${UPSTREAM_BRANCH_DIR}" + git branch -m master main "${QUIET_ARGS[@]}" + git branch -u origin/main main "${QUIET_ARGS[@]}" + rm -f "${tmp_failure_file}" + exit + fi + fi + + rm -f "${tmp_failure_file}" + fi + + # Don't output errors if HOMEBREW_UPDATE_AUTO is set. + if [[ -n "${HOMEBREW_UPDATE_AUTO}" ]] + then + exit + fi + + # Reprint fetch errors to stderr + [[ -n "${git_errors}" ]] && echo "${git_errors}" 1>&2 + + if [[ "${UPSTREAM_SHA_HTTP_CODE}" == "404" ]] + then + TAP="${DIR#"${HOMEBREW_LIBRARY}"/Taps/}" + echo "${TAP} does not exist! Run \`brew untap ${TAP}\` to remove it." >>"${update_failed_file}" + else + echo "Fetching ${DIR} failed!" >>"${update_failed_file}" + + if [[ -f "${tmp_failure_file}" ]] && + [[ "$(cat "${tmp_failure_file}")" == "fatal: couldn't find remote ref refs/heads/${UPSTREAM_BRANCH_DIR}" ]] then - TAP="${DIR#$HOMEBREW_LIBRARY/Taps/}" - echo "$TAP does not exist! Run \`brew untap $TAP\` to remove it." >>"$update_failed_file" - else - echo "Fetching $DIR failed!" >>"$update_failed_file" + echo "${DIR}" >>"${missing_remote_ref_dirs_file}" fi fi + elif [[ "${DIR}" == "${HOMEBREW_LIBRARY}"/Taps/*/* ]] && + [[ -s "${tmp_failure_file}" ]] + then + redirected_remote="$(sed -n 's/.*redirecting to //p' "${tmp_failure_file}" | tail -n1)" + if [[ -n "${redirected_remote}" ]] + then + printf "%s\t%s\n" "${DIR}" "${redirected_remote}" >>"${redirected_remotes_file}" + fi fi + + if [[ -n "${MAIN_MIGRATION_REQUIRED}" ]] + then + git remote set-head origin --auto >/dev/null + fi + + rm -f "${tmp_failure_file}" ) & done wait trap - SIGINT - if [[ -f "$update_failed_file" ]] + if [[ -f "${missing_remote_ref_dirs_file}" ]] then - onoe <"$update_failed_file" - rm -f "$update_failed_file" - export HOMEBREW_UPDATE_FAILED="1" + HOMEBREW_MISSING_REMOTE_REF_DIRS="$(cat "${missing_remote_ref_dirs_file}")" + rm -f "${missing_remote_ref_dirs_file}" + export HOMEBREW_MISSING_REMOTE_REF_DIRS + fi + + if [[ -f "${redirected_remotes_file}" ]] + then + export HOMEBREW_REDIRECTED_REMOTES_FILE="${redirected_remotes_file}" fi - for DIR in "$HOMEBREW_REPOSITORY" "$HOMEBREW_LIBRARY"/Taps/*/* + for DIR in "${HOMEBREW_REPOSITORY}" "${HOMEBREW_LIBRARY}"/Taps/*/* do - [[ -d "$DIR/.git" ]] || continue - cd "$DIR" || continue + if [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" ]] && + [[ -n "${HOMEBREW_UPDATE_AUTO}" || (-z "${HOMEBREW_DEVELOPER}" && -z "${HOMEBREW_DEV_CMD_RUN}") ]] && + [[ -n "${HOMEBREW_UPDATE_AUTO}" && + (("${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" && -z "${HOMEBREW_AUTO_UPDATE_CORE_TAP}") || + ("${DIR}" == "${HOMEBREW_CASK_REPOSITORY}" && -z "${HOMEBREW_AUTO_UPDATE_CASK_TAP}")) ]] + then + continue + fi - TAP_VAR="$(repo_var "$DIR")" - UPSTREAM_BRANCH_VAR="UPSTREAM_BRANCH$TAP_VAR" + [[ -d "${DIR}/.git" ]] || continue + cd "${DIR}" || continue + if ! git config --local --get remote.origin.url &>/dev/null + then + # No need to display a (duplicate) warning here + continue + fi + + TAP_VAR="$(repository_var_suffix "${DIR}")" + UPSTREAM_BRANCH_VAR="UPSTREAM_BRANCH${TAP_VAR}" UPSTREAM_BRANCH="${!UPSTREAM_BRANCH_VAR}" CURRENT_REVISION="$(read_current_revision)" - PREFETCH_REVISION_VAR="PREFETCH_REVISION$TAP_VAR" + PREFETCH_REVISION_VAR="PREFETCH_REVISION${TAP_VAR}" PREFETCH_REVISION="${!PREFETCH_REVISION_VAR}" - POSTFETCH_REVISION="$(git rev-parse -q --verify refs/remotes/origin/"$UPSTREAM_BRANCH")" + POSTFETCH_REVISION="$(git rev-parse -q --verify refs/remotes/origin/"${UPSTREAM_BRANCH}")" - if [[ -n "$HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH" ]] + # HOMEBREW_UPDATE_FORCE and HOMEBREW_VERBOSE weren't modified in subshell. + # shellcheck disable=SC2031 + if [[ -n "${HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH}" ]] then - simulate_from_current_branch "$DIR" "$TAP_VAR" "$UPSTREAM_BRANCH" "$CURRENT_REVISION" - elif [[ -z "$HOMEBREW_UPDATE_FORCE" ]] && - [[ "$PREFETCH_REVISION" = "$POSTFETCH_REVISION" ]] && - [[ "$CURRENT_REVISION" = "$POSTFETCH_REVISION" ]] + simulate_from_current_branch "${DIR}" "${TAP_VAR}" "${UPSTREAM_BRANCH}" "${CURRENT_REVISION}" + elif [[ -z "${HOMEBREW_UPDATE_FORCE}" && + "${PREFETCH_REVISION}" == "${POSTFETCH_REVISION}" && + "${CURRENT_REVISION}" == "${POSTFETCH_REVISION}" ]] || + [[ -n "${GITHUB_ACTIONS}" && -n "${HOMEBREW_UPDATE_SKIP_BREW}" && "${DIR}" == "${HOMEBREW_REPOSITORY}" ]] then - export HOMEBREW_UPDATE_BEFORE"$TAP_VAR"="$CURRENT_REVISION" - export HOMEBREW_UPDATE_AFTER"$TAP_VAR"="$CURRENT_REVISION" + export HOMEBREW_UPDATE_BEFORE"${TAP_VAR}"="${CURRENT_REVISION}" + export HOMEBREW_UPDATE_AFTER"${TAP_VAR}"="${CURRENT_REVISION}" else - merge_or_rebase "$DIR" "$TAP_VAR" "$UPSTREAM_BRANCH" - [[ -n "$HOMEBREW_VERBOSE" ]] && echo + merge_or_rebase "${DIR}" "${TAP_VAR}" "${UPSTREAM_BRANCH}" fi done - safe_cd "$HOMEBREW_REPOSITORY" + if [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" ]] + then + api_files=("internal/packages.$(bottle_tag)") + + for json in "${api_files[@]}" + do + local filename="${json}.jws.json" + fetch_api_file "${filename}" "${update_failed_file}" + done + + rm -f "${HOMEBREW_CACHE}/api/formula.jws.json" "${HOMEBREW_CACHE}/api/cask.jws.json" + rm -f "${HOMEBREW_CACHE}/api/formula_tap_migrations.jws.json" "${HOMEBREW_CACHE}/api/cask_tap_migrations.jws.json" + + # Not a typo, these are the files we used to download that no longer need so should cleanup. + rm -f "${HOMEBREW_CACHE}/api/formula.json" "${HOMEBREW_CACHE}/api/cask.json" + rm -f "${HOMEBREW_CACHE}"/api/internal/formula.*.jws.json + rm -f "${HOMEBREW_CACHE}"/api/internal/cask.*.jws.json + + # Remove API files from previous OS versions. + for f in "${HOMEBREW_CACHE}"/api/internal/packages.*.jws.json + do + case "${f}" in + "${HOMEBREW_CACHE}/api/internal/packages.$(bottle_tag).jws.json") ;; + *) rm -f "${f}" ;; + esac + done + else + if [[ -n "${HOMEBREW_VERBOSE}" ]] + then + echo "HOMEBREW_NO_INSTALL_FROM_API set: skipping API JSON downloads." + fi + fi + + if [[ -f "${update_failed_file}" ]] + then + onoe <"${update_failed_file}" + rm -f "${update_failed_file}" + export HOMEBREW_UPDATE_FAILED="1" + fi - if [[ -n "$HOMEBREW_UPDATED" || - -n "$HOMEBREW_UPDATE_FAILED" || - -n "$HOMEBREW_UPDATE_FORCE" || - -d "$HOMEBREW_LIBRARY/LinkedKegs" || - (-n "$HOMEBREW_DEVELOPER" && -z "$HOMEBREW_UPDATE_PREINSTALL") ]] + safe_cd "${HOMEBREW_REPOSITORY}" + + # HOMEBREW_UPDATE_AUTO wasn't modified in subshell. + # shellcheck disable=SC2031 + if [[ -n "${HOMEBREW_UPDATED}" ]] || + [[ -n "${HOMEBREW_UPDATE_FAILED}" ]] || + [[ -n "${HOMEBREW_MISSING_REMOTE_REF_DIRS}" ]] || + [[ -n "${HOMEBREW_REDIRECTED_REMOTES_FILE}" ]] || + [[ -n "${HOMEBREW_UPDATE_FORCE}" ]] || + [[ -d "${HOMEBREW_LIBRARY}/LinkedKegs" ]] || + [[ ! -f "${HOMEBREW_CACHE}/all_commands_list.txt" ]] || + [[ -n "${HOMEBREW_DEVELOPER}" && -z "${HOMEBREW_UPDATE_AUTO}" ]] then - unset HOMEBREW_RUBY_PATH brew update-report "$@" return $? - elif [[ -z "$HOMEBREW_UPDATE_PREINSTALL" ]] + elif [[ -z "${HOMEBREW_UPDATE_AUTO}" && -z "${HOMEBREW_QUIET}" ]] then echo "Already up-to-date." fi diff --git a/Library/Homebrew/cmd/update_report/reporter.rb b/Library/Homebrew/cmd/update_report/reporter.rb new file mode 100644 index 0000000000000..cc255daec80a8 --- /dev/null +++ b/Library/Homebrew/cmd/update_report/reporter.rb @@ -0,0 +1,439 @@ +# typed: strict +# frozen_string_literal: true + +require "trust" + +class Reporter + include Utils::Output::Mixin + + Report = T.type_alias do + { + A: T::Array[String], + AC: T::Array[String], + D: T::Array[String], + DC: T::Array[String], + M: T::Array[String], + MC: T::Array[String], + R: T::Array[[String, String]], + RC: T::Array[[String, String]], + T: T::Array[String], + } + end + + class ReporterRevisionUnsetError < RuntimeError + sig { params(var_name: String).void } + def initialize(var_name) + super "#{var_name} is unset!" + end + end + + sig { + params(tap: Tap, api_names_txt: T.nilable(Pathname), api_names_before_txt: T.nilable(Pathname), + api_dir_prefix: T.nilable(Pathname)).void + } + def initialize(tap, api_names_txt: nil, api_names_before_txt: nil, api_dir_prefix: nil) + @tap = tap + + # This is slightly involved/weird but all the #report logic is shared so it's worth it. + if installed_from_api?(api_names_txt, api_names_before_txt, api_dir_prefix) + @api_names_txt = T.let(api_names_txt, T.nilable(Pathname)) + @api_names_before_txt = T.let(api_names_before_txt, T.nilable(Pathname)) + @api_dir_prefix = T.let(api_dir_prefix, T.nilable(Pathname)) + else + initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{tap.repository_var_suffix}" + @initial_revision = T.let(ENV[initial_revision_var].to_s, String) + raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty? + + current_revision_var = "HOMEBREW_UPDATE_AFTER#{tap.repository_var_suffix}" + @current_revision = T.let(ENV[current_revision_var].to_s, String) + raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty? + end + + @report = T.let(nil, T.nilable(Report)) + end + + sig { params(auto_update: T::Boolean).returns(Report) } + def report(auto_update: false) + return @report if @report + + @report = { + A: [], AC: [], D: [], DC: [], M: [], MC: [], R: T.let([], T::Array[[String, String]]), + RC: T.let([], T::Array[[String, String]]), T: [] + } + return @report unless updated? + + diff.each_line do |line| + status, *paths = line.split + src = Pathname.new paths.first + dst = Pathname.new paths.last + + next if dst.extname != ".rb" + + if paths.any? { |p| tap.cask_file?(p) } + case status + when "A" + # Have a dedicated report array for new casks. + @report[:AC] << tap.formula_file_to_name(src) + when "D" + # Have a dedicated report array for deleted casks. + @report[:DC] << tap.formula_file_to_name(src) + when "M" + # Report updated casks + @report[:MC] << tap.formula_file_to_name(src) + when /^R\d{0,3}/ + src_full_name = tap.formula_file_to_name(src) + dst_full_name = tap.formula_file_to_name(dst) + # Don't report formulae that are moved within a tap but not renamed + next if src_full_name == dst_full_name + + @report[:DC] << src_full_name + @report[:AC] << dst_full_name + end + end + + next unless paths.any? do |p| + tap.formula_file?(p) || + # Need to check for case where Formula directory was deleted + (status == "D" && File.fnmatch?("{Homebrew,}Formula/**/*.rb", p, File::FNM_EXTGLOB | File::FNM_PATHNAME)) + end + + case status + when "A", "D" + full_name = tap.formula_file_to_name(src) + name = Utils.name_from_full_name(full_name) + new_tap = tap.tap_migrations[name] + if new_tap.blank? + @report[T.must(status).to_sym] << full_name + elsif status == "D" + # Retain deleted formulae for tap migrations separately to avoid reporting as deleted + @report[:T] << full_name + end + when "M" + name = tap.formula_file_to_name(src) + + @report[:M] << name + when /^R\d{0,3}/ + src_full_name = tap.formula_file_to_name(src) + dst_full_name = tap.formula_file_to_name(dst) + # Don't report formulae that are moved within a tap but not renamed + next if src_full_name == dst_full_name + + @report[:D] << src_full_name + @report[:A] << dst_full_name + end + end + + renamed_casks = Set.new + @report[:DC].each do |old_full_name| + old_name = Utils.name_from_full_name(old_full_name) + new_name = tap.cask_renames[old_name] + next unless new_name + + new_full_name = if tap.core_cask_tap? + new_name + else + "#{tap}/#{new_name}" + end + + renamed_casks << [old_full_name, new_full_name] if @report[:AC].include?(new_full_name) + end + + @report[:AC].each do |new_full_name| + new_name = Utils.name_from_full_name(new_full_name) + old_name = tap.cask_renames.key(new_name) + next unless old_name + + old_full_name = if tap.core_cask_tap? + old_name + else + "#{tap}/#{old_name}" + end + + renamed_casks << [old_full_name, new_full_name] + end + + if renamed_casks.any? + @report[:AC] -= renamed_casks.map(&:last) + @report[:DC] -= renamed_casks.map(&:first) + @report[:RC] = renamed_casks.to_a + end + + renamed_formulae = Set.new + @report[:D].each do |old_full_name| + old_name = Utils.name_from_full_name(old_full_name) + new_name = tap.formula_renames[old_name] + next unless new_name + + new_full_name = if tap.core_tap? + new_name + else + "#{tap}/#{new_name}" + end + + renamed_formulae << [old_full_name, new_full_name] if @report[:A].include? new_full_name + end + + @report[:A].each do |new_full_name| + new_name = Utils.name_from_full_name(new_full_name) + old_name = tap.formula_renames.key(new_name) + next unless old_name + + old_full_name = if tap.core_tap? + old_name + else + "#{tap}/#{old_name}" + end + + renamed_formulae << [old_full_name, new_full_name] + end + + if renamed_formulae.any? + @report[:A] -= renamed_formulae.map(&:last) + @report[:D] -= renamed_formulae.map(&:first) + @report[:R] = renamed_formulae.to_a + end + + # If any formulae/casks are marked as added and deleted, remove them from + # the report as we've not detected things correctly. + if (added_and_deleted_formulae = (@report[:A] & @report[:D]).presence) + @report[:A] -= added_and_deleted_formulae + @report[:D] -= added_and_deleted_formulae + end + if (added_and_deleted_casks = (@report[:AC] & @report[:DC]).presence) + @report[:AC] -= added_and_deleted_casks + @report[:DC] -= added_and_deleted_casks + end + + @report + end + + sig { returns(T::Boolean) } + def updated? + if installed_from_api? + diff.present? + else + initial_revision != current_revision + end + end + + sig { void } + def migrate_tap_migration + [report[:D], report[:DC], report[:T]].flatten.each do |full_name| + name = Utils.name_from_full_name(full_name) + migration_target = tap.tap_migrations[name] + next if migration_target.nil? # skip if not in tap_migrations list. + + migrated_tap_name = Utils.tap_from_full_name(migration_target) + new_name = if migrated_tap_name + new_full_name = Utils.name_from_full_name(migration_target) + new_tap_name = migrated_tap_name + new_full_name + elsif migration_target.include?("/") + new_tap_name = migration_target + new_full_name = "#{new_tap_name}/#{name}" + name + else + new_tap_name = tap.name + new_full_name = "#{new_tap_name}/#{migration_target}" + migration_target + end + + # This means it is a cask + if Array(report[:DC]).include? full_name + next unless (HOMEBREW_PREFIX/"Caskroom"/name).exist? + + new_tap = Tap.fetch(new_tap_name) + next unless ensure_trusted_tap_installed!(name, new_name, new_tap) + + ohai "#{name} has been moved to Homebrew.", <<~EOS + To uninstall the cask, run: + brew uninstall --cask --force #{name} + EOS + next if (HOMEBREW_CELLAR/Utils.name_from_full_name(new_name)).directory? + + ohai "Installing #{new_name}..." + begin + system HOMEBREW_BREW_FILE.to_s, "install", "--overwrite", new_full_name + # Rescue any possible exception types. + rescue Exception => e # rubocop:disable Lint/RescueException + if Homebrew::EnvConfig.developer? + require "utils/backtrace" + onoe "#{e.message}\n#{Utils::Backtrace.clean(e)&.join("\n")}" + end + end + next + end + + next unless (dir = HOMEBREW_CELLAR/name).exist? # skip if formula is not installed. + + tabs = dir.subdirs.map { |d| Keg.new(d).tab } + next if tabs.first.tap != tap # skip if installed formula is not from this tap. + + new_tap = Tap.fetch(new_tap_name) + # For formulae migrated to cask: Auto-install cask or provide install instructions. + # Check if the migration target is a cask (either in homebrew/cask or any other tap) + if new_tap.core_cask_tap? || new_tap.cask_tokens.intersect?([new_full_name, new_name]) + migration_message = if new_tap == tap + "#{full_name} has been migrated from a formula to a cask." + else + "#{name} has been moved to #{new_tap_name}." + end + if new_tap.installed? && (HOMEBREW_PREFIX/"Caskroom").directory? + ohai migration_message + ohai "brew unlink #{name}" + system HOMEBREW_BREW_FILE.to_s, "unlink", name + ohai "brew cleanup" + system HOMEBREW_BREW_FILE.to_s, "cleanup" + ohai "brew install --cask #{new_full_name}" + system HOMEBREW_BREW_FILE.to_s, "install", "--cask", new_full_name + ohai migration_message, <<~EOS + The existing keg has been unlinked. + Please uninstall the formula when convenient by running: + brew uninstall --formula --force #{name} + EOS + else + ohai migration_message, <<~EOS + To uninstall the formula and install the cask, run: + brew uninstall --formula --force #{name} + brew tap #{new_tap_name} + brew install --cask #{new_full_name} + EOS + end + else + next unless ensure_trusted_tap_installed!(name, new_name, new_tap) + + # update tap for each Tab + tabs.each { |tab| tab.tap = new_tap } + tabs.each(&:write) + end + end + end + + sig { void } + def migrate_cask_rename + Cask::Caskroom.casks.each do |cask| + Cask::Migrator.migrate_if_needed(cask) + end + end + + sig { params(force: T::Boolean, verbose: T::Boolean).void } + def migrate_formula_rename(force:, verbose:) + Formula.installed.each do |formula| + next unless Migrator.needs_migration?(formula) + + oldnames_to_migrate = formula.oldnames.select do |oldname| + oldname_rack = HOMEBREW_CELLAR/oldname + next false unless oldname_rack.exist? + + if oldname_rack.subdirs.empty? + oldname_rack.rmdir_if_possible + next false + end + + true + end + next if oldnames_to_migrate.empty? + + Migrator.migrate_if_needed(formula, force:) + end + end + + private + + sig { params(name: String, new_name: String, new_tap: Tap).returns(T::Boolean) } + def ensure_trusted_tap_installed!(name, new_name, new_tap) + return true if new_tap.installed? + + unless Homebrew::Trust.trusted_tap?(new_tap) + new_bare_name = Utils.name_from_full_name(new_name) + new_full_name = "#{new_tap.name}/#{new_bare_name}" + # `brew migrate` only migrates renamed packages, so a tap-only migration + # (unchanged name) needs a reinstall from the new tap instead. + complete_command = if new_bare_name == name + "brew reinstall #{name}" + else + "brew migrate #{name}" + end + opoo <<~EOS + Not automatically tapping #{new_tap} to migrate #{name} as it is not a + trusted tap. To complete the migration yourself, run: + brew tap #{new_tap} + brew trust #{new_full_name} + #{complete_command} + EOS + return false + end + + new_tap.ensure_installed! + true + end + + sig { returns(Tap) } + attr_reader :tap + + sig { returns(String) } + attr_reader :initial_revision + + sig { returns(String) } + attr_reader :current_revision + + sig { returns(T.nilable(Pathname)) } + attr_reader :api_names_txt + + sig { returns(T.nilable(Pathname)) } + attr_reader :api_names_before_txt + + sig { returns(T.nilable(Pathname)) } + attr_reader :api_dir_prefix + + sig { + params(api_names_txt: T.nilable(Pathname), api_names_before_txt: T.nilable(Pathname), + api_dir_prefix: T.nilable(Pathname)).returns(T::Boolean) + } + def installed_from_api?(api_names_txt = @api_names_txt, api_names_before_txt = @api_names_before_txt, + api_dir_prefix = @api_dir_prefix) + !api_names_txt.nil? && !api_names_before_txt.nil? && !api_dir_prefix.nil? + end + + sig { returns(String) } + def diff + @diff ||= T.let(nil, T.nilable(String)) + @diff ||= if installed_from_api? + # Hack `git diff` output with regexes to look like `git diff-tree` output. + # Yes, I know this is a bit filthy but it saves duplicating the #report logic. + diff_output = Utils.popen_read("git", "diff", "--no-ext-diff", api_names_before_txt, api_names_txt) + header_regex = /^(---|\+\+\+) / + add_delete_characters = ["+", "-"].freeze + + api_dir_prefix_basename = T.must(api_dir_prefix).basename + + diff_hash = diff_output.lines.each_with_object({}) do |line, hash| + next if line.match?(header_regex) + next unless add_delete_characters.include?(line[0]) + + name = line.chomp.delete_prefix("+").delete_prefix("-") + file = "#{api_dir_prefix_basename}/#{name}.rb" + + hash[file] ||= 0 + if line.start_with?("+") + hash[file] += 1 + elsif line.start_with?("-") + hash[file] -= 1 + end + end + + diff_hash.filter_map do |file, count| + if count.positive? + "A #{file}" + elsif count.negative? + "D #{file}" + end + end.join("\n") + else + Utils.popen_read( + "git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR", + "-M85%", initial_revision, current_revision + ) + end + end +end diff --git a/Library/Homebrew/cmd/update_report/reporter_hub.rb b/Library/Homebrew/cmd/update_report/reporter_hub.rb new file mode 100644 index 0000000000000..263e852d960bc --- /dev/null +++ b/Library/Homebrew/cmd/update_report/reporter_hub.rb @@ -0,0 +1,231 @@ +# typed: strict +# frozen_string_literal: true + +class ReporterHub + include Utils::Output::Mixin + + sig { returns(T::Array[Reporter]) } + attr_reader :reporters + + sig { void } + def initialize + @hash = T.let({}, T::Hash[Symbol, T::Array[T.any(String, [String, String])]]) + @reporters = T.let([], T::Array[Reporter]) + end + + sig { params(key: Symbol).returns(T::Array[String]) } + def select_formula_or_cask(key) + raise "Unsupported key #{key}" unless [:A, :AC, :D, :DC, :M, :MC, :R, :RC, :T].include?(key) + + T.cast(@hash.fetch(key, []), T::Array[String]) + end + + sig { params(reporter: Reporter, auto_update: T::Boolean).void } + def add(reporter, auto_update: false) + @reporters << reporter + report = reporter.report(auto_update:).reject { |_k, v| v.empty? } + @hash.update(report) { |_key, oldval, newval| oldval + newval } + end + + sig { returns(T::Boolean) } + def empty? + @hash.empty? + end + + sig { params(auto_update: T::Boolean).void } + def dump(auto_update: false) + unless Homebrew::EnvConfig.no_update_report_new? + dump_new_formula_report + dump_new_cask_report + end + + dump_deleted_formula_report + dump_deleted_cask_report + + outdated_formulae = Formula.installed.select(&:outdated?).map(&:name) + outdated_casks = Cask::Caskroom.casks.select(&:outdated?).map(&:token) + unless auto_update + output_dump_formula_or_cask_report "Outdated Formulae", outdated_formulae + output_dump_formula_or_cask_report "Outdated Casks", outdated_casks + end + return if outdated_formulae.blank? && outdated_casks.blank? + + outdated_formulae = outdated_formulae.count + outdated_casks = outdated_casks.count + + update_pronoun = if (outdated_formulae + outdated_casks) == 1 + "it" + else + "them" + end + + msg = "" + + if outdated_formulae.positive? + noun = Utils.pluralize("formula", outdated_formulae) + msg += "#{Tty.bold}#{outdated_formulae}#{Tty.reset} outdated #{noun}" + end + + if outdated_casks.positive? + msg += " and " if msg.present? + msg += "#{Tty.bold}#{outdated_casks}#{Tty.reset} outdated #{Utils.pluralize("cask", outdated_casks)}" + end + + return if msg.blank? + + # When auto-updating before a zero-argument `brew upgrade` or `brew outdated`, + # that command lists the outdated packages itself so don't duplicate it here. + # Two-way sync: `auto-update` in `Library/Homebrew/brew.sh`. + return if auto_update && ENV["HOMEBREW_AUTO_UPDATE_SKIP_OUTDATED"].present? + + puts + puts "You have #{msg} installed." + # If we're auto-updating, don't need to suggest commands that we're perhaps + # already running. + return if auto_update + + puts <<~EOS + You can upgrade #{update_pronoun} with #{Tty.bold}brew upgrade#{Tty.reset} + or list #{update_pronoun} with #{Tty.bold}brew outdated#{Tty.reset}. + EOS + end + + private + + sig { void } + def dump_new_formula_report + formulae = select_formula_or_cask(:A).sort.reject { |name| installed?(name) } + return if formulae.blank? + + ohai "New Formulae" + should_display_descriptions = if Homebrew::EnvConfig.no_install_from_api? + formulae.size <= 100 + else + true + end + formulae.each do |formula| + if should_display_descriptions && (desc = description(formula)) + puts "#{formula}: #{desc}" + else + puts formula + end + end + end + + sig { void } + def dump_new_cask_report + return unless Cask::Caskroom.any_casks_installed? + + casks = select_formula_or_cask(:AC).sort.reject { |name| cask_installed?(name) } + return if casks.blank? + + ohai "New Casks" + should_display_descriptions = if Homebrew::EnvConfig.no_install_from_api? + casks.size <= 100 + else + true + end + casks.each do |cask| + cask_token = Utils.name_from_full_name(cask) + if should_display_descriptions && (desc = cask_description(cask)) + puts "#{cask_token}: #{desc}" + else + puts cask_token + end + end + end + + sig { void } + def dump_deleted_formula_report + formulae = select_formula_or_cask(:D).sort.filter_map do |name| + pretty_uninstalled(name) if installed?(name) + end + + output_dump_formula_or_cask_report "Deleted Installed Formulae", formulae + end + + sig { void } + def dump_deleted_cask_report + return if Homebrew::SimulateSystem.simulating_or_running_on_linux? + + casks = select_formula_or_cask(:DC).sort.filter_map do |name| + name = Utils.name_from_full_name(name) + pretty_uninstalled(name) if cask_installed?(name) + end + + output_dump_formula_or_cask_report "Deleted Installed Casks", casks + end + + sig { params(title: String, formulae_or_casks: T::Array[String]).void } + def output_dump_formula_or_cask_report(title, formulae_or_casks) + return if formulae_or_casks.blank? + + ohai title, Formatter.columns(formulae_or_casks.sort) + end + + sig { params(formula: String).returns(T::Boolean) } + def installed?(formula) + (HOMEBREW_CELLAR/Utils.name_from_full_name(formula)).directory? + end + + sig { params(cask: String).returns(T::Boolean) } + def cask_installed?(cask) + (Cask::Caskroom.path/cask).directory? + end + + sig { returns(T::Array[T.untyped]) } + def all_formula_json + return @all_formula_json if @all_formula_json + + @all_formula_json = T.let(nil, T.nilable(T::Array[T.untyped])) + all_formula_json, = Homebrew::API.fetch_json_api_file "formula.jws.json" + all_formula_json = T.cast(all_formula_json, T::Array[T.untyped]) + @all_formula_json = all_formula_json + end + + sig { returns(T::Array[T.untyped]) } + def all_cask_json + return @all_cask_json if @all_cask_json + + @all_cask_json = T.let(nil, T.nilable(T::Array[T.untyped])) + all_cask_json, = Homebrew::API.fetch_json_api_file "cask.jws.json" + all_cask_json = T.cast(all_cask_json, T::Array[T.untyped]) + @all_cask_json = all_cask_json + end + + sig { params(formula: String).returns(T.nilable(String)) } + def description(formula) + if Homebrew::EnvConfig.no_install_from_api? + # Skip non-homebrew/core formulae for security. + return if formula.include?("/") + + begin + Formula[formula].desc&.presence + rescue FormulaUnavailableError + nil + end + else + all_formula_json.find { |f| f["name"] == formula } + &.fetch("desc", nil) + &.presence + end + end + + sig { params(cask: String).returns(T.nilable(String)) } + def cask_description(cask) + if Homebrew::EnvConfig.no_install_from_api? + # Skip non-homebrew/cask formulae for security. + return if cask.include?("/") + + begin + Cask::CaskLoader.load(cask).desc&.presence + rescue Cask::CaskError + nil + end + else + all_cask_json.find { |f| f["token"] == cask } + &.fetch("desc", nil) + &.presence + end + end +end diff --git a/Library/Homebrew/cmd/upgrade.rb b/Library/Homebrew/cmd/upgrade.rb index 7ff9cba77e227..55856109d6435 100644 --- a/Library/Homebrew/cmd/upgrade.rb +++ b/Library/Homebrew/cmd/upgrade.rb @@ -1,341 +1,858 @@ +# typed: strict # frozen_string_literal: true +require "abstract_command" +require "formula_installer" require "install" +require "upgrade" +require "cask/utils" +require "cask/upgrade" +require "api" require "reinstall" -require "formula_installer" -require "development_tools" -require "messages" -require "cleanup" -require "cli/parser" +require "minimum_version" +require "trust" module Homebrew - module_function - - def upgrade_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `upgrade` [] [] - - Upgrade outdated, unpinned formulae using the same options they were originally - installed with, plus any appended brew formula options. If are specified, - upgrade only the given kegs (unless they are pinned; see `pin`, `unpin`). - - Unless `HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for the - upgraded formulae or, every 30 days, for all formulae. - EOS - switch :debug, - description: "If brewing fails, open an interactive debugging session with access to IRB "\ - "or a shell inside the temporary build directory." - switch "-s", "--build-from-source", - description: "Compile from source even if a bottle is available." - switch "--force-bottle", - description: "Install from a bottle if it exists for the current or newest version of "\ - "macOS, even if it would not normally be used for installation." - switch "--fetch-HEAD", - description: "Fetch the upstream repository to detect if the HEAD installation of the "\ - "formula is outdated. Otherwise, the repository's HEAD will only be checked for "\ - "updates when a new stable or development version has been released." - switch "--ignore-pinned", - description: "Set a successful exit status even if pinned formulae are not upgraded." - switch "--keep-tmp", - description: "Retain the temporary files created during installation." - switch :force, - description: "Install without checking for previously installed keg-only or "\ - "non-migrated versions." - switch :verbose, - description: "Print the verification and postinstall steps." - switch "--display-times", - env: :display_install_times, - description: "Print install times for each formula at the end of the run." - switch "-n", "--dry-run", - description: "Show what would be upgraded, but do not actually upgrade anything." - conflicts "--build-from-source", "--force-bottle" - formula_options - end - end + module Cmd + class UpgradeCmd < AbstractCommand + class FormulaeUpgradeContext < T::Struct + const :formulae_to_install, T::Array[Formula] + const :formulae_installer, T::Array[FormulaInstaller] + const :dependants, Homebrew::Upgrade::Dependents + const :pinned_formulae, T::Array[Formula], default: [] + end - def upgrade - upgrade_args.parse + class FinalUpgradeSummary < T::Struct + prop :version_changes, T::Array[String], default: [] + prop :pinned_formulae, T::Array[String], default: [] + prop :pinned_casks, T::Array[String], default: [] + prop :deprecated, T::Array[String], default: [] + prop :disabled, T::Array[String], default: [] + prop :source_build_formulae, T::Array[String], default: [] + end - FormulaInstaller.prevent_build_flags unless DevelopmentTools.installed? + cmd_args do + description <<~EOS + Upgrade outdated, unpinned packages using the same options they were originally installed with, + plus any appended brew formula options. If or are specified, upgrade only the given + or (unless they are pinned; see `pin`, `unpin`). + + Unless `$HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK` is set, `brew upgrade` or `brew reinstall` will be run for + outdated dependents and dependents with broken linkage, respectively. + + Unless `$HOMEBREW_NO_INSTALL_CLEANUP` is set, `brew cleanup` will then be run for the + upgraded formulae or, every 30 days, for all formulae. + EOS + switch "-d", "--debug", + description: "If brewing fails, open an interactive debugging session with access to IRB " \ + "or a shell inside the temporary build directory." + switch "--display-times", + description: "Print install times for each package at the end of the run.", + env: :display_install_times + switch "-f", "--force", + description: "Install formulae without checking for previously installed keg-only or " \ + "non-migrated versions. When installing casks, overwrite existing files " \ + "(binaries and symlinks are excluded, unless originally from the same cask)." + switch "-v", "--verbose", + description: "Print the verification and post-install steps." + switch "-n", "--dry-run", + description: "Show what would be upgraded, but do not actually upgrade anything." + flag "--minimum-version=", "--min-version=", + description: "Only upgrade a named formula or cask with an installed version below the given " \ + "minimum version." + switch "--no-ask", "--yes", "-y", + description: "Do not ask for confirmation before downloading and upgrading. Ask mode is the default.", + env: :no_ask + switch "--ask", + description: "Ask for confirmation before downloading and upgrading. " \ + "Print the same plan as `--dry-run`, including available download sizes. " \ + "When named arguments are provided, only prompts if the plan includes packages " \ + "other than those arguments; if the requested formulae or casks are the only " \ + "things to upgrade, it only prints the plan. With no named arguments, prompts if " \ + "anything would be upgraded. The confirmation prompt is skipped without a TTY. " \ + "This is the default unless `$HOMEBREW_NO_ASK` is set.", + env: :ask, + replacement: "the default behaviour", + odeprecated: true + [ + [:switch, "--formula", "--formulae", { + description: "Treat all named arguments as formulae. If no named arguments " \ + "are specified, upgrade only outdated formulae.", + }], + [:switch, "-s", "--build-from-source", { + description: "Compile from source even if a bottle is available.", + }], + [:switch, "-i", "--interactive", { + description: "Download and patch , then open a shell. This allows the user to " \ + "run `./configure --help` and otherwise determine how to turn the software " \ + "package into a Homebrew package.", + }], + [:switch, "--force-bottle", { + description: "Install from a bottle if it exists for the current or newest version of " \ + "macOS, even if it would not normally be used for installation.", + }], + [:switch, "--fetch-HEAD", { + description: "Fetch the upstream repository to detect if the HEAD installation of the " \ + "formula is outdated. Otherwise, the repository's HEAD will only be checked for " \ + "updates when a new stable or development version has been released.", + }], + [:switch, "--keep-tmp", { + description: "Retain the temporary files created during installation.", + }], + [:switch, "--debug-symbols", { + depends_on: "--build-from-source", + description: "Generate debug symbols on build. Source will be retained in a cache directory.", + }], + [:switch, "--overwrite", { + description: "Delete files that already exist in the prefix while linking.", + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--cask", args.last + end + formula_options + [ + [:switch, "--cask", "--casks", { + description: "Treat all named arguments as casks. If no named arguments " \ + "are specified, upgrade only outdated casks.", + }], + [:switch, "--skip-cask-deps", { + description: "Skip installing cask dependencies.", + }], + [:switch, "--no-quit", { + description: "Prevent running cask applications from being quit during upgrade.", + env: :no_upgrade_quit_casks, + }], + [:switch, "-g", "--greedy", { + description: "Also include casks with `version :latest` and `auto_updates true` casks " \ + "that would otherwise be skipped.", + env: :upgrade_greedy, + }], + [:switch, "--greedy-latest", { + description: "Also include casks with `version :latest`.", + }], + [:switch, "--greedy-auto-updates", { + description: "Also include `auto_updates true` casks that would otherwise be skipped.", + }], + [:switch, "--[no-]binaries", { + description: "Disable/enable linking of helper executables (default: enabled).", + env: :cask_opts_binaries, + }], + [:switch, "--require-sha", { + description: "Require all casks to have a checksum.", + env: :cask_opts_require_sha, + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--formula", args.last + end + cask_options - Install.perform_preinstall_checks + conflicts "--build-from-source", "--force-bottle" + conflicts "--ask", "--no-ask" - if Homebrew.args.named.blank? - outdated = Formula.installed.select do |f| - f.outdated?(fetch_head: args.fetch_HEAD?) + named_args [:installed_formula, :installed_cask] end - exit 0 if outdated.empty? - else - outdated = Homebrew.args.resolved_formulae.select do |f| - f.outdated?(fetch_head: args.fetch_HEAD?) + sig { override.params(argv: T::Array[String]).void } + def initialize(argv = ARGV.freeze) + super + @ask_prompt_required = T.let(false, T::Boolean) end - (Homebrew.args.resolved_formulae - outdated).each do |f| - versions = f.installed_kegs.map(&:version) - if versions.empty? - ofail "#{f.full_specified_name} not installed" - else - version = versions.max - opoo "#{f.full_specified_name} #{version} already installed" + sig { override.void } + def run + if args.build_from_source? && args.named.empty? + raise ArgumentError, "`--build-from-source` requires at least one formula" + end + raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if + minimum_version.present? && args.named.length != 1 + + formulae = T.let([], T::Array[Formula]) + casks = T.let([], T::Array[Cask::Cask]) + unavailable_errors = T.let( + [], + T::Array[T.any(FormulaOrCaskUnavailableError, NoSuchKegError)], + ) + @prefetched_formulae_upgrade_context = T.let(nil, T.nilable(FormulaeUpgradeContext)) + prefetched_formulae_names = T.let([], T::Array[String]) + prefetched_formulae_upgrades = T.let([], T::Array[String]) + prefetched_cask_names = T.let([], T::Array[String]) + prefetched_cask_upgrades = T.let([], T::Array[String]) + @final_upgrade_summary = T.let(FinalUpgradeSummary.new, T.nilable(FinalUpgradeSummary)) + @ask_prompt_required = false + ask = !args.no_ask? && !args.dry_run? + skip_upgrades_after_failed_ask_preview = T.let(false, T::Boolean) + + if args.named.present? + Homebrew::Trust.trust_fully_qualified_items!(args.named, type: args.only_formula_or_cask) + + args.named.to_formulae_and_casks_and_unavailable(method: :resolve).each do |item| + case item + when FormulaOrCaskUnavailableError, NoSuchKegError + unavailable_errors << item + when Formula + formulae << item + when Cask::Cask + casks << item + end + end end - end - return if outdated.empty? - end - pinned = outdated.select(&:pinned?) - outdated -= pinned - formulae_to_install = outdated.map(&:latest_formula) + # If one or more formulae are specified, but no casks were + # specified, we want to make note of that so we don't + # try to upgrade all outdated casks. + # + # When names were given, we must also prevent empty resolved lists + # from triggering the "upgrade all" path (which happens when all + # names failed resolution). + named_given = args.named.present? + only_upgrade_formulae = (named_given && casks.blank?) || (formulae.present? && casks.blank?) + only_upgrade_casks = (named_given && formulae.blank?) || (casks.present? && formulae.blank?) + + if Homebrew::EnvConfig.verify_attestations? + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) + end - if !pinned.empty? && !args.ignore_pinned? - ofail "Not upgrading #{pinned.count} pinned #{"package".pluralize(pinned.count)}:" - puts pinned.map { |f| "#{f.full_specified_name} #{f.pkg_version}" } * ", " - end + formulae_prefetched = T.let(false, T::Boolean) + prefetched_casks = T.let(false, T::Boolean) + ask_upgrade_planned = T.let(false, T::Boolean) + shared_download_queue = T.let(nil, T.nilable(Homebrew::DownloadQueue)) + if ask + unless only_upgrade_casks + upgrade_outdated_formulae!( + formulae, + dry_run: true, + show_upgrade_summary: false, + ) + end + unless only_upgrade_formulae + upgrade_outdated_casks!( + casks, + dry_run: true, + skip_prefetch: false, + show_upgrade_summary: false, + download_queue: nil, + ) + end + + show_final_upgrade_summary(dry_run: true) + if Install.ask_prompt_needed?( + planned_names: final_upgrade_summary.version_changes.map do |version_change| + planned_name = version_change.split.fetch(0) + formulae.find { |formula| formula.full_specified_name == planned_name }&.full_name || planned_name + end, + requested_names: args.named, + force: @ask_prompt_required, + named: args.named.present?, + ) + Install.ask(action: "upgrade") + Cask::Upgrade.show_upgrade_summary(final_upgrade_summary.version_changes) + end + ask_upgrade_planned = final_upgrade_summary.version_changes.present? + skip_upgrades_after_failed_ask_preview = Homebrew.failed? && !ask_upgrade_planned + @final_upgrade_summary = FinalUpgradeSummary.new + end - if formulae_to_install.empty? - oh1 "No packages to upgrade" - else - verb = args.dry_run? ? "Would upgrade" : "Upgrading" - oh1 "#{verb} #{formulae_to_install.count} outdated #{"package".pluralize(formulae_to_install.count)}:" - formulae_upgrades = formulae_to_install.map do |f| - if f.optlinked? - "#{f.full_specified_name} #{Keg.new(f.opt_prefix).version} -> #{f.pkg_version}" - else - "#{f.full_specified_name} #{f.pkg_version}" + if !args.dry_run? && (!ask || ask_upgrade_planned) && !only_upgrade_formulae && !only_upgrade_casks + shared_download_queue = Homebrew::DownloadQueue.new(pour: true) + begin + formulae_prefetched = upgrade_outdated_formulae!( + formulae, + prefetch_only: true, + download_queue: shared_download_queue, + prefetch_names: prefetched_formulae_names, + prefetch_upgrades: prefetched_formulae_upgrades, + show_upgrade_summary: false, + show_downloads_heading: false, + ) + prefetched_casks = prefetch_outdated_casks!( + casks, + download_queue: shared_download_queue, + prefetch_names: prefetched_cask_names, + prefetch_upgrades: prefetched_cask_upgrades, + show_downloads_heading: false, + ) + unless ask + Cask::Upgrade.show_upgrade_summary( + prefetched_formulae_upgrades + prefetched_cask_upgrades, + dry_run: args.dry_run?, + ) + end + Install.show_combined_fetch_downloads_heading( + formula_names: prefetched_formulae_names, + cask_names: prefetched_cask_names, + ) + shared_download_queue.fetch + if shared_download_queue.fetch_failed + formulae_prefetched = false + prefetched_casks = false + end + ensure + shared_download_queue.shutdown + end + end + + if !only_upgrade_casks && !skip_upgrades_after_failed_ask_preview + upgrade_outdated_formulae!( + formulae, + use_prefetched: formulae_prefetched, + show_upgrade_summary: prefetched_formulae_upgrades.blank? && !args.dry_run? && !ask, + ) end + if !only_upgrade_formulae && !skip_upgrades_after_failed_ask_preview + upgrade_outdated_casks!( + casks, + skip_prefetch: prefetched_casks, + show_upgrade_summary: prefetched_cask_upgrades.blank? && !args.dry_run? && !ask, + download_queue: nil, + ) + end + + unavailable_errors.each { |e| ofail e } + + Cleanup.periodic_clean!(dry_run: args.dry_run?) + + Homebrew::Reinstall.reinstall_pkgconf_if_needed!(dry_run: args.dry_run?) + + Homebrew.messages.display_messages(display_times: args.display_times?) + + show_final_upgrade_summary end - puts formulae_upgrades.join("\n") - end - upgrade_formulae(formulae_to_install) + private - check_dependents(formulae_to_install) + sig { returns(T.nilable(String)) } + def minimum_version = args.minimum_version || args.min_version - Homebrew.messages.display_messages - end + sig { params(formula: Formula).returns(T::Boolean) } + def formula_outdated?(formula) + version = minimum_version + return formula.outdated?(fetch_head: args.fetch_HEAD?) if version.blank? - def upgrade_formulae(formulae_to_install) - return if formulae_to_install.empty? - return if args.dry_run? - - # Sort keg-only before non-keg-only formulae to avoid any needless conflicts - # with outdated, non-keg-only versions of formulae being upgraded. - formulae_to_install.sort! do |a, b| - if !a.keg_only? && b.keg_only? - 1 - elsif a.keg_only? && !b.keg_only? - -1 - else - 0 + formula.outdated?(fetch_head: args.fetch_HEAD?) && + MinimumVersion.formula_outdated_kegs(formula, version, fetch_head: args.fetch_HEAD?).present? end - end - formulae_to_install.each do |f| - Migrator.migrate_if_needed(f) - begin - upgrade_formula(f) - Cleanup.install_formula_clean!(f) - rescue UnsatisfiedRequirements => e - Homebrew.failed = true - onoe "#{f}: #{e}" + sig { params(casks: T::Array[Cask::Cask], quiet: T::Boolean).returns(T::Array[Cask::Cask]) } + def minimum_version_casks(casks, quiet: args.quiet?) + version = minimum_version + return casks if version.blank? + + casks.select do |cask| + if MinimumVersion.cask_installed_below?(cask, version) + true + else + unless quiet + opoo "Not upgrading #{cask.token}, the installed version is not below the minimum version #{version}" + end + false + end + end end - end - end - def upgrade_formula(f) - return if args.dry_run? + sig { + params(formulae: T::Array[Formula], show_upgrade_summary: T::Boolean, + dry_run: T::Boolean).returns(T.nilable(FormulaeUpgradeContext)) + } + def formulae_upgrade_context(formulae, show_upgrade_summary: true, dry_run: args.dry_run?) + if args.build_from_source? + unless DevelopmentTools.installed? + raise BuildFlagsError.new(["--build-from-source"], bottled: formulae.all?(&:bottled?)) + end + + unless Homebrew::EnvConfig.developer? + opoo "building from source is not supported!" + puts "You're on your own. Failures are expected so don't create any issues, please!" + end + end - if f.opt_prefix.directory? - keg = Keg.new(f.opt_prefix.resolved_path) - keg_had_linked_opt = true - keg_was_linked = keg.linked? - end + quiet = args.quiet? || (dry_run && !args.dry_run?) + not_outdated = T.let([], T::Array[Formula]) + if formulae.blank? + outdated = Formula.installed.select do |f| + formula_outdated?(f) + end + elsif minimum_version.present? + outdated, not_outdated = formulae.partition do |f| + f.outdated?(fetch_head: args.fetch_HEAD?) + end + outdated, minimum_version_skipped = outdated.partition do |f| + MinimumVersion.formula_outdated_kegs(f, minimum_version, fetch_head: args.fetch_HEAD?).present? + end + + minimum_version_skipped.each do |f| + next if quiet + + opoo "Not upgrading #{f.full_specified_name}, the installed version is not below " \ + "the minimum version #{minimum_version}" + end + else + outdated, not_outdated = formulae.partition do |f| + formula_outdated?(f) + end + end - formulae_maybe_with_kegs = [f] + f.old_installed_formulae - outdated_kegs = formulae_maybe_with_kegs - .map(&:linked_keg) - .select(&:directory?) - .map { |k| Keg.new(k.resolved_path) } - linked_kegs = outdated_kegs.select(&:linked?) + if formulae.present? + not_outdated.each do |f| + latest_keg = f.installed_kegs.max_by(&:scheme_and_version) + if latest_keg.nil? + ofail "#{f.full_specified_name} not installed" + else + opoo "#{f.full_specified_name} #{latest_keg.version} already installed" unless quiet + end + end + end - if f.opt_prefix.directory? - keg = Keg.new(f.opt_prefix.resolved_path) - tab = Tab.for_keg(keg) - end + return if outdated.blank? + + pinned = outdated.select(&:pinned?) + outdated -= pinned + formulae_to_install = outdated.map do |f| + f_latest = f.latest_formula + if f_latest.latest_version_installed? + f + else + f_latest + end + end - build_options = BuildOptions.new(Options.create(Homebrew.args.flags_only), f.options) - options = build_options.used_options - options |= f.build.used_options - options &= f.options - - fi = FormulaInstaller.new(f) - fi.options = options - fi.build_bottle = args.build_bottle? - fi.installed_on_request = Homebrew.args.named.present? - fi.link_keg ||= keg_was_linked if keg_had_linked_opt - if tab - fi.build_bottle ||= tab.built_bottle? - fi.installed_as_dependency = tab.installed_as_dependency - fi.installed_on_request ||= tab.installed_on_request - end - fi.prelude - - oh1 "Upgrading #{Formatter.identifier(f.full_specified_name)} #{fi.options.to_a.join " "}" - - # first we unlink the currently active keg for this formula otherwise it is - # possible for the existing build to interfere with the build we are about to - # do! Seriously, it happens! - outdated_kegs.each(&:unlink) - - fi.install - fi.finish - rescue FormulaInstallationAlreadyAttemptedError - # We already attempted to upgrade f as part of the dependency tree of - # another formula. In that case, don't generate an error, just move on. - nil - rescue CannotInstallFormulaError => e - ofail e - rescue BuildError => e - e.dump - puts - Homebrew.failed = true - rescue DownloadError => e - ofail e - ensure - # restore previous installation state if build failed - begin - linked_kegs.each(&:link) unless f.installed? - rescue - nil - end - end + if formulae_to_install.empty? + oh1 "No packages to upgrade" if show_upgrade_summary + elsif show_upgrade_summary + verb = dry_run ? "Would upgrade" : "Upgrading" + oh1 "#{verb} #{formulae_to_install.count} outdated #{Utils.pluralize("package", + formulae_to_install.count)}:" + puts Upgrade.format_upgrade_summary(formula_upgrade_descriptions(formulae_to_install)).join("\n") if + args.no_ask? + end - # @private - def depends_on(a, b) - if a.opt_or_installed_prefix_keg - .runtime_dependencies - .any? { |d| d["full_name"] == b.full_name } - 1 - else - a <=> b - end - end + Install.perform_preinstall_checks_once - def check_dependents(formulae_to_install) - return if formulae_to_install.empty? + if formulae_to_install.any? do |formula| + formula.bottle&.github_packages_manifest_resource&.downloaded_and_valid? == false + end + oh1 "Downloading bottle manifests" + end - oh1 "Checking for dependents of upgraded formulae..." unless args.dry_run? - outdated_dependents = - formulae_to_install.flat_map(&:runtime_installed_formula_dependents) - .select(&:outdated?) - if outdated_dependents.blank? - ohai "No dependents found!" unless args.dry_run? - return - end - outdated_dependents -= formulae_to_install if args.dry_run? - - upgradeable_dependents = - outdated_dependents.reject(&:pinned?) - .sort { |a, b| depends_on(a, b) } - pinned_dependents = - outdated_dependents.select(&:pinned?) - .sort { |a, b| depends_on(a, b) } - - if pinned_dependents.present? - plural = "dependent".pluralize(pinned_dependents.count) - ohai "Not upgrading #{pinned_dependents.count} pinned #{plural}:" - puts(pinned_dependents.map do |f| - "#{f.full_specified_name} #{f.pkg_version}" - end.join(", ")) - end + formulae_installer = Upgrade.formula_installers( + formulae_to_install, + flags: args.flags_only, + dry_run:, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + overwrite: args.overwrite?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) + + if formulae_installer.blank? + return if formulae_to_install.present? + return if pinned.blank? + end + + if pinned.any? + message = "Not upgrading #{pinned.count} pinned #{Utils.pluralize("package", pinned.count)}:" + # only fail when pinned formulae are named explicitly + if formulae.any? + ofail message + else + opoo message + end + puts pinned.map { |f| "#{f.full_specified_name} #{f.pkg_version}" } * ", " + end + + if formulae_installer.blank? + return FormulaeUpgradeContext.new( + formulae_to_install:, + formulae_installer: formulae_installer, + dependants: Homebrew::Upgrade::Dependents.new(upgradeable: [], pinned: [], skipped: []), + pinned_formulae: pinned, + ) + end + + dependants = Upgrade.dependants( + formulae_to_install, + flags: args.flags_only, + dry_run:, + ask: !args.no_ask?, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) + + FormulaeUpgradeContext.new( + formulae_to_install:, + formulae_installer: formulae_installer, + dependants:, + pinned_formulae: pinned, + ) + end + + sig { returns(FinalUpgradeSummary) } + def final_upgrade_summary + @final_upgrade_summary ||= T.let(FinalUpgradeSummary.new, T.nilable(FinalUpgradeSummary)) + @final_upgrade_summary + end - # Print the upgradable dependents. - if upgradeable_dependents.blank? - ohai "No outdated dependents to upgrade!" unless args.dry_run? - else - plural = "dependent".pluralize(upgradeable_dependents.count) - verb = args.dry_run? ? "Would upgrade" : "Upgrading" - ohai "#{verb} #{upgradeable_dependents.count} #{plural}:" - formulae_upgrades = upgradeable_dependents.map do |f| - name = f.full_specified_name - if f.optlinked? - "#{name} #{Keg.new(f.opt_prefix).version} -> #{f.pkg_version}" + sig { + params( + context: FormulaeUpgradeContext, + include_sizes: T::Boolean, + formulae_installer: T.nilable(T::Array[FormulaInstaller]), + version_changes: T.nilable(T::Array[String]), + ).void + } + def record_formula_upgrade_summary(context, include_sizes: false, formulae_installer: nil, version_changes: nil) + summary = final_upgrade_summary + formulae_installer ||= context.formulae_installer + upgrade_formulae = formulae_installer.map(&:formula) + dependent_formulae = context.dependants.upgradeable + summary.version_changes.concat( + version_changes || (formula_upgrade_descriptions(upgrade_formulae, include_sizes:) + + formula_upgrade_descriptions(dependent_formulae, include_sizes:)), + ) + summary.pinned_formulae.concat((context.pinned_formulae + context.dependants.pinned).map do |formula| + "#{formula.full_specified_name} #{formula.pkg_version}" + end) + + formulae = context.formulae_to_install + context.pinned_formulae + + context.dependants.upgradeable + context.dependants.pinned + summary.deprecated.concat(formulae.filter_map do |formula| + formula.full_specified_name if formula.deprecated? + end) + summary.disabled.concat(formulae.filter_map do |formula| + formula.full_specified_name if formula.disabled? + end) + summary.source_build_formulae.concat(formulae_installer.filter_map do |formula_installer| + formula = formula_installer.formula + next unless formula.core_formula? + next if formula_installer.pour_bottle? + + formula.full_specified_name + end) + end + + sig { params(dry_run: T::Boolean).void } + def show_final_upgrade_summary(dry_run: args.dry_run?) + summary = final_upgrade_summary + return if summary.version_changes.empty? && summary.pinned_formulae.empty? && summary.pinned_casks.empty? && + summary.deprecated.empty? && summary.disabled.empty? && summary.source_build_formulae.empty? + + if summary.version_changes.present? + version_change_count = summary.version_changes.uniq.count + show_final_upgrade_summary_section( + "#{dry_run ? "Would upgrade" : "Upgraded"} #{version_change_count} outdated " \ + "#{Utils.pluralize("package", version_change_count)}", + Upgrade.format_upgrade_summary(summary.version_changes), + ) + end + if summary.pinned_formulae.present? + pinned_count = summary.pinned_formulae.uniq.count + show_final_upgrade_summary_section( + "#{pinned_count} Pinned #{Utils.pluralize("formula", pinned_count)}", + summary.pinned_formulae, + ) + end + if summary.pinned_casks.present? + pinned_count = summary.pinned_casks.uniq.count + show_final_upgrade_summary_section( + "#{pinned_count} Pinned #{Utils.pluralize("cask", pinned_count)}", + summary.pinned_casks, + ) + end + deprecate_disable_summary = summary.deprecated.map { |item| "#{item} (deprecated)" } + + summary.disabled.map { |item| "#{item} (disabled)" } + deprecate_disable_count = deprecate_disable_summary.uniq.count + show_final_upgrade_summary_section( + "#{deprecate_disable_count} Deprecated or disabled #{Utils.pluralize("package", deprecate_disable_count)}", + deprecate_disable_summary, + ) + source_build_count = summary.source_build_formulae.uniq.count + if dry_run + show_final_upgrade_summary_section( + "#{source_build_count} homebrew/core " \ + "#{Utils.pluralize("formula", source_build_count)} that would build from source", + summary.source_build_formulae, + ) else - "#{name} #{f.pkg_version}" + show_final_upgrade_summary_section( + "#{source_build_count} homebrew/core #{Utils.pluralize("formula", source_build_count)} built from source", + summary.source_build_formulae, + ) end end - puts formulae_upgrades.join(", ") - end - upgrade_formulae(upgradeable_dependents) + sig { params(title: String, items: T::Array[String]).void } + def show_final_upgrade_summary_section(title, items) + items = items.uniq + return if items.empty? - # Assess the dependents tree again now we've upgraded. - oh1 "Checking for dependents of upgraded formulae..." unless args.dry_run? - broken_dependents = CacheStoreDatabase.use(:linkage) do |db| - formulae_to_install.flat_map(&:runtime_installed_formula_dependents) - .select do |f| - keg = f.opt_or_installed_prefix_keg - next unless keg + oh1 title + puts items.join("\n") + end - LinkageChecker.new(keg, cache_db: db) - .broken_library_linkage? - end.compact - end - if broken_dependents.blank? - if args.dry_run? - ohai "No currently broken dependents found!" - opoo "If they are broken by the upgrade they will also be upgraded or reinstalled." - else - ohai "No broken dependents found!" + sig { params(formulae: T::Array[Formula], include_sizes: T::Boolean).returns(T::Array[String]) } + def formula_upgrade_descriptions(formulae, include_sizes: false) + formulae.map do |formula| + if formula.optlinked? + old_keg = Keg.new(formula.opt_prefix) + old_version = old_keg.version + if include_sizes + "#{formula.full_specified_name} #{old_version} -> " \ + "#{formula.pkg_version}#{formula_upgrade_size(formula)}" + else + "#{formula.full_specified_name} #{old_version} -> #{formula.pkg_version}" + end + elsif include_sizes + "#{formula.full_specified_name} #{formula.pkg_version}#{formula_upgrade_size(formula)}" + else + "#{formula.full_specified_name} #{formula.pkg_version}" + end + end end - return - end - reinstallable_broken_dependents = - broken_dependents.reject(&:outdated?) - .reject(&:pinned?) - .sort { |a, b| depends_on(a, b) } - outdated_pinned_broken_dependents = - broken_dependents.select(&:outdated?) - .select(&:pinned?) - .sort { |a, b| depends_on(a, b) } - - # Print the pinned dependents. - if outdated_pinned_broken_dependents.present? - count = outdated_pinned_broken_dependents.count - plural = "dependent".pluralize(outdated_pinned_broken_dependents.count) - onoe "Not reinstalling #{count} broken and outdated, but pinned #{plural}:" - $stderr.puts(outdated_pinned_broken_dependents.map do |f| - "#{f.full_specified_name} #{f.pkg_version}" - end.join(", ")) - end + sig { params(formula: Formula).returns(String) } + def formula_upgrade_size(formula) + bottle = formula.bottle + return "" unless bottle - # Print the broken dependents. - if reinstallable_broken_dependents.blank? - ohai "No broken dependents to reinstall!" - else - count = reinstallable_broken_dependents.count - plural = "dependent".pluralize(reinstallable_broken_dependents.count) - ohai "Reinstalling #{count} broken #{plural} from source:" - puts reinstallable_broken_dependents.map(&:full_specified_name) - .join(", ") - end + bottle.fetch_tab(quiet: !args.debug?) + return "" unless (download_size = bottle.bottle_size) + + " (#{Formatter.disk_usage_readable(download_size.to_i)})" + end + + sig { + params( + formulae: T::Array[Formula], + prefetch_only: T::Boolean, + use_prefetched: T::Boolean, + dry_run: T::Boolean, + download_queue: T.nilable(Homebrew::DownloadQueue), + prefetch_names: T.nilable(T::Array[String]), + prefetch_upgrades: T.nilable(T::Array[String]), + show_upgrade_summary: T::Boolean, + show_downloads_heading: T::Boolean, + ).returns(T::Boolean) + } + def upgrade_outdated_formulae!(formulae, prefetch_only: false, use_prefetched: false, + dry_run: args.dry_run?, + download_queue: nil, + prefetch_names: nil, + prefetch_upgrades: nil, + show_upgrade_summary: true, + show_downloads_heading: true) + return false if args.cask? + + use_prefetched_context = use_prefetched && @prefetched_formulae_upgrade_context + context = if use_prefetched_context + @prefetched_formulae_upgrade_context + else + formulae_upgrade_context(formulae, show_upgrade_summary:, dry_run:) + end + return false if context.blank? + + if prefetch_only + prefetch_download_queue = download_queue || Homebrew.default_download_queue + valid_formula_installers = Install.enqueue_formulae(context.formulae_installer, + download_queue: prefetch_download_queue) + if show_downloads_heading + Install.show_combined_fetch_downloads_heading( + formula_names: valid_formula_installers.map { |fi| fi.formula.name }, + ) + end + prefetch_names&.replace(valid_formula_installers.map { |fi| fi.formula.name }) + prefetch_upgrades&.replace(formula_upgrade_descriptions(valid_formula_installers.map(&:formula))) + @prefetched_formulae_upgrade_context = FormulaeUpgradeContext.new( + formulae_to_install: context.formulae_to_install, + formulae_installer: valid_formula_installers, + dependants: context.dependants, + pinned_formulae: context.pinned_formulae, + ) + return valid_formula_installers.present? + end + + formula_version_changes = formula_upgrade_descriptions(context.formulae_installer.map(&:formula), + include_sizes: dry_run) + dependent_version_changes = formula_upgrade_descriptions(context.dependants.upgradeable, + include_sizes: dry_run) + if dry_run + record_formula_upgrade_summary(context, + version_changes: formula_version_changes + dependent_version_changes) + end + if !args.no_ask? && dry_run && args.named.present? && + Install.formulae_ask_prompt_needed?(context.formulae_installer, context.dependants) + @ask_prompt_required = true + end + + skip_formula_names = if dry_run + (context.formulae_installer.map(&:formula) + context.dependants.upgradeable) + .uniq(&:full_name) + .map(&:full_name) + else + [] + end + + upgraded_formula_installers = Upgrade.upgrade_formulae( + context.formulae_installer, + dry_run:, + verbose: args.verbose?, + fetch: !use_prefetched_context, + skip_formula_names:, + ) + + Upgrade.upgrade_dependents( + context.dependants, context.formulae_to_install, + flags: args.flags_only, + dry_run:, + force_bottle: args.force_bottle?, + build_from_source_formulae: args.build_from_source_formulae, + interactive: args.interactive?, + keep_tmp: args.keep_tmp?, + debug_symbols: args.debug_symbols?, + force: args.force?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + skip_formula_names: + ) + + unless dry_run + upgraded_formula_installers_by_identity = T.let({}.compare_by_identity, + T::Hash[FormulaInstaller, T::Boolean]) + upgraded_formula_installers.each do |formula_installer| + upgraded_formula_installers_by_identity[formula_installer] = true + end + record_formula_upgrade_summary( + context, + formulae_installer: upgraded_formula_installers, + version_changes: context.formulae_installer.each_with_index.filter_map do |formula_installer, index| + formula_version_changes.fetch(index) if upgraded_formula_installers_by_identity.key?(formula_installer) + end + dependent_version_changes, + ) + end + + @prefetched_formulae_upgrade_context = nil if use_prefetched_context + true + end - reinstallable_broken_dependents.each do |f| - reinstall_formula(f, build_from_source: true) - rescue FormulaInstallationAlreadyAttemptedError - # We already attempted to reinstall f as part of the dependency tree of - # another formula. In that case, don't generate an error, just move on. - nil - rescue CannotInstallFormulaError => e - ofail e - rescue BuildError => e - e.dump - puts - Homebrew.failed = true - rescue DownloadError => e - ofail e + sig { + params(casks: T::Array[Cask::Cask], download_queue: Homebrew::DownloadQueue, + prefetch_names: T.nilable(T::Array[String]), + prefetch_upgrades: T.nilable(T::Array[String]), + show_downloads_heading: T::Boolean) + .returns(T::Boolean) + } + def prefetch_outdated_casks!(casks, download_queue:, prefetch_names: nil, + prefetch_upgrades: nil, + show_downloads_heading: true) + return false if args.formula? + + casks = minimum_version_casks(casks, quiet: true) + return false if minimum_version.present? && casks.empty? + + outdated_casks = Cask::Upgrade.outdated_casks( + casks, + args:, + force: args.force?, + quiet: true, + greedy: args.greedy?, + greedy_latest: args.greedy_latest?, + greedy_auto_updates: args.greedy_auto_updates?, + ) + return false if outdated_casks.empty? + + manual_installer_casks = outdated_casks.select do |cask| + cask.artifacts.any? do |artifact| + artifact.is_a?(Cask::Artifact::Installer) && artifact.manual_install + end + end + outdated_casks -= manual_installer_casks + return false if outdated_casks.empty? + + require "cask/installer" + fetchable_cask_installers = outdated_casks.map do |cask| + Cask::Installer.new( + cask, + binaries: args.binaries?, + verbose: args.verbose?, + force: args.force?, + skip_cask_deps: args.skip_cask_deps?, + require_sha: args.require_sha?, + upgrade: true, + download_queue:, + defer_fetch: true, + ) + end + cask_names = outdated_casks.map(&:full_name) + Install.enqueue_cask_installers(fetchable_cask_installers, download_queue:) + prefetch_names&.replace(cask_names) + prefetch_upgrades&.replace( + outdated_casks.map { |cask| "#{cask.full_name} #{cask.installed_version} -> #{cask.version}" }, + ) + Install.show_combined_fetch_downloads_heading(cask_names:) if show_downloads_heading + + true + rescue => e + ofail e + false + end + + sig { + params(casks: T::Array[Cask::Cask], skip_prefetch: T::Boolean, show_upgrade_summary: T::Boolean, + dry_run: T::Boolean, + download_queue: T.nilable(Homebrew::DownloadQueue)) + .returns(T::Boolean) + } + def upgrade_outdated_casks!(casks, skip_prefetch: false, show_upgrade_summary: true, + dry_run: args.dry_run?, + download_queue: nil) + return false if args.formula? + + quiet = args.quiet? || (dry_run && !args.dry_run?) + casks = minimum_version_casks(casks, quiet:) + return false if minimum_version.present? && casks.empty? + + Cask::Upgrade.upgrade_casks!( + *casks, + force: args.force?, + greedy: args.greedy?, + greedy_latest: args.greedy_latest?, + greedy_auto_updates: args.greedy_auto_updates?, + dry_run:, + binaries: args.binaries?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + quit: !args.no_quit?, + verbose: args.verbose?, + quiet:, + skip_prefetch:, + show_upgrade_summary:, + download_queue:, + summary_upgrades: final_upgrade_summary.version_changes, + summary_pinned: final_upgrade_summary.pinned_casks, + summary_deprecated: final_upgrade_summary.deprecated, + summary_disabled: final_upgrade_summary.disabled, + args:, + ) + rescue => e + ofail e + false + end end end end diff --git a/Library/Homebrew/cmd/uses.rb b/Library/Homebrew/cmd/uses.rb index e9151f71a2e90..0ae741913af2a 100644 --- a/Library/Homebrew/cmd/uses.rb +++ b/Library/Homebrew/cmd/uses.rb @@ -1,107 +1,189 @@ +# typed: strict # frozen_string_literal: true -# `brew uses foo bar` returns formulae that use both foo and bar -# If you want the union, run the command twice and concatenate the results. -# The intersection is harder to achieve with shell tools. - +require "abstract_command" require "formula" -require "cli/parser" +require "cask/caskroom" +require "dependencies_helpers" module Homebrew - module_function - - def uses_args - Homebrew::CLI::Parser.new do - usage_banner <<~EOS - `uses` [] - - Show formulae that specify as a dependency. When given multiple - formula arguments, show the intersection of formulae that use . - By default, `uses` shows all formulae that specify as a required - or recommended dependency for their stable builds. - EOS - switch "--recursive", - description: "Resolve more than one level of dependencies." - switch "--installed", - description: "Only list formulae that are currently installed." - switch "--include-build", - description: "Include all formulae that specify as `:build` type dependency." - switch "--include-test", - description: "Include all formulae that specify as `:test` type dependency." - switch "--include-optional", - description: "Include all formulae that specify as `:optional` type dependency." - switch "--skip-recommended", - description: "Skip all formulae that specify as `:recommended` type dependency." - switch "--devel", - description: "Show usage of by development builds." - switch "--HEAD", - description: "Show usage of by HEAD builds." - switch :debug - conflicts "--devel", "--HEAD" - end - end + module Cmd + # `brew uses foo bar` returns formulae that use both foo and bar + # If you want the union, run the command twice and concatenate the results. + # The intersection is harder to achieve with shell tools. + class Uses < AbstractCommand + include DependenciesHelpers + + class UnavailableFormula < T::Struct + const :name, String + const :full_name, String + end - def uses - uses_args.parse + cmd_args do + description <<~EOS + Show formulae and casks that specify as a dependency; that is, show dependents + of . When given multiple formula arguments, show the intersection + of formulae that use . By default, `uses` shows all formulae and casks that + specify as a required or recommended dependency for their stable builds. + + *Note:* `--missing` and `--skip-recommended` have precedence over `--include-*`. + EOS + switch "--recursive", + description: "Resolve more than one level of dependencies." + switch "--installed", + description: "Only list formulae and casks that are currently installed." + switch "--missing", + description: "Only list formulae and casks that are not currently installed." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to show " \ + "their dependents.", + env: :eval_all, + odeprecated: true + switch "--include-implicit", + description: "Include formulae that have as an implicit dependency for " \ + "downloading and unpacking source files." + switch "--include-build", + description: "Include formulae that specify as a `:build` dependency." + switch "--include-test", + description: "Include formulae that specify as a `:test` dependency." + switch "--include-optional", + description: "Include formulae that specify as an `:optional` dependency." + switch "--skip-recommended", + description: "Skip all formulae that specify as a `:recommended` dependency." + switch "--formula", "--formulae", + description: "Include only formulae." + switch "--cask", "--casks", + description: "Include only casks." + + conflicts "--formula", "--cask" + conflicts "--installed", "--eval-all" + conflicts "--missing", "--installed" + + named_args :formula, min: 1 + end - raise FormulaUnspecifiedError if args.remaining.empty? + sig { override.void } + def run + Formulary.enable_factory_cache! + + used_formulae_missing = false + used_formulae = begin + args.named.to_formulae + rescue FormulaUnavailableError => e + opoo e + used_formulae_missing = true + # If the formula doesn't exist: fake the needed formula object name. + args.named.map { |name| UnavailableFormula.new name:, full_name: name } + end - Formulary.enable_factory_cache! + use_runtime_dependents = args.installed? && + !used_formulae_missing && + !args.include_implicit? && + !args.include_build? && + !args.include_test? && + !args.include_optional? && + !args.skip_recommended? - used_formulae_missing = false - used_formulae = begin - Homebrew.args.formulae - rescue FormulaUnavailableError => e - opoo e - used_formulae_missing = true - # If the formula doesn't exist: fake the needed formula object name. - Homebrew.args.named.map { |name| OpenStruct.new name: name, full_name: name } - end + uses = intersection_of_dependents(use_runtime_dependents, used_formulae) - use_runtime_dependents = args.installed? && - !args.include_build? && - !args.include_test? && - !args.include_optional? && - !args.skip_recommended? - - uses = if use_runtime_dependents && !used_formulae_missing - used_formulae.map(&:runtime_installed_formula_dependents) - .reduce(&:&) - .select(&:any_version_installed?) - else - formulae = args.installed? ? Formula.installed : Formula - recursive = args.recursive? - includes, ignores = argv_includes_ignores(ARGV) - - formulae.select do |f| - deps = if recursive - recursive_includes(Dependency, f, includes, ignores) + return if uses.empty? + + puts Formatter.columns(uses.map(&:full_name).sort) + odie "Missing formulae should not have dependents!" if used_formulae_missing + end + + private + + sig { + params(use_runtime_dependents: T::Boolean, used_formulae: T::Array[T.any(Formula, UnavailableFormula)]) + .returns(T::Array[T.any(Formula, CaskDependent)]) + } + def intersection_of_dependents(use_runtime_dependents, used_formulae) + recursive = args.recursive? + show_formulae_and_casks = !args.formula? && !args.cask? + includes, ignores = args_includes_ignores(args) + + deps = [] + if use_runtime_dependents + # We can only get here if `used_formulae_missing` is false, thus there are no UnavailableFormula. + used_formulae = T.cast(used_formulae, T::Array[Formula]) + if show_formulae_and_casks || args.formula? + deps += T.must(used_formulae.map(&:runtime_installed_formula_dependents) + .reduce(&:&)) + .select(&:any_version_installed?) + end + if show_formulae_and_casks || args.cask? + deps += select_used_dependents( + dependents(Cask::Caskroom.casks), + used_formulae, recursive, includes, ignores + ) + end + + deps else - reject_ignores(f.deps, ignores, includes) - end + eval_all = args.eval_all? + eval_all ||= Homebrew::EnvConfig.tap_trust_configured? - used_formulae.all? do |ff| - deps.any? do |dep| - match = begin - dep.to_formula.full_name == ff.full_name if dep.name.include?("/") - rescue - nil - end - next match unless match.nil? + if !args.installed? && !eval_all + raise UsageError, + "`brew uses` needs `--installed`, `HOMEBREW_REQUIRE_TAP_TRUST=1` or " \ + "`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` set!" + end - dep.name == ff.name + if show_formulae_and_casks || args.formula? + deps += args.installed? ? Formula.installed : Formula.all(eval_all:) end - rescue FormulaUnavailableError - # Silently ignore this case as we don't care about things used in - # taps that aren't currently tapped. - next + if show_formulae_and_casks || args.cask? + deps += args.installed? ? Cask::Caskroom.casks : Cask::Cask.all(eval_all:) + end + + if args.missing? + deps.reject!(&:any_version_installed?) + ignores.delete(:satisfied?) + end + + select_used_dependents(dependents(deps), used_formulae, recursive, includes, ignores) end end - end - return if uses.empty? + sig { + params( + dependents: T::Array[T.any(Formula, CaskDependent)], + used_formulae: T::Array[T.any(Formula, UnavailableFormula)], + recursive: T::Boolean, + includes: T::Array[Symbol], + ignores: T::Array[Symbol], + ).returns(T::Array[T.any(Formula, CaskDependent)]) + } + def select_used_dependents(dependents, used_formulae, recursive, includes, ignores) + dependents.select do |d| + deps = if recursive + recursive_dep_includes(d, includes, ignores) + else + select_includes(d.deps, ignores, includes) + end - puts Formatter.columns(uses.map(&:full_name).sort) - odie "Missing formulae should not have dependents!" if used_formulae_missing + used_formulae.all? do |ff| + deps.any? do |dep| + match = case dep + when Dependency + dep.to_formula.full_name == ff.full_name if dep.name.include?("/") + when Requirement + nil + else + T.absurd(dep) + end + next match unless match.nil? + + dep.name == ff.name + end + rescue FormulaUnavailableError + # Silently ignore this case as we don't care about things used in + # taps that aren't currently tapped. + next + end + end + end + end end end diff --git a/Library/Homebrew/cmd/vendor-install.rb b/Library/Homebrew/cmd/vendor-install.rb new file mode 100644 index 0000000000000..5995dd7682e18 --- /dev/null +++ b/Library/Homebrew/cmd/vendor-install.rb @@ -0,0 +1,23 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class VendorInstall < AbstractCommand + include ShellCommand + + cmd_args do + description <<~EOS + Install Homebrew's portable Ruby. + EOS + + named_args :target + + hide_from_man_page! + end + end + end +end diff --git a/Library/Homebrew/cmd/vendor-install.sh b/Library/Homebrew/cmd/vendor-install.sh index fdd49136208f7..a4fc1c137cbde 100644 --- a/Library/Homebrew/cmd/vendor-install.sh +++ b/Library/Homebrew/cmd/vendor-install.sh @@ -1,159 +1,237 @@ -#: @hide_from_man_page -#: * `vendor-install` [] -#: -#: Install Homebrew's portable Ruby. - -# Don't need shellcheck to follow this `source`. -# shellcheck disable=SC1090 -source "$HOMEBREW_LIBRARY/Homebrew/utils/lock.sh" - -VENDOR_DIR="$HOMEBREW_LIBRARY/Homebrew/vendor" - -# Built from https://github.com/Homebrew/homebrew-portable-ruby. -# -# Dynamic variables can't be detected by shellcheck -# shellcheck disable=SC2034 -if [[ -n "$HOMEBREW_MACOS" ]] -then - if [[ "$HOMEBREW_PROCESSOR" = "Intel" ]] - then - ruby_URL="$HOMEBREW_BOTTLE_DOMAIN/bottles-portable-ruby/portable-ruby-2.6.3.mavericks.bottle.tar.gz" - ruby_URL2="https://github.com/Homebrew/homebrew-portable-ruby/releases/download/2.6.3/portable-ruby-2.6.3.mavericks.bottle.tar.gz" - ruby_SHA="ab81211a2052ccaa6d050741c433b728d0641523d8742eef23a5b450811e5104" - fi -elif [[ -n "$HOMEBREW_LINUX" ]] -then - case "$HOMEBREW_PROCESSOR" in - x86_64) - ruby_URL="$HOMEBREW_BOTTLE_DOMAIN/bottles-portable-ruby/portable-ruby-2.6.3.x86_64_linux.bottle.tar.gz" - ruby_URL2="https://github.com/Homebrew/homebrew-portable-ruby/releases/download/2.6.3/portable-ruby-2.6.3.x86_64_linux.bottle.tar.gz" - ruby_SHA="e8c9b6d3dc5f40844e07b4b694897b8b7cb5a7dab1013b3b8712a22868f98c98" - ;; - aarch64) - ruby_URL="$HOMEBREW_BOTTLE_DOMAIN/bottles-portable-ruby/portable-ruby-2.6.3.aarch64_linux.bottle.tar.gz" - ruby_URL2="https://github.com/Homebrew/homebrew-portable-ruby/releases/download/2.6.3/portable-ruby-2.6.3.aarch64_linux.bottle.tar.gz" - ruby_SHA="c0b08e2835897af74948508a004d30380b8bcf14264e0dcce194e2c199fb1e35" - ;; - armv[67]*) - ruby_URL="$HOMEBREW_BOTTLE_DOMAIN/bottles-portable-ruby/portable-ruby-2.6.3.armv6_linux.bottle.tar.gz" - ruby_URL2="https://github.com/Homebrew/homebrew-portable-ruby/releases/download/2.6.3/portable-ruby-2.6.3.armv6_linux.bottle.tar.gz" - ruby_SHA="78e36e4671fd08790bfbfda4408d559341c9872bf48a4f6eab78157a3bf3efa6" - ;; - esac -fi - -# Execute the specified command, and suppress stderr unless HOMEBREW_STDERR is set. -quiet_stderr() { - if [[ -z "$HOMEBREW_STDERR" ]]; then - command "$@" 2>/dev/null +# Documentation defined in Library/Homebrew/cmd/vendor-install.rb + +# HOMEBREW_ARTIFACT_DOMAIN, HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK, HOMEBREW_BOTTLE_DOMAIN, HOMEBREW_CACHE, +# HOMEBREW_CURLRC, HOMEBREW_DEVELOPER, HOMEBREW_DEBUG, HOMEBREW_VERBOSE are from the user environment +# HOMEBREW_PORTABLE_RUBY_VERSION is set by utils/ruby.sh +# HOMEBREW_LIBRARY, HOMEBREW_PREFIX are set by bin/brew +# HOMEBREW_CURL, HOMEBREW_GITHUB_PACKAGES_AUTH, HOMEBREW_LINUX, HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION, HOMEBREW_MACOS, +# HOMEBREW_PHYSICAL_PROCESSOR, HOMEBREW_PROCESSOR, HOMEBREW_USER_AGENT_CURL are set by brew.sh +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" + +VENDOR_DIR="${HOMEBREW_LIBRARY}/Homebrew/vendor" + +set_ruby_variables() { + # Handle the case where /usr/local/bin/brew is run under arm64. + # It's a x86_64 installation there (we refuse to install arm64 binaries) so + # use a x86_64 Portable Ruby. + if [[ -n "${HOMEBREW_MACOS}" && "${VENDOR_PHYSICAL_PROCESSOR}" == "arm64" && "${HOMEBREW_PREFIX}" == "/usr/local" ]] + then + ruby_PROCESSOR="x86_64" + ruby_OS="darwin" else - command "$@" + ruby_PROCESSOR="${VENDOR_PHYSICAL_PROCESSOR}" + if [[ -n "${HOMEBREW_MACOS}" ]] + then + ruby_OS="darwin" + elif [[ -n "${HOMEBREW_LINUX}" ]] + then + ruby_OS="linux" + fi + fi + + ruby_PLATFORMINFO="${HOMEBREW_LIBRARY}/Homebrew/vendor/portable-ruby-${ruby_PROCESSOR}-${ruby_OS}" + if [[ -f "${ruby_PLATFORMINFO}" && -r "${ruby_PLATFORMINFO}" ]] + then + # ruby_TAG and ruby_SHA will be set via the sourced file if it exists + # shellcheck disable=SC1090 + source "${ruby_PLATFORMINFO}" + fi + + # Dynamic variables can't be detected by shellcheck + # shellcheck disable=SC2034 + if [[ -n "${ruby_TAG}" && -n "${ruby_SHA}" ]] + then + ruby_FILENAME="portable-ruby-${HOMEBREW_PORTABLE_RUBY_VERSION}.${ruby_TAG}.bottle.tar.gz" + ruby_URLs=() + if [[ -n "${HOMEBREW_ARTIFACT_DOMAIN}" ]] + then + ruby_URLs+=("${HOMEBREW_ARTIFACT_DOMAIN}/v2/homebrew/core/portable-ruby/blobs/sha256:${ruby_SHA}") + if [[ -n "${HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK}" ]] + then + ruby_URL="${ruby_URLs[0]}" + return + fi + fi + if [[ -n "${HOMEBREW_BOTTLE_DOMAIN}" ]] + then + ruby_URLs+=("${HOMEBREW_BOTTLE_DOMAIN}/${ruby_FILENAME}") + fi + ruby_URLs+=( + "https://ghcr.io/v2/homebrew/core/portable-ruby/blobs/sha256:${ruby_SHA}" + ) + ruby_URL="${ruby_URLs[0]}" + fi +} + +check_linux_glibc_version() { + if [[ -z "${HOMEBREW_LINUX}" || -z "${HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION}" ]] + then + return 0 + fi + + local glibc_version + local glibc_version_major + local glibc_version_minor + + local minimum_required_major="${HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION%.*}" + local minimum_required_minor="${HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION#*.}" + + if [[ "$(/usr/bin/ldd --version)" =~ \ [0-9]\.[0-9]+ ]] + then + glibc_version="${BASH_REMATCH[0]// /}" + glibc_version_major="${glibc_version%.*}" + glibc_version_minor="${glibc_version#*.}" + if ((glibc_version_major < minimum_required_major || glibc_version_minor < minimum_required_minor)) + then + odie "Vendored tools require system Glibc ${HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION} or later (yours is ${glibc_version})." + fi + else + odie "Failed to detect system Glibc version." fi } fetch() { local -a curl_args + local url local sha + local first_try=1 + local vendor_locations local temporary_path + local curl_exit_code=0 curl_args=() # do not load .curlrc unless requested (must be the first argument) - if [[ -z "$HOMEBREW_CURLRC" ]] + # HOMEBREW_CURLRC isn't misspelt here + # shellcheck disable=SC2153 + if [[ -z "${HOMEBREW_CURLRC}" ]] then curl_args[${#curl_args[*]}]="-q" + elif [[ "${HOMEBREW_CURLRC}" == /* ]] + then + curl_args+=("-q" "--config" "${HOMEBREW_CURLRC}") fi + # Authorization is needed for GitHub Packages but harmless on GitHub Releases curl_args+=( --fail --remote-time --location - --user-agent "$HOMEBREW_USER_AGENT_CURL" + --user-agent "${HOMEBREW_USER_AGENT_CURL}" ) - if [[ -n "$HOMEBREW_QUIET" ]] - then - curl_args[${#curl_args[*]}]="--silent" - elif [[ -z "$HOMEBREW_VERBOSE" ]] + if [[ -n "${HOMEBREW_GITHUB_PACKAGES_AUTH}" ]] then - curl_args[${#curl_args[*]}]="--progress-bar" + curl_args[${#curl_args[*]}]="--header" + curl_args[${#curl_args[*]}]="Authorization: ${HOMEBREW_GITHUB_PACKAGES_AUTH}" fi - if [[ "$HOMEBREW_MACOS_VERSION_NUMERIC" -lt "100600" ]] + if [[ -n "${HOMEBREW_QUIET}" ]] then - curl_args[${#curl_args[*]}]="--insecure" + curl_args[${#curl_args[*]}]="--silent" + elif [[ -z "${HOMEBREW_VERBOSE}" ]] + then + curl_args[${#curl_args[*]}]="--progress-bar" fi - temporary_path="$CACHED_LOCATION.incomplete" + temporary_path="${CACHED_LOCATION}.incomplete" - mkdir -p "$HOMEBREW_CACHE" - [[ -n "$HOMEBREW_QUIET" ]] || ohai "Downloading $VENDOR_URL" >&2 - if [[ -f "$CACHED_LOCATION" ]] + mkdir -p "${HOMEBREW_CACHE}" + [[ -n "${HOMEBREW_QUIET}" ]] || ohai "Downloading ${VENDOR_URL}" >&2 + if [[ -f "${CACHED_LOCATION}" ]] then - [[ -n "$HOMEBREW_QUIET" ]] || echo "Already downloaded: $CACHED_LOCATION" >&2 + [[ -n "${HOMEBREW_QUIET}" ]] || echo "Already downloaded: ${CACHED_LOCATION}" >&2 else - if [[ -f "$temporary_path" ]] - then - "$HOMEBREW_CURL" "${curl_args[@]}" -C - "$VENDOR_URL" -o "$temporary_path" - if [[ $? -eq 33 ]] + for url in "${VENDOR_URLs[@]}" + do + [[ -n "${HOMEBREW_QUIET}" || -n "${first_try}" ]] || ohai "Downloading ${url}" >&2 + first_try='' + if [[ -f "${temporary_path}" ]] then - [[ -n "$HOMEBREW_QUIET" ]] || echo "Trying a full download" >&2 - rm -f "$temporary_path" - "$HOMEBREW_CURL" "${curl_args[@]}" "$VENDOR_URL" -o "$temporary_path" + # HOMEBREW_CURL is set by brew.sh (and isn't misspelt here) + # shellcheck disable=SC2153 + "${HOMEBREW_CURL}" "${curl_args[@]}" -C - "${url}" -o "${temporary_path}" + curl_exit_code="$?" + if [[ "${curl_exit_code}" -eq 33 ]] + then + [[ -n "${HOMEBREW_QUIET}" ]] || echo "Trying a full download" >&2 + rm -f "${temporary_path}" + "${HOMEBREW_CURL}" "${curl_args[@]}" "${url}" -o "${temporary_path}" + curl_exit_code="$?" + fi + else + "${HOMEBREW_CURL}" "${curl_args[@]}" "${url}" -o "${temporary_path}" + curl_exit_code="$?" fi - else - "$HOMEBREW_CURL" "${curl_args[@]}" "$VENDOR_URL" -o "$temporary_path" - fi - if [[ ! -f "$temporary_path" ]] + [[ -f "${temporary_path}" ]] && break + done + + if [[ "${curl_exit_code}" -ne 0 ]] then - [[ -n "$HOMEBREW_QUIET" ]] || ohai "Downloading $VENDOR_URL2" >&2 - "$HOMEBREW_CURL" "${curl_args[@]}" "$VENDOR_URL2" -o "$temporary_path" + rm -f "${temporary_path}" fi - if [[ ! -f "$temporary_path" ]] + if [[ ! -f "${temporary_path}" ]] then + vendor_locations="$(printf " - %s\n" "${VENDOR_URLs[@]}")" odie <&2 - tar "$tar_args" "$CACHED_LOCATION" - safe_cd "$VENDOR_DIR/portable-$VENDOR_NAME" + safe_cd "${VENDOR_DIR}" + [[ -n "${HOMEBREW_QUIET}" ]] || ohai "Pouring ${VENDOR_FILENAME}" >&2 + tar "${tar_args}" "${CACHED_LOCATION}" - if quiet_stderr "./$VENDOR_VERSION/bin/$VENDOR_NAME" --version >/dev/null + if [[ "${VENDOR_PROCESSOR}" != "${HOMEBREW_PROCESSOR}" ]] || + [[ "${VENDOR_PHYSICAL_PROCESSOR}" != "${HOMEBREW_PHYSICAL_PROCESSOR}" ]] then - ln -sfn "$VENDOR_VERSION" current - if [[ -d "$VENDOR_VERSION.reinstall" ]] + return 0 + fi + + safe_cd "${VENDOR_DIR}/portable-${VENDOR_NAME}" + + if "./${VENDOR_VERSION}/bin/${VENDOR_NAME}" --version >/dev/null + then + ln -sfn "${VENDOR_VERSION}" current + if [[ -d "${VENDOR_VERSION}.reinstall" ]] then - rm -rf "$VENDOR_VERSION.reinstall" + rm -rf "${VENDOR_VERSION}.reinstall" fi else - rm -rf "$VENDOR_VERSION" - if [[ -d "$VENDOR_VERSION.reinstall" ]] + rm -rf "${VENDOR_VERSION}" + if [[ -d "${VENDOR_VERSION}.reinstall" ]] then - mv "$VENDOR_VERSION.reinstall" "$VENDOR_VERSION" + mv "${VENDOR_VERSION}.reinstall" "${VENDOR_VERSION}" fi - odie "Failed to vendor $VENDOR_NAME $VENDOR_VERSION." + odie "Failed to install ${VENDOR_NAME} ${VENDOR_VERSION}!" fi trap - SIGINT @@ -208,48 +293,82 @@ homebrew-vendor-install() { local url_var local sha_var + unset VENDOR_PHYSICAL_PROCESSOR + unset VENDOR_PROCESSOR + for option in "$@" do - case "$option" in - -\?|-h|--help|--usage) brew help vendor-install; exit $? ;; - --verbose) HOMEBREW_VERBOSE=1 ;; - --quiet) HOMEBREW_QUIET=1 ;; - --debug) HOMEBREW_DEBUG=1 ;; - --*) ;; - -*) - [[ "$option" = *v* ]] && HOMEBREW_VERBOSE=1 - [[ "$option" = *q* ]] && HOMEBREW_QUIET=1 - [[ "$option" = *d* ]] && HOMEBREW_DEBUG=1 - ;; + if homebrew-command-help vendor-install "${option}" + then + exit $? + fi + if homebrew-command-common-option "${option}" + then + continue + fi + + case "${option}" in + --*) ;; + -*) homebrew-command-common-short-options "${option}" ;; *) - [[ -n "$VENDOR_NAME" ]] && odie "This command does not take multiple vendor targets" - VENDOR_NAME="$option" + if [[ -n "${VENDOR_NAME}" ]] + then + if [[ -n "${HOMEBREW_DEVELOPER}" ]] + then + if [[ -n "${PROCESSOR_TARGET}" ]] + then + odie "This command does not take more than vendor and processor targets!" + else + VENDOR_PHYSICAL_PROCESSOR="${option}" + VENDOR_PROCESSOR="${option}" + fi + else + odie "This command does not take multiple vendor targets!" + fi + else + VENDOR_NAME="${option}" + fi ;; esac done - [[ -z "$VENDOR_NAME" ]] && odie "This command requires one vendor target." - [[ -n "$HOMEBREW_DEBUG" ]] && set -x + [[ -z "${VENDOR_NAME}" ]] && odie "This command requires a vendor target!" + homebrew-command-enable-debug - url_var="${VENDOR_NAME}_URL" - url2_var="${VENDOR_NAME}_URL2" + if [[ -z "${VENDOR_PHYSICAL_PROCESSOR}" ]] + then + VENDOR_PHYSICAL_PROCESSOR="${HOMEBREW_PHYSICAL_PROCESSOR}" + fi + + if [[ -z "${VENDOR_PROCESSOR}" ]] + then + VENDOR_PROCESSOR="${HOMEBREW_PROCESSOR}" + fi + + set_ruby_variables + check_linux_glibc_version + + filename_var="${VENDOR_NAME}_FILENAME" sha_var="${VENDOR_NAME}_SHA" - VENDOR_URL="${!url_var}" - VENDOR_URL2="${!url2_var}" + url_var="${VENDOR_NAME}_URL" + VENDOR_FILENAME="${!filename_var}" VENDOR_SHA="${!sha_var}" + VENDOR_URL="${!url_var}" + VENDOR_VERSION="$(cat "${VENDOR_DIR}/portable-${VENDOR_NAME}-version")" - if [[ -z "$VENDOR_URL" || -z "$VENDOR_SHA" ]] + if [[ -z "${VENDOR_URL}" || -z "${VENDOR_SHA}" ]] then - odie <<-EOS -Cannot find a vendored version of $VENDOR_NAME for your $HOMEBREW_PROCESSOR -processor on $HOMEBREW_PRODUCT! -EOS + odie "No Homebrew ${VENDOR_NAME} ${VENDOR_VERSION} available for ${HOMEBREW_PROCESSOR} processors!" fi - VENDOR_VERSION="$(<"$VENDOR_DIR/portable-$VENDOR_NAME-version")" - CACHED_LOCATION="$HOMEBREW_CACHE/$(basename "$VENDOR_URL")" + # Expand the name to an array of variables + # The array name must be "${VENDOR_NAME}_URLs"! Otherwise substitution errors will occur! + # shellcheck disable=SC2086 + read -r -a VENDOR_URLs <<<"$(eval "echo "\$\{${url_var}s[@]\}"")" + + CACHED_LOCATION="${HOMEBREW_CACHE}/${VENDOR_FILENAME}" - lock "vendor-install-$VENDOR_NAME" + lock "vendor-install ${VENDOR_NAME}" fetch install } diff --git a/Library/Homebrew/cmd/version-install.rb b/Library/Homebrew/cmd/version-install.rb new file mode 100644 index 0000000000000..06756847cc62e --- /dev/null +++ b/Library/Homebrew/cmd/version-install.rb @@ -0,0 +1,141 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" +require "formulary" +require "tap" +require "utils/github" +require "utils/user" + +module Homebrew + module Cmd + class VersionInstall < AbstractCommand + DEFAULT_TAP_REPOSITORY = "versions" + private_constant :DEFAULT_TAP_REPOSITORY + + cmd_args do + usage_banner "`version-install` [@] []" + description <<~EOS + Extract a specific of into a personal tap and install it. + The default tap is /#{DEFAULT_TAP_REPOSITORY}. + uses the GitHub username if available and the local username otherwise. + EOS + + named_args [:formula, :version], min: 1, max: 2 + end + + sig { override.void } + def run + formula_input = args.named.fetch(0) + version_input = args.named[1] + + if version_input.nil? || formula_input.include?("@") + unless formula_input.include?("@") + raise UsageError, "Specify a version with or @." + end + + formula_base, _, version_from_input = formula_input.rpartition("@") + odie "Invalid formula reference: #{formula_input}" if formula_base.empty? || version_from_input.empty? + + version_input ||= version_from_input + odie "Version mismatch: #{formula_input} != #{version_input}" if version_from_input != version_input + + versioned_ref = formula_input + formula_input = formula_base + end + + tap_with_name = Tap.with_formula_name(formula_input) + tap, base_name = tap_with_name || [nil, formula_input] + base_name = base_name.downcase + .sub(/\b@(.*)\z\b/i, "") + normalized_version = version_input.to_s + .sub(/\D*(.+?)\D*$/, "\\1") + .gsub(/\D+/, ".") + versioned_name = "#{base_name}@#{normalized_version}" + versioned_ref ||= if tap + "#{tap}/#{versioned_name}" + else + versioned_name + end + + installed_formula_names = Formula.installed_formula_names + if installed_formula_names.include?(versioned_name) + ohai "#{versioned_name} is already installed" + return + end + + existing_tap = Tap.installed + .sort_by(&:name) + .find { |tap| tap.formula_files_by_name.key?(versioned_name) } + install_target = "#{existing_tap}/#{versioned_name}" if existing_tap + + versioned_formula = begin + Formulary.factory(versioned_ref, warn: false) + rescue TapFormulaAmbiguityError, FormulaUnavailableError, TapFormulaUnavailableError, + TapFormulaUnreadableError + nil + end + + if install_target.nil? + install_target = if versioned_formula + versioned_formula.full_name + else + current_formula = begin + Formulary.factory(formula_input, warn: false) + rescue FormulaUnavailableError, TapFormulaUnavailableError, TapFormulaUnreadableError + nil + end + + if current_formula && current_formula.version.to_s == version_input + if installed_formula_names.include?(current_formula.name) + ohai "#{current_formula.full_name} is already installed" + return + end + + current_formula.full_name + end + end + end + + # Pretend we've run a dev command to avoid making it seem like the user + # has done so manually. + ENV["HOMEBREW_DEV_CMD_RUN"] = "1" + + if install_target.nil? + username = if !Homebrew::EnvConfig.no_github_api? && GitHub::API.credentials_type != :none + begin + GitHub.user["login"].presence + rescue *GitHub::API::ERRORS + nil + end + end + username ||= User.current&.to_s + username ||= ENV.fetch("USER") + odie "Unable to determine a username for tap creation." if username.blank? + + tap = Tap.fetch("#{username}/homebrew-#{DEFAULT_TAP_REPOSITORY}") + unless tap.installed? + ohai "Creating #{tap.name} tap for storing versioned formulae..." + safe_system HOMEBREW_BREW_FILE, "tap-new", "--no-git", tap.name + end + + ohai "Extracting #{formula_input}@#{version_input} into #{tap.name}..." + safe_system HOMEBREW_BREW_FILE, "extract", formula_input, tap.name, "--version=#{version_input}" + + install_target = "#{tap}/#{versioned_name}" + + opoo <<~EOS + You are responsible for maintaining this #{install_target}! + It will not receive any bugfix/security updates. + Homebrew cannot support it for you because we cannot maintain every formula + at every version or fix older versions in our Git history. + EOS + end + + ohai "Installing #{install_target}..." + safe_system HOMEBREW_BREW_FILE, "install", install_target + end + end + end +end diff --git a/Library/Homebrew/cmd/which-formula.rb b/Library/Homebrew/cmd/which-formula.rb new file mode 100755 index 0000000000000..98616e15e0667 --- /dev/null +++ b/Library/Homebrew/cmd/which-formula.rb @@ -0,0 +1,29 @@ +# typed: strict +# frozen_string_literal: true + +# License: MIT +# The license text can be found in Library/Homebrew/command-not-found/LICENSE + +require "abstract_command" +require "api" +require "shell_command" + +module Homebrew + module Cmd + class WhichFormula < AbstractCommand + ENDPOINT = "internal/executables.txt" + DATABASE_FILE = T.let((Homebrew::API::HOMEBREW_CACHE_API/ENDPOINT).freeze, Pathname) + + include ShellCommand + + cmd_args do + description <<~EOS + Show which formula(e) provides the given command. + EOS + switch "--explain", + description: "Output explanation of how to get by installing one of the providing formulae." + named_args :command, min: 1 + end + end + end +end diff --git a/Library/Homebrew/cmd/which-formula.sh b/Library/Homebrew/cmd/which-formula.sh new file mode 100644 index 0000000000000..6a4f9b06559de --- /dev/null +++ b/Library/Homebrew/cmd/which-formula.sh @@ -0,0 +1,90 @@ +# Documentation defined in Library/Homebrew/cmd/which-formula.rb + +# shellcheck disable=SC2154 +source "${HOMEBREW_LIBRARY}/Homebrew/utils/cmd.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/executables.sh" + +homebrew-which-formula() { + local args=() + + while [[ "$#" -gt 0 ]] + do + if homebrew-command-help which-formula "$1" + then + return $? + fi + if homebrew-command-common-option "$1" + then + shift + continue + fi + + case "$1" in + --explain) + HOMEBREW_EXPLAIN=1 + ;; + --*) + onoe "Unknown option: $1" + brew help which-formula + return 1 + ;; + -*) homebrew-command-common-short-options "$1" ;; + *) args+=("$1") ;; + esac + shift + done + + homebrew-command-enable-debug + + if [[ ${#args[@]} -eq 0 ]] + then + brew help which-formula + exit 1 + fi + + for cmd in "${args[@]}" + do + ensure_executables_file + + local formulae=() + local formula + while read -r formula + do + formulae+=("${formula}") + done < <(formulae_containing_executable "${cmd}") + + [[ ${#formulae[@]} -eq 0 ]] && return 1 + + if [[ -n ${HOMEBREW_EXPLAIN} ]] + then + local filtered_formulae=() + for formula in "${formulae[@]}" + do + if [[ ! -d "${HOMEBREW_CELLAR}/${formula}" ]] + then + filtered_formulae+=("${formula}") + fi + done + + if [[ ${#filtered_formulae[@]} -eq 0 ]] + then + return 1 + fi + + if [[ ${#filtered_formulae[@]} -eq 1 ]] + then + echo "The program '${cmd}' is currently not installed. You can install it by typing:" + echo " brew install ${filtered_formulae[0]}" + else + echo "The program '${cmd}' can be found in the following formulae:" + printf " * %s\n" "${filtered_formulae[@]}" + echo "Try: brew install " + fi + else + for formula in "${formulae[@]}" + do + print_formula_install_status "${formula}" + done + fi + done +} diff --git a/Library/Homebrew/command-not-found/LICENSE b/Library/Homebrew/command-not-found/LICENSE new file mode 100644 index 0000000000000..c1255e837b4d1 --- /dev/null +++ b/Library/Homebrew/command-not-found/LICENSE @@ -0,0 +1,7 @@ +Copyright © 2014-2018 Homebrew contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Library/Homebrew/command-not-found/handler.fish b/Library/Homebrew/command-not-found/handler.fish new file mode 100644 index 0000000000000..e7b760a7f915a --- /dev/null +++ b/Library/Homebrew/command-not-found/handler.fish @@ -0,0 +1,22 @@ +# See https://docs.brew.sh/Command-Not-Found for current setup instructions +# License: MIT +# The license text can be found in Library/Homebrew/command-not-found/LICENSE + +function fish_command_not_found + set -l cmd $argv[1] + set -l txt + + if not contains -- "$cmd" "-h" "--help" "--usage" "-?" + set txt (brew which-formula --explain $cmd 2> /dev/null) + end + + if test -z "$txt" + __fish_default_command_not_found_handler $cmd + else + string collect $txt + end +end + +function __fish_command_not_found_handler --on-event fish_command_not_found + fish_command_not_found $argv +end diff --git a/Library/Homebrew/command-not-found/handler.sh b/Library/Homebrew/command-not-found/handler.sh new file mode 100644 index 0000000000000..51fa56af7ea5e --- /dev/null +++ b/Library/Homebrew/command-not-found/handler.sh @@ -0,0 +1,70 @@ +# +# Homebrew command-not-found script for macOS +# +# Usage: Source it somewhere in your .bashrc (bash) or .zshrc (zsh) +# +# Author: Baptiste Fontaine +# License: MIT +# The license text can be found in Library/Homebrew/command-not-found/LICENSE + +if ! command -v brew >/dev/null; then return; fi + +homebrew_command_not_found_handle() { + local cmd="$1" + + if [[ -n "${ZSH_VERSION}" ]] + then + autoload is-at-least + fi + + # The code below is based off this Linux Journal article: + # http://www.linuxjournal.com/content/bash-command-not-found + + # do not run when inside Midnight Commander or within a Pipe, except if CI + # HOMEBREW_COMMAND_NOT_FOUND_CI is defined in the CI environment + # MC_SID is defined when running inside Midnight Commander. + # shellcheck disable=SC2154 + if test -z "${HOMEBREW_COMMAND_NOT_FOUND_CI}" && test -n "${MC_SID}" -o ! -t 1 + then + [[ -n "${BASH_VERSION}" ]] && + TEXTDOMAIN=command-not-found echo $"${cmd}: command not found" + # Zsh versions 5.3 and above don't print this for us. + [[ -n "${ZSH_VERSION}" ]] && is-at-least "5.2" "${ZSH_VERSION}" && + echo "zsh: command not found: ${cmd}" >&2 + return 127 + fi + + if [[ "${cmd}" != "-h" ]] && [[ "${cmd}" != "--help" ]] && [[ "${cmd}" != "--usage" ]] && [[ "${cmd}" != "-?" ]] + then + local txt + txt="$(brew which-formula --explain "${cmd}" 2>/dev/null)" + fi + + if [[ -z "${txt}" ]] + then + [[ -n "${BASH_VERSION}" ]] && + TEXTDOMAIN=command-not-found echo $"${cmd}: command not found" + + # Zsh versions 5.3 and above don't print this for us. + [[ -n "${ZSH_VERSION}" ]] && is-at-least "5.2" "${ZSH_VERSION}" && + echo "zsh: command not found: ${cmd}" >&2 + else + echo "${txt}" + fi + + return 127 +} + +if [[ -n "${BASH_VERSION}" ]] +then + command_not_found_handle() { + homebrew_command_not_found_handle "$*" + return $? + } +elif [[ -n "${ZSH_VERSION}" ]] +then + command_not_found_handler() { + homebrew_command_not_found_handle "$*" + return $? + } +fi diff --git a/Library/Homebrew/command_path.sh b/Library/Homebrew/command_path.sh new file mode 100644 index 0000000000000..362b654abe255 --- /dev/null +++ b/Library/Homebrew/command_path.sh @@ -0,0 +1,47 @@ +# does the quickest output of brew command possible for the basic cases of an +# official Bash or Ruby normal or dev-cmd command. +# HOMEBREW_LIBRARY is set by brew.sh +# shellcheck disable=SC2154 +homebrew-command-path() { + case "$1" in + # check we actually have command and not e.g. commandsomething + command) ;; + command*) return 1 ;; + *) ;; + esac + + local first_command found_command + for arg in "$@" + do + if [[ -z "${first_command}" && "${arg}" == "command" ]] + then + first_command=1 + continue + elif [[ -f "${HOMEBREW_LIBRARY}/Homebrew/cmd/${arg}.sh" ]] + then + echo "${HOMEBREW_LIBRARY}/Homebrew/cmd/${arg}.sh" + found_command=1 + elif [[ -f "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${arg}.sh" ]] + then + echo "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${arg}.sh" + found_command=1 + elif [[ -f "${HOMEBREW_LIBRARY}/Homebrew/cmd/${arg}.rb" ]] + then + echo "${HOMEBREW_LIBRARY}/Homebrew/cmd/${arg}.rb" + found_command=1 + elif [[ -f "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${arg}.rb" ]] + then + echo "${HOMEBREW_LIBRARY}/Homebrew/dev-cmd/${arg}.rb" + found_command=1 + else + return 1 + fi + done + + if [[ -n "${found_command}" ]] + then + return 0 + else + return 1 + fi +} diff --git a/Library/Homebrew/commands.rb b/Library/Homebrew/commands.rb index 46db1789170ef..4d6bd1d5e11c0 100644 --- a/Library/Homebrew/commands.rb +++ b/Library/Homebrew/commands.rb @@ -1,12 +1,358 @@ +# typed: strict # frozen_string_literal: true +require "homebrew" +require "cli/parser" +require "extend/ENV/sensitive" + +# Helper functions for commands. module Commands - def self.path(cmd) + HOMEBREW_CMD_PATH = T.let((HOMEBREW_LIBRARY_PATH/"cmd").freeze, Pathname) + HOMEBREW_DEV_CMD_PATH = T.let((HOMEBREW_LIBRARY_PATH/"dev-cmd").freeze, Pathname) + # If you are going to change anything in below hash, + # be sure to also update appropriate case statement in brew.sh + HOMEBREW_INTERNAL_COMMAND_ALIASES = T.let({ + "ls" => "list", + "homepage" => "home", + "-S" => "search", + "up" => "update", + "ln" => "link", + "instal" => "install", # gem does the same + "uninstal" => "uninstall", + "post_install" => "postinstall", + "rm" => "uninstall", + "remove" => "uninstall", + "abv" => "info", + "dr" => "doctor", + "--repo" => "--repository", + "environment" => "--env", + "--config" => "config", + "-v" => "--version", + "lc" => "livecheck", + "tc" => "typecheck", + "x" => "exec", + }.freeze, T::Hash[String, String]) + # This pattern is used to split descriptions at full stops. We only consider a + # dot as a full stop if it is either followed by a whitespace or at the end of + # the description. In this way we can prevent cutting off a sentence in the + # middle due to dots in URLs or paths. + DESCRIPTION_SPLITTING_PATTERN = /\.(?>\s|$)/ + + sig { params(cmd: String).returns(T::Boolean) } + def self.valid_internal_cmd?(cmd) + Homebrew.require?(HOMEBREW_CMD_PATH/cmd) + end + + sig { params(cmd: String).returns(T::Boolean) } + def self.valid_internal_dev_cmd?(cmd) + Homebrew.require?(HOMEBREW_DEV_CMD_PATH/cmd) + end + + sig { params(cmd: String).returns(T::Boolean) } + def self.valid_ruby_cmd?(cmd) + (valid_internal_cmd?(cmd) || valid_internal_dev_cmd?(cmd) || external_ruby_v2_cmd_path(cmd).present?) && + (Homebrew::AbstractCommand.command(cmd)&.ruby_cmd? == true) + end + + sig { params(cmd: String).returns(Symbol) } + def self.method_name(cmd) + cmd.to_s + .tr("-", "_") + .downcase + .to_sym + end + + sig { params(cmd_path: Pathname).returns(Symbol) } + def self.args_method_name(cmd_path) + cmd_path_basename = basename_without_extension(cmd_path) + cmd_method_prefix = method_name(cmd_path_basename) + :"#{cmd_method_prefix}_args" + end + + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.internal_cmd_path(cmd) + [ + HOMEBREW_CMD_PATH/"#{cmd}.rb", + HOMEBREW_CMD_PATH/"#{cmd}.sh", + ].find(&:exist?) + end + + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.internal_dev_cmd_path(cmd) [ - HOMEBREW_LIBRARY_PATH/"cmd/#{cmd}.sh", - HOMEBREW_LIBRARY_PATH/"dev-cmd/#{cmd}.sh", - HOMEBREW_LIBRARY_PATH/"cmd/#{cmd}.rb", - HOMEBREW_LIBRARY_PATH/"dev-cmd/#{cmd}.rb", + HOMEBREW_DEV_CMD_PATH/"#{cmd}.rb", + HOMEBREW_DEV_CMD_PATH/"#{cmd}.sh", ].find(&:exist?) end + + # Ruby commands which can be `require`d without being run. + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.external_ruby_v2_cmd_path(cmd) + path = which("#{cmd}.rb", tap_cmd_directories) + require_trusted_command!(path, cmd) + path if ENV.clear_sensitive_environment! { Homebrew.require?(path) } + end + + # Ruby commands which are run by being `require`d. + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.external_ruby_cmd_path(cmd) + path = which("brew-#{cmd}.rb", PATH.new(ENV.fetch("PATH")).append(tap_cmd_directories)) + require_trusted_command!(path, cmd) + path + end + + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.external_cmd_path(cmd) + path = which("brew-#{cmd}", PATH.new(ENV.fetch("PATH")).append(tap_cmd_directories)) + require_trusted_command!(path, cmd) + path + end + + sig { params(path: T.nilable(Pathname), cmd: String).void } + def self.require_trusted_command!(path, cmd) + return unless path + return if path.expand_path.ascend.none?(HOMEBREW_TAP_DIRECTORY) + + require "trust" + Homebrew::Trust.require_trusted_command!(path, cmd) + end + + sig { params(cmd: String).returns(T.nilable(Pathname)) } + def self.path(cmd) + internal_cmd = HOMEBREW_INTERNAL_COMMAND_ALIASES.fetch(cmd, cmd) + path ||= internal_cmd_path(internal_cmd) + path ||= internal_dev_cmd_path(internal_cmd) + path ||= external_ruby_v2_cmd_path(cmd) + path ||= external_ruby_cmd_path(cmd) + path ||= external_cmd_path(cmd) + path + end + + sig { params(external: T::Boolean, aliases: T::Boolean).returns(T::Array[String]) } + def self.commands(external: true, aliases: false) + cmds = internal_commands + cmds += internal_developer_commands + cmds += external_commands if external + cmds += internal_commands_aliases if aliases + cmds.sort + end + + # An array of all tap cmd directory {Pathname}s. + sig { returns(T::Array[Pathname]) } + def self.tap_cmd_directories + Pathname.glob HOMEBREW_TAP_DIRECTORY/"*/*/cmd" + end + + sig { returns(T::Array[Pathname]) } + def self.internal_commands_paths + find_commands HOMEBREW_CMD_PATH + end + + sig { returns(T::Array[Pathname]) } + def self.internal_developer_commands_paths + find_commands HOMEBREW_DEV_CMD_PATH + end + + sig { returns(T::Array[String]) } + def self.internal_commands + find_internal_commands(HOMEBREW_CMD_PATH).map(&:to_s) + end + + sig { returns(T::Array[String]) } + def self.internal_developer_commands + find_internal_commands(HOMEBREW_DEV_CMD_PATH).map(&:to_s) + end + + sig { returns(T::Array[String]) } + def self.internal_commands_aliases + HOMEBREW_INTERNAL_COMMAND_ALIASES.keys + end + + sig { params(path: Pathname).returns(T::Array[String]) } + def self.find_internal_commands(path) + raise ArgumentError, "#{path} is not an official command path" \ + unless [HOMEBREW_CMD_PATH, HOMEBREW_DEV_CMD_PATH].include?(path) + + find_commands(path).map(&:basename) + .map { |basename| basename_without_extension(basename) } + .uniq + .reject { |name| Homebrew::CLI::Parser.from_cmd_path(path/"#{name}.rb")&.hide_from_man_page } + end + + sig { returns(T::Array[String]) } + def self.external_commands + tap_cmd_directories.flat_map do |path| + commands = find_commands(path).select(&:executable?) + if path.expand_path.ascend.any?(HOMEBREW_TAP_DIRECTORY) + require "trust" + commands = Homebrew::Trust.trusted_command_files(commands) + end + commands + .map { |basename| basename_without_extension(basename) } + .map { |p| p.to_s.delete_prefix("brew-").strip } + end.map(&:to_s) + .sort + end + + sig { params(path: Pathname).returns(String) } + def self.basename_without_extension(path) + path.basename(path.extname).to_s + end + + sig { params(path: Pathname).returns(T::Array[Pathname]) } + def self.find_commands(path) + Pathname.glob("#{path}/*") + .select(&:file?) + .sort + end + + sig { void } + def self.rebuild_internal_commands_completion_list + require "completions" + + cmds = internal_commands + internal_developer_commands + cmds.reject! do |cmd| + Homebrew::Completions::COMPLETIONS_EXCLUSION_LIST.include?(cmd) || + Homebrew::Completions.command_hidden_from_manpage?(cmd) + end + + file = HOMEBREW_REPOSITORY/"completions/internal_commands_list.txt" + file.atomic_write("#{cmds.sort.join("\n")}\n") + end + + sig { void } + def self.rebuild_commands_completion_list + require "completions" + + # Ensure that the cache exists so we can build the commands list + HOMEBREW_CACHE.mkpath + + # Don't reject `command_hidden_from_manpage?` commands here: internal ones + # are already excluded and checking externals loads every tap command file. + cmds = commands - Homebrew::Completions::COMPLETIONS_EXCLUSION_LIST + + all_commands_file = HOMEBREW_CACHE/"all_commands_list.txt" + external_commands_file = HOMEBREW_CACHE/"external_commands_list.txt" + all_commands_file.atomic_write("#{cmds.sort.join("\n")}\n") + external_commands_file.atomic_write("#{external_commands.sort.join("\n")}\n") + end + + sig { params(command: String, subcommand: T.nilable(String)).returns(T.nilable(T::Array[[String, String]])) } + def self.command_options(command, subcommand: nil) + return if command == "help" + + path = self.path(command) + return unless path + + if (cmd_parser = Homebrew::CLI::Parser.from_cmd_path(path)) + processed_options = if subcommand.nil? && cmd_parser.subcommands.present? + cmd_parser.processed_options_for_root_command + else + cmd_parser.processed_options_for_subcommand(subcommand) + end + processed_options.filter_map do |short, long, desc, hidden| + next if hidden + + option = long || short + next if option.nil? + + [option, desc] + end + else + options = [] + comment_lines = path.read.lines.grep(/^#:/) + return options if comment_lines.empty? + + # skip the comment's initial usage summary lines + comment_lines.slice(2..-1)&.each do |line| + match_data = / (?