diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..260f7c7d7e4cf --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,46 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "Homebrew/brew", + "image": "ghcr.io/homebrew/brew:master", + "workspaceFolder": "/home/linuxbrew/.linuxbrew/Homebrew", + "workspaceMount": "source=${localWorkspaceFolder},target=/home/linuxbrew/.linuxbrew/Homebrew,type=bind,consistency=cached", + "onCreateCommand": ".devcontainer/on-create-command.sh", + "customizations": { + "codespaces": { + "repositories": { + "Homebrew/homebrew-bundle": { + "permissions": { + "contents": "write" + } + }, + "Homebrew/homebrew-services": { + "permissions": { + "contents": "write" + } + } + } + }, + "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..7762064c888a6 --- /dev/null +++ b/.devcontainer/on-create-command.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +# fix permissions so Homebrew and Bundler don't complain +sudo chmod -R g-w,o-w /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 +# tap some other repos so codespaces can be used for developing multiple taps +brew tap homebrew/bundle +brew tap homebrew/services + +# install some useful development things +sudo apt-get update + +apt_get_install() { + sudo apt-get install -y \ + -o Dpkg::Options::=--force-confdef \ + -o Dpkg::Options::=--force-confnew \ + "$@" +} + +apt_get_install \ + openssh-server \ + zsh + +# Start the SSH server so that `gh cs ssh` works. +sudo service ssh start diff --git a/.editorconfig b/.editorconfig index 3c1a0b9a9f3b7..a3f724a3659a0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,11 +16,6 @@ trim_trailing_whitespace = true # trailing whitespace is crucial for patches trim_trailing_whitespace = false -[**.drawio.svg] -indent_size = unset -indent_style = unset -insert_final_newline = false - [**.md] trim_trailing_whitespace = true x-soft-wrap-text = 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.yml b/.github/ISSUE_TEMPLATE/bug.yml index 0403b6327dbb8..20f8839f1c95d 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -4,13 +4,7 @@ labels: [bug] body: - type: markdown attributes: - value: Please note we will close your issue without comment if you 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. - - type: textarea - attributes: - render: shell - label: "`brew config` output" - validations: - required: true + value: Please note we will close your issue without comment if you do not correctly 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. - type: textarea attributes: render: shell @@ -20,12 +14,20 @@ body: - type: checkboxes attributes: label: Verification - description: Please verify that you've followed these steps. + description: Please verify that you've followed these steps. If you cannot truthfully check these boxes, open a discussion at https://github.com/orgs/Homebrew/discussions instead. options: - - label: I ran `brew update` and am still able to reproduce my issue. + - label: My "`brew doctor` output" above says `Your system is ready to brew.` and am still able to reproduce my issue. + required: true + - label: I ran `brew update` twice and am still able to reproduce my issue. required: true - - label: I have resolved all warnings from `brew doctor` and that did not fix my problem. + - 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)? diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index a74a79d4e5465..b96a27f18b7f5 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -5,6 +5,13 @@ body: - type: markdown attributes: value: Please note we will close your issue without comment if you 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. + - type: checkboxes + attributes: + label: Verification + description: Please verify that you've followed these steps. + 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 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..97ba215778c25 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +self-hosted-runner: + # Labels of self-hosted 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_APP_ID diff --git a/.github/codecov.yml b/.github/codecov.yml index 65b70e6ea521b..fa828ea69cbd6 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -3,6 +3,7 @@ coverage: status: project: default: + informational: true threshold: 0.05% patch: default: diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml deleted file mode 100644 index af5879d427096..0000000000000 --- a/.github/codeql/codeql-config.yml +++ /dev/null @@ -1,2 +0,0 @@ -paths-ignore: - - Library/Homebrew/vendor/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4058b3e79f60c..786c1d7697c48 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,10 +5,15 @@ updates: directory: / schedule: interval: daily + allow: + - dependency-type: all # The actions in triage-issues.yml are updated in the Homebrew/.github repo ignore: - dependency-name: actions/stale - - dependency-name: dessant/lock-threads + groups: + artifacts: + patterns: + - actions/*-artifact - package-ecosystem: bundler directory: /Library/Homebrew @@ -16,4 +21,35 @@ updates: interval: daily allow: - dependency-type: all - versioning-strategy: lockfile-only + groups: + sorbet: + patterns: + - "sorbet*" + + - package-ecosystem: npm + directory: / + schedule: + interval: daily + allow: + - dependency-type: all + + - package-ecosystem: docker + directory: / + schedule: + interval: daily + allow: + - dependency-type: all + + - package-ecosystem: devcontainers + directory: / + schedule: + interval: daily + allow: + - dependency-type: all + + - package-ecosystem: pip + directory: / + schedule: + interval: daily + allow: + - dependency-type: all diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000000000..8d560e7fd9fcf --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,84 @@ +name: actionlint + +on: + push: + paths: + - '.github/workflows/*.ya?ml' + - '.github/actionlint.yaml' + pull_request: + paths: + - '.github/workflows/*.ya?ml' + - '.github/actionlint.yaml' + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +concurrency: + group: "actionlint-${{ github.ref }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + +jobs: + workflow_syntax: + if: github.repository_owner == 'Homebrew' + runs-on: ubuntu-latest + steps: + - name: Set up Homebrew + id: setup-homebrew + uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false + + - name: Install tools + run: brew install actionlint shellcheck zizmor + + - name: Set up GITHUB_WORKSPACE + env: + HOMEBREW_REPOSITORY: ${{ steps.setup-homebrew.outputs.repository-path }} + run: | + # Annotations work only relative to GITHUB_WORKSPACE + (shopt -s dotglob; rm -rf "${GITHUB_WORKSPACE:?}"/*; mv "${HOMEBREW_REPOSITORY:?}"/* "$GITHUB_WORKSPACE") + rmdir "$HOMEBREW_REPOSITORY" + ln -vs "$GITHUB_WORKSPACE" "$HOMEBREW_REPOSITORY" + + echo "::add-matcher::.github/actionlint-matcher.json" + + - run: | + # NOTE: exit code intentionally suppressed here + zizmor --format sarif . > results.sarif || true + + - name: Upload SARIF file + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + with: + name: results.sarif + path: results.sarif + + - run: actionlint + + upload_sarif: + needs: workflow_syntax + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Download SARIF file + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: results.sarif + path: results.sarif + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + with: + sarif_file: results.sarif + category: zizmor diff --git a/.github/workflows/autogenerated-files.yml b/.github/workflows/autogenerated-files.yml new file mode 100644 index 0000000000000..bd0bf5cd45592 --- /dev/null +++ b/.github/workflows/autogenerated-files.yml @@ -0,0 +1,54 @@ +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@master + with: + core: false + cask: false + test-bot: true + + - name: Cache Bundler RubyGems + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 371605a34f1cf..8ca717e7c68df 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,31 +1,39 @@ name: "CodeQL" on: - push: - branches: - - master - pull_request: - branches: - - master + push: + branches: + - master + pull_request: + branches: + - master + +defaults: + run: + shell: bash -xeuo pipefail {0} jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write - steps: - - name: Checkout repository - uses: actions/checkout@v3 + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ruby - config-file: ./.github/codeql/codeql-config.yml + - name: Initialize CodeQL + uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + with: + languages: ruby + config: | + paths-ignore: + - Library/Homebrew/vendor - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8bdfb1f8db32b..8ff3d5ea96312 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,27 +1,31 @@ name: Docker + on: - push: - paths: - - .github/workflows/docker.yml - - Dockerfile - branches-ignore: - - master + pull_request: + merge_group: release: types: - published + permissions: contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + jobs: ubuntu: - if: startsWith(github.repository, 'Homebrew/') + if: github.repository_owner == 'Homebrew' + name: docker (Ubuntu ${{ matrix.version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - version: ["16.04", "18.04", "20.04", "22.04"] + version: ["18.04", "20.04", "22.04", "24.04"] steps: - name: Checkout - uses: actions/checkout@main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 persist-credentials: false @@ -29,53 +33,115 @@ jobs: - name: Fetch origin/master from Git run: git fetch origin master - - name: Build Docker image + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0 + + - name: Determine build attributes + id: attributes run: | + date="$(date --rfc-3339=seconds --utc)" brew_version="$(git describe --tags --dirty --abbrev=7)" - echo "Building for Homebrew ${brew_version}" - docker build -t brew \ - --build-arg=version=${{matrix.version}} \ - --label org.opencontainers.image.created="$(date --rfc-3339=seconds --utc)" \ - --label org.opencontainers.image.url="https://brew.sh" \ - --label org.opencontainers.image.documentation="https://docs.brew.sh" \ - --label org.opencontainers.image.source="https://github.com/${GITHUB_REPOSITORY}" \ - --label org.opencontainers.image.version="${brew_version}" \ - --label org.opencontainers.image.revision="${GITHUB_SHA}" \ - --label org.opencontainers.image.vendor="${GITHUB_REPOSITORY_OWNER}" \ - --label org.opencontainers.image.licenses="BSD-2-Clause" \ - . + + DELIMITER="END_LABELS_$(uuidgen)" + cat <=4.4 + echo "The homebrew/ubuntu18.04 image is deprecated and will soon be retired. Use homebrew/ubuntu22.04 or homebrew/ubuntu24.04 or homebrew/ubuntu20.04 or homebrew/brew." > .docker-deprecate + fi + + { + if [[ "${#tags[@]}" -ne 0 ]]; then + DELIMITER="END_TAGS_$(uuidgen)" + echo "tags<<${DELIMITER}" + printf "%s\n" "${tags[@]}" + echo "${DELIMITER}" + echo "push=true" + else + echo "push=false" + fi + } | tee -a "${GITHUB_OUTPUT}" + + - name: Log in to GitHub Packages (github-actions[bot]) + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: github-actions[bot] + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Docker image + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + with: + context: . + load: true + tags: brew + cache-from: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache + build-args: version=${{ matrix.version }} + labels: ${{ steps.attributes.outputs.labels }} - name: Run brew test-bot --only-setup run: docker run --rm brew brew test-bot --only-setup - - name: Deploy the tagged Docker image to GitHub Packages - if: startsWith(github.ref, 'refs/tags/') - run: | - brew_version="${GITHUB_REF:10}" - echo "brew_version=${brew_version}" >> "${GITHUB_ENV}" - echo ${{secrets.HOMEBREW_BREW_GITHUB_PACKAGES_TOKEN}} | docker login ghcr.io -u BrewTestBot --password-stdin - docker tag brew "ghcr.io/homebrew/ubuntu${{matrix.version}}:${brew_version}" - docker push "ghcr.io/homebrew/ubuntu${{matrix.version}}:${brew_version}" - docker tag brew "ghcr.io/homebrew/ubuntu${{matrix.version}}:latest" - docker push "ghcr.io/homebrew/ubuntu${{matrix.version}}:latest" - - - name: Deploy the tagged Docker image to Docker Hub - if: startsWith(github.ref, 'refs/tags/') - run: | - echo ${{secrets.HOMEBREW_BREW_DOCKER_TOKEN}} | docker login -u brewtestbot --password-stdin - docker tag brew "homebrew/ubuntu${{matrix.version}}:${brew_version}" - docker push "homebrew/ubuntu${{matrix.version}}:${brew_version}" - docker tag brew "homebrew/ubuntu${{matrix.version}}:latest" - docker push "homebrew/ubuntu${{matrix.version}}:latest" - - - name: Deploy the homebrew/brew Docker image to GitHub Packages and Docker Hub - if: startsWith(github.ref, 'refs/tags/') && matrix.version == '22.04' - run: | - docker tag brew "ghcr.io/homebrew/brew:${brew_version}" - docker push "ghcr.io/homebrew/brew:${brew_version}" - docker tag brew "ghcr.io/homebrew/brew:latest" - docker push "ghcr.io/homebrew/brew:latest" - docker tag brew "homebrew/brew:${brew_version}" - docker push "homebrew/brew:${brew_version}" - docker tag brew "homebrew/brew:latest" - docker push "homebrew/brew:latest" + - name: Log in to GitHub Packages (BrewTestBot) + if: steps.attributes.outputs.push == 'true' + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: BrewTestBot + password: ${{ secrets.HOMEBREW_BREW_GITHUB_PACKAGES_TOKEN }} + + - name: Log in to Docker Hub + if: steps.attributes.outputs.push == 'true' + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + username: brewtestbot + password: ${{ secrets.HOMEBREW_BREW_DOCKER_TOKEN }} + + - name: Deploy the tagged Docker image + if: steps.attributes.outputs.push == 'true' + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + with: + context: . + push: true + tags: ${{ steps.attributes.outputs.tags }} + cache-from: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache + cache-to: type=registry,ref=ghcr.io/homebrew/ubuntu${{ matrix.version }}:cache,mode=max + build-args: version=${{ matrix.version }} + labels: ${{ steps.attributes.outputs.labels }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000000..a2e67348197ee --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,77 @@ +name: Documentation CI + +on: + pull_request: + merge_group: + +permissions: + contents: read + pages: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + HOMEBREW_BOOTSNAP: 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@master + with: + core: false + cask: false + test-bot: false + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install vale + run: brew install vale + + - name: Cleanup Homebrew/brew docs + if: github.repository == 'Homebrew/brew' + run: | + # Avoid failing on broken symlinks. + rm Library/Homebrew/os/mac/pkgconfig/fuse/fuse.pc + rm Library/Homebrew/os/mac/pkgconfig/fuse/osxfuse.pc + + # No ignore support (https://github.com/errata-ai/vale/issues/131). + rm -r Library/Homebrew/vendor + + - name: Run Vale + run: vale docs/ + + - name: Install Ruby + uses: ruby/setup-ruby@540484a3c0f308b08619664ec40bf6c371d172c3 # v1.205.0 + with: + bundler-cache: 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 + + - name: Build the site and check for broken links + working-directory: docs + run: bundle exec rake test + env: + JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml index 50409557e6d85..7dfa3abbbe15f 100644 --- a/.github/workflows/doctor.yml +++ b/.github/workflows/doctor.yml @@ -8,21 +8,47 @@ on: - 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 - HOMEBREW_CHANGE_ARCH_TO_ARM: 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@master + with: + core: false + cask: false + test-bot: 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: - version: ["12-arm64", "12", "11-arm64", "11", "10.15"] + include: ${{ fromJson(needs.determine-runners.outputs.runners) }} fail-fast: false - runs-on: ${{ matrix.version }} - env: - PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} defaults: run: working-directory: /tmp @@ -30,10 +56,15 @@ jobs: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: true - run: brew test-bot --only-cleanup-before + if: matrix.cleanup - - run: brew test-bot --only-setup + - run: brew doctor - run: brew test-bot --only-cleanup-after - if: always() + if: always() && matrix.cleanup diff --git a/.github/workflows/pkg-installer.yml b/.github/workflows/pkg-installer.yml new file mode 100644 index 0000000000000..3157d8ce3749c --- /dev/null +++ b/.github/workflows/pkg-installer.yml @@ -0,0 +1,267 @@ +name: Installer Package +on: + push: + branches: + - '**' + tags-ignore: + - '**' + paths: + - .github/workflows/pkg-installer.yml + - package/**/* + release: + types: + - published +env: + PKG_APPLE_DEVELOPER_TEAM_ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }} + HOMEBREW_NO_ANALYTICS_THIS_RUN: 1 + HOMEBREW_NO_ANALYTICS_MESSAGE_OUTPUT: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + build: + if: github.repository_owner == 'Homebrew' && github.actor != 'dependabot[bot]' + runs-on: macos-15 + 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: '13.0' + permissions: + contents: read # for code access + attestations: write # for actions/attest-build-provenance + id-token: write # for actions/attest-build-provenance + 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@master + with: + core: false + cask: false + test-bot: false + + - name: Install Pandoc + run: brew install pandoc + + - 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + path: brew + fetch-depth: 0 + persist-credentials: false + + - name: Get Homebrew version from Git + id: homebrew-version + run: echo "version=$(git -C brew describe --tags --always)" >> "${GITHUB_OUTPUT}" + + - name: Copy Homebrew API cache to brew subdirectory + run: cp -vR ~/Library/Caches/Homebrew/api brew/cache_api + + - name: Open macOS keychain + run: security list-keychain -d user -s "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + + - 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 brew/package/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-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0 + with: + subject-path: Homebrew-${{ steps.homebrew-version.outputs.version }}.pkg + + - name: Upload installer to GitHub Actions + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + 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-13 + name: macos-13-x86_64 + # Apple Silicon + - runner: macos-14 + name: macos-14-arm64 + - runner: macos-15 + name: macos-15-arm64 + steps: + - name: Download installer from GitHub Actions + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + 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 + + - 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-15 + permissions: + # To write assets to GitHub release + contents: write + steps: + - name: Download installer from GitHub Actions + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + 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: Install gh + run: brew install gh + + - name: Upload installer to GitHub release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + INSTALLER_PATH: ${{ needs.build.outputs.installer_path }} + run: gh release upload --repo Homebrew/brew + "${GITHUB_REF//refs\/tags\//}" + "${INSTALLER_PATH}" + + issue: + needs: [build, test, upload] + if: always() && github.event_name == 'release' + runs-on: ubuntu-latest + 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 pkg installer issue + uses: Homebrew/actions/create-or-update-issue@master + with: + title: Failed to publish pkg installer + body: > + The pkg installer workflow [failed](${{ env.RUN_URL }}) for release + ${{ github.ref_name }}. No pkg installer was uploaded to the GitHub + release. + labels: bug,release blocker + update-existing: ${{ contains(needs.*.result, 'failure') }} + close-existing: ${{ needs.upload.result == 'success' }} + close-from-author: github-actions[bot] + close-comment: > + The pkg installer workflow [succeeded](${{ env.RUN_URL }}) for + release ${{ github.ref_name }}. Closing this issue. diff --git a/.github/workflows/rubydoc.yml b/.github/workflows/rubydoc.yml new file mode 100644 index 0000000000000..df915608fa171 --- /dev/null +++ b/.github/workflows/rubydoc.yml @@ -0,0 +1,53 @@ +name: Ruby Documentation CI + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +env: + HOMEBREW_DEVELOPER: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + HOMEBREW_BOOTSNAP: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} + +jobs: + rubydoc: + if: github.repository == 'Homebrew/brew' + runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/rubydoc/Gemfile + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false + + - name: Checkout Homebrew/rubydoc.brew.sh + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: Homebrew/rubydoc.brew.sh + path: rubydoc + persist-credentials: false + + - name: Install Ruby + uses: ruby/setup-ruby@540484a3c0f308b08619664ec40bf6c371d172c3 # v1.205.0 + with: + bundler-cache: true + working-directory: rubydoc + + - name: Process rubydoc comments + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }}/Library/Homebrew + run: bundle exec yard doc --no-output --fail-on-warning diff --git a/.github/workflows/schemas.yml b/.github/workflows/schemas.yml new file mode 100644 index 0000000000000..b7d5a3b454321 --- /dev/null +++ b/.github/workflows/schemas.yml @@ -0,0 +1,95 @@ +name: Update schema data +on: + push: + paths: + - .github/workflows/schemas.yml + branches-ignore: + - 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@master + with: + core: false + cask: false + test-bot: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@master + with: + username: BrewTestBot + + - name: Set up commit signing + uses: Homebrew/actions/setup-commit-signing@master + with: + signing_key: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY }} + + - name: Update schema data + id: update + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + 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/master + 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/development/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/master/.github/workflows/schemas.yml)." + echo "committed=true" >> "$GITHUB_OUTPUT" + PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state")" + 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@master + 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: "master" + + - 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 }} diff --git a/.github/workflows/sorbet.yml b/.github/workflows/sorbet.yml index 4944cfdaaad28..0e419bbfce9e4 100644 --- a/.github/workflows/sorbet.yml +++ b/.github/workflows/sorbet.yml @@ -1,6 +1,11 @@ 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 @@ -13,68 +18,99 @@ on: permissions: contents: read +defaults: + run: + shell: bash -xeuo pipefail {0} + jobs: tapioca: if: github.repository == 'Homebrew/brew' - runs-on: macos-latest + runs-on: macos-15 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - name: Configure Git user + if: github.event_name != 'pull_request' uses: Homebrew/actions/git-user-config@master with: username: BrewTestBot - name: Set up commit signing + if: github.event_name != 'pull_request' uses: Homebrew/actions/setup-commit-signing@master with: signing_key: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY }} - name: Update RBI files id: update - env: - GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} - HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} run: | - git fetch origin + if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]] + then + git fetch origin - BRANCH="sorbet-files-update" - echo "::set-output name=branch::${BRANCH}" + BRANCH="sorbet-files-update" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" - if git ls-remote --exit-code --heads origin "${BRANCH}" - then - git checkout "${BRANCH}" - git reset --hard origin/master - else - git checkout --no-track -B "${BRANCH}" origin/master + 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/master + fi fi - if brew typecheck --update --fail-if-not-changed + 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 }} + HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + run: | + if ! git diff --stat --exit-code "Library/Homebrew/sorbet" then - git add "${GITHUB_WORKSPACE}/Library/Homebrew/sorbet" + git add "Library/Homebrew/sorbet" git commit -m "sorbet: Update RBI files." \ -m "Autogenerated by the [sorbet](https://github.com/Homebrew/brew/blob/master/.github/workflows/sorbet.yml) workflow." - echo "::set-output name=committed::true" + + 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/master/.github/workflows/sorbet.yml) workflow." + fi + + echo "committed=true" >> "$GITHUB_OUTPUT" PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state")" if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] then - echo "::set-output name=pull_request::true" + echo "pull_request=true" >> "$GITHUB_OUTPUT" fi fi - name: Push commits - if: steps.update.outputs.committed == 'true' + if: steps.commit.outputs.committed == 'true' uses: Homebrew/actions/git-try-push@master 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: "master" - name: Open a pull request - if: steps.update.outputs.pull_request == 'true' - run: hub pull-request --no-edit + 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 }} diff --git a/.github/workflows/spdx.yml b/.github/workflows/spdx.yml index 71ed6a6e6b314..65d68d6f620b3 100644 --- a/.github/workflows/spdx.yml +++ b/.github/workflows/spdx.yml @@ -7,8 +7,15 @@ on: - master schedule: - cron: "0 0 * * *" + workflow_dispatch: + permissions: contents: read + +defaults: + run: + shell: bash -xeuo pipefail {0} + jobs: spdx: if: github.repository == 'Homebrew/brew' @@ -17,6 +24,10 @@ jobs: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - name: Configure Git user uses: Homebrew/actions/git-user-config@master @@ -33,29 +44,30 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} run: | git fetch origin BRANCH="spdx-update" - echo "::set-output name=branch::${BRANCH}" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" if git ls-remote --exit-code --heads origin "${BRANCH}" then git checkout "${BRANCH}" - git reset --hard origin/master + git checkout "Library/Homebrew/data/spdx" else git checkout --no-track -B "${BRANCH}" origin/master fi - if brew update-license-data --fail-if-not-changed + if brew update-license-data then - git add "${GITHUB_WORKSPACE}/Library/Homebrew/data/spdx" + 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/master/.github/workflows/spdx.yml)." - echo "::set-output name=committed::true" + echo "committed=true" >> "$GITHUB_OUTPUT" PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state")" if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] then - echo "::set-output name=pull_request::true" + echo "pull_request=true" >> "$GITHUB_OUTPUT" fi fi @@ -64,12 +76,14 @@ jobs: uses: Homebrew/actions/git-try-push@master 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: "master" - name: Open a pull request if: steps.update.outputs.pull_request == 'true' - run: hub pull-request --no-edit + run: gh pr create --fill env: GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} diff --git a/.github/workflows/sponsors-maintainers-man-completions.yml b/.github/workflows/sponsors-maintainers-man-completions.yml new file mode 100644 index 0000000000000..10a200468e94e --- /dev/null +++ b/.github/workflows/sponsors-maintainers-man-completions.yml @@ -0,0 +1,140 @@ +name: Update sponsors, maintainers, manpage and completions + +on: + push: + branches: + - 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@master + with: + core: false + cask: false + test-bot: false + + - name: Configure Git user + uses: Homebrew/actions/git-user-config@master + with: + username: BrewTestBot + + - name: Set up commit signing + uses: Homebrew/actions/setup-commit-signing@master + with: + signing_key: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY }} + + - name: Cache Bundler RubyGems + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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" ]] + 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 "${BRANCH}" + git checkout "README.md" \ + "docs/Manpage.md" \ + "manpages/brew.1" \ + "completions" + else + git checkout --no-track -B "${BRANCH}" origin/master + 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")" + 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 }} + HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + + - name: Push commits + if: steps.update.outputs.committed == 'true' + uses: Homebrew/actions/git-try-push@master + 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 }} diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 0000000000000..608d2e65e9c5d --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,82 @@ +# 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-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale Issues and Pull Requests + uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.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-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Mark/Close Stale `bump-formula-pr` and `bump-cask-pr` Pull Requests + uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.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/tests.yml b/.github/workflows/tests.yml index 0bc3616251e30..49929681f0eee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: - master pull_request: + merge_group: permissions: contents: read @@ -12,6 +13,14 @@ permissions: env: HOMEBREW_DEVELOPER: 1 HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_ENV_HINTS: 1 + HOMEBREW_BOOTSNAP: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_VERIFY_ATTESTATIONS: 1 + +defaults: + run: + shell: bash -xeuo pipefail {0} concurrency: group: "${{ github.ref }}" @@ -19,138 +28,180 @@ concurrency: jobs: syntax: - if: github.repository == 'Homebrew/brew' + if: github.repository_owner == 'Homebrew' runs-on: ubuntu-latest steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - name: Cache Bundler RubyGems - uses: actions/cache@v1 + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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- + 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=sorbet + run: brew install-bundler-gems --groups=style,typecheck - - name: Install shellcheck - run: brew install shellcheck + - name: Install shellcheck and shfmt + run: brew install shellcheck shfmt - - run: brew style --display-cop-names + - name: Cache style cache + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ~/.cache/Homebrew/style + key: syntax-style-cache-${{ github.sha }} + restore-keys: syntax-style-cache- + + - run: brew style - run: brew typecheck - - name: Run vale for docs linting + - name: Check RuboCop filepaths + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }}/Library/Homebrew run: | - brew install vale - vale docs/ + public_apis="$(git grep -l "@api public" -- :^sorbet/ :^vendor/ | sort)" + rubocop_docs="$(yq '.Style/Documentation.Include[]' .rubocop.yml | sort)" + + if [[ "${public_apis}" != "${rubocop_docs}" ]]; then + echo "All public Homebrew APIs should be included in the \`Style/Documentation\` RuboCop." + echo "Add/remove the following paths from \`Library/Homebrew/.rubocop.yml\` as appropriate:" + echo "${public_apis} ${rubocop_docs}" | tr ' ' '\n' | sort | uniq -u + + exit 1 + fi tap-syntax: - name: tap syntax (Linux) + name: tap syntax needs: syntax - if: startsWith(github.repository, 'Homebrew/') - runs-on: ubuntu-latest + if: github.repository_owner == 'Homebrew' + runs-on: macos-14 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master - - - run: brew test-bot --only-cleanup-before - - - run: brew config + with: + core: true + cask: true + test-bot: true - name: Cache Bundler RubyGems - uses: actions/cache@v1 + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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- + 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=sorbet - - - run: brew doctor + run: brew install-bundler-gems --groups=style - - name: Run brew update-tests - if: github.event_name == 'pull_request' - run: | - brew update-test - brew update-test --to-tag - brew update-test --commit=HEAD - - - name: Run brew readall on all taps - run: brew readall --aliases - - - name: Run brew style on homebrew-core for Linux - run: brew style --display-cop-names homebrew/core + - name: Cache style cache + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ~/.cache/Homebrew/style + key: tap-syntax-style-cache-${{ github.sha }} + restore-keys: tap-syntax-style-cache- - - name: Run brew audit --skip-style on all taps - run: brew audit --skip-style --except=version --display-failures-only + - name: Run brew style on homebrew-core + run: brew style homebrew/core - name: Set up all Homebrew taps run: | - HOMEBREW_REPOSITORY="$(brew --repo)" - HOMEBREW_CORE_REPOSITORY="${HOMEBREW_REPOSITORY}/Library/Taps/homebrew/homebrew-core" - git -C "${HOMEBREW_CORE_REPOSITORY}" remote add homebrew_core https://github.com/Homebrew/homebrew-core - git -C "${HOMEBREW_CORE_REPOSITORY}" fetch homebrew_core || git -C "${HOMEBREW_CORE_REPOSITORY}" fetch homebrew_core - git -C "${HOMEBREW_CORE_REPOSITORY}" checkout --force -B master homebrew_core/master - brew tap homebrew/aliases - brew tap homebrew/autoupdate brew tap homebrew/bundle - brew tap homebrew/cask - brew tap homebrew/cask-drivers - brew tap homebrew/cask-fonts - brew tap homebrew/cask-versions brew tap homebrew/command-not-found brew tap homebrew/formula-analytics brew tap homebrew/portable-ruby brew tap homebrew/services - brew update-reset Library/Taps/homebrew/homebrew-bundle - # brew style doesn't like world writable directories - sudo chmod -R g-w,o-w "${HOMEBREW_REPOSITORY}/Library/Taps" + sudo chmod -R g-w,o-w "$(brew --repo)/Library/Taps" - - name: Run brew style on homebrew-core for macOS - run: brew style --display-cop-names homebrew/core - env: - HOMEBREW_SIMULATE_MACOS_ON_LINUX: 1 + - name: Run brew style on official taps + run: | + brew style homebrew/bundle \ + homebrew/services \ + homebrew/test-bot - - name: Run brew audit --skip-style on homebrew-core for macOS - run: brew audit --skip-style --except=version --tap=homebrew/core - env: - HOMEBREW_SIMULATE_MACOS_ON_LINUX: 1 + brew style homebrew/aliases \ + homebrew/command-not-found \ + homebrew/formula-analytics \ + homebrew/portable-ruby - - name: Run brew style on official taps + - name: Run brew style on homebrew/cask run: | - brew style --display-cop-names homebrew/bundle \ - homebrew/services \ - homebrew/test-bot + brew style 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:master + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@master + with: + core: true + cask: false + test-bot: 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-15 + steps: + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@master + with: + core: true + cask: true + test-bot: false - brew style --display-cop-names homebrew/aliases\ - homebrew/autoupdate\ - homebrew/command-not-found \ - homebrew/formula-analytics \ - homebrew/portable-ruby + - name: Run brew readall on all casks + run: brew readall --os=all --arch=all homebrew/cask - - name: Run brew style on cask taps + - name: Run brew audit --skip-style on homebrew/cask run: | - brew style --display-cop-names homebrew/cask \ - homebrew/cask-drivers \ - homebrew/cask-fonts \ - homebrew/cask-versions + brew audit --skip-style --except=version --tap=homebrew/cask + + - name: Generate formula API + run: brew generate-formula-api --dry-run + + - name: Generate cask API + run: brew generate-cask-api --dry-run vendored-gems: - name: vendored gems (Linux) + name: vendored gems + needs: syntax runs-on: ubuntu-latest steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - name: Configure Git user uses: Homebrew/actions/git-user-config@master @@ -160,185 +211,192 @@ jobs: # 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=sorbet + 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/ruby - docker: + update-test: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} needs: syntax - runs-on: ubuntu-latest + if: github.event_name != 'push' + strategy: + matrix: + include: + - name: update-test (Ubuntu) + runs-on: ubuntu-latest + - name: update-test (macOS) + runs-on: macos-15 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - - name: Build Docker image - run: docker build -t brew --build-arg=version=16.04 . - - - name: Deploy the Docker image to GitHub Packages and Docker Hub - if: github.ref == 'refs/heads/master' + - name: Run brew update-tests run: | - echo ${{secrets.HOMEBREW_BREW_GITHUB_PACKAGES_TOKEN}} | - docker login ghcr.io -u BrewTestBot --password-stdin - docker tag brew "ghcr.io/homebrew/ubuntu16.04:master" - docker push "ghcr.io/homebrew/ubuntu16.04:master" - echo ${{secrets.HOMEBREW_BREW_DOCKER_TOKEN}} | - docker login -u brewtestbot --password-stdin - docker tag brew "homebrew/ubuntu16.04:master" - docker push "homebrew/ubuntu16.04:master" + brew update-test + brew update-test --to-tag + brew update-test --commit=HEAD tests: + if: github.event_name != 'push' name: ${{ matrix.name }} needs: syntax - runs-on: ubuntu-latest + runs-on: ${{ matrix.runs-on }} strategy: matrix: include: - - name: tests (no-compatibility mode) - test-flags: --no-compat --online --coverage - - name: tests (generic OS) - test-flags: --generic --online --coverage - - name: tests (Linux) + - name: tests (online) test-flags: --online --coverage + runs-on: ubuntu-latest + - name: tests (generic OS) + test-flags: --generic --coverage + runs-on: ubuntu-latest + - name: tests (Ubuntu 24.04) + test-flags: --coverage + runs-on: ubuntu-24.04 + - name: tests (Ubuntu 22.04) + test-flags: --coverage + runs-on: ubuntu-22.04 + - name: tests (Ubuntu 20.04) + test-flags: --coverage + runs-on: ubuntu-20.04 + - name: tests (macOS 13 x86_64) + test-flags: --coverage + runs-on: macos-13 + - name: tests (macOS 15 arm64) + test-flags: --coverage + runs-on: macos-15 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + 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 + test-bot: false - name: Cache Bundler RubyGems - uses: actions/cache@v1 + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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- + 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=sorbet + run: brew install-bundler-gems --groups=all - name: Create parallel test log directory run: mkdir tests - name: Cache parallel tests log - uses: actions/cache@v1 + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.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 dependencies - run: brew install curl + - name: Install brew tests --online dependencies + if: matrix.name == 'tests (online)' + run: brew install subversion curl - - name: Run brew tests + - name: Install brew tests macOS dependencies + if: runner.os != 'Linux' run: | - # brew tests doesn't like world writable directories - sudo chmod -R g-w,o-w /home/linuxbrew/.linuxbrew/Homebrew + # Workaround GitHub Actions Python issues + brew unlink python && brew link --overwrite python + brew install subversion + + # brew tests doesn't like world writable directories + - name: Cleanup permissions + if: runner.os == 'Linux' + run: sudo chmod -R g-w,o-w /home/linuxbrew/.linuxbrew/Homebrew - brew tests ${{ matrix.test-flags }} + - name: Run brew tests + if: github.event_name == 'pull_request' || matrix.name != 'tests (online)' + run: | + # Retry multiple times to detect and submit flakiness to CodeCov (because rspec-retry is disabled). + if [[ -n "${CODECOV_TOKEN-}" ]] + then + brew tests ${{ matrix.test-flags }} || + brew tests ${{ matrix.test-flags }} + else + brew tests ${{ matrix.test-flags }} + fi 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 }} - - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 - with: - files: Library/Homebrew/test/coverage/coverage.xml - - test-default-formula-linux: - name: test default formula (Linux) - runs-on: ubuntu-latest - env: - HOMEBREW_BOOTSNAP: 1 - steps: - - name: Set up Homebrew - id: set-up-homebrew - uses: Homebrew/actions/setup-homebrew@master + - 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" - - run: brew test-bot --only-cleanup-before + - uses: codecov/test-results-action@9739113ad922ea0a9abb4b2c0f8bf6a4aa8ef820 # v1.0.1 + with: + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} + files: ${{ steps.junit_xml.outputs.filenames }} + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} - - run: brew test-bot --only-formulae --only-json-tab --test-default-formula + - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + 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-everything: - name: test everything (macOS) + test-default-formula: + name: ${{ matrix.name }} needs: syntax - if: startsWith(github.repository, 'Homebrew/') - runs-on: macos-latest + if: github.repository_owner == 'Homebrew' && github.event_name != 'push' + runs-on: ${{ matrix.runs-on }} + container: ${{ matrix.container }} + strategy: + matrix: + include: + - name: test default formula (Ubuntu 22.04) + runs-on: ubuntu-latest + container: ghcr.io/homebrew/ubuntu22.04:master + - name: test default formula (Ubuntu 20.04) + runs-on: ubuntu-latest + container: ghcr.io/homebrew/ubuntu20.04 + - name: test default formula (macOS 13 x86_64) + runs-on: macos-13 + - name: test default formula (macOS 15 arm64) + runs-on: macos-15 env: - HOMEBREW_BOOTSNAP: 1 + HOMEBREW_TEST_BOT_ANALYTICS: 1 + HOMEBREW_ENFORCE_SBOM: 1 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: true + cask: false + test-bot: true - run: brew test-bot --only-cleanup-before - - run: brew config + - run: brew test-bot --only-setup - # 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=sorbet + - run: brew install gnu-tar - - name: Check for uncommitted RubyGems - run: git diff --stat --exit-code Library/Homebrew/vendor/bundle/ruby - - - run: brew doctor - - - name: Run brew update-tests - if: github.event_name == 'pull_request' - run: | - brew update-test - brew update-test --to-tag - brew update-test --commit=HEAD - - - name: Set up all Homebrew taps - run: | - brew tap homebrew/cask - brew tap homebrew/cask-drivers - brew tap homebrew/cask-fonts - brew tap homebrew/cask-versions - brew update-reset Library/Taps/homebrew/homebrew-bundle \ - Library/Taps/homebrew/homebrew-cask \ - Library/Taps/homebrew/homebrew-cask-versions \ - Library/Taps/homebrew/homebrew-services - - - name: Run brew readall on all taps - run: brew readall --aliases - - - name: Install brew tests dependencies - run: brew install subversion curl - - - name: Create parallel test log directory - run: mkdir tests - - - name: Cache parallel tests log - uses: actions/cache@v1 - with: - path: tests - key: ${{ runner.os }}-parallel_runtime_rspec-${{ github.sha }} - restore-keys: ${{ runner.os }}-parallel_runtime_rspec- - - - name: Run brew tests - run: | - # Retry multiple times when using BuildPulse to detect and submit - # flakiness (because rspec-retry is disabled). - if [[ -n "${HOMEBREW_BUILDPULSE_ACCESS_KEY_ID}" ]] - then - brew tests --online --coverage || - brew tests --online --coverage || - brew tests --online --coverage - else - brew tests --online --coverage - fi - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # These cannot be queried at the macOS level on GitHub Actions. - HOMEBREW_LANGUAGES: en-GB - HOMEBREW_BUILDPULSE_ACCESS_KEY_ID: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }} - HOMEBREW_BUILDPULSE_SECRET_ACCESS_KEY: ${{ secrets.BUILDPULSE_SECRET_ACCESS_KEY }} - HOMEBREW_BUILDPULSE_ACCOUNT_ID: 1503512 - HOMEBREW_BUILDPULSE_REPOSITORY_ID: 53238813 - - - run: brew test-bot --only-formulae --test-default-formula - - - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 - with: - files: Library/Homebrew/test/coverage/coverage.xml + - run: brew test-bot --only-formulae --only-json-tab --test-default-formula diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml deleted file mode 100644 index 7ef4af3ca5047..0000000000000 --- a/.github/workflows/triage-issues.yml +++ /dev/null @@ -1,83 +0,0 @@ -# This file is synced from the `.github` repository, do not modify it directly. -name: Triage issues - -on: - push: - paths: - - .github/workflows/triage-issues.yml - branches-ignore: - - dependabot/** - schedule: - # Once every day at midnight UTC - - cron: "0 0 * * *" - issue_comment: - -permissions: - issues: write - pull-requests: write - -concurrency: - group: triage-issues - cancel-in-progress: ${{ github.event_name != 'issue_comment' }} - -jobs: - stale: - if: > - startsWith(github.repository, 'Homebrew/') && ( - github.event_name != 'issue_comment' || ( - contains(github.event.issue.labels.*.name, 'stale') || - contains(github.event.pull_request.labels.*.name, 'stale') - ) - ) - runs-on: ubuntu-latest - steps: - - name: Mark/Close Stale Issues and Pull Requests - uses: actions/stale@v5 - 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" - - bump-pr-stale: - if: > - startsWith(github.repository, 'Homebrew/') && ( - github.event_name != 'issue_comment' || ( - contains(github.event.issue.labels.*.name, 'stale') || - contains(github.event.pull_request.labels.*.name, 'stale') - ) - ) - runs-on: ubuntu-latest - steps: - - name: Mark/Close Stale `bump-formula-pr` and `bump-cask-pr` Pull Requests - uses: actions/stale@v5 - 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" - - lock-threads: - if: startsWith(github.repository, 'Homebrew/') && github.event_name != 'issue_comment' - runs-on: ubuntu-latest - steps: - - name: Lock Outdated Threads - uses: dessant/lock-threads@e460dfeb36e731f3aeb214be6b0c9a9d9a67eda6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - issue-inactive-days: 30 - add-issue-labels: outdated - pr-inactive-days: 30 - add-pr-labels: outdated diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index 2ef2939e34309..0000000000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: Triage - -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - - closed - - labeled - - unlabeled - schedule: - - cron: "0 */3 * * *" # every 3 hours - -permissions: {} - -concurrency: triage-${{ github.head_ref }} - -jobs: - review: - runs-on: ubuntu-latest - if: startsWith(github.repository, 'Homebrew/') - steps: - - name: Re-run this workflow - if: github.event_name == 'schedule' || github.event.action == 'closed' - uses: reitermarkus/rerun-workflow@c8d5bc3526acb50c12004f31c0dcb1598c87e32d - with: - token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} - continuous-label: waiting for feedback - trigger-labels: critical - workflow: triage.yml - - name: Review pull request - if: > - (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && - github.event.action != 'closed' && github.event.pull_request.state != 'closed' - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.HOMEBREW_BREW_TRIAGE_PULL_REQUESTS_TOKEN }} - script: | - async function approvePullRequest(pullRequestNumber) { - const reviews = await approvalsByAuthenticatedUser(pullRequestNumber) - - if (reviews.length > 0) { - return - } - - await github.rest.pulls.createReview({ - ...context.repo, - pull_number: pullRequestNumber, - event: 'APPROVE', - }) - } - - async function findComment(pullRequestNumber, id) { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNumber, - }) - - const regex = new RegExp(``) - return comments.filter(comment => comment.body.match(regex))[0] - } - - async function createOrUpdateComment(pullRequestNumber, id, message) { - const beginComment = await findComment(pullRequestNumber, id) - - const body = `\n\n${message}` - if (beginComment) { - await github.rest.issues.updateComment({ - ...context.repo, - comment_id: beginComment.id, - body, - }) - } else { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: pullRequestNumber, - body, - }) - } - } - - async function approvalsByAuthenticatedUser(pullRequestNumber) { - const { data: user } = await github.rest.users.getAuthenticated() - - const { data: reviews } = await github.rest.pulls.listReviews({ - ...context.repo, - pull_number: pullRequestNumber, - }) - - const approvals = reviews.filter(review => review.state == 'APPROVED') - return approvals.filter(review => review.user.login == user.login) - } - - async function dismissApprovals(pullRequestNumber, message) { - const reviews = await approvalsByAuthenticatedUser(pullRequestNumber) - for (const review of reviews) { - await github.rest.pulls.dismissReview({ - ...context.repo, - pull_number: pullRequestNumber, - review_id: review.id, - message: message - }); - } - } - - function offsetDate(start, offsetHours, skippedDays) { - let end = new Date(start) - - end.setUTCHours(end.getUTCHours() + (offsetHours % 24)) - - while (skippedDays.includes(end.getUTCDay()) || offsetHours >= 24) { - if (!skippedDays.includes(end.getUTCDay())) { - offsetHours -= 24 - } - - end.setUTCDate(end.getUTCDate() + 1) - } - - if (skippedDays.includes(start.getUTCDay())) { - end.setUTCHours(offsetHours, 0, 0) - } - - return end - } - - function formatDate(date) { - return date.toISOString().replace(/\.\d+Z$/, ' UTC').replace('T', ' at ') - } - - async function reviewPullRequest(pullRequestNumber) { - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - }) - - const { data: user } = await github.rest.users.getAuthenticated() - if (pullRequest.user.login == user.login) { - core.warning('Pull request author is a bot.') - return - } - - if (pullRequest.author_association != 'MEMBER') { - core.warning('Pull request author is not a member.') - return - } - - const reviewLabel = 'waiting for feedback' - const criticalLabel = 'critical' - - const labels = pullRequest.labels.map(label => label.name) - const hasReviewLabel = labels.includes(reviewLabel) - const hasCriticalLabel = labels.includes(criticalLabel) - - const offsetHours = 24 - const skippedDays = [ - 6, // Saturday - 0, // Sunday - ] - - const currentDate = new Date() - const reviewStartDate = new Date(pullRequest.created_at) - const reviewEndDate = offsetDate(reviewStartDate, offsetHours, skippedDays) - const reviewEnded = currentDate > reviewEndDate - - if (reviewEnded || hasCriticalLabel) { - let message - if (hasCriticalLabel && !reviewEnded) { - message = `Review period skipped due to \`${criticalLabel}\` label.` - } else { - message = 'Review period ended.' - } - - if (hasReviewLabel) { - await github.rest.issues.removeLabel({ - ...context.repo, - issue_number: pullRequestNumber, - name: reviewLabel, - }) - } - - core.info(message) - await createOrUpdateComment(pullRequestNumber, 'review-period-end', message) - await approvePullRequest(pullRequestNumber) - } else { - const message = `Review period will end on ${formatDate(reviewEndDate)}.` - core.info(message) - - await dismissApprovals(pullRequestNumber, 'Review period has not ended yet.') - await createOrUpdateComment(pullRequestNumber, 'review-period-begin', message) - - const endComment = await findComment(pullRequestNumber, 'review-period-end') - if (endComment) { - await github.rest.issues.deleteComment({ - ...context.repo, - comment_id: endComment.id, - }) - } - - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: pullRequestNumber, - labels: [reviewLabel], - }) - - core.setFailed('Review period has not ended yet.') - } - } - - await reviewPullRequest(context.issue.number) diff --git a/.github/workflows/update-man-completions.yml b/.github/workflows/update-man-completions.yml deleted file mode 100644 index 548d63be6e881..0000000000000 --- a/.github/workflows/update-man-completions.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Update maintainers, manpage and completions - -on: - push: - paths: - - .github/workflows/update-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 - branches: - - master - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - update-manpage: - runs-on: ubuntu-latest - if: github.repository == 'Homebrew/brew' - steps: - - name: Setup Homebrew - uses: Homebrew/actions/setup-homebrew@master - - - name: Configure Git user - uses: Homebrew/actions/git-user-config@master - with: - username: BrewTestBot - - - name: Set up commit signing - uses: Homebrew/actions/setup-commit-signing@master - with: - signing_key: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY }} - - - name: Update maintainers, manpage and completions - id: update - run: | - git fetch origin - - BRANCH=update-man-completions - echo "::set-output name=branch::${BRANCH}" - - if git ls-remote --exit-code --heads origin "${BRANCH}" - then - git checkout "${BRANCH}" - git reset --hard origin/master - else - git checkout --no-track -B "${BRANCH}" origin/master - fi - - if [[ "${{github.event_name}}" != "push" ]] - then - brew update-maintainers - fi - - if brew generate-man-completions --fail-if-not-changed - then - git add "${GITHUB_WORKSPACE}/README.md" \ - "${GITHUB_WORKSPACE}/docs/Manpage.md" \ - "${GITHUB_WORKSPACE}/manpages/brew.1" \ - "${GITHUB_WORKSPACE}/completions" - git commit -m "Update maintainers, manpage and completions." \ - -m "Autogenerated by the [update-man-completions](https://github.com/Homebrew/brew/blob/HEAD/.github/workflows/update-man-completions.yml) workflow." - echo "::set-output name=committed::true" - PULL_REQUEST_STATE="$(gh pr view --json=state | jq -r ".state")" - if [[ "${PULL_REQUEST_STATE}" != "OPEN" ]] - then - echo "::set-output name=pull_request::true" - fi - fi - env: - GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_BREW_UPDATE_MAINTAINERS_TOKEN }} - HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} - - - name: Push commits - if: steps.update.outputs.committed == 'true' - uses: Homebrew/actions/git-try-push@master - with: - token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} - branch: ${{ steps.update.outputs.branch }} - force: true - origin_branch: "master" - - - name: Open a pull request - if: steps.update.outputs.pull_request == 'true' - run: hub pull-request --no-edit - env: - GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} diff --git a/.github/workflows/vendor-gems.yml b/.github/workflows/vendor-gems.yml index bbe11aeab80cb..92c3098f257ce 100644 --- a/.github/workflows/vendor-gems.yml +++ b/.github/workflows/vendor-gems.yml @@ -1,7 +1,15 @@ name: Vendor Gems on: - pull_request_target: + pull_request: + paths: + - Library/Homebrew/dev-cmd/vendor-gems.rb + - Library/Homebrew/Gemfile* + push: + paths: + - .github/workflows/vendor-gems.yml + branches-ignore: + - master workflow_dispatch: inputs: pull_request: @@ -12,69 +20,92 @@ permissions: contents: read pull-requests: read +defaults: + run: + shell: bash -xeuo pipefail {0} + jobs: vendor-gems: - if: > - startsWith(github.repository, 'Homebrew/') && ( - github.event_name == 'workflow_dispatch' || ( - github.event.pull_request.user.login == 'dependabot[bot]' && - contains(github.event.pull_request.title, '/Library/Homebrew') - ) - ) - runs-on: macos-latest + if: github.repository_owner == 'Homebrew' + runs-on: macos-15 steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + with: + core: false + cask: false + test-bot: false - name: Configure Git user + if: github.event_name == 'workflow_dispatch' uses: Homebrew/actions/git-user-config@master with: username: BrewTestBot - name: Set up commit signing + if: github.event_name == 'workflow_dispatch' uses: Homebrew/actions/setup-commit-signing@master with: signing_key: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY }} - name: Check out pull request id: checkout + if: github.event_name == 'workflow_dispatch' run: | - gh pr checkout '${{ github.event.pull_request.number || github.event.inputs.pull_request }}' + gh pr checkout "${PR}" branch="$(git branch --show-current)" - echo "::set-output name=branch::${branch}" + echo "branch=${branch}" >> "$GITHUB_OUTPUT" gem_name="$(echo "${branch}" | sed -E 's|.*/||;s|(.*)-.*$|\1|')" - echo "::set-output name=gem_name::${gem_name}" + 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 env: HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} - run: brew 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: 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 }} HOMEBREW_GPG_PASSPHRASE: ${{ secrets.BREWTESTBOT_GPG_SIGNING_SUBKEY_PASSPHRASE }} + working-directory: ${{ steps.set-up-homebrew.outputs.repository-path }} run: | - set -u - - if brew typecheck --update --fail-if-not-changed + if ! git diff --stat --exit-code "Library/Homebrew/sorbet" then - if git add Library/Homebrew/sorbet - then - git commit -m "Update RBI files for ${GEM_NAME}." - fi - - git reset --hard + 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@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 + id: app-token + if: github.event_name == 'workflow_dispatch' + with: + app-id: ${{ vars.BREW_COMMIT_APP_ID }} + private-key: ${{ secrets.BREW_COMMIT_APP_KEY }} + - name: Push to pull request + if: github.event_name == 'workflow_dispatch' uses: Homebrew/actions/git-try-push@master with: - token: ${{ secrets.HOMEBREW_GITHUB_PUBLIC_REPO_TOKEN }} + token: ${{ steps.app-token.outputs.token }} + directory: ${{ steps.set-up-homebrew.outputs.repository-path }} branch: ${{ steps.checkout.outputs.branch }} force: true diff --git a/.github/workflows/vendor-version.yml b/.github/workflows/vendor-version.yml new file mode 100644 index 0000000000000..6ec0a95d7a608 --- /dev/null +++ b/.github/workflows/vendor-version.yml @@ -0,0 +1,70 @@ +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@master + with: + core: false + cask: false + test-bot: 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: + 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 [[ "$(<.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/gems.rb" >&2 + else + echo "::error::git check-ignore failed" >&2 + fi + exit "${exit_code}" + fi + done <<< "${IGNORED_GEMS}" + fi diff --git a/.gitignore b/.gitignore index 8f52acf25c76b..73789bf9e6a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,13 @@ .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/.subversion /Library/Homebrew/test/coverage @@ -20,151 +21,132 @@ /Library/Taps /Library/PinnedTaps /Library/Homebrew/.byebug_history -/Library/Homebrew/sorbet/rbi/hidden-definitions/errors.txt +/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/3.3.0/ + +# Ignore Bundler binary files +**/vendor/bundle/ruby/*/gems/**/*.bundle + # Ignore YARD files **/.yardoc # Unignore vendored gems +!**/vendor/bundle/ruby/*/gems/*/*LICENSE* !**/vendor/bundle/ruby/*/gems/*/lib !**/vendor/bundle/ruby/*/gems/addressable-*/data !**/vendor/bundle/ruby/*/gems/public_suffix-*/data -!**/vendor/bundle/ruby/*/gems/rubocop-performance-*/config -!**/vendor/bundle/ruby/*/gems/rubocop-rails-*/config -!**/vendor/bundle/ruby/*/gems/rubocop-rspec-*/config -!**/vendor/bundle/ruby/*/gems/rubocop-sorbet-*/config # 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*.rb -**/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/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/colorize-*/ **/vendor/bundle/ruby/*/gems/commander-*/ -**/vendor/bundle/ruby/*/gems/connection_pool-*/ **/vendor/bundle/ruby/*/gems/diff-lcs-*/ **/vendor/bundle/ruby/*/gems/docile-*/ -**/vendor/bundle/ruby/*/gems/domain_name-*/ **/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/http-cookie-*/ -**/vendor/bundle/ruby/*/gems/hpricot-*/ **/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/logger-*/ **/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/msgpack-*/ -**/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/netrc-*/ **/vendor/bundle/ruby/*/gems/ntlm-http-*/ **/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/racc-*/ **/vendor/bundle/ruby/*/gems/rainbow-*/ **/vendor/bundle/ruby/*/gems/rbi-*/ -**/vendor/bundle/ruby/*/gems/rdiscount-*/ +**/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/rexml-*/ -**/vendor/bundle/ruby/*/gems/ronn-*/ **/vendor/bundle/ruby/*/gems/rspec-*/ **/vendor/bundle/ruby/*/gems/rspec-core-*/ **/vendor/bundle/ruby/*/gems/rspec-expectations-*/ **/vendor/bundle/ruby/*/gems/rspec_junit_formatter-*/ -**/vendor/bundle/ruby/*/gems/rspec-its-*/ **/vendor/bundle/ruby/*/gems/rspec-mocks-*/ **/vendor/bundle/ruby/*/gems/rspec-retry-*/ **/vendor/bundle/ruby/*/gems/rspec-support-*/ **/vendor/bundle/ruby/*/gems/rspec-sorbet-*/ -**/vendor/bundle/ruby/*/gems/rspec-wait-*/ -**/vendor/bundle/ruby/*/gems/rubocop-1*/ -**/vendor/bundle/ruby/*/gems/rubocop-ast-*/ +**/vendor/bundle/ruby/*/gems/rubocop-*/ +**/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-html-*/ +**/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/sorbet-runtime-stub-*/ +!**/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/thor-*/ -**/vendor/bundle/ruby/*/gems/unf_ext-*/ -**/vendor/bundle/ruby/*/gems/unf-*/ **/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 @@ -172,12 +154,17 @@ # Unignore our `brew` script. !/bin/brew -# Unignore our documentation/completions. +# Unignore our configuration/completions/documentation. +!/.devcontainer !/.github !/completions !/docs !/manpages +# Unignore our packaging files +!/package +/package/resources/LICENSE.rtf + # Ignore generated documentation site /docs/_site /docs/bin @@ -189,9 +176,11 @@ !/.dockerignore !/.editorconfig !/.gitignore -!/.yardopts -!/.vale.ini +!/.irb_config +!/.ruby-version !/.shellcheckrc +!/.vale.ini +!/.yardopts !/CHANGELOG.md !/CONTRIBUTING.md !/Dockerfile 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 index 4ff3e09d4dd4e..33251fe7a3813 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,11 +1,17 @@ { "recommendations": [ - "kaiwood.endwise", - "misogi.ruby-rubocop", - "rebornix.ruby", - "wingrunr21.vscode-ruby", - "timonwong.shellcheck", + "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/ruby-lsp-activate.sh b/.vscode/ruby-lsp-activate.sh new file mode 100755 index 0000000000000..142acf76b407a --- /dev/null +++ b/.vscode/ruby-lsp-activate.sh @@ -0,0 +1,7 @@ +#!/bin/bash +HOMEBREW_PREFIX="$(cd "$(dirname "$0")"/../ && pwd)" + +"${HOMEBREW_PREFIX}/bin/brew" install-bundler-gems --add-groups=style,typecheck,vscode >/dev/null 2>&1 + +export PATH="${HOMEBREW_PREFIX}/Library/Homebrew/vendor/portable-ruby/current/bin:${PATH}" +export BUNDLE_WITH="style:typecheck:vscode" diff --git a/.vscode/settings.json b/.vscode/settings.json index 865fc53af2f00..fae9f4229f93b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,54 @@ { + "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, - "ruby.rubocop.executePath": "Library/Homebrew/shims/gems/", + "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", + "cwd": "${workspaceFolder}", + "command": [ + "./bin/brew", + "typecheck", + "--lsp", + ] + } + ], + "sorbet.selectedLspConfigId": "default", "shellcheck.customArgs": [ "--shell=bash", "--enable=all", @@ -21,7 +63,9 @@ "[shellscript]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.shellcheck": true, + "source.fixAll.shellcheck": "explicit" } - } + }, + "simplecov-vscode.path": "Library/Homebrew/test/coverage/.resultset.json", + "simplecov-vscode.enabled": false } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfd91c66cd380..1623d75e30512 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Homebrew -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). +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). ### Report a bug diff --git a/Dockerfile b/Dockerfile index a8c8ee7fc348b..64ce1a2019616 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,98 @@ ARG version=22.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`. +# shellcheck disable=SC1091,SC2154,SC2292 RUN apt-get update \ && apt-get install -y --no-install-recommends software-properties-common gnupg-agent \ && 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++ \ - gawk \ - git \ - less \ - libz-dev \ - locales \ - make \ - netbase \ - openssh-client \ - patch \ - sudo \ - uuid-runtime \ - tzdata \ - && apt remove --purge -y software-properties-common \ - && apt autoremove --purge -y \ + acl \ + bzip2 \ + ca-certificates \ + curl \ + file \ + fonts-dejavu-core \ + g++ \ + gawk \ + git \ + gpg \ + less \ + locales \ + make \ + netbase \ + openssh-client \ + patch \ + sudo \ + unzip \ + uuid-runtime \ + tzdata \ + jq \ + && if [ "$(. /etc/lsb-release; echo "${DISTRIB_RELEASE}" | cut -d. -f1)" -ge 22 ]; then apt-get install -y --no-install-recommends skopeo; 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 \ && rm -rf /var/lib/apt/lists/* \ + && sed -i -E 's/^(USERGROUPS_ENAB\s+)yes$/\1no/' /etc/login.defs \ && localedef -i en_US -f UTF-8 en_US.UTF-8 \ - && useradd -m -s /bin/bash linuxbrew \ + && 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 COPY --chown=linuxbrew:linuxbrew . /home/linuxbrew/.linuxbrew/Homebrew -ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" +ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" \ + XDG_CACHE_HOME=/home/linuxbrew/.cache WORKDIR /home/linuxbrew -RUN mkdir -p \ - .linuxbrew/bin \ - .linuxbrew/etc \ - .linuxbrew/include \ - .linuxbrew/lib \ - .linuxbrew/opt \ - .linuxbrew/sbin \ - .linuxbrew/share \ - .linuxbrew/var/homebrew/linked \ - .linuxbrew/Cellar \ + +RUN --mount=type=cache,target=/tmp/homebrew-core,uid="${USER_ID}",sharing=locked \ + # Clone the homebre-core repo into /tmp/homebrew-core or pull latest changes if it exists + git clone https://github.com/homebrew/homebrew-core /tmp/homebrew-core || { cd /tmp/homebrew-core && git pull; } \ + && mkdir -p /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/homebrew/homebrew-core \ + && cp -r /tmp/homebrew-core /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/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 homebrew/core \ - && brew install-bundler-gems \ + && 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; } \ - && rm -rf .cache + && touch .linuxbrew/.homebrewdocker diff --git a/Library/.rubocop.yml b/Library/.rubocop.yml index bad15147fa56f..96c2f71e1f6b2 100644 --- a/Library/.rubocop.yml +++ b/Library/.rubocop.yml @@ -1,9 +1,10 @@ -# TODO: Try getting more rules in sync. - +--- require: - ./Homebrew/rubocops.rb + - rubocop-md - rubocop-performance - - rubocop-rails + - rubocop-rspec + - rubocop-sorbet inherit_mode: merge: @@ -11,32 +12,24 @@ inherit_mode: - Exclude AllCops: - TargetRubyVersion: 2.6 - DisplayCopNames: false - ActiveSupportExtensionsEnabled: true - # enable all pending rubocops + TargetRubyVersion: 3.3 NewCops: enable Include: - "**/*.rbi" Exclude: - - "Homebrew/sorbet/rbi/gems/**/*.rbi" - - "Homebrew/sorbet/rbi/hidden-definitions/**/*.rbi" - - "Homebrew/sorbet/rbi/todo.rbi" - - "Homebrew/sorbet/rbi/upstream.rbi" + - "Homebrew/sorbet/rbi/{dsl,gems}/**/*.rbi" - "Homebrew/bin/*" - "Homebrew/vendor/**/*" - "Taps/*/*/vendor/**/*" + SuggestExtensions: + rubocop-minitest: false Cask/Desc: Description: "Ensure that the desc stanza conforms to various content and style checks." Enabled: true -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." +Cask/HomepageUrlStyling: + Description: "Ensure that the homepage url has the correct format and styling." Enabled: true Cask/StanzaGrouping: @@ -47,352 +40,71 @@ 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 -# enable all Homebrew custom cops Homebrew: Enabled: true -# makes DSL usage ugly. -Layout/SpaceBeforeBrackets: - Exclude: - - "**/*_spec.rb" - - "Taps/*/*/*.rb" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" - -# Use `<<~` for heredocs. -Layout/HeredocIndentation: - Enabled: true - -# Keyword arguments don't have the same readability -# problems as normal parameters. -Metrics/ParameterLists: - CountKeywordArgs: false - -# 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: - # TODO: If possible, make this stricter. - 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' - -# Both styles are used depending on context, -# e.g. `sha256` and `something_countable_1`. -Naming/VariableNumber: - Enabled: false - -# Require &&/|| instead of and/or -Style/AndOr: - Enabled: true - EnforcedStyle: always - -# 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 - -# 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" - -# Prefer tokens with type annotations for consistency -# between formatting numbers and strings. -Style/FormatStringToken: - EnforcedStyle: annotated - -# autocorrectable and more readable -Style/HashEachMethods: - Enabled: true -Style/HashTransformKeys: - Enabled: true -Style/HashTransformValues: - Enabled: true - -# Allow for license expressions -Style/HashAsLastArrayItem: +Homebrew/Blank: Exclude: - - "Taps/*/*/*.rb" - - "/**/Formula/*.rb" - - "**/Formula/*.rb" + # Core extensions are not available here: + - "Homebrew/startup/bootsnap.rb" -# Enabled now LineLength is lowish. -Style/IfUnlessModifier: - Enabled: true - -# Only use this for numbers >= `1_000_000`. -Style/NumericLiterals: - MinDigits: 7 - Strict: true +Homebrew/CompactBlank: Exclude: - - "**/Brewfile" - -# 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 + # `blank?` is not necessarily available here: + - "Homebrew/extend/enumerable.rb" -# Makes code less readable for minor performance increases. -Performance/Caller: - Enabled: false +Homebrew/NoFileutilsRmrf: + Include: + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" -# Makes code less readable for minor performance increases. -Performance/MethodObjectAsBlock: +# only used internally +Homebrew/MoveToExtendOS: Enabled: false -Rails: - # Selectively enable what we want. - Enabled: false - # Cannot use ActiveSupport in RuboCops. +Homebrew/NegateInclude: Exclude: + # `exclude?` is not available here: + - "Homebrew/standalone/init.rb" - "Homebrew/rubocops/**/*" - -# These relate to ActiveSupport and not other parts of Rails. -Rails/ActiveSupportAliases: - Enabled: true -Rails/Blank: - Enabled: true -Rails/CompactBlank: - Enabled: true -Rails/Delegate: - Enabled: false # TODO -Rails/DelegateAllowBlank: - Enabled: true -Rails/DurationArithmetic: - Enabled: true -Rails/ExpandedDateRange: - Enabled: true -Rails/Inquiry: - Enabled: true -Rails/NegateInclude: - Enabled: true -Rails/PluralizationGrammar: - Enabled: true -Rails/Presence: - Enabled: true -Rails/Present: - Enabled: true -Rails/RelativeDateConstant: - Enabled: true -Rails/SafeNavigation: - Enabled: true -Rails/SafeNavigationWithBlank: - Enabled: true -Rails/StripHeredoc: - Enabled: true -Rails/ToFormattedS: - 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" - -# make our hashes consistent -Layout/HashAlignment: - EnforcedHashRocketStyle: table - EnforcedColonStyle: table + - "Homebrew/sorbet/tapioca/**/*" # `system` is a special case and aligns on second argument, so allow this for formulae. Layout/ArgumentAlignment: Exclude: - "Taps/*/*/*.rb" - - "/**/Formula/*.rb" - - "**/Formula/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" # this is a bit less "floaty" Layout/CaseIndentation: EnforcedStyle: end -# Need to allow #: for external commands. -Layout/LeadingCommentSpace: - Exclude: - - "Taps/*/*/cmd/*.rb" - -# this is a bit less "floaty" -Layout/EndAlignment: - EnforcedStyleAlignWith: start_of_line - -# conflicts with DSL-style path concatenation with `/` -Layout/SpaceAroundOperators: - Enabled: false - -# layout is not configurable (https://github.com/rubocop-hq/rubocop/issues/6254). -Layout/RescueEnsureAlignment: - Enabled: false - # significantly less indentation involved; more consistent Layout/FirstArrayElementIndentation: EnforcedStyle: consistent Layout/FirstHashElementIndentation: EnforcedStyle: consistent -# favour parens-less DSL-style arguments -Lint/AmbiguousBlockAssociation: - Enabled: false - -Lint/RequireRelativeSelfPath: - # bugged on formula-analytics - # https://github.com/Homebrew/brew/pull/12152/checks?check_run_id=3755137378#step:15:60 - Exclude: - - "Taps/homebrew/homebrew-formula-analytics/*/*.rb" - -Lint/DuplicateBranch: - Exclude: - - "Taps/*/*/*.rb" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" - -# needed for lazy_object magic -Naming/MemoizedInstanceVariableName: - Exclude: - - "Homebrew/lazy_object.rb" - -# useful for metaprogramming in RSpec -Lint/ConstantDefinitionInBlock: - Exclude: - - "**/*_spec.rb" +# this is a bit less "floaty" +Layout/EndAlignment: + EnforcedStyleAlignWith: start_of_line -# so many of these in formulae and can't be autocorrected -Lint/ParenthesesAsGroupedExpression: - Exclude: - - "Taps/*/*/*.rb" - - "/**/Formula/*.rb" - - "**/Formula/*.rb" +# make our hashes consistent +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table -# Most metrics don't make sense to apply for casks/formulae/taps. -Metrics/AbcSize: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/BlockLength: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/ClassLength: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/CyclomaticComplexity: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/MethodLength: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/ModuleLength: - Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" -Metrics/PerceivedComplexity: +# Need to allow #: for external commands. +Layout/LeadingCommentSpace: Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" - -# allow 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" + - "Taps/*/*/cmd/*.rb" # GitHub diff UI wraps beyond 118 characters Layout/LineLength: @@ -404,7 +116,6 @@ Layout/LineLength: ' url "', ' mirror "', " plist_options ", - ' appcast "', ' executable: "', ' font "', ' homepage "', @@ -420,98 +131,315 @@ Layout/LineLength: '"/Library/PreferencePanes/', ' "~/Library/Application Support/', '"~/Library/Caches/', + '"~/Library/Containers', '"~/Application Support', " was verified as official when first introduced to the cask", ] -Sorbet/FalseSigil: +# conflicts with DSL-style path concatenation with `/` +Layout/SpaceAroundOperators: + Enabled: false + +# makes DSL usage ugly. +Layout/SpaceBeforeBrackets: Exclude: - - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" - - "Homebrew/test/**/Casks/**/*.rb" + - "**/*_spec.rb" + - "Taps/*/*/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" -Sorbet/StrictSigil: +# 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 +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 + +# Disabled because it breaks Sorbet: "The declaration for `with` is missing parameter(s): & (RuntimeError)" +Naming/BlockForwarding: + Enabled: false + +# 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: - override: - - Include + merge: + - AllowedNames + +# Both styles are used depending on context, +# e.g. `sha256` and `something_countable_1`. +Naming/VariableNumber: + Enabled: false + +# Makes code less readable for minor performance increases. +Performance/Caller: + Enabled: false + +# 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: - - "**/*.rbi" + - "Homebrew/test/**/*" + +# Intentionally disabled as it doesn't fit with our code style. +RSpec/AnyInstance: + Enabled: false +RSpec/SpecFilePathFormat: + Enabled: false +RSpec/StubbedMock: + Enabled: false +RSpec/SubjectStub: + Enabled: false +# These were ever-growing numbers, not useful. +RSpec/ExampleLength: + Enabled: false +RSpec/MultipleExpectations: + Enabled: false +RSpec/NestedGroups: + Enabled: false +RSpec/MultipleMemoizedHelpers: + Enabled: false + +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 # Try getting rid of these. Sorbet/ConstantsFromStrings: Enabled: false -# Avoid false positives on modifiers used on symbols of methods -# See https://github.com/rubocop-hq/rubocop/issues/5953 -Style/AccessModifierDeclarations: +# This is already the default +Sorbet/FalseSigil: Enabled: false -# Conflicts with type signatures on `attr_*`s. -Style/AccessorGrouping: +# We generally prefer to colo rbi files with the Ruby files they describe. +Sorbet/ForbidRBIOutsideOfAllowedPaths: Enabled: false -# make rspec formatting more flexible -Style/BlockDelimiters: +# T::Sig is monkey-patched into Module +Sorbet/RedundantExtendTSig: + Enabled: true + +# We make limited and intentional use of refinements. +# It's posssible this may change in the future, though we probably still do not want to ban it in taps +# and Sorbet typecheck will tell us what is and isn't a problem anyway. +# Right now, our use of refinements isn't problematic (or at least not yet). +Sorbet/Refinement: + Enabled: false + +Sorbet/StrictSigil: + Enabled: true Exclude: - - "Homebrew/**/*_spec.rb" - - "Homebrew/**/shared_examples/**/*.rb" + - "Taps/**/*" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "Homebrew/{standalone,startup}/*.rb" # These are loaded before sorbet-runtime + - "Homebrew/test/**/*.rb" + +Sorbet/TrueSigil: + Enabled: true + Exclude: + - "Taps/**/*" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + - "Homebrew/test/**/*.rb" + +# 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 + +# Avoid leaking resources. +Style/AutoResourceCleanup: + Enabled: true + +# This makes these a little more obvious. +Style/BarePercentLiterals: + EnforcedStyle: percent_q + +Style/BlockDelimiters: + BracesRequiredMethods: + - "sig" -# TODO: remove this when possible. -Style/ClassVars: +Style/ClassAndModuleChildren: Exclude: - - "**/developer/bin/*" + - "**/*.rbi" -# Don't enforce documentation in casks or formulae. +# 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: Exclude: - "Taps/**/*" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" - "**/*.rbi" -Style/DocumentationMethod: - Include: - - "Homebrew/formula.rb" +# 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" # Not used for casks and formulae. Style/FrozenStringLiteralComment: EnforcedStyle: always Exclude: - "Taps/*/*/*.rb" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" - "Homebrew/test/**/Casks/**/*.rb" - "**/*.rbi" - "**/Brewfile" -# TODO: remove this when possible. -Style/GlobalVars: - Exclude: - - "**/developer/bin/*" - # potential for errors in formulae too high with this Style/GuardClause: Exclude: - "Taps/*/*/*.rb" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" + +# Allow for license expressions +Style/HashAsLastArrayItem: + Exclude: + - "Taps/*/*/*.rb" + - "/**/Formula/**/*.rb" + - "**/Formula/**/*.rb" -# avoid hash rockets where possible -Style/HashSyntax: - EnforcedStyle: ruby19 +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? + +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 use this for numbers >= `1_000_000`. +Style/NumericLiterals: + MinDigits: 7 + Strict: true + Exclude: + - "**/Brewfile" -# OpenStruct is a nice helper. 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? + +# Broken in RuboCop 1.68.0 so tries to fix line continuations in inline patch blocks: +# https://github.com/Homebrew/brew/actions/runs/11653110391/job/32460881827?pr=18682 +Style/RedundantLineContinuation: + Enabled: false + +# 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 # so many of these in formulae and can't be autocorrected Style/StringConcatenation: Exclude: - "Taps/*/*/*.rb" - - "/**/{Formula,Casks}/*.rb" - - "**/{Formula,Casks}/*.rb" + - "/**/{Formula,Casks}/**/*.rb" + - "**/{Formula,Casks}/**/*.rb" # ruby style guide favorite Style/StringLiterals: @@ -521,10 +449,36 @@ 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 +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 @@ -533,11 +487,3 @@ Style/UnlessLogicalOperators: # a bit confusing to non-Rubyists but useful for longer arrays Style/WordArray: MinSize: 4 - -# would rather freeze too much than too little -Style/MutableConstant: - EnforcedStyle: strict - -# unused keyword arguments improve APIs -Lint/UnusedMethodArgument: - AllowUnusedKeywordArguments: true diff --git a/Library/.rubocop_rspec.yml b/Library/.rubocop_rspec.yml deleted file mode 100644 index 1cc0a316d4d96..0000000000000 --- a/Library/.rubocop_rspec.yml +++ /dev/null @@ -1,37 +0,0 @@ -inherit_from: ./.rubocop.yml - -# Intentionally disabled as it doesn't fit with our code style. -RSpec/AnyInstance: - Enabled: false -RSpec/FilePath: - Enabled: false -RSpec/ImplicitBlockExpectation: - Enabled: false -RSpec/SubjectStub: - Enabled: false - -# TODO: try to enable these -RSpec/DescribeClass: - Enabled: false -RSpec/LeakyConstantDeclaration: - Enabled: false -RSpec/MessageSpies: - Enabled: false -RSpec/RepeatedDescription: - Enabled: false -RSpec/StubbedMock: - Enabled: false - -# TODO: try to reduce these -RSpec/ExampleLength: - Max: 75 -RSpec/MultipleExpectations: - Max: 26 -RSpec/NestedGroups: - Max: 5 -RSpec/MultipleMemoizedHelpers: - Max: 12 - -# Annoying to have these autoremoved. -RSpec/Focus: - AutoCorrect: false diff --git a/Library/Homebrew/.bundle/config b/Library/Homebrew/.bundle/config index e2c528e14d425..5d1f6bbaea0ed 100644 --- a/Library/Homebrew/.bundle/config +++ b/Library/Homebrew/.bundle/config @@ -2,6 +2,7 @@ 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" diff --git a/Library/Homebrew/.parlour b/Library/Homebrew/.parlour deleted file mode 100644 index 1fe16333dc4e2..0000000000000 --- a/Library/Homebrew/.parlour +++ /dev/null @@ -1,10 +0,0 @@ -parser: false - -output_file: - rbi: sorbet/rbi/parlour.rbi - -relative_requires: - - sorbet/parlour.rb - -plugins: - Attr: {} diff --git a/Library/Homebrew/.rspec_parallel b/Library/Homebrew/.rspec_parallel index 5200a0136d80d..7bc3e335f0ac2 100644 --- a/Library/Homebrew/.rspec_parallel +++ b/Library/Homebrew/.rspec_parallel @@ -1,4 +1,4 @@ ---format NoSeedProgressFormatter +--format QuietProgressFormatter --format ParallelTests::RSpec::RuntimeLogger --out <%= ENV["PARALLEL_RSPEC_LOG_PATH"] %> --format RspecJunitFormatter diff --git a/Library/Homebrew/.rubocop.yml b/Library/Homebrew/.rubocop.yml index 07b613ecb3b2b..8cdc7766cc071 100644 --- a/Library/Homebrew/.rubocop.yml +++ b/Library/Homebrew/.rubocop.yml @@ -1,63 +1,81 @@ inherit_from: - - ../.rubocop_rspec.yml - - .rubocop_todo.yml + - ../.rubocop.yml -# make rspec formatting more flexible -Layout/MultilineMethodCallIndentation: - Exclude: - - "**/*_spec.rb" - -# `formula do` uses nested method definitions -Lint/NestedMethodDefinition: - Exclude: - - "test/**/*" +Bundler/GemFilename: + Enabled: false -# TODO: Try to bring down all metrics maximums. -Metrics/AbcSize: - Max: 280 -Metrics/BlockLength: - Max: 106 - Exclude: - # TODO: extract more of the bottling logic - - "dev-cmd/bottle.rb" - - "test/**/*" -Metrics/BlockNesting: - Max: 5 -Metrics/ClassLength: - Max: 800 - Exclude: - - "formula.rb" - - "formula_installer.rb" -Metrics/CyclomaticComplexity: - Max: 80 -Metrics/PerceivedComplexity: - Max: 90 -Metrics/MethodLength: - Max: 260 -Metrics/ModuleLength: - Max: 500 +Homebrew/MoveToExtendOS: + Enabled: true Exclude: - # TODO: extract more of the bottling logic - - "dev-cmd/bottle.rb" - # TODO: try break this down - - "utils/github.rb" - - "test/**/*" + - "{extend,test,requirements}/**/*" + - "os.rb" Naming/PredicateName: - # Can't rename these. + inherit_mode: + merge: + - AllowedMethods AllowedMethods: - - is_a? - is_32_bit? - is_64_bit? -Style/HashAsLastArrayItem: - Exclude: - - "test/utils/spdx_spec.rb" - -Style/BlockDelimiters: - BracesRequiredMethods: - - "sig" +# Only enforce documentation for public APIs. +# Checked by the tests.yml syntax job +Style/Documentation: + AllowedConstants: + - Homebrew + Include: + - abstract_command.rb + - cask/cask.rb + - cask/dsl.rb + - cask/dsl/version.rb + - cask/url.rb + - development_tools.rb + - download_strategy.rb + - extend/ENV/super.rb + - extend/kernel.rb + - extend/pathname.rb + - formula.rb + - formula_assertions.rb + - formula_free_port.rb + - language/go.rb + - language/java.rb + - language/node.rb + - language/perl.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/sourceforge.rb + - livecheck/strategy/sparkle.rb + - livecheck/strategy/xml.rb + - livecheck/strategy/xorg.rb + - livecheck/strategy/yaml.rb + - os.rb + - resource.rb + - utils/inreplace.rb + - utils/shebang.rb + - utils/string_inreplace_extension.rb + - version.rb + - tap.rb -Bundler/GemFilename: +Homebrew/NegateInclude: Exclude: - - "utils/gems.rb" + # YARD runs stand-alone. + - yard/docstring_parser.rb + +Style/DocumentationMethod: + Include: + - "formula.rb" diff --git a/Library/Homebrew/.rubocop_todo.yml b/Library/Homebrew/.rubocop_todo.yml deleted file mode 100644 index 249ddfbd948f3..0000000000000 --- a/Library/Homebrew/.rubocop_todo.yml +++ /dev/null @@ -1,38 +0,0 @@ -Style/Documentation: - Exclude: - - "compat/**/*.rb" - - "extend/**/*.rb" - - "cmd/**/*.rb" - - "dev-cmd/**/*.rb" - - "test/**/*.rb" - - "cask/macos.rb" - - "cli/args.rb" - - "cli/parser.rb" - - "default_prefix.rb" - - "global.rb" - - "keg_relocate.rb" - - "os/linux/global.rb" - - "os/mac/global.rb" - - "os/mac/keg.rb" - - "reinstall.rb" - - "software_spec.rb" - - "upgrade.rb" - - "utils.rb" - - "utils/fork.rb" - - "utils/gems.rb" - - "utils/git_repository.rb" - - "utils/popen.rb" - - "utils/shell.rb" - - "version.rb" - -Lint/EmptyBlock: - Exclude: - - "dependency.rb" - - "dev-cmd/extract.rb" - - "test/cache_store_spec.rb" - - "test/checksum_verification_spec.rb" - - "test/compiler_failure_spec.rb" - - "test/formula_spec.rb" - - "test/formula_validation_spec.rb" - - "test/pathname_spec.rb" - - "test/support/fixtures/cask/Casks/*flight*.rb" diff --git a/Library/Homebrew/.ruby-version b/Library/Homebrew/.ruby-version new file mode 100644 index 0000000000000..9c25013dbb862 --- /dev/null +++ b/Library/Homebrew/.ruby-version @@ -0,0 +1 @@ +3.3.6 diff --git a/Library/Homebrew/.simplecov b/Library/Homebrew/.simplecov index 792b6951ee864..6b48c7ad2699b 100755 --- a/Library/Homebrew/.simplecov +++ b/Library/Homebrew/.simplecov @@ -8,19 +8,23 @@ 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 - at_fork do |pid| - # This needs a unique name so it won't be ovewritten - command_name "#{SimpleCov.command_name} (#{pid})" - + at_fork do # be quiet, the parent process will be in charge of output and checking coverage totals SimpleCov.print_error_status = false end @@ -30,9 +34,9 @@ SimpleCov.start do .map { |p| "#{p}/**/*.rb" }.join(",") files = "#{SimpleCov.root}/{#{subdirs},*.rb}" - if ENV["HOMEBREW_INTEGRATION_TEST"] - # This needs a unique name so it won't be ovewritten - command_name "#{ENV["HOMEBREW_INTEGRATION_TEST"]} (#{$PROCESS_ID})" + 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}" # be quiet, the parent process will be in charge of output and checking coverage totals SimpleCov.print_error_status = false @@ -53,39 +57,43 @@ SimpleCov.start do raise if $ERROR_INFO.is_a?(SystemExit) end else - command_name "#{command_name} (#{$PROCESS_ID})" + 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 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} unless /darwin/.match?(host_os) - add_filter %r{/os/linux} unless /linux/.match?(host_os) + 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 index b86f778054d33..4bb4553c05ef6 100644 --- a/Library/Homebrew/.yardopts +++ b/Library/Homebrew/.yardopts @@ -2,12 +2,15 @@ --main README.md --markup markdown --no-private ---load yard/ignore_directives.rb +--plugin sorbet +--load ./yard/docstring_parser.rb --template-path yard/templates +--exclude sorbet/rbi/gems/ --exclude test/ --exclude vendor/ ---exclude compat/ +--exclude yard/ extend/os/**/*.rb **/*.rb +**/*.rbi - *.md diff --git a/Library/Homebrew/Gemfile b/Library/Homebrew/Gemfile index 12e3d0926d3d6..3bcd332f44923 100644 --- a/Library/Homebrew/Gemfile +++ b/Library/Homebrew/Gemfile @@ -2,46 +2,84 @@ source "https://rubygems.org" +# 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 "~> 3.3.0" +else + ruby ">= 3.3.0" +end + # disallowed gems (should not be used) # * nokogiri - use rexml instead for XML parsing # installed gems (should all be require: false) -gem "bootsnap", require: false -gem "byebug", require: false -gem "json_schemer", require: false -gem "minitest", require: false -gem "parallel_tests", require: false -gem "ronn", require: false -gem "rspec", require: false -gem "rspec-github", require: false -gem "rspec-its", require: false -gem "rspec_junit_formatter", require: false -gem "rspec-retry", require: false -gem "rspec-wait", require: false -gem "rubocop", require: false -gem "rubocop-ast", require: false -gem "simplecov", require: false -gem "simplecov-cobertura", require: false -gem "warning", require: false - -group :sorbet, optional: true do - gem "parlour", 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 :tests, optional: true do + gem "parallel_tests", require: false + gem "rspec", 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 + +# shared gems (used by multiple groups) +group :audit, :bump_unversioned_casks, :livecheck, optional: true do + gem "rexml", require: false +end -# vendored gems -gem "activesupport", "< 7" # 7 requires Ruby 2.7 +# vendored gems (no group) gem "addressable" gem "concurrent-ruby" -gem "did_you_mean" # remove when HOMEBREW_REQUIRED_RUBY_VERSION >= 2.7 -gem "mechanize" gem "patchelf" gem "plist" -gem "rubocop-performance" -gem "rubocop-rails" -gem "rubocop-rspec" -gem "rubocop-sorbet" gem "ruby-macho" -gem "sorbet-runtime-stub" +gem "sorbet-runtime" +gem "warning" diff --git a/Library/Homebrew/Gemfile.lock b/Library/Homebrew/Gemfile.lock index 97800c4ba3c0c..e1a7649e8c5b8 100644 --- a/Library/Homebrew/Gemfile.lock +++ b/Library/Homebrew/Gemfile.lock @@ -1,244 +1,200 @@ GEM remote: https://rubygems.org/ specs: - activesupport (6.1.6) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) - bindata (2.4.10) - bootsnap (1.12.0) - msgpack (~> 1.2) - byebug (11.1.3) + bigdecimal (3.1.8) + bindata (2.5.0) coderay (1.1.3) - commander (4.6.0) - highline (~> 2.0.0) - concurrent-ruby (1.1.10) - connection_pool (2.2.5) - did_you_mean (1.6.1) - diff-lcs (1.5.0) - docile (1.4.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - ecma-re-validator (0.4.0) - regexp_parser (~> 2.2) - elftools (1.1.3) + concurrent-ruby (1.3.4) + diff-lcs (1.5.1) + docile (1.4.1) + elftools (1.3.1) bindata (~> 2) + erubi (1.13.1) hana (1.3.7) - highline (2.0.3) - hpricot (0.8.6) - http-cookie (1.0.5) - domain_name (~> 0.5) - i18n (1.10.0) - concurrent-ruby (~> 1.0) - json (2.6.2) - json_schemer (0.2.21) - ecma-re-validator (~> 0.3) + json (2.9.1) + json_schemer (2.3.0) + bigdecimal hana (~> 1.3) regexp_parser (~> 2.0) - uri_template (~> 0.7) - mechanize (2.8.5) - addressable (~> 2.8) - domain_name (~> 0.5, >= 0.5.20190701) - http-cookie (~> 1.0, >= 1.0.3) - mime-types (~> 3.0) - net-http-digest_auth (~> 1.4, >= 1.4.1) - net-http-persistent (>= 2.5.2, < 5.0.dev) - nokogiri (~> 1.11, >= 1.11.2) - rubyntlm (~> 0.6, >= 0.6.3) - webrick (~> 1.7) - webrobots (~> 0.1.2) - method_source (1.0.0) - mime-types (3.4.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2022.0105) - mini_portile2 (2.8.0) - minitest (5.16.2) - msgpack (1.5.3) - mustache (1.1.1) - net-http-digest_auth (1.4.1) - net-http-persistent (4.0.1) - connection_pool (~> 2.2) - nokogiri (1.13.6) - mini_portile2 (~> 2.8.0) - racc (~> 1.4) - parallel (1.22.1) - parallel_tests (3.11.1) + simpleidn (~> 0.2) + kramdown (2.5.1) + rexml (>= 3.3.9) + language_server-protocol (3.17.0.3) + logger (1.6.4) + method_source (1.1.0) + minitest (5.25.4) + netrc (0.11.0) + parallel (1.26.3) + parallel_tests (4.7.2) parallel - parlour (8.0.0) - commander (~> 4.5) - parser - rainbow (~> 3.0) - sorbet-runtime (>= 0.5) - parser (3.1.2.0) + parser (3.3.6.0) ast (~> 2.4.1) - patchelf (1.3.0) - elftools (>= 1.1.3) - plist (3.6.0) - pry (0.14.1) + racc + patchelf (1.5.1) + elftools (>= 1.3) + plist (3.7.1) + prism (1.3.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (4.0.7) - racc (1.6.0) - rack (2.2.4) + public_suffix (6.0.1) + racc (1.8.1) rainbow (3.1.1) - rbi (0.0.14) - ast - parser (>= 2.6.4.0) + rbi (0.2.2) + prism (~> 1.0) sorbet-runtime (>= 0.5.9204) - unparser - rdiscount (2.2.0.2) - regexp_parser (2.5.0) - rexml (3.2.5) - ronn (0.7.3) - hpricot (>= 0.8.2) - mustache (>= 0.7.0) - rdiscount (>= 1.5.8) - rspec (3.11.0) - rspec-core (~> 3.11.0) - rspec-expectations (~> 3.11.0) - rspec-mocks (~> 3.11.0) - rspec-core (3.11.0) - rspec-support (~> 3.11.0) - rspec-expectations (3.11.0) + rbs (3.8.0) + logger + redcarpet (3.6.0) + regexp_parser (2.9.3) + rexml (3.4.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-github (2.3.1) + rspec-support (~> 3.13.0) + rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-its (1.3.0) - rspec-core (>= 3.0.0) - rspec-expectations (>= 3.0.0) - rspec-mocks (3.11.1) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) + rspec-support (~> 3.13.0) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-sorbet (1.8.3) + rspec-sorbet (1.9.2) sorbet-runtime - rspec-support (3.11.0) - rspec-wait (0.0.9) - rspec (>= 3, < 4) - rspec_junit_formatter (0.5.1) + rspec-support (3.13.2) + rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.31.1) + rubocop (1.69.2) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.1.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.18.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.18.0) - parser (>= 3.1.1.0) - rubocop-performance (1.14.2) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.15.1) - activesupport (>= 4.2.0) - rack (>= 1.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-rspec (2.12.1) - rubocop (~> 1.31) - rubocop-sorbet (0.6.11) - rubocop (>= 0.90.0) - ruby-macho (3.0.0) - ruby-progressbar (1.11.0) - rubyntlm (0.6.3) - simplecov (0.21.2) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-md (1.2.4) + rubocop (>= 1.45) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + rubocop-sorbet (0.8.7) + rubocop (>= 1) + ruby-lsp (0.22.1) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 4) + sorbet-runtime (>= 0.5.10782) + ruby-macho (4.1.0) + ruby-prof (1.7.1) + ruby-progressbar (1.13.0) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-cobertura (2.1.0) rexml simplecov (~> 0.19) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - sorbet (0.5.10141) - sorbet-static (= 0.5.10141) - sorbet-runtime (0.5.10141) - sorbet-runtime-stub (0.2.0) - sorbet-static (0.5.10141-universal-darwin-14) - sorbet-static-and-runtime (0.5.10141) - sorbet (= 0.5.10141) - sorbet-runtime (= 0.5.10141) - spoom (1.1.11) - sorbet (>= 0.5.9204) - sorbet-runtime (>= 0.5.9204) + simpleidn (0.2.3) + sorbet (0.5.11708) + sorbet-static (= 0.5.11708) + sorbet-runtime (0.5.11708) + sorbet-static (0.5.11708-aarch64-linux) + sorbet-static (0.5.11708-universal-darwin) + sorbet-static (0.5.11708-x86_64-linux) + sorbet-static-and-runtime (0.5.11708) + sorbet (= 0.5.11708) + sorbet-runtime (= 0.5.11708) + spoom (1.5.0) + erubi (>= 1.10.0) + prism (>= 0.28.0) + sorbet-static-and-runtime (>= 0.5.10187) thor (>= 0.19.2) - tapioca (0.7.2) - bundler (>= 1.17.3) - pry (>= 0.12.2) - rbi (~> 0.0.0, >= 0.0.14) - sorbet-runtime (>= 0.5.9204) - sorbet-static (>= 0.5.9204) - spoom (~> 1.1.0, >= 1.1.11) + stackprof (0.2.26) + tapioca (0.16.5) + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (~> 0.2) + sorbet-static-and-runtime (>= 0.5.11087) + spoom (>= 1.2.0) thor (>= 1.2.0) yard-sorbet - thor (1.2.1) - tzinfo (2.0.4) - concurrent-ruby (~> 1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.2.0) - unparser (0.6.4) - diff-lcs (~> 1.3) - parser (>= 3.1.0) - uri_template (0.7.0) - warning (1.2.1) - webrick (1.7.0) - webrobots (0.1.2) - yard (0.9.28) - webrick (~> 1.7.0) - yard-sorbet (0.6.1) - sorbet-runtime (>= 0.5) - yard (>= 0.9) - zeitwerk (2.6.0) + thor (1.3.2) + unicode-display_width (3.1.2) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + vernier (1.5.0) + warning (1.5.0) + yard (0.9.37) + yard-sorbet (0.9.0) + sorbet-runtime + yard PLATFORMS - ruby + aarch64-linux + arm-linux + arm64-darwin + x86_64-darwin + x86_64-linux DEPENDENCIES - activesupport (< 7) addressable - bootsnap - byebug concurrent-ruby - did_you_mean json_schemer - mechanize + kramdown + method_source minitest parallel_tests - parlour patchelf plist - ronn + pry + redcarpet + rexml rspec rspec-github - rspec-its rspec-retry rspec-sorbet - rspec-wait rspec_junit_formatter rubocop rubocop-ast + rubocop-md rubocop-performance - rubocop-rails rubocop-rspec rubocop-sorbet + ruby-lsp ruby-macho + ruby-prof + ruby-progressbar simplecov simplecov-cobertura - sorbet-runtime-stub + sorbet-runtime sorbet-static-and-runtime + spoom + stackprof tapioca + vernier warning + yard + yard-sorbet + +RUBY VERSION + ruby 3.3.6p108 BUNDLED WITH - 1.17.3 + 2.5.20 diff --git a/Library/Homebrew/PATH.rb b/Library/Homebrew/PATH.rb index 6e48ef8553ea9..46fd38bb3f32b 100644 --- a/Library/Homebrew/PATH.rb +++ b/Library/Homebrew/PATH.rb @@ -1,28 +1,24 @@ -# typed: true +# typed: strict # frozen_string_literal: true -# Represention of a `*PATH` environment variable. -# -# @api private -class PATH - extend T::Sig +require "forwardable" +# Representation of a `*PATH` environment variable. +class PATH include Enumerable extend Forwardable + extend T::Generic delegate each: :@paths - # FIXME: Enable cop again when https://github.com/sorbet/sorbet/issues/3532 is fixed. - # rubocop:disable Style/MutableConstant + 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]) } private_constant :Elements - # rubocop:enable Style/MutableConstant - 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) } @@ -63,7 +59,9 @@ def to_ary def to_str @paths.join(File::PATH_SEPARATOR) end - alias to_s to_str + + sig { returns(String) } + def to_s = to_str sig { params(other: T.untyped).returns(T::Boolean) } def ==(other) @@ -79,7 +77,7 @@ def empty? sig { returns(T.nilable(T.self_type)) } def existing - existing_path = select(&File.method(:directory?)) + existing_path = select { File.directory?(_1) } # return nil instead of empty PATH, to unset environment variables existing_path unless existing_path.empty? end diff --git a/Library/Homebrew/abstract_command.rb b/Library/Homebrew/abstract_command.rb new file mode 100644 index 0000000000000..03b493c83431c --- /dev/null +++ b/Library/Homebrew/abstract_command.rb @@ -0,0 +1,82 @@ +# typed: strong +# frozen_string_literal: true + +require "cli/parser" +require "shell_command" + +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 + + 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 { _1.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; end + end +end diff --git a/Library/Homebrew/api.rb b/Library/Homebrew/api.rb index e4028b26781eb..103ac6eec4cb9 100644 --- a/Library/Homebrew/api.rb +++ b/Library/Homebrew/api.rb @@ -1,42 +1,207 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "api/analytics" require "api/cask" -require "api/cask-source" require "api/formula" -require "api/versions" -require "extend/cachable" +require "base64" # TODO: vendor this for Ruby 3.4. module Homebrew # Helper functions for using Homebrew's formulae.brew.sh API. - # - # @api private module API - extend T::Sig - extend Cachable - module_function - - API_DOMAIN = "https://formulae.brew.sh/api" HOMEBREW_CACHE_API = (HOMEBREW_CACHE/"api").freeze + HOMEBREW_CACHE_API_SOURCE = (HOMEBREW_CACHE/"api-source").freeze - sig { params(endpoint: String, json: T::Boolean).returns(T.any(String, Hash)) } - def fetch(endpoint, json: true) + sig { params(endpoint: String).returns(Hash) } + def self.fetch(endpoint) return cache[endpoint] if cache.present? && cache.key?(endpoint) - api_url = "#{API_DOMAIN}/#{endpoint}" - output = Utils::Curl.curl_output("--fail", api_url, max_time: 5) + 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] = if json - JSON.parse(output.stdout) - else - output.stdout - end + 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(endpoint: String, target: Pathname, stale_seconds: Integer).returns([T.any(Array, Hash), T::Boolean]) + } + def self.fetch_json_api_file(endpoint, target: HOMEBREW_CACHE_API/endpoint, + stale_seconds: Homebrew::EnvConfig.api_auto_update_secs.to_i) + # 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) + %W[ + --compressed + --speed-limit #{ENV.fetch("HOMEBREW_CURL_SPEED_LIMIT")} + --speed-time #{ENV.fetch("HOMEBREW_CURL_SPEED_TIME")} + ] + + insecure_download = DevelopmentTools.ca_file_substitution_required? || + DevelopmentTools.curl_substitution_required? + skip_download = target.exist? && + !target.empty? && + (!Homebrew.auto_update_command? || + Homebrew::EnvConfig.no_auto_update? || + ((Time.now - stale_seconds) < target.mtime)) + skip_download ||= Homebrew.running_as_root_but_not_owned_by_root? + + json_data = begin + 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) + 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 + + mtime = insecure_download ? Time.new(1970, 1, 1) : Time.now + FileUtils.touch(target, mtime:) unless skip_download + JSON.parse(target.read, 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: Hash).returns(Hash) } + def self.merge_variations(json) + return json unless json.key?("variations") + + bottle_tag = ::Utils::Bottles::Tag.new(system: Homebrew::SimulateSystem.current_os, + arch: Homebrew::SimulateSystem.current_arch) + + if (variation = json.dig("variations", bottle_tag.to_s).presence) + json = json.merge(variation) + end + + json.except("variations") + 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.write(names.join("\n")) + return true + end + + false + end + + sig { params(json_data: Hash).returns([T::Boolean, T.any(String, Array, Hash)]) } + 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::Boolean) } + def self.internal_json_v3? + ENV["HOMEBREW_INTERNAL_JSON_V3"].present? + end + end + + sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) } + 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 { params(condition: T::Boolean, block: T.proc.returns(T.untyped)).returns(T.untyped) } + 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 index 87e57b1b0efa4..2bf4a62e49a33 100644 --- a/Library/Homebrew/api/analytics.rb +++ b/Library/Homebrew/api/analytics.rb @@ -1,22 +1,18 @@ -# typed: false +# typed: strict # frozen_string_literal: true module Homebrew module API # Helper functions for using the analytics JSON API. - # - # @api private module Analytics class << self - extend T::Sig - sig { returns(String) } def analytics_api_path "analytics" end alias generic_analytics_api_path analytics_api_path - sig { params(category: String, days: T.any(Integer, String)).returns(Hash) } + 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 diff --git a/Library/Homebrew/api/cask-source.rb b/Library/Homebrew/api/cask-source.rb deleted file mode 100644 index 0c1cd67a21849..0000000000000 --- a/Library/Homebrew/api/cask-source.rb +++ /dev/null @@ -1,29 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module Homebrew - module API - # Helper functions for using the cask source API. - # - # @api private - module CaskSource - class << self - extend T::Sig - - sig { params(token: String).returns(Hash) } - def fetch(token) - token = token.sub(%r{^homebrew/cask/}, "") - Homebrew::API.fetch "cask-source/#{token}.rb", json: false - end - - sig { params(token: String).returns(T::Boolean) } - def available?(token) - fetch token - true - rescue ArgumentError - false - end - end - end - end -end diff --git a/Library/Homebrew/api/cask.rb b/Library/Homebrew/api/cask.rb index b7ec658bf2382..03b94f848a3cb 100644 --- a/Library/Homebrew/api/cask.rb +++ b/Library/Homebrew/api/cask.rb @@ -1,19 +1,93 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "extend/cachable" +require "api/download" + module Homebrew module API # Helper functions for using the cask JSON API. - # - # @api private module Cask - class << self - extend T::Sig + extend Cachable + + DEFAULT_API_FILENAME = "cask.jws.json" + + private_class_method :cache + + sig { params(token: String).returns(Hash) } + def self.fetch(token) + Homebrew::API.fetch "cask/#{token}.json" + end + + sig { params(cask: ::Cask::Cask).returns(::Cask::Cask) } + def self.source_download(cask) + path = cask.ruby_source_path.to_s || "Casks/#{cask.token}.rb" + 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" + + download = Homebrew::API::Download.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", + ) + download.fetch + ::Cask::CaskLoader::FromPathLoader.new(download.symlink_location) + .load(config: cask.config) + end + + def self.cached_json_file_path + HOMEBREW_CACHE_API/DEFAULT_API_FILENAME + end + + sig { returns(T::Boolean) } + def self.download_and_cache_data! + json_casks, updated = Homebrew::API.fetch_json_api_file DEFAULT_API_FILENAME + + 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 - sig { params(name: String).returns(Hash) } - def fetch(name) - Homebrew::API.fetch "cask/#{name}.json" + [token, json_cask.except("token")] end + + updated + end + private_class_method :download_and_cache_data! + + sig { returns(T::Hash[String, Hash]) } + 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 { 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 diff --git a/Library/Homebrew/api/download.rb b/Library/Homebrew/api/download.rb new file mode 100644 index 0000000000000..e848daf328481 --- /dev/null +++ b/Library/Homebrew/api/download.rb @@ -0,0 +1,60 @@ +# typed: strict +# frozen_string_literal: true + +require "downloadable" + +module Homebrew + module API + class DownloadStrategy < CurlDownloadStrategy + sig { override.returns(Pathname) } + def symlink_location + cache/name + end + end + + class Download + 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::DownloadStrategy), URL) + @checksum = checksum + @mirrors = mirrors + @cache = cache + end + + sig { override.returns(API::DownloadStrategy) } + def downloader + T.cast(super, API::DownloadStrategy) + end + + sig { override.returns(String) } + def name + download_name + end + + sig { override.returns(String) } + def download_type + "API source" + end + + 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/formula.rb b/Library/Homebrew/api/formula.rb index a70fe31b8c653..0baaa94e9dd06 100644 --- a/Library/Homebrew/api/formula.rb +++ b/Library/Homebrew/api/formula.rb @@ -1,47 +1,141 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "extend/cachable" +require "api/download" + module Homebrew module API # Helper functions for using the formula JSON API. - # - # @api private module Formula - class << self - extend T::Sig + extend Cachable - sig { returns(String) } - def formula_api_path - "formula" - end - alias generic_formula_api_path formula_api_path + DEFAULT_API_FILENAME = "formula.jws.json" + INTERNAL_V3_API_FILENAME = "internal/v3/homebrew-core.jws.json" + + private_class_method :cache + + sig { params(name: String).returns(Hash) } + def self.fetch(name) + Homebrew::API.fetch "formula/#{name}.json" + end - sig { returns(String) } - def cached_formula_json_file - HOMEBREW_CACHE_API/"#{formula_api_path}.json" + sig { params(formula: ::Formula).returns(::Formula) } + def self.source_download(formula) + path = formula.ruby_source_path || "Formula/#{formula.name}.rb" + git_head = formula.tap_git_head || "HEAD" + tap = formula.tap&.full_name || "Homebrew/homebrew-core" + + download = Homebrew::API::Download.new( + "https://raw.githubusercontent.com/#{tap}/#{git_head}/#{path}", + formula.ruby_source_checksum, + cache: HOMEBREW_CACHE_API_SOURCE/"#{tap}/#{git_head}/Formula", + ) + download.fetch + + with_env(HOMEBREW_FORBID_PACKAGES_FROM_PATHS: nil) do + Formulary.factory(download.symlink_location, + formula.active_spec_sym, + alias_path: formula.alias_path, + flags: formula.class.build_flags) end + end - sig { params(name: String).returns(Hash) } - def fetch(name) - Homebrew::API.fetch "#{formula_api_path}/#{name}.json" + def self.cached_json_file_path + if Homebrew::API.internal_json_v3? + HOMEBREW_CACHE_API/INTERNAL_V3_API_FILENAME + else + HOMEBREW_CACHE_API/DEFAULT_API_FILENAME end + end + + sig { returns(T::Boolean) } + def self.download_and_cache_data! + if Homebrew::API.internal_json_v3? + json_formulae, updated = Homebrew::API.fetch_json_api_file INTERNAL_V3_API_FILENAME + overwrite_cache! T.cast(json_formulae, T::Hash[String, T.untyped]) + else + json_formulae, updated = Homebrew::API.fetch_json_api_file DEFAULT_API_FILENAME - sig { returns(Array) } - def all_formulae - @all_formulae ||= begin - curl_args = %w[--compressed --silent https://formulae.brew.sh/api/formula.json] - if cached_formula_json_file.exist? - last_modified = cached_formula_json_file.mtime.utc - last_modified = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT") - curl_args = ["--header", "If-Modified-Since: #{last_modified}", *curl_args] + 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 - curl_download(*curl_args, to: HOMEBREW_CACHE_API/"#{formula_api_path}.json", max_time: 5) - json_formulae = JSON.parse(cached_formula_json_file.read) + [json_formula["name"], json_formula.except("name")] + end + end - json_formulae.to_h do |json_formula| - [json_formula["name"], json_formula.except("name")] - end + updated + end + private_class_method :download_and_cache_data! + + sig { returns(T::Hash[String, Hash]) } + def self.all_formulae + unless cache.key?("formulae") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache["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["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["renames"] + end + + sig { returns(Hash) } + def self.tap_migrations + # Not sure that we need to reload here. + unless cache.key?("tap_migrations") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache["tap_migrations"] + end + + sig { returns(String) } + def self.tap_git_head + # Note sure we need to reload here. + unless cache.key?("tap_git_head") + json_updated = download_and_cache_data! + write_names_and_aliases(regenerate: json_updated) + end + + cache["tap_git_head"] + end + + sig { params(regenerate: T::Boolean).void } + def self.write_names_and_aliases(regenerate: false) + download_and_cache_data! unless cache.key?("formulae") + + return unless Homebrew::API.write_names_file(all_formulae.keys, "formula", regenerate:) + + (HOMEBREW_CACHE_API/"formula_aliases.txt").open("w") do |file| + all_aliases.each do |alias_name, real_name| + file.puts "#{alias_name}|#{real_name}" 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/versions.rb b/Library/Homebrew/api/versions.rb deleted file mode 100644 index e2758c4b9c13d..0000000000000 --- a/Library/Homebrew/api/versions.rb +++ /dev/null @@ -1,47 +0,0 @@ -# typed: true -# frozen_string_literal: true - -module Homebrew - module API - # Helper functions for using the versions JSON API. - # - # @api private - module Versions - class << self - extend T::Sig - - def formulae - # The result is cached by Homebrew::API.fetch - Homebrew::API.fetch "versions-formulae.json" - end - - def casks - # The result is cached by Homebrew::API.fetch - Homebrew::API.fetch "versions-casks.json" - end - - sig { params(name: String).returns(T.nilable(PkgVersion)) } - def latest_formula_version(name) - versions = formulae - return unless versions.key? name - - version = Version.new(versions[name]["version"]) - revision = versions[name]["revision"] - PkgVersion.new(version, revision) - end - - sig { params(token: String).returns(T.nilable(Version)) } - def latest_cask_version(token) - return unless casks.key? token - - version = if casks[token]["versions"].key? MacOS.version.to_sym.to_s - casks[token]["versions"][MacOS.version.to_sym.to_s] - else - casks[token]["version"] - end - Version.new(version) - end - end - end - end -end diff --git a/Library/Homebrew/ast_constants.rb b/Library/Homebrew/ast_constants.rb index c7a8fa88d73f7..18a80c4a00698 100644 --- a/Library/Homebrew/ast_constants.rb +++ b/Library/Homebrew/ast_constants.rb @@ -1,9 +1,9 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "macos_versions" +require "macos_version" -FORMULA_COMPONENT_PRECEDENCE_LIST = [ +FORMULA_COMPONENT_PRECEDENCE_LIST = T.let([ [{ name: :include, type: :method_call }], [{ name: :desc, type: :method_call }], [{ name: :homepage, type: :method_call }], @@ -24,17 +24,18 @@ [{ name: :keg_only, type: :method_call }], [{ name: :option, type: :method_call }], [{ name: :deprecated_option, type: :method_call }], - [{ name: :disable!, 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 }], - *MacOSVersions::SYMBOLS.keys.map do |os_name| - [{ name: :"on_#{os_name}", type: :block_call }] - end, [{ name: :conflicts_with, type: :method_call }], [{ name: :skip_clean, type: :method_call }], [{ name: :cxxstdlib_check, type: :method_call }], @@ -43,9 +44,11 @@ [{ name: :go_resource, type: :block_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 +].freeze, T::Array[[{ name: Symbol, type: Symbol }]]) diff --git a/Library/Homebrew/attestation.rb b/Library/Homebrew/attestation.rb new file mode 100644 index 0000000000000..00efbcad29ed0 --- /dev/null +++ b/Library/Homebrew/attestation.rb @@ -0,0 +1,280 @@ +# typed: strict +# frozen_string_literal: true + +require "date" +require "json" +require "utils/popen" +require "utils/github/api" +require "exceptions" +require "system_command" + +module Homebrew + module Attestation + extend SystemCommand::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 = T.let(DateTime.new(2024, 3, 14).freeze, DateTime) + + # 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 whether attestation verification is enabled. + # + # @api private + sig { returns(T::Boolean) } + def self.enabled? + return false if Homebrew::EnvConfig.no_verify_attestations? + return true if Homebrew::EnvConfig.verify_attestations? + return false if ENV.fetch("CI", false) + return false if OS.unsupported_configuration? + + # Always check credentials last to avoid unnecessary credential extraction. + (Homebrew::EnvConfig.developer? || Homebrew::EnvConfig.devcmdrun?) && GitHub::API.credentials.present? + 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.present? + + # 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. + with_env(HOMEBREW_NO_VERIFY_ATTESTATIONS: "1") do + @gh_executable = ensure_executable!("gh", reason: "verifying attestations", latest: true) + end + + T.must(@gh_executable) + 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 formulae.include?(Formula["gh"]) + [Formula["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[T.untyped, 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.status.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.status.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-build-provenance` 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[T.untyped, 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}" + + # 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. + path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name, + bottle.resource.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/attrable.rb b/Library/Homebrew/attrable.rb new file mode 100644 index 0000000000000..8dd176ef240a9 --- /dev/null +++ b/Library/Homebrew/attrable.rb @@ -0,0 +1,30 @@ +# typed: strict +# frozen_string_literal: true + +# This module provides methods to define specialized attributes. +# Method stubs are generated by the {Tapioca::Compilers::Attrables} compiler. +# @note The compiler is fragile, and must be updated if the filename changes, if methods are added or removed, +# or if a method's arity changes. +module Attrable + extend T::Helpers + + requires_ancestor { Module } + + sig { params(attrs: Symbol).void } + def attr_predicate(*attrs) + attrs.each do |attr| + define_method attr do + instance_variable_get("@#{attr.to_s.sub(/\?$/, "")}") == true + end + end + end + + sig { params(attrs: Symbol).void } + def attr_rw(*attrs) + attrs.each do |attr| + define_method attr do |val = nil| + val.nil? ? instance_variable_get(:"@#{attr}") : instance_variable_set(:"@#{attr}", val) + end + end + end +end diff --git a/Library/Homebrew/brew.rb b/Library/Homebrew/brew.rb index b514d0062158e..679abea0d245d 100644 --- a/Library/Homebrew/brew.rb +++ b/Library/Homebrew/brew.rb @@ -1,23 +1,20 @@ -# typed: false +# typed: strict # frozen_string_literal: true +# `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 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 std_trap = trap("INT") { exit! 130 } # no backtrace thanks -# check ruby version before requiring any modules. -REQUIRED_RUBY_X, REQUIRED_RUBY_Y, = ENV.fetch("HOMEBREW_REQUIRED_RUBY_VERSION").split(".").map(&:to_i) -RUBY_X, RUBY_Y, = RUBY_VERSION.split(".").map(&:to_i) -if RUBY_X < REQUIRED_RUBY_X || (RUBY_X == REQUIRED_RUBY_X && RUBY_Y < REQUIRED_RUBY_Y) - raise "Homebrew must be run under Ruby #{REQUIRED_RUBY_X}.#{REQUIRED_RUBY_Y}! " \ - "You're running #{RUBY_VERSION}." -end - require_relative "global" begin @@ -31,8 +28,8 @@ empty_argv = ARGV.empty? help_flag_list = %w[-h --help --usage -?] help_flag = !ENV["HOMEBREW_HELP"].nil? - help_cmd_index = nil - cmd = nil + help_cmd_index = T.let(nil, T.nilable(Integer)) + cmd = T.let(nil, T.nilable(String)) ARGV.each_with_index do |arg, i| break if help_flag && cmd @@ -42,6 +39,7 @@ help_flag = true help_cmd_index = i elsif !cmd && help_flag_list.exclude?(arg) + require "commands" cmd = ARGV.delete_at(i) cmd = Commands::HOMEBREW_INTERNAL_COMMAND_ALIASES.fetch(cmd, cmd) end @@ -50,7 +48,7 @@ ARGV.delete_at(help_cmd_index) if help_cmd_index require "cli/parser" - args = Homebrew::CLI::Parser.new.parse(ARGV.dup.freeze, ignore_invalid_options: true) + 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")) @@ -60,19 +58,18 @@ path.prepend(HOMEBREW_SHIMS_PATH/"shared") homebrew_path.prepend(HOMEBREW_SHIMS_PATH/"shared") - ENV["PATH"] = path + ENV["PATH"] = path.to_s require "commands" - require "settings" internal_cmd = Commands.valid_internal_cmd?(cmd) || Commands.valid_internal_dev_cmd?(cmd) if cmd unless internal_cmd # Add contributed commands to PATH before checking. - homebrew_path.append(Tap.cmd_directories) + homebrew_path.append(Commands.tap_cmd_directories) # External commands expect a normal PATH - ENV["PATH"] = homebrew_path + ENV["PATH"] = homebrew_path.to_s end # Usage instructions should be displayed if and only if one of: @@ -81,41 +78,68 @@ # - no arguments are passed if empty_argv || help_flag require "help" - Homebrew::Help.help cmd, remaining_args: args.remaining, empty_argv: empty_argv + Homebrew::Help.help cmd, remaining_args: args.remaining, empty_argv: # `Homebrew::Help.help` never returns, except for unknown commands. end if internal_cmd || Commands.external_ruby_v2_cmd_path(cmd) - if Commands::INSTALL_FROM_API_FORBIDDEN_COMMANDS.include?(cmd) && Homebrew::EnvConfig.install_from_api? - odie "This command cannot be run while HOMEBREW_INSTALL_FROM_API is set!" - end + cmd = T.must(cmd) + cmd_class = Homebrew::AbstractCommand.command(cmd) + Homebrew.running_command = cmd + if cmd_class + command_instance = cmd_class.new + + require "utils/analytics" + Utils::Analytics.report_command_run(command_instance) + command_instance.run + else + begin + Homebrew.public_send Commands.method_name(cmd) + rescue NoMethodError => e + case_error = "undefined method `#{cmd.downcase}' for module Homebrew" + odie "Unknown command: brew #{cmd}" if e.message == case_error - Homebrew.send Commands.method_name(cmd) + raise + end + end elsif (path = Commands.external_ruby_cmd_path(cmd)) + Homebrew.running_command = cmd require?(path) exit Homebrew.failed? ? 1 : 0 elsif Commands.external_cmd_path(cmd) %w[CACHE LIBRARY_PATH].each do |env| - ENV["HOMEBREW_#{env}"] = Object.const_get("HOMEBREW_#{env}").to_s + ENV["HOMEBREW_#{env}"] = Object.const_get(:"HOMEBREW_#{env}").to_s end exec "brew-#{cmd}", *ARGV else + require "tap" + possible_tap = OFFICIAL_CMD_TAPS.find { |_, cmds| cmds.include?(cmd) } possible_tap = Tap.fetch(possible_tap.first) if possible_tap - if !possible_tap || possible_tap.installed? || Tap.untapped_official_taps.include?(possible_tap.name) + if !possible_tap || + possible_tap.installed? || + (blocked_tap = Tap.untapped_official_taps.include?(possible_tap.name)) + if blocked_tap + onoe <<~EOS + `brew #{cmd}` is unavailable because #{possible_tap.name} was manually untapped. + Run `brew tap #{possible_tap.name}` to reenable `brew #{cmd}`. + EOS + end # Check for cask explicitly because it's very common in old guides odie "`brew cask` is no longer a `brew` command. Use `brew --cask` instead." if cmd == "cask" - odie "Unknown command: #{cmd}" + odie "Unknown command: brew #{cmd}" end # Unset HOMEBREW_HELP to avoid confusing the tap with_env HOMEBREW_HELP: nil do tap_commands = [] - cgroup = Utils.popen_read("cat", "/proc/1/cgroup") - if %w[azpl_job actions_job docker garden kubepods].none? { |container| cgroup.include?(container) } - brew_uid = HOMEBREW_BREW_FILE.stat.uid - tap_commands += %W[/usr/bin/sudo -u ##{brew_uid}] if Process.uid.zero? && !brew_uid.zero? + if (File.exist?("/.dockerenv") || + Homebrew.running_as_root? || + ((cgroup = Utils.popen_read("cat", "/proc/1/cgroup").presence) && + %w[azpl_job actions_job docker garden kubepods].none? { |type| cgroup.include?(type) })) && + Homebrew.running_as_root_but_not_owned_by_root? + tap_commands += %W[/usr/bin/sudo -u ##{Homebrew.owner_uid}] end quiet_arg = args.quiet? ? "--quiet" : nil tap_commands += [HOMEBREW_BREW_FILE, "tap", *quiet_arg, possible_tap.name] @@ -127,23 +151,42 @@ end rescue UsageError => e require "help" - Homebrew::Help.help cmd, remaining_args: args.remaining, usage_error: e.message + Homebrew::Help.help cmd, remaining_args: args&.remaining || [], usage_error: e.message rescue SystemExit => e - onoe "Kernel.exit" if args.debug? && !e.success? - $stderr.puts e.backtrace if args.debug? + 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(verbose: args.verbose?) - - if e.formula.head? || e.formula.deprecated? || e.formula.disabled? + e.dump(verbose: args&.verbose? || false) + + if OS.unsupported_configuration? + $stderr.puts "#{Tty.bold}Do not report this issue: you are running in an unsupported configuration.#{Tty.reset}" + elsif e.formula.head? || e.formula.deprecated? || e.formula.disabled? + reason = if e.formula.head? + "was built from an unstable upstream --HEAD" + elsif e.formula.deprecated? + "is deprecated" + elsif e.formula.disabled? + "is disabled" + end $stderr.puts <<~EOS - Please create pull requests instead of asking for help on Homebrew's GitHub, - Twitter or any other official channels. + #{e.formula.name}'s formula #{reason}. + This build failure is expected behaviour. + Do not create issues about this on Homebrew's GitHub repositories. + Any opened issues will be immediately closed without response. + Do not ask for help from Homebrew or its maintainers on social media. + You may ask for help in Homebrew's discussions but are unlikely to receive a response. + Try to figure out the problem yourself and submit a fix as a pull request. + We will review it but may or may not accept it. EOS + end exit 1 @@ -151,28 +194,34 @@ raise if e.message.empty? onoe e - $stderr.puts e.backtrace if args.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)}" + if args&.debug? || ARGV.include?("--debug") + require "utils/backtrace" + $stderr.puts Utils::Backtrace.clean(e) end - $stderr.puts e.backtrace if args.debug? + exit 1 rescue Exception => e # rubocop:disable Lint/RescueException onoe e - if internal_cmd && defined?(OS::ISSUES_URL) - if Homebrew::EnvConfig.no_auto_update? - $stderr.puts "#{Tty.bold}Do not report this issue until you've run `brew update` and tried again.#{Tty.reset}" - else - $stderr.puts "#{Tty.bold}Please report this issue:#{Tty.reset}" - $stderr.puts " #{Formatter.url(OS::ISSUES_URL)}" - end + + 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.unsupported_configuration? + $stderr.puts "#{Tty.bold}Do not report this issue: you are running in an unsupported configuration.#{Tty.reset}" + 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 "If reporting this issue please do so at (not Homebrew/brew or Homebrew/homebrew-core):" + $stderr.puts " #{Formatter.url(issues_url)}" + elsif internal_cmd + $stderr.puts "#{Tty.bold}Please report this issue:#{Tty.reset}" + $stderr.puts " #{Formatter.url(OS::ISSUES_URL)}" end - $stderr.puts e.backtrace + exit 1 else exit 1 if Homebrew.failed? diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index cbe08846a29ad..7c59fd881e8aa 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -1,47 +1,52 @@ ##### -##### First do the essential, fast things to be able to make e.g. brew --prefix and other commands we want to be -##### able to `source` in shell configuration quick. +##### 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. ##### -# Doesn't need a default case because we don't support other OSs -# shellcheck disable=SC2249 -HOMEBREW_PROCESSOR="$(uname -m)" -HOMEBREW_PHYSICAL_PROCESSOR="${HOMEBREW_PROCESSOR}" -HOMEBREW_SYSTEM="$(uname -s)" -case "${HOMEBREW_SYSTEM}" in - Darwin) HOMEBREW_MACOS="1" ;; - Linux) HOMEBREW_LINUX="1" ;; +case "${MACHTYPE}" in + arm64-*) + HOMEBREW_PROCESSOR="arm64" + ;; + x86_64-*) + HOMEBREW_PROCESSOR="x86_64" + ;; + *) + HOMEBREW_PROCESSOR="$(uname -m)" + ;; esac -if [[ "${HOMEBREW_MACOS}" == "1" ]] && - [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == "1" ]] -then - # used in vendor-install.sh - # shellcheck disable=SC2034 - HOMEBREW_PHYSICAL_PROCESSOR="arm64" - HOMEBREW_ROSETTA="$(sysctl -n sysctl.proc_translated)" - - # If we're running under macOS Rosetta 2, and it was requested by setting - # HOMEBREW_CHANGE_ARCH_TO_ARM (for example in CI), then we re-exec this - # same file under the native architecture - # These variables are set from the user environment. - # shellcheck disable=SC2154 - if [[ "${HOMEBREW_CHANGE_ARCH_TO_ARM}" == "1" ]] && - [[ "${HOMEBREW_ROSETTA}" == "1" ]] - then - exec arch -arm64e "${HOMEBREW_BREW_FILE}" "$@" - fi -fi +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}" -# Where we store built products; a Cellar in HOMEBREW_PREFIX (often /usr/local -# for bottles) unless there's already a Cellar in HOMEBREW_REPOSITORY. -# These variables are set by bin/brew -# shellcheck disable=SC2154 -if [[ -d "${HOMEBREW_REPOSITORY}/Cellar" ]] +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_CELLAR="${HOMEBREW_REPOSITORY}/Cellar" + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_MACOS_ARM_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_MACOS_ARM_DEFAULT_REPOSITORY}" +elif [[ -n "${HOMEBREW_LINUX}" ]] +then + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_LINUX_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_LINUX_DEFAULT_REPOSITORY}" else - HOMEBREW_CELLAR="${HOMEBREW_PREFIX}/Cellar" + HOMEBREW_DEFAULT_PREFIX="${HOMEBREW_GENERIC_DEFAULT_PREFIX}" + HOMEBREW_DEFAULT_REPOSITORY="${HOMEBREW_GENERIC_DEFAULT_REPOSITORY}" fi if [[ -n "${HOMEBREW_MACOS}" ]] @@ -49,21 +54,93 @@ then HOMEBREW_DEFAULT_CACHE="${HOME}/Library/Caches/Homebrew" HOMEBREW_DEFAULT_LOGS="${HOME}/Library/Logs/Homebrew" HOMEBREW_DEFAULT_TEMP="/private/tmp" + + HOMEBREW_MACOS_VERSION="$(/usr/bin/sw_vers -productVersion)" + + IFS=. read -r -a MACOS_VERSION_ARRAY <<<"${HOMEBREW_MACOS_VERSION}" + printf -v HOMEBREW_MACOS_VERSION_NUMERIC "%02d%02d%02d" "${MACOS_VERSION_ARRAY[@]}" + + unset MACOS_VERSION_ARRAY else - CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}" + CACHE_HOME="${HOMEBREW_XDG_CACHE_HOME:-${HOME}/.cache}" HOMEBREW_DEFAULT_CACHE="${CACHE_HOME}/Homebrew" HOMEBREW_DEFAULT_LOGS="${CACHE_HOME}/Homebrew/Logs" HOMEBREW_DEFAULT_TEMP="/tmp" 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. +# These variables are set by bin/brew +# shellcheck disable=SC2154 +if [[ -d "${HOMEBREW_REPOSITORY}/Cellar" ]] +then + HOMEBREW_CELLAR="${HOMEBREW_REPOSITORY}/Cellar" +else + HOMEBREW_CELLAR="${HOMEBREW_PREFIX}/Cellar" +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}}" -# Don't need to handle a default case. +# commands that take a single or no arguments. # HOMEBREW_LIBRARY set by bin/brew -# shellcheck disable=SC2249,SC2154 -case "$*" in +# 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 + ;; + setup-ruby) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/setup-ruby.sh" + shift + homebrew-setup-ruby "$1" + exit 0 + ;; +esac + +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 @@ -73,130 +150,51 @@ case "$*" in exit 0 ;; --caskroom) - echo "${HOMEBREW_PREFIX}/Caskroom" + echo "${HOMEBREW_CASKROOM}" exit 0 ;; --cache) echo "${HOMEBREW_CACHE}" exit 0 ;; - shellenv) - source "${HOMEBREW_LIBRARY}/Homebrew/cmd/shellenv.sh" - homebrew-shellenv - 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 ;; - formulae) - source "${HOMEBREW_LIBRARY}/Homebrew/cmd/formulae.sh" - homebrew-formulae - 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 ;; - casks) - source "${HOMEBREW_LIBRARY}/Homebrew/cmd/casks.sh" - homebrew-casks + # 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/prefix.rb on a non-zero return - --prefix*) - source "${HOMEBREW_LIBRARY}/Homebrew/prefix.sh" - homebrew-prefix "$@" && exit 0 + # falls back to cmd/help.rb on a non-zero return + help | --help | -h | --usage | "-?" | "") + homebrew-help "$@" && exit 0 ;; esac ##### ##### Next, define all helper functions. ##### - -# These variables are set from the user environment. -# shellcheck disable=SC2154 -ohai() { - # Check whether stdout is a tty. - if [[ -n "${HOMEBREW_COLOR}" || (-t 1 && -z "${HOMEBREW_NO_COLOR}") ]] - then - echo -e "\\033[34m==>\\033[0m \\033[1m$*\\033[0m" # blue arrow and bold text - else - echo "==> $*" - fi -} - -opoo() { - # Check whether stderr is a tty. - if [[ -n "${HOMEBREW_COLOR}" || (-t 2 && -z "${HOMEBREW_NO_COLOR}") ]] - then - echo -ne "\\033[4;33mWarning\\033[0m: " >&2 # highlight Warning with underline and yellow color - else - echo -n "Warning: " >&2 - fi - if [[ $# -eq 0 ]] - then - cat >&2 - else - echo "$*" >&2 - fi -} - -bold() { - # Check whether stderr is a tty. - if [[ -n "${HOMEBREW_COLOR}" || (-t 2 && -z "${HOMEBREW_NO_COLOR}") ]] - then - echo -e "\\033[1m""$*""\\033[0m" - else - echo "$*" - fi -} - -onoe() { - # Check whether stderr is a tty. - if [[ -n "${HOMEBREW_COLOR}" || (-t 2 && -z "${HOMEBREW_NO_COLOR}") ]] - then - echo -ne "\\033[4;31mError\\033[0m: " >&2 # highlight Error with underline and red color - else - echo -n "Error: " >&2 - fi - if [[ $# -eq 0 ]] - then - cat >&2 - else - echo "$*" >&2 - fi -} - -odie() { - onoe "$@" - exit 1 -} - -safe_cd() { - cd "$@" >/dev/null || odie "Failed to cd to $*!" -} - -brew() { - "${HOMEBREW_BREW_FILE}" "$@" -} - -curl() { - "${HOMEBREW_LIBRARY}/Homebrew/shims/shared/curl" "$@" -} - -git() { - "${HOMEBREW_LIBRARY}/Homebrew/shims/shared/git" "$@" -} - -# Search given executable in PATH (remove dependency for `which` command) -which() { - # Alias to Bash built-in command `type -P` - type -P "$@" -} - -numeric() { - # Condense the exploded argument into a single return value. - # shellcheck disable=SC2086,SC2183 - printf "%01d%02d%02d%03d" ${1//[.rc]/ } 2>/dev/null -} +source "${HOMEBREW_LIBRARY}/Homebrew/utils/helpers.sh" check-run-command-as-root() { - [[ "$(id -u)" == 0 ]] || return + [[ "${EUID}" == 0 || "${UID}" == 0 ]] || return - # Allow Azure Pipelines/GitHub Actions/Docker/Concourse/Kubernetes to do everything as root (as it's normal there) + # 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 # Homebrew Services may need `sudo` for system-wide daemons. @@ -219,26 +217,23 @@ check-prefix-is-not-tmpdir() { then odie <&2 - if [[ -z "${HOMEBREW_NO_ENV_HINTS}" && -z "${HOMEBREW_AUTO_UPDATE_SECS}" ]] +# NOTE: The members of the array in the second arg must not have spaces! +check-array-membership() { + local item=$1 + shift + + if [[ " ${*} " == *" ${item} "* ]] 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. Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).' >&2 + return 0 + else + return 1 fi } @@ -254,49 +249,90 @@ auto-update() { # If we've checked for updates, we don't need to check again. export HOMEBREW_AUTO_UPDATE_CHECKED="1" - if [[ "${HOMEBREW_COMMAND}" == "install" ]] || - [[ "${HOMEBREW_COMMAND}" == "upgrade" ]] || - [[ "${HOMEBREW_COMMAND}" == "bump-formula-pr" ]] || - [[ "${HOMEBREW_COMMAND}" == "bump-cask-pr" ]] || - [[ "${HOMEBREW_COMMAND}" == "bundle" ]] || - [[ "${HOMEBREW_COMMAND}" == "release" ]] || - [[ "${HOMEBREW_COMMAND}" == "tap" && "${HOMEBREW_ARG_COUNT}" -gt 1 ]] + if [[ -n "${HOMEBREW_AUTO_UPDATE_COMMAND}" ]] then export HOMEBREW_AUTO_UPDATING="1" - if [[ -z "${HOMEBREW_AUTO_UPDATE_SECS}" ]] + # 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 - HOMEBREW_AUTO_UPDATE_SECS="300" + for arg in "$@" + do + if [[ "${arg}" == */*/* ]] && [[ "${arg}" != Homebrew/* ]] && [[ "${arg}" != homebrew/* ]] + then + + HOMEBREW_AUTO_UPDATE_TAP="1" + break + fi + done fi - # Skip auto-update if the repository has been updated in the - # last $HOMEBREW_AUTO_UPDATE_SECS. - repo_fetch_head="${HOMEBREW_REPOSITORY}/.git/FETCH_HEAD" - if [[ -f "${repo_fetch_head}" ]] && - [[ -n "$(find "${repo_fetch_head}" -type f -mtime -"${HOMEBREW_AUTO_UPDATE_SECS}"s 2>/dev/null)" ]] + if [[ -z "${HOMEBREW_AUTO_UPDATE_SECS}" ]] then - return + 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 - if [[ -z "${HOMEBREW_VERBOSE}" ]] + 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 - auto-update-timer & - timer_pid=$! + 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 - brew update --auto-update - - if [[ -n "${timer_pid}" ]] + # 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 - kill "${timer_pid}" 2>/dev/null - wait "${timer_pid}" 2>/dev/null + return fi + brew update --auto-update + unset HOMEBREW_AUTO_UPDATING + unset HOMEBREW_AUTO_UPDATE_TAP # exec a new process to set any new environment variables. exec "${HOMEBREW_BREW_FILE}" "$@" 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 } ##### @@ -347,17 +383,22 @@ then odie "Cowardly refusing to continue at this prefix: ${HOMEBREW_PREFIX}" fi -# Many Pathname operations use getwd when they shouldn't, and then throw -# odd exceptions. Reduce our support burden by showing a user-friendly error. -if [[ ! -d "$(pwd)" ]] -then - odie "The current working directory doesn't exist, cannot proceed." -fi - ##### ##### Now, do everything else (that may be a bit slower). ##### +# Docker image deprecation +if [[ -f "${HOMEBREW_REPOSITORY}/.docker-deprecate" ]] +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)}" @@ -399,12 +440,59 @@ setup_git() { setup_curl setup_git -HOMEBREW_VERSION="$("${HOMEBREW_GIT}" -C "${HOMEBREW_REPOSITORY}" describe --tags --dirty --abbrev=7 2>/dev/null)" +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 + read -r GIT_HEAD 2>/dev/null <"${HOMEBREW_REPOSITORY}/.git/HEAD" + if [[ "${GIT_HEAD}" == "ref: refs/heads/master" ]] + then + read -r GIT_REVISION 2>/dev/null <"${HOMEBREW_REPOSITORY}/.git/refs/heads/master" + 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 [[ -n "${GIT_REVISION}" ]] +then + 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_USER_AGENT_VERSION="${HOMEBREW_VERSION}" if [[ -z "${HOMEBREW_VERSION}" ]] then - HOMEBREW_VERSION=">=2.5.0 (shallow or no git repository)" - HOMEBREW_USER_AGENT_VERSION="2.X.Y" + 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" @@ -412,7 +500,17 @@ HOMEBREW_CORE_REPOSITORY="${HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-core" # shellcheck disable=SC2034 HOMEBREW_CASK_REPOSITORY="${HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask" -case "$*" in +# 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 @@ -420,18 +518,40 @@ case "$*" in ;; esac +# 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) +# and, if needed: +# - MacOSVersion::SYMBOLS +HOMEBREW_MACOS_NEWEST_UNSUPPORTED="16" +# TODO: bump version when new macOS is released and update references in: +# - docs/Installation.md +# - HOMEBREW_MACOS_OLDEST_SUPPORTED in .github/workflows/pkg-installer.yml +# - `os-version min` in package/Distribution.xml +# - https://github.com/Homebrew/install/blob/HEAD/install.sh +HOMEBREW_MACOS_OLDEST_SUPPORTED="13" +HOMEBREW_MACOS_OLDEST_ALLOWED="10.11" + if [[ -n "${HOMEBREW_MACOS}" ]] then HOMEBREW_PRODUCT="Homebrew" HOMEBREW_SYSTEM="Macintosh" [[ "${HOMEBREW_PROCESSOR}" == "x86_64" ]] && HOMEBREW_PROCESSOR="Intel" - HOMEBREW_MACOS_VERSION="$(/usr/bin/sw_vers -productVersion)" # 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}" - # 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 + # shellcheck disable=SC2034 + HOMEBREW_PHYSICAL_PROCESSOR="arm64" + fi + + IFS=. read -r -a MACOS_VERSION_ARRAY <<<"${HOMEBREW_MACOS_OLDEST_ALLOWED}" + printf -v HOMEBREW_MACOS_OLDEST_ALLOWED_NUMERIC "%02d%02d%02d" "${MACOS_VERSION_ARRAY[@]}" + + unset MACOS_VERSION_ARRAY # Don't include minor versions for Big Sur and later. if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -gt "110000" ]] @@ -442,7 +562,7 @@ then fi # Refuse to run on pre-El Capitan - if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "101100" ]] + 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" ]] @@ -466,7 +586,14 @@ then HOMEBREW_FORCE_BREWED_CA_CERTIFICATES="1" fi + # TEMP: backwards compatiblity with existing 10.11-cross image + # Can (probably) be removed in March 2024. if [[ -n "${HOMEBREW_FAKE_EL_CAPITAN}" ]] + then + export HOMEBREW_FAKE_MACOS="10.11.6" + fi + + if [[ "${HOMEBREW_FAKE_MACOS}" =~ ^10\.11(\.|$) ]] then # We only need this to work enough to update brew and build the set portable formulae, so relax the requirement. HOMEBREW_MINIMUM_GIT_VERSION="2.7.4" @@ -478,44 +605,25 @@ then HOMEBREW_FORCE_BREWED_GIT="1" fi fi - - # Set a variable when the macOS system Ruby is new enough to avoid spawning - # a Ruby process unnecessarily. - if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "120000" ]] - 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_PRODUCT="${HOMEBREW_SYSTEM}brew" - [[ -n "${HOMEBREW_LINUX}" ]] && HOMEBREW_OS_VERSION="$(lsb_release -s -d 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}" - # This is set by the user environment. - # shellcheck disable=SC2154 - if [[ -n "${HOMEBREW_ON_DEBIAN7}" ]] - then - # Special version for our debian 7 docker container used to build binutils - HOMEBREW_MINIMUM_CURL_VERSION="7.25.0" - HOMEBREW_SYSTEM_CA_CERTIFICATES_TOO_OLD="1" - HOMEBREW_FORCE_BREWED_CA_CERTIFICATES="1" - else - # Ensure the system Curl is a version that supports modern HTTPS certificates. - HOMEBREW_MINIMUM_CURL_VERSION="7.41.0" - fi + # Ensure the system Curl is a version that supports modern HTTPS certificates. + HOMEBREW_MINIMUM_CURL_VERSION="7.41.0" + curl_version_output="$(${HOMEBREW_CURL} --version 2>/dev/null)" curl_name_and_version="${curl_version_output%% (*}" - # shellcheck disable=SC2248 if [[ "$(numeric "${curl_name_and_version##* }")" -lt "$(numeric "${HOMEBREW_MINIMUM_CURL_VERSION}")" ]] then 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})" +Your curl executable: $(type -p "${HOMEBREW_CURL}")" if [[ -z ${HOMEBREW_CURL_PATH} ]] then @@ -538,13 +646,12 @@ Your curl executable: $(type -p ${HOMEBREW_CURL})" # $extra is intentionally discarded. # shellcheck disable=SC2034 IFS='.' read -r major minor micro build extra <<<"${git_version_output##* }" - # shellcheck disable=SC2248 if [[ "$(numeric "${major}.${minor}.${micro}.${build}")" -lt "$(numeric "${HOMEBREW_MINIMUM_GIT_VERSION}")" ]] then 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})" +Your Git executable: $(unset git && type -p "${HOMEBREW_GIT}")" if [[ -z ${HOMEBREW_GIT_PATH} ]] then HOMEBREW_FORCE_BREWED_GIT="1" @@ -559,7 +666,6 @@ Your Git executable: $(unset git && type -p ${HOMEBREW_GIT})" fi HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION="2.13" - unset HOMEBREW_MACOS_SYSTEM_RUBY_NEW_ENOUGH HOMEBREW_CORE_REPOSITORY_ORIGIN="$("${HOMEBREW_GIT}" -C "${HOMEBREW_CORE_REPOSITORY}" remote get-url origin 2>/dev/null)" if [[ "${HOMEBREW_CORE_REPOSITORY_ORIGIN}" =~ (/linuxbrew|Linuxbrew/homebrew)-core(\.git)?$ ]] @@ -598,6 +704,7 @@ then unset HOMEBREW_BOTTLE_DOMAIN fi +HOMEBREW_API_DEFAULT_DOMAIN="https://formulae.brew.sh/api" HOMEBREW_BOTTLE_DEFAULT_DOMAIN="https://ghcr.io/v2/homebrew/core" HOMEBREW_USER_AGENT="${HOMEBREW_PRODUCT}/${HOMEBREW_USER_AGENT_VERSION} (${HOMEBREW_SYSTEM}; ${HOMEBREW_PROCESSOR} ${HOMEBREW_OS_USER_AGENT_VERSION})" @@ -605,7 +712,21 @@ 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// //}" +# 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 @@ -613,6 +734,7 @@ 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 @@ -623,15 +745,21 @@ 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_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_MACOS_SYSTEM_RUBY_NEW_ENOUGH +export HOMEBREW_CURL_SPEED_LIMIT +export HOMEBREW_CURL_SPEED_TIME if [[ -n "${HOMEBREW_MACOS}" && -x "/usr/bin/xcode-select" ]] then @@ -652,6 +780,7 @@ EOS # a popup window asking the user to install the CLT if [[ -n "${XCODE_SELECT_PATH}" ]] then + # TODO: this is fairly slow, figure out if there's a faster way. XCRUN_OUTPUT="$(/usr/bin/xcrun clang 2>&1)" XCRUN_STATUS="$?" @@ -665,13 +794,6 @@ EOS fi fi -if [[ "$1" == "-v" ]] -then - # Shift the -v to the end of the parameter list - shift - set -- "$@" -v -fi - for arg in "$@" do [[ "${arg}" == "--" ]] && break @@ -686,6 +808,10 @@ done HOMEBREW_ARG_COUNT="$#" HOMEBREW_COMMAND="$1" shift +# If you are going to change anything in below case statement, +# be sure to also update HOMEBREW_INTERNAL_COMMAND_ALIASES hash in commands.rb +# doesn't need a default case as other arguments handled elsewhere. +# shellcheck disable=SC2249 case "${HOMEBREW_COMMAND}" in ls) HOMEBREW_COMMAND="list" ;; homepage) HOMEBREW_COMMAND="home" ;; @@ -694,6 +820,7 @@ case "${HOMEBREW_COMMAND}" in ln) HOMEBREW_COMMAND="link" ;; instal) HOMEBREW_COMMAND="install" ;; # gem does the same uninstal) HOMEBREW_COMMAND="uninstall" ;; + post_install) HOMEBREW_COMMAND="postinstall" ;; rm) HOMEBREW_COMMAND="uninstall" ;; remove) HOMEBREW_COMMAND="uninstall" ;; abv) HOMEBREW_COMMAND="info" ;; @@ -702,6 +829,8 @@ case "${HOMEBREW_COMMAND}" in environment) HOMEBREW_COMMAND="--env" ;; --config) HOMEBREW_COMMAND="config" ;; -v) HOMEBREW_COMMAND="--version" ;; + lc) HOMEBREW_COMMAND="livecheck" ;; + tc) HOMEBREW_COMMAND="typecheck" ;; esac # Set HOMEBREW_DEV_CMD_RUN for users who have run a development command. @@ -719,8 +848,50 @@ then unset HOMEBREW_RUBY_WARNINGS fi -# Disable Ruby options we don't need. -RUBY_DISABLE_OPTIONS="--disable=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 + +# 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 + unset HOMEBREW_AUTO_UPDATE_CORE_TAP +fi + +# 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 + unset HOMEBREW_AUTO_UPDATE_CASK_TAP +fi if [[ -z "${HOMEBREW_RUBY_WARNINGS}" ]] then @@ -742,27 +913,43 @@ fi export HOMEBREW_CORE_GIT_REMOTE # 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 export HOMEBREW_DEVELOPER_COMMAND="1" fi +# Provide a (temporary, undocumented) way to disable Sorbet globally if needed +# to avoid reverting the above. +if [[ -n "${HOMEBREW_NO_SORBET_RUNTIME}" ]] +then + unset HOMEBREW_SORBET_RUNTIME +fi + if [[ -n "${HOMEBREW_DEVELOPER_COMMAND}" && -z "${HOMEBREW_DEVELOPER}" ]] then if [[ -z "${HOMEBREW_DEV_CMD_RUN}" ]] then - message="$(bold "${HOMEBREW_COMMAND}") is a developer command, so -Homebrew's developer mode has been automatically turned on. -To turn developer mode off, run $(bold "brew developer off") -" - opoo "${message}" + opoo </dev/null export HOMEBREW_DEV_CMD_RUN="1" fi +if [[ -n "${HOMEBREW_DEVELOPER}" || -n "${HOMEBREW_DEV_CMD_RUN}" ]] +then + # Always run with Sorbet for Homebrew developers or when a Homebrew developer command has been run. + export HOMEBREW_SORBET_RUNTIME="1" +fi + if [[ -f "${HOMEBREW_LIBRARY}/Homebrew/cmd/${HOMEBREW_COMMAND}.sh" ]] then HOMEBREW_BASH_COMMAND="${HOMEBREW_LIBRARY}/Homebrew/cmd/${HOMEBREW_COMMAND}.sh" @@ -775,17 +962,16 @@ check-run-command-as-root check-prefix-is-not-tmpdir -# shellcheck disable=SC2250 if [[ "${HOMEBREW_PREFIX}" == "/usr/local" ]] && [[ "${HOMEBREW_PREFIX}" != "${HOMEBREW_REPOSITORY}" ]] && [[ "${HOMEBREW_CELLAR}" == "${HOMEBREW_REPOSITORY}/Cellar" ]] then cat >&2 < e # rubocop:disable Lint/RescueException error_hash = JSON.parse e.to_json @@ -230,12 +238,12 @@ 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. - case error_hash["json_class"] - when "BuildError" + case e + when BuildError error_hash["cmd"] = e.cmd error_hash["args"] = e.args error_hash["env"] = e.env - when "ErrorDuringExecution" + when ErrorDuringExecution error_hash["cmd"] = e.cmd error_hash["status"] = if e.status.is_a?(Process::Status) { diff --git a/Library/Homebrew/build_environment.rb b/Library/Homebrew/build_environment.rb index 2392b98d5cc52..10fecf1579ba9 100644 --- a/Library/Homebrew/build_environment.rb +++ b/Library/Homebrew/build_environment.rb @@ -1,12 +1,8 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true # Settings for the build environment. -# -# @api private class BuildEnvironment - extend T::Sig - sig { params(settings: Symbol).void } def initialize(*settings) @settings = Set.new(settings) @@ -18,9 +14,9 @@ def merge(*args) self end - sig { params(o: Symbol).returns(T.self_type) } - def <<(o) - @settings << o + sig { params(option: Symbol).returns(T.self_type) } + def <<(option) + @settings << option self end @@ -31,11 +27,17 @@ def std? # DSL for specifying build environment settings. module DSL - extend T::Sig + # 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. + def inherited(child) + super + child.instance_eval do + @env = BuildEnvironment.new + end + end sig { params(settings: Symbol).returns(BuildEnvironment) } def env(*settings) - @env ||= BuildEnvironment.new @env.merge(settings) end end @@ -55,25 +57,26 @@ def env(*settings) ].freeze private_constant :KEYS - sig { params(env: T.untyped).returns(T::Array[String]) } + sig { params(env: T::Hash[String, T.nilable(T.any(String, Pathname))]).returns(T::Array[String]) } def self.keys(env) KEYS & env.keys end - sig { params(env: T.untyped, f: IO).void } - def self.dump(env, f = $stdout) + sig { params(env: T::Hash[String, T.nilable(T.any(String, Pathname))], 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.fetch(key) - s = +"#{key}: #{value}" + + string = "#{key}: #{value}" case key when "CC", "CXX", "LD" - s << " => #{Pathname.new(value).realpath}" if File.symlink?(value) + 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 5f173ad2d2fe8..1baf70938e723 100644 --- a/Library/Homebrew/build_options.rb +++ b/Library/Homebrew/build_options.rb @@ -1,27 +1,34 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true # Options for a formula build. -# -# @api private class BuildOptions - # @private def initialize(args, options) @args = args @options = options 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 + # ``` def with?(val) option_names = val.respond_to?(:option_names) ? val.option_names : [val] @@ -29,7 +36,7 @@ def with?(val) if option_defined? "with-#{name}" include? "with-#{name}" elsif option_defined? "without-#{name}" - !include? "without-#{name}" # rubocop:disable Rails/NegateInclude + !include? "without-#{name}" else false end @@ -37,7 +44,12 @@ 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" + # ``` def without?(val) !with?(val) end @@ -48,19 +60,33 @@ def 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.
+  #
+  # ### 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
+ # end + # ``` def head? include? "HEAD" end # True if a {Formula} is being built with {Formula.stable} instead of {Formula.head}. # This is the default. - #
args << "--some-beta" if build.head?
+ # + # ### Example + # + # ```ruby + # args << "--some-beta" if build.head? + # ``` def stable? !head? end @@ -70,12 +96,10 @@ def any_args_or_options? !@args.empty? || !@options.empty? end - # @private def used_options @options & @args end - # @private def unused_options @options - @args end diff --git a/Library/Homebrew/bump_version_parser.rb b/Library/Homebrew/bump_version_parser.rb new file mode 100644 index 0000000000000..40c23e4b0de20 --- /dev/null +++ b/Library/Homebrew/bump_version_parser.rb @@ -0,0 +1,59 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + # Class handling architecture-specific version information. + class BumpVersionParser + 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? + raise UsageError, "`--version-arm` must not be empty." if arm.blank? + raise UsageError, "`--version-intel` must not be empty." if 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) + 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.untyped).returns(T::Boolean) } + def ==(other) + return false unless other.is_a?(BumpVersionParser) + + (general == other.general) && (arm == other.arm) && (intel == other.intel) + end + end +end diff --git a/Library/Homebrew/bundle_version.rb b/Library/Homebrew/bundle_version.rb index 92c522659a1f5..065e3098ba61c 100644 --- a/Library/Homebrew/bundle_version.rb +++ b/Library/Homebrew/bundle_version.rb @@ -1,15 +1,11 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "system_command" module Homebrew # Representation of a macOS bundle version, commonly found in `Info.plist` files. - # - # @api private class BundleVersion - extend T::Sig - include Comparable extend SystemCommand::Mixin @@ -52,6 +48,9 @@ def self.from_package_info(package_info_path) 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 = short_version.presence @version = version.presence @@ -61,8 +60,26 @@ def initialize(short_version, version) end def <=>(other) - [version, short_version].map { |v| v&.yield_self(&Version.public_method(:new)) || Version::NULL } <=> - [other.version, other.short_version].map { |v| v&.yield_self(&Version.public_method(:new)) || Version::NULL } + 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 def ==(other) @@ -81,8 +98,6 @@ def nice_parts short_version = self.short_version version = self.version - short_version = short_version&.delete_suffix("(#{version})") if version - return [T.must(short_version)] if short_version == version if short_version && version diff --git a/Library/Homebrew/cache_store.rb b/Library/Homebrew/cache_store.rb index e28795d4bf557..11426ef5eac45 100644 --- a/Library/Homebrew/cache_store.rb +++ b/Library/Homebrew/cache_store.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "json" @@ -60,7 +60,7 @@ def get(key) 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). def delete(key) return unless created? @@ -68,6 +68,14 @@ def delete(key) db.delete(key) end + # Deletes all content from the underlying database (if it already exists). + def clear! + return unless created? + + dirty! + db.clear + end + # Closes the underlying database (if it is created and open). def write_if_dirty! return unless dirty? diff --git a/Library/Homebrew/cask.rb b/Library/Homebrew/cask.rb index 2f9df69d02336..9bb3b7444a1b8 100644 --- a/Library/Homebrew/cask.rb +++ b/Library/Homebrew/cask.rb @@ -8,7 +8,6 @@ require "cask/cask_loader" require "cask/cask" require "cask/caskroom" -require "cask/cmd" require "cask/config" require "cask/exceptions" require "cask/denylist" @@ -17,8 +16,10 @@ 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/artifact.rb b/Library/Homebrew/cask/artifact.rb index bee610cfb5702..eab26b9f4334f 100644 --- a/Library/Homebrew/cask/artifact.rb +++ b/Library/Homebrew/cask/artifact.rb @@ -11,6 +11,7 @@ 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" @@ -29,8 +30,6 @@ module Cask # Module containing all cask artifact classes. - # - # @api private module Artifact end end diff --git a/Library/Homebrew/cask/artifact/abstract_artifact.rb b/Library/Homebrew/cask/artifact/abstract_artifact.rb index 5c1abb3ffd40a..fa8057335c955 100644 --- a/Library/Homebrew/cask/artifact/abstract_artifact.rb +++ b/Library/Homebrew/cask/artifact/abstract_artifact.rb @@ -1,33 +1,37 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" +require "extend/object/deep_dup" + module Cask module Artifact # Abstract superclass for all artifacts. - # - # @api private class AbstractArtifact - extend T::Sig + extend T::Helpers + abstract! include Comparable - extend Predicable def self.english_name - @english_name ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1 \2') + @english_name ||= T.must(name).sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1 \2') end def self.english_article - @english_article ||= (english_name =~ /^[aeiou]/i) ? "an" : "a" + @english_article ||= /^[aeiou]/i.match?(english_name) ? "an" : "a" end def self.dsl_key - @dsl_key ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym + @dsl_key ||= T.must(name).sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym end def self.dirmethod - @dirmethod ||= "#{dsl_key}dir".to_sym + @dirmethod ||= :"#{dsl_key}dir" end + sig { abstract.returns(String) } + def summarize; end + def staged_path_join_executable(path) path = Pathname(path) path = path.expand_path if path.to_s.start_with?("~") @@ -75,6 +79,7 @@ def <=>(other) Service, InputMethod, InternetPlugin, + KeyboardLayout, AudioUnitPlugin, VstPlugin, Vst3Plugin, @@ -97,7 +102,12 @@ def self.read_script_arguments(arguments, stanza, default_arguments = {}, overri description = key ? "#{stanza} #{key.inspect}" : stanza.to_s # backward-compatible string value - arguments = { executable: arguments } if arguments.is_a?(String) + arguments = if arguments.is_a?(String) + { executable: arguments } + else + # Avoid mutating the original argument + arguments.dup + end # key sanity permitted_keys = [:args, :input, :executable, :must_succeed, :sudo, :print_stdout, :print_stderr] @@ -127,8 +137,9 @@ def self.read_script_arguments(arguments, stanza, default_arguments = {}, overri attr_reader :cask - def initialize(cask) + def initialize(cask, *dsl_args) @cask = cask + @dsl_args = dsl_args.deep_dup end def config @@ -139,6 +150,10 @@ def config def to_s "#{summarize} (#{self.class.english_name})" end + + 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 1695a257a5e5c..497f1afdf1b70 100644 --- a/Library/Homebrew/cask/artifact/abstract_flight_block.rb +++ b/Library/Homebrew/cask/artifact/abstract_flight_block.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/abstract_artifact" @@ -6,8 +6,6 @@ module Cask module Artifact # Abstract superclass for block artifacts. - # - # @api private class AbstractFlightBlock < AbstractArtifact def self.dsl_key super.to_s.sub(/_block$/, "").to_sym @@ -32,6 +30,10 @@ def uninstall_phase(**) abstract_phase(self.class.uninstall_dsl_key) end + def summarize + directives.keys.map(&:to_s).join(", ") + end + private def class_for_dsl_key(dsl_key) @@ -44,10 +46,6 @@ def abstract_phase(dsl_key) class_for_dsl_key(dsl_key).new(cask).instance_eval(&block) end - - def summarize - directives.keys.map(&:to_s).join(", ") - end end end end diff --git a/Library/Homebrew/cask/artifact/abstract_uninstall.rb b/Library/Homebrew/cask/artifact/abstract_uninstall.rb index 2c8489828ce84..2242b7961a72c 100644 --- a/Library/Homebrew/cask/artifact/abstract_uninstall.rb +++ b/Library/Homebrew/cask/artifact/abstract_uninstall.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "timeout" @@ -6,16 +6,14 @@ require "utils/user" require "cask/artifact/abstract_artifact" require "cask/pkg" -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" +require "system_command" module Cask module Artifact # Abstract superclass for uninstall artifacts. - # - # @api private class AbstractUninstall < AbstractArtifact - extend T::Sig + include SystemCommand::Mixin ORDERED_DIRECTIVES = [ :early_script, @@ -32,21 +30,24 @@ class AbstractUninstall < AbstractArtifact ].freeze def self.from_args(cask, **directives) - new(cask, directives) + new(cask, **directives) end attr_reader :directives - def initialize(cask, directives) - directives.assert_valid_keys!(*ORDERED_DIRECTIVES) + def initialize(cask, **directives) + directives.assert_valid_keys(*ORDERED_DIRECTIVES) - super(cask) + 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 @@ -55,7 +56,7 @@ def to_h directives.to_h end - sig { returns(String) } + sig { override.returns(String) } def summarize to_h.flat_map { |key, val| Array(val).map { |v| "#{key.inspect} => #{v.inspect}" } }.join(", ") end @@ -73,7 +74,7 @@ def dispatch_uninstall_directive(directive_sym, **options) 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 def stanza @@ -90,32 +91,66 @@ def uninstall_early_script(directives, **options) # :launchctl must come before :quit/:signal for cases where app would instantly re-launch def uninstall_launchctl(*services, command: nil, **_) 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}" - booleans.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.start_with?("{") - command.run!("/bin/launchctl", args: ["remove", service], sudo: with_sudo) + result = command.run( + "/bin/launchctl", + args: ["remove", service], + must_succeed: sudo, + sudo:, + sudo_as_root: sudo, + ) + next if !sudo && !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(Dir.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], 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], + sudo:, + sudo_as_root: sudo, + ) + command.run!( + "/bin/rm", + args: ["-f", "--", service], + sudo:, + sudo_as_root: sudo, + ) sleep 1 end end @@ -131,11 +166,27 @@ def running_processes(bundle_id) end end + 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? && id.match?(regex) + end + end + sig { returns(String) } def automation_access_instructions + navigation_path = if MacOS.version >= :ventura + "System Settings → Privacy & Security" + else + "System Preferences → Security & Privacy → Privacy" + end + <<~EOS Enable Automation access for "Terminal → System Events" in: - System Preferences → Security & Privacy → Privacy → Automation + #{navigation_path} → Automation if you haven't already. EOS end @@ -145,7 +196,7 @@ def uninstall_quit(*bundle_ids, command: nil, **_) 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 @@ -220,12 +271,12 @@ def quit(bundle_id) # :signal should come after :quit so it can be used as a backup when :quit fails def uninstall_signal(*signals, command: nil, **_) 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 @@ -233,14 +284,19 @@ 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 + def uninstall_login_item(*login_items, command: nil, successor: nil, **_) + return if successor apps = cask.artifacts.select { |a| a.class.dsl_key == :app } derived_login_items = apps.map { |a| { path: a.target } } @@ -272,14 +328,35 @@ def uninstall_login_item(*login_items, command: nil, upgrade: false, **_) def uninstall_kext(*kexts, command: nil, **_) 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 @@ -307,12 +384,12 @@ def uninstall_script(directives, directive_name: :script, force: false, command: 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; your password may be necessary:" + ohai "Uninstalling packages with sudo; the password may be necessary:" pkgs.each do |regex| ::Cask::Pkg.all_matching(regex, command).each do |pkg| puts pkg.package_id @@ -344,8 +421,14 @@ def each_resolved_path(action, paths) rescue Errno::EPERM raise if File.readable?(File.expand_path("~/Library/Application Support/com.apple.TCC")) + navigation_path = if MacOS.version >= :ventura + "System Settings → Privacy & Security" + else + "System Preferences → Security & Privacy → Privacy" + end + odie "Unable to remove some files. Please enable Full Disk Access for your terminal under " \ - "System Preferences → Security & Privacy → Privacy → Full Disk Access." + "#{navigation_path} → Full Disk Access." end end end @@ -377,20 +460,19 @@ def uninstall_trash(*paths, **options) def trash_paths(*paths, command: nil, **_) return if paths.empty? - stdout, stderr, = system_command HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", - args: paths, - print_stderr: false + stdout, = system_command HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", + args: paths, + print_stderr: Homebrew::EnvConfig.developer? - trashed = stdout.split(":").sort - untrashable = stderr.split(":").sort + trashed, _, untrashable = stdout.partition("\n") + trashed = trashed.split(":") + untrashable = untrashable.split(":") - return trashed, untrashable if untrashable.empty? - - untrashable.delete_if do |path| + trashed_with_permissions, untrashable = untrashable.partition do |path| Utils.gain_permissions(path, ["-R"], SystemCommand) do system_command! HOMEBREW_LIBRARY_PATH/"cask/utils/trash.swift", args: [path], - print_stderr: false + print_stderr: Homebrew::EnvConfig.developer? end true @@ -398,6 +480,10 @@ def trash_paths(*paths, command: nil, **_) false end + trashed += trashed_with_permissions + + return trashed, untrashable if untrashable.empty? + opoo "The following files could not be trashed, please do so manually:" $stderr.puts untrashable @@ -409,32 +495,48 @@ def all_dirs?(*directories) end def recursive_rmdir(*directories, command: nil, **_) - success = true - each_resolved_path(:rmdir, directories) do |_path, resolved_paths| - resolved_paths.select(&method(:all_dirs?)).each do |resolved_path| - puts resolved_path.sub(Dir.home, "~") + directories.all? do |resolved_path| + puts resolved_path.sub(Dir.home, "~") - if (ds_store = resolved_path.join(".DS_Store")).exist? - command.run!("/bin/rm", args: ["-f", "--", ds_store], sudo: true, print_stderr: false) - end + if resolved_path.readable? + children = resolved_path.children - unless recursive_rmdir(*resolved_path.children, command: command) - success = false - next - end + 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) - status = command.run("/bin/rmdir", args: ["--", resolved_path], sudo: true, print_stderr: false).success? - success &= status + # 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 + + # 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 + + next false unless recursive_rmdir(*children, command:) + + Utils.gain_permissions_rmdir(resolved_path, command:) + + true end - success end - def uninstall_rmdir(*args) - return if args.empty? + def uninstall_rmdir(*directories, **kwargs) + return if directories.empty? ohai "Removing directories if empty:" - recursive_rmdir(*args) + + each_resolved_path(:rmdir, directories) do |_path, resolved_paths| + next unless resolved_paths.all?(&:directory?) + + recursive_rmdir(*resolved_paths, **kwargs) + end end end end diff --git a/Library/Homebrew/cask/artifact/app.rb b/Library/Homebrew/cask/artifact/app.rb index c2ec38df44544..c4775ab323033 100644 --- a/Library/Homebrew/cask/artifact/app.rb +++ b/Library/Homebrew/cask/artifact/app.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `app` stanza. - # - # @api private class App < Moved end end diff --git a/Library/Homebrew/cask/artifact/artifact.rb b/Library/Homebrew/cask/artifact/artifact.rb index 705cc4d18ade1..f28fa816c05db 100644 --- a/Library/Homebrew/cask/artifact/artifact.rb +++ b/Library/Homebrew/cask/artifact/artifact.rb @@ -3,17 +3,10 @@ require "cask/artifact/moved" -require "extend/hash_validator" -using HashValidator - module Cask module Artifact # Generic artifact corresponding to the `artifact` stanza. - # - # @api private class Artifact < Moved - extend T::Sig - sig { returns(String) } def self.english_name "Generic Artifact" @@ -25,7 +18,7 @@ def self.from_args(cask, *args) raise CaskInvalidError.new(cask.token, "No source provided for #{english_name}.") if source.blank? - unless options.try(:key?, :target) + unless options&.key?(:target) raise CaskInvalidError.new(cask.token, "#{english_name} '#{source}' requires a target.") end @@ -36,11 +29,6 @@ def self.from_args(cask, *args) def resolve_target(target) super(target, base_dir: nil) end - - sig { params(cask: Cask, source: T.any(String, Pathname), target: T.any(String, Pathname)).void } - def initialize(cask, source, target:) - super(cask, source, target: target) - end end end end diff --git a/Library/Homebrew/cask/artifact/audio_unit_plugin.rb b/Library/Homebrew/cask/artifact/audio_unit_plugin.rb index 1fe7cf108fa52..0a8b71eaa8310 100644 --- a/Library/Homebrew/cask/artifact/audio_unit_plugin.rb +++ b/Library/Homebrew/cask/artifact/audio_unit_plugin.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `audio_unit_plugin` stanza. - # - # @api private class AudioUnitPlugin < Moved end end diff --git a/Library/Homebrew/cask/artifact/binary.rb b/Library/Homebrew/cask/artifact/binary.rb index a8c7d55450a62..aab9616be4c90 100644 --- a/Library/Homebrew/cask/artifact/binary.rb +++ b/Library/Homebrew/cask/artifact/binary.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/symlinked" @@ -6,11 +6,9 @@ module Cask module Artifact # Artifact corresponding to the `binary` stanza. - # - # @api private class Binary < Symlinked def link(command: nil, **options) - super(command: command, **options) + super return if source.executable? if source.writable? diff --git a/Library/Homebrew/cask/artifact/colorpicker.rb b/Library/Homebrew/cask/artifact/colorpicker.rb index 3743b70b21a57..a0d1bc7b5fe35 100644 --- a/Library/Homebrew/cask/artifact/colorpicker.rb +++ b/Library/Homebrew/cask/artifact/colorpicker.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `colorpicker` stanza. - # - # @api private class Colorpicker < Moved end end diff --git a/Library/Homebrew/cask/artifact/dictionary.rb b/Library/Homebrew/cask/artifact/dictionary.rb index 50749b2bdd81b..a247c0949e9a3 100644 --- a/Library/Homebrew/cask/artifact/dictionary.rb +++ b/Library/Homebrew/cask/artifact/dictionary.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `dictionary` stanza. - # - # @api private class Dictionary < Moved end end diff --git a/Library/Homebrew/cask/artifact/font.rb b/Library/Homebrew/cask/artifact/font.rb index caa2fcc7ba3ce..2902156de6e1b 100644 --- a/Library/Homebrew/cask/artifact/font.rb +++ b/Library/Homebrew/cask/artifact/font.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `font` stanza. - # - # @api private class Font < Moved end end diff --git a/Library/Homebrew/cask/artifact/input_method.rb b/Library/Homebrew/cask/artifact/input_method.rb index 79b3acaadf875..347efba7d5e22 100644 --- a/Library/Homebrew/cask/artifact/input_method.rb +++ b/Library/Homebrew/cask/artifact/input_method.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `input_method` stanza. - # - # @api private class InputMethod < Moved end end diff --git a/Library/Homebrew/cask/artifact/installer.rb b/Library/Homebrew/cask/artifact/installer.rb index 95c57418f49f8..903316077096e 100644 --- a/Library/Homebrew/cask/artifact/installer.rb +++ b/Library/Homebrew/cask/artifact/installer.rb @@ -1,36 +1,25 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # 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. - # - # @api private class Installer < AbstractArtifact VALID_KEYS = Set.new([ :manual, :script, ]).freeze - # Extension module for manual installers. - module ManualInstaller - def install_phase(**) + def install_phase(command: nil, **_) + 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 - - # Extension module for script installers. - module ScriptInstaller - def install_phase(command: nil, **_) + else ohai "Running #{self.class.dsl_key} script '#{path}'" executable_path = staged_path_join_executable(path) @@ -38,9 +27,10 @@ def install_phase(command: nil, **_) command.run!( executable_path, **args, - env: { "PATH" => PATH.new( + env: { "PATH" => PATH.new( HOMEBREW_PREFIX/"bin", HOMEBREW_PREFIX/"sbin", ENV.fetch("PATH") ) }, + reset_uid: true, ) end end @@ -58,45 +48,46 @@ 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(T::Boolean) } + attr_reader :manual_install + 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? + @manual_install = true + else + 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 = Pathname(path) - extend(ScriptInstaller) + @path = Pathname(path) + @manual_install = false + end end - def summarize - path.to_s - end + sig { override.returns(String) } + def summarize = path.to_s 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 73c655a52bc34..a603b14200037 100644 --- a/Library/Homebrew/cask/artifact/internet_plugin.rb +++ b/Library/Homebrew/cask/artifact/internet_plugin.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `internet_plugin` stanza. - # - # @api private 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..b1e72ccaaff5c --- /dev/null +++ b/Library/Homebrew/cask/artifact/keyboard_layout.rb @@ -0,0 +1,32 @@ +# typed: true # rubocop:todo Sorbet/StrictSigil +# frozen_string_literal: true + +require "cask/artifact/moved" + +module Cask + module Artifact + # Artifact corresponding to the `keyboard_layout` stanza. + class KeyboardLayout < Moved + def install_phase(**options) + super + delete_keyboard_layout_cache(**options) + end + + def uninstall_phase(**options) + super + delete_keyboard_layout_cache(**options) + end + + private + + def delete_keyboard_layout_cache(command: nil, **_) + 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 2c085154dec1e..170a0904d3b5b 100644 --- a/Library/Homebrew/cask/artifact/manpage.rb +++ b/Library/Homebrew/cask/artifact/manpage.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/symlinked" @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `manpage` stanza. - # - # @api private class Manpage < Symlinked attr_reader :section diff --git a/Library/Homebrew/cask/artifact/mdimporter.rb b/Library/Homebrew/cask/artifact/mdimporter.rb index 5a4ce2d052290..f2774fc971c28 100644 --- a/Library/Homebrew/cask/artifact/mdimporter.rb +++ b/Library/Homebrew/cask/artifact/mdimporter.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/moved" @@ -6,18 +6,14 @@ module Cask module Artifact # Artifact corresponding to the `mdimporter` stanza. - # - # @api private class Mdimporter < Moved - extend T::Sig - sig { returns(String) } def self.english_name "Spotlight metadata importer" end def install_phase(**options) - super(**options) + super reload_spotlight(**options) end diff --git a/Library/Homebrew/cask/artifact/moved.rb b/Library/Homebrew/cask/artifact/moved.rb index 6ad4cf61ccd41..d3c5331639b95 100644 --- a/Library/Homebrew/cask/artifact/moved.rb +++ b/Library/Homebrew/cask/artifact/moved.rb @@ -1,16 +1,13 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # 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. - # - # @api private class Moved < Relocated - extend T::Sig - sig { returns(String) } def self.english_description "#{english_name}s" @@ -34,48 +31,127 @@ 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 - + def move(adopt: false, auto_updates: false, force: false, verbose: false, predecessor: nil, reinstall: false, + command: nil, **options) 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:, command:, **options) + 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. + 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 - def move_back(skip: false, force: false, command: nil, **options) + 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, adopt: false, command: nil, **options) 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:, command:, **options) end unless target.exist? @@ -88,21 +164,36 @@ def move_back(skip: false, force: false, command: nil, **options) 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: ["-pR", target, source], + must_succeed: sudo, + sudo:, + ) + break if result.success? + end - delete(target, force: force, command: command, **options) + delete(target, force:, command:, **options) end - def delete(target, force: false, command: nil, **_) + def delete(target, force: false, successor: nil, command: nil, **_) ohai "Removing #{self.class.english_name} '#{target}'" raise CaskError, "Cannot remove undeletable #{self.class.english_name}." if MacOS.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 end diff --git a/Library/Homebrew/cask/artifact/pkg.rb b/Library/Homebrew/cask/artifact/pkg.rb index dd3a9447bfe70..7c4ce514a60f8 100644 --- a/Library/Homebrew/cask/artifact/pkg.rb +++ b/Library/Homebrew/cask/artifact/pkg.rb @@ -1,29 +1,25 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # 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. - # - # @api private class Pkg < AbstractArtifact attr_reader :path, :stanza_options def self.from_args(cask, path, **stanza_options) - stanza_options.assert_valid_keys!(:allow_untrusted, :choices) + stanza_options.assert_valid_keys(:allow_untrusted, :choices) new(cask, path, **stanza_options) end def initialize(cask, path, **stanza_options) - super(cask) + super @path = cask.staged_path.join(path) @stanza_options = stanza_options end @@ -39,8 +35,7 @@ def install_phase(**options) private def run_installer(command: nil, verbose: false, **_options) - ohai "Running installer for #{cask}; your password may be necessary.", - "Package installers may write to any location; options such as `--appdir` are ignored." + ohai "Running installer for #{cask} with sudo; the password may be necessary." unless path.exist? pkg = path.relative_path_from(cask.staged_path) pkgs = Pathname.glob(cask.staged_path/"**"/"*.pkg").map { |path| path.relative_path_from(cask.staged_path) } @@ -65,7 +60,14 @@ def run_installer(command: nil, verbose: false, **_options) "USER" => User.current, "USERNAME" => User.current, } - 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 diff --git a/Library/Homebrew/cask/artifact/postflight_block.rb b/Library/Homebrew/cask/artifact/postflight_block.rb index 00d170fda7ce0..fb0e7b5efc2e6 100644 --- a/Library/Homebrew/cask/artifact/postflight_block.rb +++ b/Library/Homebrew/cask/artifact/postflight_block.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `postflight` stanza. - # - # @api private class PostflightBlock < AbstractFlightBlock end end diff --git a/Library/Homebrew/cask/artifact/preflight_block.rb b/Library/Homebrew/cask/artifact/preflight_block.rb index 0b7f21c8ecd98..d2845590356e2 100644 --- a/Library/Homebrew/cask/artifact/preflight_block.rb +++ b/Library/Homebrew/cask/artifact/preflight_block.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `preflight` stanza. - # - # @api private class PreflightBlock < AbstractFlightBlock end end diff --git a/Library/Homebrew/cask/artifact/prefpane.rb b/Library/Homebrew/cask/artifact/prefpane.rb index a2a1057b34d94..a397234538424 100644 --- a/Library/Homebrew/cask/artifact/prefpane.rb +++ b/Library/Homebrew/cask/artifact/prefpane.rb @@ -6,11 +6,7 @@ module Cask module Artifact # Artifact corresponding to the `prefpane` stanza. - # - # @api private class Prefpane < Moved - extend T::Sig - sig { returns(String) } def self.english_name "Preference Pane" diff --git a/Library/Homebrew/cask/artifact/qlplugin.rb b/Library/Homebrew/cask/artifact/qlplugin.rb index d50956d8ca753..605303c360054 100644 --- a/Library/Homebrew/cask/artifact/qlplugin.rb +++ b/Library/Homebrew/cask/artifact/qlplugin.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/moved" @@ -6,23 +6,19 @@ module Cask module Artifact # Artifact corresponding to the `qlplugin` stanza. - # - # @api private class Qlplugin < Moved - extend T::Sig - sig { returns(String) } def self.english_name - "QuickLook Plugin" + "Quick Look Plugin" end def install_phase(**options) - super(**options) + super reload_quicklook(**options) end def uninstall_phase(**options) - super(**options) + super reload_quicklook(**options) end diff --git a/Library/Homebrew/cask/artifact/relocated.rb b/Library/Homebrew/cask/artifact/relocated.rb index 612a0c7420e5c..1ff075e8cde34 100644 --- a/Library/Homebrew/cask/artifact/relocated.rb +++ b/Library/Homebrew/cask/artifact/relocated.rb @@ -1,26 +1,20 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # 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. - # - # @api private class Relocated < AbstractArtifact - extend T::Sig - def self.from_args(cask, *args) source_string, target_hash = args 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 ||= {} @@ -39,21 +33,28 @@ def resolve_target(target, base_dir: config.public_send(self.class.dirmethod)) target end - attr_reader :source, :target - sig { - params(cask: Cask, source: T.nilable(T.any(String, Pathname)), target: T.nilable(T.any(String, Pathname))) + params(cask: Cask, source: T.nilable(T.any(String, Pathname)), target_hash: T.any(String, Pathname)) .void } - def initialize(cask, source, target: nil) - super(cask) + def initialize(cask, source, **target_hash) + super + target = target_hash[:target] @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) + end + + def source + @source ||= begin + base_path = cask.staged_path + base_path = base_path.join(cask.url.only_path) if cask.url&.only_path.present? + base_path.join(@source_string) + end + end + + def target + @target ||= resolve_target(@target_string.presence || source.basename) end def to_a @@ -62,7 +63,7 @@ def to_a end end - sig { returns(String) } + sig { override.returns(String) } def summarize target_string = @target_string.empty? ? "" : " -> #{@target_string}" "#{@source_string}#{target_string}" diff --git a/Library/Homebrew/cask/artifact/screen_saver.rb b/Library/Homebrew/cask/artifact/screen_saver.rb index c52e0361da015..cc9cd6249fdcb 100644 --- a/Library/Homebrew/cask/artifact/screen_saver.rb +++ b/Library/Homebrew/cask/artifact/screen_saver.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `screen_saver` stanza. - # - # @api private class ScreenSaver < Moved end end diff --git a/Library/Homebrew/cask/artifact/service.rb b/Library/Homebrew/cask/artifact/service.rb index 901cb5ac07900..032fd78788e54 100644 --- a/Library/Homebrew/cask/artifact/service.rb +++ b/Library/Homebrew/cask/artifact/service.rb @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `service` stanza. - # - # @api private class Service < Moved end end diff --git a/Library/Homebrew/cask/artifact/stage_only.rb b/Library/Homebrew/cask/artifact/stage_only.rb index b475b00a860e0..5a1cb6f9d0e70 100644 --- a/Library/Homebrew/cask/artifact/stage_only.rb +++ b/Library/Homebrew/cask/artifact/stage_only.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/abstract_artifact" @@ -6,15 +6,13 @@ module Cask module Artifact # Artifact corresponding to the `stage_only` stanza. - # - # @api private class StageOnly < AbstractArtifact - extend T::Sig + 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 - def self.from_args(cask, *args) - raise CaskInvalidError.new(cask.token, "'stage_only' takes only a single argument: true") if args != [true] - - new(cask) + new(cask, true) end sig { returns(T::Array[T::Boolean]) } @@ -22,7 +20,7 @@ def to_a [true] end - sig { returns(String) } + sig { override.returns(String) } def summarize "true" end diff --git a/Library/Homebrew/cask/artifact/suite.rb b/Library/Homebrew/cask/artifact/suite.rb index 5eb84626404ec..cc49c85b16058 100644 --- a/Library/Homebrew/cask/artifact/suite.rb +++ b/Library/Homebrew/cask/artifact/suite.rb @@ -6,11 +6,7 @@ module Cask module Artifact # Artifact corresponding to the `suite` stanza. - # - # @api private class Suite < Moved - extend T::Sig - sig { returns(String) } def self.english_name "App Suite" diff --git a/Library/Homebrew/cask/artifact/symlinked.rb b/Library/Homebrew/cask/artifact/symlinked.rb index 653caeed91494..170ecc5367465 100644 --- a/Library/Homebrew/cask/artifact/symlinked.rb +++ b/Library/Homebrew/cask/artifact/symlinked.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/relocated" @@ -6,11 +6,7 @@ module Cask module Artifact # Superclass for all artifacts which are installed by symlinking them to the target location. - # - # @api private class Symlinked < Relocated - extend T::Sig - sig { returns(String) } def self.link_type_english_name "Symlink" @@ -45,7 +41,7 @@ def summarize_installed private - def link(force: false, **options) + def link(force: false, adopt: false, command: nil, **_options) unless source.exist? raise CaskError, "It seems the #{self.class.link_type_english_name.downcase} " \ @@ -56,30 +52,33 @@ def link(force: false, **options) message = "It seems there is already #{self.class.english_article} " \ "#{self.class.english_name} at '#{target}'" - if force && target.symlink? && \ + if (force || adopt) && target.symlink? && (target.realpath == source.realpath || target.realpath.to_s.start_with?("#{cask.caskroom_path}/")) opoo "#{message}; overwriting." - target.delete + Utils.gain_permissions_remove(target, command:) else raise CaskError, "#{message}." end end ohai "Linking #{self.class.english_name} '#{source.basename}' to '#{target}'" - create_filesystem_link(**options) + create_filesystem_link(command:) end - def unlink(**) + def unlink(command: nil, **) return unless target.symlink? ohai "Unlinking #{self.class.english_name} '#{target}'" - target.delete + Utils.gain_permissions_remove(target, command:) 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) + def create_filesystem_link(command: nil) + Utils.gain_permissions_mkpath(target.dirname, command:) + + command.run! "/bin/ln", args: ["-h", "-f", "-s", "--", source, target], + sudo: !target.dirname.writable? + + add_altname_metadata(source, target.basename, command:) end end end diff --git a/Library/Homebrew/cask/artifact/uninstall.rb b/Library/Homebrew/cask/artifact/uninstall.rb index a91efd2fa88b7..c9801b09618ec 100644 --- a/Library/Homebrew/cask/artifact/uninstall.rb +++ b/Library/Homebrew/cask/artifact/uninstall.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/abstract_uninstall" @@ -6,14 +6,21 @@ module Cask module Artifact # Artifact corresponding to the `uninstall` stanza. - # - # @api private 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 = [:quit, :signal].freeze + + def uninstall_phase(upgrade: false, reinstall: false, **options) + filtered_directives = ORDERED_DIRECTIVES.filter do |directive_sym| + next false if directive_sym == :rmdir + + next false if (upgrade || reinstall) && UPGRADE_REINSTALL_SKIP_DIRECTIVES.include?(directive_sym) + + true + end + + filtered_directives.each do |directive_sym| + dispatch_uninstall_directive(directive_sym, **options) + end end def post_uninstall_phase(**options) diff --git a/Library/Homebrew/cask/artifact/vst3_plugin.rb b/Library/Homebrew/cask/artifact/vst3_plugin.rb index bd69c57a41fa8..f59701de59e0d 100644 --- a/Library/Homebrew/cask/artifact/vst3_plugin.rb +++ b/Library/Homebrew/cask/artifact/vst3_plugin.rb @@ -6,9 +6,11 @@ module Cask module Artifact # Artifact corresponding to the `vst3_plugin` stanza. - # - # @api private class Vst3Plugin < Moved + sig { 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 976ae18d68fa5..cd330cc840fd1 100644 --- a/Library/Homebrew/cask/artifact/vst_plugin.rb +++ b/Library/Homebrew/cask/artifact/vst_plugin.rb @@ -6,9 +6,11 @@ module Cask module Artifact # Artifact corresponding to the `vst_plugin` stanza. - # - # @api private class VstPlugin < Moved + sig { 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 eadc014618ea6..0da02e19854c6 100644 --- a/Library/Homebrew/cask/artifact/zap.rb +++ b/Library/Homebrew/cask/artifact/zap.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/artifact/abstract_uninstall" @@ -6,8 +6,6 @@ module Cask module Artifact # Artifact corresponding to the `zap` stanza. - # - # @api private class Zap < AbstractUninstall def zap_phase(**options) dispatch_uninstall_directives(**options) diff --git a/Library/Homebrew/cask/artifact_set.rb b/Library/Homebrew/cask/artifact_set.rb new file mode 100644 index 0000000000000..517debcd25968 --- /dev/null +++ b/Library/Homebrew/cask/artifact_set.rb @@ -0,0 +1,18 @@ +# typed: true # rubocop:todo Sorbet/StrictSigil +# frozen_string_literal: true + +module Cask + # Sorted set containing all cask artifacts. + class ArtifactSet < ::Set + def each(&block) + return enum_for(T.must(__method__)) { size } unless block + + to_a.each(&block) + self + end + + def to_a + super.sort + end + end +end diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index 91bcf2d3ec749..cd243526d8fbd 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -1,89 +1,75 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" require "cask/denylist" require "cask/download" 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" module Cask # Audit a cask for various problems. - # - # @api private class Audit - extend T::Sig + include SystemCommand::Mixin + include ::Utils::Curl + extend Attrable - extend Predicable + sig { returns(Cask) } + attr_reader :cask - attr_reader :cask, :download + sig { returns(T.nilable(Download)) } + attr_reader :download - attr_predicate :appcast?, :new_cask?, :strict?, :online?, :token_conflicts? + attr_predicate :new_cask?, :strict?, :signing?, :online?, :token_conflicts? - def initialize(cask, appcast: nil, download: nil, quarantine: nil, - token_conflicts: nil, online: nil, strict: nil, - new_cask: nil) - - # `new_cask` implies `online` and `strict` + def initialize( + cask, + download: nil, quarantine: nil, + token_conflicts: nil, online: nil, strict: nil, signing: nil, + new_cask: nil, only: [], except: [] + ) + # `new_cask` implies `online`, `token_conflicts`, `strict` and `signing` online = new_cask if online.nil? strict = new_cask if strict.nil? - - # `online` implies `appcast` and `download` - appcast = online if appcast.nil? - download = online if download.nil? - - # `new_cask` implies `token_conflicts` + signing = new_cask if signing.nil? token_conflicts = new_cask if token_conflicts.nil? + # `online` and `signing` imply `download` + download = online || signing if download.nil? + @cask = cask - @appcast = appcast - @download = Download.new(cask, quarantine: quarantine) if download + @download = Download.new(cask, quarantine:) if download @online = online @strict = strict + @signing = signing @new_cask = new_cask @token_conflicts = token_conflicts + @only = only || [] + @except = except || [] end def run! - check_denylist - check_reverse_migration - check_required_stanzas - check_version - check_sha256 - check_desc - check_url - check_unnecessary_verified - check_missing_verified - check_no_match - check_generic_artifacts - check_token_valid - check_token_bad_words - check_token_conflicts - check_languages - check_download - check_https_availability - check_single_pre_postflight - check_single_uninstall_zap - check_untrusted_pkg - livecheck_result = check_livecheck_version - check_hosting_with_livecheck(livecheck_result: livecheck_result) - check_appcast_and_livecheck - check_latest_with_appcast_or_livecheck - check_latest_with_auto_updates - check_stanza_requires_uninstall - check_appcast_contains_version - check_gitlab_repository - check_gitlab_repository_archived - check_gitlab_prerelease_version - check_github_repository - check_github_repository_archived - check_github_prerelease_version - check_bitbucket_repository + 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, e.backtrace + odebug e, ::Utils::Backtrace.clean(e) add_error "exception while auditing #{cask}: #{e.message}" self end @@ -92,44 +78,37 @@ def errors @errors ||= [] end - def warnings - @warnings ||= [] - end - - def add_error(message, location: nil) - errors << ({ message: message, location: location }) + sig { returns(T::Boolean) } + def errors? + errors.any? end - def add_warning(message, location: nil) - if strict? - add_error message, location: location - else - warnings << ({ message: message, location: location }) - end + sig { returns(T::Boolean) } + def success? + !errors? end - def errors? - errors.any? - 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 - def warnings? - warnings.any? + errors << ({ message:, location:, corrected: false }) end def result - if errors? - Formatter.error("failed") - elsif warnings? - Formatter.warning("warning") - else - Formatter.success("passed") - end + Formatter.error("failed") if errors? end - sig { params(include_passed: T::Boolean, include_warnings: T::Boolean).returns(String) } - def summary(include_passed: false, include_warnings: true) - return if success? && !include_passed - return if warnings? && !errors? && !include_warnings + sig { returns(T.nilable(String)) } + def summary + return if success? summary = ["audit for #{cask}: #{result}"] @@ -137,22 +116,13 @@ def summary(include_passed: false, include_warnings: true) summary << " #{Formatter.error("-")} #{error[:message]}" end - if include_warnings - warnings.each do |warning| - summary << " #{Formatter.warning("-")} #{warning[:message]}" - end - end - summary.join("\n") end - def success? - !(errors? || warnings?) - end - private - def check_untrusted_pkg + sig { void } + def audit_untrusted_pkg odebug "Auditing pkg stanza: allow_untrusted" return if @cask.sourcefile_path.nil? @@ -166,7 +136,8 @@ def check_untrusted_pkg 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) } @@ -175,7 +146,8 @@ def check_stanza_requires_uninstall 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 @@ -186,18 +158,15 @@ def check_single_pre_postflight k.is_a?(Artifact::PostflightBlock) && k.directives.key?(:postflight) end - return unless count > 1 + return if count <= 1 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_error "only a single uninstall stanza is allowed" - end - count = cask.artifacts.count do |k| k.is_a?(Artifact::PreflightBlock) && k.directives.key?(:uninstall_preflight) @@ -212,12 +181,13 @@ def check_single_uninstall_zap 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_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) @@ -229,77 +199,93 @@ def check_required_stanzas add_error "at least one activatable artifact stanza is required" if installable_artifacts.empty? end - def check_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-") + + add_error("Cask should have a description. Please add a `desc` stanza.", strict_only: true) if cask.desc.blank? + end + + sig { void } + def audit_version_special_characters return unless cask.version - check_no_string_version_latest + return if cask.version.latest? + + raw_version = cask.version.raw_version + return if raw_version.exclude?(":") && raw_version.exclude?("/") + + add_error "version should not contain colons or slashes" 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_no_string_version_latest + return unless cask.version + + 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 + sig { void } + def audit_sha256_no_check_if_latest return unless cask.sha256 + return unless cask.version - check_sha256_no_check_if_latest - check_sha256_no_check_if_unversioned - check_sha256_actually_256 - check_sha256_invalid - end - - def check_sha256_no_check_if_latest - odebug "Verifying sha256 :no_check with version :latest" + 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_no_check_if_unversioned + sig { void } + def audit_sha256_no_check_if_unversioned + return unless cask.sha256 return if cask.sha256 == :no_check - add_error "Use `sha256 :no_check` when URL is unversioned." if cask.url&.unversioned? + return unless cask.url&.unversioned? + + add_error "Use `sha256 :no_check` when URL is unversioned." end - def check_sha256_actually_256 - odebug "Verifying sha256 string is a legal SHA-256 digest" + 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 - def check_sha256_invalid - odebug "Verifying sha256 is not a known invalid value" + sig { void } + def audit_sha256_invalid + return unless cask.sha256 + + odebug "Auditing sha256 is not a known invalid value" empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - return unless cask.sha256 == empty_sha256 + return if cask.sha256 != empty_sha256 add_error "cannot use the sha256 for an empty string: #{empty_sha256}" end - def check_appcast_and_livecheck - return unless cask.appcast - - if cask.livecheckable? - add_error "Cask has a `livecheck`, the `appcast` should be removed." - elsif new_cask? - add_error "New casks should use a `livecheck` instead of an `appcast`." - end - end - - def check_latest_with_appcast_or_livecheck - return unless cask.version.latest? + sig { void } + def audit_latest_with_livecheck + return unless cask.version&.latest? + return unless cask.livecheck_defined? + return if cask.livecheck.skip? - add_error "Casks with an `appcast` should not use `version :latest`." if cask.appcast - add_error "Casks with a `livecheck` should not use `version :latest`." if cask.livecheckable? + 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_error "Casks with `version :latest` should not use `auto_updates`." @@ -307,128 +293,56 @@ def check_latest_with_auto_updates LIVECHECK_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#stanza-livecheck" - def check_hosting_with_livecheck(livecheck_result:) - return if cask.discontinued? || cask.version.latest? - return if block_url_offline? || cask.appcast || cask.livecheckable? + sig { params(livecheck_result: T.any(NilClass, T::Boolean, Symbol)).void } + def audit_hosting_with_livecheck(livecheck_result: audit_livecheck_version) + return if cask.deprecated? || cask.disabled? + return if cask.version&.latest? + return if (url = cask.url).nil? + return if block_url_offline? + return if cask.livecheck_defined? return if livecheck_result == :auto_detected add_livecheck = "please add a livecheck. See #{Formatter.url(LIVECHECK_REFERENCE_URL)}" - case cask.url.to_s + case url.to_s when %r{sourceforge.net/(\S+)} return unless online? - add_error "Download is hosted on SourceForge, #{add_livecheck}" + add_error "Download is hosted on SourceForge, #{add_livecheck}", location: url.location when %r{dl.devmate.com/(\S+)} - add_error "Download is hosted on DevMate, #{add_livecheck}" + add_error "Download is hosted on DevMate, #{add_livecheck}", location: url.location when %r{rink.hockeyapp.net/(\S+)} - add_error "Download is hosted on HockeyApp, #{add_livecheck}" + add_error "Download is hosted on HockeyApp, #{add_livecheck}", location: url.location end end - def check_desc - return if cask.desc.present? - - # Fonts seldom benefit from descriptions and requiring them disproportionately increases the maintenance burden - return if cask.tap == "homebrew/cask-fonts" - - add_warning "Cask should have a description. Please add a `desc` stanza." - end - - def check_url - return unless cask.url - - check_download_url_format - end - SOURCEFORGE_OSDN_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#sourceforgeosdn-urls" - def check_download_url_format - odebug "Auditing URL format" - if bad_sourceforge_url? - add_error "SourceForge URL format incorrect. See #{Formatter.url(SOURCEFORGE_OSDN_REFERENCE_URL)}" - elsif bad_osdn_url? - add_error "OSDN URL format incorrect. See #{Formatter.url(SOURCEFORGE_OSDN_REFERENCE_URL)}" - end - end - - 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 =~ format } - 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)/)}, - ]) - end - - def bad_osdn_url? - bad_url_format?(/osd/, [%r{\Ahttps?://([^/]+.)?dl\.osdn\.jp/}]) - end - - def homepage - URI(cask.homepage.to_s).host - end - - def domain - URI(cask.url.to_s).host - end - - 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.downcase - if (split_host = host.split(".")).length >= 3 - host = split_host[-2..].join(".") - end - if (split_home = homepage.split(".")).length >= 3 - home = split_home[-2..].join(".") - end - host == home - end - - def strip_url_scheme(url) - url.sub(%r{^[^:/]+://(www\.)?}, "") - end - - def url_from_verified - strip_url_scheme(cask.url.verified) - end - - 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) - - (url_domain == verified_domain || (verified_domain && url_domain&.end_with?(".#{verified_domain}"))) && - (!verified_path || url_path&.start_with?(verified_path)) - end + sig { void } + def audit_download_url_format + return if (url = cask.url).nil? + return if block_url_offline? - def verified_present? - cask.url.verified.present? - end + odebug "Auditing URL format" + return unless bad_sourceforge_url? - def file_url? - URI(cask.url.to_s).scheme == "file" + add_error "SourceForge URL format incorrect. See #{Formatter.url(SOURCEFORGE_OSDN_REFERENCE_URL)}", + location: url.location end - def block_url_offline? - return false if online? + def audit_download_url_is_osdn + return if (url = cask.url).nil? + return if block_url_offline? + return unless bad_osdn_url? - cask.url.from_block? + add_error "OSDN download urls are disabled.", location: url.location, strict_only: true end VERIFIED_URL_REFERENCE_URL = "https://docs.brew.sh/Cask-Cookbook#when-url-and-homepage-domains-differ-add-verified" - def check_unnecessary_verified + sig { void } + def audit_unnecessary_verified + return unless cask.url return if block_url_offline? return unless verified_present? return unless url_match_homepage? @@ -439,7 +353,9 @@ def check_unnecessary_verified "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}" end - def check_missing_verified + sig { void } + def audit_missing_verified + return unless cask.url return if block_url_offline? return if file_url? return if url_match_homepage? @@ -450,17 +366,21 @@ def check_missing_verified "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}" end - def check_no_match + sig { void } + def audit_no_match + return if (url = cask.url).nil? return if block_url_offline? 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(cask.url.to_s))}. " \ - "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}" + "#{Formatter.url(strip_url_scheme(url.to_s))}. " \ + "See #{Formatter.url(VERIFIED_URL_REFERENCE_URL)}", + location: url.location end - def check_generic_artifacts + sig { void } + def audit_generic_artifacts cask.artifacts.select { |a| a.is_a?(Artifact::Artifact) }.each do |artifact| unless artifact.target.absolute? add_error "target must be absolute path for #{artifact.class.english_name} #{artifact.source}" @@ -468,7 +388,8 @@ def check_generic_artifacts end end - def check_languages + sig { void } + def audit_languages @cask.languages.each do |language| Locale.parse(language) rescue Locale::ParserError @@ -476,82 +397,230 @@ def check_languages end end - def check_token_conflicts - return unless token_conflicts? - return unless core_formula_names.include?(cask.token) + 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: " \ - "#{Formatter.url(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 check_token_valid - add_error "cask token contains non-ascii characters" unless cask.token.ascii_only? - add_error "cask token + should be replaced by -plus-" if cask.token.include? "+" - add_error "cask token whitespace should be replaced by hyphens" if cask.token.include? " " - add_error "cask token @ should be replaced by -at-" if cask.token.include? "@" - add_error "cask token underscores should be replaced by hyphens" if cask.token.include? "_" - add_error "cask token should not contain double hyphens" if cask.token.include? "--" - - if cask.token.match?(/[^a-z0-9\-]/) - add_error "cask token should only contain lowercase alphanumeric characters and hyphens" - end + sig { void } + def audit_token_conflicts + return unless token_conflicts? - return if !cask.token.start_with?("-") && !cask.token.end_with?("-") + Homebrew.with_no_api_env do + return unless core_formula_names.include?(cask.token) - add_error "cask token should not have leading or trailing hyphens" + add_error( + "possible duplicate, cask token conflicts with Homebrew core formula: #{Formatter.url(core_formula_url)}", + strict_only: true, + ) + end end - def check_token_bad_words + 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" - if /-(?alpha|beta|rc|release-candidate)$/ =~ cask.token && - cask.tap&.official? && - cask.tap != "homebrew/cask-versions" - add_error "cask token contains version designation '#{designation}'" + 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_warning "cask token mentions launcher" if token.end_with? "launcher" + add_error("cask token mentions launcher", strict_only: true) if token.end_with? "launcher" - add_warning "cask token mentions desktop" if token.end_with? "desktop" + add_error("cask token mentions desktop", strict_only: true) if token.end_with? "desktop" - add_warning "cask token mentions platform" if token.end_with? "mac", "osx", "macos" + add_error("cask token mentions platform", strict_only: true) if token.end_with? "mac", "osx", "macos" - add_warning "cask token mentions architecture" if token.end_with? "x86", "32_bit", "x86_64", "64_bit" + 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_warning "cask token mentions framework" + add_error("cask token mentions framework", strict_only: true) end - def core_tap - @core_tap ||= CoreTap.instance + 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 core_formula_names - core_tap.formula_names + 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 - sig { returns(String) } - def core_formula_url - "#{core_tap.default_remote}/blob/HEAD/Formula/#{cask.token}.rb" + sig { void } + def audit_signing + return if !signing? || download.blank? || (url = cask.url).nil? + + odebug "Auditing signing" + + extract_artifacts do |artifacts, tmpdir| + is_container = artifacts.any? { |a| a.is_a?(Artifact::App) || a.is_a?(Artifact::Pkg) } + + artifacts.each do |artifact| + next if artifact.is_a?(Artifact::Binary) && is_container == true + + artifact_path = artifact.is_a?(Artifact::Pkg) ? artifact.path : artifact.source + + path = tmpdir/artifact_path.relative_path_from(cask.staged_path) + + result = case artifact + when Artifact::Pkg + system_command("spctl", args: ["--assess", "--type", "install", path], print_stderr: false) + when Artifact::App + system_command("spctl", args: ["--assess", "--type", "execute", path], print_stderr: false) + when Artifact::Binary + system_command("codesign", args: ["--verify", path], print_stderr: false) + else + add_error "Unknown artifact type: #{artifact.class}", location: url.location + end + + next if result.success? + + add_error <<~EOS, location: url.location, strict_only: true + Signature verification failed: + #{result.merged_output} + macOS on ARM requires software to be signed. + Please contact the upstream developer to let them know they should sign and notarize their software. + EOS + end + end end - def check_download - return if download.blank? || cask.url.blank? + sig { void } + def extract_artifacts + return unless online? + return if (download = self.download).nil? - odebug "Auditing download" - download.fetch - rescue => e - add_error "download not possible: #{e}" + artifacts = cask.artifacts.select do |artifact| + artifact.is_a?(Artifact::Pkg) || artifact.is_a?(Artifact::App) || artifact.is_a?(Artifact::Binary) + end + + if @artifacts_extracted && @tmpdir + yield artifacts, @tmpdir if block_given? + return + end + + return if artifacts.empty? + + @tmpdir ||= Pathname(Dir.mktmpdir("cask-audit", HOMEBREW_TEMP)) + + # 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? + + # 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 + + @artifacts_extracted = true # Set the flag to indicate that extraction has occurred. + + # Yield the artifacts and temp directory to the block if provided. + yield artifacts, @tmpdir if block_given? end - def check_livecheck_version - return unless appcast? + sig { void } + def audit_rosetta + return if (url = cask.url).nil? + return unless online? + return if Homebrew::SimulateSystem.current_arch != :arm + + 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) } + + artifacts.filter { |a| a.is_a?(Artifact::App) || a.is_a?(Artifact::Binary) } + .each do |artifact| + next if artifact.is_a?(Artifact::Binary) && is_container + + 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.first + + system_command("lipo", args: ["-archs", main_binary], print_stderr: false) + when Artifact::Binary + binary_path = path.to_s.gsub(cask.appdir, tmpdir) + system_command("lipo", args: ["-archs", binary_path], print_stderr: true) + else + add_error "Unknown artifact type: #{artifact.class}", location: url.location + end + + # binary stanza can contain shell scripts, so we just continue if lipo fails. + next unless result.success? + + odebug 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 + + supports_arm = result.merged_output.include?("arm64") + mentions_rosetta = cask.caveats.include?("requires Rosetta 2") + + if supports_arm && mentions_rosetta + add_error "Artifacts does not require Rosetta 2 but the caveats say otherwise!", + location: url.location + elsif !supports_arm && !mentions_rosetta + add_error "Artifacts require Rosetta 2 but this is not indicated by the caveats!", + location: url.location + end + end + end + end + + sig { returns(T.any(NilClass, T::Boolean, Symbol)) } + def audit_livecheck_version + return unless online? + return unless cask.version referenced_cask, = Homebrew::Livecheck.resolve_livecheck_reference(cask) @@ -559,11 +628,11 @@ def check_livecheck_version if referenced_cask skip_info = Homebrew::Livecheck::SkipConditions.referenced_skip_information( referenced_cask, - Homebrew::Livecheck.cask_name(cask), + Homebrew::Livecheck.package_or_resource_name(cask), ) end - # Respect cask skip conditions (e.g. discontinued, latest, unversioned) + # Respect cask skip conditions (e.g. deprecated, disabled, latest, unversioned) skip_info ||= Homebrew::Livecheck::SkipConditions.skip_information(cask) return :skip if skip_info.present? @@ -571,157 +640,344 @@ def check_livecheck_version cask, referenced_formula_or_cask: referenced_cask, )&.fetch(:latest) - if cask.version.to_s == latest_version.to_s - if cask.appcast - add_error "Version '#{latest_version}' was automatically detected by livecheck; " \ - "the appcast should be removed." - end - - return :auto_detected - end - return :appcast if cask.appcast && !cask.livecheckable? + return :auto_detected if cask.version.to_s == latest_version.to_s add_error "Version '#{cask.version}' differs from '#{latest_version}' retrieved by livecheck." false end - def check_appcast_contains_version - return unless appcast? - return if cask.appcast.to_s.empty? - return if cask.appcast.must_contain == :no_check + sig { void } + def audit_min_os + return unless online? + return unless strict? + + odebug "Auditing minimum OS version" + + plist_min_os = cask_plist_min_os + sparkle_min_os = livecheck_min_os + + debug_messages = [] + debug_messages << "Plist #{plist_min_os}" if plist_min_os + debug_messages << "Sparkle #{sparkle_min_os}" if sparkle_min_os + odebug "Detected minimum OS version: #{debug_messages.join(" | ")}" unless debug_messages.empty? + min_os = [plist_min_os, sparkle_min_os].compact.max + + return if min_os.nil? || min_os <= HOMEBREW_MACOS_OLDEST_ALLOWED + + on_system_block_min_os = cask.on_system_block_min_os + cask_min_os = [on_system_block_min_os, cask.depends_on.macos&.minimum_version].compact.max + odebug "Declared minimum OS version: #{cask_min_os&.to_sym}" + return if cask_min_os&.to_sym == min_os.to_sym + return if cask.on_system_blocks_exist? && + OnSystem.arch_condition_met?(:arm) && + cask_min_os.present? && + cask_min_os < MacOSVersion.new("11") + + min_os_definition = if cask_min_os.present? + if on_system_block_min_os.present? && + on_system_block_min_os > cask.depends_on.macos&.minimum_version + "a block with a minimum OS version of #{cask_min_os.to_sym.inspect}" + else + cask_min_os.to_sym.inspect + end + else + "no minimum OS version" + end + add_error "Upstream defined #{min_os.to_sym.inspect} as the minimum OS version " \ + "but the cask declared #{min_os_definition}", + strict_only: true + end + + sig { returns(T.nilable(MacOSVersion)) } + def livecheck_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? - appcast_url = cask.appcast.to_s begin - details = curl_http_content_headers_and_checksum(appcast_url, user_agent: HOMEBREW_USER_AGENT_FAKE_SAFARI) - appcast_contents = details[:file] + items = Homebrew::Livecheck::Strategy::Sparkle.sort_items( + Homebrew::Livecheck::Strategy::Sparkle.filter_items( + Homebrew::Livecheck::Strategy::Sparkle.items_from_content(content), + ), + ) rescue - add_error "appcast at URL '#{Formatter.url(appcast_url)}' offline or looping" return end + return if items.blank? - version_stanza = cask.version.to_s - adjusted_version_stanza = cask.appcast.must_contain.presence || version_stanza.match(/^[[:alnum:].]+/)[0] - return if appcast_contents.blank? - return if appcast_contents.include?(adjusted_version_stanza) + min_os = items[0]&.minimum_system_version&.strip_patch + # 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 + end + + sig { returns(T.nilable(MacOSVersion)) } + def cask_plist_min_os + return unless online? - add_error <<~EOS.chomp - appcast at URL '#{Formatter.url(appcast_url)}' does not contain \ - the version number '#{adjusted_version_stanza}': - #{appcast_contents} - EOS + plist_min_os = T.let(nil, T.untyped) + @staged_path ||= cask.staged_path + + extract_artifacts do |artifacts, tmpdir| + artifacts.each do |artifact| + artifact_path = artifact.is_a?(Artifact::Pkg) ? artifact.path : artifact.source + path = tmpdir/artifact_path.relative_path_from(cask.staged_path) + plist_path = "#{path}/Contents/Info.plist" + next unless File.exist?(plist_path) + + plist = system_command!("plutil", args: ["-convert", "xml1", "-o", "-", plist_path]).plist + plist_min_os = plist["LSMinimumSystemVersion"].presence + break if plist_min_os + end + end + + begin + MacOSVersion.new(plist_min_os).strip_patch + rescue MacOSVersion::Error + nil + end end - def check_github_prerelease_version - return if cask.tap == "homebrew/cask-versions" + 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? + return if user.nil? || repo.nil? - tag = SharedAudits.github_tag_from_url(cask.url) + tag = SharedAudits.github_tag_from_url(url.to_s) tag ||= cask.version - error = SharedAudits.github_release(user, repo, tag, cask: cask) - add_error error if error + error = SharedAudits.github_release(user, repo, tag, cask:) + add_error error, location: url.location if error end - def check_gitlab_prerelease_version - return if cask.tap == "homebrew/cask-versions" + 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? + return if user.nil? || repo.nil? odebug "Auditing GitLab prerelease" - tag = SharedAudits.gitlab_tag_from_url(cask.url) + tag = SharedAudits.gitlab_tag_from_url(url.to_s) tag ||= cask.version - error = SharedAudits.gitlab_release(user, repo, tag, cask: cask) - add_error error if error + error = SharedAudits.gitlab_release(user, repo, tag, cask:) + add_error error, location: url.location if error end - def check_github_repository_archived - user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) if online? - return if user.nil? + 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? - odebug "Auditing GitHub repo archived" + 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? - return unless metadata["archived"] - - message = "GitHub repo is archived" - - if cask.discontinued? - add_warning message - else - add_error message - end + add_error "GitHub repo is archived", location: url.location if metadata["archived"] end - def check_gitlab_repository_archived + 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? + return if user.nil? || repo.nil? odebug "Auditing GitLab repo archived" metadata = SharedAudits.gitlab_repo_data(user, repo) return if metadata.nil? - return unless metadata["archived"] - - message = "GitLab repo is archived" - - if cask.discontinued? - add_warning message - else - add_error message - end + add_error "GitLab repo is archived", location: url.location if metadata["archived"] end - def check_github_repository + 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? + return if user.nil? || repo.nil? odebug "Auditing GitHub repo" error = SharedAudits.github(user, repo) - add_error error if error + add_error error, location: url.location if error end - def check_gitlab_repository + 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? + return if user.nil? || repo.nil? odebug "Auditing GitLab repo" error = SharedAudits.gitlab(user, repo) - add_error error if error + add_error error, location: url.location if error end - def check_bitbucket_repository + 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? + return if user.nil? || repo.nil? odebug "Auditing Bitbucket repo" error = SharedAudits.bitbucket(user, repo) + add_error error, location: url.location if error + end + + sig { void } + def audit_denylist + return unless cask.tap + 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 + 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 + [: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) + + validate_url_for_https_availability( + url, "livecheck URL", + check_content: true, + user_agents: [:default, :browser] + ) + end + + sig { void } + def audit_cask_path + return unless cask.tap.core_cask_tap? + + expected_path = cask.tap.new_cask_path(cask.token) + + return if cask.sourcefile_path.to_s.end_with?(expected_path) + + 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 - _, user, repo = *regex.match(cask.appcast.to_s) unless user return if !user || !repo repo.gsub!(/.git$/, "") @@ -729,52 +985,113 @@ def get_repo_data(regex) [user, repo] end - def check_denylist - return unless cask.tap - return unless cask.tap.official? - return unless (reason = Denylist.reason(cask.token)) + 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) - add_error "#{cask.token} is not allowed: #{reason}" + valid_formats_array.none? { |format| cask.url.to_s.match?(format) } end - def check_reverse_migration - return unless new_cask? - return unless cask.tap - return unless cask.tap.official? - return unless cask.tap.tap_migrations.key?(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 listed in tap_migrations.json" + sig { returns(T::Boolean) } + def bad_osdn_url? + domain.match?(%r{^(?:\w+\.)*osdn\.jp(?=/|$)}) end - def check_https_availability - return unless download + # sig { returns(String) } + def homepage + URI(cask.homepage.to_s).host + end - if cask.url && !cask.url.using - check_url_for_https_availability(cask.url, "binary URL", cask.token, cask.tap, - user_agents: [cask.url.user_agent]) + # sig { returns(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 - if cask.appcast && appcast? - check_url_for_https_availability(cask.appcast, "appcast URL", cask.token, cask.tap, check_content: true) + return false if homepage.blank? + + home = homepage.downcase + if (split_host = T.must(host).split(".")).length >= 3 + host = T.must(split_host[-2..]).join(".") end + if (split_home = homepage.split(".")).length >= 3 + home = split_home[-2..].join(".") + end + host == home + end - return unless cask.homepage + # sig { params(url: String).returns(String) } + def strip_url_scheme(url) + url.sub(%r{^[^:/]+://(www\.)?}, "") + end - check_url_for_https_availability(cask.homepage, "homepage URL", cask.token, cask.tap, - user_agents: [:browser, :default], - check_content: true, - strict: strict?) + # sig { returns(String) } + def url_from_verified + strip_url_scheme(T.must(cask.url).verified) end - def check_url_for_https_availability(url_to_check, url_type, cask_token, tap, **options) - problem = curl_check_http_content(url_to_check.to_s, url_type, **options) - exception = tap&.audit_exception(:secure_connection_audit_skiplist, cask_token, url_to_check.to_s) + 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) - if problem - add_error problem unless exception - elsif exception - add_error "#{url_to_check} is in the secure connection audit skiplist but does not need to be skipped" - end + (url_domain == verified_domain || (verified_domain && url_domain&.end_with?(".#{verified_domain}"))) && + (!verified_path || url_path&.start_with?(verified_path)) + 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(T::Boolean) } + def block_url_offline? + return false if online? + + !!cask.url&.from_block? + end + + sig { returns(Tap) } + def core_tap + @core_tap ||= CoreTap.instance + end + + # sig { returns(T::Array[String]) } + def core_formula_names + core_tap.formula_names + 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 508fb0a379736..f79de7218568e 100644 --- a/Library/Homebrew/cask/auditor.rb +++ b/Library/Homebrew/cask/auditor.rb @@ -1,41 +1,13 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/audit" module Cask # Helper class for auditing all available languages of a cask. - # - # @api private class Auditor - def self.audit( - cask, - audit_download: nil, - audit_appcast: nil, - audit_online: nil, - audit_new_cask: nil, - audit_strict: nil, - audit_token_conflicts: nil, - quarantine: nil, - any_named_args: nil, - language: nil, - display_passes: nil, - display_failures_only: nil - ) - new( - cask, - audit_download: audit_download, - audit_appcast: audit_appcast, - audit_online: audit_online, - audit_new_cask: audit_new_cask, - audit_strict: audit_strict, - audit_token_conflicts: audit_token_conflicts, - quarantine: quarantine, - any_named_args: any_named_args, - language: language, - display_passes: display_passes, - display_failures_only: display_failures_only, - ).audit + def self.audit(cask, **options) + new(cask, **options).audit end attr_reader :cask, :language @@ -43,55 +15,61 @@ def self.audit( def initialize( cask, audit_download: nil, - audit_appcast: nil, audit_online: nil, audit_strict: nil, + audit_signing: nil, audit_token_conflicts: nil, audit_new_cask: nil, quarantine: nil, any_named_args: nil, language: nil, - display_passes: nil, - display_failures_only: 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 @audit_token_conflicts = audit_token_conflicts @any_named_args = any_named_args @language = language - @display_passes = display_passes - @display_failures_only = display_failures_only + @only = only + @except = except end + LANGUAGE_BLOCK_LIMIT = 10 + def audit - warnings = Set.new errors = Set.new if !language && language_blocks - language_blocks.each_key do |l| + sample_languages = if language_blocks.length > LANGUAGE_BLOCK_LIMIT && !@audit_new_cask + sample_keys = language_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}" + language_blocks.select { |k| sample_keys.include?(k) } + else + language_blocks + end + + sample_languages.each_key do |l| audit = audit_languages(l) - summary = audit.summary(include_passed: output_passed?, include_warnings: output_warnings?) - if summary.present? && output_summary?(audit) + if audit.summary.present? && output_summary?(audit) ohai "Auditing language: #{l.map { |lang| "'#{lang}'" }.to_sentence}" if output_summary? - puts summary + puts audit.summary end - warnings += audit.warnings errors += audit.errors end else audit = audit_cask_instance(cask) - summary = audit.summary(include_passed: output_passed?, include_warnings: output_warnings?) - puts summary if summary.present? && output_summary?(audit) - warnings += audit.warnings + puts audit.summary if audit.summary.present? && output_summary?(audit) errors += audit.errors end - { warnings: warnings, errors: errors } + errors end private @@ -104,22 +82,9 @@ def output_summary?(audit = nil) audit.errors? end - def output_passed? - return false if @display_failures_only.present? - return true if @display_passes.present? - - false - end - - def output_warnings? - return false if @display_failures_only.present? - - true - end - def audit_languages(languages) original_config = cask.config - localized_config = original_config.merge(Config.new(explicit: { languages: languages })) + localized_config = original_config.merge(Config.new(explicit: { languages: })) cask.config = localized_config audit_cask_instance(cask) @@ -130,16 +95,17 @@ def audit_languages(languages) def audit_cask_instance(cask) audit = Audit.new( cask, - appcast: @audit_appcast, online: @audit_online, strict: @audit_strict, + signing: @audit_signing, new_cask: @audit_new_cask, token_conflicts: @audit_token_conflicts, download: @audit_download, quarantine: @quarantine, + only: @only, + except: @except, ) audit.run! - audit end def language_blocks diff --git a/Library/Homebrew/cask/cache.rb b/Library/Homebrew/cask/cache.rb index 911a4eb833053..9735e4789d813 100644 --- a/Library/Homebrew/cask/cache.rb +++ b/Library/Homebrew/cask/cache.rb @@ -1,16 +1,12 @@ -# typed: true +# typed: strict # frozen_string_literal: true module Cask # Helper functions for the cask cache. - # - # @api private module Cache - extend T::Sig - sig { returns(Pathname) } def self.path - @path ||= HOMEBREW_CACHE/"Cask" + @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 c30dfa7c224b7..75a9e00b4280d 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -1,36 +1,54 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" +require "bundle_version" require "cask/cask_loader" require "cask/config" require "cask/dsl" require "cask/metadata" -require "searchable" +require "cask/tab" require "utils/bottles" +require "extend/api_hashable" module Cask # An instance of a cask. - # - # @api private class Cask - extend T::Sig - extend Forwardable - extend Searchable + extend Attrable + extend APIHashable include Metadata - attr_reader :token, :sourcefile_path, :source, :config, :default_config + # The token of this {Cask}. + # + # @api internal + attr_reader :token + + # The configuration of this {Cask}. + # + # @api internal + attr_reader :config + attr_reader :sourcefile_path, :source, :default_config, :loader attr_accessor :download, :allow_reassignment - def self.all - Tap.flat_map(&:cask_files).map do |f| - CaskLoader::FromTapPathLoader.new(f).load(config: nil) + attr_predicate :loaded_from_api? + + def self.all(eval_all: false) + if !eval_all && !Homebrew::EnvConfig.eval_all? + raise ArgumentError, "Cask::Cask#all cannot be used without `--eval-all` or HOMEBREW_EVAL_ALL" + 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) + tokens_and_files.filter_map do |token_or_file| + CaskLoader.load(token_or_file) rescue CaskUnreadableError => e opoo e.message nil - end.compact + end end def tap @@ -39,13 +57,31 @@ def tap @tap end - def initialize(token, sourcefile_path: nil, source: nil, tap: nil, config: nil, allow_reassignment: false, &block) + sig { + params( + token: String, + sourcefile_path: T.nilable(Pathname), + source: T.nilable(String), + tap: T.nilable(Tap), + loaded_from_api: T::Boolean, + 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, + config: nil, allow_reassignment: false, loader: nil, &block) @token = token @sourcefile_path = sourcefile_path @source = source @tap = tap @allow_reassignment = allow_reassignment - @block = block + @loaded_from_api = loaded_from_api + @loader = loader + # Sorbet has trouble with bound procs assigned to instance variables: + # https://github.com/sorbet/sorbet/issues/6843 + instance_variable_set(:@block, block) @default_config = config || Config.new @@ -56,6 +92,17 @@ def initialize(token, sourcefile_path: nil, source: nil, tap: nil, config: nil, end end + # An old name for the cask. + sig { returns(T::Array[String]) } + def old_tokens + @old_tokens ||= if (tap = self.tap) + Tap.tap_migration_oldnames(tap, token) + + tap.cask_reverse_renames.fetch(token, []) + else + [] + end + end + def config=(config) @config = config @@ -70,64 +117,112 @@ def refresh @dsl.language_eval end - DSL::DSL_METHODS.each do |method_name| - define_method(method_name) { |&block| @dsl.send(method_name, &block) } + ::Cask::DSL::DSL_METHODS.each do |method_name| + define_method(method_name) { |*args, &block| @dsl.send(method_name, *args, &block) } end - sig { returns(T::Array[[String, String]]) } - 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) } + sig { params(caskroom_path: Pathname).returns(T::Array[[String, String]]) } + def timestamped_versions(caskroom_path: self.caskroom_path) + relative_paths = Pathname.glob(metadata_timestamped_path( + version: "*", timestamp: "*", + caskroom_path: + )) + .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 versions - timestamped_versions.map(&:first) - .reverse - .uniq - .reverse + # The fully-qualified token of this {Cask}. + # + # @api internal + def full_token + return token if tap.nil? + return token if tap.core_cask_tap? + + "#{tap.name}/#{token}" end - def os_versions - @os_versions ||= begin - version_os_hash = {} - actual_version = MacOS.full_version.to_s + # Alias for {#full_token}. + # + # @api internal + def full_name = full_token - MacOSVersions::SYMBOLS.each do |os_name, os_version| - MacOS.full_version = os_version - cask = CaskLoader.load(token) - version_os_hash[os_name] = cask.version if cask.version != version - end + sig { returns(T::Boolean) } + def installed? + installed_caskfile&.exist? || false + end - version_os_hash - ensure - MacOS.full_version = actual_version + # The caskfile is needed during installation when there are + # `*flight` blocks or the cask has multiple languages + def caskfile_only? + languages.any? || artifacts.any?(Artifact::AbstractFlightBlock) + end + + 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 - def full_name - return token if tap.nil? - return token if tap.user == "Homebrew" + 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 - "#{tap.name}/#{token}" + sig { returns(T.nilable(Pathname)) } + def installed_caskfile + installed_caskroom_path = caskroom_path + installed_token = token + + # Check if the cask is installed with an old name. + old_tokens.each do |old_token| + old_caskroom_path = Caskroom.path/old_token + next if !old_caskroom_path.directory? || old_caskroom_path.symlink? + + installed_caskroom_path = old_caskroom_path + installed_token = old_token + break + end + + installed_version = timestamped_versions(caskroom_path: installed_caskroom_path).last + return unless installed_version + + caskfile_dir = metadata_main_container_path(caskroom_path: installed_caskroom_path) + .join(*installed_version, "Casks") + + ["json", "rb"] + .map { |ext| caskfile_dir.join("#{installed_token}.#{ext}") } + .find(&:exist?) end - def installed? - !versions.empty? + sig { returns(T.nilable(String)) } + def installed_version + return unless (installed_caskfile = self.installed_caskfile) + + # /.metadata///Casks/.{rb,json} -> + installed_caskfile.dirname.dirname.dirname.basename.to_s end - sig { returns(T.nilable(Time)) } - def install_time - _, time = timestamped_versions.last - return unless time + sig { returns(T.nilable(String)) } + def bundle_short_version + bundle_version&.short_version + end - Time.strptime(time, Metadata::TIMESTAMP_FORMAT) + sig { returns(T.nilable(String)) } + def bundle_long_version + bundle_version&.version end - def installed_caskfile - installed_version = timestamped_versions.last - metadata_main_container_path.join(*installed_version, "Casks", "#{token}.rb") + def tab + Tab.for_cask(self) end def config_path @@ -135,6 +230,8 @@ def config_path end def checksumable? + return false if (url = self.url).nil? + DownloadStrategyDetector.detect(url.to_s, url.using) <= AbstractFileDownloadStrategy end @@ -158,56 +255,101 @@ def outdated_download_sha? current_download_sha.blank? || current_download_sha != new_download_sha end + sig { returns(Pathname) } def caskroom_path @caskroom_path ||= Caskroom.path.join(token) end + # Check if the installed cask is outdated. + # + # @api internal def outdated?(greedy: false, greedy_latest: false, greedy_auto_updates: false) - !outdated_versions(greedy: greedy, greedy_latest: greedy_latest, - greedy_auto_updates: greedy_auto_updates).empty? + !outdated_version(greedy:, greedy_latest:, + greedy_auto_updates:).nil? end - def outdated_versions(greedy: false, greedy_latest: false, greedy_auto_updates: false) + 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 versions if (greedy || greedy_latest) && outdated_download_sha? + return installed_version if (greedy || greedy_latest) && outdated_download_sha? - return [] + return elsif auto_updates && !greedy && !greedy_auto_updates - return [] + return end - installed = versions - current = installed.last - # not outdated unless there is a different version on tap - return [] if current == version + return if installed_version == version - # collect all installed versions that are different than tap version and return them - installed.reject { |v| v == version } + installed_version end def outdated_info(greedy, verbose, json, greedy_latest, greedy_auto_updates) return token if !verbose && !json - installed_versions = outdated_versions(greedy: greedy, greedy_latest: greedy_latest, - greedy_auto_updates: greedy_auto_updates).join(", ") + installed_version = outdated_version(greedy:, greedy_latest:, + greedy_auto_updates:).to_s if json { name: token, - installed_versions: installed_versions, + installed_versions: [installed_version], current_version: version, } else - "#{token} (#{installed_versions}) != #{version}" + "#{token} (#{installed_version}) != #{version}" end end - def to_s - @token + def ruby_source_path + return @ruby_source_path if defined?(@ruby_source_path) + + return unless sourcefile_path + return unless tap + + @ruby_source_path = sourcefile_path.relative_path_from(tap.path) + end + + sig { returns(T::Hash[Symbol, String]) } + def ruby_source_checksum + @ruby_source_checksum ||= { + sha256: Digest::SHA256.file(sourcefile_path).hexdigest, + }.freeze + end + + def languages + @languages ||= @dsl.languages + end + + def tap_git_head + @tap_git_head ||= tap&.git_head + rescue TapUnavailableError + nil + end + + def populate_from_api!(json_cask) + raise ArgumentError, "Expected cask to be loaded from the API" unless loaded_from_api? + + @languages = json_cask.fetch(:languages, []) + @tap_git_head = json_cask.fetch(:tap_git_head, "HEAD") + + @ruby_source_path = json_cask[:ruby_source_path] + + # TODO: Clean this up when we deprecate the current JSON API and move to the internal JSON v3. + ruby_source_sha256 = json_cask.dig(:ruby_source_checksum, :sha256) + ruby_source_sha256 ||= json_cask[:ruby_source_sha256] + @ruby_source_checksum = { sha256: ruby_source_sha256 } + end + + # @api public + sig { returns(String) } + def to_s = token + + sig { returns(String) } + def inspect + "#" end def hash @@ -219,95 +361,180 @@ def eql?(other) end alias == eql? - def to_hash + def to_h { - "token" => token, - "full_token" => full_name, - "tap" => tap&.name, - "name" => name, - "desc" => desc, - "homepage" => homepage, - "url" => url, - "appcast" => appcast, - "version" => version, - "versions" => os_versions, - "installed" => versions.last, - "outdated" => outdated?, - "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, + "installed" => installed_version, + "installed_time" => install_time&.to_i, + "bundle_version" => bundle_long_version, + "bundle_short_version" => bundle_short_version, + "outdated" => outdated?, + "sha256" => sha256, + "artifacts" => artifacts_list, + "caveats" => (caveats unless caveats.empty?), + "depends_on" => depends_on, + "conflicts_with" => conflicts_with, + "container" => container&.pairs, + "auto_updates" => auto_updates, + "deprecated" => deprecated?, + "deprecation_date" => deprecation_date, + "deprecation_reason" => deprecation_reason, + "deprecation_replacement" => deprecation_replacement, + "disabled" => disabled?, + "disable_date" => disable_date, + "disable_reason" => disable_reason, + "disable_replacement" => disable_replacement, + "tap_git_head" => tap_git_head, + "languages" => languages, + "ruby_source_path" => ruby_source_path, + "ruby_source_checksum" => ruby_source_checksum, } end - def to_h - hash = to_hash - variations = {} + def to_internal_api_hash + api_hash = { + "token" => token, + "name" => name, + "desc" => desc, + "homepage" => homepage, + "url" => url, + "version" => version, + "sha256" => sha256, + "artifacts" => artifacts_list(compact: true), + "ruby_source_path" => ruby_source_path, + "ruby_source_sha256" => ruby_source_checksum.fetch(:sha256), + } - hash_keys_to_skip = %w[outdated installed versions] + if deprecation_date + api_hash["deprecation_date"] = deprecation_date + api_hash["deprecation_reason"] = deprecation_reason + api_hash["deprecation_replacement"] = deprecation_replacement + end - if @dsl.on_system_blocks_exist? - [:arm, :intel].each do |arch| - MacOSVersions::SYMBOLS.each_key do |os_name| - # Big Sur is the first version of macOS that supports arm - next if arch == :arm && MacOS::Version.from_symbol(os_name) < MacOS::Version.from_symbol(:big_sur) + if disable_date + api_hash["disable_date"] = disable_date + api_hash["disable_reason"] = disable_reason + api_hash["disable_replacement"] = disable_replacement + end + + if (url_specs_hash = url_specs).present? + api_hash["url_specs"] = url_specs_hash + end + + api_hash["caskfile_only"] = true if caskfile_only? + api_hash["conflicts_with"] = conflicts_with if conflicts_with.present? + api_hash["depends_on"] = depends_on if depends_on.present? + api_hash["container"] = container.pairs if container + api_hash["caveats"] = caveats if caveats.present? + api_hash["auto_updates"] = auto_updates if auto_updates + api_hash["languages"] = languages if languages.present? - Homebrew::SimulateSystem.os = os_name - Homebrew::SimulateSystem.arch = arch + api_hash + end - refresh + HASH_KEYS_TO_SKIP = %w[outdated installed versions].freeze + private_constant :HASH_KEYS_TO_SKIP - bottle_tag = ::Utils::Bottles::Tag.new(system: os_name, arch: arch).to_sym - to_hash.each do |key, value| - next if hash_keys_to_skip.include? key - next if value.to_s == hash[key].to_s + def to_hash_with_variations(hash_method: :to_h) + case hash_method + when :to_h + if loaded_from_api? && !Homebrew::EnvConfig.no_install_from_api? + return api_to_local_hash(Homebrew::API::Cask.all_casks[token].dup) + end + when :to_internal_api_hash + raise ArgumentError, "API Hash must be generated from Ruby source files" if loaded_from_api? + else + raise ArgumentError, "Unknown hash method #{hash_method.inspect}" + end - variations[bottle_tag] ||= {} - variations[bottle_tag][key] = value + hash = public_send(hash_method) + variations = {} + + if @dsl.on_system_blocks_exist? + begin + MacOSVersion::SYMBOLS.keys.product(OnSystem::ARCH_OPTIONS).each do |os, arch| + bottle_tag = ::Utils::Bottles::Tag.new(system: os, arch:) + next unless bottle_tag.valid_combination? + next if depends_on.macos && + !@dsl.depends_on_set_in_block? && + !depends_on.macos.allows?(bottle_tag.to_macos_version) + + Homebrew::SimulateSystem.with(os:, arch:) do + refresh + + public_send(hash_method).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 - Homebrew::SimulateSystem.clear - refresh - - hash["variations"] = variations + hash["variations"] = variations if hash_method != :to_internal_api_hash || variations.present? hash end - private - - def to_h_string_gsubs(string) - string.to_s - .gsub(Dir.home, "$HOME") - .gsub(HOMEBREW_PREFIX, "$(brew --prefix)") + def artifacts_list(compact: false, 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 + # We can skip this entirely once we move to internal JSON v3. + { artifact.summarize.to_sym => nil } unless compact + 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 + + { artifact.class.dsl_key => artifact.to_args } + end + end end - def to_h_array_gsubs(array) - array.to_a.map do |value| - to_h_gsubs(value) + private + + sig { returns(T.nilable(Homebrew::BundleVersion)) } + def bundle_version + @bundle_version ||= if (bundle = artifacts.find { |a| a.is_a?(Artifact::App) }&.target) && + (plist = Pathname("#{bundle}/Contents/Info.plist")) && plist.exist? + Homebrew::BundleVersion.from_info_plist(plist) end end - def to_h_hash_gsubs(hash) - hash.to_h.transform_values do |value| - to_h_gsubs(value) - end - rescue TypeError - to_h_array_gsubs(hash) + def api_to_local_hash(hash) + hash["token"] = token + hash["installed"] = installed_version + hash["outdated"] = outdated? + 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) + 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.rbi b/Library/Homebrew/cask/cask.rbi index f2cb16087afbc..1f789206e3bf0 100644 --- a/Library/Homebrew/cask/cask.rbi +++ b/Library/Homebrew/cask/cask.rbi @@ -2,7 +2,7 @@ module Cask class Cask - def appcast; end + def appdir; end def artifacts; end @@ -14,9 +14,27 @@ module Cask def container; end + def depends_on; end + def desc; end - def depends_on; end + def discontinued?; end + + def deprecated?; end + + def deprecation_date; end + + def deprecation_reason; end + + def deprecation_replacement; end + + def disabled?; end + + def disable_date; end + + def disable_reason; end + + def disable_replacement; end def homepage; end @@ -24,22 +42,26 @@ module Cask def languages; end + def livecheck; end + + def livecheck_defined?; end + + def livecheckable?; end + def name; end + def on_system_blocks_exist?; end + + sig { returns(T.nilable(MacOSVersion)) } + def on_system_block_min_os; end + def sha256; end def staged_path; end + sig { returns(T.nilable(::Cask::URL)) } def url; end def version; end - - def appdir; end - - def discontinued?; end - - def livecheck; end - - def livecheckable?; end end end diff --git a/Library/Homebrew/cask/cask_loader.rb b/Library/Homebrew/cask/cask_loader.rb index 8049077ee6280..a91882a6e5049 100644 --- a/Library/Homebrew/cask/cask_loader.rb +++ b/Library/Homebrew/cask/cask_loader.rb @@ -1,34 +1,84 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/cache" require "cask/cask" require "uri" +require "utils/curl" +require "extend/hash/keys" module Cask # Loads a cask from various sources. - # - # @api private module CaskLoader + extend Context + + ALLOWED_URL_SCHEMES = %w[file].freeze + private_constant :ALLOWED_URL_SCHEMES + + module ILoader + extend T::Helpers + interface! + + sig { abstract.params(config: T.nilable(Config)).returns(Cask) } + def load(config:); end + end + # Loads a cask from a string. - class FromContentLoader + 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 { returns(T.nilable(Tap)) } + attr_reader :tap + + private + + 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 - token = /(?:"[^"]*"|'[^']*')/ - curly = /\(\s*#{token.source}\s*\)\s*\{.*\}/ - do_end = /\s+#{token.source}\s+do(?:\s*;\s*|\s+).*end/ - regex = /\A\s*cask(?:#{curly.source}|#{do_end.source})\s*\Z/m + # Cache compiled regex + @regex ||= 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 - content.match?(regex) + return unless content.match?(@regex) + + new(content) end - def initialize(content) - @content = content.force_encoding("UTF-8") + sig { params(content: String, tap: Tap).void } + def initialize(content, tap: T.unsafe(nil)) + super() + + @content = content.dup.force_encoding("UTF-8") + @tap = tap end def load(config:) @@ -36,30 +86,47 @@ def load(config:) instance_eval(content, __FILE__, __LINE__) end - - private - - def cask(header_token, **options, &block) - Cask.new(header_token, source: content, **options, config: @config, &block) - end end # Loads a cask from a path. - class FromPathLoader < FromContentLoader - def self.can_load?(ref) - path = Pathname(ref) - path.extname == ".rb" && path.expand_path.exist? + 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 if %w[.rb .json].exclude?(path.extname) + return unless path.expand_path.exist? + + return if Homebrew::EnvConfig.forbid_packages_from_paths? && + !path.realpath.to_s.start_with?("#{Caskroom.path}/", "#{HOMEBREW_LIBRARY}/Taps/") + + new(path) end attr_reader :token, :path - def initialize(path) # rubocop:disable Lint/MissingSuper + sig { params(path: T.any(Pathname, String), token: String).void } + def initialize(path, token: T.unsafe(nil)) + super() + path = Pathname(path).expand_path - @token = path.basename(".rb").to_s + @token = path.basename(path.extname).to_s @path = path + @tap = Tap.from_path(path) || Homebrew::API.tap_from_source_download(path) end + 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? @@ -68,6 +135,10 @@ def load(config:) @content = path.read(encoding: "UTF-8") @config = config + if path.extname == ".json" + return FromAPILoader.new(token, from_json: JSON.parse(@content), path:).load(config:) + end + begin instance_eval(content, path).tap do |cask| raise CaskUnreadableError.new(token, "'#{path}' does not contain a cask.") unless cask.is_a?(Cask) @@ -90,33 +161,50 @@ def cask(header_token, **options, &block) # Loads a cask from a URI. class FromURILoader < FromPathLoader - extend T::Sig + 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 ||= begin + uri_regex = ::URI::DEFAULT_PARSER.make_regexp + Regexp.new("\\A#{uri_regex.source}\\Z", uri_regex.options) + end - 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 = ref.to_s + return unless uri.match?(@uri_regex) - uri = URI(ref) - return false unless uri - return false unless uri.path + uri = URI(uri) + return unless uri.path - true + new(uri) end - attr_reader :url + attr_reader :url, :name sig { params(url: T.any(URI::Generic, String)).void } def initialize(url) @url = URI(url) - super Cache.path/File.basename(@url.path) + @name = File.basename(T.must(@url.path)) + super Cache.path/name end 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 extract` or `brew create` and `brew tap-new` to create a formula file in a tap " \ + "on GitHub instead." + end + begin ohai "Downloading #{url}" - curl_download url, to: path + ::Utils::Curl.curl_download url, to: path rescue ErrorDuringExecution raise CaskUnavailableError.new(token, "Failed to download #{Formatter.url(url)}.") end @@ -125,39 +213,40 @@ def load(config:) end end - # Loads a cask from a tap path. - 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 { 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) - end - end + token, tap, type = token_tap_type - # Loads a cask from a specific tap. - class FromTapLoader < FromTapPathLoader - def self.can_load?(ref) - ref.to_s.match?(HOMEBREW_TAP_CASK_REGEX) + 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, token = Tap.with_cask_token(tapped_token) + cask = CaskLoader.find_cask_in_tap(token, tap) + super cask end + sig { override.params(config: T.nilable(Config)).returns(Cask) } def load(config:) - raise TapCaskUnavailableError.new(tap, token) unless tap.installed? + raise TapCaskUnavailableError.new(tap, token) unless T.must(tap).installed? super end @@ -165,10 +254,17 @@ def load(config:) # Loads a cask from an existing {Cask} instance. class FromInstanceLoader - 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(T.attached_class)) + } + 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 @@ -178,12 +274,268 @@ def load(config:) 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(Hash)) } + attr_reader :from_json + + sig { + 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.no_install_from_api? + return unless ref.is_a?(String) + return unless (token = ref[HOMEBREW_DEFAULT_TAP_CASK_REGEX, :token]) + if !Homebrew::API::Cask.all_casks.key?(token) && + !Homebrew::API::Cask.all_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: Hash, path: T.nilable(Pathname)).void } + def initialize(token, from_json: T.unsafe(nil), path: nil) + @token = token.sub(%r{^homebrew/(?:homebrew-)?cask/}i, "") + @sourcefile_path = path || Homebrew::API::Cask.cached_json_file_path + @path = path || CaskLoader.default_path(@token) + @from_json = from_json + end + + def load(config:) + json_cask = from_json || Homebrew::API::Cask.all_casks.fetch(token) + + cask_options = { + loaded_from_api: true, + sourcefile_path: @sourcefile_path, + source: JSON.pretty_generate(json_cask), + config:, + loader: self, + } + + json_cask = Homebrew::API.merge_variations(json_cask).deep_symbolize_keys.freeze + + cask_options[:tap] = Tap.fetch(json_cask[:tap]) if json_cask[:tap].to_s.include?("/") + + user_agent = json_cask.dig(:url_specs, :user_agent) + json_cask[:url_specs][:user_agent] = user_agent[1..].to_sym if user_agent && user_agent[0] == ":" + if (using = json_cask.dig(:url_specs, :using)) + json_cask[:url_specs][:using] = using.to_sym + end + + api_cask = Cask.new(token, **cask_options) do + version json_cask[:version] + + if json_cask[:sha256] == "no_check" + sha256 :no_check + else + sha256 json_cask[:sha256] + end + + url json_cask[:url], **json_cask.fetch(:url_specs, {}) if json_cask[:url].present? + json_cask[:name]&.each do |cask_name| + name cask_name + end + desc json_cask[:desc] + homepage json_cask[:homepage] + + if (deprecation_date = json_cask[:deprecation_date].presence) + reason = DeprecateDisable.to_reason_string_or_symbol json_cask[:deprecation_reason], type: :cask + deprecate! date: deprecation_date, because: reason + end + + if (disable_date = json_cask[:disable_date].presence) + reason = DeprecateDisable.to_reason_string_or_symbol json_cask[:disable_reason], type: :cask + disable! date: disable_date, because: reason + end + + auto_updates json_cask[:auto_updates] unless json_cask[:auto_updates].nil? + conflicts_with(**json_cask[:conflicts_with]) if json_cask[:conflicts_with].present? + + if json_cask[:depends_on].present? + dep_hash = json_cask[:depends_on].to_h do |dep_key, dep_value| + # Arch dependencies are encoded like `{ type: :intel, bits: 64 }` + # but `depends_on arch:` only accepts `:intel` or `:arm64` + if dep_key == :arch + next [:arch, :intel] if dep_value.first[:type] == "intel" + + next [:arch, :arm64] + end + + next [dep_key, dep_value] if dep_key != :macos + + dep_type = dep_value.keys.first + if dep_type == :== + version_symbols = dep_value[dep_type].map do |version| + MacOSVersion::SYMBOLS.key(version) || version + end + next [dep_key, version_symbols] + end + + version_symbol = dep_value[dep_type].first + version_symbol = MacOSVersion::SYMBOLS.key(version_symbol) || version_symbol + [dep_key, "#{dep_type} :#{version_symbol}"] + end.compact + depends_on(**dep_hash) + end + + if json_cask[:container].present? + container_hash = json_cask[:container].to_h do |container_key, container_value| + next [container_key, container_value] if container_key != :type + + [container_key, container_value.to_sym] + end + container(**container_hash) + end + + json_cask[:artifacts].each do |artifact| + # convert generic string replacements into actual ones + artifact = cask.loader.from_h_gsubs(artifact, appdir) + key = artifact.keys.first + if artifact[key].nil? + # for artifacts with blocks that can't be loaded from the API + send(key) {} # empty on purpose + else + args = artifact[key] + kwargs = if args.last.is_a?(Hash) + args.pop + else + {} + end + send(key, *args, **kwargs) + end + end + + if json_cask[:caveats].present? + # convert generic string replacements into actual ones + caveats cask.loader.from_h_string_gsubs(json_cask[:caveats], appdir) + end + end + api_cask.populate_from_api!(json_cask) + api_cask + end + + def from_h_string_gsubs(string, appdir) + string.to_s + .gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home) + .gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX) + .gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR) + .gsub(HOMEBREW_CASK_APPDIR_PLACEHOLDER, appdir) + end + + def from_h_array_gsubs(array, appdir) + array.to_a.map do |value| + from_h_gsubs(value, appdir) + end + end + + def from_h_hash_gsubs(hash, appdir) + hash.to_h.transform_values do |value| + from_h_gsubs(value, appdir) + end + end + + def from_h_gsubs(value, appdir) + return value if value.blank? + + case value + when Hash + from_h_hash_gsubs(value, appdir) + when Array + from_h_array_gsubs(value, appdir) + when String + from_h_string_gsubs(value, appdir) + else + value + end + end + end + + # Loader which tries loading casks from tap paths, failing + # if the same token exists in multiple taps. + class FromNameLoader < FromTapLoader + 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 + + # If it exists in the default tap, never treat it as ambiguous with another tap. + if (core_cask_tap = CoreCaskTap.instance).installed? && + (core_cask_loader = super("#{core_cask_tap}/#{token}", warn:))&.path&.exist? + return core_cask_loader + 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).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 + end + end + # Pseudo-loader which raises an error when trying to load the corresponding cask. class NullLoader < FromPathLoader - extend T::Sig - - 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 } @@ -198,60 +550,101 @@ def load(config:) end def self.path(ref) - self.for(ref).path + self.for(ref, need_path: true).path + end + + def self.load(ref, config: nil, warn: true) + self.for(ref, warn:).load(config:) end - def self.load(ref, config: nil) - self.for(ref).load(config: config) + 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) || [Tap.fetch(new_tap_name), token] + new_tap.ensure_installed! + new_tapped_token = "#{new_tap}/#{new_token}" + + if tapped_token == new_tapped_token + opoo "Tap migration for #{tapped_token} points to itself, stopping recursion." + else + 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 + + opoo "Cask #{old_token} was renamed to #{new_token}." if warn && old_token && new_token + + [token, tap, type] end - def self.for(ref) + def self.for(ref, need_path: false, warn: true) [ FromInstanceLoader, FromContentLoader, FromURILoader, + FromAPILoader, FromTapLoader, - FromTapPathLoader, + FromNameLoader, FromPathLoader, + FromInstalledPathLoader, + NullLoader, ].each do |loader_class| - next unless loader_class.can_load?(ref) - - if loader_class == FromTapLoader && Homebrew::EnvConfig.install_from_api? && - ref.start_with?("homebrew/cask/") && Homebrew::API::CaskSource.available?(ref) - return FromContentLoader.new(Homebrew::API::CaskSource.fetch(ref)) + if (loader = loader_class.try_new(ref, warn:)) + $stderr.puts "#{$PROGRAM_NAME} (#{loader.class}): loading #{ref}" if debug? + return loader end - - return loader_class.new(ref) end + end - if Homebrew::EnvConfig.install_from_api? && Homebrew::API::CaskSource.available?(ref) - return FromContentLoader.new(Homebrew::API::CaskSource.fetch(ref)) - end - - return FromTapPathLoader.new(default_path(ref)) if FromTapPathLoader.can_load?(default_path(ref)) - - 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 TapCaskAmbiguityError.new(ref, loaders) + 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 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?) + 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 1d35d466d74ce..eb3c6566ca494 100644 --- a/Library/Homebrew/cask/caskroom.rb +++ b/Library/Homebrew/cask/caskroom.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "utils/user" @@ -6,20 +6,31 @@ module Cask # Helper functions for interacting with the `Caskroom` directory. # - # @api private + # @api internal module Caskroom - extend T::Sig - sig { returns(Pathname) } def self.path - @path ||= HOMEBREW_PREFIX/"Caskroom" + @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? - return false unless path.exist? - - path.children.select(&:directory?).any? + paths.any? end sig { void } @@ -33,29 +44,25 @@ def self.ensure_caskroom_exists "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("/bin/mkdir", args: ["-p", path], sudo:) + SystemCommand.run("/bin/chmod", args: ["g+rwx", path], sudo:) + SystemCommand.run("/usr/sbin/chown", args: [User.current, path], sudo:) + SystemCommand.run("/usr/bin/chgrp", args: ["admin", path], sudo:) end + # Get all installed casks. + # + # @api internal sig { params(config: T.nilable(Config)).returns(T::Array[Cask]) } def self.casks(config: nil) - return [] unless path.exist? - - path.children.select(&:directory?).sort.map do |path| - token = path.basename.to_s - - begin - CaskLoader.load(token, config: config) - rescue TapCaskAmbiguityError - tap_path = CaskLoader.tap_paths(token).first - CaskLoader::FromTapPathLoader.new(tap_path).load(config: config) - rescue CaskUnavailableError - # Don't blow up because of a single unavailable cask. - nil - end - end.compact + tokens.sort.filter_map do |token| + CaskLoader.load_prefer_installed(token, config:, warn: false) + rescue TapCaskAmbiguityError => e + T.must(e.loaders.first).load(config:) + rescue + # Don't blow up because of a single unavailable cask. + nil + end end end end diff --git a/Library/Homebrew/cask/cmd.rb b/Library/Homebrew/cask/cmd.rb deleted file mode 100644 index 1956cc445377e..0000000000000 --- a/Library/Homebrew/cask/cmd.rb +++ /dev/null @@ -1,40 +0,0 @@ -# typed: true -# frozen_string_literal: true - -require "optparse" -require "shellwords" - -require "cli/parser" -require "extend/optparse" - -require "cask/config" - -require "cask/cmd/abstract_command" -require "cask/cmd/audit" -require "cask/cmd/fetch" -require "cask/cmd/info" -require "cask/cmd/install" -require "cask/cmd/list" -require "cask/cmd/reinstall" -require "cask/cmd/uninstall" -require "cask/cmd/upgrade" -require "cask/cmd/zap" - -module Cask - # Implementation of the `brew cask` command-line interface. - # - # @api private - class Cmd - extend T::Sig - - include Context - - def self.parser(&block) - Homebrew::CLI::Parser.new do - instance_eval(&block) if block - - cask_options - 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 6c93a9e49f674..0000000000000 --- a/Library/Homebrew/cask/cmd/abstract_command.rb +++ /dev/null @@ -1,99 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "search" - -module Cask - class Cmd - # Abstract superclass for all Cask implementations of commands. - # - # @api private - class AbstractCommand - extend T::Sig - extend T::Helpers - - include Homebrew::Search - - OPTIONS = [ - [: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, "--[no-]quarantine", { - description: "Disable/enable quarantining of downloads (default: enabled).", - env: :cask_opts_quarantine, - }], - ].freeze - - def self.parser(&block) - Cmd.parser do - instance_eval(&block) if block - - OPTIONS.each do |option| - send(*option) - end - end - end - - sig { returns(String) } - def self.command_name - @command_name ||= name.sub(/^.*:/, "").gsub(/(.)([A-Z])/, '\1_\2').downcase - end - - sig { returns(T::Boolean) } - def self.abstract? - name.split("::").last.match?(/^Abstract[^a-z]/) - end - - sig { returns(T::Boolean) } - def self.visible? - true - end - - sig { returns(String) } - def self.help - parser.generate_help_text - end - - sig { returns(String) } - def self.short_description - description[/\A[^.]*\./] - end - - def self.run(*args) - new(*args).run - end - - attr_reader :args - - def initialize(*args) - @args = self.class.parser.parse(args) - end - - private - - def casks(alternative: -> { [] }) - return @casks if defined?(@casks) - - @casks = args.named.empty? ? alternative.call : args.named.to_casks - 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/audit.rb b/Library/Homebrew/cask/cmd/audit.rb deleted file mode 100644 index 516f4199bd58e..0000000000000 --- a/Library/Homebrew/cask/cmd/audit.rb +++ /dev/null @@ -1,109 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "utils/github/actions" - -module Cask - class Cmd - # Cask implementation of the `brew audit` command. - # - # @api private - class Audit < AbstractCommand - extend T::Sig - - def self.parser - super do - switch "--[no-]download", - description: "Audit the downloaded file" - switch "--[no-]appcast", - description: "Audit the appcast" - switch "--[no-]token-conflicts", - description: "Audit for token conflicts" - switch "--[no-]strict", - description: "Run additional, stricter style checks" - switch "--[no-]online", - description: "Run additional, slower style checks that require a network connection" - switch "--new-cask", - description: "Run various additional style checks to determine if a new cask is eligible " \ - "for Homebrew. This should be used when creating new casks and implies " \ - "`--strict` and `--online`" - switch "--display-failures-only", - description: "Only display casks that fail the audit. This is the default for formulae." - end - end - - sig { void } - def run - casks = args.named.flat_map do |name| - next name if File.exist?(name) - next Tap.fetch(name).cask_files if name.count("/") == 1 - - name - end - casks = casks.map { |c| CaskLoader.load(c, config: Config.from_args(args)) } - any_named_args = casks.any? - casks = Cask.to_a if casks.empty? - - results = self.class.audit_casks( - *casks, - download: args.download?, - appcast: args.appcast?, - online: args.online?, - strict: args.strict?, - new_cask: args.new_cask?, - token_conflicts: args.token_conflicts?, - quarantine: args.quarantine?, - any_named_args: any_named_args, - language: args.language, - display_passes: args.verbose? || args.named.count == 1, - display_failures_only: args.display_failures_only?, - ) - - failed_casks = results.reject { |_, result| result[:errors].empty? }.map(&:first) - return if failed_casks.empty? - - raise CaskError, "audit failed for casks: #{failed_casks.join(" ")}" - end - - def self.audit_casks( - *casks, - download: nil, - appcast: nil, - online: nil, - strict: nil, - new_cask: nil, - token_conflicts: nil, - quarantine: nil, - any_named_args: nil, - language: nil, - display_passes: nil, - display_failures_only: nil - ) - options = { - audit_download: download, - audit_appcast: appcast, - audit_online: online, - audit_strict: strict, - audit_new_cask: new_cask, - audit_token_conflicts: token_conflicts, - quarantine: quarantine, - language: language, - any_named_args: any_named_args, - display_passes: display_passes, - display_failures_only: display_failures_only, - }.compact - - options[:quarantine] = true if options[:quarantine].nil? - - Homebrew.auditing = true - - require "cask/auditor" - - casks.to_h do |cask| - odebug "Auditing Cask #{cask}" - [cask.sourcefile_path, Auditor.audit(cask, **options)] - end - 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 da0eb66295629..0000000000000 --- a/Library/Homebrew/cask/cmd/fetch.rb +++ /dev/null @@ -1,41 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module Cask - class Cmd - # Cask implementation of the `brew fetch` command. - # - # @api private - class Fetch < AbstractCommand - extend T::Sig - - def self.parser - super do - switch "--force", - description: "Force redownloading even if files already exist in local cache." - end - end - - sig { void } - def run - require "cask/download" - require "cask/installer" - - options = { - quarantine: args.quarantine?, - }.compact - - options[:quarantine] = true if options[:quarantine].nil? - - casks.each do |cask| - puts Installer.caveats(cask) - ohai "Downloading external files for Cask #{cask}" - download = Download.new(cask, **options) - download.clear_cache if args.force? - downloaded_path = download.fetch - ohai "Success! Downloaded to: #{downloaded_path}" - end - 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 c5d4d9a410153..0000000000000 --- a/Library/Homebrew/cask/cmd/info.rb +++ /dev/null @@ -1,154 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "json" - -module Cask - class Cmd - # Cask implementation of the `brew info` command. - # - # @api private - class Info < AbstractCommand - extend T::Sig - - def self.parser - super do - flag "--json=", - description: "Output information in JSON format." - switch "--github", - description: "Open the GitHub source page for in a browser. " - end - end - - def github_info(cask) - sourcefile_path = cask.sourcefile_path - dir = cask.tap.path - path = sourcefile_path.relative_path_from(dir) - remote = cask.tap.remote - github_remote_path(remote, path) - end - - 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 - - sig { void } - def run - if args.json == "v1" - puts JSON.pretty_generate(args.named.to_casks.map(&:to_h)) - elsif args.github? - raise CaskUnspecifiedError if args.no_named? - - args.named.to_casks.map do |cask| - exec_browser(github_info(cask)) - end - else - args.named.to_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.get_info(cask) - require "cask/installer" - - 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) - output << desc_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) - ::Utils::Analytics.cask_output(cask, args: Homebrew::CLI::Args.new) - 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.desc_info(cask) - <<~EOS - #{ohai_title("Description")} - #{cask.desc.nil? ? Formatter.error("None") : cask.desc} - 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/HEAD/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 69bd9d80fab2d..0000000000000 --- a/Library/Homebrew/cask/cmd/install.rb +++ /dev/null @@ -1,86 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module Cask - class Cmd - # Cask implementation of the `brew install` command. - # - # @api private - class Install < AbstractCommand - extend T::Sig - - OPTIONS = [ - [: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.*", - }], - ].freeze - - def self.parser(&block) - super do - switch "--force", - description: "Force overwriting existing files." - - OPTIONS.each do |option| - send(*option) - end - - instance_eval(&block) if block - end - end - - sig { void } - def run - self.class.install_casks( - *casks, - binaries: args.binaries?, - verbose: args.verbose?, - force: args.force?, - skip_cask_deps: args.skip_cask_deps?, - require_sha: args.require_sha?, - quarantine: args.quarantine?, - quiet: args.quiet?, - zap: args.zap?, - ) - end - - def self.install_casks( - *casks, - verbose: nil, - force: nil, - binaries: nil, - skip_cask_deps: nil, - require_sha: nil, - quarantine: nil, - quiet: nil, - zap: nil - ) - odie "Installing casks is supported only on macOS" unless OS.mac? - - options = { - verbose: verbose, - force: force, - binaries: binaries, - skip_cask_deps: skip_cask_deps, - require_sha: require_sha, - quarantine: quarantine, - quiet: quiet, - zap: zap, - }.compact - - options[:quarantine] = true if options[:quarantine].nil? - - require "cask/installer" - - casks.each do |cask| - Installer.new(cask, **options).install - rescue CaskAlreadyInstalledError => e - opoo e.message - end - 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 e38b994d0e731..0000000000000 --- a/Library/Homebrew/cask/cmd/list.rb +++ /dev/null @@ -1,81 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "cask/artifact/relocated" - -module Cask - class Cmd - # Cask implementation of the `brew list` command. - # - # @api private - class List < AbstractCommand - extend T::Sig - - def self.parser - super do - switch "-1", - description: "Force output to be one entry per line." - switch "--versions", - description: "Show the version number the listed casks." - switch "--full-name", - description: "Print casks with fully-qualified names." - switch "--json", - description: "Print a JSON representation of the listed casks. " - end - end - - sig { void } - def run - self.class.list_casks( - *casks, - json: args.json?, - one: args.public_send(:"1?"), - full_name: args.full_name?, - versions: args.versions?, - ) - end - - def self.list_casks(*casks, json: false, 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 json - puts JSON.pretty_generate(output.map(&:to_h)) - elsif one - puts output.map(&:to_s) - elsif full_name - puts output.map(&:full_name).sort(&tap_and_name_comparison) - elsif versions - puts output.map(&method(:format_versioned)) - elsif !output.empty? && casks.any? - output.map(&method(:list_artifacts)) - elsif !output.empty? - puts Formatter.columns(output.map(&:to_s)) - end - end - - 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 - - def self.format_versioned(cask) - cask.to_s.concat(cask.versions.map(&:to_s).join(" ").prepend(" ")) - 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 9637805450032..0000000000000 --- a/Library/Homebrew/cask/cmd/reinstall.rb +++ /dev/null @@ -1,56 +0,0 @@ -# typed: true -# frozen_string_literal: true - -module Cask - class Cmd - # Cask implementation of the `brew reinstall` command. - # - # @api private - class Reinstall < Install - extend T::Sig - - sig { void } - def run - self.class.reinstall_casks( - *casks, - binaries: args.binaries?, - verbose: args.verbose?, - force: args.force?, - skip_cask_deps: args.skip_cask_deps?, - require_sha: args.require_sha?, - quarantine: args.quarantine?, - zap: args.zap?, - ) - end - - def self.reinstall_casks( - *casks, - verbose: nil, - force: nil, - skip_cask_deps: nil, - binaries: nil, - require_sha: nil, - quarantine: nil, - zap: nil - ) - require "cask/installer" - - options = { - binaries: binaries, - verbose: verbose, - force: force, - skip_cask_deps: skip_cask_deps, - require_sha: require_sha, - quarantine: quarantine, - zap: zap, - }.compact - - options[:quarantine] = true if options[:quarantine].nil? - - casks.each do |cask| - Installer.new(cask, **options).reinstall - end - 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 1ab2c8ce2d2aa..0000000000000 --- a/Library/Homebrew/cask/cmd/uninstall.rb +++ /dev/null @@ -1,56 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module Cask - class Cmd - # Cask implementation of the `brew uninstall` command. - # - # @api private - class Uninstall < AbstractCommand - extend T::Sig - - def self.parser - super do - switch "--force", - description: "Uninstall even if the is not installed, overwrite " \ - "existing files and ignore errors when removing files." - end - end - - sig { void } - def run - self.class.uninstall_casks( - *casks, - binaries: args.binaries?, - verbose: args.verbose?, - force: args.force?, - ) - end - - def self.uninstall_casks(*casks, binaries: nil, force: false, verbose: false) - require "cask/installer" - - options = { - binaries: binaries, - force: force, - verbose: verbose, - }.compact - - casks.each do |cask| - odebug "Uninstalling Cask #{cask}" - - raise CaskNotInstalledError, cask if !cask.installed? && !force - - Installer.new(cask, **options).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 uninstall --cask --force #{cask}`. - EOS - end - 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 ec97f1dccc56e..0000000000000 --- a/Library/Homebrew/cask/cmd/upgrade.rb +++ /dev/null @@ -1,246 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "env_config" -require "cask/config" - -module Cask - class Cmd - # Cask implementation of the `brew upgrade` command. - # - # @api private - class Upgrade < AbstractCommand - extend T::Sig - - OPTIONS = [ - [:switch, "--skip-cask-deps", { - description: "Skip installing cask dependencies.", - }], - [:switch, "--greedy", { - description: "Also include casks with `auto_updates true` or `version :latest`.", - }], - [:switch, "--greedy-latest", { - description: "Also include casks with `version :latest`.", - }], - [:switch, "--greedy-auto-updates", { - description: "Also include casks with `auto_updates true`.", - }], - ].freeze - - sig { returns(Homebrew::CLI::Parser) } - def self.parser - super do - switch "--force", - description: "Force overwriting existing files." - switch "--dry-run", - description: "Show what would be upgraded, but do not actually upgrade anything." - - OPTIONS.each do |option| - send(*option) - end - end - end - - sig { void } - def run - verbose = ($stdout.tty? || args.verbose?) && !args.quiet? - self.class.upgrade_casks( - *casks, - force: args.force?, - greedy: args.greedy?, - greedy_latest: args.greedy_latest?, - greedy_auto_updates: args.greedy_auto_updates?, - dry_run: args.dry_run?, - binaries: args.binaries?, - quarantine: args.quarantine?, - require_sha: args.require_sha?, - skip_cask_deps: args.skip_cask_deps?, - verbose: verbose, - args: args, - ) - 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), - binaries: T.nilable(T::Boolean), - quarantine: T.nilable(T::Boolean), - require_sha: T.nilable(T::Boolean), - ).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, - binaries: nil, - quarantine: nil, - require_sha: nil - ) - - quarantine = true if quarantine.nil? - - outdated_casks = if casks.empty? - Caskroom.casks(config: Config.from_args(args)).select do |cask| - cask.outdated?(greedy: greedy, greedy_latest: greedy_latest, - greedy_auto_updates: greedy_auto_updates) - end - else - casks.select do |cask| - raise CaskNotInstalledError, cask if !cask.installed? && !force - - if cask.outdated?(greedy: true) - true - elsif cask.version.latest? - opoo "Not upgrading #{cask.token}, the downloaded artifact has not changed" - false - else - opoo "Not upgrading #{cask.token}, the latest version is already installed" - false - end - end - end - - manual_installer_casks = outdated_casks.select do |cask| - cask.artifacts.any?(Artifact::Installer::ManualInstaller) - end - - if manual_installer_casks.present? - count = manual_installer_casks.count - ofail "Not upgrading #{count} `installer manual` #{"cask".pluralize(count)}." - puts manual_installer_casks.map(&:to_s) - outdated_casks -= manual_installer_casks - end - - return false if outdated_casks.empty? - - if casks.empty? && !greedy - if !greedy_auto_updates && !greedy_latest - ohai "Casks with 'auto_updates true' or 'version :latest' " \ - "will not be upgraded; pass `--greedy` to upgrade them." - end - if greedy_auto_updates && !greedy_latest - ohai "Casks with 'version :latest' will not be upgraded; pass `--greedy-latest` to upgrade them." - end - if !greedy_auto_updates && greedy_latest - ohai "Casks with 'auto_updates true' will not be upgraded; pass `--greedy-auto-updates` to upgrade them." - end - end - - 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("\n") - return true if dry_run - - upgradable_casks.each do |(old_cask, new_cask)| - upgrade_cask( - old_cask, new_cask, - binaries: binaries, force: force, skip_cask_deps: skip_cask_deps, verbose: verbose, - quarantine: quarantine, require_sha: require_sha - ) - rescue => e - caught_exceptions << e.exception("#{new_cask.full_name}: #{e}") - next - end - - return true if caught_exceptions.empty? - raise MultipleCaskErrors, caught_exceptions if caught_exceptions.count > 1 - raise caught_exceptions.first if caught_exceptions.count == 1 - end - - def self.upgrade_cask( - old_cask, new_cask, - binaries:, force:, quarantine:, require_sha:, skip_cask_deps:, verbose: - ) - require "cask/installer" - - start_time = Time.now - odebug "Started upgrade process for Cask #{old_cask}" - old_config = old_cask.config - - old_options = { - binaries: binaries, - verbose: verbose, - force: force, - upgrade: true, - }.compact - - old_cask_installer = - Installer.new(old_cask, **old_options) - - new_cask.config = new_cask.default_config.merge(old_config) - - new_options = { - binaries: binaries, - verbose: verbose, - force: force, - skip_cask_deps: skip_cask_deps, - require_sha: require_sha, - upgrade: true, - quarantine: quarantine, - }.compact - - new_cask_installer = - Installer.new(new_cask, **new_options) - - 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 - - if (caveats = new_cask_installer.caveats) - puts caveats - end - - 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_time = Time.now - Homebrew.messages.package_installed(new_cask.token, end_time - start_time) - 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 b06bfa4223e52..0000000000000 --- a/Library/Homebrew/cask/cmd/zap.rb +++ /dev/null @@ -1,42 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module Cask - class Cmd - # Cask implementation for the `brew uninstall` command. - # - # @api private - class Zap < AbstractCommand - extend T::Sig - - def self.parser - super do - switch "--force", - description: "Ignore errors when removing files." - end - end - - sig { void } - def run - self.class.zap_casks(*casks, verbose: args.verbose?, force: args.force?) - end - - sig { params(casks: Cask, force: T.nilable(T::Boolean), verbose: T.nilable(T::Boolean)).void } - def self.zap_casks( - *casks, - force: nil, - verbose: nil - ) - require "cask/installer" - - casks.each do |cask| - odebug "Zapping Cask #{cask}" - - raise CaskNotInstalledError, cask if !cask.installed? && !force - - Installer.new(cask, verbose: verbose, force: force).zap - end - end - end - end -end diff --git a/Library/Homebrew/cask/config.rb b/Library/Homebrew/cask/config.rb index c99ac319da41b..ae8c2539fb0aa 100644 --- a/Library/Homebrew/cask/config.rb +++ b/Library/Homebrew/cask/config.rb @@ -1,38 +1,39 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "json" require "lazy_object" require "locale" - -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" module Cask # Configuration for installing casks. # - # @api private + # @api internal class Config - extend T::Sig - - DEFAULT_DIRS = { - appdir: "/Applications", - 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 - + DEFAULT_DIRS = T.let( + { + appdir: "/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], + ) + + sig { returns(T::Hash[Symbol, T.untyped]) } def self.defaults { languages: LazyObject.new { MacOS.languages }, @@ -41,8 +42,12 @@ def self.defaults 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, + keyboard_layoutdir: args.keyboard_layoutdir, colorpickerdir: args.colorpickerdir, prefpanedir: args.prefpanedir, qlplugindir: args.qlplugindir, @@ -68,19 +73,26 @@ def self.from_json(json, ignore_invalid_keys: false) default: config.fetch("default", {}), env: config.fetch("env", {}), explicit: config.fetch("explicit", {}), - ignore_invalid_keys: ignore_invalid_keys, + ignore_invalid_keys:, ) end sig { - params(config: T::Enumerable[[T.any(String, Symbol), T.any(String, Pathname, T::Array[String])]]) - .returns(T::Hash[Symbol, T.any(String, Pathname, T::Array[String])]) + params( + config: T::Enumerable[ + [T.any(String, Symbol), T.any(String, Pathname, T::Array[String])], + ], + ).returns( + T::Hash[Symbol, T.any(String, Pathname, T::Array[String])], + ) } def self.canonicalize(config) config.to_h do |k, v| key = k.to_sym if DEFAULT_DIRS.key?(key) + raise TypeError, "Invalid path for default dir #{k}: #{v.inspect}" if v.is_a?(Array) + [key, Pathname(v).expand_path] else [key, v] @@ -88,6 +100,9 @@ def self.canonicalize(config) end end + # Get the explicit configuration. + # + # @api internal sig { returns(T::Hash[Symbol, T.any(String, Pathname, T::Array[String])]) } attr_accessor :explicit @@ -100,9 +115,22 @@ def self.canonicalize(config) ).void } def initialize(default: nil, env: nil, explicit: {}, ignore_invalid_keys: false) - @default = self.class.canonicalize(self.class.defaults.merge(default)) if default - @env = self.class.canonicalize(env) if env - @explicit = self.class.canonicalize(explicit) + if default + @default = T.let( + self.class.canonicalize(self.class.defaults.merge(default)), + T.nilable(T::Hash[Symbol, T.any(String, Pathname, T::Array[String])]), + ) + end + if env + @env = T.let( + self.class.canonicalize(env), + T.nilable(T::Hash[Symbol, T.any(String, Pathname, T::Array[String])]), + ) + end + @explicit = T.let( + self.class.canonicalize(explicit), + T::Hash[Symbol, T.any(String, Pathname, T::Array[String])], + ) if ignore_invalid_keys @env&.delete_if { |key, _| self.class.defaults.keys.exclude?(key) } @@ -110,8 +138,8 @@ def initialize(default: nil, env: nil, explicit: {}, ignore_invalid_keys: false) return end - @env&.assert_valid_keys!(*self.class.defaults.keys) - @explicit.assert_valid_keys!(*self.class.defaults.keys) + @env&.assert_valid_keys(*self.class.defaults.keys) + @explicit.assert_valid_keys(*self.class.defaults.keys) end sig { returns(T::Hash[Symbol, T.any(String, Pathname, T::Array[String])]) } @@ -140,20 +168,20 @@ def env 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 sig { returns(T::Array[String]) } def languages [ - *T.cast(explicit.fetch(:languages, []), T::Array[String]), - *T.cast(env.fetch(:languages, []), T::Array[String]), - *T.cast(default.fetch(:languages, []), T::Array[String]), + *explicit.fetch(:languages, []), + *env.fetch(:languages, []), + *default.fetch(:languages, []), ].uniq.select do |lang| # Ensure all languages are valid. Locale.parse(lang) @@ -163,16 +191,19 @@ def languages 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 @@ -182,25 +213,13 @@ def merge(other) self.class.new(explicit: other.explicit.merge(explicit)) end - sig { returns(String) } - def explicit_s - explicit.map do |key, value| - # inverse of #env - converts :languages config key back to --language flag - if key == :languages - key = "language" - value = T.cast(explicit.fetch(:languages, []), T::Array[String]).join(",") - end - "#{key}: \"#{value.to_s.sub(/^#{Dir.home}/, "~")}\"" - end.join(", ") - end - sig { params(options: T.untyped).returns(String) } - def to_json(**options) + def to_json(*options) { - default: default, - env: env, - explicit: explicit, - }.to_json(**options) + default:, + env:, + explicit:, + }.to_json(*options) end end end diff --git a/Library/Homebrew/cask/denylist.rb b/Library/Homebrew/cask/denylist.rb index 6b3a0ca5896a1..d65584862e8cf 100644 --- a/Library/Homebrew/cask/denylist.rb +++ b/Library/Homebrew/cask/denylist.rb @@ -3,11 +3,7 @@ module Cask # List of casks which are not allowed in official taps. - # - # @api private module Denylist - extend T::Sig - sig { params(name: String).returns(T.nilable(String)) } def self.reason(name) case name diff --git a/Library/Homebrew/cask/download.rb b/Library/Homebrew/cask/download.rb index 4c80eb262827e..eb1d9a7087139 100644 --- a/Library/Homebrew/cask/download.rb +++ b/Library/Homebrew/cask/download.rb @@ -1,79 +1,103 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "downloadable" require "fileutils" require "cask/cache" require "cask/quarantine" module Cask # A download corresponding to a {Cask}. - # - # @api private class Download + include Downloadable + include Context attr_reader :cask def initialize(cask, quarantine: nil) + super() + @cask = cask @quarantine = quarantine end + sig { override.returns(String) } + def name + cask.token + end + + sig { override.returns(T.nilable(::URL)) } + def url + return if 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 + + 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) - downloaded_path = begin - downloader.shutup! if quiet - downloader.fetch(timeout: timeout) - downloader.cached_location - rescue => e - error = CaskError.new("Download failed on Cask '#{cask}' with message: #{e}") + 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 - 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) - end - end - def time_file_size(timeout: nil) - downloader.resolved_time_file_size(timeout: timeout) - end - - def clear_cache - downloader.clear_cache - end + raise ArgumentError, "not supported for this download strategy" unless downloader.is_a?(CurlDownloadStrategy) - def cached_download - downloader.cached_location + T.cast(downloader, CurlDownloadStrategy).resolved_time_file_size(timeout:) end def basename downloader.basename end - def verify_download_integrity(fn) + sig { override.params(filename: Pathname).void } + def verify_download_integrity(filename) if @cask.sha256 == :no_check opoo "No checksum defined for cask '#{@cask}', skipping verification." return end - begin - ohai "Verifying checksum for cask '#{@cask}'" if verbose? - fn.verify_checksum(@cask.sha256) - rescue ChecksumMissingError - opoo <<~EOS - Cannot verify integrity of '#{fn.basename}'. - No checksum was provided for this cask. - For your reference, the checksum is: - sha256 "#{fn.sha256}" - EOS - end + super + end + + sig { override.returns(String) } + def download_name + cask.token + end + + sig { override.returns(String) } + def download_type + "cask" end private @@ -88,5 +112,15 @@ def quarantine(path) Quarantine.release!(download_path: path) end end + + sig { override.returns(T.nilable(::URL)) } + def determine_url + url + end + + sig { override.returns(Pathname) } + def cache + Cache.path + end end end diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index d4d06da622260..434159b57a66c 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -1,16 +1,17 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" require "locale" require "lazy_object" require "livecheck" 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" @@ -29,8 +30,6 @@ module Cask # Class representing the domain-specific language used for casks. - # - # @api private class DSL ORDINARY_ARTIFACT_CLASSES = [ Artifact::Installer, @@ -43,6 +42,7 @@ class DSL Artifact::Font, Artifact::InputMethod, Artifact::InternetPlugin, + Artifact::KeyboardLayout, Artifact::Manpage, Artifact::Pkg, Artifact::Prefpane, @@ -67,6 +67,7 @@ class DSL DSL_METHODS = Set.new([ :appcast, + :arch, :artifacts, :auto_updates, :caveats, @@ -76,33 +77,57 @@ class DSL :depends_on, :homepage, :language, - :languages, :name, :sha256, :staged_path, :url, :version, :appdir, - :discontinued?, + :deprecate!, + :deprecated?, + :deprecation_date, + :deprecation_reason, + :deprecation_replacement, + :disable!, + :disabled?, + :disable_date, + :disable_reason, + :disable_replacement, + :discontinued?, # TODO: remove once discontinued? is removed (4.5.0) :livecheck, - :livecheckable?, + :livecheck_defined?, + :livecheckable?, # TODO: remove once `#livecheckable?` is removed + :on_system_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] }, ]).freeze - extend Predicable + extend Attrable include OnSystem::MacOSOnly - attr_reader :cask, :token + attr_reader :cask, :token, :deprecation_date, :deprecation_reason, :deprecation_replacement, :disable_date, + :disable_reason, :disable_replacement, :on_system_block_min_os - attr_predicate :on_system_blocks_exist? + attr_predicate :deprecated?, :disabled?, :livecheck_defined?, :on_system_blocks_exist?, :depends_on_set_in_block? def initialize(cask) @cask = cask @token = cask.token end + # Specifies the cask's name. + # + # NOTE: Multiple names can be specified. + # + # ### Example + # + # ```ruby + # name "Visual Studio Code" + # ``` + # # @api public def name(*args) @name ||= [] @@ -111,32 +136,48 @@ def name(*args) @name.concat(args.flatten) end + # Describes the cask. + # + # ### Example + # + # ```ruby + # desc "Open-source code editor" + # ``` + # # @api public def desc(description = nil) set_unique_stanza(:desc, description.nil?) { description } end def set_unique_stanza(stanza, should_return) - return instance_variable_get("@#{stanza}") if should_return + return instance_variable_get(:"@#{stanza}") if should_return unless @cask.allow_reassignment - if instance_variable_defined?("@#{stanza}") && !@called_in_on_system_block + if instance_variable_defined?(:"@#{stanza}") && !@called_in_on_system_block raise CaskInvalidError.new(cask, "'#{stanza}' stanza may only appear once.") end - if instance_variable_defined?("@#{stanza}_set_in_block") && @called_in_on_system_block + if instance_variable_defined?(:"@#{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}_set_in_block", true) if @called_in_on_system_block - 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 def homepage(homepage = nil) set_unique_stanza(:homepage, homepage.nil?) { homepage } @@ -169,12 +210,11 @@ def language_eval raise CaskInvalidError.new(cask, "No default language specified.") if @language_blocks.default.nil? locales = cask.config.languages - .map do |language| + .filter_map do |language| Locale.parse(language) rescue Locale::ParserError nil end - .compact locales.each do |locale| key = locale.detect(@language_blocks.keys) @@ -193,31 +233,59 @@ def languages @language_blocks.keys.flatten end + # Sets the cask's download URL. + # + # ### Example + # + # ```ruby + # url "https://update.code.visualstudio.com/#{version}/#{arch}/stable" + # ``` + # # @api public def url(*args, **options, &block) - caller_location = caller_locations[0] + caller_location = T.must(caller_locations).fetch(0) set_unique_stanza(:url, args.empty? && options.empty? && !block) do if block - URL.new(*args, **options, caller_location: caller_location, dsl: self, &block) + URL.new(*args, **options, caller_location:, dsl: self, &block) else - URL.new(*args, **options, caller_location: caller_location) + URL.new(*args, **options, caller_location:) end end end + # 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 - def appcast(*args) - set_unique_stanza(:appcast, args.empty?) { DSL::Appcast.new(*args) } - end - - # @api public - def container(*args) - set_unique_stanza(:container, args.empty?) do - DSL::Container.new(*args) + def container(**kwargs) + set_unique_stanza(:container, kwargs.empty?) do + DSL::Container.new(**kwargs) end end + # Sets the cask's version. + # + # ### Example + # + # ```ruby + # version "1.88.1" + # ``` + # + # @see DSL::Version # @api public def version(arg = nil) set_unique_stanza(:version, arg.nil?) do @@ -229,48 +297,97 @@ def version(arg = nil) end end + # Sets the cask's download checksum. + # + # ### Example + # + # For universal or single-architecture downloads: + # + # ```ruby + # sha256 "7bdb497080ffafdfd8cc94d8c62b004af1be9599e865e5555e456e2681e150ca" + # ``` + # + # For architecture-dependent downloads: + # + # ```ruby + # sha256 arm: "7bdb497080ffafdfd8cc94d8c62b004af1be9599e865e5555e456e2681e150ca", + # intel: "b3c1c2442480a0219b9e05cf91d03385858c20f04b764ec08a3fa83d1b27e7b2" + # ``` + # # @api public - def sha256(arg = nil) - set_unique_stanza(:sha256, arg.nil?) do - case arg + def sha256(arg = nil, arm: nil, intel: nil) + should_return = arg.nil? && arm.nil? && intel.nil? + + set_unique_stanza(:sha256, should_return) do + @on_system_blocks_exist = true if arm.present? || intel.present? + + val = arg || on_arch_conditional(arm:, intel:) + case val when :no_check - arg + val when String - Checksum.new(arg) + Checksum.new(val) else - raise CaskInvalidError.new(cask, "invalid 'sha256' value: #{arg.inspect}") + raise CaskInvalidError.new(cask, "invalid 'sha256' value: #{val.inspect}") end end end - # `depends_on` uses a load method so that multiple stanzas can be merged. + # Sets the cask's architecture strings. + # + # ### Example + # + # ```ruby + # arch arm: "darwin-arm64", intel: "darwin" + # ``` + # + # @api public + 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 + + # Declare dependencies and requirements for a cask. + # + # NOTE: Multiple dependencies can be specified. + # # @api public - def depends_on(*args) + def depends_on(**kwargs) @depends_on ||= DSL::DependsOn.new - return @depends_on if args.empty? + @depends_on_set_in_block = true if @called_in_on_system_block + return @depends_on if kwargs.empty? begin - @depends_on.load(*args) + @depends_on.load(**kwargs) rescue RuntimeError => e raise CaskInvalidError.new(cask, e) end @depends_on end + # Declare conflicts that keep a cask from installing or working correctly. + # # @api public - 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) } + 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 def artifacts - @artifacts ||= SortedSet.new + @artifacts ||= ArtifactSet.new end def caskroom_path cask.caskroom_path end + # The staged location for this cask, including version number. + # # @api public def staged_path return @staged_path if @staged_path @@ -279,6 +396,8 @@ 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 def caveats(*strings, &block) @caveats ||= DSL::Caveats.new(cask) @@ -295,39 +414,83 @@ def caveats(*strings, &block) end def discontinued? + odisabled "`discontinued?`", "`deprecated?` or `disabled?`" @caveats&.discontinued? == true end + # Asserts that the cask artifacts auto-update. + # # @api public 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 def livecheck(&block) - @livecheck ||= Livecheck.new(self) + @livecheck ||= Livecheck.new(cask) return @livecheck unless block - if !@cask.allow_reassignment && @livecheckable + if !@cask.allow_reassignment && @livecheck_defined raise CaskInvalidError.new(cask, "'livecheck' stanza may only appear once.") end - @livecheckable = true + @livecheck_defined = true @livecheck.instance_eval(&block) end + # Whether the cask contains a `livecheck` block. This is a legacy alias + # for `#livecheck_defined?`. + sig { returns(T::Boolean) } def livecheckable? - @livecheckable == true + # odeprecated "`livecheckable?`", "`livecheck_defined?`" + @livecheck_defined == 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 + def deprecate!(date:, because:, replacement: nil) + @deprecation_date = Date.parse(date) + return if @deprecation_date > Date.today + + @deprecation_reason = because + @deprecation_replacement = 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 + def disable!(date:, because:, replacement: nil) + @disable_date = Date.parse(date) + + if @disable_date > Date.today + @deprecation_reason = because + @deprecation_replacement = replacement + @deprecated = true + return + end + + @disable_reason = because + @disable_replacement = 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 @@ -338,6 +501,7 @@ def livecheckable? ARTIFACT_BLOCK_CLASSES.each do |klass| [klass.dsl_key, klass.uninstall_dsl_key].each do |dsl_key| define_method(dsl_key) do |&block| + T.bind(self, DSL) artifacts.add(klass.new(cask, dsl_key => block)) end end @@ -356,8 +520,12 @@ def respond_to_missing?(*) true end + # The directory `app`s are installed into. + # # @api public def appdir + return HOMEBREW_CASK_APPDIR_PLACEHOLDER if Cask.generating_hash? + cask.config.appdir end end diff --git a/Library/Homebrew/cask/dsl/appcast.rb b/Library/Homebrew/cask/dsl/appcast.rb deleted file mode 100644 index c29a798192e32..0000000000000 --- a/Library/Homebrew/cask/dsl/appcast.rb +++ /dev/null @@ -1,27 +0,0 @@ -# typed: true -# frozen_string_literal: true - -module Cask - class DSL - # Class corresponding to the `appcast` stanza. - # - # @api private - class Appcast - attr_reader :uri, :parameters, :must_contain - - def initialize(uri, **parameters) - @uri = URI(uri) - @parameters = parameters - @must_contain = parameters[:must_contain] if parameters.key?(:must_contain) - 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 9a91b8381fabd..900889a59edbd 100644 --- a/Library/Homebrew/cask/dsl/base.rb +++ b/Library/Homebrew/cask/dsl/base.rb @@ -1,13 +1,12 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/utils" +require "extend/on_system" module Cask class DSL # Superclass for all stanzas which take a block. - # - # @api private class Base extend Forwardable @@ -16,7 +15,7 @@ def initialize(cask, command = SystemCommand) @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 def system_command(executable, **options) @command.run!(executable, **options) @@ -26,7 +25,7 @@ def system_command(executable, **options) # rubocop:disable Style/MissingRespondToMissing def method_missing(method, *) if method - underscored_class = self.class.name.gsub(/([[:lower:]])([[:upper:]][[:lower:]])/, '\1_\2').downcase + underscored_class = T.must(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 diff --git a/Library/Homebrew/cask/dsl/caveats.rb b/Library/Homebrew/cask/dsl/caveats.rb index 0436481f092bd..cd27bf0776038 100644 --- a/Library/Homebrew/cask/dsl/caveats.rb +++ b/Library/Homebrew/cask/dsl/caveats.rb @@ -1,6 +1,8 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" + module Cask class DSL # Class corresponding to the `caveats` stanza. @@ -12,15 +14,13 @@ class DSL # 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. - # - # @api private class Caveats < Base - extend Predicable + extend Attrable attr_predicate :discontinued? def initialize(*args) - super(*args) + super @built_in_caveats = {} @custom_caveats = [] @discontinued = false @@ -37,6 +37,7 @@ def self.caveat(name, &block) private_class_method :caveat + sig { returns(String) } def to_s (@custom_caveats + @built_in_caveats.values).join("\n") end @@ -58,10 +59,16 @@ def eval_caveats(&block) caveat :kext do next if MacOS.version < :high_sierra + navigation_path = if MacOS.version >= :ventura + "System Settings → Privacy & Security" + else + "System Preferences → Security & Privacy → General" + end + <<~EOS #{@cask} requires a kernel extension to work. If the installation fails, retry after you enable it in: - System Preferences → Security & Privacy → General + #{navigation_path} For more information, refer to vendor documentation or this Apple Technical Note: #{Formatter.url("https://developer.apple.com/library/content/technotes/tn2459/_index.html")} @@ -69,14 +76,20 @@ def eval_caveats(&block) end caveat :unsigned_accessibility do |access = "Accessibility"| - # access: the category in System Preferences > Security & Privacy > Privacy the app requires. + # access: the category in the privacy settings the app requires. + + navigation_path = if MacOS.version >= :ventura + "System Settings → Privacy & Security" + else + "System Preferences → Security & Privacy → Privacy" + end <<~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: - System Preferences → Security & Privacy → Privacy → #{access} + #{navigation_path} → #{access} To re-enable, untick and retick #{@cask}.app. EOS end @@ -122,13 +135,13 @@ def eval_caveats(&block) else <<~EOS #{@cask} requires Java #{java_version}. You can install it with: - brew install --cask homebrew/cask-versions/temurin#{java_version} + brew install --cask temurin@#{java_version} EOS end end caveat :requires_rosetta do - next unless Hardware::CPU.arm? + next if Homebrew::SimulateSystem.current_arch != :arm <<~EOS #{@cask} is built for Intel macOS and so requires Rosetta 2 to be installed. @@ -151,6 +164,7 @@ def eval_caveats(&block) end caveat :discontinued do + odisabled "`caveats :discontinued`", "`deprecate!`" @discontinued = true <<~EOS #{@cask} has been officially discontinued upstream. 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 da47113ab521f..dcf8a84b51809 100644 --- a/Library/Homebrew/cask/dsl/conflicts_with.rb +++ b/Library/Homebrew/cask/dsl/conflicts_with.rb @@ -1,16 +1,12 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "delegate" - -require "extend/hash_validator" -using HashValidator +require "extend/hash/keys" module Cask class DSL # Class corresponding to the `conflicts_with` stanza. - # - # @api private class ConflictsWith < SimpleDelegator VALID_KEYS = [ :formula, @@ -22,16 +18,16 @@ class ConflictsWith < SimpleDelegator ].freeze def initialize(**options) - options.assert_valid_keys!(*VALID_KEYS) + options.assert_valid_keys(*VALID_KEYS) - conflicts = options.transform_values { |v| Set.new(Array(v)) } + conflicts = options.transform_values { |v| Set.new(Kernel.Array(v)) } conflicts.default = Set.new super(conflicts) end def to_json(generator) - transform_values(&:to_a).to_json(generator) + __getobj__.transform_values(&:to_a).to_json(generator) end end end diff --git a/Library/Homebrew/cask/dsl/container.rb b/Library/Homebrew/cask/dsl/container.rb index a386adab8d138..0ddcb8a6f1326 100644 --- a/Library/Homebrew/cask/dsl/container.rb +++ b/Library/Homebrew/cask/dsl/container.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "unpack_strategy" @@ -6,23 +6,12 @@ module Cask class DSL # Class corresponding to the `container` stanza. - # - # @api private class Container - VALID_KEYS = Set.new([ - :type, - :nested, - ]).freeze + attr_accessor :nested, :type - attr_accessor(*VALID_KEYS, :pairs) - - 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 + def initialize(nested: nil, type: nil) + @nested = nested + @type = type return if type.nil? return unless UnpackStrategy.from_type(type).nil? @@ -30,13 +19,16 @@ def initialize(pairs = {}) raise "invalid container type: #{type.inspect}" end - def to_yaml - @pairs.to_yaml + def pairs + instance_variables.to_h { |ivar| [ivar[1..].to_sym, instance_variable_get(ivar)] }.compact end - def to_s - @pairs.inspect + 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 1d4e3a2448ff9..2aaad1d6b52e0 100644 --- a/Library/Homebrew/cask/dsl/depends_on.rb +++ b/Library/Homebrew/cask/dsl/depends_on.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "delegate" @@ -8,8 +8,6 @@ module Cask class DSL # Class corresponding to the `depends_on` stanza. - # - # @api private class DependsOn < SimpleDelegator VALID_KEYS = Set.new([ :formula, @@ -37,7 +35,7 @@ def load(**pairs) pairs.each do |key, value| raise "invalid depends_on key: '#{key.inspect}'" unless VALID_KEYS.include?(key) - self[key] = send(:"#{key}=", *value) + __getobj__[key] = send(:"#{key}=", *value) end end @@ -49,22 +47,26 @@ def cask=(*args) @cask.concat(args) end + sig { params(args: T.any(String, Symbol)).returns(T.nilable(MacOSRequirement)) } def macos=(*args) raise "Only a single 'depends_on macos' is allowed." if defined?(@macos) + # workaround for https://github.com/sorbet/sorbet/issues/6860 + first_arg = args.first&.to_s + begin @macos = if args.count > 1 MacOSRequirement.new([args], comparator: "==") - elsif MacOSVersions::SYMBOLS.key?(args.first) + elsif MacOSVersion::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) + elsif (md = /^\s*(?<|>|[=<>]=)\s*:(?\S+)\s*$/.match(first_arg)) + MacOSRequirement.new([T.must(md[:version]).to_sym], comparator: md[:comparator]) + elsif (md = /^\s*(?<|>|[=<>]=)\s*(?\S+)\s*$/.match(first_arg)) + MacOSRequirement.new([md[:version]], comparator: md[:comparator]) else # rubocop:disable Lint/DuplicateBranch MacOSRequirement.new([args.first], comparator: "==") end - rescue MacOSVersionError => e + rescue MacOSVersion::Error, TypeError => e raise "invalid 'depends_on macos' value: #{e}" 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 209533ba77487..254a6873d2863 100644 --- a/Library/Homebrew/cask/dsl/postflight.rb +++ b/Library/Homebrew/cask/dsl/postflight.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "cask/staged" @@ -6,14 +6,8 @@ module Cask class DSL # Class corresponding to the `postflight` stanza. - # - # @api private 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 d1f0e57098ce2..abaf1fb9751c3 100644 --- a/Library/Homebrew/cask/dsl/preflight.rb +++ b/Library/Homebrew/cask/dsl/preflight.rb @@ -1,11 +1,11 @@ # typed: strict # frozen_string_literal: true +require "cask/staged" + module Cask class DSL # Class corresponding to the `preflight` stanza. - # - # @api private class Preflight < Base include Staged end diff --git a/Library/Homebrew/cask/dsl/uninstall_postflight.rb b/Library/Homebrew/cask/dsl/uninstall_postflight.rb index 9dc523b05d0a0..1a3e38e86887a 100644 --- a/Library/Homebrew/cask/dsl/uninstall_postflight.rb +++ b/Library/Homebrew/cask/dsl/uninstall_postflight.rb @@ -4,8 +4,6 @@ module Cask class DSL # Class corresponding to the `uninstall_postflight` stanza. - # - # @api private class UninstallPostflight < Base end end diff --git a/Library/Homebrew/cask/dsl/uninstall_preflight.rb b/Library/Homebrew/cask/dsl/uninstall_preflight.rb index d35da3f51fa70..5280e638bdb77 100644 --- a/Library/Homebrew/cask/dsl/uninstall_preflight.rb +++ b/Library/Homebrew/cask/dsl/uninstall_preflight.rb @@ -6,8 +6,6 @@ module Cask class DSL # Class corresponding to the `uninstall_preflight` stanza. - # - # @api private class UninstallPreflight < Base include Staged end diff --git a/Library/Homebrew/cask/dsl/version.rb b/Library/Homebrew/cask/dsl/version.rb index bd7568c667a76..9613879652196 100644 --- a/Library/Homebrew/cask/dsl/version.rb +++ b/Library/Homebrew/cask/dsl/version.rb @@ -1,25 +1,21 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true module Cask class DSL # Class corresponding to the `version` stanza. - # - # @api private class Version < ::String - extend T::Sig - DIVIDERS = { "." => :dots, "-" => :hyphens, "_" => :underscores, }.freeze - 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 @@ -32,6 +28,7 @@ def define_divider_methods(divider) 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 @@ -49,6 +46,7 @@ def define_divider_conversion_methods(left_divider) 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 @@ -96,78 +94,94 @@ 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.method(:new)) + split(",").map { self.class.new(_1) } 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 + # The version without any dividers. + # + # @see DIVIDER_REGEX # @api public sig { returns(T.self_type) } - def before_colon - odisabled "Cask::DSL::Version#before_colon", "Cask::DSL::Version#csv" - version { split(":", 2).first } - end - - # @api public - sig { returns(T.self_type) } - def after_colon - odisabled "Cask::DSL::Version#after_colon", "Cask::DSL::Version#csv" - version { split(":", 2).second } + 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 { returns(T.self_type) } - def no_dividers - version { gsub(DIVIDER_REGEX, "") } + sig { params(separator: String).returns(T.self_type) } + def chomp(separator = T.unsafe(nil)) + version { to_s.chomp(separator) } end private diff --git a/Library/Homebrew/cask/exceptions.rb b/Library/Homebrew/cask/exceptions.rb index dcf76029fb407..7b681704fc7d4 100644 --- a/Library/Homebrew/cask/exceptions.rb +++ b/Library/Homebrew/cask/exceptions.rb @@ -1,18 +1,12 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true module Cask # General cask error. - # - # @api private class CaskError < RuntimeError; end # Cask error containing multiple other errors. - # - # @api private class MultipleCaskErrors < CaskError - extend T::Sig - def initialize(errors) super() @@ -29,11 +23,7 @@ def to_s end # Abstract cask error containing a cask token. - # - # @api private class AbstractCaskErrorWithToken < CaskError - extend T::Sig - sig { returns(String) } attr_reader :token @@ -49,23 +39,30 @@ def initialize(token, reason = nil) end # Error when a cask is not installed. - # - # @api private class CaskNotInstalledError < AbstractCaskErrorWithToken - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' is not installed." end end + # Error when a cask cannot be installed. + class CaskCannotBeInstalledError < AbstractCaskErrorWithToken + attr_reader :message + + 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. - # - # @api private class CaskConflictError < AbstractCaskErrorWithToken - extend T::Sig - attr_reader :conflicting_cask def initialize(token, conflicting_cask) @@ -80,11 +77,7 @@ def to_s end # Error when a cask is not available. - # - # @api private class CaskUnavailableError < AbstractCaskErrorWithToken - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' is unavailable#{reason.empty? ? "." : ": #{reason}"}" @@ -92,11 +85,7 @@ def to_s end # Error when a cask is unreadable. - # - # @api private class CaskUnreadableError < CaskUnavailableError - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' is unreadable#{reason.empty? ? "." : ": #{reason}"}" @@ -104,11 +93,7 @@ def to_s end # Error when a cask in a specific tap is not available. - # - # @api private class TapCaskUnavailableError < CaskUnavailableError - extend T::Sig - attr_reader :tap def initialize(tap, token) @@ -125,54 +110,39 @@ def to_s end # Error when a cask with the same name is found in multiple taps. - # - # @api private class TapCaskAmbiguityError < CaskError - extend T::Sig + 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) + @loaders = loaders + + taps = loaders.map(&:tap) + casks = taps.map { |tap| "#{tap}/#{token}" } + cask_list = casks.sort.map { |f| "\n * #{f}" }.join - def initialize(ref, loaders) super <<~EOS - Cask #{ref} exists in multiple taps: - #{loaders.map { |loader| " #{loader.tap}/#{loader.token}" }.join("\n")} + 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 # Error when a cask already exists. - # - # @api private class CaskAlreadyCreatedError < AbstractCaskErrorWithToken - extend T::Sig - sig { returns(String) } def to_s %Q(Cask '#{token}' already exists. Run #{Formatter.identifier("brew edit --cask #{token}")} to edit it.) end end - # Error when a cask is already installed. - # - # @api private - class CaskAlreadyInstalledError < AbstractCaskErrorWithToken - extend T::Sig - - sig { returns(String) } - def to_s - <<~EOS - Cask '#{token}' is already installed. - - To re-install #{token}, run: - #{Formatter.identifier("brew reinstall --cask #{token}")} - EOS - end - end - # Error when there is a cyclic cask dependency. - # - # @api private class CaskCyclicDependencyError < AbstractCaskErrorWithToken - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' includes cyclic dependencies on other Casks#{reason.empty? ? "." : ": #{reason}"}" @@ -180,11 +150,7 @@ def to_s end # Error when a cask depends on itself. - # - # @api private class CaskSelfReferencingDependencyError < CaskCyclicDependencyError - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' depends on itself." @@ -192,11 +158,7 @@ def to_s end # Error when no cask is specified. - # - # @api private class CaskUnspecifiedError < CaskError - extend T::Sig - sig { returns(String) } def to_s "This command requires a Cask token." @@ -204,11 +166,7 @@ def to_s end # Error when a cask is invalid. - # - # @api private class CaskInvalidError < AbstractCaskErrorWithToken - extend T::Sig - sig { returns(String) } def to_s "Cask '#{token}' definition is invalid#{reason.empty? ? "." : ": #{reason}"}" @@ -216,8 +174,6 @@ def to_s end # Error when a cask token does not match the file name. - # - # @api private class CaskTokenMismatchError < CaskInvalidError def initialize(token, header_token) super(token, "Token '#{header_token}' in header line does not match the file name.") @@ -225,11 +181,7 @@ def initialize(token, header_token) end # Error during quarantining of a file. - # - # @api private class CaskQuarantineError < CaskError - extend T::Sig - attr_reader :path, :reason def initialize(path, reason) @@ -241,7 +193,7 @@ def initialize(path, reason) 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" @@ -254,14 +206,10 @@ def to_s end # Error while propagating quarantine information to subdirectories. - # - # @api private class CaskQuarantinePropagationError < CaskQuarantineError - extend T::Sig - 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" @@ -274,14 +222,10 @@ def to_s end # Error while removing quarantine information. - # - # @api private class CaskQuarantineReleaseError < CaskQuarantineError - extend T::Sig - 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..9273ac05bbc04 --- /dev/null +++ b/Library/Homebrew/cask/info.rb @@ -0,0 +1,136 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "cmd/info" + +module Cask + class Info + sig { params(cask: Cask).returns(String) } + def self.get_info(cask) + require "cask/installer" + + output = "#{title_info(cask)}\n" + 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)}\n" + repo = repo_info(cask) + output << "#{repo}\n" if repo + output << name_info(cask) + output << desc_info(cask) + deps = deps_info(cask) + output << deps if deps + 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) + + require "utils/analytics" + ::Utils::Analytics.cask_output(cask, args:) + end + + sig { params(cask: Cask).returns(String) } + def self.title_info(cask) + title = "#{oh1_title(cask.token)}: #{cask.version}" + title += " (auto_updates)" if cask.auto_updates + title + end + + sig { params(cask: Cask).returns(String) } + def self.installation_info(cask) + return "Not installed" unless cask.installed? + return "No installed version" unless (installed_version = cask.installed_version).present? + + versioned_staged_path = cask.caskroom_path.join(installed_version) + + return "Installed\n#{versioned_staged_path} (#{Formatter.error("does not exist")})\n" unless versioned_staged_path.exist? + + path_details = versioned_staged_path.children.sum(&:disk_usage) + + tab = Tab.for_cask(cask) + + info = ["Installed"] + info << "#{versioned_staged_path} (#{disk_usage_readable(path_details)})" + info << " #{tab}" if tab.tabfile&.exist? + info.join("\n") + end + + sig { params(cask: Cask).returns(String) } + 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 + + sig { params(cask: Cask).returns(String) } + def self.desc_info(cask) + <<~EOS + #{ohai_title("Description")} + #{cask.desc.nil? ? Formatter.error("None") : cask.desc} + EOS + end + + sig { params(cask: Cask).returns(T.nilable(String)) } + def self.deps_info(cask) + depends_on = cask.depends_on + + formula_deps = Array(depends_on[:formula]).map(&:to_s) + cask_deps = Array(depends_on[:cask]).map { |dep| "#{dep} (cask)" } + + all_deps = formula_deps + cask_deps + return if all_deps.empty? + + <<~EOS + #{ohai_title("Dependencies")} + #{all_deps.join(", ")} + EOS + 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 if cask.tap.nil? + + url = if cask.tap.custom_remote? && !cask.tap.remote.nil? + cask.tap.remote + else + "#{cask.tap.default_remote}/blob/HEAD/#{cask.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 ed69a5f5cecb5..2c691fad81b3f 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -1,55 +1,49 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "attrable" require "formula_installer" require "unpack_strategy" require "utils/topological_hash" require "cask/config" require "cask/download" -require "cask/staged" +require "cask/migrator" require "cask/quarantine" +require "cask/tab" require "cgi" module Cask # Installer for a {Cask}. - # - # @api private class Installer - extend T::Sig + extend Attrable - 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, + def initialize(cask, command: SystemCommand, force: false, adopt: false, skip_cask_deps: false, binaries: true, verbose: false, - zap: false, require_sha: false, upgrade: false, - installed_as_dependency: false, quarantine: true, - verify_download_integrity: true, quiet: false) + zap: false, require_sha: false, upgrade: false, reinstall: false, + installed_as_dependency: false, installed_on_request: true, + quarantine: true, verify_download_integrity: true, quiet: 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 end - attr_predicate :binaries?, :force?, :skip_cask_deps?, :require_sha?, - :reinstall?, :upgrade?, :verbose?, :zap?, :installed_as_dependency?, + attr_predicate :binaries?, :force?, :adopt?, :skip_cask_deps?, :require_sha?, + :reinstall?, :upgrade?, :verbose?, :zap?, :installed_as_dependency?, :installed_on_request?, :quarantine?, :quiet? def self.caveats(cask) @@ -70,11 +64,16 @@ def self.caveats(cask) def fetch(quiet: nil, timeout: nil) odebug "Cask::Installer#fetch" + load_cask_from_source_api! if @cask.loaded_from_api? && @cask.caskfile_only? verify_has_sha if require_sha? && !force? + check_requirements + + forbidden_tap_check + forbidden_cask_and_formula_check - download(quiet: quiet, timeout: timeout) + download(quiet:, timeout:) - satisfy_dependencies + satisfy_cask_and_formula_dependencies end def stage @@ -93,13 +92,12 @@ def install start_time = Time.now odebug "Cask::Installer#install" - old_config = @cask.config - if @cask.installed? && !force? && !reinstall? && !upgrade? - return if quiet? + Migrator.migrate_if_needed(@cask) - raise CaskAlreadyInstalledError, @cask - end + old_config = @cask.config + predecessor = @cask if reinstall? && @cask.installed? + check_deprecate_disable check_conflicts print caveats @@ -114,9 +112,17 @@ def install @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_as_dependency = installed_as_dependency? + 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 @@ -128,12 +134,29 @@ def install 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 + def check_conflicts return unless @cask.conflicts_with @cask.conflicts_with[:cask].each do |conflicting_cask| - if (match = conflicting_cask.match(HOMEBREW_TAP_CASK_REGEX)) - conflicting_cask_tap = Tap.fetch(match[1], match[2]) + 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 @@ -144,26 +167,12 @@ def check_conflicts end end - def reinstall - odebug "Cask::Installer#reinstall" - @reinstall = true - install - end - 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 = begin - installed_caskfile.exist? ? CaskLoader.load(installed_caskfile) : @cask - rescue CaskInvalidError # could be thrown by call to CaskLoader#load with outdated caskfile - @cask # default - end - # Always force uninstallation, ignore method parameter - cask_installer = Installer.new(installed_cask, verbose: verbose?, force: true, upgrade: upgrade?) - zap? ? cask_installer.zap : cask_installer.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) } @@ -182,13 +191,13 @@ def downloader 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: quiet, verify_download_integrity: @verify_download_integrity, - timeout: timeout) + @cask.download ||= downloader.fetch(quiet:, verify_download_integrity: @verify_download_integrity, + timeout:) end def verify_has_sha odebug "Checking cask has checksum" - return unless @cask.sha256 == :no_check + return if @cask.sha256 != :no_check raise CaskError, <<~EOS Cask '#{@cask}' does not have a sha256 checksum defined and was not installed. @@ -211,31 +220,31 @@ def extract_primary_container(to: @cask.staged_path) basename = downloader.basename if (nested_container = @cask.container&.nested) - Dir.mktmpdir do |tmpdir| + Dir.mktmpdir("cask-installer", HOMEBREW_TEMP) do |tmpdir| tmpdir = Pathname(tmpdir) - primary_container.extract(to: tmpdir, basename: basename, verbose: verbose?) + primary_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: to, verbose: verbose?) + .extract_nestedly(to:, verbose: verbose?) end else - primary_container.extract_nestedly(to: to, basename: basename, verbose: verbose?) + primary_container.extract_nestedly(to:, basename:, verbose: verbose?) end return unless quarantine? return unless Quarantine.available? - Quarantine.propagate(from: primary_container.path, to: to) + Quarantine.propagate(from: primary_container.path, to:) end - def install_artifacts + sig { params(predecessor: T.nilable(Cask)).void } + def install_artifacts(predecessor: nil) artifacts = @cask.artifacts already_installed_artifacts = [] odebug "Installing artifacts" - odebug "#{artifacts.length} #{"artifact".pluralize(artifacts.length)} defined", artifacts artifacts.each do |artifact| next unless artifact.respond_to?(:install_phase) @@ -244,7 +253,10 @@ def install_artifacts next if artifact.is_a?(Artifact::Binary) && !binaries? - 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 @@ -252,7 +264,7 @@ def install_artifacts save_download_sha if @cask.version.latest? rescue => e begin - already_installed_artifacts.each do |artifact| + 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?) @@ -269,25 +281,26 @@ 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 - macos_dependencies - arch_dependencies - cask_and_formula_dependencies + sig { void } + def check_stanza_os_requirements + nil end - def macos_dependencies + def check_macos_requirements return unless @cask.depends_on.macos return if @cask.depends_on.macos.satisfied? raise CaskError, @cask.depends_on.macos.message(type: :cask) end - def arch_dependencies + def check_arch_requirements return if @cask.depends_on.arch.nil? @current_arch ||= { type: Hardware::CPU.type, bits: Hardware::CPU.bits } @@ -302,12 +315,12 @@ def arch_dependencies "but you are running #{@current_arch}." end - def collect_cask_and_formula_dependencies + def cask_and_formula_dependencies return @cask_and_formula_dependencies if @cask_and_formula_dependencies graph = ::Utils::TopologicalHash.graph_package_dependencies(@cask) - raise CaskSelfReferencingDependencyError, cask.token if graph[@cask].include?(@cask) + raise CaskSelfReferencingDependencyError, @cask.token if graph[@cask].include?(@cask) ::Utils::TopologicalHash.graph_package_dependencies(primary_container.dependencies, graph) @@ -321,27 +334,27 @@ def collect_cask_and_formula_dependencies end def missing_cask_and_formula_dependencies - collect_cask_and_formula_dependencies.reject do |cask_or_formula| - installed = if cask_or_formula.respond_to?(:any_version_installed?) - cask_or_formula.any_version_installed? - else - cask_or_formula.try(: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 - installed && (cask_or_formula.respond_to?(:optlinked?) ? cask_or_formula.optlinked? : true) end end - def cask_and_formula_dependencies + def satisfy_cask_and_formula_dependencies return if installed_as_dependency? - 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 @@ -355,12 +368,15 @@ def cask_and_formula_dependencies Installer.new( cask_or_formula, + adopt: adopt?, binaries: binaries?, verbose: verbose?, installed_as_dependency: true, + installed_on_request: false, force: false, ).install else + Homebrew::Install.perform_preinstall_checks_once fi = FormulaInstaller.new( cask_or_formula, **{ @@ -382,14 +398,18 @@ def caveats self.class.caveats(@cask) end + def metadata_subdir + @metadata_subdir ||= @cask.metadata_subdir("Casks", timestamp: :now, create: true) + end + def save_caskfile old_savedir = @cask.metadata_timestamped_path return if @cask.source.blank? - savedir = @cask.metadata_subdir("Casks", timestamp: :now, create: true) - (savedir/"#{@cask.token}.rb").write @cask.source - old_savedir&.rmtree + extension = @cask.loaded_from_api? ? "json" : "rb" + (metadata_subdir/"#{@cask.token}.#{extension}").write @cask.source + FileUtils.rm_r(old_savedir) if old_savedir end def save_config_file @@ -400,10 +420,13 @@ def save_download_sha @cask.download_sha_path.atomic_write(@cask.new_download_sha) if @cask.checksumable? end - def uninstall + sig { params(successor: T.nilable(Cask)).void } + def uninstall(successor: nil) + load_installed_caskfile! oh1 "Uninstalling Cask #{Formatter.identifier(@cask)}" - uninstall_artifacts(clear: true) + uninstall_artifacts(clear: true, successor:) if !reinstall? && !upgrade? + remove_tabfile remove_download_sha remove_config_file end @@ -411,17 +434,25 @@ def uninstall purge_caskroom_path if force? end + def remove_tabfile + tabfile = @cask.tab.tabfile + FileUtils.rm_f tabfile if tabfile + @cask.config_path.parent.rmdir_if_possible + end + def remove_config_file FileUtils.rm_f @cask.config_path @cask.config_path.parent.rmdir_if_possible end def remove_download_sha - FileUtils.rm_f @cask.download_sha_path if @cask.download_sha_path.exist? + FileUtils.rm_f @cask.download_sha_path + @cask.download_sha_path.parent.rmdir_if_possible end - def start_upgrade - uninstall_artifacts + sig { params(successor: T.nilable(Cask)).void } + def start_upgrade(successor:) + uninstall_artifacts(successor:) backup end @@ -433,17 +464,18 @@ def backup def restore_backup return if !backup_path.directory? || !backup_metadata_path.directory? - Pathname.new(@cask.staged_path).rmtree if @cask.staged_path.exist? - Pathname.new(@cask.metadata_versioned_path).rmtree if @cask.metadata_versioned_path.exist? + FileUtils.rm_r(@cask.staged_path) if @cask.staged_path.exist? + FileUtils.rm_r(@cask.metadata_versioned_path) if @cask.metadata_versioned_path.exist? backup_path.rename @cask.staged_path backup_metadata_path.rename @cask.metadata_versioned_path 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 def finalize_upgrade @@ -454,17 +486,24 @@ def finalize_upgrade puts summary end - def uninstall_artifacts(clear: false) + sig { params(clear: T::Boolean, successor: T.nilable(Cask)).void } + def uninstall_artifacts(clear: false, successor: nil) artifacts = @cask.artifacts odebug "Uninstalling artifacts" - odebug "#{artifacts.length} #{"artifact".pluralize(artifacts.length)} defined", artifacts + odebug "#{::Utils.pluralize("artifact", artifacts.length, include_count: true)} defined", artifacts artifacts.each do |artifact| if 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?, + command: @command, + verbose: verbose?, + skip: clear, + force: force?, + successor:, + upgrade: upgrade?, + reinstall: reinstall?, ) end @@ -472,12 +511,17 @@ def uninstall_artifacts(clear: false) 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 def zap + load_installed_caskfile! ohai "Implied `brew uninstall --cask #{@cask}`" uninstall_artifacts if (zap_stanzas = @cask.artifacts.select { |a| a.is_a?(Artifact::Zap) }).empty? @@ -539,11 +583,140 @@ def purge_versioned_files # 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 def purge_caskroom_path odebug "Purging all staged versions of Cask #{@cask}" gain_permissions_remove(@cask.caskroom_path) end + + sig { void } + def forbidden_tap_check + 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 + + unless 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 + + cask_tap = @cask.tap + return if cask_tap.blank? || (cask_tap.allowed_by_env? && !cask_tap.forbidden_by_env?) + + error_message = "The installation of #{@cask.full_name} has the tap #{cask_tap}\n" \ + "but #{owner} " + error_message << "has not allowed this tap in `HOMEBREW_ALLOWED_TAPS`" unless cask_tap.allowed_by_env? + error_message << " and\n" if !cask_tap.allowed_by_env? && cask_tap.forbidden_by_env? + error_message << "has forbidden this tap in `HOMEBREW_FORBIDDEN_TAPS`" if cask_tap.forbidden_by_env? + error_message << ".#{owner_contact}" + + raise CaskCannotBeInstalledError.new(@cask, error_message) + end + + sig { void } + def forbidden_cask_and_formula_check + forbidden_formulae = Set.new(Homebrew::EnvConfig.forbidden_formulae.to_s.split) + forbidden_casks = Set.new(Homebrew::EnvConfig.forbidden_casks.to_s.split) + return if forbidden_formulae.blank? && forbidden_casks.blank? + + owner = Homebrew::EnvConfig.forbidden_owner + owner_contact = if (contact = Homebrew::EnvConfig.forbidden_owner_contact.presence) + "\n#{contact}" + end + + unless 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 + dep_cask_name = if forbidden_casks.include?(dep_cask.token) + dep_cask.token + elsif dep_cask.tap.present? && + forbidden_casks.include?(dep_cask.full_name) + dep_cask.full_name + end + [dep_cask_name, "cask", "HOMEBREW_FORBIDDEN_CASKS"] + 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 + return if forbidden_casks.blank? + + if forbidden_casks.include?(@cask.token) + @cask.token + elsif forbidden_casks.include?(@cask.full_name) + @cask.full_name + else + return + end + + raise CaskCannotBeInstalledError.new(@cask, <<~EOS + forbidden for installation by #{owner} in `HOMEBREW_FORBIDDEN_CASKS`.#{owner_contact} + EOS + ) + end + + private + + # load the same cask file that was used for installation, if possible + def load_installed_caskfile! + Migrator.migrate_if_needed(@cask) + + installed_caskfile = @cask.installed_caskfile + + if installed_caskfile&.exist? + begin + @cask = CaskLoader.load(installed_caskfile) + return + rescue CaskInvalidError + # could be caused by trying to load outdated caskfile + end + end + + load_cask_from_source_api! if @cask.loaded_from_api? && @cask.caskfile_only? + # otherwise we default to the current cask + end + + def load_cask_from_source_api! + @cask = Homebrew::API::Cask.source_download(@cask) + 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..87aa1460f1daa --- /dev/null +++ b/Library/Homebrew/cask/list.rb @@ -0,0 +1,51 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/artifact/relocated" + +module Cask + class List + 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(_1) } + elsif !output.empty? && casks.any? + output.map { list_artifacts(_1) } + 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 be2693b239111..ac1a2349b9c1e 100644 --- a/Library/Homebrew/cask/macos.rb +++ b/Library/Homebrew/cask/macos.rb @@ -1,8 +1,6 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true -require "os/mac/version" - module OS module Mac module_function @@ -378,8 +376,7 @@ module Mac "~/Library/Widgets", "~/Library/Workflows", ] - .map { |path| Pathname(path.sub(%r{^~(?=(/|$))}, Dir.home)).expand_path } - .to_set + .to_set { |path| Pathname(path.sub(%r{^~(?=(/|$))}, Dir.home)).expand_path } .union(SYSTEM_DIRS) .freeze private_constant :UNDELETABLE_PATHS diff --git a/Library/Homebrew/cask/metadata.rb b/Library/Homebrew/cask/metadata.rb index c2db2e2cc0e30..39ab2d8c5bec7 100644 --- a/Library/Homebrew/cask/metadata.rb +++ b/Library/Homebrew/cask/metadata.rb @@ -1,34 +1,37 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true module Cask # Helper module for reading and writing cask metadata. - # - # @api private module Metadata + extend T::Helpers + METADATA_SUBDIR = ".metadata" TIMESTAMP_FORMAT = "%Y%m%d%H%M%S.%L" - def metadata_main_container_path - @metadata_main_container_path ||= caskroom_path.join(METADATA_SUBDIR) + requires_ancestor { Cask } + + def metadata_main_container_path(caskroom_path: self.caskroom_path) + caskroom_path.join(METADATA_SUBDIR) end - def metadata_versioned_path(version: self.version) + 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_main_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) + def metadata_timestamped_path(version: self.version, timestamp: :latest, create: false, + caskroom_path: self.caskroom_path) 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 + Pathname.glob(metadata_versioned_path(version:, caskroom_path:).join("*")).max else timestamp = new_timestamp if timestamp == :now - metadata_versioned_path(version: version).join(timestamp) + metadata_versioned_path(version:, caskroom_path:).join(timestamp) end if create && !path.directory? @@ -39,11 +42,13 @@ def metadata_timestamped_path(version: self.version, timestamp: :latest, create: path end - def metadata_subdir(leaf, version: self.version, timestamp: :latest, create: false) + 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? - parent = metadata_timestamped_path(version: version, timestamp: timestamp, create: create) + parent = metadata_timestamped_path(version:, timestamp:, create:, + caskroom_path:) return if parent.nil? diff --git a/Library/Homebrew/cask/migrator.rb b/Library/Homebrew/cask/migrator.rb new file mode 100644 index 0000000000000..1b9648869500b --- /dev/null +++ b/Library/Homebrew/cask/migrator.rb @@ -0,0 +1,87 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/cask_loader" +require "utils/inreplace" + +module Cask + class Migrator + 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) + + old_cask = CaskLoader.load(installed_caskfile) + return if new_cask.token == old_cask.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}" + 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 + 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 83447c4b6ebde..39f13c54f7711 100644 --- a/Library/Homebrew/cask/pkg.rb +++ b/Library/Homebrew/cask/pkg.rb @@ -1,15 +1,11 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "cask/macos" module Cask # Helper class for uninstalling `.pkg` installers. - # - # @api private class Pkg - extend T::Sig - 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| @@ -32,9 +28,10 @@ def uninstall 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 @@ -42,9 +39,10 @@ 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 @@ -61,7 +59,12 @@ def uninstall 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]) } @@ -71,7 +74,7 @@ def pkgutil_bom_files sig { returns(T::Array[Pathname]) } def pkgutil_bom_specials - @pkgutil_bom_specials ||= pkgutil_bom_all.select(&method(:special?)) + @pkgutil_bom_specials ||= pkgutil_bom_all.select { special?(_1) } end sig { returns(T::Array[Pathname]) } @@ -85,7 +88,7 @@ def pkgutil_bom_all .stdout .split("\n") .map { |path| root.join(path) } - .reject(&MacOS.public_method(:undeletable?)) + .reject { MacOS.undeletable?(_1) } end sig { returns(Pathname) } @@ -114,9 +117,10 @@ def special?(path) def rmdir(path) @command.run!( "/usr/bin/xargs", - args: ["-0", "--", RMDIR_SH.to_s], - input: Array(path).join("\0"), - sudo: true, + args: ["-0", "--", RMDIR_SH.to_s], + input: Array(path).join("\0"), + sudo: true, + sudo_as_root: true, ) end diff --git a/Library/Homebrew/cask/quarantine.rb b/Library/Homebrew/cask/quarantine.rb index 2b9ec8705884a..4a36a5128edac 100644 --- a/Library/Homebrew/cask/quarantine.rb +++ b/Library/Homebrew/cask/quarantine.rb @@ -1,42 +1,50 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "development_tools" require "cask/exceptions" +require "system_command" module Cask # Helper module for quarantining files. - # - # @api private module Quarantine - extend T::Sig - - module_function + extend SystemCommand::Mixin QUARANTINE_ATTRIBUTE = "com.apple.quarantine" QUARANTINE_SCRIPT = (HOMEBREW_LIBRARY_PATH/"cask/utils/quarantine.swift").freeze - - def swift - @swift ||= DevelopmentTools.locate("swift") + COPY_XATTRS_SCRIPT = (HOMEBREW_LIBRARY_PATH/"cask/utils/copy-xattrs.swift").freeze + + def self.swift + @swift ||= 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 end - private :swift + private_class_method :swift - def xattr + def self.xattr @xattr ||= DevelopmentTools.locate("xattr") end - private :xattr + private_class_method :xattr - def swift_target_args + def self.swift_target_args ["-target", "#{Hardware::CPU.arch}-apple-macosx#{MacOS.version}"] end - private :swift_target_args + private_class_method :swift_target_args sig { returns(Symbol) } - def check_quarantine_support + def self.check_quarantine_support odebug "Checking quarantine support" - if !system_command(xattr, args: ["-h"], print_stderr: false).success? + if xattr.nil? || !system_command(xattr, args: ["-h"], print_stderr: false).success? odebug "There's no working version of `xattr` on this system." :xattr_broken elsif swift.nil? @@ -58,13 +66,13 @@ def check_quarantine_support end end - def available? + def self.available? @status ||= check_quarantine_support @status == :quarantine_available end - def detect(file) + def self.detect(file) return if file.nil? odebug "Verifying Gatekeeper status of #{file}" @@ -76,13 +84,13 @@ def detect(file) quarantine_status end - def status(file) + def self.status(file) system_command(xattr, args: ["-p", QUARANTINE_ATTRIBUTE, file], print_stderr: false).stdout.rstrip end - def toggle_no_translocation_bit(attribute) + def self.toggle_no_translocation_bit(attribute) fields = attribute.split(";") # Fields: status, epoch, download agent, event ID @@ -94,7 +102,7 @@ def toggle_no_translocation_bit(attribute) fields.join(";") end - def release!(download_path: nil) + def self.release!(download_path: nil) return unless detect(download_path) odebug "Releasing #{download_path} from quarantine" @@ -112,7 +120,7 @@ def release!(download_path: nil) raise CaskQuarantineReleaseError.new(download_path, quarantiner.stderr) end - def cask!(cask: nil, download_path: nil, action: true) + def self.cask!(cask: nil, download_path: nil, action: true) return if cask.nil? || download_path.nil? return if detect(download_path) @@ -139,7 +147,7 @@ def cask!(cask: nil, download_path: nil, action: true) end end - def propagate(from: nil, to: nil) + def self.propagate(from: nil, to: nil) return if from.nil? || to.nil? raise CaskError, "#{from} was not quarantined properly." unless detect(from) @@ -176,5 +184,85 @@ 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}" + + command.run!( + 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 + + 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 + + false + end end end diff --git a/Library/Homebrew/cask/reinstall.rb b/Library/Homebrew/cask/reinstall.rb new file mode 100644 index 0000000000000..bad643b380fc7 --- /dev/null +++ b/Library/Homebrew/cask/reinstall.rb @@ -0,0 +1,33 @@ +# typed: true # rubocop:todo Sorbet/StrictSigil +# frozen_string_literal: true + +module Cask + class Reinstall + def self.reinstall_casks( + *casks, + verbose: nil, + force: nil, + skip_cask_deps: nil, + binaries: nil, + require_sha: nil, + quarantine: nil, + zap: nil + ) + require "cask/installer" + + quarantine = true if quarantine.nil? + + casks.each do |cask| + Installer.new(cask, + binaries:, + verbose:, + force:, + skip_cask_deps:, + require_sha:, + reinstall: true, + quarantine:, + zap:).install + end + end + end +end diff --git a/Library/Homebrew/cask/staged.rb b/Library/Homebrew/cask/staged.rb index bc05bb0b79fa1..43d0e8c37c2ac 100644 --- a/Library/Homebrew/cask/staged.rb +++ b/Library/Homebrew/cask/staged.rb @@ -1,20 +1,16 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "utils/user" module Cask # Helper functions for staged casks. - # - # @api private module Staged - extend T::Sig + extend T::Helpers - # FIXME: Enable cop again when https://github.com/sorbet/sorbet/issues/3532 is fixed. - # rubocop:disable Style/MutableConstant - Paths = T.type_alias { T.any(String, Pathname, T::Array[T.any(String, Pathname)]) } - # rubocop:enable Style/MutableConstant + requires_ancestor { Kernel } + 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) @@ -29,7 +25,7 @@ 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." + ohai "Changing ownership of paths required by #{@cask} with sudo; the password may be necessary." @command.run!("/usr/sbin/chown", args: ["-R", "--", "#{user}:#{group}", *full_paths], sudo: true) end diff --git a/Library/Homebrew/cask/staged.rbi b/Library/Homebrew/cask/staged.rbi deleted file mode 100644 index ddf5f8cdbcc0f..0000000000000 --- a/Library/Homebrew/cask/staged.rbi +++ /dev/null @@ -1,7 +0,0 @@ -# typed: strict - -module Cask - module Staged - include Kernel - end -end diff --git a/Library/Homebrew/cask/tab.rb b/Library/Homebrew/cask/tab.rb new file mode 100644 index 0000000000000..f7e12a3bcb853 --- /dev/null +++ b/Library/Homebrew/cask/tab.rb @@ -0,0 +1,108 @@ +# typed: true # rubocop:todo Sorbet/StrictSigil +# frozen_string_literal: true + +require "tab" + +module Cask + class Tab < ::AbstractTab + attr_accessor :uninstall_flight_blocks, :uninstall_artifacts + + # Instantiates a {Tab} for a new installation of a cask. + def self.create(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. + 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 + + def self.empty + tab = super + tab.uninstall_flight_blocks = false + tab.uninstall_artifacts = [] + tab.source["version"] = nil + + tab + end + + def self.runtime_deps_hash(cask) + cask_and_formula_dep_graph = ::Utils::TopologicalHash.graph_package_dependencies(cask) + cask_deps, formula_deps = cask_and_formula_dep_graph.values.flatten.uniq.partition do |dep| + dep.is_a?(Cask) + end + + 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 + + def version + source["version"] + end + + def to_json(*_args) + attributes = { + "homebrew_version" => homebrew_version, + "loaded_from_api" => loaded_from_api, + "uninstall_flight_blocks" => uninstall_flight_blocks, + "installed_as_dependency" => installed_as_dependency, + "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 + + def to_s + s = ["Installed"] + s << "using the formulae.brew.sh API" if loaded_from_api + s << Time.at(time).strftime("on %Y-%m-%d at %H:%M:%S") if time + s.join(" ") + end + end +end diff --git a/Library/Homebrew/cask/uninstall.rb b/Library/Homebrew/cask/uninstall.rb new file mode 100644 index 0000000000000..ebfc74ee27790 --- /dev/null +++ b/Library/Homebrew/cask/uninstall.rb @@ -0,0 +1,18 @@ +# typed: true # rubocop:todo Sorbet/StrictSigil +# frozen_string_literal: true + +module Cask + class Uninstall + def self.uninstall_casks(*casks, binaries: nil, force: false, verbose: false) + require "cask/installer" + + casks.each do |cask| + odebug "Uninstalling Cask #{cask}" + + raise CaskNotInstalledError, cask if !cask.installed? && !force + + Installer.new(cask, binaries:, force:, verbose:).uninstall + end + end + end +end diff --git a/Library/Homebrew/cask/upgrade.rb b/Library/Homebrew/cask/upgrade.rb new file mode 100644 index 0000000000000..6e60b2415ac27 --- /dev/null +++ b/Library/Homebrew/cask/upgrade.rb @@ -0,0 +1,221 @@ +# typed: strict +# frozen_string_literal: true + +require "env_config" +require "cask/config" + +module Cask + class Upgrade + 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), + ).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 + ) + quarantine = true if quarantine.nil? + + greedy = true if Homebrew::EnvConfig.upgrade_greedy? + + outdated_casks = if casks.empty? + Caskroom.casks(config: Config.from_args(args)).select do |cask| + cask.outdated?(greedy:, greedy_latest:, + greedy_auto_updates:) + end + else + casks.select do |cask| + raise CaskNotInstalledError, cask if !cask.installed? && !force + + 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 + + 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 casks.empty? && !greedy + if !greedy_auto_updates && !greedy_latest + ohai "Casks with 'auto_updates true' or 'version :latest' " \ + "will not be upgraded; pass `--greedy` to upgrade them." + end + if greedy_auto_updates && !greedy_latest + ohai "Casks with 'version :latest' will not be upgraded; pass `--greedy-latest` to upgrade them." + end + if !greedy_auto_updates && greedy_latest + ohai "Casks with 'auto_updates true' will not be upgraded; pass `--greedy-auto-updates` to upgrade them." + end + end + + verb = dry_run ? "Would upgrade" : "Upgrading" + oh1 "#{verb} #{outdated_casks.count} outdated #{::Utils.pluralize("package", outdated_casks.count)}:" + + caught_exceptions = [] + + upgradable_casks = outdated_casks.map do |c| + unless c.installed? + odie <<~EOS + The cask '#{c.token}' was affected by a bug and cannot be upgraded as-is. To fix this, run: + brew reinstall --cask --force #{c.token} + EOS + end + + [CaskLoader.load(c.installed_caskfile), c] + end + + puts upgradable_casks + .map { |(old_cask, new_cask)| "#{new_cask.full_name} #{old_cask.version} -> #{new_cask.version}" } + .join("\n") + return true if dry_run + + upgradable_casks.each do |(old_cask, new_cask)| + upgrade_cask( + old_cask, new_cask, + binaries:, force:, skip_cask_deps:, verbose:, + quarantine:, require_sha: + ) + 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.count == 1 + + false + end + + 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), + skip_cask_deps: T.nilable(T::Boolean), + verbose: T.nilable(T::Boolean), + ).void + } + def self.upgrade_cask( + old_cask, new_cask, + binaries:, force:, quarantine:, require_sha:, skip_cask_deps:, verbose: + ) + 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) + + new_cask.config = new_cask.default_config.merge(old_config) + + new_options = { + binaries:, + verbose:, + force:, + skip_cask_deps:, + require_sha:, + upgrade: true, + quarantine:, + }.compact + + new_cask_installer = + Installer.new(new_cask, **new_options) + + 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 + + if (caveats = new_cask_installer.caveats) + puts caveats + end + + new_cask_installer.fetch + + # Move the old cask's artifacts back to staging + old_cask_installer.start_upgrade(successor: new_cask) + # 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 successful, wipe the old cask from staging. + old_cask_installer.finalize_upgrade + rescue => e + new_cask_installer.uninstall_artifacts(successor: old_cask) if new_artifacts_installed + new_cask_installer.purge_versioned_files + old_cask_installer.revert_upgrade(predecessor: new_cask) if started_upgrade + raise e + end + + 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 fa09e3a0a536a..5bcee1de9f83e 100644 --- a/Library/Homebrew/cask/url.rb +++ b/Library/Homebrew/cask/url.rb @@ -1,45 +1,211 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true -# Class corresponding to the `url` stanza. -# -# @api private -class URL < Delegator - extend T::Sig +require "source_location" +require "utils/curl" - # @api private - class DSL - extend T::Sig +module Cask + # Class corresponding to the `url` stanza. + class URL < SimpleDelegator + class DSL + attr_reader :uri, :tag, :branch, :revisions, :revision, + :trust_cert, :cookies, :header, :data, :only_path - attr_reader :uri, :specs, - :verified, :using, - :tag, :branch, :revisions, :revision, - :trust_cert, :cookies, :referer, :header, :user_agent, - :data + sig { returns(T.nilable(T.any(URI::Generic, String))) } + attr_reader :referer - extend Forwardable - def_delegators :uri, :path, :scheme, :to_s + 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.any(T::Class[T.anything], Symbol, NilClass)) } + attr_reader :using + + sig { returns(T.nilable(String)) } + attr_reader :verified + + extend Forwardable + def_delegators :uri, :path, :scheme, :to_s + + sig { + params( + uri: T.any(URI::Generic, String), + # @api public + verified: T.nilable(String), + # @api public + using: T.any(Class, Symbol, NilClass), + # @api public + tag: T.nilable(String), + # @api public + branch: T.nilable(String), + # @api public + revisions: T.nilable(T::Array[String]), + # @api public + revision: T.nilable(String), + # @api public + trust_cert: T.nilable(T::Boolean), + # @api public + cookies: T.nilable(T::Hash[String, String]), + referer: T.nilable(T.any(URI::Generic, String)), + # @api public + header: T.nilable(T.any(String, T::Array[String])), + user_agent: T.nilable(T.any(Symbol, String)), + # @api public + data: T.nilable(T::Hash[String, String]), + # @api public + only_path: T.nilable(String), + ).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 + ) + @uri = URI(uri) + + header = Array(header) unless header.nil? + + specs = {} + specs[:verified] = @verified = verified + specs[:using] = @using = using + specs[:tag] = @tag = tag + specs[:branch] = @branch = branch + specs[:revisions] = @revisions = revisions + specs[:revision] = @revision = revision + specs[:trust_cert] = @trust_cert = trust_cert + specs[:cookies] = @cookies = cookies + specs[:referer] = @referer = referer + specs[:headers] = @header = header + specs[:user_agent] = @user_agent = user_agent || :default + specs[:data] = @data = data + specs[:only_path] = @only_path = only_path + + @specs = specs.compact + end + end + + class BlockDSL + # Allow accessing the URL associated with page contents. + module PageWithURL + # Get the URL of the fetched page. + # + # ### Example + # + # ```ruby + # url "https://example.org/download" do |page| + # file = page[/href="([^"]+.dmg)"/, 1] + # URL.join(page.url, file) + # end + # ``` + # + # @api public + sig { returns(URI::Generic) } + attr_accessor :url + end + + sig { + params( + uri: T.nilable(T.any(URI::Generic, String)), + dsl: ::Cask::DSL, + block: T.proc.params(arg0: T.all(String, PageWithURL)) + .returns(T.any(T.any(URI::Generic, String), [T.any(URI::Generic, String), Hash])), + ).void + } + def initialize(uri, dsl:, &block) + @uri = uri + @dsl = dsl + @block = block + + odeprecated "cask `url do` blocks" if @block + end + + sig { returns(T.any(T.any(URI::Generic, String), [T.any(URI::Generic, String), Hash])) } + def call + if @uri + result = ::Utils::Curl.curl_output("--fail", "--silent", "--location", @uri) + result.assert_success! + + page = result.stdout + page.extend PageWithURL + page.url = URI(@uri) + + instance_exec(page, &@block) + else + instance_exec(&@block) + end + end + + private + + # Allows calling a nested `url` stanza in a `url do` block. + # + # @api public + sig { + params( + uri: T.any(URI::Generic, String), + block: T.proc.params(arg0: T.all(String, PageWithURL)) + .returns(T.any(T.any(URI::Generic, String), [T.any(URI::Generic, String), Hash])), + ).returns(T.any(T.any(URI::Generic, String), [T.any(URI::Generic, String), Hash])) + } + def url(uri, &block) + self.class.new(uri, dsl: @dsl, &block).call + end + + # This allows calling DSL methods from inside a `url` block. + # + # @api public + def method_missing(method, *args, &block) + if @dsl.respond_to?(method) + @dsl.public_send(method, *args, &block) + else + super + end + end + + def respond_to_missing?(method, include_all) + @dsl.respond_to?(method, include_all) || super + end + end - # @api public sig { params( - uri: T.any(URI::Generic, String), - verified: T.nilable(String), - using: T.nilable(Symbol), - tag: T.nilable(String), - branch: T.nilable(String), - revisions: T.nilable(T::Array[String]), - revision: T.nilable(String), - trust_cert: T.nilable(T::Boolean), - cookies: T.nilable(T::Hash[String, String]), - referer: T.nilable(T.any(URI::Generic, String)), - header: T.nilable(String), - user_agent: T.nilable(T.any(Symbol, String)), - data: T.nilable(T::Hash[String, String]), + uri: T.nilable(T.any(URI::Generic, String)), + verified: T.nilable(String), + using: T.any(Class, Symbol, NilClass), + tag: T.nilable(String), + branch: T.nilable(String), + revisions: T.nilable(T::Array[String]), + revision: T.nilable(String), + trust_cert: T.nilable(T::Boolean), + cookies: T.nilable(T::Hash[String, 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, + dsl: T.nilable(::Cask::DSL), + block: T.nilable( + T.proc.params(arg0: T.all(String, BlockDSL::PageWithURL)) + .returns(T.any(T.any(URI::Generic, String), [T.any(URI::Generic, String), Hash])), + ), ).void } def initialize( - uri, + uri = nil, verified: nil, using: nil, tag: nil, @@ -51,197 +217,74 @@ def initialize( referer: nil, header: nil, user_agent: nil, - data: nil + data: nil, + only_path: nil, + caller_location: T.must(caller_locations).fetch(0), + dsl: nil, + &block ) + super( + if block + LazyObject.new do + uri2, options = *BlockDSL.new(uri, dsl: T.must(dsl), &block).call + options ||= {} + DSL.new(uri2, **options) + end + else + DSL.new( + T.must(uri), + verified:, + using:, + tag:, + branch:, + revisions:, + revision:, + trust_cert:, + cookies:, + referer:, + header:, + user_agent:, + data:, + only_path:, + ) + end + ) - @uri = URI(uri) - - specs = {} - specs[:verified] = @verified = verified - specs[:using] = @using = using - specs[:tag] = @tag = tag - specs[:branch] = @branch = branch - specs[:revisions] = @revisions = revisions - specs[:revision] = @revision = revision - specs[:trust_cert] = @trust_cert = trust_cert - specs[:cookies] = @cookies = cookies - specs[:referer] = @referer = referer - specs[:header] = @header = header - specs[:user_agent] = @user_agent = user_agent || :default - specs[:data] = @data = data - - @specs = specs.compact - end - end - - # @api private - class BlockDSL - extend T::Sig - - module PageWithURL - extend T::Sig - - # @api public - sig { returns(URI::Generic) } - attr_accessor :url + @from_block = !block.nil? + @caller_location = caller_location end - sig { - params( - uri: T.nilable(T.any(URI::Generic, String)), - dsl: T.nilable(Cask::DSL), - block: T.proc.params(arg0: T.all(String, PageWithURL)).returns(T.untyped), - ).void - } - def initialize(uri, dsl: nil, &block) - @uri = uri - @dsl = dsl - @block = block + sig { returns(Homebrew::SourceLocation) } + def location + Homebrew::SourceLocation.new(@caller_location.lineno, raw_url_line&.index("url")) end - sig { returns(T.untyped) } - def call - if @uri - result = curl_output("--fail", "--silent", "--location", @uri) - result.assert_success! + 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] } - page = result.stdout - page.extend PageWithURL - page.url = URI(@uri) + return false unless interpolated_url - instance_exec(page, &@block) - else - instance_exec(&@block) - end - end - - # @api public - sig { - params( - uri: T.any(URI::Generic, String), - block: T.proc.params(arg0: T.all(String, PageWithURL)).returns(T.untyped), - ).void - } - def url(uri, &block) - self.class.new(uri, &block).call - end - private :url - - # @api public - def method_missing(method, *args, &block) - if @dsl.respond_to?(method) - T.unsafe(@dsl).public_send(method, *args, &block) - else - super - end - end + interpolated_url = interpolated_url.gsub(/\#{\s*version\s*\.major\s*}/, "") if ignore_major_version - def respond_to_missing?(method, include_all) - @dsl.respond_to?(method, include_all) || super + interpolated_url.exclude?('#{') end - end - sig { - params( - uri: T.nilable(T.any(URI::Generic, String)), - verified: T.nilable(String), - using: T.nilable(Symbol), - tag: T.nilable(String), - branch: T.nilable(String), - revisions: T.nilable(T::Array[String]), - revision: T.nilable(String), - trust_cert: T.nilable(T::Boolean), - cookies: T.nilable(T::Hash[String, String]), - referer: T.nilable(T.any(URI::Generic, String)), - header: T.nilable(String), - user_agent: T.nilable(T.any(Symbol, String)), - data: T.nilable(T::Hash[String, String]), - caller_location: Thread::Backtrace::Location, - dsl: T.nilable(Cask::DSL), - block: T.nilable(T.proc.params(arg0: T.all(String, BlockDSL::PageWithURL)).returns(T.untyped)), - ).void - } - def initialize( - uri = nil, - 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, - caller_location: T.must(caller_locations).fetch(0), - dsl: nil, - &block - ) - super( - if block - LazyObject.new do - *args = BlockDSL.new(uri, dsl: dsl, &block).call - options = args.last.is_a?(Hash) ? args.pop : {} - uri = T.let(args.first, T.any(URI::Generic, String)) - DSL.new(uri, **options) - end - else - DSL.new( - T.must(uri), - verified: verified, - using: using, - tag: tag, - branch: branch, - revisions: revisions, - revision: revision, - trust_cert: trust_cert, - cookies: cookies, - referer: referer, - header: header, - user_agent: user_agent, - data: data, - ) + sig { returns(T::Boolean) } + def from_block? + @from_block end - ) - @from_block = !block.nil? - @caller_location = caller_location - end - - def __getobj__ - @dsl - end - - def __setobj__(dsl) - @dsl = dsl - end + private - sig { returns(T.nilable(String)) } - def raw_interpolated_url - return @raw_interpolated_url if defined?(@raw_interpolated_url) + sig { returns(T.nilable(String)) } + def raw_url_line + return @raw_url_line if defined?(@raw_url_line) - @raw_interpolated_url = - Pathname(@caller_location.absolute_path) - .each_line.drop(@caller_location.lineno - 1) - .first&.then { |line| line[/url\s+"([^"]+)"/, 1] } - end - private :raw_interpolated_url - - sig { params(ignore_major_version: T::Boolean).returns(T::Boolean) } - def unversioned?(ignore_major_version: false) - interpolated_url = raw_interpolated_url - - return false unless interpolated_url - - interpolated_url = interpolated_url.gsub(/\#{\s*version\s*\.major\s*}/, "") if ignore_major_version - - interpolated_url.exclude?('#{') - end - - sig { returns(T::Boolean) } - def from_block? - @from_block + @raw_url_line = Pathname(T.must(@caller_location.path)) + .each_line + .drop(@caller_location.lineno - 1) + .first + end end end diff --git a/Library/Homebrew/cask/url.rbi b/Library/Homebrew/cask/url.rbi deleted file mode 100644 index 6f32e1b3b2711..0000000000000 --- a/Library/Homebrew/cask/url.rbi +++ /dev/null @@ -1,6 +0,0 @@ -# typed: strict -# typed: false - -class URL - include Kernel -end diff --git a/Library/Homebrew/cask/utils.rb b/Library/Homebrew/cask/utils.rb index 23e52b91c8a37..c7019a1c50591 100644 --- a/Library/Homebrew/cask/utils.rb +++ b/Library/Homebrew/cask/utils.rb @@ -1,33 +1,60 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "utils/user" -require "yaml" require "open3" -require "stringio" - -BUG_REPORTS_URL = "https://github.com/Homebrew/homebrew-cask#reporting-bugs" module Cask # Helper functions for various cask operations. - # - # @api private module Utils - extend T::Sig + BUG_REPORTS_URL = "https://github.com/Homebrew/homebrew-cask#reporting-bugs" + + 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!("/bin/mkdir", args: ["-p", "--", path], sudo: true, print_stderr: false) + end + end + + def self.gain_permissions_rmdir(path, command: SystemCommand) + gain_permissions(path, [], command) do |p| + if p.parent.writable? + FileUtils.rmdir p + else + command.run!("/bin/rmdir", args: ["--", p], sudo: true, print_stderr: false) + end + end + end 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 @@ -39,18 +66,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, + print_stderr:, args: command_args + ["--", "u+rwx", path]) command.run("/bin/chmod", - must_succeed: false, + print_stderr:, args: command_args + ["-N", path]) tried_permissions = true retry # rmtree @@ -59,7 +87,7 @@ 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], @@ -100,11 +128,11 @@ def self.error_message_with_suggestions end def self.method_missing_message(method, token, section = nil) - message = +"Unexpected method '#{method}' called " + message = "Unexpected method '#{method}' called " message << "during #{section} " if section message << "on Cask #{token}." - opoo "#{message}\n#{error_message_with_suggestions}" + ofail "#{message}\n#{error_message_with_suggestions}" 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/trash.swift b/Library/Homebrew/cask/utils/trash.swift index cfdd3d896ac68..cd380aeb1f6fd 100755 --- a/Library/Homebrew/cask/utils/trash.swift +++ b/Library/Homebrew/cask/utils/trash.swift @@ -2,14 +2,6 @@ import Foundation -extension FileHandle : TextOutputStream { - public func write(_ string: String) { - if let data = string.data(using: .utf8) { self.write(data) } - } -} - -var stderr = FileHandle.standardError - let manager = FileManager.default var success = true @@ -17,19 +9,24 @@ var success = true // 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) - print((trashedPath as URL).path, terminator: ":") + trashed.append((trashedPath as URL).path) success = true } catch { - print(item, terminator: ":", to: &stderr) + untrashable.append(item) success = false } } +print(trashed.joined(separator: ":")) +print(untrashable.joined(separator: ":"), terminator: "") + guard success else { exit(1) } diff --git a/Library/Homebrew/cask_dependent.rb b/Library/Homebrew/cask_dependent.rb index cb9841fb8b808..cdea653623951 100644 --- a/Library/Homebrew/cask_dependent.rb +++ b/Library/Homebrew/cask_dependent.rb @@ -1,8 +1,17 @@ -# typed: true +# typed: true # rubocop:todo Sorbet/StrictSigil # 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 + satisfy(build_env: false) do + Cask::CaskLoader.load(cask).installed? + end + end + attr_reader :cask def initialize(cask) @@ -33,11 +42,21 @@ def requirements dsl_reqs = @cask.depends_on dsl_reqs.arch&.each do |arch| - requirements << ArchRequirement.new([:x86_64]) if arch[:bits] == 64 - requirements << ArchRequirement.new([arch[:type]]) + 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 << Requirement.new([{ cask: cask_ref }]) + requirements << CaskDependent::Requirement.new([{ cask: cask_ref }]) end requirements << dsl_reqs.macos if dsl_reqs.macos diff --git a/Library/Homebrew/caveats.rb b/Library/Homebrew/caveats.rb index 66b4fcf1a7069..4a233a0acd6bd 100644 --- a/Library/Homebrew/caveats.rb +++ b/Library/Homebrew/caveats.rb @@ -1,29 +1,31 @@ -# typed: false +# typed: strict # frozen_string_literal: true require "language/python" +require "utils/service" # A formula's caveats. -# -# @api private 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 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? + build = formula.build + formula.build = Tab.for_formula(formula) + string = formula.caveats.to_s + caveats << "#{string.chomp}\n" unless string.empty? ensure - f.build = build + formula.build = build end caveats << keg_only_text @@ -47,68 +49,72 @@ def caveats delegate [:empty?, :to_s] => :caveats + 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? 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", ORIGINAL_PATHS) && - ((f.lib/"pkgconfig").directory? || (f.share/"pkgconfig").directory?) + ((formula.lib/"pkgconfig").directory? || (formula.share/"pkgconfig").directory?) s << <<~EOS - For pkg-config to find #{f.name} you may need to set: + For pkg-config 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 end - s << "\n" + s << "\n" unless s.end_with?("\n") + s end private + 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 (keg = self.keg) return unless which(shell.to_s, ORIGINAL_PATHS) completion_installed = keg.completion_installed?(shell) @@ -119,7 +125,7 @@ def function_completion_caveats(shell) 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 @@ -127,69 +133,60 @@ 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 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 + sig { returns(T.nilable(String)) } def service_caveats - return if !f.plist && !f.service? && !keg&.plist_installed? + return if !formula.service? && !Utils::Service.installed?(formula) && !keg&.plist_installed? + return if formula.service? && !formula.service.command? && !Utils::Service.installed?(formula) s = [] - command = if f.service? - f.service.manual_command - else - f.plist_manual - end - - return <<~EOS if !which("launchctl") && f.plist - #{Formatter.warning("Warning:")} #{f.name} provides a launchd plist which can only be used on macOS! - You can manually execute the service instead with: - #{command} - EOS - # Brew services only works with these two tools - return <<~EOS if !which("systemctl") && !which("launchctl") && f.service? - #{Formatter.warning("Warning:")} #{f.name} provides a service which can only be used on macOS or systemd! + 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: - #{command} + #{formula.service.manual_command} EOS - is_running_service = f.service? && quiet_system("ps aux | grep #{f.service.command&.first}") - if is_running_service || (f.plist && quiet_system("/bin/launchctl list #{f.plist_name} &>/dev/null")) - s << "To restart #{f.full_name} after an upgrade:" - s << " #{f.plist_startup ? "sudo " : ""}brew services restart #{f.full_name}" - elsif f.plist_startup - s << "To start #{f.full_name} now and restart at startup:" - s << " sudo brew services start #{f.full_name}" + startup = formula.service.requires_root? + if Utils::Service.running?(formula) + s << "To restart #{formula.full_name} after an upgrade:" + s << " #{startup ? "sudo " : ""}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 - s << "To start #{f.full_name} now and restart at login:" - s << " brew services start #{f.full_name}" + s << "To start #{formula.full_name} now and restart at login:" + s << " brew services start #{formula.full_name}" end - if f.plist_manual || f.service? + if formula.service.command? s << "Or, if you don't want/need a background service you can just run:" - s << " #{command}" + s << " #{formula.service.manual_command}" end # pbpaste is the system clipboard tool on macOS and fails with `tmux` by default diff --git a/Library/Homebrew/checksum.rb b/Library/Homebrew/checksum.rb index 30d0d2f22e749..210a9fb2e4c5b 100644 --- a/Library/Homebrew/checksum.rb +++ b/Library/Homebrew/checksum.rb @@ -1,20 +1,21 @@ -# typed: true +# typed: strict # frozen_string_literal: true # A formula's checksum. -# -# @api private class Checksum extend Forwardable + sig { returns(String) } attr_reader :hexdigest + sig { params(hexdigest: String).void } def initialize(hexdigest) - @hexdigest = hexdigest.downcase + @hexdigest = T.let(hexdigest.downcase, String) end delegate [:empty?, :to_s, :length, :[]] => :@hexdigest + sig { params(other: T.any(String, Checksum, Symbol)).returns(T::Boolean) } def ==(other) case other when String diff --git a/Library/Homebrew/cleaner.rb b/Library/Homebrew/cleaner.rb index 32954f6b0c8c4..5df0e39b15865 100644 --- a/Library/Homebrew/cleaner.rb +++ b/Library/Homebrew/cleaner.rb @@ -1,10 +1,11 @@ -# typed: true +# typed: strict # frozen_string_literal: true # Cleans a newly installed keg. # By default: # # * removes `.la` files +# * removes `.tbd` files # * removes `perllocal.pod` files # * removes `.packlist` files # * removes empty directories @@ -14,55 +15,59 @@ class Cleaner include Context # Create a cleaner for the given formula. - def initialize(f) - @f = f + sig { params(formula: Formula).void } + def initialize(formula) + @formula = formula end # 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" + # 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" - [@f.bin, @f.sbin, @f.lib].each { |d| clean_dir(d) if d.exist? } + [@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 + # 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: + # The `dir` files come in at least 3 locations: # - # 1. 'info/dir' - # 2. 'info/#{name}/dir' - # 3. 'info/#{arch}/dir' + # 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 + # 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 by gromgit · Pull Request - # #11597][1], [emacs 28.1 bottle does not contain `dir` file · Issue - # #100190][2], and [Keep `info/#{f.name}/dir` files in cleaner by - # timvisher][3] for more info. + # 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 - Dir.glob(@f.info/"**/dir").each do |f| - info_dir_file = Pathname(f) + @formula.info.glob("**/dir").each do |info_dir_file| next unless info_dir_file.file? - next if info_dir_file == @f.info/@f.name/"dir" - next if @f.skip_clean?(info_dir_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 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 @@ -70,11 +75,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 @@ -98,6 +104,7 @@ def prune end end + sig { params(path: Pathname).returns(T::Boolean) } def executable_path?(path) path.text_executable? || path.executable? end @@ -106,31 +113,32 @@ def executable_path?(path) # 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 = Set.new(%w[perllocal.pod .packlist]).freeze + PERL_BASENAMES = T.let(Set.new(%w[perllocal.pod .packlist]).freeze, T::Set[String]) - # Clean a top-level (bin, sbin, lib) directory, recursively, by fixing file + # 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" || PERL_BASENAMES.include?(path.basename.to_s) + if path.extname == ".la" || path.extname == ".tbd" || PERL_BASENAMES.include?(path.basename.to_s) path.unlink elsif path.symlink? # Skip it. else - # Set permissions for executables and non-executables + # Set permissions for executables and non-executables. perms = if executable_path?(path) 0555 else @@ -145,20 +153,50 @@ def clean_dir(d) end end + sig { void } def rewrite_shebangs + require "language/node" require "language/perl" require "utils/shebang" - basepath = @f.prefix.realpath + 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 @f.skip_clean? path + Find.prune if @formula.skip_clean? path next if path.directory? || path.symlink? - begin - Utils::Shebang.rewrite_shebang Language::Perl::Shebang.detected_perl_shebang(@f), path - rescue ShebangDetectionError - break + 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 diff --git a/Library/Homebrew/cleanup.rb b/Library/Homebrew/cleanup.rb index 4eb4689c9bacc..00ca1e00d049b 100644 --- a/Library/Homebrew/cleanup.rb +++ b/Library/Homebrew/cleanup.rb @@ -1,142 +1,199 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true require "utils/bottles" +require "attrable" require "formula" require "cask/cask_loader" -require "set" module Homebrew # Helper class for cleaning up the Homebrew cache. - # - # @api private class Cleanup CLEANUP_DEFAULT_DAYS = Homebrew::EnvConfig.cleanup_periodic_full_days.to_i.freeze private_constant :CLEANUP_DEFAULT_DAYS - # {Pathname} refinement with helper functions for cleaning up files. - module CleanupRefinement - refine Pathname do - def incomplete? - extname.end_with?(".incomplete") - end + class << self + sig { params(pathname: Pathname).returns(T::Boolean) } + def incomplete?(pathname) + pathname.extname.end_with?(".incomplete") + end - def nested_cache? - directory? && %w[ - cargo_cache - go_cache - go_mod_cache - glide_home - java_cache - npm_cache - gclient_cache - ].include?(basename.to_s) - end + 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 - 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 go_mod_cache].include?(basename.to_s) - end + 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 - def prune?(days) - return false unless days - return true if days.zero? + 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? - return true if symlink? && !exist? + days_ago = (DateTime.now - days).to_time + pathname.mtime < days_ago && pathname.ctime < days_ago + end - mtime < days.days.ago && ctime < days.days.ago + 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 + stale_gh_actions_artifact?(pathname, scrub) + else + stale_formula?(pathname, scrub) end + end - def stale?(scrub: false) - return false unless resolved_path.file? + private - if dirname.basename.to_s == "Cask" - stale_cask?(scrub) - else - stale_formula?(scrub) + GH_ACTIONS_ARTIFACT_CLEANUP_DAYS = 3 + + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_gh_actions_artifact?(pathname, scrub) + scrub || prune?(pathname, GH_ACTIONS_ARTIFACT_CLEANUP_DAYS) + end + + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_api_source?(pathname, scrub) + return true if scrub + + org, repo, git_head, type, basename = pathname.each_filename.to_a.last(5) + + name = "#{org}/#{repo}/#{File.basename(T.must(basename), ".rb")}" + package = if type == "Cask" + begin + Cask::CaskLoader.load(name) + rescue Cask::CaskError + nil + end + else + begin + Formulary.factory(name) + rescue FormulaUnavailableError + nil end end + return true if package.nil? - private + package.tap_git_head != git_head + end - def stale_formula?(scrub) - return false unless HOMEBREW_CELLAR.directory? + sig { params(pathname: Pathname, scrub: T::Boolean).returns(T::Boolean) } + def stale_formula?(pathname, scrub) + return false unless HOMEBREW_CELLAR.directory? - version = if HOMEBREW_BOTTLES_EXTNAME_REGEX.match?(to_s) - begin - Utils::Bottles.resolve_version(self) - rescue - nil - 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 - version ||= basename.to_s[/\A.*(?:--.*?)*--(.*?)#{Regexp.escape(extname)}\Z/, 1] - version ||= basename.to_s[/\A.*--?(.*?)#{Regexp.escape(extname)}\Z/, 1] + version ||= basename_str[/\A.*(?:--.*?)*--(.*?)#{Regexp.escape(pathname.extname)}\Z/, 1] + version ||= basename_str[/\A.*--?(.*?)#{Regexp.escape(pathname.extname)}\Z/, 1] - return false unless version + return false if version.blank? - version = Version.new(version) + version = Version.new(version) + + unless (formula_name = basename_str[/\A(.*?)(?:--.*?)*--?(?:#{Regexp.escape(version.to_s)})/, 1]) + return false + end - return false unless (formula_name = basename.to_s[/\A(.*?)(?:--.*?)*--?(?:#{Regexp.escape(version)})/, 1]) + formula = begin + Formulary.from_rack(HOMEBREW_CELLAR/formula_name) + rescue FormulaUnavailableError, TapFormulaAmbiguityError + nil + end + if formula.blank? && formula_name.delete_suffix!("_bottle_manifest") formula = begin Formulary.from_rack(HOMEBREW_CELLAR/formula_name) - rescue FormulaUnavailableError, TapFormulaAmbiguityError, TapFormulaWithOldnameAmbiguityError + rescue FormulaUnavailableError, TapFormulaAmbiguityError nil end return false if formula.blank? - resource_name = basename.to_s[/\A.*?--(.*?)--?(?:#{Regexp.escape(version)})/, 1] - - if resource_name == "patch" - patch_hashes = formula.stable&.patches&.select(&:external?)&.map(&:resource)&.map(&:version) - return true unless patch_hashes&.include?(Checksum.new(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 - end - - return true if scrub && !formula.latest_version_installed? + # 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 Utils::Bottles.file_outdated?(formula, self) + return true if (bottle = formula.bottle).blank? - false + return version != GitHubPackages.version_rebuild(bottle.resource.version, bottle.rebuild) end - def stale_cask?(scrub) - return false unless (name = basename.to_s[/\A(.*?)--/, 1]) + return false if formula.blank? - cask = begin - Cask::CaskLoader.load(name) - rescue Cask::CaskError - nil - end + resource_name = basename_str[/\A.*?--(.*?)--?(?:#{Regexp.escape(version.to_s)})/, 1] - return false if cask.blank? + stable = formula.stable + if resource_name == "patch" + patch_hashes = stable&.patches&.filter_map { _1.resource.version if _1.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.latest_version_installed? && formula.pkg_version.to_s != version) || + formula.pkg_version.to_s > version + return true + end - return true unless basename.to_s.match?(/\A#{Regexp.escape(name)}--#{Regexp.escape(cask.version)}\b/) + return true if scrub && !formula.latest_version_installed? + return true if Utils::Bottles.file_outdated?(formula, pathname) - return true if scrub && cask.versions.exclude?(cask.version) + false + end - if cask.version.latest? - return mtime < CLEANUP_DEFAULT_DAYS.days.ago && - ctime < CLEANUP_DEFAULT_DAYS.days.ago - end + 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]) - false + cask = begin + Cask::CaskLoader.load(name) + rescue Cask::CaskError + nil end + + return false if cask.blank? + return true unless basename.to_s.match?(/\A#{Regexp.escape(name)}--#{Regexp.escape(cask.version)}\b/) + return true if scrub && cask.installed_version != cask.version + + 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 end - using CleanupRefinement - - extend Predicable + extend Attrable PERIODIC_CLEAN_FILE = (HOMEBREW_CACHE/".cleaned").freeze @@ -154,37 +211,47 @@ def initialize(*args, dry_run: false, scrub: false, days: nil, cache: HOMEBREW_C @cleaned_up_paths = Set.new end - def self.install_formula_clean!(f, dry_run: false) + def self.install_formula_clean!(formula, dry_run: false) return if Homebrew::EnvConfig.no_install_cleanup? + return unless formula.latest_version_installed? + return if skip_clean_formula?(formula) - cleanup = Cleanup.new(dry_run: dry_run) - if cleanup.periodic_clean_due? - cleanup.periodic_clean! - elsif f.latest_version_installed? && !cleanup.skip_clean_formula?(f) - ohai "Running `brew cleanup #{f}`..." - puts_no_install_cleanup_disable_message_if_not_already! - cleanup.cleanup_formula(f) + if dry_run + ohai "Would run `brew cleanup #{formula}`" + else + ohai "Running `brew cleanup #{formula}`..." end + + puts_no_install_cleanup_disable_message_if_not_already! + return if dry_run + + Cleanup.new.cleanup_formula(formula) end - def self.puts_no_install_cleanup_disable_message_if_not_already! + def self.puts_no_install_cleanup_disable_message return if Homebrew::EnvConfig.no_env_hints? return if Homebrew::EnvConfig.no_install_cleanup? - return if @puts_no_install_cleanup_disable_message_if_not_already puts "Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP." puts "Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`)." + end + + def self.puts_no_install_cleanup_disable_message_if_not_already! + return if @puts_no_install_cleanup_disable_message_if_not_already + + puts_no_install_cleanup_disable_message @puts_no_install_cleanup_disable_message_if_not_already = true end - def skip_clean_formula?(f) - return false if Homebrew::EnvConfig.no_cleanup_formulae.blank? + def self.skip_clean_formula?(formula) + no_cleanup_formula = Homebrew::EnvConfig.no_cleanup_formulae + return false if no_cleanup_formula.blank? - skip_clean_formulae = Homebrew::EnvConfig.no_cleanup_formulae.split(",") - skip_clean_formulae.include?(f.name) || (skip_clean_formulae & f.aliases).present? + @skip_clean_formulae ||= no_cleanup_formula.split(",") + @skip_clean_formulae.include?(formula.name) || @skip_clean_formulae.intersect?(formula.aliases) end - def periodic_clean_due? + def self.periodic_clean_due? return false if Homebrew::EnvConfig.no_install_cleanup? unless PERIODIC_CLEAN_FILE.exist? @@ -193,35 +260,46 @@ def periodic_clean_due? return false end - PERIODIC_CLEAN_FILE.mtime < CLEANUP_DEFAULT_DAYS.days.ago + PERIODIC_CLEAN_FILE.mtime < (DateTime.now - CLEANUP_DEFAULT_DAYS).to_time end - def periodic_clean! - return false unless periodic_clean_due? + def self.periodic_clean!(dry_run: false) + return if Homebrew::EnvConfig.no_install_cleanup? + return unless periodic_clean_due? - if dry_run? - ohai "Would run `brew cleanup` which has not been run in the last #{CLEANUP_DEFAULT_DAYS} days" + if dry_run + oh1 "Would run `brew cleanup` which has not been run in the last #{CLEANUP_DEFAULT_DAYS} days" else - ohai "`brew cleanup` has not been run in the last #{CLEANUP_DEFAULT_DAYS} days, running now..." + oh1 "`brew cleanup` has not been run in the last #{CLEANUP_DEFAULT_DAYS} days, running now..." end - Cleanup.puts_no_install_cleanup_disable_message_if_not_already! - return if dry_run? + puts_no_install_cleanup_disable_message + return if dry_run - clean!(quiet: true, periodic: true) + Cleanup.new.clean!(quiet: true, periodic: true) end def clean!(quiet: false, periodic: false) if args.empty? Formula.installed .sort_by(&:name) - .reject { |f| skip_clean_formula?(f) } + .reject { |f| Cleanup.skip_clean_formula?(f) } .each do |formula| - cleanup_formula(formula, quiet: quiet, ds_store: false, cache_db: false) + 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_lockfiles + cleanup_python_site_packages prune_prefix_symlinks_and_directories unless dry_run? @@ -238,12 +316,11 @@ def clean!(quiet: false, periodic: false) return if periodic cleanup_portable_ruby - cleanup_bootsnap else args.each do |arg| formula = begin Formulary.resolve(arg) - rescue FormulaUnavailableError, TapFormulaAmbiguityError, TapFormulaWithOldnameAmbiguityError + rescue FormulaUnavailableError, TapFormulaAmbiguityError nil end @@ -253,7 +330,7 @@ def clean!(quiet: false, periodic: false) nil end - if formula && skip_clean_formula?(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 @@ -269,16 +346,16 @@ def unremovable_kegs end def cleanup_formula(formula, quiet: false, ds_store: true, cache_db: true) - formula.eligible_kegs_for_cleanup(quiet: quiet) - .each(&method(:cleanup_keg)) - cleanup_cache(Pathname.glob(cache/"#{formula.name}--*")) + formula.eligible_kegs_for_cleanup(quiet:) + .each { cleanup_keg(_1) } + cleanup_cache(Pathname.glob(cache/"#{formula.name}{_bottle_manifest,}--*").map { |path| { path:, 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 def cleanup_cask(cask, ds_store: true) - cleanup_cache(Pathname.glob(cache/"Cask/#{cask.token}--*")) + cleanup_cache(Pathname.glob(cache/"Cask/#{cask.token}--*").map { |path| { path:, type: :cask } }) rm_ds_store([cask.caskroom_path]) if ds_store cleanup_lockfiles(CaskLock.new(cask.token).path) end @@ -293,14 +370,34 @@ def cleanup_keg(keg) 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 + + def cache_files + files = cache.directory? ? cache.children : [] + cask_files = (cache/"Cask").directory? ? (cache/"Cask").children : [] + api_source_files = (cache/"api-source").glob("*/*/*/*/*") # `////.rb` + gh_actions_artifacts = (cache/"gh-actions-artifact").directory? ? (cache/"gh-actions-artifact").children : [] + + files.map { |path| { path:, type: nil } } + + cask_files.map { |path| { path:, type: :cask } } + + api_source_files.map { |path| { path:, type: :api_source } } + + gh_actions_artifacts.map { |path| { path:, type: :gh_actions_artifact } } + end + + 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 @@ -310,15 +407,12 @@ def cleanup_unreferenced_downloads 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 @@ -334,16 +428,17 @@ def cleanup_unreferenced_downloads end def cleanup_cache(entries = nil) - entries ||= [cache, cache/"Cask"].select(&:directory?).flat_map(&:children) + 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?("--") @@ -353,7 +448,7 @@ def cleanup_cache(entries = nil) end # If we've specified --prune don't do the (expensive) .stale? check. - cleanup_path(path) { path.unlink } if !prune? && path.stale?(scrub: scrub?) + cleanup_path(path) { path.unlink } if !prune? && self.class.stale?(entry, scrub: scrub?) end cleanup_unreferenced_downloads @@ -391,55 +486,53 @@ def cleanup_lockfiles(*lockfiles) end def cleanup_portable_ruby - rubies = [which("ruby"), which("ruby", ORIGINAL_PATHS)].compact - system_ruby = Pathname.new("/usr/bin/ruby") - rubies << system_ruby if system_ruby.exist? - - use_system_ruby = if Homebrew::EnvConfig.force_vendor_ruby? - false - elsif OS.mac? - ENV["HOMEBREW_MACOS_SYSTEM_RUBY_NEW_ENOUGH"].present? - else - check_ruby_version = HOMEBREW_LIBRARY_PATH/"utils/ruby_check_version_script.rb" - rubies.uniq.any? do |ruby| - quiet_system ruby, "--enable-frozen-string-literal", "--disable=gems,did_you_mean,rubyopt", - check_ruby_version, HOMEBREW_REQUIRED_RUBY_VERSION - end - end - 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 + next if !use_system_ruby? && portable_ruby_latest_version == path.basename.to_s portable_rubies_to_remove << path - puts "Would remove: #{path} (#{path.abv})" if dry_run? end return if portable_rubies_to_remove.empty? - bundler_path = vendor_dir/"bundle/ruby" - if dry_run? - puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-nx", bundler_path).chomp - else - puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-ffqx", bundler_path).chomp + 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 - return if dry_run? + bundler_paths.each do |bundler_path| + if dry_run? + puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-nx", bundler_path).chomp + else + puts Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "clean", "-ffqx", bundler_path).chomp + end + end - FileUtils.rm_rf portable_rubies_to_remove + portable_rubies_to_remove.each do |portable_ruby| + cleanup_path(portable_ruby) { FileUtils.rm_r(portable_ruby) } + end + end + + def use_system_ruby? + false end def cleanup_bootsnap bootsnap = cache/"bootsnap" - return unless bootsnap.exist? + return unless bootsnap.directory? - if dry_run? - puts "Would remove: #{bootsnap} (#{bootsnap.abv})" - else - FileUtils.rm_rf bootsnap + bootsnap.each_child do |subdir| + cleanup_path(subdir) { FileUtils.rm_r(subdir) } if subdir.basename.to_s != Homebrew.bootsnap_key end end @@ -463,7 +556,7 @@ def cleanup_cache_db(rack = nil) end def rm_ds_store(dirs = nil) - dirs ||= Keg::MUST_EXIST_DIRECTORIES + [ + dirs ||= Keg.must_exist_directories + [ HOMEBREW_PREFIX/"Caskroom", ] dirs.select(&:directory?) @@ -476,12 +569,62 @@ def rm_ds_store(dirs = nil) end end + 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 + 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| @@ -492,21 +635,38 @@ def prune_prefix_symlinks_and_directories 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.exclude?(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 @@ -519,5 +679,43 @@ def prune_prefix_symlinks_and_directories print "and #{d} directories " if d.positive? puts "from #{HOMEBREW_PREFIX}" end + + 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?(_1) } + .flat_map { |f| [f, *f.runtime_formula_dependencies] } + end + casks = Cask::Caskroom.casks + + removable_formulae = Utils::Autoremove.removable_formulae(formulae, casks) + + 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, plural: "e")}:" + 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 49a99405414f7..f3358bfc10881 100644 --- a/Library/Homebrew/cli/args.rb +++ b/Library/Homebrew/cli/args.rb @@ -1,50 +1,68 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "ostruct" - module Homebrew module CLI - class Args < OpenStruct - extend T::Sig - - attr_reader :options_only, :flags_only + 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[[String, T.nilable(String), String, T::Boolean]] } - # undefine tap to allow --tap argument - undef tap + sig { returns(T::Array[String]) } + attr_reader :options_only, :flags_only, :remaining sig { void } def initialize require "cli/named_args" - super() - - @processed_options = [] - @options_only = [] - @flags_only = [] - @cask_options = false + @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). - self[:named] = NamedArgs.new(parent: self) - self[:remaining] = [] + @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 - def freeze_remaining_args!(remaining_args) - self[:remaining] = remaining_args.freeze + sig { params(name: Symbol, value: T.untyped).void } + def set_arg(name, value) + @table[name] = value end - def freeze_named_args!(named_args, cask_options:) - self[:named] = NamedArgs.new( - *named_args.freeze, - override_spec: spec(nil), - force_bottle: self[:force_bottle?], - flags: flags_only, - cask_options: cask_options, - parent: self, - ) + 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 @@ -52,36 +70,38 @@ def freeze_processed_options!(processed_options) @processed_options += processed_options @processed_options.freeze - @options_only = cli_args.select { |a| a.start_with?("-") }.freeze - @flags_only = cli_args.select { |a| a.start_with?("--") }.freeze + @options_only = cli_args.select { _1.start_with?("-") }.freeze + @flags_only = cli_args.select { _1.start_with?("--") }.freeze end sig { returns(NamedArgs) } def named require "formula" - self[:named] + T.must(@named) end - def no_named? - named.blank? - end + sig { returns(T::Boolean) } + def no_named? = named.empty? + sig { returns(T::Array[String]) } def build_from_source_formulae - if build_from_source? || self[:HEAD?] || self[:build_bottle?] + if @table[:build_from_source?] || @table[:HEAD?] || @table[:build_bottle?] named.to_formulae.map(&:full_name) else [] end end + sig { returns(T::Array[String]) } def include_test_formulae - if include_test? + if @table[:include_test?] named.to_formulae.map(&:full_name) else [] end 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) } @@ -95,59 +115,72 @@ def context Context::ContextStruct.new(debug: debug?, quiet: quiet?, verbose: verbose?) end + sig { returns(T.nilable(Symbol)) } def only_formula_or_cask - return :formula if formula? && !cask? - return :cask if cask? && !formula? + if @table[:formula?] && !@table[:cask?] + :formula + elsif @table[:cask?] && !@table[:formula?] + :cask + end + end + + sig { returns(T::Array[[Symbol, Symbol]]) } + def os_arch_combinations + skip_invalid_combinations = false + + oses = case (os_sym = @table[:os]&.to_sym) + when nil + [SimulateSystem.current_os] + when :all + skip_invalid_combinations = true + + OnSystem::ALL_OS_OPTIONS + else + [os_sym] + end + + arches = case (arch_sym = @table[:arch]&.to_sym) + when nil + [SimulateSystem.current_arch] + when :all + skip_invalid_combinations = true + OnSystem::ARCH_OPTIONS + else + [arch_sym] + end + + 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 + true + end + end end private + sig { params(option: String).returns(String) } def option_to_name(option) option.sub(/\A--?/, "") .tr("-", "_") end + sig { returns(T::Array[String]) } def cli_args - return @cli_args if @cli_args - - @cli_args = [] - @processed_options.each do |short, long| + @cli_args ||= @processed_options.filter_map do |short, long| option = long || short - switch = "#{option_to_name(option)}?".to_sym + switch = :"#{option_to_name(option)}?" flag = option_to_name(option).to_sym if @table[switch] == true || @table[flag] == true - @cli_args << option + option elsif @table[flag].instance_of? String - @cli_args << "#{option}=#{@table[flag]}" + "#{option}=#{@table[flag]}" elsif @table[flag].instance_of? Array - @cli_args << "#{option}=#{@table[flag].join(",")}" + "#{option}=#{@table[flag].join(",")}" end - end - @cli_args.freeze - end - - def spec(default = :stable) - if self[:HEAD?] - :head - else - default - end - end - - def respond_to_missing?(*) - !frozen? - end - - def method_missing(method_name, *args) - return_value = super - - # Once we are frozen, verify any arg method calls are already defined in the table. - # The default OpenStruct behaviour is to return nil for anything unknown. - if frozen? && args.empty? && !@table.key?(method_name) - raise NoMethodError, "CLI arg for `#{method_name}` is not declared for this command" - end - - return_value + end.freeze end end end diff --git a/Library/Homebrew/cli/args.rbi b/Library/Homebrew/cli/args.rbi index 32b1cd8e6e041..7c161fd39becc 100644 --- a/Library/Homebrew/cli/args.rbi +++ b/Library/Homebrew/cli/args.rbi @@ -1,319 +1,19 @@ # typed: strict -module Homebrew - module CLI - class Args < OpenStruct - sig { returns(T::Boolean) } - def remove_bottle_block?; end +# 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. - sig { returns(T::Boolean) } - def strict?; end +class Homebrew::CLI::Args + sig { returns(T::Boolean) } + def debug?; end - sig { returns(T::Boolean) } - def HEAD?; end + sig { returns(T::Boolean) } + def help?; end - sig { returns(T::Boolean) } - def include_test?; end + sig { returns(T::Boolean) } + def quiet?; end - sig { returns(T::Boolean) } - def build_bottle?; end - - sig { returns(T::Boolean) } - def build_universal?; end - - sig { returns(T::Boolean) } - def build_from_source?; end - - sig { returns(T::Boolean) } - def force_bottle?; end - - sig { returns(T::Boolean) } - def newer_only?; end - - sig { returns(T::Boolean) } - def full_name?; end - - sig { returns(T::Boolean) } - def json?; end - - sig { returns(T::Boolean) } - def debug?; end - - sig { returns(T::Boolean) } - def quiet?; end - - sig { returns(T::Boolean) } - def verbose?; end - - sig { returns(T::Boolean) } - def fetch_HEAD?; end - - sig { returns(T::Boolean) } - def cask?; end - - sig { returns(T::Boolean) } - def dry_run?; end - - sig { returns(T::Boolean) } - def skip_cask_deps?; end - - sig { returns(T::Boolean) } - def greedy?; end - - sig { returns(T::Boolean) } - def force?; end - - sig { returns(T::Boolean) } - def ignore_pinned?; end - - sig { returns(T::Boolean) } - def display_times?; end - - sig { returns(T::Boolean) } - def formula?; end - - sig { returns(T::Boolean) } - def zap?; end - - sig { returns(T::Boolean) } - def ignore_dependencies?; end - - sig { returns(T::Boolean) } - def aliases?; end - - sig { returns(T::Boolean) } - def fix?; end - - sig { returns(T::Boolean) } - def keep_tmp?; end - - sig { returns(T::Boolean) } - def overwrite?; end - - sig { returns(T::Boolean) } - def silent?; end - - sig { returns(T::Boolean) } - def repair?; end - - sig { returns(T::Boolean) } - def prune_prefix?; end - - sig { returns(T::Boolean) } - def upload?; end - - sig { returns(T::Boolean) } - def linux?; end - - sig { returns(T::Boolean) } - def linux_self_hosted?; end - - sig { returns(T::Boolean) } - def linux_wheezy?; end - - sig { returns(T::Boolean) } - def total?; end - - sig { returns(T::Boolean) } - def dependents?; end - - sig { returns(T::Boolean) } - def installed?; end - - sig { returns(T::Boolean) } - def installed_on_request?; end - - sig { returns(T::Boolean) } - def installed_as_dependency?; end - - sig { returns(T::Boolean) } - def all?; end - - sig { returns(T::Boolean) } - def full?; end - - sig { returns(T::Boolean) } - def list_pinned?; end - - sig { returns(T::Boolean) } - def display_cop_names?; end - - sig { returns(T::Boolean) } - def syntax?; end - - sig { returns(T::Boolean) } - def ignore_non_pypi_packages?; end - - sig { returns(T::Boolean) } - def test?; end - - sig { returns(T::Boolean) } - def reverse?; end - - sig { returns(T::Boolean) } - def print_only?; end - - sig { returns(T::Boolean) } - def markdown?; end - - sig { returns(T::Boolean) } - def reset_cache?; end - - sig { returns(T::Boolean) } - def major?; end - - sig { returns(T::Boolean) } - def minor?; end - - sig { returns(T.nilable(String)) } - def bottle_tag; end - - sig { returns(T.nilable(String)) } - def tag; end - - sig { returns(T.nilable(String)) } - def tap; end - - sig { returns(T.nilable(T::Array[String])) } - def macos; end - - sig { returns(T.nilable(T::Array[String])) } - def hide; end - - sig { returns(T.nilable(String)) } - def version; end - - sig { returns(T.nilable(String)) } - def name; end - - sig { returns(T::Boolean) } - def no_publish?; end - - sig { returns(T::Boolean) } - def shallow?; end - - sig { returns(T::Boolean) } - def fail_if_not_changed?; end - - sig { returns(T.nilable(String)) } - def limit; end - - sig { returns(T.nilable(String)) } - def start_with; end - - sig { returns(T.nilable(String)) } - def message; end - - sig { returns(T.nilable(String)) } - def timeout; end - - sig { returns(T.nilable(String)) } - def issue; end - - sig { returns(T.nilable(String)) } - def workflow; end - - sig { returns(T.nilable(String)) } - def package_name; end - - sig { returns(T.nilable(String)) } - def prune; end - - sig { returns(T.nilable(T::Array[String])) } - def only_cops; end - - sig { returns(T.nilable(T::Array[String])) } - def except_cops; end - - sig { returns(T.nilable(T::Array[String])) } - def only; end - - sig { returns(T.nilable(T::Array[String])) } - def except; end - - sig { returns(T.nilable(T::Array[String])) } - def mirror; end - - sig { returns(T.nilable(T::Array[String])) } - def without_labels; end - - sig { returns(T.nilable(T::Array[String])) } - def workflows; end - - sig { returns(T.nilable(T::Array[String])) } - def ignore_missing_artifacts; end - - sig { returns(T.nilable(T::Array[String])) } - def language; end - - sig { returns(T.nilable(T::Array[String])) } - def extra_packages; end - - sig { returns(T.nilable(T::Array[String])) } - def exclude_packages; end - - sig { returns(T.nilable(T::Array[String])) } - def update; end - - sig { returns(T::Boolean) } - def s?; end - - sig { returns(T.nilable(String)) } - def appdir; end - - sig { returns(T.nilable(String)) } - def fontdir; end - - sig { returns(T.nilable(String)) } - def colorpickerdir; end - - sig { returns(T.nilable(String)) } - def prefpanedir; end - - sig { returns(T.nilable(String)) } - def qlplugindir; end - - sig { returns(T.nilable(String)) } - def dictionarydir; end - - sig { returns(T.nilable(String)) } - def servicedir; end - - sig { returns(T.nilable(String)) } - def input_methoddir; end - - sig { returns(T.nilable(String)) } - def mdimporterdir; end - - sig { returns(T.nilable(String)) } - def internet_plugindir; end - - sig { returns(T.nilable(String)) } - def audio_unit_plugindir; end - - sig { returns(T.nilable(String)) } - def vst_plugindir; end - - sig { returns(T.nilable(String)) } - def vst3_plugindir; end - - sig { returns(T.nilable(String)) } - def screen_saverdir; end - - sig { returns(T.nilable(T::Array[String])) } - def groups; end - - sig { returns(T::Boolean) } - def write_only?; end - - sig { returns(T::Boolean) } - def custom_remote?; end - - sig { returns(T::Boolean) } - def print_path?; end - - sig { returns(T.nilable(T::Boolean)) } - def force_auto_update?; end - end - 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..0ee539961254a --- /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(_1) }.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 index e7337d2cf845c..f74719babba1e 100644 --- a/Library/Homebrew/cli/named_args.rb +++ b/Library/Homebrew/cli/named_args.rb @@ -1,43 +1,63 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "delegate" -require "api" require "cli/args" module Homebrew module CLI # Helper class for loading formulae/casks from named arguments. - # - # @api private class NamedArgs < Array - extend T::Sig + extend T::Generic - def initialize(*args, parent: Args.new, override_spec: nil, force_bottle: false, flags: [], cask_options: false) - require "cask/cask" - require "cask/cask_loader" - require "formulary" - require "keg" - require "missing_formula" + 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) - @args = args @override_spec = override_spec @force_bottle = force_bottle @flags = flags @cask_options = cask_options + @without_api = without_api @parent = parent - - super(@args) end - attr_reader :parent - + sig { returns(T::Array[Cask::Cask]) } def to_casks - @to_casks ||= to_formulae_and_casks(only: :cask).freeze + @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 ||= to_formulae_and_casks(only: :formula).freeze + @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. @@ -46,15 +66,21 @@ def to_formulae sig { params( only: T.nilable(Symbol), - ignore_unavailable: T.nilable(T::Boolean), + 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: nil, method: nil, uniq: true) - @to_formulae_and_casks ||= {} + 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: only, method: method) + options = { warn: }.compact + load_formula_or_cask(name, only:, method:, **options) rescue FormulaUnreadableError, FormulaClassUnavailableError, TapFormulaUnreadableError, TapFormulaClassUnavailableError, Cask::CaskUnreadableError @@ -66,225 +92,205 @@ def to_formulae_and_casks(only: parent&.only_formula_or_cask, ignore_unavailable end.freeze if uniq - @to_formulae_and_casks[only].uniq.freeze + @to_formulae_and_casks.fetch(only).uniq.freeze else - @to_formulae_and_casks[only] + @to_formulae_and_casks.fetch(only) end end - def to_formulae_to_casks(only: parent&.only_formula_or_cask, method: nil) - @to_formulae_to_casks ||= {} - @to_formulae_to_casks[[method, only]] = to_formulae_and_casks(only: only, method: method) - .partition { |o| o.is_a?(Formula) || o.is_a?(Keg) } - .map(&:freeze).freeze - end - - def to_formulae_and_casks_and_unavailable(only: parent&.only_formula_or_cask, method: nil) - @to_formulae_casks_unknowns ||= {} - @to_formulae_casks_unknowns[method] = downcased_unique_named.map do |name| - load_formula_or_cask(name, only: only, method: method) - rescue FormulaOrCaskUnavailableError => e - e - end.uniq.freeze + 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 - def load_formula_or_cask(name, only: nil, method: nil) - unreadable_error = nil - - if only != :cask - begin - formula = case method - when nil, :factory - Formulary.factory(name, *spec, 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 - - warn_if_cask_conflicts(name, "formula") unless only == :formula - return formula - rescue FormulaUnreadableError, FormulaClassUnavailableError, - TapFormulaUnreadableError, TapFormulaClassUnavailableError => e - # Need to rescue before `FormulaUnavailableError` (superclass of this) - # The formula was found, but there's a problem with its implementation - unreadable_error ||= e - rescue NoSuchKegError, FormulaUnavailableError => e - raise e if only == :formula + # 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 - end - if only != :formula - want_keg_like_cask = [:latest_kegs, :default_kegs, :kegs].include?(method) + return formulae_and_casks_with_taps if formulae_and_casks_without_taps.empty? - begin - config = Cask::Config.from_args(@parent) if @cask_options - cask = Cask::CaskLoader.load(name, config: config) + types = [] + types << "formulae" if formulae_and_casks_without_taps.any?(Formula) + types << "casks" if formulae_and_casks_without_taps.any?(Cask::Cask) - if unreadable_error.present? - onoe <<~EOS - Failed to load formula: #{name} - #{unreadable_error} - EOS - opoo "Treating #{name} as a cask." - end + odie <<~ERROR + These #{types.join(" and ")} are not in any locally installed taps! - # 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 && (installed_caskfile = cask.installed_caskfile) && installed_caskfile.exist? - cask = Cask::CaskLoader.load(installed_caskfile) - end + #{formulae_and_casks_without_taps.sort_by(&:to_s).join("\n ")} - return 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::Cask.new(name, config: config).versions.first - cask = Cask::Cask.new(name, config: config) do - version cask_version if cask_version - end - return cask - end - - # Need to rescue before `CaskUnavailableError` (superclass of this) - # The cask was found, but there's a problem with its implementation - unreadable_error ||= e - rescue Cask::CaskUnavailableError => e - raise e if only == :cask - end - end - - raise unreadable_error if unreadable_error.present? - - user, repo, short_name = name.downcase.split("/", 3) - if repo.present? && short_name.present? - tap = Tap.fetch(user, repo) - raise TapFormulaOrCaskUnavailableError.new(tap, short_name) - end - - raise NoSuchKegError, name if resolve_formula(name) - - raise FormulaOrCaskUnavailableError, name + You may need to run `brew tap` to install additional taps. + ERROR end - private :load_formula_or_cask - def resolve_formula(name) - Formulary.resolve(name, spec: spec, force_bottle: @force_bottle, flags: @flags) + sig { + params(only: T.nilable(Symbol), method: T.nilable(Symbol)) + .returns(T::Array[T.any(Formula, Keg, Cask::Cask, T::Array[Keg], FormulaOrCaskUnavailableError)]) + } + def to_formulae_and_casks_and_unavailable(only: parent.only_formula_or_cask, method: nil) + @to_formulae_casks_unknowns ||= T.let( + {}, + T.nilable(T::Hash[ + T.nilable(Symbol), + T::Array[T.any(Formula, Keg, Cask::Cask, T::Array[Keg], FormulaOrCaskUnavailableError)] + ]), + ) + @to_formulae_casks_unknowns[method] = downcased_unique_named.map do |name| + load_formula_or_cask(name, only:, method:) + rescue FormulaOrCaskUnavailableError => e + e + end.uniq.freeze end - private :resolve_formula sig { params(uniq: T::Boolean).returns(T::Array[Formula]) } def to_resolved_formulae(uniq: true) - @to_resolved_formulae ||= to_formulae_and_casks(only: :formula, method: :resolve, uniq: uniq) - .freeze + @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 - def to_resolved_formulae_to_casks(only: parent&.only_formula_or_cask) - to_formulae_to_casks(only: only, method: :resolve) + 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 ||= {} - @to_paths[only] ||= downcased_unique_named.flat_map do |name| - if File.exist?(name) - Pathname(name) - elsif name.count("/") == 1 && !name.start_with?("./", "/") - tap = Tap.fetch(name) - - if recurse_tap - next tap.formula_files if only == :formula - next tap.cask_files if only == :cask - end + 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 + 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) + formula_path = Formulary.path(name) + cask_path = Cask::CaskLoader.path(name) - paths = [] + paths = [] - paths << formula_path if formula_path.exist? - paths << cask_path if cask_path.exist? + if formula_path.exist? || + (!Homebrew::EnvConfig.no_install_from_api? && + !CoreTap.instance.installed? && + Homebrew::API::Formula.all_formulae.key?(path.basename.to_s)) + paths << formula_path + end + if cask_path.exist? || + (!Homebrew::EnvConfig.no_install_from_api? && + !CoreCaskTap.instance.installed? && + Homebrew::API::Cask.all_casks.key?(path.basename.to_s)) + paths << cask_path + end - paths.empty? ? Pathname(name) : paths - end - end.uniq.freeze + paths.empty? ? path : paths + end + end.uniq.freeze + end end sig { returns(T::Array[Keg]) } def to_default_kegs - @to_default_kegs ||= begin + 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 + 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 - @to_latest_kegs ||= begin + 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 + 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 - @to_kegs ||= begin + 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 + 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.nilable(T::Boolean), all_kegs: T.nilable(T::Boolean)) + 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: nil, all_kegs: nil) + def to_kegs_to_casks(only: parent.only_formula_or_cask, ignore_unavailable: false, all_kegs: nil) method = all_kegs ? :kegs : :default_kegs - @to_kegs_to_casks ||= {} + @to_kegs_to_casks ||= T.let({}, T.nilable(T::Hash[T.nilable(Symbol), [T::Array[Keg], T::Array[Cask::Cask]]])) @to_kegs_to_casks[method] ||= - to_formulae_and_casks(only: only, ignore_unavailable: ignore_unavailable, method: method) + T.cast(to_formulae_and_casks(only:, ignore_unavailable:, method:) .partition { |o| o.is_a?(Keg) } - .map(&:freeze).freeze + .map(&:freeze).freeze, [T::Array[Keg], T::Array[Cask::Cask]]) end sig { returns(T::Array[Tap]) } def to_taps - @to_taps ||= downcased_unique_named.map { |name| Tap.fetch name }.uniq.freeze + @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 ||= to_taps.each do |tap| + @to_installed_taps ||= T.let(to_taps.each do |tap| raise TapUnavailableError, tap.name unless tap.installed? - end.uniq.freeze + end.uniq.freeze, T.nilable(T::Array[Tap])) end sig { returns(T::Array[String]) } @@ -306,11 +312,143 @@ def downcased_unique_named end.uniq end - def spec - @override_spec + sig { + params(name: String, only: T.nilable(Symbol), method: T.nilable(Symbol), warn: T.nilable(T::Boolean)) + .returns(T.any(Formula, Keg, Cask::Cask, T::Array[Keg])) + } + def load_formula_or_cask(name, only: nil, method: nil, warn: nil) + 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 + options = { warn:, force_bottle: @force_bottle, flags: @flags }.compact + Formulary.factory(name, *@override_spec, **options) + 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 + candidate_cask = Cask::CaskLoader.load(name, config:, **options) + + 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 && + (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 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::Cask.new(name, config:).installed_version + 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 + + user, repo, short_name = name.downcase.split("/", 3) + if repo.present? && short_name.present? + tap = Tap.fetch(T.must(user), repo) + raise TapFormulaOrCaskUnavailableError.new(tap, short_name) + end + + raise NoSuchKegError, name if resolve_formula(name) + end + end + + sig { params(name: String).returns(Formula) } + def resolve_formula(name) + Formulary.resolve(name, **{ spec: @override_spec, force_bottle: @force_bottle, flags: @flags }.compact) end - private :spec + sig { params(name: String).returns([Pathname, T::Array[Keg]]) } def resolve_kegs(name) raise UsageError if name.blank? @@ -319,28 +457,41 @@ def resolve_kegs(name) 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 if kegs.length == 1 + return kegs.fetch(0) if kegs.length == 1 - stable_kegs = kegs.reject { |k| k.version.head? } + stable_kegs = kegs.reject { |keg| keg.version.head? } - if stable_kegs.blank? - return kegs.max_by do |keg| - [Tab.for_keg(keg).source_modified_time, keg.version.revision] + 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 - - stable_kegs.max_by(&:version) + T.must(latest_keg) end + sig { params(name: String).returns(Keg) } def resolve_default_keg(name) rack, kegs = resolve_kegs(name) @@ -350,7 +501,7 @@ def resolve_default_keg(name) 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.first if kegs.length == 1 + return kegs.fetch(0) if kegs.length == 1 f = if name.include?("/") || File.exist?(name) Formulary.factory(name) @@ -370,16 +521,41 @@ def resolve_default_keg(name) 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. + Please delete (with `rm -rf`!) all but one and then try again. EOS end end - def warn_if_cask_conflicts(ref, loaded_type) + sig { + params( + ref: String, loaded_type: String, + package: T.any(T::Array[T.any(Formula, Keg)], Cask::Cask, Formula, Keg, NilClass) + ).returns(String) + } + def package_conflicts_message(ref, loaded_type, package) message = "Treating #{ref} as a #{loaded_type}." - begin - cask = Cask::CaskLoader.load ref - message += " For the cask, use #{cask.tap.name}/#{cask.token}" if cask.tap.present? + 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 @@ -387,11 +563,16 @@ def warn_if_cask_conflicts(ref, loaded_type) Failed to load cask: #{ref} #{e} EOS + nil rescue Cask::CaskUnavailableError # No ref conflict with a cask, do nothing - return + available = false + nil end - opoo message.freeze + return unless available + return if Context.current.quiet? + + opoo package_conflicts_message(ref, loaded_type, cask) end end end diff --git a/Library/Homebrew/cli/parser.rb b/Library/Homebrew/cli/parser.rb index 4fc804ad65f6d..d74ad34cbec6d 100644 --- a/Library/Homebrew/cli/parser.rb +++ b/Library/Homebrew/cli/parser.rb @@ -1,42 +1,72 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "env_config" require "cask/config" require "cli/args" +require "cli/error" +require "commands" require "optparse" -require "set" require "utils/tty" - -COMMAND_DESC_WIDTH = 80 -OPTION_DESC_WIDTH = 45 -HIDDEN_DESC_PLACEHOLDER = "@@HIDDEN@@" +require "utils/formatter" module Homebrew module CLI class Parser - extend T::Sig + ArgType = T.type_alias { T.any(NilClass, Symbol, T::Array[String], T::Array[Symbol]) } + HIDDEN_DESC_PLACEHOLDER = "@@HIDDEN@@" + SYMBOL_TO_USAGE_MAPPING = T.let({ + text_or_regex: "|`/``/`", + url: "", + }.freeze, T::Hash[Symbol, String]) + private_constant :ArgType, :HIDDEN_DESC_PLACEHOLDER, :SYMBOL_TO_USAGE_MAPPING + + sig { returns(Args) } + attr_reader :args + + sig { returns(Args::OptionsType) } + attr_reader :processed_options - attr_reader :processed_options, :hide_from_man_page, :named_args_type + sig { returns(T::Boolean) } + attr_reader :hide_from_man_page + sig { returns(ArgType) } + attr_reader :named_args_type + + 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 - Homebrew.send(cmd_args_method_name) if require?(cmd_path) + if 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 != cmd_args_method_name + 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, "--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]}`).", @@ -46,7 +76,7 @@ def self.global_cask_options "(default: `#{Cask::Config::DEFAULT_DIRS[:prefpanedir]}`).", }], [:flag, "--qlplugindir=", { - description: "Target location for QuickLook Plugins " \ + description: "Target location for Quick Look Plugins " \ "(default: `#{Cask::Config::DEFAULT_DIRS[:qlplugindir]}`).", }], [:flag, "--mdimporterdir=", { @@ -107,37 +137,57 @@ def self.global_options ] end - sig { params(block: T.nilable(T.proc.bind(Parser).void)).void } - def initialize(&block) - @parser = OptionParser.new - - @parser.summary_indent = " " * 2 + sig { params(option: String).returns(String) } + def self.option_to_name(option) + option.sub(/\A--?(\[no-\])?/, "").tr("-", "_").delete("=") + end + sig { + params(cmd: T.nilable(T.class_of(Homebrew::AbstractCommand)), block: T.nilable(T.proc.bind(Parser).void)).void + } + def initialize(cmd = nil, &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 = Homebrew::CLI::Args.new - - # Filter out Sorbet runtime type checking method calls. - @command_name = caller_locations.select { |location| location.path.exclude?("/gems/sorbet-runtime-") } - .second.label.chomp("_args").tr("_", "-") - - @constraints = [] - @conflicts = [] - @switch_sources = {} - @processed_options = [] - @non_global_processed_options = [] - @named_args_type = nil - @max_named_args = nil - @min_named_args = nil - @description = nil - @usage_banner = nil - @hide_from_man_page = false - @formula_options = false - @cask_options = false + @args = T.let((cmd&.args_class || Args).new, Args) + + if cmd + @command_name = T.let(cmd.command_name, String) + @is_dev_cmd = T.let(cmd.dev_cmd?, T::Boolean) + else + # FIXME: remove once commands are all subclasses of `AbstractCommand`: + # Filter out Sorbet runtime type checking method calls. + cmd_location = caller_locations.select do |location| + T.must(location.path).exclude?("/gems/sorbet-runtime-") + end.fetch(1) + @command_name = T.let(T.must(cmd_location.label).chomp("_args").tr("_", "-"), String) + @is_dev_cmd = T.let(T.must(cmd_location.absolute_path).start_with?(Commands::HOMEBREW_DEV_CMD_PATH), + T::Boolean) + odeprecated( + "`brew #{@command_name}'. This command needs to be refactored, as it is written in a style that", + "inherits from `Homebrew::AbstractCommand' ( see https://docs.brew.sh/External-Commands )", + disable_for_developers: false, + ) + end + + @constraints = T.let([], T::Array[[String, String]]) + @conflicts = T.let([], T::Array[T::Array[String]]) + @switch_sources = T.let({}, T::Hash[String, Symbol]) + @processed_options = T.let([], Args::OptionsType) + @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) self.class.global_options.each do |short, long, desc| switch short, long, description: desc, env: option_to_name(long), method: :on_tail @@ -148,62 +198,68 @@ def initialize(&block) generate_banner end + sig { + params(names: String, description: T.nilable(String), replacement: T.untyped, env: T.untyped, + depends_on: T.nilable(String), method: Symbol, hidden: T::Boolean, disable: T::Boolean).void + } def switch(*names, description: nil, replacement: nil, env: nil, depends_on: nil, - method: :on, hidden: false) + method: :on, hidden: false, disable: false) global_switch = names.first.is_a?(Symbol) return if global_switch - description = option_description(description, *names, hidden: hidden) - if replacement.nil? - process_option(*names, description, type: :switch, hidden: hidden) - else - description += " (disabled#{"; replaced by #{replacement}" if replacement.present?})" + description = option_description(description, *names, hidden:) + process_option(*names, description, type: :switch, hidden:) unless disable + + if replacement || disable + description += " (#{disable ? "disabled" : "deprecated"}#{"; replaced by #{replacement}" if replacement})" end + @parser.public_send(method, *names, *wrap_option_desc(description)) do |value| - odisabled "the `#{names.first}` switch", replacement unless replacement.nil? + # This odeprecated should stick around indefinitely. + odeprecated "the `#{names.first}` switch", replacement, disable: disable if !replacement.nil? || disable value = true if names.none? { |name| name.start_with?("--[no-]") } - set_switch(*names, value: value, from: :args) + set_switch(*names, value:, from: :args) end names.each do |name| - set_constraints(name, depends_on: depends_on) + set_constraints(name, depends_on:) end - env_value = env?(env) + env_value = value_for_env(env) set_switch(*names, value: env_value, from: :env) unless env_value.nil? end alias switch_option switch - def env?(env) - return if env.blank? - - Homebrew::EnvConfig.try(:"#{env}?") - end - + 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) @usage_banner, @description = text.chomp.split("\n\n", 2) end - def usage_banner_text - @parser.banner - end + sig { returns(T.nilable(String)) } + def usage_banner_text = @parser.banner + sig { params(name: String, description: T.nilable(String), hidden: T::Boolean).void } def comma_array(name, description: nil, hidden: false) name = name.chomp "=" - description = option_description(description, name, hidden: hidden) - process_option(name, description, type: :comma_array, hidden: hidden) + description = option_description(description, name, hidden:) + process_option(name, description, type: :comma_array, hidden:) @parser.on(name, OptionParser::REQUIRED_ARGUMENT, Array, *wrap_option_desc(description)) do |list| - @args[option_to_name(name)] = list + set_args_method(option_to_name(name).to_sym, list) end end + sig { + params(names: String, description: T.nilable(String), replacement: T.any(Symbol, String, NilClass), + depends_on: T.nilable(String), hidden: T::Boolean).void + } def flag(*names, description: nil, replacement: nil, depends_on: nil, hidden: false) required, flag_type = if names.any? { |name| name.end_with? "=" } [OptionParser::REQUIRED_ARGUMENT, :required_flag] @@ -211,34 +267,45 @@ def flag(*names, description: nil, replacement: nil, depends_on: nil, hidden: fa [OptionParser::OPTIONAL_ARGUMENT, :optional_flag] end names.map! { |name| name.chomp "=" } - description = option_description(description, *names, hidden: hidden) + description = option_description(description, *names, hidden:) if replacement.nil? - process_option(*names, description, type: flag_type, hidden: hidden) + process_option(*names, description, type: flag_type, hidden:) else description += " (disabled#{"; replaced by #{replacement}" if replacement.present?})" end @parser.on(*names, *wrap_option_desc(description), required) do |option_value| + # This odisabled should stick around indefinitely. odisabled "the `#{names.first}` flag", replacement unless replacement.nil? names.each do |name| - @args[option_to_name(name)] = option_value + set_args_method(option_to_name(name).to_sym, option_value) end end names.each do |name| - set_constraints(name, depends_on: depends_on) + set_constraints(name, depends_on:) + 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) @conflicts << options.map { |option| option_to_name(option) } end - def option_to_name(option) - option.sub(/\A--?(\[no-\])?/, "") - .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}" @@ -247,10 +314,12 @@ 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 + 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? @@ -258,6 +327,10 @@ def option_description(description, *names, hidden: false) option_to_description(*names) end + 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 = [] @@ -297,9 +370,10 @@ def parse_remaining(argv, ignore_invalid_options: false) def parse(argv = ARGV.freeze, ignore_invalid_options: false) raise "Arguments were already parsed!" if @args_parsed - # If we accept formula options, parse once allowing invalid options - # so we can get the remaining list containing formula names. - if @formula_options + # 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] @@ -311,9 +385,9 @@ def parse(argv = ARGV.freeze, ignore_invalid_options: false) name = o.flag description = "`#{f.name}`: #{o.description}" if name.end_with? "=" - flag name, description: description + flag(name, description:) else - switch name, description: description + switch name, description: end conflicts "--cask", name @@ -321,7 +395,7 @@ def parse(argv = ARGV.freeze, ignore_invalid_options: false) end end - remaining, non_options = parse_remaining(argv, ignore_invalid_options: ignore_invalid_options) + remaining, non_options = parse_remaining(argv, ignore_invalid_options:) named_args = if ignore_invalid_options [] @@ -330,16 +404,20 @@ def parse(argv = ARGV.freeze, ignore_invalid_options: false) end unless ignore_invalid_options + unless @is_dev_cmd + set_default_options + validate_options + end check_constraint_violations check_named_args(named_args) end - @args.freeze_named_args!(named_args, cask_options: @cask_options) + @args.freeze_named_args!(named_args, cask_options: @cask_options, without_api: @named_args_without_api) @args.freeze_remaining_args!(non_options.empty? ? remaining : [*remaining, "--", non_options]) @args.freeze_processed_options!(@processed_options) @args.freeze - @args_parsed = true + @args_parsed = T.let(true, T.nilable(TrueClass)) if !ignore_invalid_options && @args.help? puts generate_help_text @@ -349,8 +427,15 @@ def parse(argv = ARGV.freeze, ignore_invalid_options: false) @args end + sig { void } + def set_default_options; end + + sig { void } + def validate_options; end + + sig { returns(String) } def generate_help_text - Formatter.format_help_text(@parser.to_s, width: COMMAND_DESC_WIDTH) + Formatter.format_help_text(@parser.to_s, width: Formatter::COMMAND_DESC_WIDTH) .gsub(/\n.*?@@HIDDEN@@.*?(?=\n)/, "") .sub(/^/, "#{Tty.bold}Usage: brew#{Tty.reset} ") .gsub(/`(.*?)`/m, "#{Tty.bold}\\1#{Tty.reset}") @@ -360,10 +445,12 @@ def generate_help_text end end + sig { void } def cask_options - self.class.global_cask_options.each do |method, *args, **options| - send(method, *args, **options) - conflicts "--formula", args.last + 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 @@ -375,18 +462,17 @@ def formula_options sig { params( - type: T.any(Symbol, T::Array[String], T::Array[Symbol]), - number: T.nilable(Integer), - min: T.nilable(Integer), - max: T.nilable(Integer), + 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) - if number.present? && (min.present? || max.present?) - raise ArgumentError, "Do not specify both `number` and `min` or `max`" - end + 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.present? || min.present? || max.present?) + if type == :none && (number || min || max) raise ArgumentError, "Do not specify both `number`, `min` or `max` with `named_args :none`" end @@ -394,12 +480,14 @@ def named_args(type = nil, number: nil, min: nil, max: nil) if type == :none @max_named_args = 0 - elsif number.present? + elsif number @min_named_args = @max_named_args = number - elsif min.present? || max.present? + elsif min || max @min_named_args = min @max_named_args = max end + + @named_args_without_api = without_api end sig { void } @@ -409,19 +497,15 @@ def hide_from_man_page! private - SYMBOL_TO_USAGE_MAPPING = { - text_or_regex: "|`/``/`", - url: "", - }.freeze - + sig { returns(String) } def generate_usage_banner command_names = ["`#{@command_name}`"] aliases_to_skip = %w[instal uninstal] - command_names += Commands::HOMEBREW_INTERNAL_COMMAND_ALIASES.map do |command_alias, command| + 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.compact.sort + end.sort options = if @non_global_processed_options.empty? "" @@ -430,21 +514,21 @@ def generate_usage_banner else required_argument_types = [:required_flag, :comma_array] @non_global_processed_options.map do |option, type| - next " [<#{option}>`=`]" if required_argument_types.include? type + next " [`#{option}=`]" if required_argument_types.include? type - " [<#{option}>]" + " [`#{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.map do |type| + 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.compact + end types << "" if @named_args_type.any?(String) types.join("|") elsif SYMBOL_TO_USAGE_MAPPING.key? @named_args_type @@ -453,9 +537,9 @@ def generate_usage_banner "<#{@named_args_type}>" end - named_args = if @min_named_args.blank? && @max_named_args == 1 + named_args = if @min_named_args.nil? && @max_named_args == 1 " [#{arg_type}]" - elsif @min_named_args.blank? + elsif @min_named_args.nil? " [#{arg_type} ...]" elsif @min_named_args == 1 && @max_named_args == 1 " #{arg_type}" @@ -469,6 +553,7 @@ def generate_usage_banner "#{command_names.join(", ")}#{options}#{named_args}" end + sig { returns(String) } def generate_banner @usage_banner ||= generate_usage_banner @@ -480,31 +565,39 @@ def generate_banner BANNER end + sig { params(names: String, value: T.untyped, from: Symbol).void } def set_switch(*names, value:, from:) names.each do |name| @switch_sources[option_to_name(name)] = from - @args["#{option_to_name(name)}?"] = value + set_args_method(:"#{option_to_name(name)}?", value) end end - def disable_switch(*names) - names.each do |name| - @args["#{option_to_name(name)}?"] = if name.start_with?("--[no-]") + 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[name.to_sym] || @args["#{name}?".to_sym] + [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.format_help_text(desc, width: OPTION_DESC_WIDTH).split("\n") + Formatter.format_help_text(desc, width: Formatter::OPTION_DESC_WIDTH).split("\n") end + sig { params(name: String, depends_on: T.nilable(String)).returns(T.nilable(T::Array[[String, String]])) } def set_constraints(name, depends_on:) return if depends_on.nil? @@ -513,6 +606,7 @@ def set_constraints(name, depends_on:) @constraints << [primary, secondary] end + sig { void } def check_constraints @constraints.each do |primary, secondary| primary_passed = option_passed?(primary) @@ -527,6 +621,7 @@ def check_constraints end end + sig { void } def check_conflicts @conflicts.each do |mutually_exclusive_options_group| violations = mutually_exclusive_options_group.select do |option| @@ -540,12 +635,13 @@ 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(_1) } unless select_cli_arg - env_var_options.each(&method(:disable_switch)) + env_var_options.each { disable_switch(_1) } end end + sig { void } def check_invalid_constraints @conflicts.each do |mutually_exclusive_options_group| @constraints.each do |p, s| @@ -556,41 +652,45 @@ def check_invalid_constraints end end + sig { void } def check_constraint_violations check_invalid_constraints check_conflicts check_constraints end + sig { params(args: T::Array[String]).void } def check_named_args(args) - types = Array(@named_args_type).map do |type| + types = Array(@named_args_type).filter_map do |type| next type if type.is_a? Symbol :subcommand - end.compact.uniq + end.uniq exception = if @min_named_args && @max_named_args && @min_named_args == @max_named_args && args.size != @max_named_args - NumberOfNamedArgumentsError.new(@min_named_args, types: types) + NumberOfNamedArgumentsError.new(@min_named_args, types:) elsif @min_named_args && args.size < @min_named_args - MinNamedArgumentsError.new(@min_named_args, types: types) + MinNamedArgumentsError.new(@min_named_args, types:) elsif @max_named_args && args.size > @max_named_args - MaxNamedArgumentsError.new(@max_named_args, types: types) + MaxNamedArgumentsError.new(@max_named_args, types:) end raise exception if exception end + sig { params(args: String, type: Symbol, hidden: T::Boolean).void } def process_option(*args, type:, hidden: false) option, = @parser.make_switch(args) @processed_options.reject! { |existing| existing.second == option.long.first } if option.long.first.present? - @processed_options << [option.short.first, option.long.first, option.arg, option.desc.first, hidden] + @processed_options << [option.short.first, option.long.first, option.desc.first, hidden] + args.pop # last argument is the description if type == :switch disable_switch(*args) else args.each do |name| - @args[option_to_name(name)] = nil + set_args_method(option_to_name(name).to_sym, nil) end end @@ -600,6 +700,7 @@ def process_option(*args, type:, hidden: false) @non_global_processed_options << [option.long.first || option.short.first, type] end + 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)] @@ -608,6 +709,7 @@ def split_non_options(argv) end end + sig { params(argv: T::Array[String]).returns(T::Array[Formula]) } def formulae(argv) argv, non_options = split_non_options(argv) @@ -619,85 +721,35 @@ def formulae(argv) end # Only lowercase names, not paths, bottle filenames or URLs - named_args.map do |arg| + 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 + rescue FormulaUnavailableError, FormulaSpecificationError nil end - end.compact.uniq(&:name) + end.uniq(&:name) end - end - class OptionConstraintError < UsageError - 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 + sig { params(argv: T::Array[String]).returns(T::Boolean) } + def only_casks?(argv) + argv.include?("--casks") || argv.include?("--cask") end - end - class OptionConflictError < UsageError - def initialize(args) - args_list = args.map(&Formatter.public_method(:option)) - .join(" and ") - super "Options #{args_list} are mutually exclusive." - end - end - - class InvalidConstraintError < UsageError - def initialize(arg1, arg2) - super "`#{arg1}` and `#{arg2}` cannot be mutually exclusive and mutually dependent simultaneously." - end - end - - class MaxNamedArgumentsError < UsageError - extend T::Sig + sig { params(env: T.any(NilClass, String, Symbol)).returns(T.untyped) } + def value_for_env(env) + return if env.blank? - 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." + method_name = :"#{env}?" + if Homebrew::EnvConfig.respond_to?(method_name) + Homebrew::EnvConfig.public_send(method_name) 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} #{"argument".pluralize(maximum)}." + ENV.fetch("HOMEBREW_#{env.upcase}", nil) end end end - - class MinNamedArgumentsError < UsageError - extend T::Sig - - 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} #{"argument".pluralize(minimum)}." - end - end - - class NumberOfNamedArgumentsError < UsageError - extend T::Sig - - 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} #{"argument".pluralize(minimum)}." - end - end end end + +require "extend/os/parser" 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 1e39aa66c9c91..79137eb298849 100644 --- a/Library/Homebrew/cmd/--cache.rb +++ b/Library/Homebrew/cmd/--cache.rb @@ -1,78 +1,132 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "fetch" -require "cli/parser" require "cask/download" module Homebrew - extend T::Sig - - extend Fetch - - module_function - - sig { returns(CLI::Parser) } - def __cache_args - Homebrew::CLI::Parser.new do - description <<~EOS - 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." - 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" - - named_args [:formula, :cask] - end - end + module Cmd + class Cache < AbstractCommand + include Fetch - sig { void } - def __cache - args = __cache_args.parse + sig { override.returns(String) } + def self.command_name = "--cache" - if args.no_named? - puts HOMEBREW_CACHE - return - end + cmd_args do + description <<~EOS + Display Homebrew's download cache. See also `HOMEBREW_CACHE`. - formulae_or_casks = args.named.to_formulae_and_casks + 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." - formulae_or_casks.each do |formula_or_cask| - if formula_or_cask.is_a? Formula - print_formula_cache formula_or_cask, args: args - else - print_cask_cache formula_or_cask + 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 - end - end - sig { params(formula: Formula, args: CLI::Args).void } - def print_formula_cache(formula, args:) - if fetch_bottle?(formula, args: args) - puts formula.bottle_for_tag(args.bottle_tag&.to_sym).cached_download - elsif args.HEAD? - puts formula.head.cached_download - else - puts formula.cached_download - end - end + sig { override.void } + def run + if args.no_named? + puts HOMEBREW_CACHE + return + end + + formulae_or_casks = args.named.to_formulae_and_casks + os_arch_combinations = args.os_arch_combinations + + formulae_or_casks.each do |formula_or_cask| + case formula_or_cask + when Formula + formula = formula_or_cask + ref = formula.loaded_from_api? ? formula.full_name : formula.path + + os_arch_combinations.each do |os, arch| + SimulateSystem.with(os:, arch:) do + formula = Formulary.factory(ref) + print_formula_cache(formula, os:, arch:) + end + end + when Cask::Cask + cask = formula_or_cask + ref = cask.loaded_from_api? ? cask.full_name : cask.sourcefile_path - sig { params(cask: Cask::Cask).void } - def print_cask_cache(cask) - puts Cask::Download.new(cask).downloader.cached_location + os_arch_combinations.each do |os, arch| + next if os == :linux + + SimulateSystem.with(os:, arch:) do + loaded_cask = Cask::CaskLoader.load(ref) + print_cask_cache(loaded_cask) + end + end + else + raise "Invalid type: #{formula_or_cask.class}" + 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 = if (bottle_tag = args.bottle_tag&.to_sym) + Utils::Bottles::Tag.from_symbol(bottle_tag) + else + Utils::Bottles::Tag.new(system: os, arch:) + end + + 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 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 index 681cdf03314c7..a9debf878aec2 100644 --- a/Library/Homebrew/cmd/--caskroom.rb +++ b/Library/Homebrew/cmd/--caskroom.rb @@ -1,34 +1,34 @@ # typed: strict # frozen_string_literal: true -module Homebrew - extend T::Sig - - module_function +require "abstract_command" - sig { returns(CLI::Parser) } - def __caskroom_args - Homebrew::CLI::Parser.new do - description <<~EOS - Display Homebrew's Caskroom path. +module Homebrew + module Cmd + class Caskroom < AbstractCommand + sig { override.returns(String) } + def self.command_name = "--caskroom" - If is provided, display the location in the Caskroom where - would be installed, without any sort of versioned directory as the last path. - EOS + cmd_args do + description <<~EOS + Display Homebrew's Caskroom path. - named_args :cask - end - end + If is provided, display the location in the Caskroom where + would be installed, without any sort of versioned directory as the last path. + EOS - sig { void } - def __caskroom - args = __caskroom_args.parse + named_args :cask + end - if args.named.to_casks.blank? - puts Cask::Caskroom.path - else - args.named.to_casks.each do |cask| - puts "#{Cask::Caskroom.path}/#{cask.token}" + 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 diff --git a/Library/Homebrew/cmd/--cellar.rb b/Library/Homebrew/cmd/--cellar.rb index fa1eff526e0cc..aa7abafc65f0f 100644 --- a/Library/Homebrew/cmd/--cellar.rb +++ b/Library/Homebrew/cmd/--cellar.rb @@ -1,32 +1,34 @@ -# typed: true +# 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 - description <<~EOS - Display Homebrew's Cellar path. *Default:* `$(brew --prefix)/Cellar`, or if - that directory doesn't exist, `$(brew --repository)/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`. - 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 - named_args :formula - end - end - - def __cellar - args = __cellar_args.parse + named_args :formula + end - if args.no_named? - puts HOMEBREW_CELLAR - else - puts args.named.to_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 f9c12119d9d4c..e0a2a503073eb 100644 --- a/Library/Homebrew/cmd/--env.rb +++ b/Library/Homebrew/cmd/--env.rb @@ -1,58 +1,56 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "extend/ENV" require "build_environment" require "utils/shell" -require "cli/parser" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def __env_args - Homebrew::CLI::Parser.new 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 - end - - sig { void } - def __env - args = __env_args.parse - - 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(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 - if shell.nil? - BuildEnvironment.dump ENV - else - BuildEnvironment.keys(ENV).each do |key| - puts Utils::Shell.export_value(key, ENV.fetch(key), shell) + sig { override.void } + def run + ENV.activate_extensions! + T.cast(ENV, Superenv).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 51bda5716de99..2f1a87fe80235 100644 --- a/Library/Homebrew/cmd/--prefix.rb +++ b/Library/Homebrew/cmd/--prefix.rb @@ -1,111 +1,115 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "fileutils" module Homebrew - extend T::Sig + module Cmd + class Prefix < AbstractCommand + include FileUtils + + 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 + + sig { override.returns(String) } + def self.command_name = "--prefix" + + 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" - module_function + named_args :formula + end - sig { returns(CLI::Parser) } - def __prefix_args - Homebrew::CLI::Parser.new do - description <<~EOS - Display Homebrew's install path. *Default:* + 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 - - macOS Intel: `#{HOMEBREW_DEFAULT_PREFIX}` - - macOS ARM: `#{HOMEBREW_MACOS_ARM_DEFAULT_PREFIX}` - - Linux: `#{HOMEBREW_LINUX_DEFAULT_PREFIX}` + private - 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" + sig { void } + def list_unbrewed + dirs = HOMEBREW_PREFIX.subdirs.map { |dir| dir.basename.to_s } + dirs -= %w[Library Cellar Caskroom .git] - named_args :formula - end - end + # 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 - args = __prefix_args.parse - - 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.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.compact - 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 + 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 ")" - 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 - - 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 + cd(HOMEBREW_PREFIX) { safe_system("find", *arguments) } + end end - dirs.delete "etc" - dirs.delete "var" - - 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[)] - - cd HOMEBREW_PREFIX - safe_system "find", *arguments end end diff --git a/Library/Homebrew/cmd/--repository.rb b/Library/Homebrew/cmd/--repository.rb index c72c0510fe7cf..c5ac522a13f6d 100644 --- a/Library/Homebrew/cmd/--repository.rb +++ b/Library/Homebrew/cmd/--repository.rb @@ -1,33 +1,26 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "shell_command" module Homebrew - extend T::Sig + module Cmd + class Repository < AbstractCommand + include ShellCommand - module_function + sig { override.returns(String) } + def self.command_name = "--repository" - sig { returns(CLI::Parser) } - def __repository_args - Homebrew::CLI::Parser.new do - description <<~EOS - Display where Homebrew's git repository 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 + If `/` are provided, display where tap `/`'s directory is located. + EOS - named_args :tap - end - end - - def __repository - args = __repository_args.parse - - if args.no_named? - puts HOMEBREW_REPOSITORY - else - puts args.named.to_taps.map(&: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/--version.rb b/Library/Homebrew/cmd/--version.rb new file mode 100644 index 0000000000000..bcaca65d2c879 --- /dev/null +++ b/Library/Homebrew/cmd/--version.rb @@ -0,0 +1,23 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Version < AbstractCommand + include ShellCommand + + sig { override.returns(String) } + def self.command_name = "--version" + + 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 +end diff --git a/Library/Homebrew/cmd/--version.sh b/Library/Homebrew/cmd/--version.sh index caf3ea62571d9..1b30d0e552a71 100644 --- a/Library/Homebrew/cmd/--version.sh +++ b/Library/Homebrew/cmd/--version.sh @@ -1,6 +1,4 @@ -#: * `--version`, `-v` -#: -#: Print the version numbers of Homebrew, Homebrew/homebrew-core and Homebrew/homebrew-cask (if tapped) to standard output. +# Documentation defined in Library/Homebrew/cmd/--version.rb # HOMEBREW_CORE_REPOSITORY, HOMEBREW_CASK_REPOSITORY, HOMEBREW_VERSION are set by brew.sh # shellcheck disable=SC2154 @@ -27,7 +25,11 @@ version_string() { homebrew-version() { echo "Homebrew ${HOMEBREW_VERSION}" - echo "Homebrew/homebrew-core $(version_string "${HOMEBREW_CORE_REPOSITORY}")" + + 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 diff --git a/Library/Homebrew/cmd/analytics.rb b/Library/Homebrew/cmd/analytics.rb index b9c3badfd0187..6531b823c8bae 100644 --- a/Library/Homebrew/cmd/analytics.rb +++ b/Library/Homebrew/cmd/analytics.rb @@ -1,53 +1,48 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - extend T::Sig + module Cmd + class Analytics < AbstractCommand + cmd_args do + description <<~EOS + Control Homebrew's anonymous aggregate user behaviour analytics. + Read more at . - module_function + `brew analytics` [`state`]: + Display the current state of Homebrew's analytics. - sig { returns(CLI::Parser) } - def analytics_args - Homebrew::CLI::Parser.new do - description <<~EOS - Control Homebrew's anonymous aggregate user behaviour analytics. - Read more at . + `brew analytics` (`on`|`off`): + Turn Homebrew's analytics on or off respectively. + EOS - `brew analytics` [`state`]: - Display the current state of Homebrew's analytics. - - `brew analytics` (`on`|`off`): - Turn Homebrew's analytics on or off respectively. - - `brew analytics regenerate-uuid`: - Regenerate the UUID used for Homebrew's analytics. - EOS - - named_args %w[state on off regenerate-uuid], max: 1 - end - end - - def analytics - args = analytics_args.parse + named_args %w[state on off regenerate-uuid], max: 1 + end - case args.named.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 + case args.named.first + when nil, "state" + if Utils::Analytics.disabled? + puts "InfluxDB analytics are disabled." + else + puts "InfluxDB analytics are enabled." + end + puts "Google Analytics were destroyed." + when "on" + Utils::Analytics.enable! + when "off" + Utils::Analytics.disable! + when "regenerate-uuid" + Utils::Analytics.delete_uuid! + opoo "Homebrew no longer uses an analytics UUID so this has been deleted!" + puts "brew analytics regenerate-uuid is no longer necessary." + else + raise UsageError, "unknown subcommand: #{args.named.first}" + end end - when "on" - Utils::Analytics.enable! - when "off" - Utils::Analytics.disable! - when "regenerate-uuid" - Utils::Analytics.regenerate_uuid! - else - raise UsageError, "unknown subcommand: #{args.named.first}" end end end diff --git a/Library/Homebrew/cmd/autoremove.rb b/Library/Homebrew/cmd/autoremove.rb index a80cb4ae49fb4..45f22bde1f816 100644 --- a/Library/Homebrew/cmd/autoremove.rb +++ b/Library/Homebrew/cmd/autoremove.rb @@ -1,56 +1,26 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "formula" -require "cli/parser" -require "uninstall" +require "abstract_command" +require "cleanup" module Homebrew - module_function - - def autoremove_args - Homebrew::CLI::Parser.new 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 - end - - def get_removable_formulae(formulae) - removable_formulae = Formula.installed_formulae_with_no_dependents(formulae).reject do |f| - Tab.for_keg(f.any_installed_keg).installed_on_request + 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 - - removable_formulae += get_removable_formulae(formulae - removable_formulae) if removable_formulae.present? - - removable_formulae - end - - def autoremove - args = autoremove_args.parse - - removable_formulae = get_removable_formulae(Formula.installed) - - if (casks = Cask::Caskroom.casks.presence) - removable_formulae -= casks.flat_map { |cask| cask.depends_on[:formula] } - .compact - .map { |f| Formula[f] } - .flat_map { |f| [f, *f.runtime_formula_dependencies].compact } - end - return if removable_formulae.blank? - - formulae_names = removable_formulae.map(&:full_name).sort - - verb = args.dry_run? ? "Would uninstall" : "Uninstalling" - oh1 "#{verb} #{formulae_names.count} unneeded #{"formula".pluralize(formulae_names.count)}:" - puts formulae_names.join("\n") - return if args.dry_run? - - kegs_by_rack = removable_formulae.map(&:any_installed_keg).group_by(&:rack) - Uninstall.uninstall_kegs(kegs_by_rack) end end diff --git a/Library/Homebrew/cmd/casks.rb b/Library/Homebrew/cmd/casks.rb new file mode 100644 index 0000000000000..4bc6ace0e88eb --- /dev/null +++ b/Library/Homebrew/cmd/casks.rb @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +# This Ruby command exists to allow generation of completions for the Bash +# version. It is not meant to be run. +module Homebrew + module Cmd + class Casks < AbstractCommand + include ShellCommand + + cmd_args do + description "List all locally installable casks including short names." + end + end + end +end diff --git a/Library/Homebrew/cmd/casks.sh b/Library/Homebrew/cmd/casks.sh index aa3a75cf2bb2e..b9fe0f5729639 100644 --- a/Library/Homebrew/cmd/casks.sh +++ b/Library/Homebrew/cmd/casks.sh @@ -1,12 +1,25 @@ -#: * `casks` -#: -#: List all locally installable casks including short names. -#: +# 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() { - homebrew-items 'Formula' 's|/Casks/|/|' '^homebrew/cask' + local find_include_filter='*/Casks/*\.rb' + local sed_filter='s|/Casks/(.+/)?|/|' + local grep_filter='^homebrew/cask' + + # 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/cleanup.rb b/Library/Homebrew/cmd/cleanup.rb index 79d41232f3f62..4d75cfaa5ee34 100644 --- a/Library/Homebrew/cmd/cleanup.rb +++ b/Library/Homebrew/cmd/cleanup.rb @@ -1,76 +1,72 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "cleanup" -require "cli/parser" module Homebrew - extend T::Sig + 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." - module_function + named_args [:formula, :cask] + end - sig { returns(CLI::Parser) } - def cleanup_args - Homebrew::CLI::Parser.new do - days = Homebrew::EnvConfig::ENVS[:HOMEBREW_CLEANUP_MAX_AGE_DAYS][: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", - 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." + 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 - named_args [:formula, :cask] - end - end + 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 - def cleanup - args = cleanup_args.parse + cleanup.clean!(quiet: args.quiet?, periodic: false) - 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 + 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 = Cleanup.new(*args.named, dry_run: args.dry_run?, scrub: args.s?, days: days) - if args.prune_prefix? - cleanup.prune_prefix_symlinks_and_directories - return - end - - cleanup.clean! + return if cleanup.unremovable_kegs.empty? - 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." + ofail <<~EOS + Could not cleanup old kegs! Fix your permissions on: + #{cleanup.unremovable_kegs.join "\n "} + EOS end end - - return if cleanup.unremovable_kegs.empty? - - ofail <<~EOS - Could not cleanup old kegs! Fix your permissions on: - #{cleanup.unremovable_kegs.join "\n "} - EOS end end diff --git a/Library/Homebrew/cmd/command.rb b/Library/Homebrew/cmd/command.rb new file mode 100644 index 0000000000000..8b4753e933c11 --- /dev/null +++ b/Library/Homebrew/cmd/command.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "commands" + +module Homebrew + 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 ea41c1efa3a3b..6ec4cf889e6f3 100644 --- a/Library/Homebrew/cmd/commands.rb +++ b/Library/Homebrew/cmd/commands.rb @@ -1,50 +1,46 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def commands_args - Homebrew::CLI::Parser.new 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 - end - - def commands - args = commands_args.parse - - if args.quiet? - puts Formatter.columns(Commands.commands(aliases: args.include_aliases?)) - return - end - - prepend_separator = false - - { - "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? - - puts if prepend_separator - ohai title, Formatter.columns(commands) - - prepend_separator ||= true + 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 + + sig { override.void } + def run + if args.quiet? + puts Formatter.columns(Commands.commands(aliases: args.include_aliases?)) + return + end + + prepend_separator = T.let(false, T::Boolean) + + { + "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? + + puts if prepend_separator + ohai title, Formatter.columns(commands) + + prepend_separator ||= true + end + end end end end diff --git a/Library/Homebrew/cmd/completions.rb b/Library/Homebrew/cmd/completions.rb index 4003dd0de09f2..2fd15596ceebf 100644 --- a/Library/Homebrew/cmd/completions.rb +++ b/Library/Homebrew/cmd/completions.rb @@ -1,50 +1,46 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" require "completions" module Homebrew - extend T::Sig + module Cmd + class CompletionsCmd < AbstractCommand + cmd_args do + description <<~EOS + Control whether Homebrew automatically links external tap shell completion files. + Read more at . - module_function + `brew completions` [`state`]: + Display the current state of Homebrew's completions. - sig { returns(CLI::Parser) } - def completions_args - Homebrew::CLI::Parser.new do - description <<~EOS - Control whether Homebrew automatically links external tap shell completion files. - Read more at . + `brew completions` (`link`|`unlink`): + Link or unlink Homebrew's completions. + EOS - `brew completions` [`state`]: - Display the current state of Homebrew's completions. - - `brew completions` (`link`|`unlink`): - Link or unlink Homebrew's completions. - EOS - - named_args %w[state link unlink], max: 1 - end - end - - def completions - args = completions_args.parse + named_args %w[state link unlink], max: 1 + end - case args.named.first - when nil, "state" - if Completions.link_completions? - puts "Completions are linked." - else - puts "Completions are not linked." + sig { override.void } + def run + case args.named.first + when nil, "state" + if Completions.link_completions? + puts "Completions are linked." + else + puts "Completions are not linked." + end + when "link" + Completions.link! + puts "Completions are now linked." + when "unlink" + Completions.unlink! + puts "Completions are no longer linked." + else + raise UsageError, "unknown subcommand: #{args.named.first}" + end end - when "link" - Completions.link! - puts "Completions are now linked." - when "unlink" - Completions.unlink! - puts "Completions are no longer linked." - else - raise UsageError, "unknown subcommand: #{args.named.first}" end end end diff --git a/Library/Homebrew/cmd/config.rb b/Library/Homebrew/cmd/config.rb index 7271881e561b5..515139b7ee637 100644 --- a/Library/Homebrew/cmd/config.rb +++ b/Library/Homebrew/cmd/config.rb @@ -1,29 +1,25 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "system_config" -require "cli/parser" module Homebrew - extend T::Sig + 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 - module_function + named_args :none + end - sig { returns(CLI::Parser) } - def config_args - Homebrew::CLI::Parser.new 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 - - named_args :none + 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 e684091929962..fab09e12ac05e 100644 --- a/Library/Homebrew/cmd/deps.rb +++ b/Library/Homebrew/cmd/deps.rb @@ -1,312 +1,351 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "abstract_command" require "formula" -require "ostruct" -require "cli/parser" require "cask/caskroom" require "dependencies_helpers" module Homebrew - extend T::Sig - - extend DependenciesHelpers - - module_function - - sig { returns(CLI::Parser) } - def deps_args - Homebrew::CLI::Parser.new do - description <<~EOS - 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 "--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, 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 "--formula", "--formulae", - depends_on: "--installed", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - depends_on: "--installed", - description: "Treat all named arguments as casks." - - conflicts "--tree", "--graph" - conflicts "--installed", "--all" - conflicts "--formula", "--cask" - formula_options - - named_args [:formula, :cask] - end - end - - def deps - args = deps_args.parse - - Formulary.enable_factory_cache! - - recursive = !args.send(:"1?") - installed = args.installed? || dependents(args.named.to_formulae_and_casks).all?(&:any_version_installed?) - - @use_runtime_dependencies = installed && recursive && - !args.tree? && - !args.graph? && - !args.include_build? && - !args.include_test? && - !args.include_optional? && - !args.skip_recommended? - - 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 + module Cmd + class Deps < AbstractCommand + include DependenciesHelpers + 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 the current versions' stated + dependencies if the installed versions are outdated. + + *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-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 "--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." + switch "--for-each", + description: "Switch into the mode used by the `--eval-all` option, but only list dependencies " \ + "for each provided , one formula per line. This is used for " \ + "debugging the `--installed`/`--eval-all` display mode." + 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 - if args.graph? - dot_code = dot_code(dependents, recursive: recursive, args: args) - if args.dot? - puts dot_code - else - exec_browser "https://dreampuf.github.io/GraphvizOnline/##{ERB::Util.url_encode(dot_code)}" + 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 = T.must(args.os_arch_combinations.first) + all = args.eval_all? + + Formulary.enable_factory_cache! + + SimulateSystem.with(os:, arch:) do + recursive = !args.direct? + installed = args.installed? || dependents(args.named.to_formulae_and_casks).all?(&:any_version_installed?) + + @use_runtime_dependencies = installed && recursive && + !args.tree? && + !args.graph? && + !args.HEAD? && + !args.include_build? && + !args.include_test? && + !args.include_optional? && + !args.skip_recommended? && + !args.missing? && + args.os.nil? && + args.arch.nil? + + 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 all + puts_deps(sorted_dependents( + Formula.all(eval_all: args.eval_all?) + Cask::Cask.all(eval_all: args.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? + + all_deps = deps_for_dependents(dependents, recursive:, &(args.union? ? :| : :&)) + condense_requirements(all_deps) + all_deps.map! { |d| dep_display_name(d) } + all_deps.uniq! + all_deps.sort! unless args.topological? + puts all_deps end - return end - puts_deps_tree dependents, recursive: recursive, args: args - return - elsif args.all? - puts_deps sorted_dependents(Formula.all + Cask::Cask.all), recursive: recursive, args: args - return - elsif !args.no_named? && args.for_each? - puts_deps sorted_dependents(args.named.to_formulae_and_casks), recursive: recursive, args: args - return - end + private - 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) + def sorted_dependents(formulae_or_casks) + dependents(formulae_or_casks).sort_by(&:name) end - puts_deps sorted_dependents_formulae_and_casks, recursive: recursive, args: args - return - end - - dependents = dependents(args.named.to_formulae_and_casks) - all_deps = deps_for_dependents(dependents, recursive: recursive, args: args, &(args.union? ? :| : :&)) - condense_requirements(all_deps, args: args) - all_deps.map! { |d| dep_display_name(d, args: args) } - all_deps.uniq! - all_deps.sort! unless args.n? - puts all_deps - end - - def sorted_dependents(formulae_or_casks) - dependents(formulae_or_casks).sort_by(&:name) - end - - def condense_requirements(deps, args:) - deps.select! { |dep| dep.is_a?(Dependency) } unless args.include_requirements? - deps.select! { |dep| dep.is_a?(Requirement) || dep.installed? } if args.installed? - end - - def dep_display_name(dep, args:) - 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}" + 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 - 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? - 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 - str - 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 - def deps_for_dependent(d, args:, recursive: false) - includes, ignores = args_includes_ignores(args) + str + end - deps = d.runtime_dependencies if @use_runtime_dependencies + def deps_for_dependent(dependency, recursive: false) + includes, ignores = args_includes_ignores(args) - if recursive - deps ||= recursive_includes(Dependency, d, includes, ignores) - reqs = recursive_includes(Requirement, d, includes, ignores) - else - deps ||= reject_ignores(d.deps, ignores, includes) - reqs = reject_ignores(d.requirements, ignores, includes) - end + deps = dependency.runtime_dependencies if @use_runtime_dependencies - deps + reqs.to_a - end + if recursive + deps ||= recursive_includes(Dependency, dependency, includes, ignores) + reqs = recursive_includes(Requirement, dependency, includes, ignores) + else + deps ||= select_includes(dependency.deps, ignores, includes) + reqs = select_includes(dependency.requirements, ignores, includes) + end - def deps_for_dependents(dependents, args:, recursive: false, &block) - dependents.map { |d| deps_for_dependent(d, recursive: recursive, args: args) }.reduce(&block) - end + deps + reqs.to_a + end - def puts_deps(dependents, args:, recursive: false) - dependents.each do |dependent| - deps = deps_for_dependent(dependent, recursive: recursive, args: args) - condense_requirements(deps, args: args) - deps.sort_by!(&:name) - deps.map! { |d| dep_display_name(d, args: args) } - puts "#{dependent.full_name}: #{deps.join(" ")}" - end - end + def deps_for_dependents(dependents, recursive: false, &block) + dependents.map { |d| deps_for_dependent(d, recursive:) }.reduce(&block) + end - def dot_code(dependents, recursive:, args:) - dep_graph = {} - dependents.each do |d| - graph_deps(d, dep_graph: dep_graph, recursive: recursive, args: args) - end + def check_head_spec(dependents) + headless = dependents.select { |d| d.is_a?(Formula) && d.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 - 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" + 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! { |d| dep_display_name(d) } + puts "#{dependent.full_name}: #{deps.join(" ")}" 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 - def graph_deps(f, dep_graph:, recursive:, args:) - return if dep_graph.key?(f) + def dot_code(dependents, recursive:) + dep_graph = {} + dependents.each do |d| + graph_deps(d, dep_graph:, recursive:) + end - dependables = dependables(f, args: args) - dep_graph[f] = dependables - return unless 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 - dependables.each do |dep| - next unless dep.is_a? Dependency + def graph_deps(formula, dep_graph:, recursive:) + return if dep_graph.key?(formula) - graph_deps(Formulary.factory(dep.name), - dep_graph: dep_graph, - recursive: true, - args: args) - end - end - - def puts_deps_tree(dependents, args:, recursive: false) - dependents.each do |d| - puts d.full_name - recursive_deps_tree(d, dep_stack: [], prefix: "", recursive: recursive, args: args) - puts - end - end + dependables = dependables(formula) + dep_graph[formula] = dependables + return unless recursive - def dependables(f, args:) - includes, ignores = args_includes_ignores(args) - deps = @use_runtime_dependencies ? f.runtime_dependencies : f.deps - deps = reject_ignores(deps, ignores, includes) - reqs = reject_ignores(f.requirements, ignores, includes) if args.include_requirements? - reqs ||= [] - reqs + deps - end + dependables.each do |dep| + next unless dep.is_a? Dependency - def recursive_deps_tree(f, dep_stack:, prefix:, recursive:, args:) - dependables = dependables(f, args: args) - max = dependables.length - 1 - dep_stack.push f.name - dependables.each_with_index do |dep, i| - tree_lines = if i == max - "└──" - else - "├──" + graph_deps(Formulary.factory(dep.name), + dep_graph:, + recursive: true) + end end - display_s = "#{tree_lines} #{dep_display_name(dep, args: args)}" - is_circular = dep_stack.include?(dep.name) - display_s = "#{display_s} (CIRCULAR DEPENDENCY)" if is_circular - puts "#{prefix}#{display_s}" - - next if !recursive || is_circular + 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, dep_stack: [], prefix: "", recursive:) + puts + end + end - prefix_addition = if i == max - " " - else - "│ " + 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 - next unless dep.is_a? Dependency + def recursive_deps_tree(formula, dep_stack:, prefix:, recursive:) + dependables = dependables(formula) + max = dependables.length - 1 + dep_stack.push formula.name + 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 = dep_stack.include?(dep.name) + if is_circular + display_s = "#{display_s} (CIRCULAR DEPENDENCY)" + Homebrew.failed = true + end + + puts "#{prefix}#{display_s}" + + next if !recursive || is_circular + + prefix_addition = if i == max + " " + else + "│ " + end + + next unless dep.is_a? Dependency + + recursive_deps_tree(Formulary.factory(dep.name), + dep_stack:, + prefix: prefix + prefix_addition, + recursive: true) + end - recursive_deps_tree(Formulary.factory(dep.name), - dep_stack: dep_stack, - prefix: prefix + prefix_addition, - recursive: true, - args: args) + dep_stack.pop + end end - - dep_stack.pop end end diff --git a/Library/Homebrew/cmd/desc.rb b/Library/Homebrew/cmd/desc.rb index d9976195432aa..b43d9235f8f7b 100644 --- a/Library/Homebrew/cmd/desc.rb +++ b/Library/Homebrew/cmd/desc.rb @@ -1,85 +1,74 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "descriptions" require "search" require "description_cache_store" -require "cli/parser" module Homebrew - extend T::Sig + 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. Implied if `HOMEBREW_EVAL_ALL` is set." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - module_function + conflicts "--search", "--name", "--description" - extend Search - - sig { returns(CLI::Parser) } - def desc_args - Homebrew::CLI::Parser.new do - description <<~EOS - 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 - 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 "--formula", "--formulae", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - description: "Treat all named arguments as casks." - - conflicts "--search", "--name", "--description" - - named_args [:formula, :cask, :text_or_regex], min: 1 - end - end + named_args [:formula, :cask, :text_or_regex], min: 1 + end - def desc - args = desc_args.parse + sig { override.void } + def run + search_type = if args.search? + :either + elsif args.name? + :name + elsif args.description? + :desc + end - search_type = if args.search? - :either - elsif args.name? - :name - elsif args.description? - :desc - end + if search_type.present? + if !args.eval_all? && !Homebrew::EnvConfig.eval_all? && Homebrew::EnvConfig.no_install_from_api? + raise UsageError, "`brew desc --search` needs `--eval-all` passed or `HOMEBREW_EVAL_ALL` set!" + end - if search_type.blank? - desc = {} - args.named.to_formulae_and_casks.each do |formula_or_cask| - if formula_or_cask.is_a? Formula - desc[formula_or_cask.full_name] = formula_or_cask.desc - else - description = formula_or_cask.desc.presence || Formatter.warning("[no description]") - desc[formula_or_cask.full_name] = "(#{formula_or_cask.name.join(", ")}) #{description}" - end - end - Descriptions.new(desc).print - else - query = args.named.join(" ") - string_or_regex = query_regexp(query) - unless args.cask? - ohai "Formulae" - CacheStoreDatabase.use(:descriptions) do |db| - cache_store = DescriptionCacheStore.new(db) - Descriptions.search(string_or_regex, search_type, cache_store).print + query = args.named.join(" ") + string_or_regex = Search.query_regexp(query) + return Search.search_descriptions(string_or_regex, args, search_type:) end - end - unless args.formula? - puts unless args.cask? - ohai "Casks" - CacheStoreDatabase.use(:cask_descriptions) do |db| - cache_store = CaskDescriptionCacheStore.new(db) - Descriptions.search(string_or_regex, search_type, cache_store).print + + 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 + description = formula_or_cask.desc.presence || Formatter.warning("[no description]") + desc[formula_or_cask.full_name] = "(#{formula_or_cask.name.join(", ")}) #{description}" + else + raise TypeError, "Unsupported formula_or_cask type: #{formula_or_cask.class}" + end end + Descriptions.new(desc).print end end end diff --git a/Library/Homebrew/cmd/developer.rb b/Library/Homebrew/cmd/developer.rb old mode 100755 new mode 100644 index e967844b7a201..0c4feb5134fc7 --- a/Library/Homebrew/cmd/developer.rb +++ b/Library/Homebrew/cmd/developer.rb @@ -1,58 +1,62 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def developer_args - Homebrew::CLI::Parser.new do - description <<~EOS - Control Homebrew's developer mode. When developer mode is enabled, - `brew update` will update Homebrew to the latest commit on the `master` - branch instead of the latest stable version along with some other behaviour changes. - - `brew developer` [`state`]: - Display the current state of Homebrew's developer mode. - - `brew developer` (`on`|`off`): - Turn Homebrew's developer mode on or off respectively. - EOS - - named_args %w[state on off], max: 1 - end - end - - def developer - args = developer_args.parse - - env_vars = [] - env_vars << "HOMEBREW_DEVELOPER" if Homebrew::EnvConfig.developer? - env_vars << "HOMEBREW_UPDATE_TO_TAG" if Homebrew::EnvConfig.update_to_tag? - env_vars.map! do |var| - "#{Tty.bold}#{var}#{Tty.reset}" - end + module Cmd + class Developer < AbstractCommand + cmd_args do + description <<~EOS + Control Homebrew's developer mode. When developer mode is enabled, + `brew update` will update Homebrew to the latest commit on the `master` + branch instead of the latest stable version along with some other behaviour changes. + + `brew developer` [`state`]: + Display the current state of Homebrew's developer mode. + + `brew developer` (`on`|`off`): + Turn Homebrew's developer mode on or off respectively. + EOS + + named_args %w[state on off], max: 1 + end - case args.named.first - when nil, "state" - if env_vars.any? - puts "Developer mode is enabled because #{env_vars.to_sentence} #{"is".pluralize(env_vars.count)} set." - elsif Homebrew::Settings.read("devcmdrun") == "true" - puts "Developer mode is enabled." - else - puts "Developer mode is disabled." + sig { override.void } + def run + case args.named.first + when nil, "state" + if Homebrew::EnvConfig.developer? + puts "Developer mode is enabled because #{Tty.bold}HOMEBREW_DEVELOPER#{Tty.reset} is set." + elsif Homebrew::EnvConfig.devcmdrun? + puts "Developer mode is enabled because a developer command or `brew developer on` was run." + else + puts "Developer mode is disabled." + end + if Homebrew::EnvConfig.developer? || Homebrew::EnvConfig.devcmdrun? + if Homebrew::EnvConfig.update_to_tag? + puts "However, `brew update` will update to the latest stable tag because " \ + "#{Tty.bold}HOMEBREW_UPDATE_TO_TAG#{Tty.reset} is set." + else + puts "`brew update` will update to the latest commit on the `master` branch." + end + else + puts "`brew update` will update to the latest stable tag." + end + when "on" + Homebrew::Settings.write "devcmdrun", true + if Homebrew::EnvConfig.update_to_tag? + puts "To fully enable developer mode, you must unset #{Tty.bold}HOMEBREW_UPDATE_TO_TAG#{Tty.reset}." + end + when "off" + Homebrew::Settings.delete "devcmdrun" + if Homebrew::EnvConfig.developer? + puts "To fully disable developer mode, you must unset #{Tty.bold}HOMEBREW_DEVELOPER#{Tty.reset}." + end + else + raise UsageError, "unknown subcommand: #{args.named.first}" + end end - when "on" - Homebrew::Settings.write "devcmdrun", true - when "off" - Homebrew::Settings.delete "devcmdrun" - puts "To fully disable developer mode, you must unset #{env_vars.to_sentence}." if env_vars.any? - else - raise UsageError, "unknown subcommand: #{args.named.first}" end 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 cb35eb83ae375..a273921d8e34a 100644 --- a/Library/Homebrew/cmd/doctor.rb +++ b/Library/Homebrew/cmd/doctor.rb @@ -1,83 +1,80 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "diagnostic" -require "cli/parser" require "cask/caskroom" module Homebrew - extend T::Sig + 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. - module_function + 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." - sig { returns(CLI::Parser) } - def doctor_args - Homebrew::CLI::Parser.new do - description <<~EOS - 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." + named_args :diagnostic_check + end - named_args :diagnostic_check - end - end + sig { override.void } + def run + Homebrew.inject_dump_stats!(Diagnostic::Checks, /^check_*/) if args.audit_debug? - def doctor - args = doctor_args.parse + checks = Diagnostic::Checks.new(verbose: args.verbose?) - inject_dump_stats!(Diagnostic::Checks, /^check_*/) if args.audit_debug? + if args.list_checks? + puts checks.all + return + end - checks = Diagnostic::Checks.new(verbose: args.verbose?) + 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 args.list_checks? - puts checks.all - return - 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 - 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 + out = checks.send(method) + next if out.blank? - first_warning = true - 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 + 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 - out = checks.send(method) - next if out.blank? + $stderr.puts + opoo out + Homebrew.failed = true + first_warning = false + 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 + puts "Your system is ready to brew." if !Homebrew.failed? && !args.quiet? end - - $stderr.puts - opoo out - Homebrew.failed = true - first_warning = false end - - puts "Your system is ready to brew." unless Homebrew.failed? end end diff --git a/Library/Homebrew/cmd/fetch.rb b/Library/Homebrew/cmd/fetch.rb index 8fe94debabbf9..6bd826c48d25e 100644 --- a/Library/Homebrew/cmd/fetch.rb +++ b/Library/Homebrew/cmd/fetch.rb @@ -1,192 +1,337 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "abstract_command" require "formula" require "fetch" -require "cli/parser" require "cask/download" +require "retryable_download" module Homebrew - extend T::Sig - - extend Fetch - - module_function - - sig { returns(CLI::Parser) } - def fetch_args - Homebrew::CLI::Parser.new 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 "--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." - 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 "--[no-]quarantine", - description: "Disable/enable quarantining of downloads (default: enabled).", - env: :cask_opts_quarantine - switch "--formula", "--formulae", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - description: "Treat all named arguments as casks." - - 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" - - named_args [:formula, :cask], min: 1 - end - end + module Cmd + class FetchCmd < AbstractCommand + include Fetch + FETCH_MAX_TRIES = 5 - def fetch - args = fetch_args.parse + 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.)" + flag "--bottle-tag=", + description: "Download a bottle for given tag." + flag "--concurrency=", description: "Number of concurrent downloads.", hidden: true + 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 "--[no-]quarantine", + description: "Disable/enable quarantining of downloads (default: enabled).", + env: :cask_opts_quarantine + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - bucket = if args.deps? - args.named.to_formulae_and_casks.flat_map do |formula_or_cask| - case formula_or_cask - when Formula - f = formula_or_cask + 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" - [f, *f.recursive_dependencies.map(&:to_formula)] - else - formula_or_cask + named_args [:formula, :cask], min: 1 + end + + def concurrency + @concurrency ||= args.concurrency&.to_i || 1 + end + + def download_queue + @download_queue ||= begin + require "download_queue" + DownloadQueue.new(concurrency) end end - else - args.named.to_formulae_and_casks - end.uniq - puts "Fetching: #{bucket * ", "}" if bucket.size > 1 - bucket.each do |formula_or_cask| - case formula_or_cask - when Formula - f = formula_or_cask + class Spinner + FRAMES = [ + "⠋", + "⠙", + "⠚", + "⠞", + "⠖", + "⠦", + "⠴", + "⠲", + "⠳", + "⠓", + ].freeze - f.print_tap_action verb: "Fetching" + sig { void } + def initialize + @start = Time.now + @i = 0 + end - fetched_bottle = false - if fetch_bottle?(f, args: args) - begin - f.clear_cache if args.force? - f.fetch_bottle_tab - fetch_formula(f.bottle_for_tag(args.bottle_tag&.to_sym), args: args) - 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 + sig { returns(String) } + def to_s + now = Time.now + if @start + 0.1 < now + @start = now + @i = (@i + 1) % FRAMES.count end + + FRAMES.fetch(@i) end + end + + sig { override.void } + def run + Formulary.enable_factory_cache! + + 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 + 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 = T.cast(formula_or_cask, Formula) + ref = formula.loaded_from_api? ? formula.full_name : formula.path + + 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 = if (bottle_tag = args.bottle_tag&.to_sym) + Utils::Bottles::Tag.from_symbol(bottle_tag) + else + Utils::Bottles::Tag.new(system: os, arch:) + end + + bottle = formula.bottle_for_tag(bottle_tag) - next if fetched_bottle + if bottle.nil? + opoo "Bottle for tag #{bottle_tag.to_sym.inspect} is unavailable." + next + end + + if (manifest_resource = bottle.github_packages_manifest_resource) + fetch_downloadable(manifest_resource) + end + fetch_downloadable(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 + + fetch_downloadable(formula.resource) + + formula.resources.each do |r| + fetch_downloadable(r) + r.patches.each { |patch| fetch_downloadable(patch.resource) if patch.external? } + end + + formula.patchlist.each { |patch| fetch_downloadable(patch.resource) if patch.external? } + end + end + else + cask = formula_or_cask + ref = cask.loaded_from_api? ? cask.full_name : cask.sourcefile_path - fetch_formula(f, args: args) + os_arch_combinations.each do |os, arch| + next if os == :linux - f.resources.each do |r| - fetch_resource(r, args: args) - r.patches.each { |p| fetch_patch(p, args: args) if p.external? } + SimulateSystem.with(os:, arch:) do + cask = Cask::CaskLoader.load(ref) + + if cask.url.nil? || cask.sha256.nil? + opoo "Cask #{cask} is not supported on os #{os} and arch #{arch}" + next + end + + quarantine = args.quarantine? + quarantine = true if quarantine.nil? + + download = Cask::Download.new(cask, quarantine:) + fetch_downloadable(download) + end + end + end end - f.patchlist.each { |p| fetch_patch(p, args: args) if p.external? } - else - cask = formula_or_cask + if concurrency == 1 + downloads.each do |downloadable, promise| + promise.wait! + rescue ChecksumMismatchError => e + opoo "#{downloadable.download_type.capitalize} reports different checksum: #{e.expected}" + Homebrew.failed = true if downloadable.is_a?(Resource::Patch) + end + else + spinner = Spinner.new + remaining_downloads = downloads.dup + previous_pending_line_count = 0 - quarantine = args.quarantine? - quarantine = true if quarantine.nil? + begin + $stdout.print Tty.hide_cursor + $stdout.flush - download = Cask::Download.new(cask, quarantine: quarantine) - fetch_cask(download, args: args) - end - end - end + output_message = lambda do |downloadable, future| + status = case future.state + when :fulfilled + "#{Tty.green}✔︎#{Tty.reset}" + when :rejected + "#{Tty.red}✘#{Tty.reset}" + when :pending, :processing + "#{Tty.blue}#{spinner}#{Tty.reset}" + else + raise future.state.to_s + end - def fetch_resource(r, args:) - puts "Resource: #{r.name}" - fetch_fetchable r, args: args - rescue ChecksumMismatchError => e - retry if retry_fetch?(r, args: args) - opoo "Resource #{r.name} reports different sha256: #{e.expected}" - end + message = "#{downloadable.download_type.capitalize} #{downloadable.name}" + $stdout.puts "#{status} #{message}" + $stdout.flush - def fetch_formula(f, args:) - fetch_fetchable f, args: args - rescue ChecksumMismatchError => e - retry if retry_fetch?(f, args: args) - opoo "Formula reports different sha256: #{e.expected}" - end + if future.rejected? && (e = future.reason).is_a?(ChecksumMismatchError) + opoo "#{downloadable.download_type.capitalize} reports different checksum: #{e.expected}" + Homebrew.failed = true if downloadable.is_a?(Resource::Patch) + next 2 + end - def fetch_cask(cask_download, args:) - fetch_fetchable cask_download, args: args - rescue ChecksumMismatchError => e - retry if retry_fetch?(cask_download, args: args) - opoo "Cask reports different sha256: #{e.expected}" - end + 1 + end - def fetch_patch(p, args:) - fetch_fetchable p, args: args - rescue ChecksumMismatchError => e - opoo "Patch reports different sha256: #{e.expected}" - Homebrew.failed = true - end + until remaining_downloads.empty? + begin + finished_states = [:fulfilled, :rejected] - def retry_fetch?(f, args:) - @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 + finished_downloads, remaining_downloads = remaining_downloads.partition do |_, future| + finished_states.include?(future.state) + end - def fetch_fetchable(f, args:) - f.clear_cache if args.force? + finished_downloads.each do |downloadable, future| + previous_pending_line_count -= 1 + $stdout.print Tty.clear_to_end + $stdout.flush + output_message.call(downloadable, future) + end - already_fetched = f.cached_download.exist? + previous_pending_line_count = 0 + remaining_downloads.each do |downloadable, future| + # FIXME: Allow printing full terminal height. + break if previous_pending_line_count >= [concurrency, (Tty.height - 1)].min - begin - download = f.fetch(verify_download_integrity: false) - rescue DownloadError - retry if retry_fetch?(f, args: args) - raise - end + $stdout.print Tty.clear_to_end + $stdout.flush + previous_pending_line_count += output_message.call(downloadable, future) + end + + if previous_pending_line_count.positive? + $stdout.print Tty.move_cursor_up_beginning(previous_pending_line_count) + $stdout.flush + end + + sleep 0.05 + rescue Interrupt + remaining_downloads.each do |_, future| + # FIXME: Implement cancellation of running downloads. + end + + download_queue.cancel + + if previous_pending_line_count.positive? + $stdout.print Tty.move_cursor_down(previous_pending_line_count - 1) + $stdout.flush + end + + raise + end + end + ensure + $stdout.print Tty.show_cursor + $stdout.flush + end + end + ensure + download_queue.shutdown + end - return unless download.file? + private - puts "Downloaded to: #{download}" unless already_fetched - puts "SHA256: #{download.sha256}" + def downloads + @downloads ||= {} + end - f.verify_download_integrity(download) + def fetch_downloadable(downloadable) + downloads[downloadable] ||= begin + tries = args.retry? ? {} : { tries: 1 } + download_queue.enqueue(RetryableDownload.new(downloadable, **tries), force: args.force?) + end + end + end end end diff --git a/Library/Homebrew/cmd/formulae.rb b/Library/Homebrew/cmd/formulae.rb new file mode 100644 index 0000000000000..58c41d034b982 --- /dev/null +++ b/Library/Homebrew/cmd/formulae.rb @@ -0,0 +1,17 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class Formulae < AbstractCommand + include ShellCommand + + cmd_args do + description "List all locally installable formulae including short names." + end + end + end +end diff --git a/Library/Homebrew/cmd/formulae.sh b/Library/Homebrew/cmd/formulae.sh index ccf04ea0766b8..c218b822fb498 100644 --- a/Library/Homebrew/cmd/formulae.sh +++ b/Library/Homebrew/cmd/formulae.sh @@ -1,12 +1,25 @@ -#: * `formulae` -#: -#: List all locally installable formulae including short names. -#: +# 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() { - homebrew-items 'Casks' 's|/Formula/|/|' '^homebrew/core' + local find_include_filter='*\.rb' + local sed_filter='s|/Formula/(.+/)?|/|' + local grep_filter='^homebrew/core' + + # 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 81a76f7036322..54d7e3e3fc881 100644 --- a/Library/Homebrew/cmd/gist-logs.rb +++ b/Library/Homebrew/cmd/gist-logs.rb @@ -1,122 +1,138 @@ -# typed: true +# 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 - extend T::Sig - - extend Install - - module_function - - sig { returns(CLI::Parser) } - def gist_logs_args - Homebrew::CLI::Parser.new 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." - 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 - end + 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." + 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 - def gistify_logs(f, args:) - 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, 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 f.core_formula? - tap = <<~EOS - Formula: #{f.name} - Tap: #{f.tap} - Path: #{f.path} - EOS - files["00.tap.out"] = { content: tap } - end + 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) - odie "`brew gist-logs` requires HOMEBREW_GITHUB_API_TOKEN to be set!" if GitHub::API.credentials_type == :none + gistify_logs(formula) + end - # Description formatted to work well as page title when viewing gist - descr = if f.core_formula? - "#{f.name} on #{OS_VERSION} - Homebrew build logs" - else - "#{f.name} (#{f.full_name}) on #{OS_VERSION} - Homebrew build logs" - end - url = GitHub.create_gist(files, descr, private: args.private?) + 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 - url = GitHub.create_issue(f.tap, "#{f.name} failed to build on #{MacOS.full_version}", url) if args.new_issue? + odie "`brew gist-logs` requires HOMEBREW_GITHUB_API_TOKEN to be set!" if GitHub::API.credentials_type == :none - puts url if url - 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 - def brief_build_info(f, with_hostname:) - 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 with_hostname - hostname = Socket.gethostname - s << "Host: #{hostname}\n" - end - s << "Build date: #{build_time_str}\n" - s.freeze - 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 - # Causes some terminals to display secure password entry indicators. - def noecho_gets - system "stty", "-echo" - result = $stdin.gets - system "stty", "echo" - puts - result - end + if args.new_issue? + url = GitHub.create_issue(formula.tap, "#{formula.name} failed to build on #{OS_VERSION}", + url) + end - 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 = truncate_text_to_approximate_size(contents, max_file_size, front_weight: 0.2) - logs[file.relative_path_from(basedir).to_s.tr("/", ":")] = { content: contents } + puts url if url + 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 - end - odie "No logs." if logs.empty? - logs - end + # Causes some terminals to display secure password entry indicators. + sig { void } + def noecho_gets + system "stty", "-echo" + result = $stdin.gets + system "stty", "echo" + puts + result + end - def gist_logs - args = gist_logs_args.parse + sig { params(dir: Pathname, basedir: Pathname).returns(T::Hash[String, T::Hash[Symbol, 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 = 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? - Install.perform_preinstall_checks(all_fatal: true) - Install.perform_build_from_source_checks(all_fatal: true) - gistify_logs(args.named.to_resolved_formulae.first, args: args) + logs + end + end end end diff --git a/Library/Homebrew/cmd/help.rb b/Library/Homebrew/cmd/help.rb index bcad2600c1068..4ddb60473b7ee 100644 --- a/Library/Homebrew/cmd/help.rb +++ b/Library/Homebrew/cmd/help.rb @@ -1,10 +1,24 @@ -# typed: true +# typed: strong # frozen_string_literal: true +require "abstract_command" require "help" module Homebrew - def help - Help.help + 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 649c3d09daf02..59ac2b6c5c573 100644 --- a/Library/Homebrew/cmd/home.rb +++ b/Library/Homebrew/cmd/home.rb @@ -1,56 +1,54 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" require "formula" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def home_args - Homebrew::CLI::Parser.new 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." - - conflicts "--formula", "--cask" - - named_args [:formula, :cask] - end - end - - sig { void } - def home - args = home_args.parse - - 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(*T.unsafe(homepages)) - end - - def name_of(formula_or_cask) - if formula_or_cask.is_a? Formula - "Formula #{formula_or_cask.name}" - else - "Cask #{formula_or_cask.token}" + 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." + + conflicts "--formula", "--cask" + + 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 + + private + + 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 0fe8851a56b89..7abc703422a7b 100644 --- a/Library/Homebrew/cmd/info.rb +++ b/Library/Homebrew/cmd/info.rb @@ -1,9 +1,9 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +require "abstract_command" require "missing_formula" require "caveats" -require "cli/parser" require "options" require "formula" require "keg" @@ -14,358 +14,376 @@ require "api" module Homebrew - extend T::Sig - - 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 - - sig { returns(CLI::Parser) } - def info_args - Homebrew::CLI::Parser.new 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", - description: "Open the GitHub source page for and in a browser. " \ - "To view the history locally: `brew log -p` or " - flag "--json", - description: "Print a JSON representation. Currently the default value for is `v1` for " \ - ". For and use `v2`. See the docs for examples of using the " \ - "JSON output: " - switch "--bottle", - depends_on: "--json", - description: "Output information about the bottles for and its dependencies.", - hidden: true - 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 "-v", "--verbose", - description: "Show more verbose analytics data for ." - switch "--formula", "--formulae", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - description: "Treat all named arguments as casks." - - conflicts "--installed", "--all" - conflicts "--formula", "--cask" - - %w[--cask --analytics --github].each do |conflict| - conflicts "--bottle", conflict + module Cmd + class Info < AbstractCommand + 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 + + 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." + flag "--json", + description: "Print a JSON representation. Currently the default value for is `v1` for " \ + ". For and use `v2`. 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 "--eval-all", + depends_on: "--json", + description: "Evaluate all available formulae and casks, whether installed or not, to print their " \ + "JSON. Implied if `HOMEBREW_EVAL_ALL` is set." + switch "--variations", + depends_on: "--json", + description: "Include the variations hash in each formula's JSON output." + switch "-v", "--verbose", + description: "Show more verbose analytics data for ." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--installed", "--eval-all" + conflicts "--installed", "--all" + conflicts "--formula", "--cask" + conflicts "--fetch-manifest", "--cask" + conflicts "--fetch-manifest", "--json" + + named_args [:formula, :cask] end - named_args [:formula, :cask] - end - end - - sig { void } - def info - args = info_args.parse - - if 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" + sig { override.void } + def run + if 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 args.json + all = args.eval_all? + + print_json(all) + elsif args.github? + raise FormulaOrCaskUnspecifiedError if args.no_named? + + exec_browser(*args.named.to_formulae_and_casks.map { |f| github_info(f) }) + elsif args.no_named? + print_statistics + else + print_info end + end - unless VALID_CATEGORIES.include?(args.category) - raise UsageError, "--category must be one of #{VALID_CATEGORIES.join(", ")}" + 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_analytics(args: args) - elsif args.json - print_json(args: args) - elsif args.github? - raise FormulaOrCaskUnspecifiedError if args.no_named? - - exec_browser(*args.named.to_formulae_and_casks.map { |f| github_info(f) }) - elsif args.no_named? - print_statistics - else - print_info(args: args) - end - end + private - sig { void } - def print_statistics - return unless HOMEBREW_CELLAR.exist? + sig { void } + def print_statistics + return unless HOMEBREW_CELLAR.exist? - count = Formula.racks.length - puts "#{count} #{"keg".pluralize(count)}, #{HOMEBREW_CELLAR.dup.abv}" - end + count = Formula.racks.length + puts "#{Utils.pluralize("keg", count, include_count: true)}, #{HOMEBREW_CELLAR.dup.abv}" + end - sig { params(args: CLI::Args).void } - def print_analytics(args:) - if args.no_named? - Utils::Analytics.output(args: args) - return - 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: args) - when Cask::Cask - Utils::Analytics.cask_output(obj, args: args) - when FormulaOrCaskUnavailableError - Utils::Analytics.output(filter: obj.name, args: args) - else - raise + 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:) + when Cask::Cask + Utils::Analytics.cask_output(obj, args:) + when FormulaOrCaskUnavailableError + Utils::Analytics.output(filter: obj.name, args:) + else + raise + end + end end - end - end - sig { params(args: CLI::Args).void } - def print_info(args:) - args.named.to_formulae_and_casks_and_unavailable.each_with_index do |obj, i| - puts unless i.zero? - - case obj - when Formula - info_formula(obj, args: args) - when Cask::Cask - info_cask(obj, args: args) - when FormulaUnreadableError, FormulaClassUnavailableError, - TapFormulaUnreadableError, TapFormulaClassUnavailableError, - Cask::CaskUnreadableError - # We found the formula/cask, but failed to read it - $stderr.puts obj.backtrace if Homebrew::EnvConfig.developer? - ofail obj.message - when 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 + sig { void } + def print_info + args.named.to_formulae_and_casks_and_unavailable.each_with_index do |obj, i| + puts unless i.zero? + + case obj + when Formula + info_formula(obj) + when Cask::Cask + info_cask(obj) + when 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 + raise + end end - else - raise end - end - end - - def json_version(version) - version_hash = { - true => :default, - "v1" => :v1, - "v2" => :v2, - } - - raise UsageError, "invalid JSON version: #{version}" unless version_hash.include?(version) - - version_hash[version] - end - sig { params(args: CLI::Args).void } - def print_json(args:) - raise FormulaOrCaskUnspecifiedError if !(args.all? || args.installed?) && args.no_named? + def json_version(version) + version_hash = { + true => :default, + "v1" => :v1, + "v2" => :v2, + } - json = case json_version(args.json) - when :v1, :default - raise UsageError, "cannot specify --cask with --json=v1!" if args.cask? + raise UsageError, "invalid JSON version: #{version}" unless version_hash.include?(version) - formulae = if args.all? - Formula.all.sort - elsif args.installed? - Formula.installed.sort - else - args.named.to_formulae + version_hash[version] end - if args.bottle? - formulae.map(&:to_recursive_bottle_hash) - else - formulae.map(&:to_hash) - end - when :v2 - formulae, casks = if args.all? - [Formula.all.sort, Cask::Cask.all.sort_by(&:full_name)] - elsif args.installed? - [Formula.installed.sort, Cask::Caskroom.casks.sort_by(&:full_name)] - else - args.named.to_formulae_to_casks - end + sig { params(all: T::Boolean).void } + def print_json(all) + raise FormulaOrCaskUnspecifiedError if !(all || args.installed?) && args.no_named? + + json = case json_version(args.json) + when :v1, :default + raise UsageError, "Cannot specify `--cask` when using `--json=v1`!" if args.cask? + + formulae = if all + Formula.all(eval_all: args.eval_all?).sort + elsif args.installed? + Formula.installed.sort + else + args.named.to_formulae + end + + if args.variations? + formulae.map(&:to_hash_with_variations) + else + formulae.map(&:to_hash) + end + when :v2 + formulae, casks = if all + [ + Formula.all(eval_all: args.eval_all?).sort, + Cask::Cask.all(eval_all: args.eval_all?).sort_by(&:full_name), + ] + elsif args.installed? + [Formula.installed.sort, Cask::Caskroom.casks.sort_by(&:full_name)] + else + args.named.to_formulae_to_casks + end + + 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 - if args.bottle? - { "formulae" => formulae.map(&:to_recursive_bottle_hash) } - else - { - "formulae" => formulae.map(&:to_hash), - "casks" => casks.map(&:to_h), - } + puts JSON.pretty_generate(json) end - else - raise - end - puts JSON.pretty_generate(json) - end + def github_info(formula_or_cask) + return formula_or_cask.path if formula_or_cask.tap.blank? || formula_or_cask.tap.remote.blank? - 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 + path = case formula_or_cask + when Formula + formula = formula_or_cask + formula.path.relative_path_from(T.must(formula.tap).path) + when Cask::Cask + cask = formula_or_cask + if cask.sourcefile_path.blank? || cask.sourcefile_path.extname != ".rb" + return "#{cask.tap.default_remote}/blob/HEAD/#{cask.tap.relative_cask_path(cask.token)}" + end - def github_info(f) - return f.path if f.tap.blank? || f.tap.remote.blank? + cask.sourcefile_path.relative_path_from(cask.tap.path) + end - path = case f - when Formula - f.path.relative_path_from(f.tap.path) - when Cask::Cask - f.sourcefile_path.relative_path_from(f.tap.path) - end - github_remote_path(f.tap.remote, path) - end + github_remote_path(formula_or_cask.tap.remote, path) + end - def info_formula(f, args:) - specs = [] + def info_formula(formula) + specs = [] - if (stable = f.stable) - s = "stable #{stable.version}" - s += " (bottled)" if stable.bottled? && f.pour_bottle? - specs << s - end + if (stable = formula.stable) + string = "stable #{stable.version}" + string += " (bottled)" if stable.bottled? && formula.pour_bottle? + specs << string + end - specs << "HEAD" if f.head + specs << "HEAD" if formula.head - attrs = [] - attrs << "pinned at #{f.pinned_version}" if f.pinned? - attrs << "keg-only" if f.keg_only? + attrs = [] + attrs << "pinned at #{formula.pinned_version}" if formula.pinned? + attrs << "keg-only" if formula.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 + puts "#{oh1_title(formula.full_name)}: #{specs * ", "}#{" [#{attrs * ", "}]" unless attrs.empty?}" + puts formula.desc if formula.desc + puts Formatter.url(formula.homepage) if formula.homepage - deprecate_disable_type, deprecate_disable_reason = DeprecateDisable.deprecate_disable_info f - if deprecate_disable_type.present? - if deprecate_disable_reason.present? - puts "#{deprecate_disable_type.capitalize} because it #{deprecate_disable_reason}!" - else - puts "#{deprecate_disable_type.capitalize}!" - end - end + 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 = 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 + conflicts = formula.conflicts.map do |conflict| + reason = " (because #{conflict.reason})" if conflict.reason + "#{conflict.name}#{reason}" + end.sort! + unless conflicts.empty? + puts <<~EOS + Conflicts with: + #{conflicts.join("\n ")} + EOS + 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? - end - end + kegs = formula.installed_kegs + 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? + bottle_size = bottle.bottle_size + installed_size = bottle.installed_size + puts "Bottle Size: #{disk_usage_readable(bottle_size)}" if bottle_size + puts "Installed Size: #{disk_usage_readable(installed_size)}" if installed_size + rescue RuntimeError => e + odebug e + end + end + else + puts "Installed" + kegs.each do |keg| + puts "#{keg} (#{keg.abv})#{" *" if keg.linked?}" + tab = keg.tab.to_s + puts " #{tab}" unless tab.empty? + end + end - puts "From: #{Formatter.url(github_info(f))}" + puts "From: #{Formatter.url(github_info(formula))}" - puts "License: #{SPDX.license_expression_to_string f.license}" if f.license.present? + puts "License: #{SPDX.license_expression_to_string formula.license}" if formula.license.present? - 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? - end - end + unless formula.deps.empty? + ohai "Dependencies" + %w[build required recommended optional].map do |type| + deps = formula.deps.send(type).uniq + puts "#{type.capitalize}: #{decorate_dependencies deps}" unless deps.empty? + 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? + 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)}" - end - end + puts "#{type.capitalize}: #{decorate_requirements(reqs)}" + end + end - if !f.options.empty? || f.head - ohai "Options" - Options.dump_for_formula f - end + if !formula.options.empty? || formula.head + ohai "Options" + Options.dump_for_formula formula + end - caveats = Caveats.new(f) - ohai "Caveats", caveats.to_s unless caveats.empty? + caveats = Caveats.new(formula) + ohai "Caveats", caveats.to_s unless caveats.empty? - Utils::Analytics.formula_output(f, args: args) - end + Utils::Analytics.formula_output(formula, args:) + 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)) + 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)) + end + end + deps_status.join(", ") 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 + 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 - def dep_display_s(dep) - return dep.name if dep.option_tags.empty? + def dep_display_s(dep) + return dep.name if dep.option_tags.empty? - "#{dep.name} #{dep.option_tags.map { |o| "--#{o}" }.join(" ")}" - end + "#{dep.name} #{dep.option_tags.map { |o| "--#{o}" }.join(" ")}" + end - def info_cask(cask, args:) - require "cask/cmd" - require "cask/cmd/info" + def info_cask(cask) + require "cask/info" - Cask::Cmd::Info.info(cask) + Cask::Info.info(cask, args:) + end + end end end diff --git a/Library/Homebrew/cmd/install.rb b/Library/Homebrew/cmd/install.rb index cff794979e32a..defbe8f820d5d 100644 --- a/Library/Homebrew/cmd/install.rb +++ b/Library/Homebrew/cmd/install.rb @@ -1,308 +1,416 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "cask/config" -require "cask/cmd" -require "cask/cmd/install" +require "cask/installer" +require "cask_dependent" require "missing_formula" require "formula_installer" require "development_tools" require "install" -require "search" require "cleanup" -require "cli/parser" require "upgrade" module Homebrew - extend T::Sig - - extend Search - - module_function - - sig { returns(CLI::Parser) } - def install_args - Homebrew::CLI::Parser.new do - description <<~EOS - Install a or . Additional options specific to a may be - appended to the command. - - 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 installed formulae or, every 30 days, for all formulae. - - 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 "-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 postinstall steps." - [ - [: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 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, "--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, "--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, "--display-times", { - env: :display_install_times, - description: "Print install times for each package 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.", - }], - [:switch, "--overwrite", { - description: "Delete files that already exist in the prefix while linking.", - }], - ].each do |*args, **options| - send(*args, **options) - conflicts "--cask", args.last + module Cmd + class InstallCmd < AbstractCommand + cmd_args do + description <<~EOS + Install a or . Additional options specific to a may be + appended to the command. + + 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 installed formulae or, every 30 days, for all formulae. + + 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", + env: :display_install_times, + description: "Print install times for each package at the end of the run." + 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, "--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.", + }], + [: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, "--[no-]quarantine", { + description: "Disable/enable quarantining of downloads (default: enabled).", + env: :cask_opts_quarantine, + }], + [: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 "--ignore-dependencies", "--only-dependencies" + conflicts "--build-from-source", "--build-bottle", "--force-bottle" + conflicts "--adopt", "--force" + + named_args [:formula, :cask], min: 1 end - formula_options - [ - [:switch, "--cask", "--casks", { description: "Treat all named arguments as casks." }], - *Cask::Cmd::AbstractCommand::OPTIONS, - *Cask::Cmd::Install::OPTIONS, - ].each do |*args, **options| - send(*args, **options) - conflicts "--formula", args.last - end - cask_options - - conflicts "--ignore-dependencies", "--only-dependencies" - conflicts "--build-from-source", "--build-bottle", "--force-bottle" - - named_args [:formula, :cask], min: 1 - end - end - - def install - args = install_args.parse - - if args.build_from_source? && Homebrew::EnvConfig.install_from_api? - raise UsageError, "--build-from-source is not supported when using HOMEBREW_INSTALL_FROM_API." - end - - 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. - odisabled "brew install --env", "`env :std` in specific formula files" - end - - args.named.each do |name| - next if File.exist?(name) - next unless name =~ HOMEBREW_TAP_FORMULA_REGEX - - tap = Tap.fetch(Regexp.last_match(1), Regexp.last_match(2)) - next if (tap.core_tap? || tap == "homebrew/cask") && EnvConfig.install_from_api? - - tap.install unless tap.installed? - end - - 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! - - EOS - end - - begin - formulae, casks = args.named.to_formulae_and_casks - .partition { |formula_or_cask| formula_or_cask.is_a?(Formula) } - rescue FormulaOrCaskUnavailableError, Cask::CaskUnavailableError => e - retry if Tap.install_default_cask_tap_if_necessary(force: args.cask?) - raise e - end - - if casks.any? - Cask::Cmd::Install.install_casks( - *casks, - binaries: args.binaries?, - verbose: args.verbose?, - force: args.force?, - require_sha: args.require_sha?, - skip_cask_deps: args.skip_cask_deps?, - quarantine: args.quarantine?, - quiet: args.quiet?, - ) - end - - # if the user's flags will prevent bottle only-installations when no - # developer tools are available, we need to stop them early on - unless DevelopmentTools.installed? - build_flags = [] - - build_flags << "--HEAD" if args.HEAD? - build_flags << "--build-bottle" if args.build_bottle? - build_flags << "--build-from-source" if args.build_from_source? - - raise BuildFlagsError.new(build_flags, bottled: formulae.all?(&:bottled?)) if build_flags.present? - end - - 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?, - ) - end - - return if installed_formulae.empty? - - Install.perform_preinstall_checks(cc: args.cc) - - Install.install_formulae( - installed_formulae, - 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?, - force: args.force?, - overwrite: args.overwrite?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - ) - - Upgrade.check_installed_dependents( - installed_formulae, - flags: args.flags_only, - installed_on_request: args.named.present?, - force_bottle: args.force_bottle?, - build_from_source_formulae: args.build_from_source_formulae, - interactive: args.interactive?, - keep_tmp: args.keep_tmp?, - force: args.force?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - ) - - Homebrew.messages.display_messages(display_times: args.display_times?) - 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). - $stderr.puts e.backtrace if Homebrew::EnvConfig.developer? - ofail e.message - rescue FormulaOrCaskUnavailableError => e - if e.name == "updog" - ofail "What's updog?" - return - end - - opoo e - 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 - - if (reason = MissingFormula.reason(e.name)) - $stderr.puts reason - return - end - - # Do not search taps if the formula name is qualified - return if e.name.include?("/") - - 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}" + 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 + + 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 + + tap&.ensure_installed! + 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! + + EOS + end + + begin + formulae, casks = T.cast( + args.named.to_formulae_and_casks(warn: false).partition { _1.is_a?(Formula) }, + [T::Array[Formula], T::Array[Cask::Cask]], + ) + rescue FormulaOrCaskUnavailableError, Cask::CaskUnavailableError + cask_tap = CoreCaskTap.instance + if !cask_tap.installed? && (args.cask? || Tap.untapped_official_taps.exclude?(cask_tap.name)) + cask_tap.ensure_installed! + retry if cask_tap.installed? + end + + raise + end + + if casks.any? + if args.dry_run? + if (casks_to_install = casks.reject(&:installed?).presence) + ohai "Would install #{::Utils.pluralize("cask", casks_to_install.count, include_count: true)}:" + puts casks_to_install.map(&:full_name).join(" ") + end + casks.each do |cask| + dep_names = CaskDependent.new(cask) + .runtime_dependencies + .reject(&:installed?) + .map(&:to_formula) + .map(&:name) + next if dep_names.blank? + + ohai "Would install #{::Utils.pluralize("dependenc", dep_names.count, plural: "ies", singular: "y", + include_count: true)} for #{cask.full_name}:" + puts dep_names.join(" ") + end + return + end + + require "cask/installer" + + installed_casks, new_casks = casks.partition(&:installed?) + + new_casks.each do |cask| + Cask::Installer.new( + cask, + binaries: args.binaries?, + verbose: args.verbose?, + force: args.force?, + adopt: args.adopt?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + quarantine: args.quarantine?, + quiet: args.quiet?, + ).install + end + + if !Homebrew::EnvConfig.no_install_upgrade? && installed_casks.any? + require "cask/upgrade" + + Cask::Upgrade.upgrade_casks( + *installed_casks, + force: args.force?, + dry_run: args.dry_run?, + binaries: args.binaries?, + quarantine: args.quarantine?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + verbose: args.verbose?, + quiet: args.quiet?, + args:, + ) + end + end + + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) if Homebrew::Attestation.enabled? + + # 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? + + raise BuildFlagsError.new(build_flags, bottled: formulae.all?(&:bottled?)) if build_flags.present? + end + + 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_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?, + overwrite: args.overwrite?, + ) + end + + return if formulae.any? && installed_formulae.empty? + + Install.perform_preinstall_checks_once + Install.check_cc_argv(args.cc) + + Install.install_formulae( + installed_formulae, + 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?, + ) + + Upgrade.check_installed_dependents( + installed_formulae, + flags: args.flags_only, + installed_on_request: args.named.present?, + 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?, + ) + + 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 + + if name == "updog" + ofail "What's updog?" + return + end + + opoo e + + reason = MissingFormula.reason(name, silent: true) + if !args.cask? && reason + $stderr.puts reason + return + end + + # 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?("/") + + require "search" + + package_types = [] + package_types << "formulae" unless args.cask? + package_types << "casks" unless args.formula? + + ohai "Searching for similarly named #{package_types.join(" and ")}..." + + # 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 + + 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 1c003b4b96703..8bf66f3f46d5c 100644 --- a/Library/Homebrew/cmd/leaves.rb +++ b/Library/Homebrew/cmd/leaves.rb @@ -1,49 +1,53 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "cli/parser" +require "cask_dependent" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def leaves_args - Homebrew::CLI::Parser.new do - description <<~EOS - List installed formulae that are not dependencies of another installed formula. - 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." - - conflicts "--installed-on-request", "--installed-as-dependency" - - named_args :none + 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." + + conflicts "--installed-on-request", "--installed-as-dependency" + + named_args :none + end + + sig { override.void } + def run + leaves_list = Formula.installed - Formula.installed.flat_map(&:runtime_formula_dependencies) + casks_runtime_dependencies = Cask::Caskroom.casks.flat_map do |cask| + CaskDependent.new(cask).runtime_dependencies.map(&:to_formula) + end + leaves_list -= casks_runtime_dependencies + leaves_list.select! { installed_on_request?(_1) } if args.installed_on_request? + leaves_list.select! { installed_as_dependency?(_1) } if args.installed_as_dependency? + + leaves_list.map(&:full_name) + .sort + .each { puts(_1) } + end + + private + + sig { params(formula: Formula).returns(T::Boolean) } + def installed_on_request?(formula) + formula.any_installed_keg&.tab&.installed_on_request + end + + sig { params(formula: Formula).returns(T::Boolean) } + def installed_as_dependency?(formula) + formula.any_installed_keg&.tab&.installed_as_dependency + end end end - - def installed_on_request?(formula) - Tab.for_keg(formula.any_installed_keg).installed_on_request - end - - def installed_as_dependency?(formula) - Tab.for_keg(formula.any_installed_keg).installed_as_dependency - end - - def leaves - args = leaves_args.parse - - leaves_list = Formula.installed_formulae_with_no_dependents - - leaves_list.select!(&method(:installed_on_request?)) if args.installed_on_request? - leaves_list.select!(&method(:installed_as_dependency?)) if args.installed_as_dependency? - - leaves_list.map(&:full_name) - .sort - .each(&method(:puts)) - end end diff --git a/Library/Homebrew/cmd/link.rb b/Library/Homebrew/cmd/link.rb index d52f4c5f3a911..6725453e6f029 100644 --- a/Library/Homebrew/cmd/link.rb +++ b/Library/Homebrew/cmd/link.rb @@ -1,138 +1,139 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "ostruct" +require "abstract_command" require "caveats" -require "cli/parser" require "unlink" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def link_args - Homebrew::CLI::Parser.new 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 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 "-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 - end - - def link - args = link_args.parse - - options = { - overwrite: args.overwrite?, - dry_run: args.dry_run?, - verbose: args.verbose?, - } - - kegs = if args.HEAD? - args.named.to_kegs.group_by(&:name).map do |name, resolved_kegs| - head_keg = resolved_kegs.find { |keg| keg.version.head? } - next head_keg if head_keg.present? - - opoo <<~EOS - No HEAD keg installed for #{name} - To install, run: - brew install --HEAD #{name} + 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 - end.compact - else - args.named.to_latest_kegs - end - - kegs.freeze.each do |keg| - keg_only = Formulary.keg_only?(keg.rack) - - if keg.linked? - opoo "Already linked: #{keg}" - name_and_flag = "#{"--HEAD " if args.HEAD?}#{"--force " if keg_only}#{keg.name}" - puts <<~EOS - To relink, run: - brew unlink #{keg.name} && brew link #{name_and_flag} - EOS - next + 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 - if args.dry_run? - if args.overwrite? - puts "Would remove:" + sig { override.void } + def run + options = { + overwrite: args.overwrite?, + dry_run: args.dry_run?, + verbose: args.verbose?, + } + + 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? + + opoo <<~EOS + No HEAD keg installed for #{name} + To install, run: + brew install --HEAD #{name} + EOS + + nil + end else - puts "Would link:" + args.named.to_latest_kegs end - keg.link(**options) - puts_keg_only_path_message(keg) if keg_only - next - end - formula = begin - keg.to_formula - rescue FormulaUnavailableError - # Not all kegs may belong to formulae - nil - 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} - #{caveats.keg_only_text(skip_reason: true).strip} - EOS - next - end - - if !args.force? && (formula.blank? || !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 + kegs.freeze.each do |keg| + keg_only = Formulary.keg_only?(keg.rack) + + if keg.linked? + opoo "Already linked: #{keg}" + name_and_flag = "#{"--HEAD " if args.HEAD?}#{"--force " if keg_only}#{keg.name}" + puts <<~EOS + To relink, run: + brew unlink #{keg.name} && brew link #{name_and_flag} + EOS + next + end + + 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 + next + end + + formula = begin + keg.to_formula + rescue FormulaUnavailableError + # Not all kegs may belong to formulae + nil + 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.blank? || !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_versioned_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 + + puts_keg_only_path_message(keg) if keg_only && !Homebrew::EnvConfig.developer? + end end end - Unlink.unlink_versioned_formulae(formula, verbose: args.verbose?) if formula - - keg.lock do - print "Linking #{keg}... " - puts if args.verbose? + private - begin - n = keg.link(**options) - 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 && !Homebrew::EnvConfig.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 e8e152b4c3b39..052017bc437ee 100644 --- a/Library/Homebrew/cmd/list.rb +++ b/Library/Homebrew/cmd/list.rb @@ -1,245 +1,311 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "metafiles" require "formula" require "cli/parser" -require "cask/cmd" +require "cask/list" +require "system_command" +require "tab" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def list_args - Homebrew::CLI::Parser.new 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 "--multiple", - depends_on: "--versions", - description: "Only show formulae with multiple versions installed." - switch "--pinned", - description: "List only 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 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 the formulae and/or casks sort 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." - - conflicts "--formula", "--cask" - conflicts "--pinned", "--cask" - conflicts "--multiple", "--cask" - conflicts "--pinned", "--multiple" - ["-1", "-l", "-r", "-t"].each do |flag| - conflicts "--versions", flag - conflicts "--pinned", flag - end - ["--versions", "--pinned", "-l", "-r", "-t"].each do |flag| - conflicts "--full-name", flag + module Cmd + class List < AbstractCommand + include SystemCommand::Mixin + + 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 "--multiple", + depends_on: "--versions", + description: "Only show formulae with multiple versions installed." + switch "--pinned", + description: "List only pinned formulae, or only the specified (pinned) " \ + "formulae if are provided. See also `pin`, `unpin`." + switch "--installed-on-request", + description: "List the formulae installed on request." + switch "--installed-as-dependency", + description: "List the formulae installed as dependencies." + switch "--poured-from-bottle", + description: "List the formulae installed from a bottle." + switch "--built-from-source", + description: "List the formulae compiled from source." + + # 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 the formulae and/or casks sort 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." + + conflicts "--formula", "--cask" + conflicts "--pinned", "--cask" + conflicts "--multiple", "--cask" + conflicts "--pinned", "--multiple" + ["--installed-on-request", "--installed-as-dependency", + "--poured-from-bottle", "--built-from-source"].each do |flag| + conflicts "--cask", flag + conflicts "--versions", flag + conflicts "--pinned", flag + conflicts "-l", flag + end + ["-1", "-l", "-r", "-t"].each do |flag| + conflicts "--versions", flag + conflicts "--pinned", flag + end + ["--versions", "--pinned", "-l", "-r", "-t"].each do |flag| + conflicts "--full-name", flag + end + + named_args [:installed_formula, :installed_cask] end - named_args [:installed_formula, :installed_cask] - end - end + sig { override.void } + def run + if args.full_name? && + !(args.installed_on_request? || args.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(&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(&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? + filtered_list + elsif args.versions? + filtered_list unless args.cask? + list_casks if args.cask? || (!args.formula? && !args.multiple? && args.no_named?) + elsif args.installed_on_request? || + args.installed_as_dependency? || + args.poured_from_bottle? || + args.built_from_source? + flags = [] + flags << "`--installed-on-request`" if args.installed_on_request? + flags << "`--installed-as-dependency`" if args.installed_as_dependency? + flags << "`--poured-from-bottle`" if args.poured_from_bottle? + flags << "`--built-from-source`" if args.built_from_source? - def list - args = list_args.parse + raise UsageError, "Cannot use #{flags.join(", ")} with formula arguments." unless args.no_named? - # 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, args.named.first if args.named.present? && !args.cask? + formulae = if args.t? + Formula.installed.sort_by { |formula| test("M", formula.rack) }.reverse! + elsif args.full_name? + Formula.installed.sort { |a, b| 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) - return - end + statuses = [] + statuses << "installed on request" if args.installed_on_request? && tab.installed_on_request + statuses << "installed as dependency" if args.installed_as_dependency? && tab.installed_as_dependency + 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? - if args.full_name? - unless args.cask? - formula_names = args.no_named? ? Formula.installed : args.named.to_resolved_formulae - full_formula_names = formula_names.map(&:full_name).sort(&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 + 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? + safe_system "ls", *ls_args, HOMEBREW_CELLAR + puts if $stdout.tty? && !args.formula? + end + if !args.formula? && Cask::Caskroom.any_casks_installed? + ohai "Casks" if $stdout.tty? && !args.cask? + safe_system "ls", *ls_args, Cask::Caskroom.path + end else - args.named.to_formulae_and_casks(only: :cask, method: :resolve) + 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 - full_cask_names = cask_names.map(&:full_name).sort(&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? - filtered_list(args: args) - elsif args.versions? - filtered_list(args: args) unless args.cask? - list_casks(args: args) if args.cask? || (!args.formula? && !args.multiple? && args.no_named?) - 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? - safe_system "ls", *ls_args, HOMEBREW_CELLAR - end - if !args.formula? && Cask::Caskroom.any_casks_installed? - if $stdout.tty? && !args.cask? - puts - ohai "Casks" + + private + + 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 - safe_system "ls", *ls_args, Cask::Caskroom.path - 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? - list_casks(args: args) if casks.present? - end - 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? + end + pinned_versions.each do |d, version| + puts d.basename.to_s.concat(args.versions? ? " #{version}" : "") + 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 - def filtered_list(args:) - 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 - 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? - end - pinned_versions.each do |d, version| - puts d.basename.to_s.concat(args.versions? ? " #{version}" : "") + puts "#{d.basename} #{versions * " "}" + end + end 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 * " "}" - end - end - end + sig { void } + def list_casks + casks = if args.no_named? + cask_paths = Cask::Caskroom.path.children.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 + # NamedAargs subclasses array + T.cast(filtered_args, Homebrew::CLI::NamedArgs).to_formulae_and_casks(only: :cask) + end + return if casks.blank? - def list_casks(args:) - casks = if args.no_named? - Cask::Caskroom.casks - else - args.named.dup.delete_if do |n| - Homebrew.failed = true unless Cask::Caskroom.path.join(n).exist? - !Cask::Caskroom.path.join(n).exist? - end.to_formulae_and_casks(only: :cask) + Cask::List.list_casks( + *casks, + one: args.public_send(:"1?"), + full_name: args.full_name?, + versions: args.versions?, + ) + end end - return if casks.blank? - - Cask::Cmd::List.list_casks( - *casks, - one: args.public_send(:"1?"), - full_name: args.full_name?, - versions: args.versions?, - ) - 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? - end - when ".brew" - next # Ignore .brew - else - if pn.directory? - if pn.symlink? - puts "#{pn} -> #{pn.readlink}" + class PrettyListing + sig { params(path: T.any(String, Pathname, Keg)).void } + 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? + end + when ".brew" + next # Ignore .brew else - print_dir pn + 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 - elsif Metafiles.list?(pn.basename.to_s) - puts pn end 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" - end - end + private - dirs.each do |d| - files = [] - d.find { |pn| files << pn unless pn.directory? } - print_remaining_files files, d - 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 = "" - print_remaining_files remaining_root_files, root, other - end + 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 - def print_remaining_files(files, root, other = "") - if files.length == 1 - puts files - elsif files.length > 1 - puts "#{root}/ (#{files.length} #{other}files)" + 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 9602e87d7c9fc..e76f131b87ab8 100644 --- a/Library/Homebrew/cmd/log.rb +++ b/Library/Homebrew/cmd/log.rb @@ -1,86 +1,89 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "fileutils" module Homebrew - extend T::Sig + module Cmd + class Log < AbstractCommand + include FileUtils - module_function + 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." - sig { returns(CLI::Parser) } - def log_args - Homebrew::CLI::Parser.new 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." + conflicts "-1", "--max-count" + conflicts "--formula", "--cask" - conflicts "-1", "--max-count" - conflicts "--formula", "--cask" + named_args [:formula, :cask], max: 1, without_api: true + end - named_args [:formula, :cask], max: 1 - 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 log - args = log_args.parse + if args.no_named? + git_log(HOMEBREW_REPOSITORY) + else + path = T.must(args.named.to_paths.first) + tap = Tap.from_path(path) + git_log path.dirname, path, tap + end + end - # 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 + private - if args.no_named? - git_log HOMEBREW_REPOSITORY, args: args - else - path = args.named.to_paths.first - tap = Tap.from_path(path) - git_log path.dirname, path, tap, args: args - end - end + 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 - def git_log(cd_dir, path = nil, tap = nil, args:) - 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 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 - - 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.present? - system "git", "log", *git_args end end diff --git a/Library/Homebrew/cmd/migrate.rb b/Library/Homebrew/cmd/migrate.rb index 68cae13b40dfb..7f24aa820e848 100644 --- a/Library/Homebrew/cmd/migrate.rb +++ b/Library/Homebrew/cmd/migrate.rb @@ -1,45 +1,44 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "migrator" -require "cli/parser" +require "cask/migrator" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def migrate_args - Homebrew::CLI::Parser.new 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." - - named_args :installed_formula, min: 1 - end - end - - def migrate - args = migrate_args.parse - - args.named.to_kegs.each do |keg| - f = Formulary.from_keg(keg) - - if f.oldname - rack = HOMEBREW_CELLAR/f.oldname - raise NoSuchKegError, f.oldname if !rack.exist? || rack.subdirs.empty? - - odie "#{rack} is a symlink" if rack.symlink? + 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 - Migrator.migrate_if_needed(f, force: args.force?, dry_run: args.dry_run?) + 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 + end end end end diff --git a/Library/Homebrew/cmd/missing.rb b/Library/Homebrew/cmd/missing.rb index d4784c683d40c..41cce0329db4c 100644 --- a/Library/Homebrew/cmd/missing.rb +++ b/Library/Homebrew/cmd/missing.rb @@ -1,50 +1,46 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "tab" require "diagnostic" -require "cli/parser" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def missing_args - Homebrew::CLI::Parser.new do - description <<~EOS - 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." - - named_args :formula - end - end - - def missing - args = missing_args.parse - - return unless HOMEBREW_CELLAR.exist? - - ff = if args.no_named? - Formula.installed.sort - else - args.named.to_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 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." + + named_args :formula + end + + sig { override.void } + def run + return unless HOMEBREW_CELLAR.exist? + + ff = if args.no_named? + Formula.installed.sort + else + args.named.to_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(" ") + 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..5ae54f19ad903 --- /dev/null +++ b/Library/Homebrew/cmd/nodenv-sync.rb @@ -0,0 +1,71 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" + +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 || 0 + patch_version = version.patch.to_i || 0 + + (0..minor_version).each do |minor| + (0..patch_version).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_sf path, link_path + end + end + end + end + end +end diff --git a/Library/Homebrew/cmd/options.rb b/Library/Homebrew/cmd/options.rb index c97d57d52805b..ad62093461e05 100644 --- a/Library/Homebrew/cmd/options.rb +++ b/Library/Homebrew/cmd/options.rb @@ -1,69 +1,72 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "options" module Homebrew - extend T::Sig + 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." + flag "--command=", + description: "Show options for the specified ." - module_function + conflicts "--installed", "--all", "--command" - sig { returns(CLI::Parser) } - def options_args - Homebrew::CLI::Parser.new 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 "--all", - description: "Show options for all available formulae." - flag "--command=", - description: "Show options for the specified ." - - conflicts "--installed", "--all", "--command" - - named_args :formula - end - end + named_args :formula + end - def options - args = options_args.parse + sig { override.void } + def run + all = args.eval_all? - if args.all? - puts_options Formula.all.sort, args: args - elsif args.installed? - puts_options Formula.installed.sort, args: args - elsif args.command.present? - cmd_options = Commands.command_options(args.command) - odie "Unknown command: #{args.command}" if cmd_options.nil? + if all + puts_options(Formula.all(eval_all: args.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 + 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 FormulaUnspecifiedError + else + puts_options args.named.to_formulae + end end - elsif args.no_named? - raise FormulaUnspecifiedError - else - puts_options args.named.to_formulae, args: args - end - end - def puts_options(formulae, args:) - formulae.each do |f| - next if f.options.empty? + private + + 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 - Options.dump_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 256459c2d1583..2c7e7000d1622 100644 --- a/Library/Homebrew/cmd/outdated.rb +++ b/Library/Homebrew/cmd/outdated.rb @@ -1,205 +1,222 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "keg" -require "cli/parser" -require "cask/cmd" require "cask/caskroom" require "api" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def outdated_args - Homebrew::CLI::Parser.new 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." - 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 "--greedy", - description: "Also include outdated casks with `auto_updates true` or `version :latest`." - - switch "--greedy-latest", - description: "Also include outdated casks including those with `version :latest`." - - switch "--greedy-auto-updates", - description: "Also include outdated casks including those with `auto_updates true`." - - conflicts "--quiet", "--verbose", "--json" - conflicts "--formula", "--cask" - - named_args [:formula, :cask] - end - end - - def outdated - args = outdated_args.parse - - 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(args: args), []] - elsif args.cask? - [[], outdated_casks(args: args)] - else - outdated_formulae_casks args: args + 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." + 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", + env: :upgrade_greedy, + description: "Also include outdated casks with `auto_updates true` or `version :latest`." + + switch "--greedy-latest", + description: "Also include outdated casks including those with `version :latest`." + + switch "--greedy-auto-updates", + description: "Also include outdated casks including those with `auto_updates true`." + + conflicts "--quiet", "--verbose", "--json" + conflicts "--formula", "--cask" + + named_args [:formula, :cask] end - json = { - "formulae" => json_info(formulae, args: args), - "casks" => json_info(casks, args: args), - } - puts JSON.pretty_generate(json) - - outdated = formulae + casks - - else - outdated = if args.formula? - outdated_formulae args: args - elsif args.cask? - outdated_casks args: args - else - outdated_formulae_casks(args: args).flatten - end - - print_outdated(outdated, args: args) - end - - Homebrew.failed = args.named.present? && outdated.present? - end - - def print_outdated(formulae_or_casks, args:) - formulae_or_casks.each do |formula_or_cask| - if formula_or_cask.is_a?(Formula) - f = formula_or_cask - - if verbose? - outdated_kegs = f.outdated_kegs(fetch_head: args.fetch_HEAD?) - - current_version = if f.alias_changed? && !f.latest_formula.latest_version_installed? - 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" + sig { override.void } + def run + 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 - f.pkg_version.to_s + outdated_formulae_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(", ") - - pinned_version = " [pinned at #{f.pinned_version}]" if f.pinned? + 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*\]/, "[]") - puts "#{outdated_versions} < #{current_version}#{pinned_version}" + outdated = formulae + casks else - puts f.full_installed_specified_name + outdated = if args.formula? + outdated_formulae + elsif args.cask? + outdated_casks + else + outdated_formulae_casks.flatten + end + + print_outdated(outdated) end - else - c = formula_or_cask - puts c.outdated_info(args.greedy?, verbose?, false, args.greedy_latest?, args.greedy_auto_updates?) + Homebrew.failed = args.named.present? && outdated.present? end - end - end - def json_info(formulae_or_casks, args:) - formulae_or_casks.map do |formula_or_cask| - if formula_or_cask.is_a?(Formula) - f = formula_or_cask + 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 = f.outdated_kegs(fetch_head: args.fetch_HEAD?) + + current_version = if f.alias_changed? && !f.latest_formula.latest_version_installed? + 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" + else + f.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 - outdated_versions = f.outdated_kegs(fetch_head: args.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 + puts c.outdated_info(args.greedy?, verbose?, false, args.greedy_latest?, args.greedy_auto_updates?) + end end + end - { name: f.full_name, - installed_versions: outdated_versions.map(&:to_s), - current_version: current_version, - pinned: f.pinned?, - pinned_version: f.pinned_version } - else - c = formula_or_cask + sig { + params( + formulae_or_casks: T::Array[T.any(Formula, Cask::Cask)], + ).returns( + T::Array[T.any(T::Hash[String, T.untyped], T::Hash[String, 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_versions = f.outdated_kegs(fetch_head: args.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 + end + + { 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 - c.outdated_info(args.greedy?, verbose?, true, args.greedy_latest?, args.greedy_auto_updates?) + c.outdated_info(args.greedy?, verbose?, true, args.greedy_latest?, args.greedy_auto_updates?) + end + end end - end - end - - def verbose? - ($stdout.tty? || super) && !quiet? - end - def json_version(version) - version_hash = { - nil => nil, - true => :default, - "v1" => :v1, - "v2" => :v2, - } + sig { returns(T::Boolean) } + def verbose? + ($stdout.tty? || Context.current.verbose?) && !Context.current.quiet? + end - raise UsageError, "invalid JSON version: #{version}" unless version_hash.include?(version) + 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 - version_hash[version] - end + 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 - def outdated_formulae(args:) - select_outdated((args.named.to_resolved_formulae.presence || Formula.installed), args: args).sort - end + sig { returns(T::Array[Cask::Cask]) } + def outdated_casks + outdated = if args.named.present? + select_outdated(args.named.to_casks) + else + select_outdated(Cask::Caskroom.casks) + end - def outdated_casks(args:) - if args.named.present? - select_outdated(args.named.to_casks, args: args) - else - select_outdated(Cask::Caskroom.casks, args: args) - end - end + T.cast(outdated, T::Array[Cask::Cask]) + end - def outdated_formulae_casks(args:) - formulae, casks = args.named.to_resolved_formulae_to_casks + 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 + if formulae.blank? && casks.blank? + formulae = Formula.installed + casks = Cask::Caskroom.casks + end - [select_outdated(formulae, args: args).sort, select_outdated(casks, args: args)] - end + [select_outdated(formulae).sort, select_outdated(casks)] + end - def select_outdated(formulae_or_casks, args:) - formulae_or_casks.select do |formula_or_cask| - if formula_or_cask.is_a?(Formula) - formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?) - else - formula_or_cask.outdated?(greedy: args.greedy?, greedy_latest: args.greedy_latest?, - greedy_auto_updates: args.greedy_auto_updates?) + 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) + formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?) + else + formula_or_cask.outdated?(greedy: args.greedy?, greedy_latest: args.greedy_latest?, + greedy_auto_updates: args.greedy_auto_updates?) + end + end end end end diff --git a/Library/Homebrew/cmd/pin.rb b/Library/Homebrew/cmd/pin.rb index 304105ffe908e..91a9608efed44 100644 --- a/Library/Homebrew/cmd/pin.rb +++ b/Library/Homebrew/cmd/pin.rb @@ -1,36 +1,35 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "cli/parser" module Homebrew - extend T::Sig + module Cmd + class Pin < AbstractCommand + cmd_args do + description <<~EOS + Pin the specified , preventing them from being upgraded when + issuing the `brew upgrade` command. See also `unpin`. - module_function + *Note:* Other packages which depend on newer versions of a pinned formula + might not install or run correctly. + EOS - sig { returns(CLI::Parser) } - def pin_args - Homebrew::CLI::Parser.new do - description <<~EOS - Pin the specified , preventing them from being upgraded when - issuing the `brew upgrade` command. See also `unpin`. - EOS - - named_args :installed_formula, min: 1 - end - end - - def pin - args = pin_args.parse + named_args :installed_formula, min: 1 + end - args.named.to_resolved_formulae.each do |f| - if f.pinned? - opoo "#{f.name} already pinned" - elsif !f.pinnable? - onoe "#{f.name} not installed" - else - f.pin + sig { override.void } + def run + args.named.to_resolved_formulae.each do |f| + if f.pinned? + opoo "#{f.name} already pinned" + elsif !f.pinnable? + onoe "#{f.name} not installed" + else + f.pin + end + end end end end diff --git a/Library/Homebrew/cmd/postinstall.rb b/Library/Homebrew/cmd/postinstall.rb index 821195f6dd862..f1f5d72eba91b 100644 --- a/Library/Homebrew/cmd/postinstall.rb +++ b/Library/Homebrew/cmd/postinstall.rb @@ -1,33 +1,34 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "sandbox" require "formula_installer" -require "cli/parser" module Homebrew - extend T::Sig + module Cmd + class Postinstall < AbstractCommand + cmd_args do + description <<~EOS + Rerun the post-install steps for . + EOS - module_function + named_args :installed_formula, min: 1 + end - sig { returns(CLI::Parser) } - def postinstall_args - Homebrew::CLI::Parser.new do - description <<~EOS - Rerun the post-install steps for . - EOS - - named_args :installed_formula, min: 1 - end - end - - def postinstall - args = postinstall_args.parse - - args.named.to_resolved_formulae.each do |f| - ohai "Postinstalling #{f}" - fi = FormulaInstaller.new(f, **{ debug: args.debug?, quiet: args.quiet?, verbose: args.verbose? }.compact) - fi.post_install + sig { override.void } + def run + args.named.to_resolved_formulae.each do |f| + ohai "Postinstalling #{f}" + f.install_etc_var + if f.post_install_defined? + fi = FormulaInstaller.new(f, **{ debug: args.debug?, quiet: args.quiet?, verbose: args.verbose? }.compact) + fi.post_install + else + 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..4b1a84bdd2b6d --- /dev/null +++ b/Library/Homebrew/cmd/pyenv-sync.rb @@ -0,0 +1,87 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" + +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 + + (0..patch_version).each do |patch| + # Create folder symlinks for all patch versions to the latest patch version + # (eg. 3.11.0 -> 3.11.3). + 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_sf 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? + + FileUtils.rm_f major_link_path + FileUtils.ln_s link_path/"bin/#{executable}.#{minor_version}", major_link_path + 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..4a9bf777d198a --- /dev/null +++ b/Library/Homebrew/cmd/rbenv-sync.rb @@ -0,0 +1,69 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" + +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 || 0 + + (0..patch_version).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_sf path, link_path + end + end + end + end +end diff --git a/Library/Homebrew/cmd/readall.rb b/Library/Homebrew/cmd/readall.rb index 7c64d8eb09a60..7d12ef53a4b0f 100644 --- a/Library/Homebrew/cmd/readall.rb +++ b/Library/Homebrew/cmd/readall.rb @@ -1,50 +1,68 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "readall" -require "cli/parser" +require "env_config" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def readall_args - Homebrew::CLI::Parser.new 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 - 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)." - - named_args :tap - 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. " \ + "Implied if `HOMEBREW_EVAL_ALL` is set." + switch "--no-simulate", + description: "Don't simulate other system configurations when checking formulae and casks." - def readall - args = readall_args.parse + named_args :tap + end - if args.syntax? && args.no_named? - scan_files = "#{HOMEBREW_LIBRARY_PATH}/**/*.rb" - ruby_files = Dir.glob(scan_files).grep_v(%r{/(vendor)/}) + 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 args.no_named? - Tap - else - args.named.to_installed_taps - 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 + + taps = if args.no_named? + if !args.eval_all? && !Homebrew::EnvConfig.eval_all? + raise UsageError, "`brew readall` needs a tap or `--eval-all` passed or `HOMEBREW_EVAL_ALL` 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 bc6bca5053d65..b31fd2204c2ae 100644 --- a/Library/Homebrew/cmd/reinstall.rb +++ b/Library/Homebrew/cmd/reinstall.rb @@ -1,156 +1,186 @@ -# typed: false +# 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/cmd" require "cask/utils" require "cask/macos" +require "cask/reinstall" require "upgrade" require "api" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def reinstall_args - Homebrew::CLI::Parser.new 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 "-f", "--force", - description: "Install without checking for previously installed keg-only or " \ - "non-migrated versions." - switch "-v", "--verbose", - description: "Print the verification and postinstall steps." - [ - [: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, "--display-times", { - env: :display_install_times, - description: "Print install times for each formula at the end of the run.", - }], - [:switch, "-g", "--git", { - description: "Create a Git repository, useful for creating patches to the software.", - }], - ].each do |options| - send(*options) - conflicts "--cask", options[-2] + 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", + env: :display_install_times, + description: "Print install times for each package at the end of the run." + 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, "--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, "--[no-]quarantine", { + description: "Disable/enable quarantining of downloads (default: enabled).", + env: :cask_opts_quarantine, + }], + [: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" + + named_args [:formula, :cask], min: 1 end - formula_options - [ - [:switch, "--cask", "--casks", { description: "Treat all named arguments as casks." }], - *Cask::Cmd::AbstractCommand::OPTIONS, - *Cask::Cmd::Install::OPTIONS, - ].each do |options| - send(*options) - conflicts "--formula", options[-2] - end - cask_options - - conflicts "--build-from-source", "--force-bottle" - - named_args [:formula, :cask], min: 1 - end - end - - def reinstall - args = reinstall_args.parse - - if args.build_from_source? && Homebrew::EnvConfig.install_from_api? - raise UsageError, "--build-from-source is not supported when using HOMEBREW_INSTALL_FROM_API." - end - formulae, casks = args.named.to_formulae_and_casks(method: :resolve) - .partition { |o| o.is_a?(Formula) } - - if args.build_from_source? && !DevelopmentTools.installed? - raise BuildFlagsError.new(["--build-from-source"], bottled: formulae.all?(&:bottled?)) - end - - Install.perform_preinstall_checks - - formulae.each do |formula| - if formula.pinned? - onoe "#{formula.full_name} is pinned. You must unpin it to reinstall." - next + sig { override.void } + def run + formulae, casks = args.named.to_resolved_formulae_to_casks + + 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 + + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) if Homebrew::Attestation.enabled? + + unless formulae.empty? + Install.perform_preinstall_checks_once + + formulae.each 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.reinstall_formula( + 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?, + ) + Cleanup.install_formula_clean!(formula) + end + + Upgrade.check_installed_dependents( + 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 + + if casks.any? + 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: args.quarantine?, + zap: args.zap?, + ) + end + + Cleanup.periodic_clean! + + Homebrew.messages.display_messages(display_times: args.display_times?) end - Migrator.migrate_if_needed(formula, force: args.force?) - reinstall_formula( - formula, - flags: args.flags_only, - installed_on_request: args.named.present?, - force_bottle: args.force_bottle?, - build_from_source_formulae: args.build_from_source_formulae, - interactive: args.interactive?, - keep_tmp: args.keep_tmp?, - force: args.force?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - git: args.git?, - ) - Cleanup.install_formula_clean!(formula) end - - Upgrade.check_installed_dependents( - formulae, - flags: args.flags_only, - installed_on_request: args.named.present?, - force_bottle: args.force_bottle?, - build_from_source_formulae: args.build_from_source_formulae, - interactive: args.interactive?, - keep_tmp: args.keep_tmp?, - force: args.force?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - ) - - if casks.any? - Cask::Cmd::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: args.quarantine?, - zap: args.zap?, - ) - end - - Homebrew.messages.display_messages(display_times: args.display_times?) end end diff --git a/Library/Homebrew/cmd/search.rb b/Library/Homebrew/cmd/search.rb index 603735b340f59..7429daec5e2c0 100644 --- a/Library/Homebrew/cmd/search.rb +++ b/Library/Homebrew/cmd/search.rb @@ -1,163 +1,170 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "missing_formula" require "descriptions" -require "cli/parser" require "search" module Homebrew - extend T::Sig - - module_function - - extend Search - - PACKAGE_MANAGERS = { - 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://apps.fedoraproject.org/packages/s/#{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 - - sig { returns(CLI::Parser) } - def search_args - Homebrew::CLI::Parser.new 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. - The search for is extended online to `homebrew/core` and `homebrew/cask`. - EOS - switch "--formula", "--formulae", - description: "Search online and locally for formulae." - switch "--cask", "--casks", - description: "Search online and locally for casks." - switch "--desc", - description: "Search for formulae with a description matching and casks with " \ - "a name or description matching ." - 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." + module Cmd + class SearchCmd < AbstractCommand + PACKAGE_MANAGERS = T.let({ + 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", + depends_on: "--desc", + description: "Evaluate all available formulae and casks, whether installed or not, to search their " \ + "descriptions. Implied if `HOMEBREW_EVAL_ALL` is set." + 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 - conflicts "--desc", "--pull-request" - conflicts "--open", "--closed" - conflicts(*package_manager_switches) + sig { override.void } + def run + return if search_package_manager - named_args :text_or_regex, min: 1 - end - end - - def search - args = search_args.parse + query = args.named.join(" ") + string_or_regex = Search.query_regexp(query) - return if search_package_manager(args) + if args.desc? + if !args.eval_all? && !Homebrew::EnvConfig.eval_all? && Homebrew::EnvConfig.no_install_from_api? + raise UsageError, "`brew search --desc` needs `--eval-all` passed or `HOMEBREW_EVAL_ALL` set!" + end - query = args.named.join(" ") - string_or_regex = query_regexp(query) + 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 - if args.desc? - search_descriptions(string_or_regex, args) - elsif args.pull_request? - search_pull_requests(query, args) - else - search_names(query, string_or_regex, args) - end - - print_regex_help(args) - end + puts "Use `brew desc` to list packages with a short description." if args.verbose? - def print_regex_help(args) - return unless $stdout.tty? - - metacharacters = %w[\\ | ( ) [ ] { } ^ $ * + ?].freeze - return unless metacharacters.any? do |char| - args.named.any? do |arg| - arg.include?(char) && !arg.start_with?("/") + print_regex_help 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 - - def search_package_manager(args) - package_manager = PACKAGE_MANAGERS.find { |name,| args[:"#{name}?"] } - return false if package_manager.nil? - _, url = package_manager - exec_browser url.call(URI.encode_www_form_component(args.named.join(" "))) - true - end - - def search_pull_requests(query, args) - only = if args.open? && !args.closed? - "open" - elsif args.closed? && !args.open? - "closed" - end + private - GitHub.print_pull_requests_matching(query, only) - end - - def search_names(query, string_or_regex, args) - remote_results = search_taps(query, silent: true) + sig { void } + def print_regex_help + return unless $stdout.tty? - local_formulae = search_formulae(string_or_regex) - remote_formulae = remote_results[:formulae] - all_formulae = local_formulae + remote_formulae + metacharacters = %w[\\ | ( ) [ ] { } ^ $ * + ?].freeze + return unless metacharacters.any? do |char| + args.named.any? do |arg| + arg.include?(char) && !arg.start_with?("/") + end + end - local_casks = search_casks(string_or_regex) - remote_casks = remote_results[:casks] - all_casks = local_casks + remote_casks + opoo <<~EOS + Did you mean to perform a regular expression search? + Surround your query with /slashes/ to search locally by regex. + EOS + end - print_formulae = args.formula? - print_casks = args.cask? - print_formulae = print_casks = true if !print_formulae && !print_casks - print_formulae &&= all_formulae.any? - print_casks &&= all_casks.any? + sig { returns(T::Boolean) } + def search_package_manager + package_manager = PACKAGE_MANAGERS.find { |name,| args.public_send(:"#{name}?") } + return false if package_manager.nil? - ohai "Formulae", Formatter.columns(all_formulae) if print_formulae - puts if print_formulae && print_casks - ohai "Casks", Formatter.columns(all_casks) if print_casks + _, url = package_manager + exec_browser url.call(URI.encode_www_form_component(args.named.join(" "))) + true + end - count = all_formulae.count + all_casks.count + sig { params(query: String).returns(String) } + def search_pull_requests(query) + only = if args.open? && !args.closed? + "open" + elsif args.closed? && !args.open? + "closed" + end - print_missing_formula_help(query, count.positive?) if local_casks.exclude?(query) + GitHub.print_pull_requests_matching(query, only) + end - odie "No formulae or casks found for #{query.inspect}." if count.zero? - end + 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 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 + 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 - def print_missing_formula_help(query, found_matches) - return unless $stdout.tty? + sig { params(query: String, found_matches: T::Boolean).void } + def print_missing_formula_help(query, found_matches) + return unless $stdout.tty? - reason = MissingFormula.reason(query, silent: true) - return if reason.nil? + reason = MissingFormula.reason(query, silent: true) + return if reason.nil? - if found_matches - puts - puts "If you meant #{query.inspect} specifically:" + if found_matches + puts + puts "If you meant #{query.inspect} specifically:" + end + puts reason + end end - puts reason 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..1a101cd481994 --- /dev/null +++ b/Library/Homebrew/cmd/setup-ruby.sh @@ -0,0 +1,41 @@ +# 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/helpers.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 + + if ! bundle check &>/dev/null + then + "${HOMEBREW_BREW_FILE}" install-bundler-gems + fi +} diff --git a/Library/Homebrew/cmd/shellenv.rb b/Library/Homebrew/cmd/shellenv.rb new file mode 100644 index 0000000000000..61c7e354cc3b2 --- /dev/null +++ b/Library/Homebrew/cmd/shellenv.rb @@ -0,0 +1,29 @@ +# 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: `eval "$(brew shellenv)"` + + The shell can be specified explicitly with a supported shell name parameter. 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 97c630e47038a..9b48ffab1dcbd 100644 --- a/Library/Homebrew/cmd/shellenv.sh +++ b/Library/Homebrew/cmd/shellenv.sh @@ -1,14 +1,9 @@ -#: * `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. -#: 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. `~/.profile`, -#: `~/.bash_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() { if [[ "${HOMEBREW_PATH%%:"${HOMEBREW_PREFIX}"/sbin*}" == "${HOMEBREW_PREFIX}/bin" ]] @@ -16,22 +11,51 @@ homebrew-shellenv() { return fi - case "$(/bin/ps -p "${PPID}" -c -o comm=)" in + if [[ -n "$1" ]] + then + HOMEBREW_SHELL_NAME="$1" + else + HOMEBREW_SHELL_NAME="$(/bin/ps -p "${PPID}" -c -o comm=)" + 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 -gx HOMEBREW_PREFIX \"${HOMEBREW_PREFIX}\";" - echo "set -gx HOMEBREW_CELLAR \"${HOMEBREW_CELLAR}\";" - echo "set -gx HOMEBREW_REPOSITORY \"${HOMEBREW_REPOSITORY}\";" - echo "set -q PATH; or set PATH ''; set -gx PATH \"${HOMEBREW_PREFIX}/bin\" \"${HOMEBREW_PREFIX}/sbin\" \$PATH;" - 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;" + 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};" - echo "setenv PATH ${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:\$PATH;" - echo "setenv MANPATH ${HOMEBREW_PREFIX}/share/man\`[ \${?MANPATH} == 1 ] && echo \":\${MANPATH}\"\`:;" - echo "setenv INFOPATH ${HOMEBREW_PREFIX}/share/info\`[ \${?INFOPATH} == 1 ] && echo \":\${INFOPATH}\"\`;" + if [[ -n "${PATH_HELPER_ROOT}" ]] + then + PATH_HELPER_ROOT="${PATH_HELPER_ROOT}" PATH="${HOMEBREW_PATH}" /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}\`;" ;; pwsh | -pwsh | pwsh-preview | -pwsh-preview) echo "[System.Environment]::SetEnvironmentVariable('HOMEBREW_PREFIX','${HOMEBREW_PREFIX}',[System.EnvironmentVariableTarget]::Process)" @@ -45,8 +69,17 @@ homebrew-shellenv() { 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}:\";" + if [[ "${HOMEBREW_SHELL_NAME}" == "zsh" ]] || [[ "${HOMEBREW_SHELL_NAME}" == "-zsh" ]] + then + echo "fpath[1,0]=\"${HOMEBREW_PREFIX}/share/zsh/site-functions\";" + fi + if [[ -n "${PATH_HELPER_ROOT}" ]] + then + PATH_HELPER_ROOT="${PATH_HELPER_ROOT}" PATH="${HOMEBREW_PATH}" /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/tab.rb b/Library/Homebrew/cmd/tab.rb new file mode 100644 index 0000000000000..5b911422cd88f --- /dev/null +++ b/Library/Homebrew/cmd/tab.rb @@ -0,0 +1,87 @@ +# 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]]) + formulae_not_installed = formulae.reject(&:any_version_installed?) + casks_not_installed = casks.reject(&:installed?) + if formulae_not_installed.any? || casks_not_installed.any? + names = formulae_not_installed.map(&:name) + casks_not_installed.map(&:token) + is_or_are = (names.length == 1) ? "is" : "are" + odie "#{names.to_sentence} #{is_or_are} not installed." + end + + [*formulae, *casks].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 = if formula_or_cask.is_a?(Formula) + [formula_or_cask.name, Tab.for_formula(formula_or_cask)] + else + [formula_or_cask.token, formula_or_cask.tab] + end + + if tab.tabfile.blank? || !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) + 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 d97b6168d0805..bca54866041e5 100644 --- a/Library/Homebrew/cmd/tap-info.rb +++ b/Library/Homebrew/cmd/tap-info.rb @@ -1,93 +1,97 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - extend T::Sig + 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: " - module_function - - sig { returns(CLI::Parser) } - def tap_info_args - Homebrew::CLI::Parser.new 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: " + named_args :tap + end - named_args :tap - end - end + sig { override.void } + def run + require "tap" - def tap_info - args = tap_info_args.parse + taps = if args.installed? + Tap + else + args.named.to_taps + end - taps = if args.installed? - Tap - else - args.named.to_taps - end + if args.json + raise UsageError, "invalid JSON version: #{args.json}" unless ["v1", true].include? args.json - 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 - print_tap_json(taps.sort_by(&:to_s)) - else - print_tap_info(taps.sort_by(&:to_s)) - end - end + private - 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? - 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 += if (contents = tap.contents).blank? - "no commands/casks/formulae" - else - contents.join(", ") + 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 += ", private" if tap.private? - info += "\n#{tap.path} (#{tap.path.abv})" - info += "\nFrom: #{tap.remote.presence || "N/A"}" + info = Utils.pluralize("tap", tap_count, include_count: true) + info += ", #{private_count} private" + info += ", #{Utils.pluralize("formula", formula_count, plural: "e", 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 = "" + taps.each_with_index do |tap, i| + puts unless i.zero? + info = "#{tap}: " + if tap.installed? + info += "Installed" + 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 tap.git_branch != "master" + else + info += "Not installed" + end + puts info + end end - puts info end - end - end - def print_tap_json(taps) - puts JSON.pretty_generate(taps.map(&:to_hash)) + sig { params(taps: T::Array[Tap]).void } + def print_tap_json(taps) + puts JSON.pretty_generate(taps.map(&:to_hash)) + end + end end end diff --git a/Library/Homebrew/cmd/tap.rb b/Library/Homebrew/cmd/tap.rb index 019d70c8d35ae..b6e1a7dccde96 100644 --- a/Library/Homebrew/cmd/tap.rb +++ b/Library/Homebrew/cmd/tap.rb @@ -1,75 +1,76 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" +require "tap" module Homebrew - extend T::Sig + 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. - module_function + 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-`. - sig { returns(CLI::Parser) } - def tap_args - Homebrew::CLI::Parser.new do - usage_banner "`tap` [] [`/`] []" - description <<~EOS - 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 "--full", + description: "Convert a shallow clone to a full clone without untapping. Taps are only cloned as " \ + "shallow clones if `--shallow` was originally passed.", + replacement: false, + disable: true + switch "--shallow", + description: "Fetch tap as a shallow clone rather than a full clone. Useful for continuous " \ + "integration.", + replacement: false, + disable: true + switch "--[no-]force-auto-update", + hidden: true + switch "--custom-remote", + description: "Install or change a tap with a custom remote. Useful for mirrors." + switch "--repair", + description: "Migrate tapped formulae from symlink-based to directory-based structure." + switch "--eval-all", + description: "Evaluate all the formulae, casks and aliases in the new tap to check validity. " \ + "Implied if `HOMEBREW_EVAL_ALL` is set." + 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: "Convert a shallow clone to a full clone without untapping. Taps are only cloned as " \ - "shallow clones if `--shallow` was originally passed.", - replacement: false - switch "--shallow", - description: "Fetch tap as a shallow clone rather than a full clone. Useful for continuous integration.", - replacement: false - switch "--[no-]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 "--custom-remote", - description: "Install or change a tap with a custom remote. Useful for mirrors." - switch "--repair", - description: "Migrate tapped formulae from symlink-based to directory-based structure." - switch "--list-pinned", - description: "List all pinned taps." - - named_args :tap, max: 2 - end - end - - sig { void } - def tap - args = tap_args.parse + named_args :tap, max: 2 + end - if args.repair? - Tap.each(&:link_completions_and_manpages) - Tap.each(&:fix_remote_configuration) - elsif args.list_pinned? - puts Tap.select(&:pinned?).map(&:name) - elsif args.no_named? - puts Tap.names - else - tap = Tap.fetch(args.named.first) - begin - tap.install clone_target: args.named.second, - force_auto_update: args.force_auto_update?, - custom_remote: args.custom_remote?, - quiet: args.quiet? - rescue TapRemoteMismatchError, TapNoCustomRemoteError => e - odie e - rescue TapAlreadyTappedError - nil + 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 + tap = Tap.fetch(args.named.fetch(0)) + begin + tap.install clone_target: args.named.second, + custom_remote: args.custom_remote?, + quiet: args.quiet?, + verify: args.eval_all? || Homebrew::EnvConfig.eval_all?, + force: args.force? + rescue TapRemoteMismatchError, TapNoCustomRemoteError => e + odie e + rescue TapAlreadyTappedError + nil + end + end end end end diff --git a/Library/Homebrew/cmd/uninstall.rb b/Library/Homebrew/cmd/uninstall.rb index 8c40d4219de86..ff1aafadbc44c 100644 --- a/Library/Homebrew/cmd/uninstall.rb +++ b/Library/Homebrew/cmd/uninstall.rb @@ -1,77 +1,88 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "keg" require "formula" require "diagnostic" require "migrator" -require "cli/parser" -require "cask/cmd" require "cask/cask_loader" +require "cask/exceptions" +require "cask/installer" +require "cask/uninstall" require "uninstall" module Homebrew - extend T::Sig + 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." - module_function + conflicts "--formula", "--cask" + conflicts "--formula", "--zap" - sig { returns(CLI::Parser) } - def uninstall_args - Homebrew::CLI::Parser.new 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." + named_args [:installed_formula, :installed_cask], min: 1 + end - conflicts "--formula", "--cask" - conflicts "--formula", "--zap" + sig { override.void } + def run + all_kegs, casks = args.named.to_kegs_to_casks( + ignore_unavailable: args.force?, + all_kegs: args.force?, + ) - named_args [:installed_formula, :installed_cask], min: 1 - end - end + # If ignore_unavailable is true and the named args + # are a series of invalid kegs and casks, + # #to_kegs_to_casks will return empty arrays. + return if all_kegs.blank? && casks.blank? + + kegs_by_rack = all_kegs.group_by(&:rack) - def uninstall - args = uninstall_args.parse + Uninstall.uninstall_kegs( + kegs_by_rack, + casks:, + force: args.force?, + ignore_dependencies: args.ignore_dependencies?, + named_args: args.named, + ) - all_kegs, casks = args.named.to_kegs_to_casks( - ignore_unavailable: args.force?, - all_kegs: args.force?, - ) + if args.zap? + casks.each do |cask| + odebug "Zapping Cask #{cask}" - kegs_by_rack = all_kegs.group_by(&:rack) + raise Cask::CaskNotInstalledError, cask if !cask.installed? && !args.force? - Uninstall.uninstall_kegs( - kegs_by_rack, - casks: casks, - force: args.force?, - ignore_dependencies: args.ignore_dependencies?, - named_args: args.named, - ) + Cask::Installer.new(cask, verbose: args.verbose?, force: args.force?).zap + end + else + Cask::Uninstall.uninstall_casks( + *casks, + verbose: args.verbose?, + force: args.force?, + ) + end - if args.zap? - T.unsafe(Cask::Cmd::Zap).zap_casks( - *casks, - verbose: args.verbose?, - force: args.force?, - ) - else - T.unsafe(Cask::Cmd::Uninstall).uninstall_casks( - *casks, - verbose: args.verbose?, - force: args.force?, - ) + 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? + end end end end diff --git a/Library/Homebrew/cmd/unlink.rb b/Library/Homebrew/cmd/unlink.rb index 605adfbf22d95..cb6dcff4a1f45 100644 --- a/Library/Homebrew/cmd/unlink.rb +++ b/Library/Homebrew/cmd/unlink.rb @@ -1,44 +1,39 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "ostruct" -require "cli/parser" +require "abstract_command" require "unlink" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def unlink_args - Homebrew::CLI::Parser.new 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 - end + 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 - def unlink - args = unlink_args.parse + sig { override.void } + def run + options = { dry_run: args.dry_run?, verbose: args.verbose? } - options = { dry_run: args.dry_run?, verbose: args.verbose? } + args.named.to_default_kegs.each do |keg| + if args.dry_run? + puts "Would remove:" + keg.unlink(**options) + next + end - args.named.to_default_kegs.each do |keg| - if args.dry_run? - puts "Would remove:" - keg.unlink(**options) - next + Unlink.unlink(keg, dry_run: args.dry_run?, verbose: args.verbose?) + end end - - Unlink.unlink(keg, dry_run: args.dry_run?, verbose: args.verbose?) end end end diff --git a/Library/Homebrew/cmd/unpin.rb b/Library/Homebrew/cmd/unpin.rb index 7e287d5956ee1..e75d15eb439a7 100644 --- a/Library/Homebrew/cmd/unpin.rb +++ b/Library/Homebrew/cmd/unpin.rb @@ -1,36 +1,32 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" -require "cli/parser" module Homebrew - extend T::Sig + module Cmd + class Unpin < AbstractCommand + cmd_args do + description <<~EOS + Unpin , allowing them to be upgraded by `brew upgrade` . + See also `pin`. + EOS - module_function - - sig { returns(CLI::Parser) } - def unpin_args - Homebrew::CLI::Parser.new do - description <<~EOS - Unpin , allowing them to be upgraded by `brew upgrade` . - See also `pin`. - EOS - - named_args :installed_formula, min: 1 - end - end - - def unpin - args = unpin_args.parse + named_args :installed_formula, min: 1 + end - args.named.to_resolved_formulae.each do |f| - if f.pinned? - f.unpin - elsif !f.pinnable? - onoe "#{f.name} not installed" - else - opoo "#{f.name} not pinned" + sig { override.void } + def run + args.named.to_resolved_formulae.each do |f| + if f.pinned? + f.unpin + elsif !f.pinnable? + onoe "#{f.name} not installed" + else + opoo "#{f.name} not pinned" + end + end end end end diff --git a/Library/Homebrew/cmd/untap.rb b/Library/Homebrew/cmd/untap.rb index 5245f4ad78647..17c5c11af62f9 100644 --- a/Library/Homebrew/cmd/untap.rb +++ b/Library/Homebrew/cmd/untap.rb @@ -1,53 +1,97 @@ -# typed: true +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" module Homebrew - extend T::Sig + 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." - module_function + named_args :tap, min: 1 + end - sig { returns(CLI::Parser) } - def untap_args - Homebrew::CLI::Parser.new 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." + sig { override.void } + def run + args.named.to_installed_taps.each do |tap| + odie "Untapping #{tap} is not allowed" if tap.core_tap? && Homebrew::EnvConfig.no_install_from_api? - named_args :tap, min: 1 - end - 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 + odie <<~EOS + Refusing to untap #{tap} because it contains the following installed formulae or casks: + #{installed_names} + EOS + 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?(T.must(formula_name.split("/").last)) + + formula = begin + Formulary.factory(formula_name) + rescue FormulaUnavailableError + # Don't blow up because of a single unavailable 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?(T.must(cask_token.split("/").last)) - def untap - args = untap_args.parse - - args.named.to_installed_taps.each do |tap| - odie "Untapping #{tap} is not allowed" if tap.core_tap? && !Homebrew::EnvConfig.install_from_api? - - if !Homebrew::EnvConfig.install_from_api? || (!tap.core_tap? && tap != "homebrew/cask") - installed_tap_formulae = Formula.installed.select { |formula| formula.tap == tap } - installed_tap_casks = Cask::Caskroom.casks.select { |cask| cask.tap == 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 - odie <<~EOS - Refusing to untap #{tap} because it contains the following installed formulae or casks: - #{installed_names} - EOS + cask = begin + Cask::CaskLoader.load(cask_token) + rescue Cask::CaskUnavailableError + # Don't blow up because of a single unavailable cask. + next end + + cask if cask.installed? end end - tap.uninstall manual: true + private + + 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 + + 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/update-report.rb b/Library/Homebrew/cmd/update-report.rb index 500185a59fd24..dd22f475f6ab6 100644 --- a/Library/Homebrew/cmd/update-report.rb +++ b/Library/Homebrew/cmd/update-report.rb @@ -1,288 +1,421 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true +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 "linuxbrew-core-migration" module Homebrew - extend T::Sig + 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." - module_function + hide_from_man_page! + end - def auto_update_header(args:) - @auto_update_header ||= begin - ohai "Auto-updated Homebrew!" if args.auto_update? - true - end - end + sig { override.void } + def run + return output_update_report if $stdout.tty? - sig { returns(CLI::Parser) } - def update_report_args - Homebrew::CLI::Parser.new 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." - - hide_from_man_page! - end - end + redirect_stdout($stderr) do + output_update_report + end + end - def update_report - return output_update_report if $stdout.tty? + private - redirect_stdout($stderr) do - output_update_report - end - end + def auto_update_header + @auto_update_header ||= begin + ohai "Auto-updated Homebrew!" if args.auto_update? + true + end + end - def output_update_report - args = update_report_args.parse + def output_update_report + # Run `brew update` (again) if we've got a linuxbrew-core CoreTap + if CoreTap.instance.installed? && CoreTap.instance.linuxbrew_core? && + ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"].blank? + ohai "Re-running `brew update` for linuxbrew-core migration" - # Run `brew update` (again) if we've got a linuxbrew-core CoreTap - if CoreTap.instance.installed? && CoreTap.instance.linuxbrew_core? && - ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"].blank? - ohai "Re-running `brew update` for linuxbrew-core migration" + if Homebrew::EnvConfig.core_git_remote != HOMEBREW_CORE_DEFAULT_GIT_REMOTE + opoo <<~EOS + HOMEBREW_CORE_GIT_REMOTE was set: #{Homebrew::EnvConfig.core_git_remote}. + It has been unset for the migration. + You may need to change this from a linuxbrew-core mirror to a homebrew-core one. - if HOMEBREW_CORE_DEFAULT_GIT_REMOTE != Homebrew::EnvConfig.core_git_remote - opoo <<~EOS - HOMEBREW_CORE_GIT_REMOTE was set: #{Homebrew::EnvConfig.core_git_remote}. - It has been unset for the migration. - You may need to change this from a linuxbrew-core mirror to a homebrew-core one. + EOS + end + ENV.delete("HOMEBREW_CORE_GIT_REMOTE") - EOS - end - ENV.delete("HOMEBREW_CORE_GIT_REMOTE") + if Homebrew::EnvConfig.bottle_domain != HOMEBREW_BOTTLE_DEFAULT_DOMAIN + opoo <<~EOS + HOMEBREW_BOTTLE_DOMAIN was set: #{Homebrew::EnvConfig.bottle_domain}. + It has been unset for the migration. + You may need to change this from a Linuxbrew package mirror to a Homebrew one. - if HOMEBREW_BOTTLE_DEFAULT_DOMAIN != Homebrew::EnvConfig.bottle_domain - opoo <<~EOS - HOMEBREW_BOTTLE_DOMAIN was set: #{Homebrew::EnvConfig.bottle_domain}. - It has been unset for the migration. - You may need to change this from a Linuxbrew package mirror to a Homebrew one. + EOS + end + ENV.delete("HOMEBREW_BOTTLE_DOMAIN") - EOS - end - ENV.delete("HOMEBREW_BOTTLE_DOMAIN") + ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"] = "1" + FileUtils.rm_f HOMEBREW_LOCKS/"update" - ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"] = "1" - FileUtils.rm_f HOMEBREW_LOCKS/"update" + update_args = [] + update_args << "--auto-update" if args.auto_update? + update_args << "--force" if args.force? + exec HOMEBREW_BREW_FILE, "update", *update_args + end - update_args = [] - update_args << "--auto-update" if args.auto_update? - update_args << "--force" if args.force? - exec HOMEBREW_BREW_FILE, "update", *update_args - end + 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 - if !Utils::Analytics.messages_displayed? && - !Utils::Analytics.disabled? && - !Utils::Analytics.no_message_output? + 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 - ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1" - # Use the shell's audible bell. - print "\a" + unless args.quiet? + analytics_message + donation_message + install_from_api_message + end - # Use an extra newline and bold to avoid this being missed. - ohai "Homebrew has enabled anonymous aggregate formula 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} - No analytics have been recorded yet (nor will be during this `brew` run). + tap_or_untap_core_taps_if_necessary - EOS + updated = false + new_tag = nil - # Consider the messages possibly missed if not a TTY. - Utils::Analytics.messages_displayed! if $stdout.tty? - end + 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 Settings.read("donationmessage") != "true" && !args.quiet? - ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:" - puts " #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n" + if initial_revision != current_revision + auto_update_header - # Consider the message possibly missed if not a TTY. - Settings.write "donationmessage", true if $stdout.tty? - end + updated = true - install_core_tap_if_necessary + old_tag = Settings.read "latesttag" - updated = false - new_repository_version = nil + new_tag = Utils.popen_read( + "git", "-C", HOMEBREW_REPOSITORY, "tag", "--list", "--sort=-version:refname", "*.*" + ).lines.first.chomp - 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? + Settings.write "latesttag", new_tag if new_tag != old_tag - if initial_revision != current_revision - auto_update_header args: args + 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 - updated = true + # Check if we can parse the JSON and do any Ruby-side follow-up. + unless Homebrew::EnvConfig.no_install_from_api? + Homebrew::API::Formula.write_names_and_aliases + Homebrew::API::Cask.write_names + end - old_tag = Settings.read "latesttag" + Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"] + return if Homebrew::EnvConfig.disable_load_formula? - new_tag = Utils.popen_read( - "git", "-C", HOMEBREW_REPOSITORY, "tag", "--list", "--sort=-version:refname", "*.*" - ).lines.first.chomp + migrate_gcc_dependents_if_needed - if old_tag.blank? || (new_tag == old_tag) - puts "Updated Homebrew from #{shorten_revision(initial_revision)} to #{shorten_revision(current_revision)}." - else - new_repository_version = new_tag - puts "Updated Homebrew from #{old_tag} (#{shorten_revision(initial_revision)}) " \ - "to #{new_tag} (#{shorten_revision(current_revision)})." - end - end + hub = ReporterHub.new - Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"] - return if Homebrew::EnvConfig.disable_load_formula? + 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? - hub = ReporterHub.new + if ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"].present? && tap.core_tap? && + Settings.read("linuxbrewmigrated") != "true" + ohai "Migrating formulae from linuxbrew-core to homebrew-core" - updated_taps = [] - Tap.each do |tap| - next unless tap.git? - next if (tap.core_tap? || tap == "homebrew/cask") && Homebrew::EnvConfig.install_from_api? + LINUXBREW_CORE_MIGRATION_LIST.each do |name| + begin + formula = Formula[name] + rescue FormulaUnavailableError + next + end + next unless formula.any_version_installed? - if ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"].present? && tap.core_tap? && - Settings.read("linuxbrewmigrated") != "true" - ohai "Migrating formulae from linuxbrew-core to homebrew-core" + keg = formula.installed_kegs.last + tab = keg.tab + # force a `brew upgrade` from the linuxbrew-core version to the homebrew-core version (even if lower) + tab.source["versions"]["version_scheme"] = -1 + tab.write + end + + Settings.write "linuxbrewmigrated", true + end - LINUXBREW_CORE_MIGRATION_LIST.each do |name| begin - formula = Formula[name] - rescue FormulaUnavailableError + 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 - next unless formula.any_version_installed? + if reporter.updated? + updated_taps << tap.name + hub.add(reporter, auto_update: args.auto_update?) + end + end + + # 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 - keg = formula.installed_kegs.last - tab = Tab.for_keg(keg) - # force a `brew upgrade` from the linuxbrew-core version to the homebrew-core version (even if lower) - tab.source["versions"]["version_scheme"] = -1 - tab.write + 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 - Settings.write "linuxbrewmigrated", true + 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(db) + .update_from_report!(hub) + end + CacheStoreDatabase.use(:cask_descriptions) do |db| + CaskDescriptionCacheStore.new(db) + .update_from_report!(hub) + end + end + puts if args.auto_update? + elsif !args.auto_update? && !ENV["HOMEBREW_UPDATE_FAILED"] && !ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"] + puts "Already up-to-date." unless args.quiet? + end + + Commands.rebuild_commands_completion_list + link_completions_manpages_and_docs + Tap.installed.each(&:link_completions_and_manpages) + + 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) } + + 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 + + return if new_tag.blank? || new_tag == old_tag || args.quiet? + + puts + + new_major_version, new_minor_version, new_patch_version = new_tag.split(".").map(&:to_i) + old_major_version, old_minor_version = (old_tag.split(".")[0, 2]).map(&:to_i) if old_tag.present? + if old_tag.blank? || new_major_version > old_major_version || new_minor_version > old_minor_version + puts <<~EOS + The #{new_major_version}.#{new_minor_version}.0 release notes are available on the Homebrew Blog: + #{Formatter.url("https://brew.sh/blog/#{new_major_version}.#{new_minor_version}.0")} + EOS + end + + return if new_patch_version.zero? + + puts <<~EOS + The #{new_tag} changelog can be found at: + #{Formatter.url("https://github.com/Homebrew/brew/releases/tag/#{new_tag}")} + EOS end - begin - reporter = Reporter.new(tap) - rescue Reporter::ReporterRevisionUnsetError => e - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer? - next + def no_changes_message + "No changes to formulae or casks." end - if reporter.updated? - updated_taps << tap.name - hub.add(reporter, auto_update: args.auto_update?) + + def shorten_revision(revision) + Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp end - end - unless updated_taps.empty? - auto_update_header args: args - puts "Updated #{updated_taps.count} #{"tap".pluralize(updated_taps.count)} (#{updated_taps.to_sentence})." - updated = true - end + def tap_or_untap_core_taps_if_necessary + return if ENV["HOMEBREW_UPDATE_TEST"] - if updated - if hub.empty? - puts "No changes to formulae." 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 + if Homebrew::EnvConfig.no_install_from_api? + return if Homebrew::EnvConfig.automatically_set_no_install_from_api? - hub.dump(updated_formula_report: !args.auto_update?) unless args.quiet? - hub.reporters.each(&:migrate_tap_migration) - hub.reporters.each { |r| r.migrate_formula_rename(force: args.force?, verbose: args.verbose?) } + core_tap = CoreTap.instance + return if core_tap.installed? - CacheStoreDatabase.use(:descriptions) do |db| - DescriptionCacheStore.new(db) - .update_from_report!(hub) - end - CacheStoreDatabase.use(:cask_descriptions) do |db| - CaskDescriptionCacheStore.new(db) - .update_from_report!(hub) + 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 + 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) + [CoreTap.instance, CoreCaskTap.instance].each do |tap| + next unless tap.installed? + + if tap.git_branch == "master" && + (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 end - puts if args.auto_update? - elsif !args.auto_update? && !ENV["HOMEBREW_UPDATE_FAILED"] && !ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"] - puts "Already up-to-date." unless args.quiet? - end - Commands.rebuild_commands_completion_list - link_completions_manpages_and_docs - Tap.each(&:link_completions_and_manpages) - - 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) } - - 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 + 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 - return if new_repository_version.blank? + def migrate_gcc_dependents_if_needed + # do nothing + end - puts - ohai "Homebrew was updated to version #{new_repository_version}" - Settings.write "latesttag", new_repository_version - if new_repository_version.split(".").last == "0" - puts <<~EOS - More detailed release notes are available on the Homebrew Blog: - #{Formatter.url("https://brew.sh/blog/#{new_repository_version}")} - EOS - elsif !args.quiet? - puts <<~EOS - The changelog can be found at: - #{Formatter.url("https://github.com/Homebrew/brew/releases/tag/#{new_repository_version}")} - EOS - end - end + 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). - def shorten_revision(revision) - Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp - end + EOS + end - def install_core_tap_if_necessary - return if ENV["HOMEBREW_UPDATE_TEST"] - return if Homebrew::EnvConfig.install_from_api? + # Consider the messages possibly missed if not a TTY. + Utils::Analytics.messages_displayed! if $stdout.tty? + end - core_tap = CoreTap.instance - return if core_tap.installed? + def donation_message + return if Settings.read("donationmessage") == "true" - 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 + ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:" + puts " #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n\n" - 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 + # Consider the message possibly missed if not a TTY. + Settings.write "donationmessage", true if $stdout.tty? + end + + def install_from_api_message + return if Settings.read("installfromapimessage") == "true" + + 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 + + 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" + + # Consider the message possibly missed if not a TTY. + Settings.write "installfromapimessage", true if $stdout.tty? + end + end end end +require "extend/os/cmd/update-report" + class Reporter class ReporterRevisionUnsetError < RuntimeError def initialize(var_name) @@ -290,18 +423,23 @@ def initialize(var_name) end end - attr_reader :tap, :initial_revision, :current_revision - - def initialize(tap) + def initialize(tap, api_names_txt: nil, api_names_before_txt: nil, api_dir_prefix: nil) @tap = tap - 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? + # 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 = api_names_txt + @api_names_before_txt = api_names_before_txt + @api_dir_prefix = api_dir_prefix + else + initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{tap.repository_var_suffix}" + @initial_revision = ENV[initial_revision_var].to_s + raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty? - 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? + current_revision_var = "HOMEBREW_UPDATE_AFTER#{tap.repository_var_suffix}" + @current_revision = ENV[current_revision_var].to_s + raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty? + end end def report(auto_update: false) @@ -315,7 +453,7 @@ def report(auto_update: false) src = Pathname.new paths.first dst = Pathname.new paths.last - next unless dst.extname == ".rb" + next if dst.extname != ".rb" if paths.any? { |p| tap.cask_file?(p) } case status @@ -328,6 +466,14 @@ def report(auto_update: false) 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 @@ -354,6 +500,41 @@ def report(auto_update: false) end end + renamed_casks = Set.new + @report[:DC].each do |old_full_name| + old_name = old_full_name.split("/").last + 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 = new_full_name.split("/").last + 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 = old_full_name.split("/").last @@ -383,17 +564,32 @@ def report(auto_update: false) renamed_formulae << [old_full_name, new_full_name] end - if renamed_formulae.present? + 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 def updated? - initial_revision != current_revision + if installed_from_api? + diff.present? + else + initial_revision != current_revision + end end def migrate_tap_migration @@ -417,7 +613,7 @@ def migrate_tap_migration next unless (HOMEBREW_PREFIX/"Caskroom"/new_name).exist? new_tap = Tap.fetch(new_tap_name) - new_tap.install unless new_tap.installed? + new_tap.ensure_installed! ohai "#{name} has been moved to Homebrew.", <<~EOS To uninstall the cask, run: brew uninstall --cask --force #{name} @@ -431,15 +627,18 @@ def migrate_tap_migration 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 Homebrew::EnvConfig.developer? + 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| Tab.for_keg(Keg.new(d)) } - next unless tabs.first.tap == tap # skip if installed formula is not from this tap. + 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. @@ -467,7 +666,7 @@ def migrate_tap_migration EOS end else - new_tap.install unless new_tap.installed? + new_tap.ensure_installed! # update tap for each Tab tabs.each { |tab| tab.tap = new_tap } tabs.each(&:write) @@ -475,49 +674,69 @@ def migrate_tap_migration end end + def migrate_cask_rename + Cask::Caskroom.casks.each do |cask| + Cask::Migrator.migrate_if_needed(cask) + end + end + def migrate_formula_rename(force:, verbose:) 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 - end - - new_name = tap.formula_renames[oldname] - next unless new_name + oldnames_to_migrate = formula.oldnames.select do |oldname| + oldname_rack = HOMEBREW_CELLAR/oldname + next false unless oldname_rack.exist? - new_full_name = "#{tap}/#{new_name}" + if oldname_rack.subdirs.empty? + oldname_rack.rmdir_if_possible + next false + end - begin - f = Formulary.factory(new_full_name) - rescue Exception => e # rubocop:disable Lint/RescueException - onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer? - next + true end + next if oldnames_to_migrate.empty? - Migrator.migrate_if_needed(f, force: force) + Migrator.migrate_if_needed(formula, force:) end end private + attr_reader :tap, :initial_revision, :current_revision, :api_names_txt, :api_names_before_txt, :api_dir_prefix + + 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 + def diff - Utils.popen_read( - "git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR", - "-M85%", initial_revision, current_revision - ) + @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 + + diff_output.lines.filter_map do |line| + next if line.match?(header_regex) + next unless add_delete_characters.include?(line[0]) + + line.sub(/^\+/, "A #{api_dir_prefix.basename}/") + .sub(/^-/, "D #{api_dir_prefix.basename}/") + .sub(/$/, ".rb") + .chomp + 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 class ReporterHub - extend T::Sig - - extend Forwardable - attr_reader :reporters sig { void } @@ -532,46 +751,29 @@ def select_formula_or_cask(key) def add(reporter, auto_update: false) @reporters << reporter - report = reporter.report(auto_update: auto_update).delete_if { |_k, v| v.empty? } + report = reporter.report(auto_update:).delete_if { |_k, v| v.empty? } @hash.update(report) { |_key, oldval, newval| oldval.concat(newval) } end - delegate empty?: :@hash - - def dump(updated_formula_report: true) - report_all = Homebrew::EnvConfig.update_report_all_formulae? + def empty? + @hash.empty? + end - dump_new_formula_report - dump_new_cask_report - dump_renamed_formula_report if report_all - dump_deleted_formula_report(report_all) - dump_deleted_cask_report(report_all) + def dump(auto_update: false) + unless Homebrew::EnvConfig.no_update_report_new? + dump_new_formula_report + dump_new_cask_report + end - outdated_formulae = nil - outdated_casks = nil + dump_deleted_formula_report + dump_deleted_cask_report - if updated_formula_report && report_all - dump_modified_formula_report - dump_modified_cask_report - elsif updated_formula_report - outdated_formulae = Formula.installed.select(&:outdated?).map(&:name) + 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 - - outdated_casks = Cask::Caskroom.casks.select(&:outdated?).map(&:token) output_dump_formula_or_cask_report "Outdated Casks", outdated_casks - elsif report_all - if (changed_formulae = select_formula_or_cask(:M).count) && changed_formulae.positive? - ohai "Modified Formulae", "Modified #{changed_formulae} #{"formula".pluralize(changed_formulae)}." - end - - if (changed_casks = select_formula_or_cask(:MC).count) && changed_casks.positive? - ohai "Modified Casks", "Modified #{changed_casks} #{"cask".pluralize(changed_casks)}." - end - else - outdated_formulae = Formula.installed.select(&:outdated?).map(&:name) - outdated_casks = Cask::Caskroom.casks.select(&:outdated?).map(&:token) end - return if outdated_formulae.blank? && outdated_casks.blank? outdated_formulae = outdated_formulae.count @@ -586,19 +788,24 @@ def dump(updated_formula_report: true) msg = "" if outdated_formulae.positive? - msg += "#{Tty.bold}#{outdated_formulae}#{Tty.reset} outdated #{"formula".pluralize(outdated_formulae)}" + noun = Utils.pluralize("formula", outdated_formulae, plural: "e") + 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 #{"cask".pluralize(outdated_casks)}" + msg += "#{Tty.bold}#{outdated_casks}#{Tty.reset} outdated #{Utils.pluralize("cask", outdated_casks)}" end return if msg.blank? 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 have #{msg} installed. You can upgrade #{update_pronoun} with #{Tty.bold}brew upgrade#{Tty.reset} or list #{update_pronoun} with #{Tty.bold}brew outdated#{Tty.reset}. EOS @@ -613,79 +820,32 @@ def dump_new_formula_report end def dump_new_cask_report - casks = select_formula_or_cask(:AC).sort.map do |name| + return if Homebrew::SimulateSystem.simulating_or_running_on_linux? + + casks = select_formula_or_cask(:AC).sort.filter_map do |name| name.split("/").last unless cask_installed?(name) - end.compact + end output_dump_formula_or_cask_report "New Casks", casks end - def dump_renamed_formula_report - formulae = select_formula_or_cask(:R).sort.map do |name, new_name| - name = pretty_installed(name) if installed?(name) - new_name = pretty_installed(new_name) if installed?(new_name) - "#{name} -> #{new_name}" + 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 "Renamed Formulae", formulae - end - - def dump_deleted_formula_report(report_all) - formulae = select_formula_or_cask(:D).sort.map do |name| - if installed?(name) - pretty_uninstalled(name) - elsif report_all - name - end - end.compact - - output_dump_formula_or_cask_report "Deleted Formulae", formulae + output_dump_formula_or_cask_report "Deleted Installed Formulae", formulae end - def dump_deleted_cask_report(report_all) - casks = select_formula_or_cask(:DC).sort.map do |name| - name = name.split("/").last - if cask_installed?(name) - pretty_uninstalled(name) - elsif report_all - name - end - end.compact + def dump_deleted_cask_report + return if Homebrew::SimulateSystem.simulating_or_running_on_linux? - output_dump_formula_or_cask_report "Deleted Casks", casks - end - - def dump_modified_formula_report - formulae = select_formula_or_cask(:M).sort.map do |name| - if installed?(name) - if outdated?(name) - pretty_outdated(name) - else - pretty_installed(name) - end - else - name - end - end - - output_dump_formula_or_cask_report "Modified Formulae", formulae - end - - def dump_modified_cask_report - casks = select_formula_or_cask(:MC).sort.map do |name| + casks = select_formula_or_cask(:DC).sort.filter_map do |name| name = name.split("/").last - if cask_installed?(name) - if cask_outdated?(name) - pretty_outdated(name) - else - pretty_installed(name) - end - else - name - end + pretty_uninstalled(name) if cask_installed?(name) end - output_dump_formula_or_cask_report "Modified Casks", casks + output_dump_formula_or_cask_report "Deleted Installed Casks", casks end def output_dump_formula_or_cask_report(title, formulae_or_casks) 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 bfd1234e5e21a..6d1c314c83246 100644 --- a/Library/Homebrew/cmd/update-reset.sh +++ b/Library/Homebrew/cmd/update-reset.sh @@ -1,8 +1,4 @@ -#: * `update-reset` [ ...] -#: -#: 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. +# Documentation defined in Library/Homebrew/cmd/update-reset.rb # 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. @@ -12,6 +8,10 @@ git() { # 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}" "$@" } @@ -33,7 +33,14 @@ homebrew-update-reset() { [[ "${option}" == *d* ]] && HOMEBREW_DEBUG=1 ;; *) - 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 @@ -56,15 +63,30 @@ homebrew-update-reset() { 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}..." - head="$(git -C "${DIR}" symbolic-ref refs/remotes/origin/HEAD)" - head="${head#refs/remotes/origin/}" - git -C "${DIR}" checkout --force -B "${head}" origin/HEAD + # HOMEBREW_* variables here may all 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..b36f7776c973b --- /dev/null +++ b/Library/Homebrew/cmd/update.rb @@ -0,0 +1,31 @@ +# 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`)." + 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 0269d73edbd77..66be9ac6b475f 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -1,21 +1,15 @@ -#: * `update` [] -#: -#: 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`). -#: --auto-update Run on auto-updates (e.g. before `brew install`). Skips some slower steps. -#: -f, --force Always do a slower, full update check (even if unnecessary). -#: -q, --quiet Make some output more quiet -#: -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. - -# HOMEBREW_CURLRC, HOMEBREW_DEVELOPER, HOMEBREW_GIT_EMAIL, HOMEBREW_GIT_NAME -# HOMEBREW_UPDATE_CLEANUP, HOMEBREW_UPDATE_TO_TAG are from the user environment +# 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_BREW_DEFAULT_GIT_REMOTE, HOMEBREW_BREW_GIT_REMOTE, HOMEBREW_CACHE, HOMEBREW_CELLAR, HOMEBREW_CURL -# HOMEBREW_DEV_CMD_RUN, HOMEBREW_FORCE_BREWED_CURL, HOMEBREW_FORCE_BREWED_GIT, HOMEBREW_SYSTEM_CURL_TOO_OLD -# HOMEBREW_USER_AGENT_CURL are set by brew.sh +# 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_LINUXBREW_CORE_MIGRATION, +# HOMEBREW_SYSTEM_CURL_TOO_OLD, HOMEBREW_USER_AGENT_CURL are set by brew.sh # shellcheck disable=SC2154 source "${HOMEBREW_LIBRARY}/Homebrew/utils/lock.sh" @@ -25,6 +19,10 @@ 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}" "$@" } @@ -33,6 +31,10 @@ git() { if [[ -z "${GIT_EXECUTABLE}" ]] then 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}" "$@" } @@ -45,9 +47,10 @@ git_init_if_necessary() { trap '{ rm -rf .git; exit 1; }' EXIT git init git config --bool core.autocrlf false + 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} for Homebrew/brew Git remote URL." + 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/*" @@ -67,9 +70,10 @@ git_init_if_necessary() { trap '{ rm -rf .git; exit 1; }' EXIT git init git config --bool core.autocrlf false + 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} for Homebrew/core Git remote URL." + 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/*" @@ -82,18 +86,18 @@ git_init_if_necessary() { 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() { @@ -256,9 +260,10 @@ EOS then git checkout --force "${UPSTREAM_BRANCH}" "${QUIET_ARGS[@]}" else - if [[ -n "${UPSTREAM_TAG}" && "${UPSTREAM_BRANCH}" != "master" ]] + if [[ -n "${UPSTREAM_TAG}" && "${UPSTREAM_BRANCH}" != "master" ]] && + [[ "${INITIAL_BRANCH}" != "master" ]] then - git checkout --force -B "master" "origin/master" "${QUIET_ARGS[@]}" + git branch --force "master" "origin/master" "${QUIET_ARGS[@]}" fi git checkout --force -B "${UPSTREAM_BRANCH}" "${REMOTE_REF}" "${QUIET_ARGS[@]}" @@ -269,7 +274,10 @@ EOS 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 [[ "${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" && -n "${HOMEBREW_LINUXBREW_CORE_MIGRATION}" ]] then @@ -334,9 +342,15 @@ homebrew-update() { --verbose) HOMEBREW_VERBOSE=1 ;; --debug) HOMEBREW_DEBUG=1 ;; --quiet) HOMEBREW_QUIET=1 ;; - --merge) HOMEBREW_MERGE=1 ;; + --merge) + shift + HOMEBREW_MERGE=1 + ;; --force) HOMEBREW_UPDATE_FORCE=1 ;; - --simulate-from-current-branch) HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH=1 ;; + --simulate-from-current-branch) + shift + HOMEBREW_SIMULATE_FROM_CURRENT_BRANCH=1 + ;; --auto-update) export HOMEBREW_UPDATE_AUTO=1 ;; --*) ;; -*) @@ -359,7 +373,7 @@ EOS set -x fi - if [[ -z "${HOMEBREW_UPDATE_CLEANUP}" && -z "${HOMEBREW_UPDATE_TO_TAG}" ]] + if [[ -z "${HOMEBREW_UPDATE_TO_TAG}" ]] then if [[ -n "${HOMEBREW_DEVELOPER}" || -n "${HOMEBREW_DEV_CMD_RUN}" ]] then @@ -376,17 +390,23 @@ EOS ${HOMEBREW_CELLAR} is not writable. You should change the ownership and permissions of ${HOMEBREW_CELLAR} back to your user account: - sudo chown -R \$(whoami) ${HOMEBREW_CELLAR} + sudo chown -R ${USER-\$(whoami)} ${HOMEBREW_CELLAR} EOS fi + if [[ -d "${HOMEBREW_CORE_REPOSITORY}" ]] || + [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" ]] + then + HOMEBREW_CORE_AVAILABLE="1" + fi + if [[ ! -w "${HOMEBREW_REPOSITORY}" ]] then odie </dev/null then opoo "No remote 'origin' in ${DIR}, skipping update!" @@ -567,11 +596,16 @@ EOS 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}")" + 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)" ]] then @@ -584,12 +618,34 @@ EOS [[ -n "${SKIP_FETCH_CORE_REPOSITORY}" && "${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" ]] && continue fi + 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. Hide these hints with HOMEBREW_NO_ENV_HINTS (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 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". # the refspec ensures that the default upstream branch gets updated ( UPSTREAM_REPOSITORY_URL="$(git config remote.origin.url)" + 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 @@ -597,31 +653,54 @@ EOS 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 + + if [[ -n "${UPSTREAM_REPOSITORY}" ]] + 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}" ]] 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_ACCEPT="application/vnd.github.sha" GITHUB_API_ENDPOINT="commits/${UPSTREAM_BRANCH_DIR}" fi - # HOMEBREW_CURL is set by brew.sh (and isn't mispelt here) + # HOMEBREW_CURL is set by brew.sh (and isn't misspelt here) # shellcheck disable=SC2153 UPSTREAM_SHA_HTTP_CODE="$( curl \ "${CURL_DISABLE_CURLRC_ARGS[@]}" \ + "${CURL_GITHUB_API_ARGS[@]}" \ --silent --max-time 3 \ --location --no-remote-time --output /dev/null --write-out "%{http_code}" \ --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}\"" \ "https://api.github.com/repos/${UPSTREAM_REPOSITORY}/${GITHUB_API_ENDPOINT}" @@ -630,14 +709,6 @@ EOS # 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_AUTO}" ]] - then - FORCE_AUTO_UPDATE="$(git config homebrew.forceautoupdate 2>/dev/null || echo "false")" - if [[ "${FORCE_AUTO_UPDATE}" != "true" ]] - then - # Don't try to do a `git fetch` that may take longer than expected. - exit - fi fi # HOMEBREW_VERBOSE isn't modified here so ignore subshell warning. @@ -685,13 +756,6 @@ EOS wait trap - SIGINT - if [[ -f "${update_failed_file}" ]] - then - onoe <"${update_failed_file}" - rm -f "${update_failed_file}" - export HOMEBREW_UPDATE_FAILED="1" - fi - if [[ -f "${missing_remote_ref_dirs_file}" ]] then HOMEBREW_MISSING_REMOTE_REF_DIRS="$(cat "${missing_remote_ref_dirs_file}")" @@ -701,9 +765,11 @@ EOS for DIR in "${HOMEBREW_REPOSITORY}" "${HOMEBREW_LIBRARY}"/Taps/*/* do - if [[ -n "${HOMEBREW_INSTALL_FROM_API}" ]] && - [[ "${DIR}" == "${HOMEBREW_CORE_REPOSITORY}" || - "${DIR}" == "${HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask" ]] + 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 @@ -716,7 +782,7 @@ EOS continue fi - TAP_VAR="$(repo_var "${DIR}")" + TAP_VAR="$(repository_var_suffix "${DIR}")" UPSTREAM_BRANCH_VAR="UPSTREAM_BRANCH${TAP_VAR}" UPSTREAM_BRANCH="${!UPSTREAM_BRANCH_VAR}" CURRENT_REVISION="$(read_current_revision)" @@ -730,30 +796,110 @@ EOS 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}" ]] + 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}" else merge_or_rebase "${DIR}" "${TAP_VAR}" "${UPSTREAM_BRANCH}" - [[ -n "${HOMEBREW_VERBOSE}" ]] && echo fi done - if [[ -n "${HOMEBREW_INSTALL_FROM_API}" ]] - then - mkdir -p "${HOMEBREW_CACHE}/api" - # TODO: use --header If-Modified-Since - curl \ - "${CURL_DISABLE_CURLRC_ARGS[@]}" \ - --fail --compressed --silent --max-time 5 \ - --location --remote-time --output "${HOMEBREW_CACHE}/api/formula.json" \ - --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ - "https://formulae.brew.sh/api/formula.json" - # TODO: we probably want to print an error if this fails. - # TODO: set HOMEBREW_UPDATED or HOMEBREW_UPDATE_FAILED + if [[ -z "${HOMEBREW_NO_INSTALL_FROM_API}" ]] + then + local api_cache="${HOMEBREW_CACHE}/api" + mkdir -p "${api_cache}" + + for json in formula cask formula_tap_migrations cask_tap_migrations + do + local filename="${json}.jws.json" + local cache_path="${api_cache}/${filename}" + if [[ -f "${cache_path}" ]] + then + INITIAL_JSON_BYTESIZE="$(wc -c "${cache_path}")" + fi + + if [[ -n "${HOMEBREW_VERBOSE}" ]] + then + echo "Checking if we need to fetch ${filename}..." + fi + + JSON_URLS=() + if [[ -n "${HOMEBREW_API_DOMAIN}" && "${HOMEBREW_API_DOMAIN}" != "${HOMEBREW_API_DEFAULT_DOMAIN}" ]] + then + JSON_URLS=("${HOMEBREW_API_DOMAIN}/${filename}") + fi + + JSON_URLS+=("${HOMEBREW_API_DEFAULT_DOMAIN}/${filename}") + for json_url in "${JSON_URLS[@]}" + do + time_cond=() + if [[ -s "${cache_path}" ]] + then + time_cond=("--time-cond" "${cache_path}") + fi + curl \ + "${CURL_DISABLE_CURLRC_ARGS[@]}" \ + --fail --compressed --silent \ + --speed-limit "${HOMEBREW_CURL_SPEED_LIMIT}" --speed-time "${HOMEBREW_CURL_SPEED_TIME}" \ + --location --remote-time --output "${cache_path}" \ + "${time_cond[@]}" \ + --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ + "${json_url}" + curl_exit_code=$? + [[ ${curl_exit_code} -eq 0 ]] && break + done + + if [[ "${json}" == "formula" ]] && [[ -f "${api_cache}/formula_names.txt" ]] + then + mv -f "${api_cache}/formula_names.txt" "${api_cache}/formula_names.before.txt" + elif [[ "${json}" == "cask" ]] && [[ -f "${api_cache}/cask_names.txt" ]] + then + mv -f "${api_cache}/cask_names.txt" "${api_cache}/cask_names.before.txt" + fi + + if [[ ${curl_exit_code} -eq 0 ]] + then + touch "${cache_path}" + + CURRENT_JSON_BYTESIZE="$(wc -c "${cache_path}")" + if [[ "${INITIAL_JSON_BYTESIZE}" != "${CURRENT_JSON_BYTESIZE}" ]] + then + + if [[ "${json}" == "formula" ]] + then + rm -f "${api_cache}/formula_aliases.txt" + fi + HOMEBREW_UPDATED="1" + + if [[ -n "${HOMEBREW_VERBOSE}" ]] + then + echo "Updated ${filename}." + fi + fi + else + echo "Failed to download ${json_url}!" >>"${update_failed_file}" + fi + + done + + # 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" + 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 safe_cd "${HOMEBREW_REPOSITORY}" diff --git a/Library/Homebrew/cmd/upgrade.rb b/Library/Homebrew/cmd/upgrade.rb index ec7520a7c33d7..5511cd71e0d4d 100644 --- a/Library/Homebrew/cmd/upgrade.rb +++ b/Library/Homebrew/cmd/upgrade.rb @@ -1,231 +1,275 @@ -# typed: false +# typed: strict # frozen_string_literal: true -require "cli/parser" +require "abstract_command" require "formula_installer" require "install" require "upgrade" -require "cask/cmd" require "cask/utils" +require "cask/upgrade" require "cask/macos" require "api" module Homebrew - extend T::Sig - - module_function - - sig { returns(CLI::Parser) } - def upgrade_args - Homebrew::CLI::Parser.new do - description <<~EOS - Upgrade outdated casks and outdated, unpinned formulae using the same options they were originally - installed with, plus any appended brew formula options. If or are specified, - upgrade only the given or kegs (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 "-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 postinstall steps." - switch "-n", "--dry-run", - description: "Show what would be upgraded, but do not actually upgrade anything." - [ - [: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, "--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, "--display-times", { - env: :display_install_times, - description: "Print install times for each package at the end of the run.", - }], - ].each do |options| - send(*options) - conflicts "--cask", options[-2] - end - formula_options - [ - [:switch, "--cask", "--casks", { - description: "Treat all named arguments as casks. If no named arguments " \ - "are specified, upgrade only outdated casks.", - }], - *Cask::Cmd::AbstractCommand::OPTIONS, - *Cask::Cmd::Upgrade::OPTIONS, - ].each do |options| - send(*options) - conflicts "--formula", options[-2] - end - cask_options + module Cmd + class UpgradeCmd < AbstractCommand + cmd_args do + description <<~EOS + Upgrade outdated casks and outdated, unpinned formulae using the same options they were originally + installed with, plus any appended brew formula options. If or are specified, + upgrade only the given or kegs (unless they are pinned; see `pin`, `unpin`). - conflicts "--build-from-source", "--force-bottle" + 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. - named_args [:outdated_formula, :outdated_cask] - end - end + 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", + env: :display_install_times, + description: "Print install times for each package at the end of the run." + 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." + [ + [: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, "-g", "--greedy", { + description: "Also include casks with `auto_updates true` or `version :latest`.", + }], + [:switch, "--greedy-latest", { + description: "Also include casks with `version :latest`.", + }], + [:switch, "--greedy-auto-updates", { + description: "Also include casks with `auto_updates true`.", + }], + [: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, "--[no-]quarantine", { + description: "Disable/enable quarantining of downloads (default: enabled).", + env: :cask_opts_quarantine, + }], + ].each do |args| + options = args.pop + send(*args, **options) + conflicts "--formula", args.last + end + cask_options - sig { void } - def upgrade - args = upgrade_args.parse + conflicts "--build-from-source", "--force-bottle" - formulae, casks = args.named.to_resolved_formulae_to_casks - # 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. - only_upgrade_formulae = formulae.present? && casks.blank? - only_upgrade_casks = casks.present? && formulae.blank? + named_args [:installed_formula, :installed_cask] + end - upgrade_outdated_formulae(formulae, args: args) unless only_upgrade_casks - upgrade_outdated_casks(casks, args: args) unless only_upgrade_formulae + sig { override.void } + def run + if args.build_from_source? && args.named.empty? + raise ArgumentError, "--build-from-source requires at least one formula" + end - Homebrew.messages.display_messages(display_times: args.display_times?) - end + formulae, casks = args.named.to_resolved_formulae_to_casks + # 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. + only_upgrade_formulae = formulae.present? && casks.blank? + only_upgrade_casks = casks.present? && formulae.blank? - sig { params(formulae: T::Array[Formula], args: CLI::Args).returns(T::Boolean) } - def upgrade_outdated_formulae(formulae, args:) - return false if args.cask? + formulae = Homebrew::Attestation.sort_formulae_for_install(formulae) if Homebrew::Attestation.enabled? - if args.build_from_source? && !DevelopmentTools.installed? - raise BuildFlagsError.new(["--build-from-source"], bottled: formulae.all?(&:bottled?)) - end + upgrade_outdated_formulae(formulae) unless only_upgrade_casks + upgrade_outdated_casks(casks) unless only_upgrade_formulae - Install.perform_preinstall_checks + Cleanup.periodic_clean!(dry_run: args.dry_run?) - if formulae.blank? - outdated = Formula.installed.select do |f| - f.outdated?(fetch_head: args.fetch_HEAD?) - end - else - outdated, not_outdated = formulae.partition do |f| - f.outdated?(fetch_head: args.fetch_HEAD?) + Homebrew.messages.display_messages(display_times: args.display_times?) end - not_outdated.each do |f| - versions = f.installed_kegs.map(&:version) - if versions.empty? - ofail "#{f.full_specified_name} not installed" + private + + sig { params(formulae: T::Array[Formula]).returns(T::Boolean) } + def upgrade_outdated_formulae(formulae) + return false if args.cask? + + 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 formulae.blank? + outdated = Formula.installed.select do |f| + f.outdated?(fetch_head: args.fetch_HEAD?) + end else - version = versions.max - opoo "#{f.full_specified_name} #{version} already installed" + outdated, not_outdated = formulae.partition do |f| + f.outdated?(fetch_head: args.fetch_HEAD?) + end + + 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 args.quiet? + end + end end - end - end - return false if outdated.blank? + return false 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 + 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 - 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 + if pinned.any? + Kernel.public_send( + formulae.any? ? :ofail : :opoo, # only fail when pinned formulae are named explicitly + "Not upgrading #{pinned.count} pinned #{Utils.pluralize("package", pinned.count)}:", + ) + puts pinned.map { |f| "#{f.full_specified_name} #{f.pkg_version}" } * ", " + 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}" + if formulae_to_install.empty? + oh1 "No packages to upgrade" else - "#{f.full_specified_name} #{f.pkg_version}" + verb = args.dry_run? ? "Would upgrade" : "Upgrading" + oh1 "#{verb} #{formulae_to_install.count} outdated #{Utils.pluralize("package", + 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}" + end + end + puts formulae_upgrades.join("\n") end + + Install.perform_preinstall_checks_once + + Upgrade.upgrade_formulae( + formulae_to_install, + 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?, + overwrite: args.overwrite?, + debug: args.debug?, + quiet: args.quiet?, + verbose: args.verbose?, + ) + + Upgrade.check_installed_dependents( + formulae_to_install, + 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?, + ) + + true end - puts formulae_upgrades.join("\n") - end - Upgrade.upgrade_formulae( - formulae_to_install, - flags: args.flags_only, - dry_run: args.dry_run?, - installed_on_request: args.named.present?, - force_bottle: args.force_bottle?, - build_from_source_formulae: args.build_from_source_formulae, - interactive: args.interactive?, - keep_tmp: args.keep_tmp?, - force: args.force?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - ) - - Upgrade.check_installed_dependents( - formulae_to_install, - flags: args.flags_only, - dry_run: args.dry_run?, - installed_on_request: args.named.present?, - force_bottle: args.force_bottle?, - build_from_source_formulae: args.build_from_source_formulae, - interactive: args.interactive?, - keep_tmp: args.keep_tmp?, - force: args.force?, - debug: args.debug?, - quiet: args.quiet?, - verbose: args.verbose?, - ) - - true - end + sig { params(casks: T::Array[Cask::Cask]).returns(T::Boolean) } + def upgrade_outdated_casks(casks) + return false if args.formula? - sig { params(casks: T::Array[Cask::Cask], args: CLI::Args).returns(T::Boolean) } - def upgrade_outdated_casks(casks, args:) - return false if args.formula? - - Cask::Cmd::Upgrade.upgrade_casks( - *casks, - force: args.force?, - greedy: args.greedy?, - greedy_latest: args.greedy_latest?, - greedy_auto_updates: args.greedy_auto_updates?, - dry_run: args.dry_run?, - binaries: args.binaries?, - quarantine: args.quarantine?, - require_sha: args.require_sha?, - skip_cask_deps: args.skip_cask_deps?, - verbose: args.verbose?, - args: args, - ) + 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: args.dry_run?, + binaries: args.binaries?, + quarantine: args.quarantine?, + require_sha: args.require_sha?, + skip_cask_deps: args.skip_cask_deps?, + verbose: args.verbose?, + quiet: args.quiet?, + args:, + ) + end + end end end diff --git a/Library/Homebrew/cmd/uses.rb b/Library/Homebrew/cmd/uses.rb index d5cf623617041..5305925ffe72d 100644 --- a/Library/Homebrew/cmd/uses.rb +++ b/Library/Homebrew/cmd/uses.rb @@ -1,145 +1,182 @@ -# typed: false +# 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 - extend T::Sig - - extend DependenciesHelpers - - module_function - - sig { returns(CLI::Parser) } - def uses_args - Homebrew::CLI::Parser.new 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. - EOS - switch "--recursive", - description: "Resolve more than one level of dependencies." - switch "--installed", - description: "Only list formulae and casks that are currently installed." - switch "--all", - description: "List all formulae and casks whether installed or not.", - hidden: true - 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 "--formula", "--formulae", - description: "Include only formulae." - switch "--cask", "--casks", - description: "Include only casks." - - conflicts "--formula", "--cask" - conflicts "--installed", "--all" - - named_args :formula, min: 1 - end - end - - def uses - args = uses_args.parse + 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 - Formulary.enable_factory_cache! + 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." + 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", "--all" + conflicts "--missing", "--installed" + + named_args :formula, min: 1 + end - 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| OpenStruct.new name: name, full_name: name } - end + 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 - use_runtime_dependents = args.installed? && - !used_formulae_missing && - !args.include_build? && - !args.include_test? && - !args.include_optional? && - !args.skip_recommended? + use_runtime_dependents = args.installed? && + !used_formulae_missing && + !args.include_build? && + !args.include_test? && + !args.include_optional? && + !args.skip_recommended? - uses = intersection_of_dependents(use_runtime_dependents, used_formulae, args: args) + uses = intersection_of_dependents(use_runtime_dependents, used_formulae) - return if uses.empty? + return if uses.empty? - puts Formatter.columns(uses.map(&:full_name).sort) - odie "Missing formulae should not have dependents!" if used_formulae_missing - end + puts Formatter.columns(uses.map(&:full_name).sort) + odie "Missing formulae should not have dependents!" if used_formulae_missing + end - def intersection_of_dependents(use_runtime_dependents, used_formulae, args:) - recursive = args.recursive? - show_formulae_and_casks = !args.formula? && !args.cask? - includes, ignores = args_includes_ignores(args) + private + + sig { + params(use_runtime_dependents: T::Boolean, used_formulae: T::Array[T.any(Formula, UnavailableFormula)]) + .returns(T::Array[Formula]) + } + 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 += 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 - # TODO: 3.6.0: odeprecate not specifying args.all?, require args.installed? + deps + else + all = args.eval_all? - deps = [] - if use_runtime_dependents - if show_formulae_and_casks || args.formula? - deps += 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 + if !args.installed? && !(all || Homebrew::EnvConfig.eval_all?) + raise UsageError, "`brew uses` needs `--installed` or `--eval-all` passed or `HOMEBREW_EVAL_ALL` set!" + end - deps - else - if show_formulae_and_casks || args.formula? - deps += args.installed? ? Formula.installed : Formula.all - end - if show_formulae_and_casks || args.cask? - deps += args.installed? ? Cask::Caskroom.casks : Cask::Cask.all - end + if show_formulae_and_casks || args.formula? + deps += args.installed? ? Formula.installed : Formula.all(eval_all: args.eval_all?) + end + if show_formulae_and_casks || args.cask? + deps += args.installed? ? Cask::Caskroom.casks : Cask::Cask.all(eval_all: args.eval_all?) + end - select_used_dependents(dependents(deps), used_formulae, recursive, includes, ignores) - end - end + if args.missing? + deps.reject! do |dep| + case dep + when Formula + dep.any_version_installed? + when Cask::Cask + dep.installed? + end + end + ignores.delete(:satisfied?) + end - def select_used_dependents(dependents, used_formulae, recursive, includes, ignores) - dependents.select do |d| - deps = if recursive - recursive_includes(Dependency, d, includes, ignores) - else - reject_ignores(d.deps, ignores, includes) + select_used_dependents(dependents(deps), used_formulae, recursive, includes, ignores) + end end - 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 + sig { + params( + dependents: T::Array[Formula], used_formulae: T::Array[T.any(Formula, UnavailableFormula)], + recursive: T::Boolean, includes: T::Array[Symbol], ignores: T::Array[Symbol] + ).returns( + T::Array[Formula], + ) + } + def select_used_dependents(dependents, used_formulae, recursive, includes, ignores) + dependents.select do |d| + deps = if recursive + recursive_includes(Dependency, d, includes, ignores) + else + select_includes(d.deps, ignores, includes) end - next match unless match.nil? - dep.name == ff.name + 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? + + 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 - 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 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 90010adf0a8de..8825d9fccdad0 100644 --- a/Library/Homebrew/cmd/vendor-install.sh +++ b/Library/Homebrew/cmd/vendor-install.sh @@ -1,62 +1,71 @@ -#: @hide_from_man_page -#: * `vendor-install` [] -#: -#: Install Homebrew's portable Ruby. - -# HOMEBREW_CURLRC, HOMEBREW_LIBRARY, HOMEBREW_STDERR is from the user environment -# HOMEBREW_CACHE, HOMEBREW_CURL, HOMEBREW_LINUX, HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION, HOMEBREW_MACOS, -# HOMEBREW_MACOS_VERSION_NUMERIC and HOMEBREW_PROCESSOR are set by brew.sh +# 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/lock.sh" +source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" VENDOR_DIR="${HOMEBREW_LIBRARY}/Homebrew/vendor" # Built from https://github.com/Homebrew/homebrew-portable-ruby. -if [[ -n "${HOMEBREW_MACOS}" ]] -then - if [[ "${HOMEBREW_PHYSICAL_PROCESSOR}" == "x86_64" ]] || - # 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. - [[ "${HOMEBREW_PHYSICAL_PROCESSOR}" == "arm64" && "${HOMEBREW_PREFIX}" == "/usr/local" ]] +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_FILENAME="portable-ruby-2.6.8_1.el_capitan.bottle.tar.gz" - ruby_SHA="1f50bf80583bd436c9542d4fa5ad47df0ef0f0bea22ae710c4f04c42d7560bca" - elif [[ "${HOMEBREW_PHYSICAL_PROCESSOR}" == "arm64" ]] - then - ruby_FILENAME="portable-ruby-2.6.8_1.arm64_big_sur.bottle.tar.gz" - ruby_SHA="cf9137b1da5568d4949f71161a69b101f60ddb765e94d2b423c9801b67a1cb43" + ruby_PROCESSOR="x86_64" + ruby_OS="darwin" + else + ruby_PROCESSOR="${VENDOR_PHYSICAL_PROCESSOR}" + if [[ -n "${HOMEBREW_MACOS}" ]] + then + ruby_OS="darwin" + elif [[ -n "${HOMEBREW_LINUX}" ]] + then + ruby_OS="linux" + fi fi -elif [[ -n "${HOMEBREW_LINUX}" ]] -then - case "${HOMEBREW_PROCESSOR}" in - x86_64) - ruby_FILENAME="portable-ruby-2.6.8_1.x86_64_linux.bottle.tar.gz" - ruby_SHA="fc45ee6eddf4c7a17f4373dde7b1bc8a58255ea61e6847d3bf895225b28d072a" - ;; - *) ;; - esac -fi - -# Dynamic variables can't be detected by shellcheck -# shellcheck disable=SC2034 -if [[ -n "${ruby_SHA}" && -n "${ruby_FILENAME}" ]] -then - ruby_URLs=() - if [[ -n "${HOMEBREW_ARTIFACT_DOMAIN}" ]] + + ruby_PLATFORMINFO="${HOMEBREW_LIBRARY}/Homebrew/vendor/portable-ruby-${ruby_PROCESSOR}-${ruby_OS}" + if [[ -f "${ruby_PLATFORMINFO}" && -r "${ruby_PLATFORMINFO}" ]] then - ruby_URLs+=("${HOMEBREW_ARTIFACT_DOMAIN}/v2/homebrew/portable-ruby/portable-ruby/blobs/sha256:${ruby_SHA}") + # ruby_TAG and ruby_SHA will be set via the sourced file if it exists + # shellcheck disable=SC1090 + source "${ruby_PLATFORMINFO}" fi - if [[ -n "${HOMEBREW_BOTTLE_DOMAIN}" ]] + + # Dynamic variables can't be detected by shellcheck + # shellcheck disable=SC2034 + if [[ -n "${ruby_TAG}" && -n "${ruby_SHA}" ]] then - ruby_URLs+=("${HOMEBREW_BOTTLE_DOMAIN}/bottles-portable-ruby/${ruby_FILENAME}") + 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/portable-ruby/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}/bottles-portable-ruby/${ruby_FILENAME}") + fi + ruby_URLs+=( + "https://ghcr.io/v2/homebrew/portable-ruby/portable-ruby/blobs/sha256:${ruby_SHA}" + "https://github.com/Homebrew/homebrew-portable-ruby/releases/download/${HOMEBREW_PORTABLE_RUBY_VERSION}/${ruby_FILENAME}" + ) + ruby_URL="${ruby_URLs[0]}" fi - ruby_URLs+=( - "https://ghcr.io/v2/homebrew/portable-ruby/portable-ruby/blobs/sha256:${ruby_SHA}" - "https://github.com/Homebrew/homebrew-portable-ruby/releases/download/2.6.8_1/${ruby_FILENAME}" - ) - ruby_URL="${ruby_URLs[0]}" -fi +} check_linux_glibc_version() { if [[ -z "${HOMEBREW_LINUX}" || -z "${HOMEBREW_LINUX_MINIMUM_GLIBC_VERSION}" ]] @@ -85,16 +94,6 @@ check_linux_glibc_version() { fi } -# Execute the specified command, and suppress stderr unless HOMEBREW_STDERR is set. -quiet_stderr() { - if [[ -z "${HOMEBREW_STDERR}" ]] - then - command "$@" 2>/dev/null - else - command "$@" - fi -} - fetch() { local -a curl_args local url @@ -111,6 +110,9 @@ fetch() { 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 @@ -130,11 +132,6 @@ fetch() { curl_args[${#curl_args[*]}]="--progress-bar" fi - if [[ "${HOMEBREW_MACOS_VERSION_NUMERIC}" -lt "100600" ]] - then - curl_args[${#curl_args[*]}]="--insecure" - fi - temporary_path="${CACHED_LOCATION}.incomplete" mkdir -p "${HOMEBREW_CACHE}" @@ -149,7 +146,7 @@ fetch() { first_try='' if [[ -f "${temporary_path}" ]] then - # HOMEBREW_CURL is set by brew.sh (and isn't mispelt here) + # HOMEBREW_CURL is set by brew.sh (and isn't misspelt here) # shellcheck disable=SC2153 "${HOMEBREW_CURL}" "${curl_args[@]}" -C - "${url}" -o "${temporary_path}" if [[ $? -eq 33 ]] @@ -186,21 +183,33 @@ EOS if [[ -x "/usr/bin/shasum" ]] then sha="$(/usr/bin/shasum -a 256 "${CACHED_LOCATION}" | cut -d' ' -f1)" - elif [[ -x "$(type -P sha256sum)" ]] + fi + + if [[ -z "${sha}" && -x "$(type -P sha256sum)" ]] then sha="$(sha256sum "${CACHED_LOCATION}" | cut -d' ' -f1)" - elif [[ -x "$(type -P ruby)" ]] + fi + + if [[ -z "${sha}" ]] then - sha="$( - ruby <&2 tar "${tar_args}" "${CACHED_LOCATION}" + + if [[ "${VENDOR_PROCESSOR}" != "${HOMEBREW_PROCESSOR}" ]] || + [[ "${VENDOR_PHYSICAL_PROCESSOR}" != "${HOMEBREW_PHYSICAL_PROCESSOR}" ]] + then + return 0 + fi + safe_cd "${VENDOR_DIR}/portable-${VENDOR_NAME}" - if quiet_stderr "./${VENDOR_VERSION}/bin/${VENDOR_NAME}" --version >/dev/null + if "./${VENDOR_VERSION}/bin/${VENDOR_NAME}" --version >/dev/null then ln -sfn "${VENDOR_VERSION}" current if [[ -d "${VENDOR_VERSION}.reinstall" ]] @@ -264,6 +280,9 @@ homebrew-vendor-install() { local url_var local sha_var + unset VENDOR_PHYSICAL_PROCESSOR + unset VENDOR_PROCESSOR + for option in "$@" do case "${option}" in @@ -281,14 +300,41 @@ homebrew-vendor-install() { [[ "${option}" == *d* ]] && HOMEBREW_DEBUG=1 ;; *) - [[ -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 a vendor target!" [[ -n "${HOMEBREW_DEBUG}" ]] && set -x + + 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" @@ -311,7 +357,7 @@ homebrew-vendor-install() { CACHED_LOCATION="${HOMEBREW_CACHE}/${VENDOR_FILENAME}" - lock "vendor-install-${VENDOR_NAME}" + lock "vendor-install ${VENDOR_NAME}" fetch install } 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 383c5cb768481..bff04823efdfa 100644 --- a/Library/Homebrew/commands.rb +++ b/Library/Homebrew/commands.rb @@ -1,84 +1,72 @@ -# typed: false +# typed: true # rubocop:todo Sorbet/StrictSigil # frozen_string_literal: true -require "completions" - # Helper functions for commands. -# -# @api private module Commands - module_function - HOMEBREW_CMD_PATH = (HOMEBREW_LIBRARY_PATH/"cmd").freeze HOMEBREW_DEV_CMD_PATH = (HOMEBREW_LIBRARY_PATH/"dev-cmd").freeze + # 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 = { - "ls" => "list", - "homepage" => "home", - "-S" => "search", - "up" => "update", - "ln" => "link", - "instal" => "install", # gem does the same - "uninstal" => "uninstall", - "rm" => "uninstall", - "remove" => "uninstall", - "abv" => "info", - "dr" => "doctor", - "--repo" => "--repository", - "environment" => "--env", - "--config" => "config", - "-v" => "--version", - "lc" => "livecheck", - "tc" => "typecheck", + "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", }.freeze + # 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|$)/ - INSTALL_FROM_API_FORBIDDEN_COMMANDS = %w[ - audit - bottle - bump-cask-pr - bump-formula-pr - bump-revision - bump-unversioned-casks - cat - create - edit - extract - formula - livecheck - pr-pull - pr-upload - test - update-python-resources - ].freeze - - def valid_internal_cmd?(cmd) + def self.valid_internal_cmd?(cmd) require?(HOMEBREW_CMD_PATH/cmd) end - def valid_internal_dev_cmd?(cmd) + def self.valid_internal_dev_cmd?(cmd) require?(HOMEBREW_DEV_CMD_PATH/cmd) end - def method_name(cmd) + def self.valid_ruby_cmd?(cmd) + (valid_internal_cmd?(cmd) || valid_internal_dev_cmd?(cmd) || external_ruby_v2_cmd_path(cmd)) && + Homebrew::AbstractCommand.command(cmd)&.ruby_cmd? + end + + def self.method_name(cmd) cmd.to_s .tr("-", "_") .downcase .to_sym end - def args_method_name(cmd_path) + 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".to_sym + :"#{cmd_method_prefix}_args" end - def internal_cmd_path(cmd) + def self.internal_cmd_path(cmd) [ HOMEBREW_CMD_PATH/"#{cmd}.rb", HOMEBREW_CMD_PATH/"#{cmd}.sh", ].find(&:exist?) end - def internal_dev_cmd_path(cmd) + def self.internal_dev_cmd_path(cmd) [ HOMEBREW_DEV_CMD_PATH/"#{cmd}.rb", HOMEBREW_DEV_CMD_PATH/"#{cmd}.sh", @@ -86,21 +74,21 @@ def internal_dev_cmd_path(cmd) end # Ruby commands which can be `require`d without being run. - def external_ruby_v2_cmd_path(cmd) - path = which("#{cmd}.rb", Tap.cmd_directories) + def self.external_ruby_v2_cmd_path(cmd) + path = which("#{cmd}.rb", tap_cmd_directories) path if require?(path) end # Ruby commands which are run by being `require`d. - def external_ruby_cmd_path(cmd) - which("brew-#{cmd}.rb", PATH.new(ENV.fetch("PATH")).append(Tap.cmd_directories)) + def self.external_ruby_cmd_path(cmd) + which("brew-#{cmd}.rb", PATH.new(ENV.fetch("PATH")).append(tap_cmd_directories)) end - def external_cmd_path(cmd) - which("brew-#{cmd}", PATH.new(ENV.fetch("PATH")).append(Tap.cmd_directories)) + def self.external_cmd_path(cmd) + which("brew-#{cmd}", PATH.new(ENV.fetch("PATH")).append(tap_cmd_directories)) end - def path(cmd) + 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) @@ -110,7 +98,7 @@ def path(cmd) path end - def commands(external: true, aliases: false) + def self.commands(external: true, aliases: false) cmds = internal_commands cmds += internal_developer_commands cmds += external_commands if external @@ -118,59 +106,70 @@ def commands(external: true, aliases: false) cmds.sort end - def internal_commands_paths + # 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 + + def self.internal_commands_paths find_commands HOMEBREW_CMD_PATH end - def internal_developer_commands_paths + def self.internal_developer_commands_paths find_commands HOMEBREW_DEV_CMD_PATH end - def official_external_commands_paths(quiet:) + def self.official_external_commands_paths(quiet:) + require "tap" + OFFICIAL_CMD_TAPS.flat_map do |tap_name, cmds| tap = Tap.fetch(tap_name) - tap.install(quiet: quiet) unless tap.installed? + tap.install(quiet:) unless tap.installed? cmds.map(&method(:external_ruby_v2_cmd_path)).compact end end - def internal_commands + def self.internal_commands find_internal_commands(HOMEBREW_CMD_PATH).map(&:to_s) end - def internal_developer_commands + def self.internal_developer_commands find_internal_commands(HOMEBREW_DEV_CMD_PATH).map(&:to_s) end - def internal_commands_aliases + def self.internal_commands_aliases HOMEBREW_INTERNAL_COMMAND_ALIASES.keys end - def find_internal_commands(path) + def self.find_internal_commands(path) find_commands(path).map(&:basename) - .map(&method(:basename_without_extension)) + .map { basename_without_extension(_1) } + .uniq end - def external_commands - Tap.cmd_directories.flat_map do |path| + def self.external_commands + tap_cmd_directories.flat_map do |path| find_commands(path).select(&:executable?) - .map(&method(:basename_without_extension)) + .map { basename_without_extension(_1) } .map { |p| p.to_s.delete_prefix("brew-").strip } end.map(&:to_s) .sort end - def basename_without_extension(path) + def self.basename_without_extension(path) path.basename(path.extname) end - def find_commands(path) + def self.find_commands(path) Pathname.glob("#{path}/*") .select(&:file?) .sort end - def rebuild_internal_commands_completion_list + def self.rebuild_internal_commands_completion_list + require "completions" + cmds = internal_commands + internal_developer_commands + internal_commands_aliases cmds.reject! { |cmd| Homebrew::Completions::COMPLETIONS_EXCLUSION_LIST.include? cmd } @@ -178,7 +177,9 @@ def rebuild_internal_commands_completion_list file.atomic_write("#{cmds.sort.join("\n")}\n") end - def rebuild_commands_completion_list + def self.rebuild_commands_completion_list + require "completions" + # Ensure that the cache exists so we can build the commands list HOMEBREW_CACHE.mkpath @@ -190,16 +191,19 @@ def rebuild_commands_completion_list external_commands_file.atomic_write("#{external_commands.sort.join("\n")}\n") end - def command_options(command) + sig { params(command: String).returns(T.nilable(T::Array[[String, String]])) } + def self.command_options(command) + return if command == "help" + path = self.path(command) return if path.blank? if (cmd_parser = Homebrew::CLI::Parser.from_cmd_path(path)) - cmd_parser.processed_options.map do |short, long, _, desc, hidden| + cmd_parser.processed_options.filter_map do |short, long, desc, hidden| next if hidden [long || short, desc] - end.compact + end else options = [] comment_lines = path.read.lines.grep(/^#:/) @@ -207,21 +211,20 @@ def command_options(command) # skip the comment's initial usage summary lines comment_lines.slice(2..-1).each do |line| - if / (?