From e98268b934a49380ea315fc3ed7607628e0b0e1c Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 15:50:04 -0500
Subject: [PATCH 1/7] feat: harden installer, docs, and release governance
---
.DS_Store | Bin 6148 -> 0 bytes
.github/dependabot.yml | 10 +
.github/workflows/automerge.yml | 7 +-
.github/workflows/cd.yml | 172 ++++-
.github/workflows/ci.yml | 108 ++-
.github/workflows/codeql.yml | 47 ++
.github/workflows/release.yml | 180 ++++-
.github/workflows/scorecard.yml | 42 +
.gitignore | 2 +
AGENTS.md | 171 +----
CONTRIBUTING.md | 9 +-
Makefile | 52 +-
README.md | 210 ++---
STANDARDS.md | 13 +-
TOOLS.md | 44 +-
bash_profile | 42 +-
bashrc | 2 +
bashrc.d/10-helpers.sh | 38 +
bashrc.d/20-path.sh | 6 +-
bashrc.d/30-buildflags.sh | 50 +-
bashrc.d/40-completions.sh | 15 +-
bashrc.d/50-tool-init.sh | 18 +-
bashrc.d/60-asdf.sh | 14 +-
bashrc.d/65-tools.sh | 41 +-
bashrc.d/70-bash-it.sh | 7 +-
bashrc.d/70-env.sh | 1 -
bashrc.d/95-ssh-agent.sh | 25 +-
bashrc.d/99-secrets.sh | 6 -
bin/README.md | 2 +-
bin/gen_tool_versions | 50 +-
bin/ram_usage | 313 +-------
bin/ram_usage_lib.py | 169 ++++
bin/ram_usage_report.py | 133 ++++
docs/CONFIG.md | 90 ++-
docs/DESIGN.md | 2 +
docs/INSTALLER.md | 12 -
docs/INSTALLERS.md | 51 +-
docs/INSTALLERS_HELPERS.md | 170 ++--
docs/MODULES.md | 82 +-
docs/README.md | 55 +-
docs/SHDOC.md | 39 +-
docs/STATE.md | 78 +-
docs/TESTING.md | 172 ++---
docs/api/index.md | 19 +
docs/conf.py | 47 +-
docs/getting-started/downloads.md | 42 +
docs/getting-started/index.md | 33 +
docs/getting-started/install-and-verify.md | 58 ++
docs/index.md | 64 +-
docs/public/install.sh | 216 ++++++
docs/reference/architecture.md | 20 +
docs/reference/index.md | 24 +
docs/reference/release-checklist.md | 69 ++
docs/reference/release-verification.md | 54 ++
docs/reference/security.md | 42 +
docs/reference/supply-chain.md | 39 +
docs/reference/testing.md | 37 +
install.bash | 725 +-----------------
install.sh | 205 ++++-
installers/README.md | 3 +-
installers/_helpers.sh | 686 +----------------
installers/bootstrap_sources.sh | 5 +
installers/lib/asdf.sh | 82 ++
installers/lib/core.sh | 6 +
installers/lib/installers.sh | 154 ++++
installers/lib/languages.sh | 80 ++
installers/lib/packages.sh | 151 ++++
installers/lib/system.sh | 148 ++++
installers/lib/tool_runner.sh | 170 ++++
installers/sources.sh | 85 ++
installers/tools.sh | 21 +-
installlib/config.sh | 186 +++++
installlib/filesystem.sh | 4 +
installlib/installers.sh | 74 ++
installlib/managed_files.sh | 173 +++++
installlib/resolve.sh | 252 ++++++
installlib/runtime_files.sh | 130 ++++
installlib/ui.sh | 161 ++++
release-please-config.json | 2 +
scripts/build_release_artifact.sh | 157 ++++
scripts/ci-setup.sh | 11 +
scripts/gen-docs.sh | 123 ++-
scripts/generate_pkg_manifests.sh | 157 ++++
scripts/lib/immutable_release_flow.sh | 65 ++
scripts/package.sh | 19 +-
scripts/pre-commit-ci.sh | 7 +-
scripts/publish_draft_release.sh | 64 ++
scripts/publish_pkg_pr.sh | 92 +++
scripts/reconcile_codeql_governance.sh | 62 ++
.../reconcile_immutable_release_governance.sh | 28 +
scripts/release_validate.sh | 246 ++++++
scripts/smoke_test_release_artifact.sh | 65 ++
scripts/supply_chain_verify.sh | 277 +++++++
scripts/test-setup.sh | 61 +-
scripts/validate-docs.sh | 47 ++
scripts/verify-install.sh | 3 +-
scripts/verify_branch_protection.sh | 96 +++
.../verify_immutable_release_governance.sh | 28 +
scripts/verify_published_release.sh | 92 +++
scripts/wsl-quality.sh | 18 +
tests/asdf_pins.bats | 119 +++
tests/bootstrap.bats | 148 ++++
tests/branch_protection.bats | 136 ++++
tests/brew_runtime.bats | 177 +++++
tests/codeql_contract.bats | 34 +
tests/codeql_governance.bats | 110 +++
tests/config_output.bats | 6 +-
tests/docs_contract.bats | 325 ++++++++
tests/dry_run.bats | 40 +
tests/git_sources.bats | 70 ++
tests/immutable_release_governance.bats | 198 +++++
tests/install.bats | 103 ++-
tests/interactive_features.bats | 193 +++++
tests/link_dotfiles.bats | 6 +-
tests/managed_assets.bats | 27 +
tests/migration.bats | 20 +
tests/optional_deps.bats | 2 +-
tests/registry_idempotent.bats | 2 +-
tests/release_pipeline.bats | 425 ++++++++++
tests/runtime_modules.bats | 393 ++++++++++
tests/supply_chain_verify.bats | 272 +++++++
tests/test_helper.bash | 36 +
tests/test_setup.bats | 57 ++
tests/workflow_permissions.bats | 28 +
tox.ini | 16 +-
125 files changed, 9150 insertions(+), 2528 deletions(-)
delete mode 100644 .DS_Store
create mode 100644 .github/workflows/codeql.yml
create mode 100644 .github/workflows/scorecard.yml
create mode 100644 bin/ram_usage_lib.py
create mode 100644 bin/ram_usage_report.py
create mode 100644 docs/api/index.md
create mode 100644 docs/getting-started/downloads.md
create mode 100644 docs/getting-started/index.md
create mode 100644 docs/getting-started/install-and-verify.md
create mode 100755 docs/public/install.sh
create mode 100644 docs/reference/architecture.md
create mode 100644 docs/reference/index.md
create mode 100644 docs/reference/release-checklist.md
create mode 100644 docs/reference/release-verification.md
create mode 100644 docs/reference/security.md
create mode 100644 docs/reference/supply-chain.md
create mode 100644 docs/reference/testing.md
mode change 100755 => 100644 installers/_helpers.sh
create mode 100644 installers/bootstrap_sources.sh
create mode 100644 installers/lib/asdf.sh
create mode 100644 installers/lib/core.sh
create mode 100644 installers/lib/installers.sh
create mode 100644 installers/lib/languages.sh
create mode 100644 installers/lib/packages.sh
create mode 100644 installers/lib/system.sh
create mode 100644 installers/lib/tool_runner.sh
create mode 100644 installers/sources.sh
create mode 100644 installlib/config.sh
create mode 100644 installlib/filesystem.sh
create mode 100644 installlib/installers.sh
create mode 100644 installlib/managed_files.sh
create mode 100644 installlib/resolve.sh
create mode 100644 installlib/runtime_files.sh
create mode 100644 installlib/ui.sh
create mode 100755 scripts/build_release_artifact.sh
create mode 100755 scripts/generate_pkg_manifests.sh
create mode 100644 scripts/lib/immutable_release_flow.sh
create mode 100644 scripts/publish_draft_release.sh
create mode 100755 scripts/publish_pkg_pr.sh
create mode 100644 scripts/reconcile_codeql_governance.sh
create mode 100644 scripts/reconcile_immutable_release_governance.sh
create mode 100755 scripts/release_validate.sh
create mode 100755 scripts/smoke_test_release_artifact.sh
create mode 100644 scripts/supply_chain_verify.sh
create mode 100755 scripts/validate-docs.sh
create mode 100644 scripts/verify_branch_protection.sh
create mode 100644 scripts/verify_immutable_release_governance.sh
create mode 100755 scripts/verify_published_release.sh
create mode 100755 scripts/wsl-quality.sh
create mode 100644 tests/asdf_pins.bats
create mode 100644 tests/bootstrap.bats
create mode 100644 tests/branch_protection.bats
create mode 100644 tests/brew_runtime.bats
create mode 100644 tests/codeql_contract.bats
create mode 100644 tests/codeql_governance.bats
create mode 100644 tests/docs_contract.bats
create mode 100644 tests/dry_run.bats
create mode 100644 tests/git_sources.bats
create mode 100644 tests/immutable_release_governance.bats
create mode 100644 tests/interactive_features.bats
create mode 100644 tests/managed_assets.bats
create mode 100644 tests/migration.bats
create mode 100644 tests/release_pipeline.bats
create mode 100644 tests/runtime_modules.bats
create mode 100644 tests/supply_chain_verify.bats
create mode 100644 tests/test_setup.bats
create mode 100644 tests/workflow_permissions.bats
diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 5cc5cbd2a3fb6f758c1046343a3420f9d9a7d5d7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6148
zcmeHK%}T>S5Z-NTO(;SSiXIod7EHhj;w8lT0!H+pQWFw17_+5G?V%KM)fe(jd>&_Z
zH-}*GC}L;I?l(I>yO|HVKa4T%FJhOmA!E#fhR9KA5j3~DIwlyAt2s(mM9X3pWio1+
z=r5Y^+xskJ1^g1${Qi$3H*(wB=oE>v!y9hPm|)8YPV<&1$Pp|9Uh%sPM@=vT)t@@IZ&=-$6yWbpfpN)4d!_!^GC4PIdv={F+dCu1H`}v
zGGGn^Yp{W}Q^~{tG4L}3xIYMJh@QbpquM&4!|OBpdx$8Y<68nz81xKQ8o>j?bt<4v
z<>raObvoFEiSrCr8g)A3YGs(mtXw}{xLO_TLWMK#X{4SQAO@NYboFqE=l=!#Wm+Hk
z%@i6D1H`~TV}SQ2!NiB6%-Q;_JUnX!vT00Z1d4wO^-1?mvz8LTwo
TENEBhfOHX1giuEe`~m}CyctXt
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 62b5749..0728da0 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,16 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ day: "monday"
+ time: "06:00"
+ timezone: "Etc/UTC"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci"
labels:
- "dependencies"
+ - "ci"
open-pull-requests-limit: 10
diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml
index e6d0cb4..a183a72 100644
--- a/.github/workflows/automerge.yml
+++ b/.github/workflows/automerge.yml
@@ -4,14 +4,15 @@ on:
pull_request:
types: [labeled, opened, synchronize, reopened]
-permissions:
- pull-requests: write
- contents: write
+permissions: {}
jobs:
dependabot:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]'
+ permissions:
+ pull-requests: write
+ contents: write
steps:
- name: Dependabot metadata
id: metadata
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 48168f0..7ebabf0 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -4,11 +4,7 @@ on:
push:
branches: [main]
-permissions:
- contents: write
- pages: write
- id-token: write
- pull-requests: write
+permissions: {}
concurrency:
group: "pages"
@@ -18,17 +14,174 @@ jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
+ outputs:
+ release_created: ${{ steps.release.outputs.release_created }}
+ tag_name: ${{ steps.release.outputs.tag_name }}
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
steps:
- - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
+ - id: release
+ uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
with:
- token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+ build-release:
+ name: Build And Validate Draft Release
+ needs: release-please
+ if: needs.release-please.outputs.release_created == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ needs.release-please.outputs.tag_name }}
+ persist-credentials: false
+
+ - name: Extract version
+ id: ver
+ shell: bash
+ env:
+ RAW_TAG: ${{ needs.release-please.outputs.tag_name }}
+ run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate docs contract
+ run: ./scripts/validate-docs.sh
+
+ - name: Build release archives
+ shell: bash
+ run: |
+ mkdir -p dist/release
+ bash scripts/build_release_artifact.sh '${{ steps.ver.outputs.version }}' dist/release
+
+ - name: Validate archives, manifests, and docs installer
+ shell: bash
+ run: |
+ bash scripts/release_validate.sh '${{ steps.ver.outputs.version }}' dist/release
+
+ - name: Upload release dist
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-dist
+ path: |
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz.sha256
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip.sha256
+ dist/release/checksums.txt
+ retention-days: 7
+
+ - name: Upload package manifests
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: package-manifests
+ path: |
+ dist/release/pkg/get-bashed.rb
+ dist/release/pkg/get-bashed.json
+ dist/release/pkg/get-bashed.nuspec
+ dist/release/pkg/chocolateyInstall.ps1
+ dist/release/pkg/VERIFICATION.txt
+ retention-days: 7
+
+ publish-release:
+ name: Attest, Publish, And Verify Release
+ needs:
+ - release-please
+ - build-release
+ if: needs.release-please.outputs.release_created == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ needs.release-please.outputs.tag_name }}
+ persist-credentials: false
+
+ - name: Download release dist
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: release-dist
+ path: dist/release
+
+ - name: Attest release artifacts
+ uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
+ with:
+ subject-path: |
+ dist/release/*.tar.gz
+ dist/release/*.zip
+ dist/release/checksums.txt
+
+ - name: Upload assets to draft release and publish
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+ RAW_TAG: ${{ needs.release-please.outputs.tag_name }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ bash scripts/publish_draft_release.sh "${RAW_TAG}" dist/release "${GH_REPO}"
+
+ - name: Verify published assets, checksum, attestation, and smoke path
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+ GH_REPO: ${{ github.repository }}
+ RAW_TAG: ${{ needs.release-please.outputs.tag_name }}
+ OWNER: ${{ github.repository_owner }}
+ run: |
+ bash scripts/verify_published_release.sh "${RAW_TAG}" "${GH_REPO}" "${OWNER}"
+
+ publish-packages:
+ name: Publish To jbcom/pkgs
+ needs:
+ - release-please
+ - build-release
+ - publish-release
+ if: needs.release-please.outputs.release_created == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ needs.release-please.outputs.tag_name }}
+ persist-credentials: false
+
+ - name: Extract version
+ id: ver
+ shell: bash
+ env:
+ RAW_TAG: ${{ needs.release-please.outputs.tag_name }}
+ run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Download validated manifests
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: package-manifests
+ path: out
+
+ - name: Open PR against jbcom/pkgs
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }}
+ run: |
+ bash scripts/publish_pkg_pr.sh '${{ steps.ver.outputs.version }}' out
+
docs:
name: Docs Deployment
runs-on: ubuntu-latest
needs: release-please
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
@@ -38,6 +191,8 @@ jobs:
run: ./scripts/ci-setup.sh "shdoc"
- name: Generate shell docs
run: ./scripts/gen-docs.sh
+ - name: Validate docs surface
+ run: ./scripts/validate-docs.sh
- name: Build Sphinx docs
run: uvx tox -e docs
- name: Upload pages artifact
@@ -49,6 +204,9 @@ jobs:
name: Deploy Docs
runs-on: ubuntu-latest
needs: docs
+ permissions:
+ pages: write
+ id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff44f75..de48cc5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,36 +6,114 @@ on:
pull_request:
types: [opened, synchronize, reopened]
-jobs:
- lint:
- name: Lint
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: CI setup (get-bashed)
- run: ./scripts/ci-setup.sh "shellcheck,actionlint,bashate,pre_commit,shdoc"
- - name: Pre-commit
- run: ./scripts/pre-commit-ci.sh
+permissions: {}
- tests:
- name: Tests
- runs-on: ubuntu-latest
+concurrency:
+ group: ci-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Quality (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
+ with:
+ enable-cache: true
- name: CI setup (get-bashed)
- run: ./scripts/ci-setup.sh "bats"
+ run: ./scripts/ci-setup.sh "bats,shdoc,actionlint,shellcheck,bashate,pre_commit"
- name: Fetch Bats helpers
run: ./scripts/test-setup.sh
+ - name: Pre-commit
+ run: pre-commit run --all-files
- name: Run tests
run: bats tests
- name: Verify install wiring
run: ./scripts/verify-install.sh
+ - name: Generate shell docs
+ run: ./scripts/gen-docs.sh
+ - name: Validate docs surface
+ run: ./scripts/validate-docs.sh
+ - name: Build Sphinx docs and linkcheck
+ run: uvx tox -e docs,docs-linkcheck
+ - name: Verify supply chain posture
+ run: ./scripts/supply_chain_verify.sh
+
+ wsl-quality:
+ name: Quality (wsl-ubuntu)
+ runs-on: windows-2025
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Ensure Ubuntu WSL
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $distro = 'Ubuntu'
+ $installed = @(
+ wsl.exe --list --quiet 2>$null |
+ ForEach-Object { $_.Trim() } |
+ Where-Object { $_ }
+ )
+
+ if (-not ($installed -contains $distro)) {
+ wsl.exe --install --distribution $distro --no-launch --web-download
+ }
+
+ $ready = $false
+ for ($attempt = 0; $attempt -lt 30; $attempt++) {
+ wsl.exe -d $distro --user root --exec true 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ $ready = $true
+ break
+ }
+ Start-Sleep -Seconds 2
+ }
+
+ if (-not $ready) {
+ throw "WSL distro $distro did not become ready"
+ }
+
+ wsl.exe --list --verbose
+ - name: Install WSL base packages
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ wsl.exe -d Ubuntu --user root --exec bash -lc 'export DEBIAN_FRONTEND=noninteractive; apt-get update; apt-get install -y bash ca-certificates curl git make python3 python3-pip python3-venv'
+ - name: Clone repository into WSL workspace
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $distro = 'Ubuntu'
+ $source = (wsl.exe -d $distro --user root --exec wslpath -a $env:GITHUB_WORKSPACE).Trim()
+ $workspace = (wsl.exe -d $distro --user root --exec bash -lc 'workspace="$HOME/get-bashed"; rm -rf "$workspace"; printf "%s" "$workspace"').Trim()
+
+ wsl.exe -d $distro --user root --exec bash -lc "git clone --no-local '$source' '$workspace' && cd '$workspace' && git checkout --force '$env:GITHUB_SHA'"
+
+ Add-Content -Path $env:GITHUB_ENV -Value "WSL_DISTRO=$distro"
+ Add-Content -Path $env:GITHUB_ENV -Value "WSL_WORKSPACE=$workspace"
+ - name: Run WSL quality checks
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ wsl.exe -d $env:WSL_DISTRO --user root --exec bash -lc "cd '$env:WSL_WORKSPACE' && ./scripts/wsl-quality.sh"
sonarqube:
name: SonarQube Scan
- needs: [lint, tests]
+ needs: [quality, wsl-quality]
runs-on: ubuntu-latest
if: success()
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..fe2ca0c
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,47 @@
+name: CodeQL
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ push:
+ branches: [main]
+ schedule:
+ - cron: "0 6 * * 1"
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: codeql-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ analyze:
+ name: CodeQL (${{ matrix.language }})
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [actions, python]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ with:
+ languages: ${{ matrix.language }}
+ queries: security-extended
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c0a0cf1..45673ae 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,18 +1,178 @@
name: Release
on:
- release:
- types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Draft release tag to build and publish (for example v0.1.0)"
+ required: true
+ type: string
+ publish_release:
+ description: "Publish the draft release after uploading and verifying assets"
+ required: false
+ default: true
+ type: boolean
+
+permissions: {}
jobs:
- artifacts:
- name: Upload Release Artifacts
+ build-validate:
+ name: Build And Validate Release Inputs
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ startsWith(github.event.release.tag_name, 'v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.tag }}
+ persist-credentials: false
+
+ - name: Extract version
+ id: ver
+ shell: bash
+ env:
+ RAW_TAG: ${{ inputs.tag }}
+ run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate docs contract
+ run: ./scripts/validate-docs.sh
+
+ - name: Build release archives
+ shell: bash
+ run: |
+ mkdir -p dist/release
+ bash scripts/build_release_artifact.sh '${{ steps.ver.outputs.version }}' dist/release
+
+ - name: Validate archives, manifests, and docs installer
+ shell: bash
+ run: |
+ bash scripts/release_validate.sh '${{ steps.ver.outputs.version }}' dist/release
+
+ - name: Upload release dist
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-dist
+ path: |
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-unix.tar.gz.sha256
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip
+ dist/release/get-bashed-${{ steps.ver.outputs.version }}-windows.zip.sha256
+ dist/release/checksums.txt
+ retention-days: 7
+
+ - name: Upload package manifests
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: package-manifests
+ path: |
+ dist/release/pkg/get-bashed.rb
+ dist/release/pkg/get-bashed.json
+ dist/release/pkg/get-bashed.nuspec
+ dist/release/pkg/chocolateyInstall.ps1
+ dist/release/pkg/VERIFICATION.txt
+ retention-days: 7
+
+ attest-and-publish:
+ name: Attest And Publish Release Assets
+ needs: build-validate
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Package
- run: ./scripts/package.sh dist "${GITHUB_REF_NAME}"
- - name: Upload tarball
- uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8ad5bc06adad52 # v6.0.0
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.tag }}
+ persist-credentials: false
+
+ - name: Download release dist
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
- files: dist/get-bashed-${{ github.ref_name }}.tar.gz
+ name: release-dist
+ path: dist/release
+
+ - name: Attest release artifacts
+ uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
+ with:
+ subject-path: |
+ dist/release/*.tar.gz
+ dist/release/*.zip
+ dist/release/checksums.txt
+
+ - name: Upload assets to draft release and publish
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+ RAW_TAG: ${{ inputs.tag }}
+ GH_REPO: ${{ github.repository }}
+ PUBLISH_RELEASE: ${{ inputs.publish_release && 'true' || 'false' }}
+ run: |
+ bash scripts/publish_draft_release.sh "${RAW_TAG}" dist/release "${GH_REPO}" "${PUBLISH_RELEASE}"
+
+ release-surface-verify:
+ name: Verify Published Release Surface
+ needs: attest-and-publish
+ if: ${{ inputs.publish_release }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.tag }}
+ persist-credentials: false
+
+ - name: Verify published assets, checksum, attestation, and smoke path
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+ GH_REPO: ${{ github.repository }}
+ RAW_TAG: ${{ inputs.tag }}
+ OWNER: ${{ github.repository_owner }}
+ run: |
+ bash scripts/verify_published_release.sh "${RAW_TAG}" "${GH_REPO}" "${OWNER}"
+
+ publish-packages:
+ name: Publish To jbcom/pkgs
+ needs:
+ - build-validate
+ - attest-and-publish
+ - release-surface-verify
+ if: ${{ inputs.publish_release }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.tag }}
+ persist-credentials: false
+
+ - name: Extract version
+ id: ver
+ shell: bash
+ env:
+ RAW_TAG: ${{ inputs.tag }}
+ run: echo "version=${RAW_TAG#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Download validated manifests
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: package-manifests
+ path: out
+
+ - name: Open PR against jbcom/pkgs
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }}
+ run: |
+ bash scripts/publish_pkg_pr.sh '${{ steps.ver.outputs.version }}' out
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 0000000..a32257a
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,42 @@
+name: OpenSSF Scorecard
+
+on:
+ push:
+ branches: [main]
+ schedule:
+ - cron: "0 6 * * 1"
+
+permissions: {}
+
+jobs:
+ analysis:
+ name: Scorecard Analysis
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ security-events: write
+ id-token: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard
+ uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload SARIF artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: scorecard-results
+ path: results.sarif
+ retention-days: 14
+
+ - name: Upload SARIF to Code Scanning
+ uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ with:
+ sarif_file: results.sarif
diff --git a/.gitignore b/.gitignore
index e05b443..9895999 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ tests/lib/
docs/_build/
.tox/
__pycache__/
+.DS_Store
+dist/
diff --git a/AGENTS.md b/AGENTS.md
index e0bcc72..9159d7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,151 +1,62 @@
---
title: AGENTS.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
# get-bashed — Extended AI Protocols
-This file extends CLAUDE.md with architecture detail, patterns, and operational protocols for AI agents working in this repository.
+This file extends `CLAUDE.md` with repository-specific architecture and operational expectations for agents.
-## Architecture Overview
+## Architecture overview
-get-bashed has two distinct layers: the installer and the runtime.
+get-bashed has two layers:
-### Installer Layer
+### Installer layer
-`install.sh` is a POSIX-only bootstrap that locates or installs bash, then re-execs `install.bash`. `install.bash` is the full installer written in Bash 4+. It:
+- `install.sh` is POSIX `sh` only.
+- It locates or installs Bash 4+ and then execs `install.bash`.
+- `docs/public/install.sh` is the release-surface bootstrap served from the docs site root; it downloads a published bundle and then execs the bundled `install.sh`.
+- `install.bash` is a small orchestrator that sources `installlib/*.sh` for argument parsing, profile/feature resolution, interactive UI, managed-file sync, config generation, and installer execution.
-1. Parses CLI arguments for profiles, features, and installer lists.
-2. Applies profiles (which set feature flag defaults).
-3. Applies feature flag overrides.
-4. Copies dotfiles and `bashrc.d/` to `PREFIX` (default `~/.get-bashed`).
-5. Writes a generated config file `get-bashedrc.sh` encoding the resolved flags.
-6. Optionally links dotfiles from `$HOME` to `~/.get-bashed` with backup.
-7. Runs selected tool installers in dependency order.
+### Runtime layer
-### Runtime Layer
+- `bashrc` is the interactive entrypoint.
+- `bash_profile` is the login entrypoint and delegates to `bashrc`.
+- `bashrc.d/` contains ordered modules. Local secrets live in `~/.get-bashed/secrets.d`.
-`bashrc` is sourced by the user's `~/.bashrc`. It reads `get-bashedrc.sh` (the generated config), then sources all files in `bashrc.d/` matching `[0-9][0-9]-*.sh` in sorted order.
+### Installer helpers
-`bash_profile` handles login shells: initializes Homebrew shellenv, then delegates to `bashrc`.
+- `installers/tools.sh` is the tool registry.
+- `installers/sources.sh` is the shared pinned manifest for git/curl fallbacks, BATS helper refs, and built-in `asdf` runtime defaults.
+- `installers/_helpers.sh` sources `installers/lib/*.sh`, which contain the helper implementations.
+- Release packaging lives under `scripts/build_release_artifact.sh`, `scripts/release_validate.sh`, `scripts/publish_draft_release.sh`, `scripts/generate_pkg_manifests.sh`, and `scripts/publish_pkg_pr.sh`.
+- Branch-protection verification lives in `scripts/verify_branch_protection.sh`.
+- Supply-chain verification lives in `scripts/supply_chain_verify.sh`.
-### Tool Registry
+## Product contract
-`installers/tools.sh` is the single source of truth for all installable tools. Each tool is declared with:
+- The docs are the contract. When behavior and docs drift, prefer fixing behavior unless the current behavior is intentionally safer.
+- `--dry-run` is zero-write.
+- `--force` may remove stale repo-managed assets, but must never delete unknown user-owned files under the managed prefix.
+- `doppler_env` is explicit integration only. Startup never auto-fetches Doppler secrets.
+- `asdf` must work for both Homebrew and git installs.
-- `tool_register id desc deps platforms methods`
-- `tool_pkgs id brew apt dnf yum pacman` (package names per manager)
-- `tool_git id url` / `tool_curl id url cmd` (alternative sources)
-- `tool_handler id fn` (custom installation function)
-- `tool_opt_deps id "FLAG:dep,..."` (optional deps gated by feature flags)
-
-`installers/_helpers.sh` contains platform detection helpers and all custom handler functions (`install_asdf`, `install_vimrc`, `install_shdoc`, `install_actionlint`, etc.).
-
-### Config System
-
-The installer writes `~/.get-bashed/get-bashedrc.sh` encoding:
-
-| Variable | Meaning |
-|---|---|
-| `GET_BASHED_GNU` | Prefer GNU tools over BSD on macOS |
-| `GET_BASHED_BUILD_FLAGS` | Export Homebrew build flags (CPPFLAGS, LDFLAGS, etc.) |
-| `GET_BASHED_AUTO_TOOLS` | Auto-install optional CLIs |
-| `GET_BASHED_SSH_AGENT` | Auto-start ssh-agent |
-| `GET_BASHED_USE_DOPPLER` | Enable Doppler env injection |
-| `GET_BASHED_USE_BASH_IT` | Enable bash-it framework |
-| `GET_BASHED_GIT_SIGNING` | Enable GPG git signing |
-| `GET_BASHED_VIMRC_MODE` | `awesome` or `basic` vimrc flavor |
-
-Runtime modules read these flags and conditionally enable behavior.
-
-### Profiles
-
-Profiles live in `profiles/*.env`. Each defines `FEATURES` and `INSTALLS` values. Built-in profiles:
-
-- `minimal`: all flags off, no tools.
-- `dev`: GNU tools, build flags, auto tools. Installs dev CLI bundle.
-- `ops`: dev plus SSH agent, Doppler, kubectl, helm, stern, terraform, awscli.
-
-CLI `--features` and `--install` always override profile defaults.
-
-### Module Load Order
-
-| Range | Purpose |
-|---|---|
-| `00-` | Shell options, history, editor |
-| `10-` | Shared helper functions |
-| `20-` | PATH construction |
-| `30-` | Build flags (conditional on `GET_BASHED_BUILD_FLAGS`) |
-| `40-` | Shell completions |
-| `50-` | Tool init (starship, direnv, cargo) |
-| `60-` | asdf version manager activation |
-| `65-` | Optional CLI tools |
-| `66-` | Doppler env integration |
-| `70-` | Aliases and env vars; bash-it init |
-| `80-` | Extended aliases |
-| `90-` | Shell functions |
-| `95-` | SSH agent management |
-| `99-` | Secrets sourcing from `secrets.d/` |
-
-## Key Design Constraints
-
-- `install.sh` must remain POSIX sh-compatible (no bashisms).
-- `install.bash` requires Bash 4+ (enforced at runtime with `BASH_VERSINFO` check).
-- All `bashrc.d/` modules must tolerate being sourced multiple times (idempotent).
-- No module may hard-code a user's home directory or username.
-- Tool installers prefer package managers over raw curl/git when available.
-- Optional dependencies are gated by feature flags, not hard-wired.
-
-## Data Flow
-
-```
-User runs install.sh
- └─> install.bash resolves profiles + features
- └─> copies bashrc.d/, dotfiles to ~/.get-bashed/
- └─> writes ~/.get-bashed/get-bashedrc.sh
- └─> runs tool installers (dependency order)
- └─> optionally symlinks ~/.bashrc -> ~/.get-bashed/bashrc
-
-Shell startup
- └─> ~/.bashrc sources ~/.get-bashed/bashrc
- └─> sources get-bashedrc.sh (config)
- └─> sources bash_aliases
- └─> iterates bashrc.d/[0-9][0-9]-*.sh in order
- └─> each module reads GET_BASHED_* flags as needed
-```
-
-## Docs Pipeline
-
-`scripts/gen-docs.sh` uses shdoc to generate `docs/INSTALLER.md`, `docs/INSTALLERS_HELPERS.md`, `docs/INSTALLERS.md`, and `docs/MODULES.md` from shell script annotations. It then regenerates `docs/INDEX.md`.
-
-Run after any change to installer or module functions.
-
-## CI Workflows
+## Workflow map
| Workflow | Trigger | Purpose |
|---|---|---|
-| `ci.yml` | PR + push main | Lint (pre-commit), BATS tests, install verification |
-| `docs.yml` | PR + push main | Build and publish docs to GitHub Pages |
-| `pr-title.yml` | PR | Enforce Conventional Commits title format |
-| `release-please.yml` | push main | Auto-manage release PRs and CHANGELOG |
-| `release.yml` | version tag | Build release artifacts |
-| `autofix.yml` | PR | Auto-apply fixable lint issues |
-| `dependabot-automerge.yml` | Dependabot PR | Auto-merge minor/patch dependency updates |
-
-## Testing Approach
-
-Tests use BATS with pinned helper libraries (bats-support, bats-assert, bats-file). Each test runs the installer in an isolated temp `HOME` directory to prevent side effects on the host machine.
-
-`scripts/test-setup.sh` fetches helper libraries at pinned SHAs. Do not change these without auditing the new commits.
-
-## Security Surfaces
-
-Changes to any of the following require extra scrutiny:
-
-- `install.sh`, `install.bash`, `installers/` — arbitrary code execution during install
-- `bashrc.d/99-secrets.sh` — sources everything in `secrets.d/`
-- PATH construction (`bashrc.d/20-path.sh`) — ordering matters
-- Any `curl`/`git` download — must be from trusted sources
-
-See `SECURITY.md` for the full threat model and reporting guidance.
+| `ci.yml` | PR + push main | Ubuntu/macOS matrix quality job plus dedicated Ubuntu-under-WSL validation on `windows-2025` |
+| `codeql.yml` | PR + push main + weekly schedule | Repo-owned advanced CodeQL analysis for `actions` and `python` |
+| `cd.yml` | push main | Release Please, draft-first release publication, and docs build/GitHub Pages deploy |
+| `release.yml` | manual dispatch | Manual recovery path for the checked-in draft-first release pipeline |
+| `scorecard.yml` | push main + weekly schedule | Run OpenSSF Scorecard with the isolated permissions it needs for published results |
+| `automerge.yml` | Dependabot and labeled PR events | Auto-merge approved dependency bumps |
+
+## Agent rules
+
+- Keep `install.sh` POSIX compatible.
+- Keep every runtime module idempotent.
+- Do not add secret-provider auto-sourcing on shell startup.
+- Prefer updating generated docs through `scripts/gen-docs.sh` rather than hand-editing generated files.
+- Treat installer, PATH, secrets, and external download changes as security-sensitive.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 25abc4d..59e57a6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,14 +36,13 @@ pre-commit run --all-files
## Tests
```bash
-./scripts/test-setup.sh
-bats tests
+make test
```
## Docs
```bash
-./scripts/gen-docs.sh
+make docs
```
## CI
@@ -75,7 +74,7 @@ CI bootstraps tools into `GET_BASHED_HOME` via `scripts/ci-setup.sh`.
1. Register tools in `installers/tools.sh`.
2. Use `dependencies` and `optional_dependencies` to express ordering.
3. Avoid unpinned downloads; prefer package managers when possible.
-4. If a new tool needs a custom handler, add it to `installers/_helpers.sh`.
+4. If a new tool needs a custom handler, expose it via `installers/_helpers.sh` and implement it under `installers/lib/`.
## Docs Expectations
@@ -92,7 +91,7 @@ CI bootstraps tools into `GET_BASHED_HOME` via `scripts/ci-setup.sh`.
## Conventional Commits
-PR titles must follow Conventional Commits, for example `feat: add installer`.
+Conventional Commits are expected for commits and preferred for PR titles, for example `feat: add installer`.
## Security
diff --git a/Makefile b/Makefile
index 06e6707..b405a8a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,53 @@
# Docs and lint targets for get-bashed.
-.PHONY: docs lint test
+PATH := /opt/homebrew/bin:/usr/local/bin:$(PATH)
+VERSION ?= $(shell git describe --tags --always --dirty | sed 's/^v//')
+TAG ?= v$(VERSION)
+.PHONY: docs docs-check lint test ci verify-security verify-branch-protection verify-immutable-release-governance reconcile-codeql-governance reconcile-immutable-release-governance package-release smoke-release release-validate verify-published-release
docs:
- ./scripts/gen-docs.sh
- tox -e docs
+ bash -c '. ./scripts/ci-setup.sh "shdoc,uv" && PATH="$$GET_BASHED_HOME/bin:$$PATH" ./scripts/gen-docs.sh && ./scripts/validate-docs.sh && uvx tox -e docs'
+
+docs-check:
+ bash -c '. ./scripts/ci-setup.sh "shdoc,uv" && PATH="$$GET_BASHED_HOME/bin:$$PATH" ./scripts/gen-docs.sh && ./scripts/validate-docs.sh && uvx tox -e docs,docs-linkcheck'
lint:
- pre-commit run --all-files
+ ./scripts/pre-commit-ci.sh
test:
- ./scripts/test-setup.sh
- bats tests
+ bash -c '. ./scripts/ci-setup.sh "bats" && ./scripts/test-setup.sh && bats tests && ./scripts/verify-install.sh'
+
+ci:
+ $(MAKE) lint
+ $(MAKE) test
+ $(MAKE) docs-check
+ $(MAKE) verify-security
+
+verify-security:
+ bash ./scripts/supply_chain_verify.sh
+
+verify-branch-protection:
+ bash ./scripts/verify_branch_protection.sh
+
+verify-immutable-release-governance:
+ bash ./scripts/verify_immutable_release_governance.sh
+
+reconcile-codeql-governance:
+ bash ./scripts/reconcile_codeql_governance.sh
+
+reconcile-immutable-release-governance:
+ bash ./scripts/reconcile_immutable_release_governance.sh
+
+package-release:
+ rm -rf dist/release
+ mkdir -p dist/release
+ bash ./scripts/build_release_artifact.sh "$(VERSION)" dist/release
+
+smoke-release: package-release
+ bash ./scripts/smoke_test_release_artifact.sh "$(VERSION)" dist/release/get-bashed-$(VERSION)-unix.tar.gz
+ bash ./scripts/smoke_test_release_artifact.sh "$(VERSION)" dist/release/get-bashed-$(VERSION)-windows.zip
+
+release-validate: package-release
+ bash ./scripts/release_validate.sh "$(VERSION)" dist/release
+
+verify-published-release:
+ bash ./scripts/verify_published_release.sh "$(TAG)" "jbcom/get-bashed" "jbcom"
diff --git a/README.md b/README.md
index 9bcfc87..029f9fc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
---
title: README.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
@@ -11,59 +11,55 @@ status: current
[](https://github.com/jbcom/get-bashed/actions/workflows/ci.yml)
-[](https://github.com/jbcom/get-bashed/actions/workflows/docs.yml)
+[](https://github.com/jbcom/get-bashed/actions/workflows/cd.yml)
[](https://github.com/jbcom/get-bashed/releases)
[](LICENSE)
-A modular, portable Bash environment you can install on any machine. get-bashed gives you clean shell defaults, ordered runtime modules, a centralized tool installer, and reproducible configuration — without touching anything you do not explicitly ask it to touch.
+get-bashed is a modular Bash environment for macOS, Linux, and WSL. It installs a managed Bash profile under `~/.get-bashed`, wires shell startup predictably, and keeps installer choices reproducible through a generated config file. CI validates the repo on Ubuntu, macOS, and Ubuntu under WSL on `windows-2025`.
+A dedicated `scorecard.yml` workflow tracks repository supply-chain posture separately from the Pages and release workflows, a repo-owned `codeql.yml` workflow carries advanced CodeQL scanning for `actions` and `python`, and every checked-in workflow now starts from explicit top-level `permissions: {}` before opting into narrower job-level scopes.
-## ⚠️ Upgrading & Breaking Changes
-
-**BREAKING CHANGE:** As of the latest release, get-bashed consolidates all runtime modules, local secrets, and dotfiles into a single managed prefix (default: `~/.get-bashed/`).
-
-If you are upgrading from a legacy installation (where files were stored in `~/.bashrc.d` and `~/.secrets.d`), the installer will attempt to automatically migrate your custom scripts and secrets into the new managed prefix.
-
-**Before upgrading, it is highly recommended to back up your custom modules:**
-```bash
-cp -r ~/.bashrc.d ~/bashrc.d.backup 2>/dev/null || true
-cp -r ~/.secrets.d ~/secrets.d.backup 2>/dev/null || true
-```
-
-To upgrade your environment, simply re-run the installer over your existing setup:
-```bash
-./install.sh --auto
-```
+The public docs site is also the release installer surface: it hosts `/install.sh`, documents the published bundles, and tracks the generated Homebrew, Scoop, and Chocolatey manifests that are pushed into `jbcom/pkgs`.
## What it is
-- Ordered `bashrc.d/` modules loaded by `bashrc` at shell startup.
-- A generated config file (`get-bashedrc.sh`) that encodes your feature choices.
-- A dependency-aware tool installer driven by a single registry (`installers/tools.sh`).
-- Profile presets (`minimal`, `dev`, `ops`) with per-feature overrides.
-- Optional dotfile symlinking from `$HOME` into `~/.get-bashed` with automatic backups.
-- Cross-platform: macOS, Linux, WSL.
+- Ordered `bashrc.d/` runtime modules loaded by `bashrc`.
+- A generated config file, `get-bashedrc.sh`, that records feature choices.
+- A centralized tool registry with dependency-aware installs.
+- A managed install prefix that can be sourced or symlinked into `$HOME`.
+- A Bash-first alternative to larger dotfile frameworks, aimed at workstation portability.
## What it is not
-- A replacement for your existing dotfile manager (it can coexist).
-- A framework that auto-sources secrets or alters behavior without explicit opt-in.
-- Opinionated about your prompt or editor — both are optional and configurable.
+- A replacement for a general-purpose dotfile manager.
+- A framework that auto-loads secrets or hidden providers at startup.
+- A shell-agnostic project. This repo targets Bash, not zsh or fish.
## Quick start
```bash
-curl -fsSL -o install.sh https://raw.githubusercontent.com/jbcom/get-bashed/main/install.sh
-# Review install.sh before running
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh
+```
+
+That installer resolves the latest GitHub Release, downloads `get-bashed--unix.tar.gz`, verifies `checksums.txt`, extracts the bundled tree, and then execs the bundled `install.sh`.
+It accepts either `--version v0.1.0` or `--version 0.1.0`.
+Internally, the docs-site bootstrap supports either `curl` or `wget` for release downloads.
+On Windows, use WSL, Scoop, Chocolatey, or the release bundle wrappers instead of piping the docs-site installer through Git Bash or MSYS shells.
+
+If you are working from a checkout instead of a published release, the source-tree bootstrap remains:
+
+```bash
sh install.sh
```
+The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision before execing `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer first and then installs Bash through Homebrew.
+
To symlink shell dotfiles and set git identity:
```bash
sh install.sh --link-dotfiles --name "Jane Doe" --email "jane@example.com"
```
-To install with the `dev` profile (GNU tools, build flags, dev CLI bundle):
+To install with the `dev` profile:
```bash
sh install.sh --profiles dev --link-dotfiles --name "Jane Doe" --email "jane@example.com"
@@ -71,37 +67,29 @@ sh install.sh --profiles dev --link-dotfiles --name "Jane Doe" --email "jane@exa
## Profiles
-Profiles set feature flag defaults and an installer list. CLI flags always override profile defaults.
+Profiles set feature defaults and default installer bundles. CLI flags always win.
| Profile | Features | Use case |
|---|---|---|
-| `minimal` | all off | Minimal defaults, no tool installs |
+| `minimal` | all off | Minimal Bash profile with no tool installs |
| `dev` | GNU tools, build flags, auto tools | Developer workstation |
-| `ops` | dev + SSH agent, Doppler | Ops/platform workstation |
-
-```bash
-sh install.sh --profiles ops
-```
+| `ops` | dev + SSH agent + explicit Doppler support | Platform / ops workstation |
## Features
-Enable or disable individual behaviors with `--features`. Prefix `no-` to disable.
+Enable or disable behaviors with `--features`. Prefix `no-` to disable.
| Feature | Description |
|---|---|
-| `gnu_over_bsd` | Prefer GNU coreutils/sed/tar over BSD equivalents on macOS |
-| `build_flags` | Export Homebrew build paths (CPPFLAGS, LDFLAGS, PKG_CONFIG_PATH) |
-| `auto_tools` | Auto-install optional CLI tools at shell startup |
-| `ssh_agent` | Auto-start and reuse `ssh-agent` |
-| `doppler_env` | Enable Doppler env integration (does not auto-source on startup) |
+| `gnu_over_bsd` | Prefer GNU coreutils/sed/tar over BSD tools on macOS |
+| `build_flags` | Export Homebrew build paths for local source builds |
+| `auto_tools` | Run the optional pinned CLI bootstrap on shell startup |
+| `ssh_agent` | Start and reuse `ssh-agent` |
+| `doppler_env` | Enable explicit Doppler integration via `doppler_shell` |
| `bash_it` | Enable the bash-it framework if installed |
-| `git_signing` | Install gnupg and enable GPG git signing |
-| `dev_tools` | Bundle: rg, fd, bat, fzf, jq, yq, tree, direnv, starship, nodejs, python, bash |
-| `ops_tools` | Bundle: gh, git-lfs, terraform, awscli, kubectl, helm, stern, doppler, nodejs, python, java, bash |
-
-```bash
-sh install.sh --profiles minimal --features gnu_over_bsd,ssh_agent
-```
+| `git_signing` | Install gnupg and wire git signing support |
+| `dev_tools` | Bundle: rg, fd, bat, eza, fzf, jq, yq, tree, direnv, starship, nodejs, python, bash |
+| `ops_tools` | Bundle: gh, git-lfs, terraform, awscli, kubectl, helm, stern, doppler, eza, nodejs, python, java, bash |
## Tool installer
@@ -111,9 +99,8 @@ Install any combination of tools from the registry:
sh install.sh --install brew,asdf,rg,fd,bat,fzf,jq
```
-The installer resolves dependencies in order and uses the best available method (brew, apt, dnf, yum, pacman, pipx, git, curl).
-
-Available tools: `brew`, `asdf`, `bash`, `bash_it`, `vimrc`, `shdoc`, `dialog`, `pipx`, `pre_commit`, `bashate`, `shellcheck`, `actionlint`, `bats`, `curl`, `wget`, `gnupg`, `gnu_tools`, `git`, `git_lfs`, `gh`, `direnv`, `starship`, `rg`, `fd`, `bat`, `fzf`, `jq`, `yq`, `tree`, `nodejs`, `python`, `java`, `terraform`, `awscli`, `kubectl`, `helm`, `stern`, `doppler`.
+The registry prefers package managers, falls back to pinned git refs when needed, realigns managed git checkouts to the pinned refs on rerun, uses pinned pip/pipx package specs where those fallbacks are required, and uses a pinned, checksum-verified actionlint fallback download.
+When `asdf` is used for `nodejs`, `python`, or `java`, the installer applies repo-pinned default versions from `installers/sources.sh` instead of resolving `latest` at install time.
Inspect without installing:
@@ -125,6 +112,36 @@ sh install.sh --list-installers
sh install.sh --dry-run --install rg,fd,bat
```
+## Releases and package managers
+
+Published releases ship:
+
+- `get-bashed--unix.tar.gz`
+- `get-bashed--windows.zip`
+- `checksums.txt`
+
+Release packaging is driven by checked-in scripts:
+
+- `scripts/build_release_artifact.sh`
+- `scripts/smoke_test_release_artifact.sh`
+- `scripts/release_validate.sh`
+- `scripts/publish_draft_release.sh`
+- `scripts/generate_pkg_manifests.sh`
+- `scripts/verify_published_release.sh`
+- `scripts/publish_pkg_pr.sh`
+
+`release-please` is configured for draft-first releases with eager tag creation, so the tag exists before publication and GitHub can still compute the next changelog correctly.
+`cd.yml` now creates the draft release, validates and attests the bundles, uploads the assets to the draft, publishes it, verifies the published surface, and then opens the `jbcom/pkgs` PR.
+`release.yml` is the manual recovery path for rerunning that same checked-in release pipeline against an existing draft tag.
+The docs installer latest-resolution path and the package-PR publication script are both exercised locally in the test suite rather than being left as GitHub-only assumptions.
+When available, the release automation uses `CI_GITHUB_TOKEN` instead of the default workflow token so release-please PRs and release-side automation can trigger the rest of the repo’s GitHub Actions surface.
+
+Package-manager install paths:
+
+- `brew tap jbcom/pkgs && brew install get-bashed`
+- `scoop bucket add jbcom https://github.com/jbcom/pkgs && scoop install get-bashed`
+- `choco install get-bashed`
+
## Installer options
| Flag | Description |
@@ -134,65 +151,70 @@ sh install.sh --dry-run --install rg,fd,bat
| `--features LIST` | Comma list of features (supports `no-` prefix) |
| `--install LIST` | Comma list of tools to install |
| `--link-dotfiles` | Symlink dotfiles from `$HOME` to `~/.get-bashed` |
-| `--name NAME` | Set `user.name` in the installed gitconfig |
-| `--email EMAIL` | Set `user.email` in the installed gitconfig |
+| `--name NAME` | Set `user.name` in the managed gitconfig |
+| `--email EMAIL` | Set `user.email` in the managed gitconfig |
| `--vimrc-mode MODE` | `awesome` (default) or `basic` vimrc flavor |
| `--with-ui` | Use curses dialog UI if `dialog` is available |
-| `--auto` / `-a` | Disable interactive prompts |
-| `--yes` / `-y` | Auto-accept all prompts |
-| `--force` | Overwrite existing files |
-| `--dry-run` | Print what would happen, make no changes |
+| `--auto` / `-a` | Disable prompts |
+| `--yes` / `-y` | Auto-accept prompts |
+| `--force` | Remove stale repo-managed assets while preserving unknown files |
+| `--dry-run` | Print the resolved plan and installer dependency order without writing files |
-## Runtime modules
+## Runtime and secrets
-Modules in `bashrc.d/` load in numeric order at shell startup. You can add your own by creating a file with the next available prefix. Each module reads `GET_BASHED_*` flags from `get-bashedrc.sh` and enables behavior conditionally.
+Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh`.
-## Secrets
+Doppler is explicit only: enabling `doppler_env` exposes `doppler_shell`, but startup never fetches or injects Doppler secrets automatically.
-Place local secrets in `~/.get-bashed/secrets.d/*.sh`. The installer creates a starter file `secrets.d/00-local.sh`. All files in `secrets.d/` are sourced by `bashrc.d/99-secrets.sh`. The `secrets.d/` directory is git-ignored and never committed.
+`auto_tools` remains opt-in. When enabled, `bashrc.d/65-tools.sh` only installs repo-pinned Node CLI package specs, checks the existing pinned npm package state first, and only runs if `asdf exec npm` is already available.
## Configuration
-After install, `~/.get-bashed/get-bashedrc.sh` holds the resolved config. You can edit it directly to change behavior without re-running the installer.
-
-See `docs/CONFIG.md` for all keys and their defaults.
+After install, `~/.get-bashed/get-bashedrc.sh` holds the resolved runtime config, including:
-## How to run locally
+- `GET_BASHED_GNU`
+- `GET_BASHED_BUILD_FLAGS`
+- `GET_BASHED_AUTO_TOOLS`
+- `GET_BASHED_SSH_AGENT`
+- `GET_BASHED_USE_DOPPLER`
+- `GET_BASHED_USE_BASH_IT`
+- `GET_BASHED_GIT_SIGNING`
+- `GET_BASHED_VIMRC_MODE`
+- optional `GET_BASHED_USER_NAME`
+- optional `GET_BASHED_USER_EMAIL`
-```bash
-# Clone
-git clone https://github.com/jbcom/get-bashed.git ~/.get-bashed-dev
-cd ~/.get-bashed-dev
-
-# Test install into temp prefix
-./install.sh --prefix /tmp/get-bashed-test --auto --force
+See `docs/CONFIG.md` for the full contract.
-# Run the full test suite
-make test
+## Local development
-# Lint
+```bash
make lint
-
-# Regenerate docs
+make test
make docs
+make docs-check
+make verify-security
+make verify-immutable-release-governance
+make package-release
+make smoke-release
+make release-validate
```
-## How to contribute
-
-See `CONTRIBUTING.md` for the full guide. Quick summary:
-
-1. Fork and create a branch.
-2. Follow POSIX-safe conventions in `install.sh`; Bash 4+ elsewhere.
-3. Add shdoc annotations for new public functions.
-4. Run `make lint && make test` before pushing.
-5. PR titles must follow Conventional Commits (`feat:`, `fix:`, `docs:`, etc.).
+`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, including the docs installer fallback downloader path.
+The shared bootstrap disables Homebrew auto-update during these tool installs so local runs and CI stay deterministic.
+`make verify-branch-protection` is the authenticated governance check for the live `main` branch policy; it verifies the exact required CI contexts plus the enforced review, code owner, and branch-safety settings instead of relying on stale prose about workflow files. Once `.github/workflows/codeql.yml` lands on `main`, it also expects `CodeQL (actions)` and `CodeQL (python)` to become required branch checks.
+`make reconcile-codeql-governance` is the one-time post-merge cutover for that transition: it retires GitHub default CodeQL setup and patches the live required status-check list to include the repo-owned CodeQL jobs after `codeql.yml` is on `main`.
+`make verify-immutable-release-governance` is the live check for the release side of that posture: it defers until the draft-first release flow lands on `main`, and after that it expects GitHub immutable releases to be enabled.
+`make reconcile-immutable-release-governance` is the one-time post-merge cutover for that transition: it enables GitHub immutable releases after the checked-in draft-first release flow is present on `main`.
+The repo also expects `.github/dependabot.yml` plus live GitHub automated security fixes, secret scanning, secret scanning push protection, validity checks, and non-provider secret patterns to stay enabled.
-## Security
+To refresh pinned `asdf` defaults after auditing newer runtime releases:
-See `SECURITY.md` for the threat model and vulnerability reporting guidance.
+```bash
+bin/gen_tool_versions
+```
-## Docs
+## Docs and security
-Full docs: `https://jonbogaty.com/get-bashed`
+Published docs are built by `cd.yml`, live under `docs/`, and expose the docs-site installer at `https://jbcom.github.io/get-bashed/install.sh`.
-Generated API docs are in `docs/` and published to GitHub Pages via `docs.yml`.
+Security-sensitive surfaces include the bootstrap, installer helpers, PATH construction, and secrets handling. See `SECURITY.md` for the full policy.
diff --git a/STANDARDS.md b/STANDARDS.md
index dd5cfb4..cb6d65b 100644
--- a/STANDARDS.md
+++ b/STANDARDS.md
@@ -55,7 +55,7 @@ Every module and installer must be safe to run multiple times without unintended
- New installer flows need BATS test coverage.
- Tests run in an isolated temp `HOME`; they must not modify the developer's real home directory.
-- Test helper libraries are pinned to specific commit SHAs in `scripts/test-setup.sh`. Do not update SHAs without auditing the new commits.
+- Test helper libraries are pinned in `installers/sources.sh`. Do not update SHAs without auditing the new commits.
## Secrets Handling
@@ -67,7 +67,7 @@ Every module and installer must be safe to run multiple times without unintended
## Installer Security
- Prefer package managers (brew, apt, dnf, yum, pacman) over raw curl or git downloads.
-- When curl/git downloads are unavoidable, document the source and prefer pinned releases.
+- When curl/git downloads are unavoidable, document the source and prefer pinned releases or pinned refs from `installers/sources.sh`.
- Avoid `eval` with untrusted input. Validate all user-supplied arguments that affect execution paths.
- Do not suppress `set -e` behavior in installers without an explicit inline comment explaining why.
@@ -75,13 +75,12 @@ Every module and installer must be safe to run multiple times without unintended
All PRs must pass before merge:
-- `lint` job: shellcheck, bashate, actionlint, gitleaks (via pre-commit).
-- `tests` job: BATS suite + `scripts/verify-install.sh`.
-- PR title must follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `ci:`).
+- `quality` job: shellcheck, bashate, actionlint, gitleaks (via pre-commit), BATS, install verification, docs generation, and docs build on Ubuntu and macOS.
+- `sonarqube` job: repository scan after the quality matrix succeeds.
## Commit Style
-Conventional Commits format is required. Examples:
+Conventional Commits format is expected. Examples:
```
feat: add shdoc auto-install handler
@@ -90,7 +89,7 @@ docs: update CONFIG.md with all feature flags
refactor: extract pkg_install into _helpers.sh
test: add coverage for --link-dotfiles flow
ci: pin checkout action to v6 SHA
-chore: bump bats-assert to latest pinned SHA
+chore: bump bats-assert pinned SHA
```
## Pull Requests
diff --git a/TOOLS.md b/TOOLS.md
index 2ff279c..b9cac22 100644
--- a/TOOLS.md
+++ b/TOOLS.md
@@ -1,6 +1,6 @@
---
title: TOOLS.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
@@ -19,12 +19,13 @@ It is recommended to install the latest GNU Bash for full compatibility:
- Recommended for: Node.js, Python, Java, and other multi-version runtimes.
- `bashrc.d/60-asdf.sh` activates asdf when present.
+- get-bashed pins default `asdf` runtime versions in `installers/sources.sh` for the built-in `nodejs`, `python`, and `java` installers.
Example:
```bash
asdf plugin add nodejs
-asdf install nodejs lts
-asdf set --home nodejs lts
+asdf install nodejs 24.14.1
+asdf set --home nodejs 24.14.1
```
## Build Flags (macOS)
@@ -54,9 +55,11 @@ This adds `coreutils`, `findutils`, `gnu-sed`, and `gnu-tar` gnubin paths ahead
## Optional CLI Tools
-`bashrc.d/65-tools.sh` includes a helper to install optional CLIs using Node:
-- `@google/gemini-cli`
-- `@sonar/scan`
+`bashrc.d/65-tools.sh` includes an opt-in helper to install optional CLIs using Node:
+- `@google/gemini-cli@0.38.1`
+- `@sonar/scan@4.3.6`
+
+Those package specs are pinned in `installers/sources.sh`, written to `~/.get-bashed/get-bashed-pins.sh` by the installer, and only used when `GET_BASHED_AUTO_TOOLS=1` and `asdf exec npm` is already available. The runtime checks whether the exact pinned package is already installed before attempting a global install.
## bash-it
@@ -70,8 +73,10 @@ Enable components via search:
```bash
get_bashed_component enable git docker
```
-If bash-it isn't available, it will try asdf, brew, or system package managers,
-then fall back to known git/curl sources.
+Install `bash_it` first if you want bash-it search-backed enable/disable behavior:
+```bash
+./install.sh --install bash_it --features bash_it
+```
## Vim (amix/vimrc)
@@ -99,7 +104,7 @@ You can install via the installer:
./install.sh --install doppler
```
-Note: we do not auto-source doppler in shell init. Use `doppler_shell` to start a doppler-enabled subshell.
+Note: startup does not auto-fetch or inject Doppler secrets. Use `doppler_shell` to start a doppler-enabled subshell.
## Curation Policy
@@ -109,8 +114,8 @@ Note: we do not auto-source doppler in shell init. Use `doppler_shell` to start
## Installer UI
If you pass `--with-ui`, the installer will try to use a curses UI (`dialog`).
-If `dialog` is not present, it will attempt to install it using the system package
-manager (Homebrew, apt, dnf, yum). Otherwise it falls back to plain prompts.
+If `dialog` is not present, it will attempt to install it using the detected
+package manager. Otherwise it falls back to plain prompts.
## Listing and Dry Run
@@ -119,7 +124,7 @@ manager (Homebrew, apt, dnf, yum). Otherwise it falls back to plain prompts.
- `./install.sh --list-profiles` shows available profiles.
- `./install.sh --list-features` shows available features.
- `./install.sh --list-installers` shows the installer catalog.
-- `./install.sh --dry-run --install ` shows what would be installed.
+- `./install.sh --dry-run --install ` shows what would be installed without writing files.
- `./install.sh --link-dotfiles` backs up and symlinks shell dotfiles to `~/.get-bashed`.
- `./install.sh --name "Full Name" --email "me@example.com"` sets git identity.
@@ -146,7 +151,20 @@ Use `--features` with comma-separated values:
## Language Installers
Installers exist for `nodejs`, `python`, and `java`. If `asdf` is installed, they
-will use it. Otherwise they fall back to the system package manager.
+use repo-pinned default versions. Otherwise they fall back to the system package manager.
+
+## Pin Maintenance
+
+The pinned git refs, BATS helper SHAs, pip and pipx package specs, optional Node CLI package specs, and default `asdf` runtime versions live in
+`installers/sources.sh`.
+
+The `actionlint` fallback tarball is pinned by version and verified against manifest SHA-256 values before extraction.
+Managed git-backed installs such as `bash_it` and `asdf` are realigned to their pinned refs on rerun instead of trusting an existing clone blindly.
+
+To print manifest-ready `asdf` runtime pins from your current installed versions:
+```bash
+bin/gen_tool_versions
+```
## Profiles
diff --git a/bash_profile b/bash_profile
index 849309c..2229c27 100644
--- a/bash_profile
+++ b/bash_profile
@@ -1,20 +1,46 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC1091
# @file bash_profile
# @brief get-bashed login entrypoint.
# @description
# Loads Homebrew shellenv (if present) then delegates to bashrc.
+# shellcheck disable=SC1091
# Return early if not interactive
[[ $- != *i* ]] && return
+_get_brew_bin() {
+ local candidate
+ local -a candidates=()
+
+ if command -v brew >/dev/null 2>&1; then
+ command -v brew
+ return 0
+ fi
+
+ if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then
+ # shellcheck disable=SC2206
+ candidates=(${GET_BASHED_BREW_BIN_CANDIDATES})
+ else
+ candidates=(
+ "/opt/homebrew/bin/brew"
+ "/usr/local/bin/brew"
+ "/home/linuxbrew/.linuxbrew/bin/brew"
+ )
+ fi
+
+ for candidate in "${candidates[@]}"; do
+ [[ -x "$candidate" ]] || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+
+ return 1
+}
+
# Homebrew shellenv (optional)
-if command -v brew >/dev/null 2>&1; then
- BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")"
- export HOMEBREW_PREFIX="$BREW_PREFIX"
- export HOMEBREW_CELLAR="$BREW_PREFIX/Cellar"
- export HOMEBREW_REPOSITORY="$BREW_PREFIX"
- export PATH="$BREW_PREFIX/bin:$BREW_PREFIX/sbin${PATH+:$PATH}"
- export MANPATH="$BREW_PREFIX/share/man${MANPATH+:$MANPATH}:"
- export INFOPATH="$BREW_PREFIX/share/info:${INFOPATH:-}"
+if BREW_BIN="$(_get_brew_bin)"; then
+ eval "$("$BREW_BIN" shellenv 2>/dev/null)"
fi
# Hand off to interactive rc
diff --git a/bashrc b/bashrc
index c0098c0..b13b4de 100644
--- a/bashrc
+++ b/bashrc
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC1090,SC1091
# @file bashrc
# @brief get-bashed interactive entrypoint.
# @description
diff --git a/bashrc.d/10-helpers.sh b/bashrc.d/10-helpers.sh
index 5015b1b..481ca8e 100644
--- a/bashrc.d/10-helpers.sh
+++ b/bashrc.d/10-helpers.sh
@@ -12,6 +12,44 @@ _path_dedupe() {
awk -v RS=: '!seen[$0]++ { out = out (NR==1?"":":") $0 } END{ print out }' <<<"$PATH"
}
+declare -f get_brew_bin >/dev/null 2>&1 || get_brew_bin() {
+ local candidate
+ local -a candidates=()
+
+ if command -v brew >/dev/null 2>&1; then
+ command -v brew
+ return 0
+ fi
+
+ if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then
+ # shellcheck disable=SC2206
+ candidates=(${GET_BASHED_BREW_BIN_CANDIDATES})
+ else
+ candidates=(
+ "/opt/homebrew/bin/brew"
+ "/usr/local/bin/brew"
+ "/home/linuxbrew/.linuxbrew/bin/brew"
+ )
+ fi
+
+ for candidate in "${candidates[@]}"; do
+ [[ -x "$candidate" ]] || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+
+ return 1
+}
+
+declare -f get_brew_prefix >/dev/null 2>&1 || get_brew_prefix() {
+ local brew_bin prefix
+
+ brew_bin="$(get_brew_bin)" || return 1
+ prefix="$("$brew_bin" --prefix 2>/dev/null || true)"
+ [[ -n "$prefix" && -d "$prefix" ]] || return 1
+ printf '%s\n' "$prefix"
+}
+
declare -f path_add >/dev/null 2>&1 || path_add() {
_path_add_front "$@"
}
diff --git a/bashrc.d/20-path.sh b/bashrc.d/20-path.sh
index d209c38..c510592 100644
--- a/bashrc.d/20-path.sh
+++ b/bashrc.d/20-path.sh
@@ -15,13 +15,13 @@ path_add "$HOME/.cargo/bin"
export GOBIN="${GOBIN:-$HOME/go/bin}"
path_add "$GOBIN"
-# Optional: ASDF shims
+# Optional: ASDF
+path_add "$HOME/.asdf/bin"
path_add "$HOME/.asdf/shims"
# Optional: prefer GNU tools on macOS (requires Homebrew coreutils, gnu-sed, etc.)
# Set GET_BASHED_GNU=1 to enable.
-if [[ "${GET_BASHED_GNU:-0}" == "1" ]] && command -v brew >/dev/null 2>&1; then
- BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")"
+if [[ "${GET_BASHED_GNU:-0}" == "1" ]] && BREW_PREFIX="$(get_brew_prefix)"; then
path_add "$BREW_PREFIX/opt/coreutils/libexec/gnubin"
path_add "$BREW_PREFIX/opt/findutils/libexec/gnubin"
path_add "$BREW_PREFIX/opt/gnu-sed/libexec/gnubin"
diff --git a/bashrc.d/30-buildflags.sh b/bashrc.d/30-buildflags.sh
index e9034f5..824824e 100644
--- a/bashrc.d/30-buildflags.sh
+++ b/bashrc.d/30-buildflags.sh
@@ -6,8 +6,33 @@
# Build flags for compiling language runtimes (optional)
# Enable with GET_BASHED_BUILD_FLAGS=1
-if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && command -v brew >/dev/null 2>&1; then
- BREW_PREFIX="$(dirname "$(dirname "$(command -v brew)")")"
+if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && BREW_PREFIX="$(get_brew_prefix)"; then
+ _join_space_prefix() {
+ local prefix="$1"
+ local current="${2:-}"
+
+ if [[ -n "$prefix" && -n "$current" ]]; then
+ printf '%s %s\n' "$prefix" "$current"
+ elif [[ -n "$prefix" ]]; then
+ printf '%s\n' "$prefix"
+ else
+ printf '%s\n' "$current"
+ fi
+ }
+
+ _join_path_prefix() {
+ local prefix="$1"
+ local current="${2:-}"
+
+ if [[ -n "$prefix" && -n "$current" ]]; then
+ printf '%s:%s\n' "$prefix" "$current"
+ elif [[ -n "$prefix" ]]; then
+ printf '%s\n' "$prefix"
+ else
+ printf '%s\n' "$current"
+ fi
+ }
+
OPENSSL_PREFIX="$BREW_PREFIX/opt/openssl@3"
[[ -d "$OPENSSL_PREFIX" ]] || OPENSSL_PREFIX="$BREW_PREFIX/opt/openssl"
READLINE_PREFIX="$BREW_PREFIX/opt/readline"
@@ -52,10 +77,19 @@ if [[ "${GET_BASHED_BUILD_FLAGS:-0}" == "1" ]] && command -v brew >/dev/null 2>&
_cpath+="${ZSTD_PREFIX}/include:"
}
- export LDFLAGS="${_ldflags}${LDFLAGS:-}"
- export CPPFLAGS="${_cppflags}${CPPFLAGS:-}"
- export PKG_CONFIG_PATH="${_pkgconfig}${PKG_CONFIG_PATH:-}"
- export LIBRARY_PATH="${_libpath}${LIBRARY_PATH:-}"
- export CPATH="${_cpath}${CPATH:-}"
- export PYTHON_CONFIGURE_OPTS="${_pyopts}${PYTHON_CONFIGURE_OPTS:-}"
+ _ldflags="${_ldflags% }"
+ _cppflags="${_cppflags% }"
+ _pkgconfig="${_pkgconfig%:}"
+ _libpath="${_libpath%:}"
+ _cpath="${_cpath%:}"
+ _pyopts="${_pyopts% }"
+
+ LDFLAGS="$(_join_space_prefix "$_ldflags" "${LDFLAGS:-}")"
+ CPPFLAGS="$(_join_space_prefix "$_cppflags" "${CPPFLAGS:-}")"
+ PKG_CONFIG_PATH="$(_join_path_prefix "$_pkgconfig" "${PKG_CONFIG_PATH:-}")"
+ LIBRARY_PATH="$(_join_path_prefix "$_libpath" "${LIBRARY_PATH:-}")"
+ CPATH="$(_join_path_prefix "$_cpath" "${CPATH:-}")"
+ PYTHON_CONFIGURE_OPTS="$(_join_space_prefix "$_pyopts" "${PYTHON_CONFIGURE_OPTS:-}")"
+
+ export LDFLAGS CPPFLAGS PKG_CONFIG_PATH LIBRARY_PATH CPATH PYTHON_CONFIGURE_OPTS
fi
diff --git a/bashrc.d/40-completions.sh b/bashrc.d/40-completions.sh
index ab7eaf8..1e1e6c1 100644
--- a/bashrc.d/40-completions.sh
+++ b/bashrc.d/40-completions.sh
@@ -1,18 +1,16 @@
#!/usr/bin/env bash
-#!/usr/bin/env bash
# shellcheck disable=SC1090,SC1091
# @file 40-completions
# @brief get-bashed module: 40-completions
# @description
# Runtime module loaded by get-bashed in lexicographic order.
-# Bash completion core (Homebrew)
-if command -v brew >/dev/null 2>&1; then
+# Bash completion core (Homebrew/Linuxbrew)
+if [[ -z "${GET_BASHED_BREW_COMPLETIONS_LOADED:-}" ]] && BREW_PREFIX="$(get_brew_prefix)"; then
export BASH_COMPLETION_USER_DIR="$HOME/.local/share/bash-completion"
- if [[ -r "/opt/homebrew/etc/profile.d/bash_completion.sh" ]]; then
- . "/opt/homebrew/etc/profile.d/bash_completion.sh"
- elif [[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]]; then
- . "/usr/local/etc/profile.d/bash_completion.sh"
+ if [[ -r "$BREW_PREFIX/etc/profile.d/bash_completion.sh" ]]; then
+ . "$BREW_PREFIX/etc/profile.d/bash_completion.sh"
+ export GET_BASHED_BREW_COMPLETIONS_LOADED=1
fi
fi
@@ -20,6 +18,7 @@ fi
complete -cf sudo
# asdf completions
-if command -v asdf >/dev/null 2>&1; then
+if [[ -z "${GET_BASHED_ASDF_COMPLETIONS_LOADED:-}" ]] && command -v asdf >/dev/null 2>&1; then
. <(asdf completion bash)
+ export GET_BASHED_ASDF_COMPLETIONS_LOADED=1
fi
diff --git a/bashrc.d/50-tool-init.sh b/bashrc.d/50-tool-init.sh
index 9922774..aa0173d 100644
--- a/bashrc.d/50-tool-init.sh
+++ b/bashrc.d/50-tool-init.sh
@@ -4,9 +4,19 @@
# @description
# Runtime module loaded by get-bashed in lexicographic order.
-# Cargo (idempotent)
-_maybe_source "$HOME/.cargo/env"
+# Cargo
+if [[ -z "${GET_BASHED_CARGO_ENV_LOADED:-}" ]] && [[ -r "$HOME/.cargo/env" ]]; then
+ _maybe_source "$HOME/.cargo/env"
+ export GET_BASHED_CARGO_ENV_LOADED=1
+fi
# Prompt + env managers
-command -v starship >/dev/null 2>&1 && eval "$(starship init bash)"
-command -v direnv >/dev/null 2>&1 && eval "$(direnv hook bash)"
+if command -v starship >/dev/null 2>&1 && [[ -z "${GET_BASHED_STARSHIP_INIT:-}" ]]; then
+ eval "$(starship init bash)"
+ export GET_BASHED_STARSHIP_INIT=1
+fi
+
+if command -v direnv >/dev/null 2>&1 && [[ -z "${GET_BASHED_DIRENV_HOOKED:-}" ]]; then
+ eval "$(direnv hook bash)"
+ export GET_BASHED_DIRENV_HOOKED=1
+fi
diff --git a/bashrc.d/60-asdf.sh b/bashrc.d/60-asdf.sh
index f0ac7e7..b4dd5b4 100644
--- a/bashrc.d/60-asdf.sh
+++ b/bashrc.d/60-asdf.sh
@@ -6,12 +6,10 @@
# Runtime module loaded by get-bashed in lexicographic order.
# asdf: full activation for interactive shells
-if command -v asdf >/dev/null 2>&1; then
- if [[ -r "$HOME/.asdf/asdf.sh" ]]; then
- . "$HOME/.asdf/asdf.sh"
- elif [[ -r "/opt/homebrew/opt/asdf/libexec/asdf.sh" ]]; then
- . "/opt/homebrew/opt/asdf/libexec/asdf.sh"
- elif [[ -r "/usr/local/opt/asdf/libexec/asdf.sh" ]]; then
- . "/usr/local/opt/asdf/libexec/asdf.sh"
- fi
+if [[ -r "$HOME/.asdf/asdf.sh" ]]; then
+ . "$HOME/.asdf/asdf.sh"
+elif BREW_PREFIX="$(get_brew_prefix)" && [[ -r "$BREW_PREFIX/opt/asdf/libexec/asdf.sh" ]]; then
+ . "$BREW_PREFIX/opt/asdf/libexec/asdf.sh"
+elif command -v asdf >/dev/null 2>&1; then
+ :
fi
diff --git a/bashrc.d/65-tools.sh b/bashrc.d/65-tools.sh
index 410ea9f..d0cd6ba 100644
--- a/bashrc.d/65-tools.sh
+++ b/bashrc.d/65-tools.sh
@@ -7,16 +7,45 @@
# Optional CLI tool installer (manual)
# Set GET_BASHED_AUTO_TOOLS=1 to run on shell startup.
+load_auto_tool_pins() {
+ local prefix pins_file
+
+ if [[ -n "${GET_BASHED_GEMINI_CLI_PACKAGE_SPEC:-}" && -n "${GET_BASHED_SONAR_SCAN_PACKAGE_SPEC:-}" ]]; then
+ return 0
+ fi
+
+ prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
+ pins_file="$prefix/get-bashed-pins.sh"
+ [[ -r "$pins_file" ]] || return 0
+
+ # shellcheck disable=SC1090
+ source "$pins_file"
+}
+
+node_package_installed() {
+ local spec="$1"
+ asdf exec npm list -g --depth=0 "$spec" >/dev/null 2>&1
+}
+
+ensure_node_global_package() {
+ local spec="$1"
+
+ node_package_installed "$spec" && return 0
+ asdf exec npm install -g "$spec"
+}
+
install_cli_tools() {
+ local gemini_pkg sonar_pkg
+
command -v asdf >/dev/null 2>&1 || return 0
+ asdf exec npm --version >/dev/null 2>&1 || return 0
- if ! command -v gemini >/dev/null 2>&1; then
- asdf exec npm install -g @google/gemini-cli
- fi
+ load_auto_tool_pins
+ gemini_pkg="${GET_BASHED_GEMINI_CLI_PACKAGE_SPEC:-@google/gemini-cli}"
+ sonar_pkg="${GET_BASHED_SONAR_SCAN_PACKAGE_SPEC:-@sonar/scan}"
- if ! command -v sonar >/dev/null 2>&1; then
- asdf exec npm install -g @sonar/scan
- fi
+ ensure_node_global_package "$gemini_pkg"
+ ensure_node_global_package "$sonar_pkg"
}
if [[ "${GET_BASHED_AUTO_TOOLS:-0}" == "1" ]]; then
diff --git a/bashrc.d/70-bash-it.sh b/bashrc.d/70-bash-it.sh
index c3bce27..c2f8901 100644
--- a/bashrc.d/70-bash-it.sh
+++ b/bashrc.d/70-bash-it.sh
@@ -8,8 +8,11 @@ if [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]]; then
GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}"
BASH_IT="$GET_BASHED_HOME/vendor/bash-it"
if [[ -r "$BASH_IT/bash_it.sh" ]]; then
- # shellcheck disable=SC1090,SC1091
- source "$BASH_IT/bash_it.sh"
+ if [[ -z "${GET_BASHED_BASH_IT_LOADED:-}" ]]; then
+ # shellcheck disable=SC1090,SC1091
+ source "$BASH_IT/bash_it.sh"
+ export GET_BASHED_BASH_IT_LOADED=1
+ fi
get_bashed_component() {
local action="${1:-enable}"
diff --git a/bashrc.d/70-env.sh b/bashrc.d/70-env.sh
index f623829..265b476 100644
--- a/bashrc.d/70-env.sh
+++ b/bashrc.d/70-env.sh
@@ -1,5 +1,4 @@
#!/usr/bin/env bash
-#!/usr/bin/env bash
# @file 70-env
# @brief get-bashed module: 70-env
# @description
diff --git a/bashrc.d/95-ssh-agent.sh b/bashrc.d/95-ssh-agent.sh
index b981809..4327bd6 100644
--- a/bashrc.d/95-ssh-agent.sh
+++ b/bashrc.d/95-ssh-agent.sh
@@ -4,8 +4,10 @@
# @description
# Runtime module loaded by get-bashed in lexicographic order.
-# Start SSH agent in interactive TTYs
-if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then
+# Start SSH agent in interactive TTYs.
+# GET_BASHED_TEST_TTY is a test-only override for non-interactive harnesses.
+if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] &&
+ { [[ -t 1 ]] || [[ "${GET_BASHED_TEST_TTY:-0}" == "1" ]]; }; then
_ssh_agent_usable() {
local sock="$1" rc
[[ -S "$sock" ]] || return 1
@@ -17,7 +19,10 @@ if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then
if [[ -n "${SSH_AUTH_SOCK:-}" ]] && _ssh_agent_usable "$SSH_AUTH_SOCK"; then
:
else
- SSH_AGENT_SOCK="${HOME}/.ssh/agent.sock"
+ SSH_DIR="${HOME}/.ssh"
+ mkdir -p "$SSH_DIR"
+ chmod 700 "$SSH_DIR" 2>/dev/null || true
+ SSH_AGENT_SOCK="${SSH_DIR}/agent.sock"
if _ssh_agent_usable "$SSH_AGENT_SOCK"; then
export SSH_AUTH_SOCK="$SSH_AGENT_SOCK"
else
@@ -28,10 +33,14 @@ if [[ "${GET_BASHED_SSH_AGENT:-0}" == "1" ]] && [[ -t 1 ]]; then
fi
fi
- if [[ -f "$HOME/.ssh/id_rsa" ]]; then
- ssh-add "$HOME/.ssh/id_rsa" 2>/dev/null || true
- fi
- if [[ -f "$HOME/.ssh/id_ed25519" ]]; then
- ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true
+ current_agent_key="${SSH_AUTH_SOCK:-}:${SSH_AGENT_PID:-}"
+ if [[ "${GET_BASHED_SSH_KEYS_ADDED_FOR:-}" != "$current_agent_key" ]]; then
+ if [[ -f "$HOME/.ssh/id_rsa" ]]; then
+ ssh-add "$HOME/.ssh/id_rsa" 2>/dev/null || true
+ fi
+ if [[ -f "$HOME/.ssh/id_ed25519" ]]; then
+ ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true
+ fi
+ export GET_BASHED_SSH_KEYS_ADDED_FOR="$current_agent_key"
fi
fi
diff --git a/bashrc.d/99-secrets.sh b/bashrc.d/99-secrets.sh
index f1835d8..01144fa 100644
--- a/bashrc.d/99-secrets.sh
+++ b/bashrc.d/99-secrets.sh
@@ -9,12 +9,6 @@
GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}"
GET_BASHED_SECRETS_DIR="${GET_BASHED_SECRETS_DIR:-$GET_BASHED_HOME/secrets.d}"
-if [[ "${GET_BASHED_USE_DOPPLER:-0}" == "1" ]] && command -v doppler >/dev/null 2>&1; then
- set -a
- source <(doppler secrets download --no-file --format env)
- set +a
-fi
-
if [[ -d "$GET_BASHED_SECRETS_DIR" ]]; then
for f in "$GET_BASHED_SECRETS_DIR"/*.sh; do
[[ -r "$f" ]] && source "$f"
diff --git a/bin/README.md b/bin/README.md
index 68b09ea..511d6d0 100644
--- a/bin/README.md
+++ b/bin/README.md
@@ -3,4 +3,4 @@
Curated helper scripts intended to be safe, portable, and generally useful.
- `ram_usage`: macOS RAM usage analyzer.
-- `gen_tool_versions`: asdf helper to pin latest installed versions.
+- `gen_tool_versions`: prints manifest-ready `asdf` runtime pins from installed versions.
diff --git a/bin/gen_tool_versions b/bin/gen_tool_versions
index b19a61f..b5a89e6 100755
--- a/bin/gen_tool_versions
+++ b/bin/gen_tool_versions
@@ -1,25 +1,45 @@
#!/usr/bin/env bash
# @file gen_tool_versions
-# @brief Update all asdf plugins to latest versions.
+# @brief Print pinned asdf runtime versions for get-bashed.
+
+# Generate manifest-ready entries for installers/sources.sh using currently
+# installed asdf runtimes. This does not install or update anything.
set -euo pipefail
-plugins="$(asdf plugin list 2>/dev/null || true)"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-for plugin in $plugins; do
- [[ -z "$plugin" ]] && continue
- asdf install "$plugin" latest
-done
+# shellcheck disable=SC1091
+source "$ROOT_DIR/installers/sources.sh"
-for plugin in $plugins; do
- [[ -z "$plugin" ]] && continue
- versions=$(asdf list "$plugin" 2>/dev/null | sed 's/^[[:space:]]*//' | grep -v '^latest$' || true)
- if [[ -z "$versions" ]]; then
- echo "No versions installed for $plugin"
- continue
+installed_version() {
+ local plugin="$1"
+ local version
+
+ version="$(asdf current "$plugin" 2>/dev/null | awk 'NR == 1 { print $2 }')"
+ if [[ -n "$version" && "$version" != "system" ]]; then
+ printf '%s\n' "$version"
+ return 0
fi
- latest_version=$(echo "$versions" | sort -V | tail -n 1)
- echo "Setting $plugin to $latest_version"
- asdf set "$plugin" "$latest_version"
+ asdf list "$plugin" 2>/dev/null | sed 's/^[[:space:]]*//' | awk 'NF { last = $0 } END { print last }'
+}
+
+if ! command -v asdf >/dev/null 2>&1; then
+ echo "asdf is required to inspect installed runtime versions." >&2
+ exit 1
+fi
+
+printf 'declare -Ar GET_BASHED_ASDF_DEFAULT_VERSIONS=(\n'
+for plugin in java nodejs python; do
+ version="$(installed_version "$plugin")"
+ if [[ -z "$version" ]]; then
+ version="${GET_BASHED_ASDF_DEFAULT_VERSIONS[$plugin]:-}"
+ fi
+ if [[ -z "$version" ]]; then
+ echo "No installed or pinned version found for ${plugin}." >&2
+ exit 1
+ fi
+ printf ' ["%s"]="%s"\n' "$plugin" "$version"
done
+printf ')\n'
diff --git a/bin/ram_usage b/bin/ram_usage
index 65fdd29..6c91aed 100755
--- a/bin/ram_usage
+++ b/bin/ram_usage
@@ -1,316 +1,7 @@
#!/usr/bin/env python3
-"""
-macRAM.py - MacOS RAM Usage Analyzer
-A Python script to analyze and display RAM usage on macOS systems.
-"""
-import subprocess
-import re
-import os
-import sys
-from collections import defaultdict
-import math
-from datetime import datetime
+from ram_usage_lib import main
-# ANSI colors for terminal output
-class Colors:
- BLUE = '\033[94m'
- GREEN = '\033[92m'
- YELLOW = '\033[93m'
- RED = '\033[91m'
- BOLD = '\033[1m'
- RESET = '\033[0m'
-
-def get_command_output(command):
- """Run a command and return its output as a string."""
- try:
- result = subprocess.run(command, check=True, capture_output=True, text=True)
- return result.stdout
- except subprocess.CalledProcessError as e:
- print(f"Error running command: {command}")
- print(f"Error message: {e.stderr}")
- return ""
-
-def get_total_ram():
- """Get the total physical RAM in the system."""
- output = get_command_output(["sysctl", "hw.memsize"])
- if output:
- match = re.search(r'hw.memsize: (\d+)', output)
- if match:
- return int(match.group(1))
- return 0
-
-def format_bytes(bytes, precision=2):
- """Format bytes to human-readable format."""
- if bytes == 0:
- return "0 B"
-
- size_names = ["B", "KB", "MB", "GB", "TB"]
- i = int(math.floor(math.log(bytes, 1024)))
- p = math.pow(1024, i)
- s = round(bytes / p, precision)
-
- return f"{s} {size_names[i]}"
-
-def parse_vm_stat():
- """Parse vm_stat output to get memory statistics."""
- output = get_command_output(["vm_stat"])
- if not output:
- return {}
-
- # Extract page size from the first line
- first_line = output.split('\n')[0]
- page_size_match = re.search(r'page size of (\d+) bytes', first_line)
- if not page_size_match:
- return {}
-
- page_size = int(page_size_match.group(1))
-
- # Parse the rest of the output
- memory_stats = {}
-
- # Define the patterns to extract from vm_stat
- patterns = {
- 'free': r'Pages free:\s+(\d+)',
- 'active': r'Pages active:\s+(\d+)',
- 'inactive': r'Pages inactive:\s+(\d+)',
- 'speculative': r'Pages speculative:\s+(\d+)',
- 'wired': r'Pages wired down:\s+(\d+)',
- 'compressed': r'Pages occupied by compressor:\s+(\d+)',
- 'purgeable': r'Pages purgeable:\s+(\d+)',
- }
-
- for key, pattern in patterns.items():
- match = re.search(pattern, output)
- if match:
- # Convert from page count to bytes
- pages = int(match.group(1).replace('.', ''))
- memory_stats[key] = pages * page_size
-
- # Calculate additional metrics
- memory_stats['total'] = get_total_ram()
-
- # Available = free + purgeable + inactive (+ speculative)
- memory_stats['available'] = (
- memory_stats.get('free', 0)
- + memory_stats.get('purgeable', 0)
- + memory_stats.get('inactive', 0)
- + memory_stats.get('speculative', 0)
- )
-
- # Used = total - available
- memory_stats['used'] = memory_stats['total'] - memory_stats['available']
-
- # Used percentage
- if memory_stats['total'] > 0:
- memory_stats['used_percent'] = (memory_stats['used'] / memory_stats['total']) * 100
- else:
- memory_stats['used_percent'] = 0
-
- return memory_stats
-
-def get_process_memory():
- """Get memory usage per process."""
- output = get_command_output(["ps", "-eo", "pid,rss,vsz,user,comm"])
-
- processes = []
- for line in output.strip().split('\n')[1:]: # Skip header
- parts = line.split(None, 4)
- if len(parts) >= 5:
- try:
- pid, rss, vsz, user, command = parts
- processes.append({
- 'pid': int(pid),
- 'rss': int(rss) * 1024, # Convert to bytes
- 'vsz': int(vsz) * 1024, # Convert to bytes
- 'user': user,
- 'command': command
- })
- except (ValueError, IndexError):
- continue
-
- return processes
-
-def group_processes_by_app(processes):
- """Group processes by application and sum their memory usage."""
- app_groups = defaultdict(lambda: {'count': 0, 'memory': 0, 'pids': []})
-
- for process in processes:
- # Extract base app name from command
- command = process['command']
- app_name = os.path.basename(command)
-
- # Normalize app names
- if 'chrome' in command.lower() or 'google chrome' in command.lower():
- app_name = 'Google Chrome'
- elif 'firefox' in command.lower():
- app_name = 'Firefox'
- elif 'safari' in command.lower():
- app_name = 'Safari'
- elif 'slack' in command.lower():
- app_name = 'Slack'
- elif 'vs code' in command.lower() or 'code helper' in command.lower():
- app_name = 'VS Code'
- elif 'cursor' in command.lower():
- app_name = 'Cursor'
- elif 'iterm' in command.lower():
- app_name = 'iTerm'
- elif 'terminal' in command.lower():
- app_name = 'Terminal'
- elif 'finder' in command.lower():
- app_name = 'Finder'
- elif 'kernel' in command.lower():
- app_name = 'Kernel'
- elif 'launchd' in command.lower():
- app_name = 'System (launchd)'
- elif 'windowserver' in command.lower():
- app_name = 'WindowServer'
-
- # Add to the group
- app_groups[app_name]['count'] += 1
- app_groups[app_name]['memory'] += process['rss']
- app_groups[app_name]['pids'].append(process['pid'])
-
- return app_groups
-
-def print_header(memory_stats):
- """Print header with system information."""
- print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
- print(f"{Colors.BLUE}{Colors.BOLD} MacOS RAM Usage Monitor {Colors.RESET}")
- print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
-
- total = format_bytes(memory_stats['total'])
- used = format_bytes(memory_stats['used'])
- available = format_bytes(memory_stats['available'])
-
- print(f"{Colors.GREEN}Total RAM:{Colors.RESET} {total}")
- print(f"{Colors.GREEN}Used RAM:{Colors.RESET} {used} ({memory_stats['used_percent']:.2f}%)")
- print(f"{Colors.GREEN}Available RAM:{Colors.RESET} {available}")
-
- # Memory pressure category
- if memory_stats['used_percent'] < 70:
- print(f"{Colors.GREEN}Memory Pressure: Low{Colors.RESET}")
- elif memory_stats['used_percent'] < 85:
- print(f"{Colors.YELLOW}Memory Pressure: Medium{Colors.RESET}")
- else:
- print(f"{Colors.RED}Memory Pressure: High{Colors.RESET}")
-
- print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
- print()
-
-def print_memory_breakdown(memory_stats):
- """Print detailed memory breakdown."""
- print(f"{Colors.GREEN}Memory Breakdown:{Colors.RESET}")
-
- categories = [
- ('active', "Active", "Apps currently in use"),
- ('wired', "Wired", "System/kernel memory"),
- ('inactive', "Inactive", "Recently used, can be freed"),
- ('compressed', "Compressed", "Compressed to save space"),
- ('free', "Free", "Immediately available"),
- ('purgeable', "Purgeable", "Can be reclaimed if needed")
- ]
-
- for key, name, description in categories:
- if key in memory_stats:
- value = format_bytes(memory_stats[key])
- print(f" {name}: {value} ({description})")
-
- # Calculate unaccounted memory
- accounted = (
- memory_stats.get('active', 0) +
- memory_stats.get('wired', 0) +
- memory_stats.get('inactive', 0) +
- memory_stats.get('compressed', 0) +
- memory_stats.get('free', 0)
- )
-
- unaccounted = memory_stats['total'] - accounted
- if unaccounted > 0:
- print(f" Unaccounted: {format_bytes(unaccounted)} (Memory used by GPU/file cache)")
-
- print()
-
-def print_top_processes(processes, count=10):
- """Print top processes by memory usage."""
- # Sort processes by RSS (Resident Set Size) in descending order
- sorted_processes = sorted(processes, key=lambda p: p['rss'], reverse=True)
- total_ram = get_total_ram()
-
- print(f"{Colors.GREEN}Top {count} Processes by RAM Usage:{Colors.RESET}")
- print(f"{Colors.BLUE}{'PID':<8}{'MEM':<12}{'%MEM':<8}{'USER':<15}{'COMMAND'}{Colors.RESET}")
-
- for proc in sorted_processes[:count]:
- mem = format_bytes(proc['rss'])
- mem_percent = (proc['rss'] / total_ram) * 100 if total_ram else 0
-
- # Color code based on memory percentage
- if mem_percent > 10:
- color = Colors.RED
- elif mem_percent > 5:
- color = Colors.YELLOW
- else:
- color = ''
-
- print(f"{color}{proc['pid']:<8}{mem:<12}{mem_percent:.1f}%{' ':<5}{proc['user']:<15}{proc['command']}{Colors.RESET}")
-
- print()
-
-def print_app_groups(app_groups, total_ram):
- """Print processes grouped by application."""
- print(f"{Colors.GREEN}Memory Usage by Application Group:{Colors.RESET}")
- print(f"{Colors.BLUE}{'APPLICATION':<25}{'PROCESSES':<12}{'MEMORY':<15}{'%TOTAL'}{Colors.RESET}")
-
- # Sort by memory usage
- sorted_apps = sorted(app_groups.items(), key=lambda x: x[1]['memory'], reverse=True)
-
- total_shown_memory = 0
- for app_name, data in sorted_apps[:20]: # Show top 20
- count = data['count']
- memory = data['memory']
- memory_percent = (memory / total_ram) * 100 if total_ram else 0
- total_shown_memory += memory
-
- # Color code based on memory percentage
- if memory_percent > 10:
- color = Colors.RED
- elif memory_percent > 5:
- color = Colors.YELLOW
- else:
- color = ''
-
- print(f"{color}{app_name:<25}{count:<12}{format_bytes(memory):<15}{memory_percent:.1f}%{Colors.RESET}")
-
- print(f"\n{Colors.YELLOW}Total Memory from Top Apps: {format_bytes(total_shown_memory)}{Colors.RESET}")
- print()
-
-def main():
- # Get memory statistics
- memory_stats = parse_vm_stat()
- if not memory_stats:
- print("Error: Could not get memory statistics")
- return 1
-
- # Get process information
- processes = get_process_memory()
- if not processes:
- print("Error: Could not get process information")
- return 1
-
- # Group processes by application
- app_groups = group_processes_by_app(processes)
-
- # Print report
- print_header(memory_stats)
- print_memory_breakdown(memory_stats)
- print_top_processes(processes)
- print_app_groups(app_groups, memory_stats['total'])
-
- # Print timestamp
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- print(f"{Colors.BLUE}Report generated: {now}{Colors.RESET}")
-
- return 0
if __name__ == "__main__":
- sys.exit(main())
+ raise SystemExit(main())
diff --git a/bin/ram_usage_lib.py b/bin/ram_usage_lib.py
new file mode 100644
index 0000000..37a7f9a
--- /dev/null
+++ b/bin/ram_usage_lib.py
@@ -0,0 +1,169 @@
+"""Core data collection for the macOS RAM usage helper."""
+
+from collections import defaultdict
+from datetime import datetime
+import os
+import re
+import subprocess
+
+from ram_usage_report import print_app_groups
+from ram_usage_report import print_header
+from ram_usage_report import print_memory_breakdown
+from ram_usage_report import print_report_timestamp
+from ram_usage_report import print_top_processes
+
+
+def get_command_output(command):
+ """Run a command and return its output as a string."""
+ try:
+ result = subprocess.run(command, check=True, capture_output=True, text=True)
+ return result.stdout
+ except subprocess.CalledProcessError as exc:
+ print(f"Error running command: {command}")
+ print(f"Error message: {exc.stderr}")
+ return ""
+
+
+def get_total_ram():
+ """Get total physical RAM."""
+ output = get_command_output(["sysctl", "hw.memsize"])
+ if output:
+ match = re.search(r"hw.memsize: (\d+)", output)
+ if match:
+ return int(match.group(1))
+ return 0
+
+
+def parse_vm_stat():
+ """Parse vm_stat into a memory summary dictionary."""
+ output = get_command_output(["vm_stat"])
+ if not output:
+ return {}
+
+ first_line = output.split("\n")[0]
+ page_size_match = re.search(r"page size of (\d+) bytes", first_line)
+ if not page_size_match:
+ return {}
+
+ page_size = int(page_size_match.group(1))
+ memory_stats = {}
+ patterns = {
+ "free": r"Pages free:\s+(\d+)",
+ "active": r"Pages active:\s+(\d+)",
+ "inactive": r"Pages inactive:\s+(\d+)",
+ "speculative": r"Pages speculative:\s+(\d+)",
+ "wired": r"Pages wired down:\s+(\d+)",
+ "compressed": r"Pages occupied by compressor:\s+(\d+)",
+ "purgeable": r"Pages purgeable:\s+(\d+)",
+ }
+
+ for key, pattern in patterns.items():
+ match = re.search(pattern, output)
+ if match:
+ pages = int(match.group(1).replace(".", ""))
+ memory_stats[key] = pages * page_size
+
+ memory_stats["total"] = get_total_ram()
+ memory_stats["available"] = (
+ memory_stats.get("free", 0)
+ + memory_stats.get("purgeable", 0)
+ + memory_stats.get("inactive", 0)
+ + memory_stats.get("speculative", 0)
+ )
+ memory_stats["used"] = memory_stats["total"] - memory_stats["available"]
+
+ if memory_stats["total"] > 0:
+ memory_stats["used_percent"] = (memory_stats["used"] / memory_stats["total"]) * 100
+ else:
+ memory_stats["used_percent"] = 0
+
+ return memory_stats
+
+
+def get_process_memory():
+ """Collect process memory usage."""
+ output = get_command_output(["ps", "-eo", "pid,rss,vsz,user,comm"])
+ processes = []
+
+ for line in output.strip().split("\n")[1:]:
+ parts = line.split(None, 4)
+ if len(parts) < 5:
+ continue
+ try:
+ pid, rss, vsz, user, command = parts
+ processes.append(
+ {
+ "pid": int(pid),
+ "rss": int(rss) * 1024,
+ "vsz": int(vsz) * 1024,
+ "user": user,
+ "command": command,
+ }
+ )
+ except (ValueError, IndexError):
+ continue
+
+ return processes
+
+
+def group_processes_by_app(processes):
+ """Group processes by app name."""
+ app_groups = defaultdict(lambda: {"count": 0, "memory": 0, "pids": []})
+
+ for process in processes:
+ command = process["command"]
+ app_name = os.path.basename(command)
+
+ if "chrome" in command.lower() or "google chrome" in command.lower():
+ app_name = "Google Chrome"
+ elif "firefox" in command.lower():
+ app_name = "Firefox"
+ elif "safari" in command.lower():
+ app_name = "Safari"
+ elif "slack" in command.lower():
+ app_name = "Slack"
+ elif "vs code" in command.lower() or "code helper" in command.lower():
+ app_name = "VS Code"
+ elif "cursor" in command.lower():
+ app_name = "Cursor"
+ elif "iterm" in command.lower():
+ app_name = "iTerm"
+ elif "terminal" in command.lower():
+ app_name = "Terminal"
+ elif "finder" in command.lower():
+ app_name = "Finder"
+ elif "kernel" in command.lower():
+ app_name = "Kernel"
+ elif "launchd" in command.lower():
+ app_name = "System (launchd)"
+ elif "windowserver" in command.lower():
+ app_name = "WindowServer"
+
+ app_groups[app_name]["count"] += 1
+ app_groups[app_name]["memory"] += process["rss"]
+ app_groups[app_name]["pids"].append(process["pid"])
+
+ return app_groups
+
+
+def main():
+ """Collect memory information and print the report."""
+ memory_stats = parse_vm_stat()
+ if not memory_stats:
+ print("Error: Could not get memory statistics")
+ return 1
+
+ processes = get_process_memory()
+ if not processes:
+ print("Error: Could not get process information")
+ return 1
+
+ app_groups = group_processes_by_app(processes)
+
+ print_header(memory_stats)
+ print_memory_breakdown(memory_stats)
+ print_top_processes(processes, memory_stats["total"])
+ print_app_groups(app_groups, memory_stats["total"])
+ print_report_timestamp(datetime.now())
+
+ return 0
diff --git a/bin/ram_usage_report.py b/bin/ram_usage_report.py
new file mode 100644
index 0000000..0420711
--- /dev/null
+++ b/bin/ram_usage_report.py
@@ -0,0 +1,133 @@
+"""Formatting helpers for the macOS RAM usage helper."""
+
+import math
+
+
+class Colors:
+ BLUE = "\033[94m"
+ GREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ RED = "\033[91m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def format_bytes(byte_count, precision=2):
+ """Format bytes to a human-readable value."""
+ if byte_count == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB"]
+ index = int(math.floor(math.log(byte_count, 1024)))
+ power = math.pow(1024, index)
+ scaled = round(byte_count / power, precision)
+
+ return f"{scaled} {size_names[index]}"
+
+
+def print_header(memory_stats):
+ """Print report header and summary."""
+ print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BLUE}{Colors.BOLD} MacOS RAM Usage Monitor {Colors.RESET}")
+ print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
+
+ total = format_bytes(memory_stats["total"])
+ used = format_bytes(memory_stats["used"])
+ available = format_bytes(memory_stats["available"])
+
+ print(f"{Colors.GREEN}Total RAM:{Colors.RESET} {total}")
+ print(f"{Colors.GREEN}Used RAM:{Colors.RESET} {used} ({memory_stats['used_percent']:.2f}%)")
+ print(f"{Colors.GREEN}Available RAM:{Colors.RESET} {available}")
+
+ if memory_stats["used_percent"] < 70:
+ print(f"{Colors.GREEN}Memory Pressure: Low{Colors.RESET}")
+ elif memory_stats["used_percent"] < 85:
+ print(f"{Colors.YELLOW}Memory Pressure: Medium{Colors.RESET}")
+ else:
+ print(f"{Colors.RED}Memory Pressure: High{Colors.RESET}")
+
+ print(f"{Colors.BLUE}{'=' * 60}{Colors.RESET}")
+ print()
+
+
+def print_memory_breakdown(memory_stats):
+ """Print detailed memory categories."""
+ print(f"{Colors.GREEN}Memory Breakdown:{Colors.RESET}")
+
+ categories = [
+ ("active", "Active", "Apps currently in use"),
+ ("wired", "Wired", "System/kernel memory"),
+ ("inactive", "Inactive", "Recently used, can be freed"),
+ ("compressed", "Compressed", "Compressed to save space"),
+ ("free", "Free", "Immediately available"),
+ ("purgeable", "Purgeable", "Can be reclaimed if needed"),
+ ]
+
+ for key, name, description in categories:
+ if key in memory_stats:
+ print(f" {name}: {format_bytes(memory_stats[key])} ({description})")
+
+ accounted = (
+ memory_stats.get("active", 0)
+ + memory_stats.get("wired", 0)
+ + memory_stats.get("inactive", 0)
+ + memory_stats.get("compressed", 0)
+ + memory_stats.get("free", 0)
+ )
+ unaccounted = memory_stats["total"] - accounted
+ if unaccounted > 0:
+ print(f" Unaccounted: {format_bytes(unaccounted)} (Memory used by GPU/file cache)")
+
+ print()
+
+
+def print_top_processes(processes, total_ram, count=10):
+ """Print top processes by RSS."""
+ sorted_processes = sorted(processes, key=lambda process: process["rss"], reverse=True)
+
+ print(f"{Colors.GREEN}Top {count} Processes by RAM Usage:{Colors.RESET}")
+ print(f"{Colors.BLUE}{'PID':<8}{'MEM':<12}{'%MEM':<8}{'USER':<15}{'COMMAND'}{Colors.RESET}")
+
+ for process in sorted_processes[:count]:
+ mem = format_bytes(process["rss"])
+ mem_percent = (process["rss"] / total_ram) * 100 if total_ram else 0
+ color = ""
+ if mem_percent > 10:
+ color = Colors.RED
+ elif mem_percent > 5:
+ color = Colors.YELLOW
+ print(
+ f"{color}{process['pid']:<8}{mem:<12}{mem_percent:.1f}%{' ':<5}"
+ f"{process['user']:<15}{process['command']}{Colors.RESET}"
+ )
+
+ print()
+
+
+def print_app_groups(app_groups, total_ram):
+ """Print grouped app memory usage."""
+ print(f"{Colors.GREEN}Memory Usage by Application Group:{Colors.RESET}")
+ print(f"{Colors.BLUE}{'APPLICATION':<25}{'PROCESSES':<12}{'MEMORY':<15}{'%TOTAL'}{Colors.RESET}")
+
+ total_shown_memory = 0
+ sorted_apps = sorted(app_groups.items(), key=lambda item: item[1]["memory"], reverse=True)
+ for app_name, data in sorted_apps[:20]:
+ memory_percent = (data["memory"] / total_ram) * 100 if total_ram else 0
+ total_shown_memory += data["memory"]
+ color = ""
+ if memory_percent > 10:
+ color = Colors.RED
+ elif memory_percent > 5:
+ color = Colors.YELLOW
+ print(
+ f"{color}{app_name:<25}{data['count']:<12}{format_bytes(data['memory']):<15}"
+ f"{memory_percent:.1f}%{Colors.RESET}"
+ )
+
+ print(f"\n{Colors.YELLOW}Total Memory from Top Apps: {format_bytes(total_shown_memory)}{Colors.RESET}")
+ print()
+
+
+def print_report_timestamp(timestamp):
+ """Print the timestamp footer."""
+ print(f"{Colors.BLUE}Report generated: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}")
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index ee3b18d..f0e3b42 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1,69 +1,85 @@
---
title: CONFIG.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
# Configuration
-get-bashed writes a generated config file at:
+get-bashed writes a generated runtime config at:
-```
+```bash
~/.get-bashed/get-bashedrc.sh
```
-This file contains the resolved configuration from the installer and is sourced by the runtime. You can edit it manually after install if desired.
+The file is written by the installer and sourced by `bashrc` on startup.
## Keys
-- `GET_BASHED_GNU` (0/1)
-- `GET_BASHED_BUILD_FLAGS` (0/1)
-- `GET_BASHED_AUTO_TOOLS` (0/1)
-- `GET_BASHED_SSH_AGENT` (0/1)
-- `GET_BASHED_USE_DOPPLER` (0/1)
-
-## Feature Bundles
-
-These are feature keywords that expand to installer sets:
-- `dev_tools`
-- `ops_tools`
+Always emitted:
-## Defaults
+- `GET_BASHED_GNU`
+- `GET_BASHED_BUILD_FLAGS`
+- `GET_BASHED_AUTO_TOOLS`
+- `GET_BASHED_SSH_AGENT`
+- `GET_BASHED_USE_DOPPLER`
+- `GET_BASHED_USE_BASH_IT`
+- `GET_BASHED_GIT_SIGNING`
+- `GET_BASHED_VIMRC_MODE`
-If you run the installer with no flags, the defaults are conservative:
+Conditionally emitted:
-- GNU tools: off
-- Build flags: off
-- Auto tools: off
-- SSH agent: off
-- Doppler: off
+- `GET_BASHED_USER_NAME`
+- `GET_BASHED_USER_EMAIL`
-These defaults are written into `get-bashedrc.sh` so installs are reproducible.
+## Prefix
-## Override Install Prefix
+Set `GET_BASHED_HOME` to override the managed install prefix for the installer and runtime.
-Set `GET_BASHED_HOME` to override the install prefix (used by the installer and runtime).
-Example for CI:
+Example:
```bash
-export GET_BASHED_HOME=\"$RUNNER_TEMP/get-bashed\"
+export GET_BASHED_HOME="$RUNNER_TEMP/get-bashed"
./install.sh --auto --install shdoc
```
## Profiles
-- `minimal`: all flags off.
-- `dev`: `GET_BASHED_GNU=1`, `GET_BASHED_BUILD_FLAGS=1`, `GET_BASHED_AUTO_TOOLS=1`.
-- `ops`: `dev` plus `GET_BASHED_SSH_AGENT=1` and `GET_BASHED_USE_DOPPLER=1`.
+- `minimal`: all flags off, no installers.
+- `dev`: GNU tools, build flags, auto tools.
+- `ops`: `dev` plus SSH agent and explicit Doppler support.
+
+Profiles live in `profiles/*.env` and may define both `FEATURES` and `INSTALLS`.
+
+## Feature bundles
-Profiles live in `profiles/*.env` and can include both `FEATURES` and `INSTALLS`.
+These feature names expand into installer lists:
+- `dev_tools`
+- `ops_tools`
+
+## Branch protection
+
+Current `main` branch protection policy:
-## Branch Protection
+- `Quality (ubuntu-latest)`
+- `Quality (macos-latest)`
+- `Quality (wsl-ubuntu)`
+- `SonarQube Scan`
+- strict status checks enabled
+- 1 approving review required
+- stale reviews dismissed on new pushes
+- code owner reviews required
+- admin enforcement enabled
+- linear history required
+- conversation resolution required
+
+`cd.yml`, `release.yml`, and `scorecard.yml` are intentionally not required branch checks because they do not run as PR validation jobs. The checked-in verifier for this policy is:
+
+```bash
+make verify-branch-protection
+```
-Recommended required checks:
-- CI workflow (`ci.yml`)
-- PR title lint (`pr-title.yml`)
-- Docs build (`docs.yml`) if you publish Pages
+After `.github/workflows/codeql.yml` lands on `main` and GitHub default CodeQL setup is retired, `make verify-branch-protection` also expects `CodeQL (actions)` and `CodeQL (python)` to become required checks.
-Also consider requiring CODEOWNERS review.
+`CODEOWNERS` is active policy, not a placeholder: the repository requires code owner review on `main`.
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 739913f..33ec8ab 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -53,6 +53,8 @@ Secrets belong in `~/.get-bashed/secrets.d/` which is git-ignored. No auto-sourc
The install experience should:
- Work from a single `curl | sh` command on a fresh machine.
+- Fetch the repo tree automatically when `install.sh` is being run as a standalone downloaded file, using a revision-pinned archive URL.
+- Bootstrap Homebrew from a pinned installer first when a fresh macOS machine does not yet have a modern Bash.
- Let the user review the installer before running it.
- Succeed non-interactively (`--auto --yes`) for CI and provisioning scripts.
- Offer a curses UI (`--with-ui`) for exploratory first installs.
diff --git a/docs/INSTALLER.md b/docs/INSTALLER.md
index 1e4c0a1..46e08ce 100644
--- a/docs/INSTALLER.md
+++ b/docs/INSTALLER.md
@@ -2,18 +2,6 @@
Installer and configurator for get-bashed.
-## ⚠️ Upgrading & Breaking Changes
-
-**BREAKING CHANGE:** As of the latest release, get-bashed consolidates all runtime modules, local secrets, and dotfiles into a single managed prefix (default: `~/.get-bashed/`).
-
-If you are upgrading from a legacy installation (where files were stored in `~/.bashrc.d` and `~/.secrets.d`), the installer will attempt to automatically migrate your custom scripts and secrets into the new managed prefix.
-
-**Before upgrading, it is highly recommended to back up your custom modules:**
-```bash
-cp -r ~/.bashrc.d ~/bashrc.d.backup 2>/dev/null || true
-cp -r ~/.secrets.d ~/secrets.d.backup 2>/dev/null || true
-```
-
## Overview
Supports non-interactive and interactive installation with profiles,
diff --git a/docs/INSTALLERS.md b/docs/INSTALLERS.md
index 511691b..eb2423f 100644
--- a/docs/INSTALLERS.md
+++ b/docs/INSTALLERS.md
@@ -1,9 +1,46 @@
-# tools
-
-Tool registry for get-bashed installers.
-
-## Overview
-
-Defines tool metadata, dependencies, and installation methods.
+# Tool Registry
+Generated from `installers/tools.sh` and pinned source metadata.
+| Tool | Description | Dependencies | Platforms | Methods |
+|---|---|---|---|---|
+| `brew` | Homebrew/Linuxbrew installer | | macos,linux,wsl | curl |
+| `asdf` | asdf version manager | | macos,linux,wsl | handler |
+| `bash` | Latest GNU Bash | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `bash_it` | bash-it framework | git | macos,linux,wsl | git |
+| `vimrc` | amix/vimrc (awesome/basic) | git | macos,linux,wsl | git |
+| `shdoc` | shdoc (shell script doc generator) | | macos,linux,wsl | handler |
+| `uv` | uv | | macos,linux,wsl | brew,pip |
+| `dialog` | curses dialog UI | | macos,linux,wsl | brew,apt,dnf,yum |
+| `pipx` | pipx | | macos,linux,wsl | brew,apt,dnf,yum,pacman,pip |
+| `pre_commit` | pre-commit | pipx | macos,linux,wsl | pipx |
+| `bashate` | bashate | pipx | macos,linux,wsl | pipx |
+| `shellcheck` | shellcheck | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `actionlint` | actionlint | | macos,linux,wsl | handler |
+| `bats` | bats | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `curl` | curl | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `wget` | wget | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `gnupg` | gnupg | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `gnu_tools` | GNU coreutils/findutils/sed/tar | brew | macos | handler |
+| `git` | git | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `git_lfs` | git-lfs | | macos,linux,wsl | brew,apt,dnf,yum |
+| `gh` | GitHub CLI | | macos,linux,wsl | brew,apt |
+| `direnv` | direnv | | macos,linux,wsl | brew,apt |
+| `starship` | starship | | macos,linux,wsl | brew |
+| `eza` | eza (modern ls) | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `rg` | ripgrep | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `fd` | fd | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `bat` | bat | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `fzf` | fzf | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `jq` | jq | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `yq` | yq | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `tree` | tree | | macos,linux,wsl | brew,apt,dnf,yum,pacman |
+| `nodejs` | Node.js (asdf preferred) | asdf | macos,linux,wsl | handler |
+| `python` | Python (asdf preferred) | asdf | macos,linux,wsl | handler |
+| `java` | Java (asdf preferred) | asdf | macos,linux,wsl | handler |
+| `terraform` | terraform | | macos,linux,wsl | brew |
+| `awscli` | AWS CLI | | macos,linux,wsl | brew,apt |
+| `kubectl` | kubectl | | macos,linux,wsl | brew |
+| `helm` | helm | | macos,linux,wsl | brew |
+| `stern` | stern | | macos,linux,wsl | brew |
+| `doppler` | Doppler CLI | brew | macos,linux,wsl | brew |
diff --git a/docs/INSTALLERS_HELPERS.md b/docs/INSTALLERS_HELPERS.md
index 872297c..58d1469 100644
--- a/docs/INSTALLERS_HELPERS.md
+++ b/docs/INSTALLERS_HELPERS.md
@@ -4,80 +4,74 @@ Shared helpers for installers.
## Overview
-Provides platform detection and package manager helpers used by
-installer scripts.
+Provides platform detection, pinned sources, and installation helpers
+used by installer scripts.
## Index
+* [auto_exec](#auto_exec)
+* [pipx_package_spec](#pipx_package_spec)
+* [pip_package_spec](#pip_package_spec)
+* [pipx_install](#pipx_install)
+* [asdf_has_plugin](#asdf_has_plugin)
+* [asdf_install_plugin](#asdf_install_plugin)
+* [asdf_plugin_source](#asdf_plugin_source)
+* [asdf_plugin_ref](#asdf_plugin_ref)
+* [asdf_pin_plugin_ref](#asdf_pin_plugin_ref)
+* [asdf_default_version](#asdf_default_version)
+* [component_install](#component_install)
* [install_tool](#install_tool)
-* [install_asdf](#install_asdf)
* [install_gnu_tools](#install_gnu_tools)
-* [install_java](#install_java)
-* [install_nodejs](#install_nodejs)
-* [install_python](#install_python)
* [install_shdoc](#install_shdoc)
* [install_vimrc](#install_vimrc)
* [install_actionlint](#install_actionlint)
-* [pkg_install](#pkg_install)
-* [asdf_has_plugin](#asdf_has_plugin)
-* [asdf_install_plugin](#asdf_install_plugin)
-* [pipx_install](#pipx_install)
+* [install_asdf](#install_asdf)
+* [install_asdf_runtime](#install_asdf_runtime)
+* [install_java](#install_java)
+* [install_nodejs](#install_nodejs)
+* [install_python](#install_python)
-### install_tool
+### auto_exec
-Install a tool from the tools registry.
+Run a command with auto-approval when configured.
#### Arguments
-* **$1** (string): Tool id.
-
-### install_asdf
-
-Install asdf (handler).
-
-### install_gnu_tools
-
-Install GNU tools (handler).
-
-### install_java
-
-Install Java (handler).
-
-### install_nodejs
-
-Install Node.js (handler).
+* **$1** (string): Command name.
+* **$2** (string): Optional flag to auto-approve (e.g., -y, --noconfirm).
+* **$3** (string): Optional extra flag (e.g., --assume-yes).
+* **$4** (string): Optional extra flag (e.g., --yes).
+* **$5** (string): Optional extra flag (e.g., --confirm).
+* **$6** (string): Optional extra flag (e.g., --no-confirm).
-### install_python
+### pipx_package_spec
-Install Python (handler).
+Return the configured pipx package spec for a tool id.
-### install_shdoc
+#### Arguments
-Install shdoc (handler).
+* **$1** (string): Tool id.
-### install_vimrc
+### pip_package_spec
-Install vimrc (handler).
+Return the configured pip package spec for a tool id.
-### install_actionlint
+#### Arguments
-Install actionlint (handler).
+* **$1** (string): Tool id.
-### pkg_install
+### pipx_install
-Install a package via available system package manager.
+Install a Python tool via pipx (fallback to pip).
#### Arguments
-* **$1** (string): Brew package name.
-* **$2** (string): Apt package name (optional).
-* **$3** (string): Dnf package name (optional).
-* **$4** (string): Yum package name (optional).
+* **$1** (string): Package name.
#### Exit codes
* **0**: If installed.
-* **1**: If no supported package manager.
+* **1**: If pipx/pip missing.
### asdf_has_plugin
@@ -106,16 +100,92 @@ Install an asdf plugin if missing.
* **0**: If installed or already present.
* **1**: If asdf not available.
-### pipx_install
+### asdf_plugin_source
-Install a Python tool via pipx (fallback to pip).
+Return the configured asdf plugin source URL.
#### Arguments
-* **$1** (string): Package name.
+* **$1** (string): Plugin name.
-#### Exit codes
+### asdf_plugin_ref
-* **0**: If installed.
-* **1**: If pipx/pip missing.
+Return the configured asdf plugin git ref.
+
+#### Arguments
+
+* **$1** (string): Plugin name.
+
+### asdf_pin_plugin_ref
+
+Pin an installed asdf plugin checkout to the configured ref.
+
+#### Arguments
+
+* **$1** (string): Plugin name.
+
+### asdf_default_version
+
+Return the configured default asdf runtime version.
+
+#### Arguments
+
+* **$1** (string): Plugin name.
+
+### component_install
+
+Install a component using available methods.
+
+#### Arguments
+
+* **$1** (string): Action (enable|disable|install).
+* **$2** (string): Term to resolve/install.
+
+### install_tool
+
+Install a tool from the tools registry.
+
+#### Arguments
+
+* **$1** (string): Tool id.
+
+### install_gnu_tools
+
+Install GNU tools (handler).
+
+### install_shdoc
+
+Install shdoc (handler).
+
+### install_vimrc
+
+Install vimrc (handler).
+
+### install_actionlint
+
+Install actionlint (handler).
+
+### install_asdf
+
+Install asdf (handler).
+
+### install_asdf_runtime
+
+Install a pinned asdf runtime version.
+
+#### Arguments
+
+* **$1** (string): Plugin name.
+
+### install_java
+
+Install Java (handler).
+
+### install_nodejs
+
+Install Node.js (handler).
+
+### install_python
+
+Install Python (handler).
diff --git a/docs/MODULES.md b/docs/MODULES.md
index f97062b..cf3fc59 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -1,49 +1,33 @@
-# 99-secrets
-
-get-bashed module: 99-secrets
-
-## Overview
-
-Runtime module loaded by get-bashed in lexicographic order.
-
-## Index
-
-* [_path_add_front](#_path_add_front)
-* [install_cli_tools](#install_cli_tools)
-* [doppler_shell](#doppler_shell)
-* [get_bashed_component](#get_bashed_component)
-* [ex](#ex)
-* [mkcd](#mkcd)
-
-### _path_add_front
-
-Runtime module loaded by get-bashed in lexicographic order.
-
-### install_cli_tools
-
-Runtime module loaded by get-bashed in lexicographic order.
-
-### doppler_shell
-
-#### Example
-
-```bash
-doppler_shell
-```
-
-### get_bashed_component
-
-Optional bash-it integration.
-
-### ex
-
-Runtime module loaded by get-bashed in lexicographic order.
-
-### mkcd
-
-Make a directory and immediately cd into it.
-
-#### Arguments
-
-* **$1** (string): Directory path.
-
+---
+title: MODULES.md — get-bashed
+updated: 2026-04-15
+status: current
+---
+
+# Runtime Modules
+
+This document is maintained manually because the runtime modules are better described by behavior than by raw shdoc output.
+
+| Module | Purpose | Flags | Startup side effects |
+|---|---|---|---|
+| `00-options` | Shell options, history, editor defaults | none | local shell settings only |
+| `10-helpers` | PATH helpers and safe source helpers | none | defines helper functions |
+| `20-path` | Core PATH construction, GNU tool preference, asdf paths | `GET_BASHED_GNU` | PATH mutation only |
+| `30-buildflags` | Homebrew-derived build flags | `GET_BASHED_BUILD_FLAGS` | exports compile-time env vars |
+| `40-completions` | Homebrew bash-completion and asdf completions | none | completion setup only |
+| `50-tool-init` | Cargo env, starship, direnv | none | prompt / hook init if tools exist |
+| `60-asdf` | asdf activation for git and Homebrew installs | none | sources `asdf.sh` if present |
+| `65-tools` | Optional CLI bootstrap | `GET_BASHED_AUTO_TOOLS` | opt-in pinned npm installs if `asdf exec npm` is available |
+| `66-doppler` | Explicit Doppler helper | `GET_BASHED_USE_DOPPLER` | defines `doppler_shell` only |
+| `70-bash-it` | Optional bash-it init | `GET_BASHED_USE_BASH_IT` | sources bash-it if installed |
+| `70-env` | Reserved non-secret shared env | none | currently no-op |
+| `80-aliases` | Common aliases | none | aliases only |
+| `90-functions` | Shell helper functions | none | defines functions only |
+| `95-ssh-agent` | Optional ssh-agent bootstrap | `GET_BASHED_SSH_AGENT` | starts/reuses ssh-agent |
+| `99-secrets` | Local secrets snippets | none | sources `~/.get-bashed/secrets.d/*.sh` |
+
+## Notes
+
+- The only secret-loading path is `99-secrets`.
+- `doppler_env` does not inject secrets at startup.
+- `auto_tools` remains opt-in, intentionally narrow, and checks pinned npm package state before installing.
diff --git a/docs/README.md b/docs/README.md
index 9214714..5d8adc2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,32 +1,57 @@
---
title: README.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
# Docs Pipeline
-This repo uses `shdoc` to generate documentation from shell scripts.
+`scripts/gen-docs.sh` generates the installer-facing reference docs.
+The installer registry and pinned source/runtime manifest live in `installers/tools.sh`
+and `installers/sources.sh`.
-## Generate
+## Generated docs
-```bash
-./scripts/gen-docs.sh
-```
-
-Outputs:
- `docs/INSTALLER.md`
- `docs/INSTALLERS_HELPERS.md`
- `docs/INSTALLERS.md`
+
+## Manual docs
+
+- `docs/getting-started/`
+- `docs/reference/`
+- `docs/api/index.md`
+- `docs/public/install.sh`
- `docs/MODULES.md`
-- `docs/INDEX.md`
+- `docs/CONFIG.md`
+- `docs/STATE.md`
+- `docs/TESTING.md`
+- `docs/ARCHITECTURE.md`
+- `docs/DESIGN.md`
+- `docs/SHDOC.md`
+- `docs/index.md`
+- `docs/reference/security.md`
+
+## Commands
-## GitHub Pages
+```bash
+make docs
+make docs-check
+make verify-security
+./scripts/validate-docs.sh
+```
-The `docs.yml` workflow builds and publishes `docs/` to GitHub Pages. The entry
-page is `docs/index.md`.
+`make docs` bootstraps `shdoc` and `uv` through `scripts/ci-setup.sh`, regenerates the shell reference docs, validates the docs contract, and builds the Sphinx site.
+`make docs-check` runs the same generation path and then adds Sphinx link checking for outbound docs links.
+`make verify-security` runs the checked-in supply-chain verifier that checks workflow pinning, repo-owned CodeQL/Scorecard presence, pinned installer sources, draft-first release/publication wiring, immutable-release governance, Dependabot/security-fix posture, docs-link wiring, and branch-protection verification availability.
-## CI Setup
+## CI and publishing
-CI uses `scripts/ci-setup.sh` to install tools into `GET_BASHED_HOME` (defaults
-to `RUNNER_TEMP` on GitHub Actions).
+- `ci.yml` validates docs generation, the Sphinx build, and outbound docs links on Ubuntu and macOS.
+- `cd.yml` rebuilds docs on `main`, creates the draft GitHub release, validates and publishes the checked-in release bundles, and then publishes GitHub Pages.
+- `release.yml` is the manual recovery workflow for rerunning the same draft-first release pipeline against an existing draft tag.
+- `scorecard.yml` runs a separate OpenSSF Scorecard analysis because Pages deploy and release workflows need different permissions.
+- `codeql.yml` runs repo-owned advanced CodeQL analysis for `actions` and `python`.
+- `scripts/verify_branch_protection.sh` plus `make verify-branch-protection` check the live `main` branch required status contexts when `gh` auth is available.
+- `scripts/verify_immutable_release_governance.sh` plus `make verify-immutable-release-governance` check the live immutable-release posture when `gh` auth is available.
+- `scripts/ci-setup.sh` persists `GET_BASHED_HOME` and `PATH` through GitHub Actions step boundaries using `GITHUB_ENV` and `GITHUB_PATH`.
diff --git a/docs/SHDOC.md b/docs/SHDOC.md
index f389cea..7559f1a 100644
--- a/docs/SHDOC.md
+++ b/docs/SHDOC.md
@@ -1,44 +1,35 @@
---
title: SHDOC.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
# shdoc
-This repo uses `shdoc` to generate documentation from shell scripts.
+get-bashed uses `shdoc` to generate installer-facing shell API docs.
## Install
-Arch Linux (AUR):
+Preferred:
+
```bash
-yay -S shdoc-git
+./install.sh --install shdoc
```
-Using Git (requires gawk):
-```bash
-sudo apt-get install gawk
+If the package manager does not provide `shdoc`, the installer falls back to a pinned git ref and installs the script into `GET_BASHED_HOME/bin` with a portable `gawk` shebang.
-git clone --recursive https://github.com/reconquest/shdoc
-cd shdoc
-sudo make install
-```
+## Generate
-Local (no sudo) to get-bashed prefix:
```bash
-GET_BASHED_HOME="$HOME/.get-bashed"
-mkdir -p "$GET_BASHED_HOME/bin"
-
-git clone --recursive https://github.com/reconquest/shdoc
-cd shdoc
-make install PREFIX="$GET_BASHED_HOME"
+make docs
```
-Note: shdoc requires Bash 4+ for `;;&` case labels. On macOS, install a newer
-Bash via Homebrew and use that when building from source.
+For direct script usage, `./scripts/gen-docs.sh` still works when `shdoc` is already on `PATH`.
-## Generate
+This regenerates:
-```bash
-./scripts/gen-docs.sh
-```
+- `docs/INSTALLER.md`
+- `docs/INSTALLERS_HELPERS.md`
+- `docs/INSTALLERS.md`
+
+`docs/MODULES.md` is maintained manually.
diff --git a/docs/STATE.md b/docs/STATE.md
index 0e7d336..7d51840 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -1,52 +1,40 @@
---
title: STATE.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
# State
-Current state and roadmap for the get-bashed project.
-
-## Beta Milestone Reached
-The project has achieved functional stability across the core installer and runtime layers. Idempotent wiring and cross-platform package management are verified.
-
-## What is done
-
-### Installer Core
-- **POSIX Bootstrap**: `install.sh` handles initial environment probing and Bash acquisition.
-- **Bash Installer**: `install.bash` provides full profile/feature resolution and config generation.
-- **Idempotent Wiring**: Dotfiles are copied into `~/.get-bashed` with automated backups, then optionally symlinked into `$HOME` or sourced via snippets injected into shell profiles.
-- **Dependency Registry**: Depth-first resolution with circular dependency detection.
-- **Git Identity**: Integrated identity prompts and `gitconfig` template application.
-
-### Runtime Modules
-- **Ordered Loading**: Modular `bashrc.d/` structure (00-99).
-- **Environment Management**: Robust PATH construction and Homebrew build flag injection.
-- **Tool Integration**: Starship, direnv, and asdf version manager activation.
-- **Secrets Protocol**: Isolated `secrets.d/` sourcing (git-ignored).
-
-### CI/CD & Automation
-- **Standardized Workflows**: Four-workflow pattern (CI, CD, Release, Automerge).
-- **Security Gates**: Secret scanning (gitleaks) and workflow linting (actionlint).
-- **Quality Gates**: BATS test suite and install verification on clean prefixes.
-
-## Active Context
-- **Refinement Phase**: Transitioning from functional completion to documentation and standard alignment.
-- **Dogfooding**: Verifying local install stability after recent structural changes (symlink-only dotfiles).
-
-## Recent Changes
-- **Standardized Documentation**: Aligned all `.md` files with brand-aware headers and professional tone.
-- **Workflow Consolidation**: Moved to the tight four-workflow pattern with pinned SHAs.
-- **Release Please Config**: Standardized `release-please-config.json` (unhidden) alongside existing manifest.
-- **Doc Index Protection**: Updated `gen-docs.sh` to preserve frontmatter in generated indices.
-
-## What is next
-- **Extended Test Coverage**: Expand BATS to cover runtime module behavior and flag side effects.
-- **Profile Auditing**: Fine-tune default toolsets for `minimal`, `dev`, and `ops` profiles.
-- **UX Polishing**: Improve the interactive `dialog` UI for more intuitive feature selection.
-
-## Known issues
-- `shdoc` is not available via standard package managers on macOS; requires local source build in CI.
-- Runtime module `bashrc.d/` shdoc coverage is sparse; needs manual annotation pass.
-- BATS helper libraries require periodic SHA auditing to stay secure.
+## Current phase
+
+get-bashed is in production-hardening mode. The installer/runtime split is stable, and the current focus is on keeping the documented contract, CI behavior, and runtime behavior consistent across macOS, Linux, and WSL.
+
+## Completed
+
+- Modern Bash bootstrap from `install.sh`
+- Docs-site release installer at `/install.sh`
+- Managed install prefix under `~/.get-bashed`
+- Ordered runtime module loading
+- Centralized tool registry and dependency resolution
+- Pinned `asdf` default versions for built-in language installers
+- Generated installer/helper API docs plus a generated installer catalog
+- Matrix CI across Ubuntu and macOS for lint, tests, install verification, docs generation, and docs build
+- Dedicated Ubuntu-under-WSL validation on `windows-2025` for lint, tests, and install verification
+- Checked-in release bundle packaging, release validation, published-release verification, and generated package manifests for `jbcom/pkgs`
+
+## Active priorities
+
+- Keep docs and code in lockstep
+- Preserve unknown user-owned files during forceful reinstalls
+- Pin external installer refs where feasible
+- Keep pinned runtime defaults and test helper SHAs in the shared manifest
+- Verify fallback download integrity when package managers are unavailable
+- Realign managed git-backed installs to pinned refs on rerun
+- Expand behavior-level tests for runtime modules and installer semantics
+- Keep the docs/release/package-manager surface aligned as the release workflow evolves
+
+## Remaining risk areas
+
+- Optional startup behaviors such as `auto_tools` are now pinned, gated on tool availability, and avoid reinstalling already-present pinned npm packages, but still need periodic review for performance and predictability
+- Pinned sources and runtime defaults still need periodic refresh as upstream projects release updates
diff --git a/docs/TESTING.md b/docs/TESTING.md
index d095c2c..971a103 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -1,6 +1,6 @@
---
title: TESTING.md — get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
@@ -8,138 +8,92 @@ status: current
## Strategy
-Tests validate installer behavior and shell wiring, not runtime shell customizations. The goal is to confirm that:
+The test suite covers installer semantics, runtime behavior, docs contract drift, and install verification.
-- The installer copies the correct files and writes the correct config.
-- Feature flags and profiles produce the expected `get-bashedrc.sh` output.
-- Dotfile linking and backup behavior is correct.
-- Install verification passes on a clean prefix.
-
-## Test Framework
-
-Tests use [BATS](https://github.com/bats-core/bats-core) (Bash Automated Testing System) with three helper libraries:
-
-- `bats-support` — core BATS helpers
-- `bats-assert` — assertion library (`assert_success`, `assert_output`, etc.)
-- `bats-file` — file and directory assertions (`assert_file_exist`, `assert_dir_exist`)
-
-Helper libraries are pinned to specific commit SHAs in `scripts/test-setup.sh`. Do not update these without auditing the new commits.
-
-## Running Tests
-
-Fetch helper libraries (one time):
-
-```bash
-./scripts/test-setup.sh
-```
-
-Run the full suite:
-
-```bash
-bats tests
-```
-
-Or via Make:
+## Commands
```bash
+make lint
make test
+make docs
+make docs-check
+make verify-security
+make verify-branch-protection
+make verify-immutable-release-governance
+make reconcile-codeql-governance
+make reconcile-immutable-release-governance
+make package-release
+make smoke-release
+make release-validate
```
-Make runs `./scripts/test-setup.sh` before `bats tests`.
+`make test` runs:
-## Test Files
+1. `source ./scripts/ci-setup.sh "bats"`
+2. `./scripts/test-setup.sh`
+3. `bats tests`
+4. `./scripts/verify-install.sh`
-| File | Coverage |
-|---|---|
-| `tests/install.bats` | Installer copies files and wires `~/.bashrc` / `~/.bash_profile` |
-| `tests/config_output.bats` | Feature flags and profiles produce correct `get-bashedrc.sh` |
-| `tests/link_dotfiles.bats` | `--link-dotfiles` creates symlinks and backs up existing files |
-| `tests/registry_idempotent.bats` | Installer can be re-run without error |
-| `tests/optional_deps.bats` | Optional dependency gating by feature flags |
+The BATS suite resolves a Bash 4+ interpreter at runtime instead of assuming a macOS Homebrew path, so the same tests run against the Ubuntu and macOS CI matrix entries and the Ubuntu-under-WSL validation job.
+Direct ad hoc runs such as `bats tests/runtime_modules.bats` also self-bootstrap the pinned helper libraries through `tests/test_helper.bash` when `tests/lib/` is missing.
-## Isolation
+`make docs` runs:
-Every test creates its own temp `HOME` directory:
+1. `source ./scripts/ci-setup.sh "shdoc,uv"`
+2. `./scripts/gen-docs.sh`
+3. `./scripts/validate-docs.sh`
+4. `uvx tox -e docs`
-```bash
-TMPDIR="$(mktemp -d)"
-HOME="$TMPDIR"
-```
+The docs targets now bootstrap both `shdoc` and `uv` so `uvx` is available even on a machine that does not already have it on `PATH`.
+`make docs-check` runs the same docs generation path and then adds `uvx tox -e docs,docs-linkcheck`.
-This prevents any test from modifying the developer's real home directory. Tests clean up via temp directory expiry; they do not explicitly clean up on failure.
+`make verify-security` runs the repo-owned supply-chain verifier across workflow SHA pinning, the checked-in `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, Dependabot/security-fix posture, secret scanning posture, draft-first release publication wiring, immutable-release governance, docs-link wiring, and branch-protection verification availability. Once `codeql.yml` lands on `main`, that same verifier expects GitHub default CodeQL setup to be turned off.
-## CI Coverage
+`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. That checked-in validation now forces the installer through both its default downloader path and its supported `wget` fallback path.
-The `tests` job in `ci.yml` runs:
+`make verify-branch-protection` is a separate authenticated governance check. It uses `gh api` to verify that GitHub branch protection for `main` still requires the exact CI job names this repo depends on and still enforces the expected review, code owner, and branch-safety settings.
+`make reconcile-codeql-governance` is the one-time authenticated cutover for moving live `main` from GitHub default CodeQL setup to the checked-in `.github/workflows/codeql.yml` path once that workflow has landed on `main`.
+`make verify-immutable-release-governance` is the parallel live check for releases. It defers until the checked-in draft-first release flow is present on `main`, and after that it expects GitHub immutable releases to be enabled.
+`make reconcile-immutable-release-governance` is the one-time authenticated cutover for enabling GitHub immutable releases after the checked-in draft-first flow has landed on `main`.
-1. `scripts/ci-setup.sh "bats"` — installs bats into `GET_BASHED_HOME`.
-2. `scripts/test-setup.sh` — fetches pinned bats helper libraries.
-3. `bats tests` — runs the full suite.
-4. `scripts/verify-install.sh` — validates that a basic install produces the expected wiring.
+## BATS coverage
-## Install Verification
+The suite currently covers:
-`scripts/verify-install.sh` performs a smoke-test install and checks:
+- installer config output and escaping
+- bootstrap selection behavior
+- true dry-run behavior
+- link-dotfiles behavior and backups
+- manifest-based force behavior for managed assets
+- optional dependency resolution
+- runtime module behavior for Doppler and asdf
+- pinned `asdf` runtime selection
+- legacy migration safety
+- documentation contract checks
+- branch-protection verifier behavior
+- supply-chain verifier behavior
+- release bundle and package-manifest validation
+- immutable-release governance verification and cutover
+- docs-site installer fallback downloader coverage
-- `~/.get-bashed/bashrc` exists.
-- `~/.get-bashed/bashrc.d/` exists.
-- `~/.get-bashed/get-bashedrc.sh` was written.
-- `~/.bashrc` contains the expected source snippet.
+## CI coverage
-This runs after BATS to catch regressions that unit tests may miss.
+`ci.yml` runs the full quality pipeline on:
-## Linting
+- `ubuntu-latest`
+- `macos-latest`
-The `lint` job runs pre-commit:
+It also runs a dedicated WSL validation job on:
-```bash
-pre-commit run --all-files
-```
-
-Pre-commit hooks run:
+- `windows-2025`, booting Ubuntu under WSL for lint, BATS, and install verification
-- `shellcheck` — shell script linting.
-- `bashate` — PEP 8 style for shell (max line length 120).
-- `actionlint` — GitHub Actions workflow linting.
-- `gitleaks` — secret detection.
-- Standard file hygiene (trailing whitespace, end-of-file newlines, YAML/JSON validation).
+The Ubuntu and macOS matrix entries run lint, BATS, install verification, docs generation, docs build, Sphinx link checking, and the repo-owned supply-chain verifier from a clean checkout. The WSL job reuses the repo `make lint` and `make test` targets inside Ubuntu under WSL so the Windows-hosted Linux path is exercised directly.
+Separately, `.github/workflows/codeql.yml` runs repo-owned advanced CodeQL analysis for `actions` and `python` on PRs, on `main`, and on the weekly schedule.
-Run locally:
+## Helper libraries
-```bash
-make lint
-# or
-pre-commit run --all-files
-```
-
-Install the pre-commit hook:
-
-```bash
-pre-commit install
-```
-
-## Adding Tests
-
-1. Add a new `.bats` file in `tests/` or add `@test` blocks to an existing file.
-2. Load helpers at the top: `load test_helper`.
-3. Create a temp HOME per test to isolate side effects.
-4. Use `run` for commands whose exit code you want to inspect.
-5. Use `assert_success`, `assert_failure`, `assert_output`, `assert_file_exist`, etc.
-
-Example:
-
-```bash
-@test "my new test" {
- TMPDIR="$(mktemp -d)"
- HOME="$TMPDIR"
-
- HOME="$HOME" bash ./install.sh --auto --prefix "$HOME/.get-bashed" --force
-
- assert_file_exist "$HOME/.get-bashed/get-bashedrc.sh"
-}
-```
+`scripts/test-setup.sh` fetches pinned BATS helper libraries into `tests/lib/` using refs from `installers/sources.sh`. If a library directory already exists without git metadata, with the wrong remote, or at the wrong commit, the script replaces it so repeated runs stay deterministic.
-## Known Gaps
+## Remaining gaps
-- No coverage of runtime module behavior (module sourcing, conditional flags).
-- shdoc availability is not tested in CI (shdoc unavailable via Homebrew on macOS; installed locally in CI via `GET_BASHED_HOME/bin`).
+- Optional startup behaviors still need periodic review for latency and predictability.
diff --git a/docs/api/index.md b/docs/api/index.md
new file mode 100644
index 0000000..77ceca8
--- /dev/null
+++ b/docs/api/index.md
@@ -0,0 +1,19 @@
+---
+title: API
+updated: 2026-04-15
+status: current
+---
+
+# API
+
+The shell-facing reference pages are generated by `scripts/gen-docs.sh` from the installer and helper source files.
+
+```{toctree}
+:maxdepth: 1
+
+/INSTALLER
+/INSTALLERS
+/INSTALLERS_HELPERS
+```
+
+The runtime module guide stays manual because the aggregated generated output was misleading for this repo’s sourced shell modules. See [MODULES.md](../MODULES.md) for that curated reference.
diff --git a/docs/conf.py b/docs/conf.py
index 62cce3c..aa5b260 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,21 +1,47 @@
-"""Sphinx configuration for get-bashed docs.
+"""Sphinx configuration for get-bashed docs."""
-Uses shibuya theme and myst_parser.
-"""
+from __future__ import annotations
+import os
+import subprocess
from datetime import datetime
+from pathlib import Path
project = "get-bashed"
copyright = f"{datetime.now().year}, Jon Bogaty"
author = "Jon Bogaty"
+html_title = project
+html_baseurl = "https://jbcom.github.io/get-bashed/"
+repo_root = Path(__file__).resolve().parent.parent
+
+
+def _release() -> str:
+ if env := os.environ.get("DOCS_VERSION"):
+ return env
+ try:
+ result = subprocess.run(
+ ["git", "describe", "--tags", "--always", "--dirty"],
+ cwd=repo_root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return "dev"
+ return result.stdout.strip() or "dev"
+
+
+release = version = _release()
extensions = [
"myst_parser",
+ "sphinx.ext.githubpages",
"sphinxcontrib.mermaid",
]
# Suppress warnings from shdoc generated brackets
suppress_warnings = ["myst.xref_missing"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# Shibuya theme options
html_theme = "shibuya"
@@ -23,13 +49,18 @@
html_favicon = "../assets/logo.png"
html_static_path = ["_static"]
html_css_files = ["custom.css"]
+html_extra_path = ["public"]
html_theme_options = {
"accent_color": "blue",
"nav_links": [
+ {"name": "Get Started", "url": "https://jbcom.github.io/get-bashed/getting-started/"},
+ {"name": "Reference", "url": "https://jbcom.github.io/get-bashed/reference/index/"},
+ {"name": "API", "url": "https://jbcom.github.io/get-bashed/api/"},
+ {"name": "Releases", "url": "https://github.com/jbcom/get-bashed/releases"},
{"name": "GitHub", "url": "https://github.com/jbcom/get-bashed"},
],
- "announcement": "Terminal supremacy achieved. 💻",
+ "announcement": "Release bundles now drive docs-site install.sh plus Homebrew, Scoop, and Chocolatey manifests.",
}
# MyST settings
@@ -38,8 +69,16 @@
"deflist",
"html_image",
]
+myst_heading_anchors = 3
# Tell Sphinx where the source files are
source_suffix = {
".md": "markdown",
}
+
+linkcheck_anchors = False
+linkcheck_timeout = 10
+linkcheck_retries = 2
+linkcheck_ignore = [
+ r"https://jbcom\.github\.io/get-bashed/.*",
+]
diff --git a/docs/getting-started/downloads.md b/docs/getting-started/downloads.md
new file mode 100644
index 0000000..b873b09
--- /dev/null
+++ b/docs/getting-started/downloads.md
@@ -0,0 +1,42 @@
+---
+title: Downloads
+updated: 2026-04-15
+status: current
+---
+
+# Downloads
+
+## Release Assets
+
+Each GitHub Release publishes:
+
+- `get-bashed--unix.tar.gz`
+- `get-bashed--windows.zip`
+- `checksums.txt`
+
+The Unix archive is the canonical bundle for macOS, Linux, and Linuxbrew/Homebrew installs. It contains the managed shell tree plus the POSIX bootstrap and can be unpacked and run directly.
+
+The Windows archive ships the same managed shell tree plus `get-bashed.ps1` and `get-bashed.cmd`, which invoke the bundled installer through WSL when available and fall back to `bash.exe` from Git Bash when present.
+The docs-site `install.sh` is for Unix-like shells; Windows users should use WSL, the release bundle wrappers, Scoop, or Chocolatey instead of running it from MSYS or Git Bash.
+
+## Docs Installer
+
+The docs site publishes [`/install.sh`](https://jbcom.github.io/get-bashed/install.sh). That script:
+
+1. resolves the latest release tag unless you pin `--version`
+2. downloads `get-bashed--unix.tar.gz`
+3. verifies the bundle against `checksums.txt`
+4. extracts the release tree
+5. execs the bundled `install.sh`
+
+## Package Managers
+
+Package-manager manifests are generated from the release workflow and published to `jbcom/pkgs`:
+
+- `brew tap jbcom/pkgs && brew install get-bashed`
+- `scoop bucket add jbcom https://github.com/jbcom/pkgs && scoop install get-bashed`
+- `choco install get-bashed`
+
+Homebrew installs the Unix bundle and wraps `install.sh` from `libexec`.
+
+Scoop and Chocolatey install the Windows wrapper bundle. Those channels expect WSL for the real Bash runtime and treat Git Bash as a fallback launcher, not the primary support target.
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
new file mode 100644
index 0000000..0ce19a9
--- /dev/null
+++ b/docs/getting-started/index.md
@@ -0,0 +1,33 @@
+---
+title: Getting Started
+updated: 2026-04-15
+status: current
+---
+
+# Getting Started
+
+```{toctree}
+:maxdepth: 1
+
+downloads
+install-and-verify
+```
+
+`get-bashed` now has two installation surfaces:
+
+- the source-tree bootstrap at the repository root, for contributors and local development
+- the release installer at the docs site root, for end users and package-manager distribution
+
+The canonical published assets are:
+
+- `get-bashed--unix.tar.gz`
+- `get-bashed--windows.zip`
+- `checksums.txt`
+
+Use the docs-hosted installer for normal installs:
+
+```bash
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh
+```
+
+Use the repo-root `install.sh` when you are working from a checkout and want the current tree rather than a released bundle.
diff --git a/docs/getting-started/install-and-verify.md b/docs/getting-started/install-and-verify.md
new file mode 100644
index 0000000..ce3b6a2
--- /dev/null
+++ b/docs/getting-started/install-and-verify.md
@@ -0,0 +1,58 @@
+---
+title: Install And Verify
+updated: 2026-04-15
+status: current
+---
+
+# Install And Verify
+
+## Install The Latest Release
+
+```bash
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh
+```
+
+Pin a specific release tag:
+
+```bash
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version v0.1.0
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version 0.1.0
+```
+
+Pass normal installer flags straight through to the bundled installer:
+
+```bash
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --profiles dev --link-dotfiles
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --auto --prefix "$HOME/.get-bashed"
+```
+
+## Install From A Downloaded Bundle
+
+```bash
+curl -LO https://github.com/jbcom/get-bashed/releases/download/v0.1.0/get-bashed-0.1.0-unix.tar.gz
+tar -xzf get-bashed-0.1.0-unix.tar.gz
+cd get-bashed-0.1.0-unix
+sh install.sh --profiles dev --link-dotfiles
+```
+
+## Verify The Result
+
+Confirm the managed prefix exists:
+
+```bash
+test -f "$HOME/.get-bashed/get-bashedrc.sh"
+test -d "$HOME/.get-bashed/bashrc.d"
+```
+
+If you linked dotfiles, confirm the startup entrypoints point at the managed tree:
+
+```bash
+ls -l "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.gitconfig" "$HOME/.vimrc"
+```
+
+If you are validating a checkout instead of a published release, the repo-owned smoke path is still:
+
+```bash
+make test
+./scripts/verify-install.sh
+```
diff --git a/docs/index.md b/docs/index.md
index 5454028..900fc4f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,38 +1,62 @@
---
title: get-bashed
-updated: 2026-04-10
+updated: 2026-04-15
status: current
---
-# get-bashed Documentation
+# get-bashed
-A modular, portable Bash environment you can install on any machine. get-bashed gives you clean shell defaults, ordered runtime modules, a centralized tool installer, and reproducible configuration — without touching anything you do not explicitly ask it to touch.
+## The Bash-First Shell Bundle for macOS, Linux, and WSL
-```{toctree}
-:maxdepth: 2
-:caption: Overview
+`get-bashed` is a managed Bash environment with a POSIX bootstrap, an ordered runtime module tree, and a reproducible installer registry. The public docs site is now also a release surface: it hosts `/install.sh`, documents the published release bundles, and tracks the package-manager manifests pushed into `jbcom/pkgs`.
-README.md
-ARCHITECTURE.md
-DESIGN.md
-STATE.md
+```bash
+curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh
```
+That docs-hosted installer resolves the latest GitHub Release, downloads the published Unix bundle, verifies `checksums.txt`, extracts the release tree, and then execs the bundled `install.sh`.
+
```{toctree}
:maxdepth: 2
-:caption: Configuration
+:caption: Docs
-CONFIG.md
-INSTALLER.md
-INSTALLERS.md
-INSTALLERS_HELPERS.md
-MODULES.md
+getting-started/index
+reference/index
+api/index
```
```{toctree}
-:maxdepth: 2
-:caption: Development
+:hidden:
-TESTING.md
-SHDOC.md
+README
+ARCHITECTURE
+SHDOC
+TESTING
```
+
+## Release Surface
+
+- [GitHub Releases](https://github.com/jbcom/get-bashed/releases) publish the canonical archives plus `checksums.txt`.
+- `docs/public/install.sh` is copied to the docs site root as `install.sh`.
+- `cd.yml` follows the GitHub-recommended draft-first path: create the draft release, attach the validated assets, publish the draft, verify the published surface, then open the `jbcom/pkgs` PR.
+
+## Product Contract
+
+- `install.sh` is POSIX `sh` and bootstraps Bash 4+ before handing off to `install.bash`.
+- `--dry-run` is zero-write.
+- `--force` preserves unknown user-owned files under `~/.get-bashed`.
+- Doppler integration is explicit-only.
+- `asdf` works for both Homebrew and git installs.
+
+## Source-Tree Reference
+
+If you are working from a checkout instead of a published release, the original repo-owned reference pages still live here:
+
+- [README](README.md)
+- [Architecture](ARCHITECTURE.md)
+- [Design](DESIGN.md)
+- [Configuration](CONFIG.md)
+- [Runtime Modules](MODULES.md)
+- [Testing](TESTING.md)
+- [State](STATE.md)
+- [Shell Docs](SHDOC.md)
diff --git a/docs/public/install.sh b/docs/public/install.sh
new file mode 100755
index 0000000..e61f817
--- /dev/null
+++ b/docs/public/install.sh
@@ -0,0 +1,216 @@
+#!/bin/sh
+# get-bashed docs-site installer.
+#
+# Usage:
+# curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh
+# curl -fsSL https://jbcom.github.io/get-bashed/install.sh | sh -s -- --version v0.1.0 --profiles dev
+
+set -eu
+
+REPO="jbcom/get-bashed"
+VERSION="latest"
+DOWNLOAD_BASE_URL=""
+CHECKSUMS_URL=""
+TAG=""
+DOWNLOADER=""
+
+usage() {
+ cat <<'EOF'
+Usage:
+ install.sh [--version TAG] [installer args...]
+
+Examples:
+ install.sh
+ install.sh --version v0.1.0 --profiles dev --link-dotfiles
+EOF
+}
+
+checksum_check() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum -c -
+ return
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 -c -
+ return
+ fi
+ echo "install.sh: no SHA-256 checksum tool found (need sha256sum or shasum)" >&2
+ exit 1
+}
+
+resolve_downloader() {
+ case "${GET_BASHED_DOWNLOAD_TOOL:-auto}" in
+ auto)
+ if command -v curl >/dev/null 2>&1; then
+ printf '%s\n' "curl"
+ return
+ fi
+ if command -v wget >/dev/null 2>&1; then
+ printf '%s\n' "wget"
+ return
+ fi
+ ;;
+ curl|wget)
+ if command -v "${GET_BASHED_DOWNLOAD_TOOL}" >/dev/null 2>&1; then
+ printf '%s\n' "${GET_BASHED_DOWNLOAD_TOOL}"
+ return
+ fi
+ echo "install.sh: requested downloader '${GET_BASHED_DOWNLOAD_TOOL}' is not available" >&2
+ exit 1
+ ;;
+ *)
+ echo "install.sh: unsupported GET_BASHED_DOWNLOAD_TOOL '${GET_BASHED_DOWNLOAD_TOOL}' (expected auto, curl, or wget)" >&2
+ exit 1
+ ;;
+ esac
+
+ printf '%s\n' ""
+}
+
+download() {
+ case "$DOWNLOADER" in
+ curl)
+ curl -fsSL -o "$2" "$1"
+ return
+ ;;
+ wget)
+ wget -qO "$2" "$1"
+ return
+ ;;
+ esac
+ echo "install.sh: need curl or wget to download release assets" >&2
+ exit 1
+}
+
+download_text() {
+ case "$DOWNLOADER" in
+ curl)
+ curl -fsSL "$1"
+ return
+ ;;
+ wget)
+ wget -qO- "$1"
+ return
+ ;;
+ esac
+ echo "install.sh: need curl or wget to query release metadata" >&2
+ exit 1
+}
+
+detect_platform() {
+ platform="$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]')"
+ case "$platform" in
+ msys*|mingw*|cygwin*)
+ echo "install.sh: Windows shells should use WSL, Scoop, Chocolatey, or the release bundle wrappers" >&2
+ exit 1
+ ;;
+ esac
+}
+
+normalize_version() {
+ case "$VERSION" in
+ latest)
+ TAG="latest"
+ ;;
+ v*)
+ TAG="$VERSION"
+ VERSION="${VERSION#v}"
+ ;;
+ *)
+ TAG="v$VERSION"
+ ;;
+ esac
+}
+
+resolve_latest_version() {
+ TAG="$(
+ download_text "${GET_BASHED_RELEASE_METADATA_URL:-https://api.github.com/repos/$REPO/releases/latest}" \
+ | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' \
+ | head -n 1
+ )"
+
+ if [ -z "$TAG" ]; then
+ echo "install.sh: could not resolve latest version" >&2
+ exit 1
+ fi
+
+ VERSION="${TAG#v}"
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --version)
+ VERSION="${2:?missing value for --version}"
+ shift 2
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+detect_platform
+DOWNLOADER="$(resolve_downloader)"
+
+if [ "$VERSION" = "latest" ]; then
+ resolve_latest_version
+else
+ normalize_version
+fi
+
+ARCHIVE="get-bashed-${VERSION}-unix.tar.gz"
+
+if [ -n "${GET_BASHED_RELEASE_BASE_URL:-}" ]; then
+ DOWNLOAD_BASE_URL="${GET_BASHED_RELEASE_BASE_URL}"
+else
+ DOWNLOAD_BASE_URL="https://github.com/$REPO/releases/download/$TAG"
+fi
+
+if [ -n "${GET_BASHED_RELEASE_CHECKSUMS_URL:-}" ]; then
+ CHECKSUMS_URL="${GET_BASHED_RELEASE_CHECKSUMS_URL}"
+else
+ CHECKSUMS_URL="${DOWNLOAD_BASE_URL}/checksums.txt"
+fi
+
+TMPDIR="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed-release)"
+cleanup() {
+ rm -rf "$TMPDIR"
+}
+trap cleanup EXIT INT TERM
+
+download "${DOWNLOAD_BASE_URL}/${ARCHIVE}" "$TMPDIR/$ARCHIVE" || {
+ echo "install.sh: failed to download ${ARCHIVE}" >&2
+ exit 1
+}
+
+download "$CHECKSUMS_URL" "$TMPDIR/checksums.txt" || {
+ echo "install.sh: failed to download checksums.txt" >&2
+ exit 1
+}
+
+(
+ cd "$TMPDIR"
+ grep " ${ARCHIVE}\$" checksums.txt | checksum_check
+) || {
+ echo "install.sh: checksum verification failed for ${ARCHIVE}" >&2
+ exit 1
+}
+
+mkdir -p "$TMPDIR/unpack"
+tar -xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR/unpack"
+
+STAGE_DIR="$TMPDIR/unpack/get-bashed-${VERSION}-unix"
+if [ ! -f "$STAGE_DIR/install.sh" ]; then
+ echo "install.sh: bundled install.sh missing from release archive" >&2
+ exit 1
+fi
+
+exec sh "$STAGE_DIR/install.sh" "$@"
diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md
new file mode 100644
index 0000000..fd83136
--- /dev/null
+++ b/docs/reference/architecture.md
@@ -0,0 +1,20 @@
+---
+title: Architecture
+updated: 2026-04-15
+status: current
+---
+
+# Architecture
+
+The product still has two layers:
+
+- the POSIX bootstrap in `install.sh`
+- the managed runtime tree rooted at `bashrc`, `bash_profile`, and `bashrc.d/`
+
+The installer/bootstrap contract is now also part of the public release surface:
+
+- the repo-root `install.sh` is the source-tree bootstrap
+- the docs-site `/install.sh` is the published release bootstrap
+- the release archives embed the full installer/runtime tree and are validated before publication
+
+For the full architecture walkthrough, including module loading and installer helper layout, see the source-tree reference page: [ARCHITECTURE.md](../ARCHITECTURE.md).
diff --git a/docs/reference/index.md b/docs/reference/index.md
new file mode 100644
index 0000000..950b5a4
--- /dev/null
+++ b/docs/reference/index.md
@@ -0,0 +1,24 @@
+---
+title: Reference
+updated: 2026-04-15
+status: current
+---
+
+# Reference
+
+```{toctree}
+:maxdepth: 1
+
+architecture
+security
+testing
+supply-chain
+release-checklist
+release-verification
+../CONFIG
+../MODULES
+../STATE
+../DESIGN
+```
+
+The reference section combines the original source-tree docs with the new release and supply-chain surface. The generated installer API pages live under [API](../api/index.md).
diff --git a/docs/reference/release-checklist.md b/docs/reference/release-checklist.md
new file mode 100644
index 0000000..aed8ac2
--- /dev/null
+++ b/docs/reference/release-checklist.md
@@ -0,0 +1,69 @@
+---
+title: Release Checklist
+updated: 2026-04-15
+status: current
+---
+
+# Release Checklist
+
+Use this checklist before and after cutting a release from `main`.
+
+## Before Tagging
+
+1. Confirm `main` branch protection still requires the live CI job contexts and code owner review.
+
+ ```bash
+ make verify-branch-protection
+ ```
+
+ If `.github/workflows/codeql.yml` is already on `main`, this check also expects `CodeQL (actions)` and `CodeQL (python)` to be required.
+
+ If `codeql.yml` has just landed on `main` and live GitHub is still on default setup, run:
+
+ ```bash
+ make reconcile-codeql-governance
+ ```
+
+2. Confirm the live immutable-release posture is either already enabled or still correctly deferred until the draft-first workflow lands on `main`.
+
+ ```bash
+ make verify-immutable-release-governance
+ ```
+
+ If the draft-first workflow has just landed on `main` and GitHub immutable releases are still off, run:
+
+ ```bash
+ make reconcile-immutable-release-governance
+ ```
+
+3. Run the merge-equivalent quality gates.
+
+ ```bash
+ make ci
+ ```
+
+4. Exercise the checked-in release packaging path.
+
+ ```bash
+ make smoke-release
+ make release-validate
+ ```
+
+5. If you are validating an already-published tag, verify the public release surface directly.
+
+ ```bash
+ make verify-published-release TAG=v0.1.0
+ ```
+
+6. Confirm the docs/download surface still points at the release bundles and package-manager channels, and that outbound docs links still pass.
+
+7. Confirm `jbcom/pkgs` is ready to accept the generated Homebrew, Scoop, and Chocolatey manifest PR.
+
+## After Publishing
+
+1. Verify that the release was published from a draft and now contains `get-bashed--unix.tar.gz`, `get-bashed--windows.zip`, and `checksums.txt`.
+2. Verify that published checksums match the uploaded assets.
+3. Verify GitHub attestation for at least one downloaded asset.
+4. If immutable releases are enabled live, confirm the release is marked immutable.
+5. Confirm the docs-site `install.sh` still installs the just-published release.
+6. Confirm the package PR against `jbcom/pkgs` was created and queued for merge.
diff --git a/docs/reference/release-verification.md b/docs/reference/release-verification.md
new file mode 100644
index 0000000..6fd3f71
--- /dev/null
+++ b/docs/reference/release-verification.md
@@ -0,0 +1,54 @@
+---
+title: Release Verification
+updated: 2026-04-15
+status: current
+---
+
+# Release Verification
+
+## Local Emulation
+
+Use the checked-in release scripts before tagging:
+
+```bash
+make verify-branch-protection
+make package-release
+make smoke-release
+make release-validate
+```
+
+`make verify-branch-protection` catches stale GitHub required-check policy before it blocks or silently weakens the release line.
+`make verify-immutable-release-governance` catches drift in the live immutable-release posture once the checked-in draft-first workflow has landed on `main`.
+
+`make package-release` creates the Unix and Windows bundles under `dist/release/`.
+
+`make smoke-release` checks the Unix installer path plus the Windows wrapper bundle structure.
+
+`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. That validation now exercises both the default downloader path and the supported `wget` fallback path so the documented downloader surface stays real.
+The checked-in GitHub workflow follows the same draft-first order GitHub recommends for immutable releases: create a draft, attach the validated assets, then publish it.
+
+## Verify A Published Release
+
+If the tag is already published:
+
+```bash
+make verify-published-release TAG=v0.1.0
+```
+
+That script verifies:
+
+- the release is no longer a draft
+- the exact expected asset set
+- checksum integrity for the downloaded host-native asset
+- GitHub attestation for the downloaded asset
+- the smoke path through `scripts/smoke_test_release_artifact.sh`
+
+## Re-run Manifest Generation
+
+If you have a complete local release dist directory:
+
+```bash
+bash scripts/generate_pkg_manifests.sh 0.1.0 dist/release/checksums.txt dist/release/pkg
+```
+
+That reproduces the Homebrew, Scoop, and Chocolatey metadata that the release workflow publishes into `jbcom/pkgs`.
diff --git a/docs/reference/security.md b/docs/reference/security.md
new file mode 100644
index 0000000..7cb8f04
--- /dev/null
+++ b/docs/reference/security.md
@@ -0,0 +1,42 @@
+---
+title: Security
+updated: 2026-04-15
+status: current
+---
+
+# Security
+
+## Security-Sensitive Surfaces
+
+The highest-risk surfaces in `get-bashed` are:
+
+- bootstrap and installer download paths
+- managed PATH construction and shell startup wiring
+- explicit secrets integration points
+- release bundle and package-manager publication scripts
+
+Those surfaces are expected to stay pinned, reviewable, and covered by checked-in validation.
+
+## Reporting
+
+For reporting instructions, disclosure guidance, and the repository security policy, see the repository root `SECURITY.md` in the source tree and the GitHub Security policy for the repository.
+
+## Validation Hooks
+
+The repo’s security-oriented checks now include:
+
+- `make lint`
+- `make test`
+- `make docs-check`
+- `make verify-security`
+- `make verify-branch-protection`
+- `make verify-immutable-release-governance`
+- `make reconcile-codeql-governance`
+- `make reconcile-immutable-release-governance`
+- `make release-validate`
+
+`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow.
+`make verify-branch-protection` is the authenticated live-policy check for the `main` branch itself.
+`make reconcile-codeql-governance` is the repo-owned post-merge cutover for that change; it retires GitHub default CodeQL setup and patches the live branch required-check list to include the repo-owned CodeQL jobs.
+`make verify-immutable-release-governance` is the authenticated live-policy check for GitHub immutable releases once the draft-first release flow is on `main`.
+`make reconcile-immutable-release-governance` is the repo-owned post-merge cutover for that change; it enables GitHub immutable releases after the checked-in draft-first release flow is live on `main`.
diff --git a/docs/reference/supply-chain.md b/docs/reference/supply-chain.md
new file mode 100644
index 0000000..2566d5e
--- /dev/null
+++ b/docs/reference/supply-chain.md
@@ -0,0 +1,39 @@
+---
+title: Supply Chain
+updated: 2026-04-15
+status: current
+---
+
+# Supply Chain
+
+`get-bashed` is intentionally pinned at every external boundary that it controls:
+
+- bootstrap Homebrew download sources live in `installers/bootstrap_sources.sh`
+- git and curl fallbacks live in `installers/sources.sh`
+- `asdf` default runtime versions are pinned in `installers/sources.sh`
+- release packaging is driven by checked-in scripts rather than ad hoc archive commands
+- release publication follows the draft-first flow required by GitHub immutable releases
+- package-manager manifests are generated from `checksums.txt`, not by hand
+- Dependabot policy is tracked in `.github/dependabot.yml`
+- advanced CodeQL analysis is tracked in `.github/workflows/codeql.yml`, not left as an invisible GitHub default-setup toggle
+- every GitHub Actions workflow starts from explicit top-level `permissions: {}` and only opts into write scopes at the job level
+
+The release workflow validates:
+
+- archive contents
+- checksum integrity
+- docs-site installer behavior
+- generated Homebrew, Scoop, and Chocolatey manifests
+- draft-release upload and publish ordering
+- published release asset shape before opening a PR against `jbcom/pkgs`
+
+The repository also runs a separate `scorecard.yml` workflow so OpenSSF Scorecard can publish results without conflicting with the Pages and release workflows' broader permissions.
+The live GitHub branch-protection policy is checked by `scripts/verify_branch_protection.sh` and exposed as `make verify-branch-protection`, because required status contexts, review requirements, and branch-safety flags live in GitHub configuration rather than the Git tree.
+The broader repository-owned supply-chain gate lives in `scripts/supply_chain_verify.sh` and is exposed as `make verify-security`.
+Once `codeql.yml` lands on `main`, that supply-chain verifier also expects GitHub default CodeQL setup to be disabled and the repo-owned workflow to take over fully.
+The one-time cutover is scripted in `scripts/reconcile_codeql_governance.sh` and exposed as `make reconcile-codeql-governance`.
+Live GitHub vulnerability alerts and automated Dependabot security fixes are also part of that verified production posture when `gh` auth is available.
+So are live secret scanning, push protection, validity checks, and non-provider secret patterns.
+The immutable-release cutover is handled the same way: `scripts/verify_immutable_release_governance.sh` and `make verify-immutable-release-governance` verify the live posture, and `scripts/reconcile_immutable_release_governance.sh` plus `make reconcile-immutable-release-governance` perform the one-time enablement after the checked-in draft-first workflow lands on `main`.
+
+For the broader runtime and secrets policy, see [Security](security.md).
diff --git a/docs/reference/testing.md b/docs/reference/testing.md
new file mode 100644
index 0000000..820c69e
--- /dev/null
+++ b/docs/reference/testing.md
@@ -0,0 +1,37 @@
+---
+title: Testing
+updated: 2026-04-15
+status: current
+---
+
+# Testing
+
+Local quality gates:
+
+```bash
+make lint
+make test
+make docs
+make docs-check
+make verify-security
+make verify-branch-protection
+```
+
+Release-oriented validation adds:
+
+```bash
+make package-release
+make smoke-release
+make release-validate
+make verify-published-release TAG=v0.1.0
+```
+
+CI validates the repo on:
+
+- `ubuntu-latest`
+- `macos-latest`
+- Ubuntu under WSL on `windows-2025`
+
+The Ubuntu and macOS entries also run the checked-in supply-chain verifier.
+
+For the deeper test matrix and runtime-module coverage notes, see [TESTING.md](../TESTING.md).
diff --git a/install.bash b/install.bash
index 44a364c..29c0bef 100755
--- a/install.bash
+++ b/install.bash
@@ -14,711 +14,46 @@ if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
exit 1
fi
-# @description Print usage help.
-# @noargs
-usage() {
- cat <<'USAGE'
-Usage: install.sh [--prefix PATH] [--force] [--with-ui]
- [--auto] [--yes]
- [--profiles minimal|dev|ops[,..]]
- [--features gnu_over_bsd,build_flags,...]
- [--install brew,asdf,doppler,...]
- [--vimrc-mode awesome|basic]
- [--link-dotfiles]
- [--name "Full Name"] [--email "me@example.com"]
- [--list] [--list-profiles] [--list-features] [--list-installers]
- [--dry-run]
-
-Notes:
-- --auto disables prompts.
-- --yes auto-accepts prompts.
-- profiles set defaults; features override defaults.
-USAGE
-}
+if [[ -n "${GET_BASHED_BOOTSTRAP_TMPDIR:-}" ]]; then
+ trap 'rm -rf "$GET_BASHED_BOOTSTRAP_TMPDIR"' EXIT
+fi
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
# shellcheck disable=SC1091
source "$REPO_DIR/installers/_helpers.sh"
# shellcheck disable=SC1091
source "$REPO_DIR/installers/tools.sh"
-PREFIX="${GET_BASHED_HOME:-$HOME/.get-bashed}"
-FORCE=0
-WITH_UI=0
-AUTO=0
-YES=0
-PROFILES=""
-FEATURES=""
-INSTALLS=""
-LIST=0
-DRY_RUN=0
-LIST_PROFILES=0
-LIST_FEATURES=0
-LIST_INSTALLERS=0
-GROUP_INSTALLS=""
-VIMRC_MODE="awesome"
-LINK_DOTFILES=0
-USER_NAME=""
-USER_EMAIL=""
-
-# Feature flags (defaults)
-GET_BASHED_GNU=0
-GET_BASHED_BUILD_FLAGS=0
-GET_BASHED_AUTO_TOOLS=0
-GET_BASHED_SSH_AGENT=0
-GET_BASHED_USE_DOPPLER=0
-GET_BASHED_USE_BASH_IT=0
-GET_BASHED_GIT_SIGNING=0
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --prefix)
- if [[ $# -lt 2 ]]; then
- echo "Error: --prefix requires a value" >&2
- usage
- exit 1
- fi
- PREFIX="$2"; shift 2 ;;
- --force)
- FORCE=1; shift ;;
- --with-ui)
- WITH_UI=1; shift ;;
- --auto|-a)
- AUTO=1; shift ;;
- --yes|-y)
- YES=1; shift ;;
- --profiles|-w)
- if [[ $# -lt 2 ]]; then
- echo "Error: --profiles requires a value" >&2
- usage
- exit 1
- fi
- PROFILES="$2"; shift 2 ;;
- --features)
- if [[ $# -lt 2 ]]; then
- echo "Error: --features requires a value" >&2
- usage
- exit 1
- fi
- FEATURES="$2"; shift 2 ;;
- --install|-i)
- if [[ $# -lt 2 ]]; then
- echo "Error: --install requires a value" >&2
- usage
- exit 1
- fi
- INSTALLS="$2"; shift 2 ;;
- --vimrc-mode)
- if [[ $# -lt 2 ]]; then
- echo "Error: --vimrc-mode requires a value" >&2
- usage
- exit 1
- fi
- VIMRC_MODE="$2"; shift 2 ;;
- --link-dotfiles)
- LINK_DOTFILES=1; shift ;;
- --name|-n)
- if [[ $# -lt 2 ]]; then
- echo "Error: --name requires a value" >&2
- usage
- exit 1
- fi
- USER_NAME="$2"; shift 2 ;;
- --email|-e)
- if [[ $# -lt 2 ]]; then
- echo "Error: --email requires a value" >&2
- usage
- exit 1
- fi
- USER_EMAIL="$2"; shift 2 ;;
- --list)
- LIST=1; shift ;;
- --list-profiles)
- LIST_PROFILES=1; shift ;;
- --list-features)
- LIST_FEATURES=1; shift ;;
- --list-installers)
- LIST_INSTALLERS=1; shift ;;
- --dry-run)
- DRY_RUN=1; shift ;;
- -h|--help)
- usage; exit 0 ;;
- *)
- echo "Unknown argument: $1"; usage; exit 1 ;;
- esac
-done
-
-if [[ "$YES" -eq 1 || "$AUTO" -eq 1 ]]; then
- export GET_BASHED_AUTO_APPROVE=1
-fi
-
-# @description Apply a built-in profile.
-# @arg $1 string Profile name.
-# @exitcode 0 If applied.
-# @exitcode 1 If unknown.
-apply_profile() {
- local p="$1"
- case "$p" in
- minimal)
- GET_BASHED_GNU=0
- GET_BASHED_BUILD_FLAGS=0
- GET_BASHED_AUTO_TOOLS=0
- GET_BASHED_SSH_AGENT=0
- GET_BASHED_USE_DOPPLER=0
- ;;
- dev)
- GET_BASHED_GNU=1
- GET_BASHED_BUILD_FLAGS=1
- GET_BASHED_AUTO_TOOLS=1
- GET_BASHED_SSH_AGENT=0
- GET_BASHED_USE_DOPPLER=0
- ;;
- ops)
- GET_BASHED_GNU=1
- GET_BASHED_BUILD_FLAGS=1
- GET_BASHED_AUTO_TOOLS=1
- GET_BASHED_SSH_AGENT=1
- GET_BASHED_USE_DOPPLER=1
- ;;
- *)
- return 1
- ;;
- esac
-}
-
-# @description Apply a feature toggle.
-# @arg $1 string Feature name (supports no- prefix).
-# @exitcode 0 If applied.
-# @exitcode 1 If unknown.
-apply_feature() {
- local f="$1" v=1
- if [[ "$f" == no-* ]]; then
- v=0
- f="${f#no-}"
- fi
- case "$f" in
- gnu_over_bsd) GET_BASHED_GNU=$v ;;
- build_flags) GET_BASHED_BUILD_FLAGS=$v ;;
- auto_tools) GET_BASHED_AUTO_TOOLS=$v ;;
- ssh_agent) GET_BASHED_SSH_AGENT=$v ;;
- doppler_env) GET_BASHED_USE_DOPPLER=$v ;;
- bash_it)
- GET_BASHED_USE_BASH_IT=$v
- if [[ "$v" -eq 1 ]]; then
- GROUP_INSTALLS="${GROUP_INSTALLS},bash_it"
- fi
- ;;
- git_signing) GET_BASHED_GIT_SIGNING=$v ;;
- dev_tools) GROUP_INSTALLS="${GROUP_INSTALLS},rg,fd,bat,eza,fzf,jq,yq,tree,direnv,starship,nodejs,python,bash" ;;
- ops_tools) GROUP_INSTALLS="${GROUP_INSTALLS},gh,git_lfs,terraform,awscli,kubectl,helm,stern,doppler,eza,nodejs,python,java,bash" ;;
- *) return 1 ;;
- esac
-}
-
-# @description Split a comma-delimited list into space-delimited output.
-# @arg $1 string Comma list.
-# @stdout Space-delimited items.
-split_csv() {
- local s="$1"; IFS=',' read -r -a _parts <<<"$s"; echo "${_parts[@]}";
-}
-
-# @internal
-is_valid_profile() {
- case "$1" in
- minimal|dev|ops) return 0 ;;
- *) return 1 ;;
- esac
-}
-
-# @internal
-apply_gitconfig() {
- local cfg="$PREFIX/gitconfig"
- [[ -r "$cfg" ]] || return 0
-
- if [[ -n "$USER_NAME" ]]; then
- git config -f "$cfg" user.name "$USER_NAME"
- fi
- if [[ -n "$USER_EMAIL" ]]; then
- git config -f "$cfg" user.email "$USER_EMAIL"
- fi
-}
-
-# @internal
-ensure_block() {
- local file="$1" marker="$2" snippet="$3"
- mkdir -p "$(dirname "$file")"
- if [[ -r "$file" ]]; then
- if grep -Fq "$marker" "$file"; then
- return 0
- fi
- fi
- {
- echo ""
- echo "$marker"
- echo "$snippet"
- } >> "$file"
-}
-
-# @internal
-backup_file() {
- local file="$1"
- [[ -e "$file" ]] || return 0
- local backup_dir="$PREFIX/backup"
- mkdir -p "$backup_dir"
- chmod 700 "$backup_dir"
- local base
- base="$(basename "$file")"
- base="${base#.}"
- local ts
- ts="$(date +%s)"
- mv "$file" "$backup_dir/${base}.${ts}"
-}
-
-# @internal
-link_dotfile() {
- local name="$1"
- local src="$PREFIX/$name"
- local dest="$HOME/.${name}"
- if [[ ! -e "$src" ]]; then
- return 0
- fi
- if [[ -L "$dest" ]]; then
- local current
- current="$(readlink "$dest" || true)"
- if [[ "$current" == "$src" ]]; then
- return 0
- fi
- backup_file "$dest"
- elif [[ -e "$dest" ]]; then
- backup_file "$dest"
- fi
- ln -s "$src" "$dest"
-}
-
-# @internal
-install_dialog() {
- if command -v dialog >/dev/null 2>&1; then
- return 0
- fi
- if command -v brew >/dev/null 2>&1; then
- brew install dialog
- elif command -v apt-get >/dev/null 2>&1; then
- apt_install dialog
- elif command -v dnf >/dev/null 2>&1; then
- dnf_install dialog
- elif command -v yum >/dev/null 2>&1; then
- yum_install dialog
- fi
-}
-
-# @internal
-prompt_yes_no() {
- local label="$1" answer
- if [[ "$YES" -eq 1 ]]; then
- return 0
- fi
- read -r -p "$label [y/N]: " answer
- [[ "$answer" =~ ^[Yy]$ ]]
-}
-
-if [[ "$WITH_UI" -eq 1 ]] && [[ "$AUTO" -eq 0 ]]; then
- install_dialog || true
-fi
-
-# If stdin isn't a TTY, default to non-interactive.
-if [[ ! -t 0 ]] && [[ "$AUTO" -eq 0 ]]; then
- AUTO=1
-fi
-
-# Load installer registry early for interactive UI.
-load_installers
-: "${INSTALLERS:=}"
-
-# Preserve CLI features/installers so profiles do not clobber them.
-CLI_FEATURES="${FEATURES:-}"
-CLI_INSTALLS="${INSTALLS:-}"
-FEATURES=""
-INSTALLS=""
-
-# Apply profiles first
-if [[ -n "$PROFILES" ]]; then
- for p in $(split_csv "$PROFILES"); do
- if ! is_valid_profile "$p"; then
- echo "Invalid profile name: $p" >&2
- exit 1
- fi
- # Load profile file if present
- PROFILE_FILE="$REPO_DIR/profiles/${p}.env"
- if [[ -r "$PROFILE_FILE" ]]; then
- # shellcheck disable=SC1090
- source "$PROFILE_FILE"
- PROFILE_FEATURES="${FEATURES:-}"
- if [[ -n "$PROFILE_FEATURES" ]]; then
- for f in $(split_csv "$PROFILE_FEATURES"); do
- apply_feature "$f" || { echo "Unknown feature: $f"; exit 1; }
- done
- fi
- if [[ -n "${INSTALLS:-}" ]]; then
- GROUP_INSTALLS="${GROUP_INSTALLS},${INSTALLS}"
- fi
- FEATURES=""
- INSTALLS=""
- else
- apply_profile "$p" || { echo "Unknown profile: $p"; exit 1; }
- fi
- done
-fi
-
-FEATURES="$CLI_FEATURES"
-INSTALLS="$CLI_INSTALLS"
-
-# Apply features overrides
-if [[ -n "$FEATURES" ]]; then
- for f in $(split_csv "$FEATURES"); do
- apply_feature "$f" || { echo "Unknown feature: $f"; exit 1; }
- done
-fi
-
-# Interactive selection
-if [[ "$AUTO" -eq 0 ]]; then
- if [[ "$WITH_UI" -eq 1 ]] && command -v dialog >/dev/null 2>&1; then
- if [[ "$YES" -ne 1 ]]; then
- PROFILE_CHOICE=$(dialog --clear --title "get-bashed" --menu "Select a profile" 12 60 3 \
- minimal "Minimal defaults" \
- dev "Developer workstation" \
- ops "Ops/Platform workstation" \
- 3>&1 1>&2 2>&3) || true
- if [[ -n "$PROFILE_CHOICE" ]]; then
- apply_profile "$PROFILE_CHOICE"
- fi
-
- CHOICES=$(dialog --clear --title "get-bashed" --checklist "Enable features" 18 70 8 \
- gnu_over_bsd "Prefer GNU tools on macOS" "$( [[ "$GET_BASHED_GNU" -eq 1 ]] && echo on || echo off )" \
- build_flags "Enable runtime build flags" "$( [[ "$GET_BASHED_BUILD_FLAGS" -eq 1 ]] && echo on || echo off )" \
- auto_tools "Auto-install optional tools" "$( [[ "$GET_BASHED_AUTO_TOOLS" -eq 1 ]] && echo on || echo off )" \
- ssh_agent "Auto-start ssh-agent" "$( [[ "$GET_BASHED_SSH_AGENT" -eq 1 ]] && echo on || echo off )" \
- doppler_env "Enable Doppler env usage" "$( [[ "$GET_BASHED_USE_DOPPLER" -eq 1 ]] && echo on || echo off )" \
- bash_it "Enable bash-it (if installed)" "$( [[ "$GET_BASHED_USE_BASH_IT" -eq 1 ]] && echo on || echo off )" \
- git_signing "Enable git signing (gnupg)" "$( [[ "$GET_BASHED_GIT_SIGNING" -eq 1 ]] && echo on || echo off )" \
- dev_tools "Developer tool bundle" off \
- ops_tools "Ops tool bundle" off \
- 3>&1 1>&2 2>&3) || true
-
- GET_BASHED_GNU=0
- GET_BASHED_BUILD_FLAGS=0
- GET_BASHED_AUTO_TOOLS=0
- GET_BASHED_SSH_AGENT=0
- GET_BASHED_USE_DOPPLER=0
- GET_BASHED_USE_BASH_IT=0
- GET_BASHED_GIT_SIGNING=0
-
- for choice in $CHOICES; do
- apply_feature "${choice//\"/}" || true
- done
-
- dialog_opts=()
- # shellcheck disable=SC2153
- for id in $INSTALLERS; do
- desc_var="INSTALL_DESC_${id}"
- desc="${!desc_var}"
- [[ -z "$desc" ]] && desc="$id"
- default_state="off"
- if [[ "$id" == "dialog" ]]; then
- default_state="on"
- fi
- dialog_opts+=("$id" "$desc" "$default_state")
- done
-
- INSTALLS_DIALOG=$(dialog --clear --title "get-bashed" --checklist "Select installers" 20 80 12 \
- "${dialog_opts[@]}" \
- 3>&1 1>&2 2>&3) || true
- if [[ -n "$INSTALLS_DIALOG" ]]; then
- INSTALLS="${INSTALLS_DIALOG//\"/}"
- INSTALLS="${INSTALLS// /,}"
- fi
-
- if [[ -z "$USER_NAME" ]]; then
- USER_NAME=$(dialog --clear --title "get-bashed" --inputbox "Git user.name" 8 60 "${USER_NAME}" 3>&1 1>&2 2>&3) || true
- fi
- if [[ -z "$USER_EMAIL" ]]; then
- USER_EMAIL=$(dialog --clear --title "get-bashed" --inputbox "Git user.email" 8 60 "${USER_EMAIL}" 3>&1 1>&2 2>&3) || true
- fi
- fi
- else
- if [[ "$YES" -eq 0 ]]; then
- prompt_yes_no "Proceed with installation?" || exit 1
- fi
- if prompt_yes_no "Enable GNU tools on macOS (gnu_over_bsd)?"; then GET_BASHED_GNU=1; fi
- if prompt_yes_no "Enable build flags (build_flags)?"; then GET_BASHED_BUILD_FLAGS=1; fi
- if prompt_yes_no "Enable auto tools (auto_tools)?"; then GET_BASHED_AUTO_TOOLS=1; fi
- if prompt_yes_no "Enable ssh-agent (ssh_agent)?"; then GET_BASHED_SSH_AGENT=1; fi
- if prompt_yes_no "Enable doppler env (doppler_env)?"; then GET_BASHED_USE_DOPPLER=1; fi
- if prompt_yes_no "Enable bash-it (bash_it)?"; then GET_BASHED_USE_BASH_IT=1; fi
- if prompt_yes_no "Enable git signing (git_signing)?"; then GET_BASHED_GIT_SIGNING=1; fi
- fi
-fi
-
-if [[ "$LIST" -eq 1 ]]; then
- echo "Profiles: minimal, dev, ops"
- echo "Features:"
- echo " gnu_over_bsd"
- echo " build_flags"
- echo " auto_tools"
- echo " ssh_agent"
- echo " doppler_env"
- echo " bash_it"
- echo " git_signing"
- echo " dev_tools"
- echo " ops_tools"
- echo "Installers:"
- # shellcheck disable=SC2153
- for id in $INSTALLERS; do
- echo " $id"
- done
- exit 0
-fi
-
-if [[ "$LIST_PROFILES" -eq 1 ]]; then
- echo "minimal"
- echo "dev"
- echo "ops"
- exit 0
-fi
-
-if [[ "$LIST_FEATURES" -eq 1 ]]; then
- echo "gnu_over_bsd"
- echo "build_flags"
- echo "auto_tools"
- echo "ssh_agent"
- echo "doppler_env"
- echo "bash_it"
- echo "git_signing"
- echo "dev_tools"
- echo "ops_tools"
- exit 0
-fi
-
-if [[ "$LIST_INSTALLERS" -eq 1 ]]; then
- for id in $INSTALLERS; do
- desc_var="INSTALL_DESC_${id}"
- desc="${!desc_var}"
- [[ -z "$desc" ]] && desc="$id"
- echo " - $id ($desc)"
- done
- exit 0
-fi
-
-if [[ "$DRY_RUN" -eq 1 ]]; then
- echo "Dry run enabled. No changes will be made."
- echo " Prefix: $PREFIX"
- echo " Profiles: ${PROFILES:-}"
- echo " Features: gnu_over_bsd=${GET_BASHED_GNU} build_flags=${GET_BASHED_BUILD_FLAGS} auto_tools=${GET_BASHED_AUTO_TOOLS} ssh_agent=${GET_BASHED_SSH_AGENT} doppler_env=${GET_BASHED_USE_DOPPLER} bash_it=${GET_BASHED_USE_BASH_IT} git_signing=${GET_BASHED_GIT_SIGNING}"
- echo " Installers: ${INSTALLS:-}"
-fi
-
-# @internal
-migrate_legacy() {
- local legacy_rc_d="$HOME/.bashrc.d"
- local legacy_secrets_d="$HOME/.secrets.d"
-
- if [[ -d "$legacy_rc_d" && ! -L "$legacy_rc_d" ]]; then
- echo "Migrating legacy .bashrc.d to $PREFIX/bashrc.d..."
- mkdir -p "$PREFIX/bashrc.d"
- # Copy files, avoid error if empty
- find "$legacy_rc_d" -type f -exec cp -p {} "$PREFIX/bashrc.d/" \;
- rm -rf "$legacy_rc_d"
- fi
-
- if [[ -d "$legacy_secrets_d" && ! -L "$legacy_secrets_d" ]]; then
- echo "Migrating legacy .secrets.d to $PREFIX/secrets.d..."
- mkdir -p "$PREFIX/secrets.d"
- chmod 700 "$PREFIX/secrets.d"
- find "$legacy_secrets_d" -type f -exec cp -p {} "$PREFIX/secrets.d/" \;
- rm -rf "$legacy_secrets_d"
- fi
-
- # Detect and fix "template as file" loop hazard
- for f in "$HOME/.bashrc" "$HOME/.bash_profile"; do
- if [[ -f "$f" && ! -L "$f" ]]; then
- if grep -q "@file bashrc" "$f" || grep -q "@file bash_profile" "$f"; then
- if [[ "$LINK_DOTFILES" -eq 0 ]]; then
- echo "Detected recursive loop hazard in $f. Cleaning..."
- backup_file "$f"
- echo "# get-bashed: recovered from loop" > "$f"
- fi
- fi
- fi
- done
-}
-
-mkdir -p "$PREFIX"
-export GET_BASHED_HOME="$PREFIX"
-export GET_BASHED_VIMRC_MODE="$VIMRC_MODE"
-
-copy_tree() {
- local src="$1" dest="$2"
- mkdir -p "$dest"
- if [[ "${FORCE:-0}" -eq 1 ]]; then
- rsync -a --delete "$src"/ "$dest"/
- else
- rsync -a "$src"/ "$dest"/
- fi
-}
-
-# Copy base assets
-copy_tree "$REPO_DIR/bashrc.d" "$PREFIX/bashrc.d"
-cp -f "$REPO_DIR/bashrc" "$PREFIX/bashrc"
-cp -f "$REPO_DIR/bash_profile" "$PREFIX/bash_profile"
-cp -f "$REPO_DIR/bash_aliases" "$PREFIX/bash_aliases"
-cp -f "$REPO_DIR/inputrc" "$PREFIX/inputrc"
-cp -f "$REPO_DIR/vimrc" "$PREFIX/vimrc"
-cp -f "$REPO_DIR/gitconfig" "$PREFIX/gitconfig"
-
-migrate_legacy
-
-# secrets.d bootstrap (only inside GET_BASHED_HOME)
-mkdir -p "$PREFIX/secrets.d"
-chmod 700 "$PREFIX/secrets.d"
-if [[ ! -e "$PREFIX/secrets.d/00-local.sh" ]]; then
- (
- umask 077
- cat <<'__SECRETS__' > "$PREFIX/secrets.d/00-local.sh"
-# Place local secrets here. Example:
-# export FOO="bar"
-__SECRETS__
- )
-fi
-
-# Write config file
-{
- echo "# Generated by get-bashed installer"
- echo "export GET_BASHED_GNU=${GET_BASHED_GNU}"
- echo "export GET_BASHED_BUILD_FLAGS=${GET_BASHED_BUILD_FLAGS}"
- echo "export GET_BASHED_AUTO_TOOLS=${GET_BASHED_AUTO_TOOLS}"
- echo "export GET_BASHED_SSH_AGENT=${GET_BASHED_SSH_AGENT}"
- echo "export GET_BASHED_USE_DOPPLER=${GET_BASHED_USE_DOPPLER}"
- echo "export GET_BASHED_USE_BASH_IT=${GET_BASHED_USE_BASH_IT}"
- echo "export GET_BASHED_GIT_SIGNING=${GET_BASHED_GIT_SIGNING}"
- echo "export GET_BASHED_VIMRC_MODE=\"${GET_BASHED_VIMRC_MODE}\""
- if [[ -n "$USER_NAME" ]]; then
- _escaped="${USER_NAME//\\/\\\\}" # escape backslashes first
- _escaped="${_escaped//\$/\\\$}" # escape dollar signs
- _escaped="${_escaped//\"/\\\"}" # escape double quotes
- _escaped="${_escaped//\`/\\\`}" # escape backticks
- echo "export GET_BASHED_USER_NAME=\"${_escaped}\""
- fi
- if [[ -n "$USER_EMAIL" ]]; then
- _escaped="${USER_EMAIL//\\/\\\\}" # escape backslashes first
- _escaped="${_escaped//\$/\\\$}" # escape dollar signs
- _escaped="${_escaped//\"/\\\"}" # escape double quotes
- _escaped="${_escaped//\`/\\\`}" # escape backticks
- echo "export GET_BASHED_USER_EMAIL=\"${_escaped}\""
- fi
-} > "$PREFIX/get-bashedrc.sh"
-
-apply_gitconfig
-
-# Link dotfiles if requested (into $HOME only)
-if [[ "$LINK_DOTFILES" -eq 1 ]]; then
- link_dotfile "bashrc"
- link_dotfile "bash_profile"
- link_dotfile "inputrc"
- link_dotfile "bash_aliases"
- link_dotfile "vimrc"
- if [[ -n "$USER_NAME" && -n "$USER_EMAIL" ]]; then
- link_dotfile "gitconfig"
- else
- echo "Skipping gitconfig link (missing --name/--email)." >&2
- fi
-else
- # Update login shell snippets (idempotent)
- # shellcheck disable=SC2016
-BASHRC_LINE="# get-bashed: source modular bashrc"
- # shellcheck disable=SC2016
-BASHRC_SNIP="if [[ -r \"$PREFIX/bashrc\" ]]; then source \"$PREFIX/bashrc\"; fi"
-BASH_PROFILE_LINE="# get-bashed: source login bash_profile"
- # shellcheck disable=SC2016
-BASH_PROFILE_SNIP="if [[ -r \"$PREFIX/bash_profile\" ]]; then source \"$PREFIX/bash_profile\"; fi"
-
- ensure_block "$HOME/.bashrc" "$BASHRC_LINE" "$BASHRC_SNIP"
- ensure_block "$HOME/.bash_profile" "$BASH_PROFILE_LINE" "$BASH_PROFILE_SNIP"
-fi
-
-# Installers
-if [[ -n "$INSTALLS" ]]; then
- INSTALLS="${INSTALLS},${GROUP_INSTALLS#,}"
-else
- INSTALLS="${GROUP_INSTALLS#,}"
-fi
-
-declare -A INSTALL_IN_PROGRESS=()
-declare -A INSTALL_DONE=()
-
-get_deps() {
- local id="$1"
- _ensure_tools_loaded
- local deps="${TOOL_DEPS[$id]:-}"
- local opt="${TOOL_OPT_DEPS[$id]:-}"
- if [[ -n "$opt" ]]; then
- IFS=',' read -r -a _opt_specs <<<"$opt"
- for spec in "${_opt_specs[@]}"; do
- local flag="${spec%%:*}"
- local dep="${spec#*:}"
- if [[ -n "${!flag:-}" && "${!flag}" != "0" ]]; then
- deps="${deps},${dep}"
- fi
- done
- fi
- echo "${deps#,}"
-}
-
-is_done() {
- local id="$1"
- [[ "${INSTALL_DONE[$id]:-}" == "1" ]]
-}
+# shellcheck disable=SC1091
+source "$REPO_DIR/installlib/config.sh"
+# shellcheck disable=SC1091
+source "$REPO_DIR/installlib/resolve.sh"
+# shellcheck disable=SC1091
+source "$REPO_DIR/installlib/ui.sh"
+# shellcheck disable=SC1091
+source "$REPO_DIR/installlib/filesystem.sh"
+# shellcheck disable=SC1091
+source "$REPO_DIR/installlib/installers.sh"
-mark_done() {
- local id="$1"
- INSTALL_DONE["$id"]=1
-}
+main() {
+ init_install_state
+ parse_args "$@"
+ prepare_interactive_mode
+ resolve_requested_state
+ handle_list_commands
+ run_interactive_selection
+ finalize_requested_state
-run_install() {
- local id="$1"
- if is_done "$id"; then
- return 0
- fi
- if [[ "${INSTALL_IN_PROGRESS[$id]:-}" == "1" ]]; then
- echo "Circular dependency detected while installing $id" >&2
- return 1
- fi
- INSTALL_IN_PROGRESS["$id"]=1
- local deps
- deps="$(get_deps "$id")"
- if [[ -n "$deps" ]]; then
- for dep in $(split_csv "$deps"); do
- run_install "$dep" || return 1
- done
- fi
if [[ "$DRY_RUN" -eq 1 ]]; then
- echo "would install: $id"
- else
- if declare -f "install_${id}" >/dev/null 2>&1; then
- "install_${id}"
- else
- install_tool "$id"
- fi
+ print_dry_run_summary
+ run_selected_installers
+ exit 0
fi
- unset "INSTALL_IN_PROGRESS[$id]"
- mark_done "$id"
-}
-if [[ -n "$INSTALLS" ]]; then
- for id in $(split_csv "$INSTALLS"); do
- run_install "$id"
- done
-fi
+ install_managed_assets
+ run_selected_installers
-if [[ "$DRY_RUN" -eq 1 ]]; then
- exit 0
-fi
+ echo "get-bashed installed to $PREFIX"
+}
-echo "get-bashed installed to $PREFIX"
+main "$@"
diff --git a/install.sh b/install.sh
index 00e1fb3..e9a541d 100755
--- a/install.sh
+++ b/install.sh
@@ -1,64 +1,203 @@
#!/bin/sh
-# Minimal POSIX bootstrap. Installs/locates bash and hands off to the bash installer.
+# Minimal POSIX bootstrap. Installs or locates Bash 4+ and hands off to install.bash.
set -eu
+BOOTSTRAP_SOURCES_FILE="$(CDPATH='' cd -- "$(dirname "$0")" && pwd)/installers/bootstrap_sources.sh"
+if [ -r "$BOOTSTRAP_SOURCES_FILE" ]; then
+ # shellcheck disable=SC1090,SC1091
+ . "$BOOTSTRAP_SOURCES_FILE"
+fi
+
+: "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}"
+: "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}"
+: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}"
+
fail() {
printf '%s\n' "$*" >&2
exit 1
}
-ensure_bash() {
- brew_bin=""
- if command -v brew >/dev/null 2>&1; then
- brew_bin="$(command -v brew)"
- elif [ -x "/opt/homebrew/bin/brew" ]; then
- brew_bin="/opt/homebrew/bin/brew"
- elif [ -x "/usr/local/bin/brew" ]; then
- brew_bin="/usr/local/bin/brew"
- fi
+script_dir() {
+ CDPATH='' cd -- "$(dirname "$0")" && pwd
+}
+
+has_repo_tree() {
+ repo_dir="$1"
+ [ -r "$repo_dir/install.bash" ] || return 1
+ [ -r "$repo_dir/installers/_helpers.sh" ] || return 1
+}
+bash_major_version() {
+ candidate="$1"
+ # shellcheck disable=SC2016
+ "$candidate" -c 'printf "%s" "${BASH_VERSINFO[0]:-0}"' 2>/dev/null || printf '0'
+}
+
+is_modern_bash() {
+ candidate="$1"
+ [ -n "$candidate" ] || return 1
+ [ -x "$candidate" ] || return 1
+ major="$(bash_major_version "$candidate")"
+ [ "$major" -ge 4 ]
+}
+
+find_modern_bash() {
+ candidates="${GET_BASHED_BOOTSTRAP_BASH_CANDIDATES:-/opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash}"
+
+ for candidate in $candidates; do
+ if is_modern_bash "$candidate"; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+
+ path_bash=""
if command -v bash >/dev/null 2>&1; then
- return 0
+ path_bash="$(command -v bash)"
fi
- if command -v /opt/homebrew/bin/bash >/dev/null 2>&1; then
+ if [ -n "$path_bash" ] && is_modern_bash "$path_bash"; then
+ printf '%s\n' "$path_bash"
return 0
fi
- if command -v /usr/local/bin/bash >/dev/null 2>&1; then
+
+ return 1
+}
+
+find_brew_bin() {
+ candidates="${GET_BASHED_BOOTSTRAP_BREW_CANDIDATES:-/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew}"
+
+ if command -v brew >/dev/null 2>&1; then
+ command -v brew
return 0
fi
- if [ -n "$brew_bin" ]; then
- "$brew_bin" install bash
+ for candidate in $candidates; do
+ if [ -x "$candidate" ]; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+download_bootstrap_asset() {
+ url="$1"
+ dest="$2"
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$url" -o "$dest"
return 0
fi
- if command -v apt-get >/dev/null 2>&1; then
- sudo apt-get update -y
- sudo apt-get install -y bash
+ if command -v wget >/dev/null 2>&1; then
+ wget -qO "$dest" "$url"
return 0
fi
- if command -v dnf >/dev/null 2>&1; then
- sudo dnf install -y bash
+
+ return 1
+}
+
+bootstrap_repo_tree() {
+ tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)"
+ archive="$tmpdir/get-bashed.tar.gz"
+ extract_dir="$tmpdir/src"
+ repo_dir=""
+
+ if ! download_bootstrap_asset "$GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL" "$archive"; then
+ rm -rf "$tmpdir"
+ fail "Standalone bootstrap requires curl or wget to fetch the get-bashed sources."
+ fi
+
+ mkdir -p "$extract_dir"
+ if ! tar -xzf "$archive" -C "$extract_dir"; then
+ rm -rf "$tmpdir"
+ fail "Failed to extract the get-bashed source archive."
+ fi
+
+ for candidate in "$extract_dir"/*; do
+ if [ -d "$candidate" ] && has_repo_tree "$candidate"; then
+ repo_dir="$candidate"
+ break
+ fi
+ done
+
+ if [ -z "$repo_dir" ]; then
+ rm -rf "$tmpdir"
+ fail "Fetched get-bashed sources are incomplete."
+ fi
+
+ GET_BASHED_BOOTSTRAP_TMPDIR="$tmpdir"
+ printf '%s\n' "$repo_dir"
+}
+
+resolve_repo_dir() {
+ current_dir="$(script_dir)"
+ if has_repo_tree "$current_dir"; then
+ printf '%s\n' "$current_dir"
return 0
fi
- if command -v yum >/dev/null 2>&1; then
- sudo yum install -y bash
+
+ bootstrap_repo_tree
+}
+
+bootstrap_homebrew() {
+ tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)"
+ installer="$tmpdir/homebrew-install.sh"
+
+ if ! download_bootstrap_asset "$GET_BASHED_BOOTSTRAP_BREW_URL" "$installer"; then
+ rm -rf "$tmpdir"
+ fail "Homebrew bootstrap requires curl or wget."
+ fi
+
+ if [ "${CI:-}" = "1" ] || [ ! -t 0 ]; then
+ NONINTERACTIVE=1 "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer"
+ else
+ "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer"
+ fi
+
+ rm -rf "$tmpdir"
+}
+
+ensure_modern_bash() {
+ brew_bin="$(find_brew_bin 2>/dev/null || true)"
+
+ if find_modern_bash >/dev/null 2>&1; then
+ find_modern_bash
return 0
fi
- if command -v pacman >/dev/null 2>&1; then
+
+ if [ -n "$brew_bin" ]; then
+ "$brew_bin" install bash
+ elif [ -n "$GET_BASHED_BOOTSTRAP_BREW_URL" ]; then
+ bootstrap_homebrew
+ brew_bin="$(find_brew_bin 2>/dev/null || true)"
+ if [ -z "$brew_bin" ]; then
+ fail "Bash 4+ is required, and Homebrew bootstrap did not produce a brew executable."
+ fi
+ "$brew_bin" install bash
+ elif command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update -y
+ sudo apt-get install -y bash
+ elif command -v dnf >/dev/null 2>&1; then
+ sudo dnf install -y bash
+ elif command -v yum >/dev/null 2>&1; then
+ sudo yum install -y bash
+ elif command -v pacman >/dev/null 2>&1; then
sudo pacman -Sy --noconfirm bash
+ else
+ fail "Bash 4+ is required but no supported installer was found."
+ fi
+
+ if find_modern_bash >/dev/null 2>&1; then
+ find_modern_bash
return 0
fi
- fail "Bash is required but was not found or installed. Install bash and re-run."
+ fail "Bash 4+ is required but could not be located after installation."
}
-ensure_bash
-
-if [ -x "/opt/homebrew/bin/bash" ]; then
- exec /opt/homebrew/bin/bash "$(dirname "$0")/install.bash" "$@"
-fi
-if [ -x "/usr/local/bin/bash" ]; then
- exec /usr/local/bin/bash "$(dirname "$0")/install.bash" "$@"
-fi
-exec bash "$(dirname "$0")/install.bash" "$@"
+repo_dir="$(resolve_repo_dir)"
+bootstrap_bash="$(ensure_modern_bash)"
+[ -n "${GET_BASHED_BOOTSTRAP_TMPDIR:-}" ] && export GET_BASHED_BOOTSTRAP_TMPDIR
+exec "$bootstrap_bash" "$repo_dir/install.bash" "$@"
diff --git a/installers/README.md b/installers/README.md
index 2405623..405e19e 100644
--- a/installers/README.md
+++ b/installers/README.md
@@ -1,10 +1,11 @@
# installers
Installers are defined in `installers/tools.sh` as a registry.
+Pinned sources and default runtime versions live in `installers/sources.sh`.
Each tool declares:
- ID, description, deps, platforms
- supported install methods (brew/apt/dnf/yum/pacman/pipx/git/curl/handler)
- optional package name overrides
-Handlers live in `installers/_helpers.sh` for tools that need custom logic.
+Handlers are exposed through `installers/_helpers.sh` and implemented in `installers/lib/`.
diff --git a/installers/_helpers.sh b/installers/_helpers.sh
old mode 100755
new mode 100644
index ad474a8..3489ee2
--- a/installers/_helpers.sh
+++ b/installers/_helpers.sh
@@ -3,676 +3,16 @@
# @file installers-helpers
# @brief Shared helpers for installers.
# @description
-# Provides platform detection and package manager helpers used by
-# installer scripts.
-
-# @internal
-_using_asdf() { command -v asdf >/dev/null 2>&1; }
-
-# @internal
-_brew_bin() {
- if command -v brew >/dev/null 2>&1; then
- command -v brew
- return 0
- fi
- if [[ -x "/opt/homebrew/bin/brew" ]]; then
- echo "/opt/homebrew/bin/brew"
- return 0
- fi
- if [[ -x "/usr/local/bin/brew" ]]; then
- echo "/usr/local/bin/brew"
- return 0
- fi
- return 1
-}
-
-# @internal
-_using_brew() { _brew_bin >/dev/null 2>&1; }
-
-# @internal
-brew_exec() {
- local brew_bin
- brew_bin="$(_brew_bin)" || return 1
- "$brew_bin" "$@"
-}
-
-# @internal
-_using_git() { command -v git >/dev/null 2>&1; }
-
-# @internal
-_using_system() {
- command -v apt-get >/dev/null 2>&1 || \
- command -v dnf >/dev/null 2>&1 || \
- command -v yum >/dev/null 2>&1 || \
- command -v pacman >/dev/null 2>&1
-}
-
-# @internal
-_using_curl() { command -v curl >/dev/null 2>&1; }
-
-# @internal
-_using_pipx() { command -v pipx >/dev/null 2>&1; }
-
-# @internal
-_using_pip() { command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; }
-
-# @internal
-_tools_loaded() { [[ -n "${TOOL_IDS[*]:-}" ]]; }
-
-# @internal
-_ensure_tools_loaded() {
- _tools_loaded && return 0
- local repo_dir
- repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
- # shellcheck disable=SC1090,SC1091
- source "$repo_dir/installers/tools.sh"
-}
-
-# @internal
-_auto_approved() { [[ "${GET_BASHED_AUTO_APPROVE:-0}" == "1" ]]; }
-
-# @description Run a command with auto-approval when configured.
-# @arg $1 string Command name.
-# @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm).
-# @arg $3 string Optional extra flag (e.g., --assume-yes).
-# @arg $4 string Optional extra flag (e.g., --yes).
-# @arg $5 string Optional extra flag (e.g., --confirm).
-# @arg $6 string Optional extra flag (e.g., --no-confirm).
-auto_exec() {
- local cmd="$1"; shift
- local -a flags=()
- if _auto_approved; then
- while [[ $# -gt 0 ]]; do
- [[ -n "$1" ]] && flags+=("$1")
- shift
- done
- else
- while [[ $# -gt 0 ]]; do
- shift
- done
- fi
- "$cmd" "${flags[@]}"
-}
-
-# @internal
-apt_install() {
- sudo apt-get update
- if _auto_approved; then
- sudo apt-get install -y "$@"
- else
- sudo apt-get install "$@"
- fi
-}
-
-# @internal
-dnf_install() {
- if _auto_approved; then
- sudo dnf install -y "$@"
- else
- sudo dnf install "$@"
- fi
-}
-
-# @internal
-yum_install() {
- if _auto_approved; then
- sudo yum install -y "$@"
- else
- sudo yum install "$@"
- fi
-}
-
-# @internal
-pacman_install() {
- if _auto_approved; then
- sudo pacman -Sy --noconfirm "$@"
- else
- sudo pacman -Sy "$@"
- fi
-}
-
-# Known install sources (git/curl)
-declare -A GET_BASHED_GIT_SOURCES=(
- ["bash_it"]="https://github.com/Bash-it/bash-it.git"
- ["vimrc"]="https://github.com/amix/vimrc.git"
- ["shdoc"]="https://github.com/reconquest/shdoc"
-)
-
-declare -A GET_BASHED_GIT_POST=(
- ["vimrc"]="install_awesome_vimrc.sh"
-)
-
-declare -A GET_BASHED_CURL_SOURCES=(
- ["brew"]="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"
-)
-
-declare -A GET_BASHED_CURL_CMD=(
- ["brew"]="/bin/bash"
-)
-
-# @internal
-_bash_it_available() {
- [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]] || return 1
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- [[ -r "$prefix/vendor/bash-it/bash_it.sh" ]]
-}
-
-# @internal
-_bash_it_search() {
- local action="$1"; shift
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- local bash_it="$prefix/vendor/bash-it"
- # shellcheck disable=SC1090,SC1091
- source "$bash_it/bash_it.sh"
- NO_COLOR=1 bash-it search "$@" "--${action}"
-}
-
-# @description Install a component using available methods.
-# @arg $1 string Action (enable|disable|install).
-# @arg $2 string Term to resolve/install.
-component_install() {
- local action="$1" term="$2"
- shift 2 || true
-
- if [[ "$action" == "enable" || "$action" == "disable" ]]; then
- if _bash_it_available; then
- _bash_it_search "$action" "$term" "$@"
- return 0
- fi
- action="install"
- fi
-
- if [[ "$action" != "install" ]]; then
- echo "Unknown action: $action" >&2
- return 1
- fi
-
- if _using_asdf; then
- if asdf plugin list all 2>/dev/null | awk '{print $1}' | grep -qx "$term"; then
- asdf plugin add "$term" >/dev/null 2>&1 || true
- asdf install "$term" latest
- return $?
- fi
- fi
-
- if _using_brew; then
- if brew_exec install "$term"; then
- return 0
- fi
- fi
-
- if command -v apt-get >/dev/null 2>&1; then
- if apt_install "$term"; then
- return 0
- fi
- elif command -v dnf >/dev/null 2>&1; then
- if dnf_install "$term"; then
- return 0
- fi
- elif command -v yum >/dev/null 2>&1; then
- if yum_install "$term"; then
- return 0
- fi
- elif command -v pacman >/dev/null 2>&1; then
- if pacman_install "$term"; then
- return 0
- fi
- fi
-
- if [[ -n "${GET_BASHED_GIT_SOURCES[$term]:-}" ]] && _using_git; then
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- local target="$prefix/vendor/$term"
- mkdir -p "$prefix/vendor"
- if ! git clone --depth=1 "${GET_BASHED_GIT_SOURCES[$term]}" "$target"; then
- echo "Failed to clone $term" >&2
- return 1
- fi
- if [[ -n "${GET_BASHED_GIT_POST[$term]:-}" ]]; then
- (cd "$target" && sh "${GET_BASHED_GIT_POST[$term]}") || return 1
- fi
- return 0
- fi
-
- if [[ -n "${GET_BASHED_CURL_SOURCES[$term]:-}" ]] && _using_curl; then
- local tmp_dir
- tmp_dir="$(mktemp -d)"
- if ! curl -fsSL "${GET_BASHED_CURL_SOURCES[$term]}" -o "$tmp_dir/install.sh"; then
- rm -rf "$tmp_dir"
- echo "Failed to download installer for $term" >&2
- return 1
- fi
- local cmd="${GET_BASHED_CURL_CMD[$term]:-bash}"
- if ! $cmd "$tmp_dir/install.sh"; then
- rm -rf "$tmp_dir"
- return 1
- fi
- rm -rf "$tmp_dir"
- return 0
- fi
-
- echo "No installation method found for: $term" >&2
- return 1
-}
-
-# @description Install a tool from the tools registry.
-# @arg $1 string Tool id.
-install_tool() {
- local id="$1"
- _ensure_tools_loaded
-
- local handler="${TOOL_HANDLER[$id]:-}"
- if [[ -n "$handler" ]]; then
- "$handler" "$id"
- return $?
- fi
-
- local bin="${TOOL_BIN[$id]:-}"
- if [[ -n "$bin" ]] && command -v "$bin" >/dev/null 2>&1; then
- return 0
- fi
-
- local methods="${TOOL_METHODS[$id]:-}"
- if [[ -z "$methods" ]]; then
- echo "No install methods defined for $id" >&2
- return 1
- fi
-
- local method
- IFS=',' read -r -a _methods <<<"$methods"
- for method in "${_methods[@]}"; do
- case "$method" in
- brew)
- _using_brew || continue
- brew_exec install "${TOOL_BREW[$id]:-$id}" && return 0
- ;;
- apt)
- command -v apt-get >/dev/null 2>&1 || continue
- apt_install "${TOOL_APT[$id]:-$id}" && return 0
- ;;
- dnf)
- command -v dnf >/dev/null 2>&1 || continue
- dnf_install "${TOOL_DNF[$id]:-$id}" && return 0
- ;;
- yum)
- command -v yum >/dev/null 2>&1 || continue
- yum_install "${TOOL_YUM[$id]:-$id}" && return 0
- ;;
- pacman)
- command -v pacman >/dev/null 2>&1 || continue
- pacman_install "${TOOL_PACMAN[$id]:-$id}" && return 0
- ;;
- pip)
- _using_pip || continue
- python3 -m pip install --user "${id}" && return 0
- ;;
- pipx)
- pipx_install "${id}" && return 0
- ;;
- git)
- _using_git || continue
- local url="${TOOL_GIT_URL[$id]:-}"
- [[ -n "$url" ]] || continue
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- local target="$prefix/vendor/$id"
- if [[ -d "$target/.git" ]]; then
- return 0
- fi
- mkdir -p "$prefix/vendor"
- git clone --depth=1 "$url" "$target" && return 0
- ;;
- curl)
- _using_curl || continue
- local url="${TOOL_CURL_URL[$id]:-}"
- [[ -n "$url" ]] || continue
- local tmp_dir
- tmp_dir="$(mktemp -d)"
- if ! curl -fsSL "$url" -o "$tmp_dir/install.sh"; then
- rm -rf "$tmp_dir"
- return 1
- fi
- local cmd="${TOOL_CURL_CMD[$id]:-bash}"
- if ! $cmd "$tmp_dir/install.sh"; then
- rm -rf "$tmp_dir"
- return 1
- fi
- rm -rf "$tmp_dir"
- return 0
- ;;
- *)
- ;;
- esac
- done
-
- echo "Failed to install $id via methods: $methods" >&2
- return 1
-}
-
-# @description Install asdf (handler).
-install_asdf() {
- if _using_asdf; then
- return 0
- fi
-
- if _using_brew; then
- brew_exec install asdf
- return 0
- fi
-
- if _using_git; then
- if [[ -d "$HOME/.asdf" ]]; then
- return 0
- fi
- git clone https://github.com/asdf-vm/asdf.git "$HOME/.asdf"
- if git -C "$HOME/.asdf" describe --tags --abbrev=0 >/dev/null 2>&1; then
- local tag
- tag="$(git -C "$HOME/.asdf" describe --tags --abbrev=0)"
- git -C "$HOME/.asdf" checkout "$tag" || true
- fi
- return 0
- fi
-
- echo "asdf install requires Homebrew or git." >&2
- return 1
-}
-
-# @description Install GNU tools (handler).
-install_gnu_tools() {
- if _using_brew; then
- brew_exec install coreutils findutils gnu-sed gnu-tar
- return $?
- fi
- echo "GNU tools install requires Homebrew." >&2
- return 1
-}
-
-# @description Install Java (handler).
-install_java() {
- if command -v java >/dev/null 2>&1; then
- return 0
- fi
-
- if _using_asdf; then
- asdf_install_plugin java https://github.com/halcyon/asdf-java.git || true
- local latest_version
- latest_version="$(asdf latest java 2>/dev/null || true)"
- if [[ -n "$latest_version" ]]; then
- asdf install java "$latest_version"
- asdf set --home java "$latest_version"
- return 0
- fi
- echo "Failed to resolve latest Java version via asdf." >&2
- return 1
- fi
-
- pkg_install openjdk
-}
-
-# @description Install Node.js (handler).
-install_nodejs() {
- if command -v node >/dev/null 2>&1; then
- return 0
- fi
-
- if _using_asdf; then
- asdf_install_plugin nodejs || true
- local latest_version
- latest_version="$(asdf latest nodejs 2>/dev/null || true)"
- if [[ -n "$latest_version" ]]; then
- asdf install nodejs "$latest_version"
- asdf set --home nodejs "$latest_version"
- return 0
- fi
- echo "Failed to resolve latest Node.js version via asdf." >&2
- return 1
- fi
-
- pkg_install node
-}
-
-# @description Install Python (handler).
-install_python() {
- if command -v python3 >/dev/null 2>&1; then
- return 0
- fi
-
- if _using_asdf; then
- asdf_install_plugin python || true
- local latest_version
- latest_version="$(asdf latest python 2>/dev/null || true)"
- if [[ -n "$latest_version" ]]; then
- asdf install python "$latest_version"
- asdf set --home python "$latest_version"
- return 0
- fi
- echo "Failed to resolve latest Python version via asdf." >&2
- return 1
- fi
-
- pkg_install python3 python3 python3 python3
-}
-
-# @description Install shdoc (handler).
-install_shdoc() {
- if command -v shdoc >/dev/null 2>&1; then
- return 0
- fi
- if pkg_install shdoc; then
- return 0
- fi
-
- if command -v yay >/dev/null 2>&1; then
- if _auto_approved; then
- yay -S --noconfirm shdoc-git && return 0
- else
- yay -S shdoc-git && return 0
- fi
- elif command -v paru >/dev/null 2>&1; then
- if _auto_approved; then
- paru -S --noconfirm shdoc-git && return 0
- else
- paru -S shdoc-git && return 0
- fi
- fi
-
- echo "shdoc is not available via the detected package manager." >&2
- echo "Attempting local install to GET_BASHED_HOME/bin without sudo." >&2
-
- _using_git || { echo "git is required to build shdoc." >&2; return 1; }
- pkg_install gawk gawk gawk gawk || true
- pkg_install make make make make || true
-
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- local bindir="$prefix/bin"
- mkdir -p "$bindir"
-
- # shdoc requires bash 4+ for ;;& case labels
- local bash_major
- bash_major="$(bash -c 'echo ${BASH_VERSINFO[0]:-0}' 2>/dev/null || echo 0)"
- if [[ "$bash_major" -lt 4 ]] && _using_brew; then
- brew_exec install bash || true
- fi
-
- local tmp_dir
- tmp_dir="$(mktemp -d)"
- git clone --recursive https://github.com/reconquest/shdoc "$tmp_dir/shdoc"
- if ! make -C "$tmp_dir/shdoc" install PREFIX="$prefix" BINDIR="$bindir"; then
- echo "Failed to install shdoc locally. See https://github.com/reconquest/shdoc" >&2
- rm -rf "$tmp_dir"
- return 1
- fi
- rm -rf "$tmp_dir"
-}
-
-# @description Install vimrc (handler).
-install_vimrc() {
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- local target="$prefix/vendor/vimrc"
- if [[ -d "$target/.git" ]]; then
- return 0
- fi
- mkdir -p "$prefix/vendor"
- if ! git clone --depth=1 https://github.com/amix/vimrc.git "$target"; then
- echo "Failed to clone vimrc repo." >&2
- return 1
- fi
- case "${GET_BASHED_VIMRC_MODE:-awesome}" in
- basic)
- sh "$target/install_basic_vimrc.sh"
- ;;
- *)
- sh "$target/install_awesome_vimrc.sh"
- ;;
- esac
-}
-
-# @description Install actionlint (handler).
-install_actionlint() {
- if command -v actionlint >/dev/null 2>&1; then
- return 0
- fi
-
- if _using_brew; then
- brew_exec install actionlint && return 0
- fi
- if command -v apt-get >/dev/null 2>&1; then
- if apt_install actionlint; then
- return 0
- fi
- fi
-
- if ! _using_curl; then
- echo "curl is required to install actionlint" >&2
- return 1
- fi
-
- if ! command -v python3 >/dev/null 2>&1; then
- echo "python3 is required to resolve actionlint release metadata" >&2
- return 1
- fi
-
- # Fallback: download latest release binary
- local tag version os arch url tmp_dir
- tag="$(python3 - <<'PY'
-import json
-import urllib.request
-u = 'https://api.github.com/repos/rhysd/actionlint/releases/latest'
-print(json.load(urllib.request.urlopen(u))['tag_name'])
-PY
-)"
- version="${tag#v}"
- os="$(uname -s | tr '[:upper:]' '[:lower:]')"
- arch="$(uname -m)"
- case "$arch" in
- x86_64) arch="amd64" ;;
- arm64|aarch64) arch="arm64" ;;
- esac
-
- if [[ "$os" == "darwin" ]]; then
- os="darwin"
- elif [[ "$os" == "linux" ]]; then
- os="linux"
- else
- echo "Unsupported OS for actionlint: $os" >&2
- return 1
- fi
-
- url="https://github.com/rhysd/actionlint/releases/download/${tag}/actionlint_${version}_${os}_${arch}.tar.gz"
- tmp_dir="$(mktemp -d)"
- if ! curl -fsSL "$url" -o "$tmp_dir/actionlint.tgz"; then
- rm -rf "$tmp_dir"
- echo "Failed to download actionlint from $url" >&2
- return 1
- fi
- if [[ ! -s "$tmp_dir/actionlint.tgz" ]]; then
- rm -rf "$tmp_dir"
- echo "Downloaded actionlint archive is empty." >&2
- return 1
- fi
- if ! tar -xzf "$tmp_dir/actionlint.tgz" -C "$tmp_dir"; then
- rm -rf "$tmp_dir"
- echo "Failed to extract actionlint archive." >&2
- return 1
- fi
- local prefix="${GET_BASHED_HOME:-$HOME/.get-bashed}"
- mkdir -p "$prefix/bin"
- mv "$tmp_dir/actionlint" "$prefix/bin/actionlint"
- chmod +x "$prefix/bin/actionlint"
- rm -rf "$tmp_dir"
-}
-# @description Install a package via available system package manager.
-# @arg $1 string Brew package name.
-# @arg $2 string Apt package name (optional).
-# @arg $3 string Dnf package name (optional).
-# @arg $4 string Yum package name (optional).
-# @exitcode 0 If installed.
-# @exitcode 1 If no supported package manager.
-pkg_install() {
- local brew_pkg="$1"
- local apt_pkg="${2:-$1}"
- local dnf_pkg="${3:-$1}"
- local yum_pkg="${4:-$1}"
- local pacman_pkg="${5:-$1}"
- if _using_brew; then
- brew_exec install "$brew_pkg"
- return $?
- elif command -v apt-get >/dev/null 2>&1; then
- apt_install "$apt_pkg"
- return $?
- elif command -v dnf >/dev/null 2>&1; then
- dnf_install "$dnf_pkg"
- return $?
- elif command -v yum >/dev/null 2>&1; then
- yum_install "$yum_pkg"
- return $?
- elif command -v pacman >/dev/null 2>&1; then
- pacman_install "$pacman_pkg"
- return $?
- else
- echo "No supported package manager found for $brew_pkg" >&2
- return 1
- fi
-}
-
-# @description Check if an asdf plugin is installed.
-# @arg $1 string Plugin name.
-# @exitcode 0 If installed.
-# @exitcode 1 If missing.
-asdf_has_plugin() {
- local plugin="$1"
- _using_asdf || return 1
- asdf plugin list | awk '{print $1}' | grep -qx "$plugin"
-}
-
-# @description Install an asdf plugin if missing.
-# @arg $1 string Plugin name.
-# @arg $2 string Plugin repo (optional).
-# @exitcode 0 If installed or already present.
-# @exitcode 1 If asdf not available.
-asdf_install_plugin() {
- local plugin="$1" repo="${2:-}"
- _using_asdf || return 1
- if asdf_has_plugin "$plugin"; then
- return 0
- fi
- if [[ -n "$repo" ]]; then
- asdf plugin add "$plugin" "$repo"
- else
- asdf plugin add "$plugin"
- fi
-}
-
-# @description Install a Python tool via pipx (fallback to pip).
-# @arg $1 string Package name.
-# @exitcode 0 If installed.
-# @exitcode 1 If pipx/pip missing.
-pipx_install() {
- local pkg="$1"
- if _using_pipx; then
- pipx install "$pkg"
- elif _using_pip; then
- python3 -m pip install --user "$pkg"
- else
- echo "pipx or pip is required to install $pkg" >&2
- return 1
- fi
-}
+# Provides platform detection, pinned sources, and installation helpers
+# used by installer scripts.
+
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/sources.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/core.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/tool_runner.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/installers.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/languages.sh"
diff --git a/installers/bootstrap_sources.sh b/installers/bootstrap_sources.sh
new file mode 100644
index 0000000..c90f5b6
--- /dev/null
+++ b/installers/bootstrap_sources.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+GET_BASHED_BOOTSTRAP_BREW_URL='https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh'
+GET_BASHED_BOOTSTRAP_BREW_CMD='/bin/bash'
+GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL='https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz'
diff --git a/installers/lib/asdf.sh b/installers/lib/asdf.sh
new file mode 100644
index 0000000..8445548
--- /dev/null
+++ b/installers/lib/asdf.sh
@@ -0,0 +1,82 @@
+# @description Check if an asdf plugin is installed.
+# @arg $1 string Plugin name.
+# @exitcode 0 If installed.
+# @exitcode 1 If missing.
+asdf_has_plugin() {
+ local plugin="$1"
+ _using_asdf || return 1
+ asdf plugin list | awk '{print $1}' | grep -qx "$plugin"
+}
+
+# @internal
+asdf_plugin_path() {
+ local plugin="$1"
+ echo "${ASDF_DATA_DIR:-$HOME/.asdf}/plugins/$plugin"
+}
+
+# @description Return the configured asdf plugin source identifier.
+# @arg $1 string Plugin name.
+asdf_plugin_id() {
+ local plugin="$1"
+ echo "${GET_BASHED_ASDF_PLUGIN_IDS[$plugin]:-}"
+}
+
+# @description Install an asdf plugin if missing.
+# @arg $1 string Plugin name.
+# @arg $2 string Plugin repo (optional).
+# @exitcode 0 If installed or already present.
+# @exitcode 1 If asdf not available.
+asdf_install_plugin() {
+ local plugin="$1"
+ local repo="${2:-$(asdf_plugin_source "$plugin")}"
+
+ _using_asdf || return 1
+ if ! asdf_has_plugin "$plugin"; then
+ if [[ -n "$repo" ]]; then
+ asdf plugin add "$plugin" "$repo" || return 1
+ else
+ asdf plugin add "$plugin" || return 1
+ fi
+ fi
+
+ asdf_pin_plugin_ref "$plugin"
+}
+
+# @description Return the configured asdf plugin source URL.
+# @arg $1 string Plugin name.
+asdf_plugin_source() {
+ local plugin="$1"
+ echo "${GET_BASHED_ASDF_PLUGIN_SOURCES[$plugin]:-}"
+}
+
+# @description Return the configured asdf plugin git ref.
+# @arg $1 string Plugin name.
+asdf_plugin_ref() {
+ local plugin="$1"
+ local id
+
+ id="$(asdf_plugin_id "$plugin")"
+ echo "${GET_BASHED_GIT_REFS[$id]:-}"
+}
+
+# @description Pin an installed asdf plugin checkout to the configured ref.
+# @arg $1 string Plugin name.
+asdf_pin_plugin_ref() {
+ local plugin="$1"
+ local ref plugin_dir
+
+ ref="$(asdf_plugin_ref "$plugin")"
+ [[ -n "$ref" ]] || return 0
+
+ plugin_dir="$(asdf_plugin_path "$plugin")"
+ [[ -d "$plugin_dir/.git" ]] || return 0
+
+ git -C "$plugin_dir" checkout "$ref" >/dev/null 2>&1
+}
+
+# @description Return the configured default asdf runtime version.
+# @arg $1 string Plugin name.
+asdf_default_version() {
+ local plugin="$1"
+ echo "${GET_BASHED_ASDF_DEFAULT_VERSIONS[$plugin]:-}"
+}
diff --git a/installers/lib/core.sh b/installers/lib/core.sh
new file mode 100644
index 0000000..d3e843c
--- /dev/null
+++ b/installers/lib/core.sh
@@ -0,0 +1,6 @@
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/system.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/packages.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/asdf.sh"
diff --git a/installers/lib/installers.sh b/installers/lib/installers.sh
new file mode 100644
index 0000000..b503b0f
--- /dev/null
+++ b/installers/lib/installers.sh
@@ -0,0 +1,154 @@
+# @description Install GNU tools (handler).
+install_gnu_tools() {
+ if _using_brew; then
+ brew_exec install coreutils findutils gnu-sed gnu-tar
+ return $?
+ fi
+
+ echo "GNU tools install requires Homebrew." >&2
+ return 1
+}
+
+# @description Install shdoc (handler).
+install_shdoc() {
+ local prefix bindir tmp_dir target
+
+ if command -v shdoc >/dev/null 2>&1; then
+ return 0
+ fi
+ if pkg_install shdoc; then
+ return 0
+ fi
+
+ if command -v yay >/dev/null 2>&1; then
+ if _auto_approved; then
+ yay -S --noconfirm shdoc-git && return 0
+ else
+ yay -S shdoc-git && return 0
+ fi
+ elif command -v paru >/dev/null 2>&1; then
+ if _auto_approved; then
+ paru -S --noconfirm shdoc-git && return 0
+ else
+ paru -S shdoc-git && return 0
+ fi
+ fi
+
+ echo "shdoc is not available via the detected package manager." >&2
+ echo "Attempting local install to GET_BASHED_HOME/bin without sudo." >&2
+
+ _using_git || {
+ echo "git is required to install shdoc." >&2
+ return 1
+ }
+ pkg_install gawk gawk gawk gawk || true
+
+ prefix="$(_tool_prefix)"
+ bindir="$prefix/bin"
+ mkdir -p "$bindir"
+
+ tmp_dir="$(mktemp -d)"
+ _clone_at_ref "shdoc" "${GET_BASHED_GIT_SOURCES["shdoc"]}" "$tmp_dir/shdoc" || {
+ rm -rf "$tmp_dir"
+ return 1
+ }
+
+ target="$bindir/shdoc"
+ {
+ echo '#!/usr/bin/env -S gawk -f'
+ tail -n +2 "$tmp_dir/shdoc/shdoc"
+ } > "$target"
+ chmod +x "$target"
+ rm -rf "$tmp_dir"
+}
+
+# @description Install vimrc (handler).
+install_vimrc() {
+ local prefix target
+
+ prefix="$(_tool_prefix)"
+ target="$prefix/vendor/vimrc"
+ mkdir -p "$prefix/vendor"
+ _ensure_git_checkout_at_ref "vimrc" "${GET_BASHED_GIT_SOURCES["vimrc"]}" "$target" || return 1
+
+ case "${GET_BASHED_VIMRC_MODE:-awesome}" in
+ basic)
+ sh "$target/install_basic_vimrc.sh"
+ ;;
+ *)
+ sh "$target/install_awesome_vimrc.sh"
+ ;;
+ esac
+}
+
+# @description Install actionlint (handler).
+install_actionlint() {
+ local os arch prefix url tmp_dir asset_name checksum_key expected_checksum actual_checksum
+
+ if command -v actionlint >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if _using_brew; then
+ brew_exec install actionlint && return 0
+ fi
+ if command -v apt-get >/dev/null 2>&1 && apt_install actionlint; then
+ return 0
+ fi
+
+ _using_curl || {
+ echo "curl is required to install actionlint" >&2
+ return 1
+ }
+
+ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
+ arch="$(uname -m)"
+ case "$arch" in
+ x86_64) arch="amd64" ;;
+ arm64|aarch64) arch="arm64" ;;
+ esac
+
+ case "$os" in
+ darwin|linux) ;;
+ *)
+ echo "Unsupported OS for actionlint: $os" >&2
+ return 1
+ ;;
+ esac
+
+ asset_name="actionlint_${GET_BASHED_ACTIONLINT_VERSION}_${os}_${arch}.tar.gz"
+ checksum_key="${os}_${arch}"
+ expected_checksum="${GET_BASHED_ACTIONLINT_SHA256[$checksum_key]:-}"
+ if [[ -z "$expected_checksum" ]]; then
+ echo "No pinned checksum configured for actionlint asset ${checksum_key}." >&2
+ return 1
+ fi
+
+ url="https://github.com/rhysd/actionlint/releases/download/${GET_BASHED_ACTIONLINT_TAG}/${asset_name}"
+ tmp_dir="$(mktemp -d)"
+ if ! curl -fsSL "$url" -o "$tmp_dir/actionlint.tgz"; then
+ rm -rf "$tmp_dir"
+ echo "Failed to download actionlint from $url" >&2
+ return 1
+ fi
+ if ! actual_checksum="$(sha256_file "$tmp_dir/actionlint.tgz")"; then
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+ if [[ "$actual_checksum" != "$expected_checksum" ]]; then
+ rm -rf "$tmp_dir"
+ echo "Actionlint checksum mismatch for ${asset_name}." >&2
+ return 1
+ fi
+ if ! tar -xzf "$tmp_dir/actionlint.tgz" -C "$tmp_dir"; then
+ rm -rf "$tmp_dir"
+ echo "Failed to extract actionlint archive." >&2
+ return 1
+ fi
+
+ prefix="$(_tool_prefix)"
+ mkdir -p "$prefix/bin"
+ mv "$tmp_dir/actionlint" "$prefix/bin/actionlint"
+ chmod +x "$prefix/bin/actionlint"
+ rm -rf "$tmp_dir"
+}
diff --git a/installers/lib/languages.sh b/installers/lib/languages.sh
new file mode 100644
index 0000000..cd76749
--- /dev/null
+++ b/installers/lib/languages.sh
@@ -0,0 +1,80 @@
+# @description Install asdf (handler).
+install_asdf() {
+ if _using_asdf; then
+ return 0
+ fi
+
+ if _using_brew; then
+ brew_exec install asdf
+ return 0
+ fi
+
+ if _using_git; then
+ _ensure_git_checkout_at_ref "asdf" "${GET_BASHED_GIT_SOURCES["asdf"]}" "$HOME/.asdf" || return 1
+ return 0
+ fi
+
+ echo "asdf install requires Homebrew or git." >&2
+ return 1
+}
+
+# @description Install a pinned asdf runtime version.
+# @arg $1 string Plugin name.
+install_asdf_runtime() {
+ local plugin="$1"
+ local version repo
+
+ version="$(asdf_default_version "$plugin")"
+ repo="$(asdf_plugin_source "$plugin")"
+
+ if [[ -z "$version" ]]; then
+ echo "No pinned asdf version configured for ${plugin}." >&2
+ return 1
+ fi
+
+ asdf_install_plugin "$plugin" "$repo" || return 1
+ asdf install "$plugin" "$version" || return 1
+ asdf set --home "$plugin" "$version"
+}
+
+# @description Install Java (handler).
+install_java() {
+ if command -v java >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if _using_asdf; then
+ install_asdf_runtime java
+ return $?
+ fi
+
+ pkg_install openjdk
+}
+
+# @description Install Node.js (handler).
+install_nodejs() {
+ if command -v node >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if _using_asdf; then
+ install_asdf_runtime nodejs
+ return $?
+ fi
+
+ pkg_install node
+}
+
+# @description Install Python (handler).
+install_python() {
+ if command -v python3 >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if _using_asdf; then
+ install_asdf_runtime python
+ return $?
+ fi
+
+ pkg_install python3 python3 python3 python3 python3
+}
diff --git a/installers/lib/packages.sh b/installers/lib/packages.sh
new file mode 100644
index 0000000..9484073
--- /dev/null
+++ b/installers/lib/packages.sh
@@ -0,0 +1,151 @@
+# @description Run a command with auto-approval when configured.
+# @arg $1 string Command name.
+# @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm).
+# @arg $3 string Optional extra flag (e.g., --assume-yes).
+# @arg $4 string Optional extra flag (e.g., --yes).
+# @arg $5 string Optional extra flag (e.g., --confirm).
+# @arg $6 string Optional extra flag (e.g., --no-confirm).
+auto_exec() {
+ local cmd="$1"
+ shift
+ local -a flags=()
+
+ if _auto_approved; then
+ while [[ $# -gt 0 ]]; do
+ [[ -n "$1" ]] && flags+=("$1")
+ shift
+ done
+ fi
+
+ "$cmd" "${flags[@]}"
+}
+
+# @internal
+apt_install() {
+ sudo apt-get update
+ if _auto_approved; then
+ sudo apt-get install -y "$@"
+ else
+ sudo apt-get install "$@"
+ fi
+}
+
+# @internal
+dnf_install() {
+ if _auto_approved; then
+ sudo dnf install -y "$@"
+ else
+ sudo dnf install "$@"
+ fi
+}
+
+# @internal
+yum_install() {
+ if _auto_approved; then
+ sudo yum install -y "$@"
+ else
+ sudo yum install "$@"
+ fi
+}
+
+# @internal
+pacman_install() {
+ if _auto_approved; then
+ sudo pacman -Sy --noconfirm "$@"
+ else
+ sudo pacman -Sy "$@"
+ fi
+}
+
+# @internal
+_bash_it_available() {
+ local prefix
+
+ [[ "${GET_BASHED_USE_BASH_IT:-0}" == "1" ]] || return 1
+ prefix="$(_tool_prefix)"
+ [[ -r "$prefix/vendor/bash-it/bash_it.sh" ]]
+}
+
+# @internal
+_bash_it_search() {
+ local action="$1"
+ shift
+ local prefix bash_it
+
+ prefix="$(_tool_prefix)"
+ bash_it="$prefix/vendor/bash-it"
+ # shellcheck disable=SC1090,SC1091
+ source "$bash_it/bash_it.sh"
+ NO_COLOR=1 bash-it search "$@" "--${action}"
+}
+
+# @description Install a package via available system package manager.
+# @arg $1 string Brew package name.
+# @arg $2 string Apt package name (optional).
+# @arg $3 string Dnf package name (optional).
+# @arg $4 string Yum package name (optional).
+# @arg $5 string Pacman package name (optional).
+# @exitcode 0 If installed.
+# @exitcode 1 If no supported package manager.
+pkg_install() {
+ local brew_pkg="$1"
+ local apt_pkg="${2:-$1}"
+ local dnf_pkg="${3:-$1}"
+ local yum_pkg="${4:-$1}"
+ local pacman_pkg="${5:-$1}"
+
+ if _using_brew; then
+ brew_exec install "$brew_pkg"
+ elif command -v apt-get >/dev/null 2>&1; then
+ apt_install "$apt_pkg"
+ elif command -v dnf >/dev/null 2>&1; then
+ dnf_install "$dnf_pkg"
+ elif command -v yum >/dev/null 2>&1; then
+ yum_install "$yum_pkg"
+ elif command -v pacman >/dev/null 2>&1; then
+ pacman_install "$pacman_pkg"
+ else
+ echo "No supported package manager found for $brew_pkg" >&2
+ return 1
+ fi
+}
+
+# @description Return the configured pipx package spec for a tool id.
+# @arg $1 string Tool id.
+pipx_package_spec() {
+ local pkg="$1"
+ echo "${GET_BASHED_PIPX_PACKAGES[$pkg]:-$pkg}"
+}
+
+# @description Return the configured pip package spec for a tool id.
+# @arg $1 string Tool id.
+pip_package_spec() {
+ local pkg="$1"
+ echo "${GET_BASHED_PIP_PACKAGES[$pkg]:-$pkg}"
+}
+
+# @description Install a Python tool via pipx (fallback to pip).
+# @arg $1 string Package name.
+# @exitcode 0 If installed.
+# @exitcode 1 If pipx/pip missing.
+pipx_install() {
+ local pkg="$1"
+ local spec
+ local prefix
+
+ spec="$(pipx_package_spec "$pkg")"
+ prefix="$(_tool_prefix)"
+
+ if _using_pipx; then
+ mkdir -p "$prefix/bin" "$prefix/pipx" "$prefix/share/man"
+ PIPX_HOME="$prefix/pipx" \
+ PIPX_BIN_DIR="$prefix/bin" \
+ PIPX_MAN_DIR="$prefix/share/man" \
+ pipx install "$spec"
+ elif _using_pip; then
+ python3 -m pip install --prefix "$prefix" "$spec"
+ else
+ echo "pipx or pip is required to install $pkg" >&2
+ return 1
+ fi
+}
diff --git a/installers/lib/system.sh b/installers/lib/system.sh
new file mode 100644
index 0000000..70fa231
--- /dev/null
+++ b/installers/lib/system.sh
@@ -0,0 +1,148 @@
+# @internal
+_using_asdf() { command -v asdf >/dev/null 2>&1; }
+
+# @internal
+_brew_bin() {
+ local candidate
+ local -a candidates=()
+
+ if command -v brew >/dev/null 2>&1; then
+ command -v brew
+ return 0
+ fi
+
+ if [[ -n "${GET_BASHED_BREW_BIN_CANDIDATES:-}" ]]; then
+ # shellcheck disable=SC2206
+ candidates=(${GET_BASHED_BREW_BIN_CANDIDATES})
+ else
+ candidates=(/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew)
+ fi
+
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "$candidate" ]]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+# @internal
+_using_brew() { _brew_bin >/dev/null 2>&1; }
+
+# @internal
+brew_exec() {
+ local brew_bin
+ brew_bin="$(_brew_bin)" || return 1
+ "$brew_bin" "$@"
+}
+
+# @internal
+_using_git() { command -v git >/dev/null 2>&1; }
+
+# @internal
+_using_curl() { command -v curl >/dev/null 2>&1; }
+
+# @internal
+_using_pipx() { command -v pipx >/dev/null 2>&1; }
+
+# @internal
+_using_pip() { command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; }
+
+# @internal
+_tools_loaded() { [[ -n "${TOOL_IDS[*]:-}" ]]; }
+
+# @internal
+_ensure_tools_loaded() {
+ local repo_dir
+
+ _tools_loaded && return 0
+
+ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+ # shellcheck disable=SC1090,SC1091
+ source "$repo_dir/installers/tools.sh"
+}
+
+# @internal
+_auto_approved() { [[ "${GET_BASHED_AUTO_APPROVE:-0}" == "1" ]]; }
+
+# @internal
+_tool_prefix() { echo "${GET_BASHED_HOME:-$HOME/.get-bashed}"; }
+
+# @internal
+_git_ref_for() {
+ local id="$1"
+ echo "${GET_BASHED_GIT_REFS[$id]:-}"
+}
+
+# @internal
+_clone_at_ref() {
+ local id="$1"
+ local url="$2"
+ local target="$3"
+ local ref
+
+ ref="$(_git_ref_for "$id")"
+ rm -rf "$target"
+
+ if ! git clone "$url" "$target" >/dev/null 2>&1; then
+ echo "Failed to clone $id from $url" >&2
+ return 1
+ fi
+
+ if [[ -n "$ref" ]] && ! git -C "$target" checkout "$ref" >/dev/null 2>&1; then
+ echo "Failed to checkout $id ref $ref" >&2
+ return 1
+ fi
+}
+
+# @internal
+_git_repo_matches_ref() {
+ local id="$1"
+ local url="$2"
+ local target="$3"
+ local ref remote head expected
+
+ [[ -d "$target/.git" ]] || return 1
+
+ remote="$(git -C "$target" remote get-url origin 2>/dev/null || true)"
+ [[ "$remote" == "$url" ]] || return 1
+
+ ref="$(_git_ref_for "$id")"
+ [[ -n "$ref" ]] || return 0
+
+ head="$(git -C "$target" rev-parse HEAD 2>/dev/null || true)"
+ expected="$(git -C "$target" rev-parse "${ref}^{commit}" 2>/dev/null || git -C "$target" rev-parse "$ref" 2>/dev/null || true)"
+ [[ -n "$expected" && "$head" == "$expected" ]]
+}
+
+# @internal
+_ensure_git_checkout_at_ref() {
+ local id="$1"
+ local url="$2"
+ local target="$3"
+
+ if _git_repo_matches_ref "$id" "$url" "$target"; then
+ return 0
+ fi
+
+ _clone_at_ref "$id" "$url" "$target"
+}
+
+# @description Return the SHA-256 digest for a file.
+# @arg $1 string File path.
+# @exitcode 0 If a checksum tool is available.
+# @exitcode 1 If no checksum tool is available.
+sha256_file() {
+ local file="$1"
+
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$file" | awk '{print $1}'
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$file" | awk '{print $1}'
+ else
+ echo "sha256sum or shasum is required to verify $file" >&2
+ return 1
+ fi
+}
diff --git a/installers/lib/tool_runner.sh b/installers/lib/tool_runner.sh
new file mode 100644
index 0000000..fde1fa8
--- /dev/null
+++ b/installers/lib/tool_runner.sh
@@ -0,0 +1,170 @@
+# @description Install a component using available methods.
+# @arg $1 string Action (enable|disable|install).
+# @arg $2 string Term to resolve/install.
+component_install() {
+ local action="$1"
+ local term="$2"
+ local prefix target target_dir
+ local tmp_dir cmd
+
+ shift 2 || true
+
+ if [[ "$action" == "enable" || "$action" == "disable" ]]; then
+ if _bash_it_available; then
+ _bash_it_search "$action" "$term" "$@"
+ return 0
+ fi
+ action="install"
+ fi
+
+ if [[ "$action" != "install" ]]; then
+ echo "Unknown action: $action" >&2
+ return 1
+ fi
+
+ if _using_asdf && [[ -n "${GET_BASHED_ASDF_DEFAULT_VERSIONS[$term]:-}" ]]; then
+ install_asdf_runtime "$term"
+ return $?
+ fi
+
+ if _using_brew && brew_exec install "$term"; then
+ return 0
+ fi
+ if command -v apt-get >/dev/null 2>&1 && apt_install "$term"; then
+ return 0
+ fi
+ if command -v dnf >/dev/null 2>&1 && dnf_install "$term"; then
+ return 0
+ fi
+ if command -v yum >/dev/null 2>&1 && yum_install "$term"; then
+ return 0
+ fi
+ if command -v pacman >/dev/null 2>&1 && pacman_install "$term"; then
+ return 0
+ fi
+
+ if [[ -n "${GET_BASHED_GIT_SOURCES[$term]:-}" ]] && _using_git; then
+ prefix="$(_tool_prefix)"
+ target_dir="${TOOL_TARGET_DIR[$term]:-$term}"
+ target="$prefix/vendor/$target_dir"
+ mkdir -p "$prefix/vendor"
+ _ensure_git_checkout_at_ref "$term" "${GET_BASHED_GIT_SOURCES[$term]}" "$target" || return 1
+ if [[ -n "${GET_BASHED_GIT_POST[$term]:-}" ]]; then
+ (cd "$target" && sh "${GET_BASHED_GIT_POST[$term]}") || return 1
+ fi
+ return 0
+ fi
+
+ if [[ -n "${GET_BASHED_CURL_SOURCES[$term]:-}" ]] && _using_curl; then
+ tmp_dir="$(mktemp -d)"
+ if ! curl -fsSL "${GET_BASHED_CURL_SOURCES[$term]}" -o "$tmp_dir/install.sh"; then
+ rm -rf "$tmp_dir"
+ echo "Failed to download installer for $term" >&2
+ return 1
+ fi
+ cmd="${GET_BASHED_CURL_CMD[$term]:-bash}"
+ if ! "$cmd" "$tmp_dir/install.sh"; then
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+ rm -rf "$tmp_dir"
+ return 0
+ fi
+
+ echo "No installation method found for: $term" >&2
+ return 1
+}
+
+# @description Install a tool from the tools registry.
+# @arg $1 string Tool id.
+install_tool() {
+ local id="$1"
+ local handler bin methods method
+ local url prefix target target_dir tmp_dir cmd
+
+ _ensure_tools_loaded
+
+ handler="${TOOL_HANDLER[$id]:-}"
+ if [[ -n "$handler" ]]; then
+ "$handler" "$id"
+ return $?
+ fi
+
+ bin="${TOOL_BIN[$id]:-}"
+ if [[ -n "$bin" ]] && command -v "$bin" >/dev/null 2>&1; then
+ return 0
+ fi
+
+ methods="${TOOL_METHODS[$id]:-}"
+ if [[ -z "$methods" ]]; then
+ echo "No install methods defined for $id" >&2
+ return 1
+ fi
+
+ IFS=',' read -r -a _methods <<<"$methods"
+ for method in "${_methods[@]}"; do
+ case "$method" in
+ brew)
+ _using_brew || continue
+ brew_exec install "${TOOL_BREW[$id]:-$id}" && return 0
+ ;;
+ apt)
+ command -v apt-get >/dev/null 2>&1 || continue
+ apt_install "${TOOL_APT[$id]:-$id}" && return 0
+ ;;
+ dnf)
+ command -v dnf >/dev/null 2>&1 || continue
+ dnf_install "${TOOL_DNF[$id]:-$id}" && return 0
+ ;;
+ yum)
+ command -v yum >/dev/null 2>&1 || continue
+ yum_install "${TOOL_YUM[$id]:-$id}" && return 0
+ ;;
+ pacman)
+ command -v pacman >/dev/null 2>&1 || continue
+ pacman_install "${TOOL_PACMAN[$id]:-$id}" && return 0
+ ;;
+ pip)
+ local pip_spec
+ _using_pip || continue
+ pip_spec="$(pip_package_spec "$id")"
+ python3 -m pip install --prefix "$(_tool_prefix)" "$pip_spec" && return 0
+ ;;
+ pipx)
+ pipx_install "$id" && return 0
+ ;;
+ git)
+ _using_git || continue
+ url="${TOOL_GIT_URL[$id]:-}"
+ [[ -n "$url" ]] || continue
+ prefix="$(_tool_prefix)"
+ target_dir="${TOOL_TARGET_DIR[$id]:-$id}"
+ target="$prefix/vendor/$target_dir"
+ mkdir -p "$prefix/vendor"
+ _ensure_git_checkout_at_ref "$id" "$url" "$target" && return 0
+ ;;
+ curl)
+ _using_curl || continue
+ url="${TOOL_CURL_URL[$id]:-}"
+ [[ -n "$url" ]] || continue
+ tmp_dir="$(mktemp -d)"
+ if ! curl -fsSL "$url" -o "$tmp_dir/install.sh"; then
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+ cmd="${TOOL_CURL_CMD[$id]:-bash}"
+ if ! "$cmd" "$tmp_dir/install.sh"; then
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+ rm -rf "$tmp_dir"
+ return 0
+ ;;
+ *)
+ ;;
+ esac
+ done
+
+ echo "Failed to install $id via methods: $methods" >&2
+ return 1
+}
diff --git a/installers/sources.sh b/installers/sources.sh
new file mode 100644
index 0000000..4e23c20
--- /dev/null
+++ b/installers/sources.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap_sources.sh"
+
+declare -gA GET_BASHED_GIT_SOURCES=()
+GET_BASHED_GIT_SOURCES["asdf"]="https://github.com/asdf-vm/asdf.git"
+GET_BASHED_GIT_SOURCES["asdf_java_plugin"]="https://github.com/halcyon/asdf-java.git"
+GET_BASHED_GIT_SOURCES["asdf_nodejs_plugin"]="https://github.com/asdf-vm/asdf-nodejs.git"
+GET_BASHED_GIT_SOURCES["asdf_python_plugin"]="https://github.com/danhper/asdf-python.git"
+GET_BASHED_GIT_SOURCES["bash_it"]="https://github.com/Bash-it/bash-it.git"
+GET_BASHED_GIT_SOURCES["vimrc"]="https://github.com/amix/vimrc.git"
+GET_BASHED_GIT_SOURCES["shdoc"]="https://github.com/reconquest/shdoc.git"
+GET_BASHED_GIT_SOURCES["bats_assert"]="https://github.com/bats-core/bats-assert.git"
+GET_BASHED_GIT_SOURCES["bats_file"]="https://github.com/bats-core/bats-file.git"
+GET_BASHED_GIT_SOURCES["bats_support"]="https://github.com/bats-core/bats-support.git"
+readonly GET_BASHED_GIT_SOURCES
+
+declare -gA GET_BASHED_GIT_REFS=()
+GET_BASHED_GIT_REFS["asdf"]="v0.18.1"
+GET_BASHED_GIT_REFS["asdf_java_plugin"]="7ade6a410c178cb1655b68fe00bcfd3cdc819fd2"
+GET_BASHED_GIT_REFS["asdf_nodejs_plugin"]="779c8dc84b3bdab38c2c80622d315c2c3267f74b"
+GET_BASHED_GIT_REFS["asdf_python_plugin"]="abc2a03863e4d569b4f9de0d0efc1a88d96c2c12"
+GET_BASHED_GIT_REFS["bash_it"]="v3.2.0"
+GET_BASHED_GIT_REFS["bats_assert"]="123860c029685bc0a4150ed57ee97fc7f7cc9d31"
+GET_BASHED_GIT_REFS["bats_file"]="13ad5e2ffcc360281432db3d43a306f7b3667d60"
+GET_BASHED_GIT_REFS["bats_support"]="64e7436962affbe15974d181173c37e1fac70073"
+GET_BASHED_GIT_REFS["vimrc"]="46294d589d15d2e7308cf76c58f2df49bbec31e8"
+GET_BASHED_GIT_REFS["shdoc"]="504cac5d8001eeb0cabdb83ddb2f813b9a1fc963"
+readonly GET_BASHED_GIT_REFS
+
+declare -gA GET_BASHED_GIT_POST=()
+GET_BASHED_GIT_POST["vimrc"]="install_awesome_vimrc.sh"
+readonly GET_BASHED_GIT_POST
+
+declare -gA GET_BASHED_CURL_SOURCES=()
+GET_BASHED_CURL_SOURCES["brew"]="${GET_BASHED_BOOTSTRAP_BREW_URL}"
+readonly GET_BASHED_CURL_SOURCES
+
+declare -gA GET_BASHED_CURL_CMD=()
+GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}"
+readonly GET_BASHED_CURL_CMD
+
+declare -gA GET_BASHED_ASDF_PLUGIN_IDS=()
+GET_BASHED_ASDF_PLUGIN_IDS["java"]="asdf_java_plugin"
+GET_BASHED_ASDF_PLUGIN_IDS["nodejs"]="asdf_nodejs_plugin"
+GET_BASHED_ASDF_PLUGIN_IDS["python"]="asdf_python_plugin"
+readonly GET_BASHED_ASDF_PLUGIN_IDS
+
+declare -gA GET_BASHED_ASDF_PLUGIN_SOURCES=()
+GET_BASHED_ASDF_PLUGIN_SOURCES["java"]="${GET_BASHED_GIT_SOURCES["asdf_java_plugin"]}"
+GET_BASHED_ASDF_PLUGIN_SOURCES["nodejs"]="${GET_BASHED_GIT_SOURCES["asdf_nodejs_plugin"]}"
+GET_BASHED_ASDF_PLUGIN_SOURCES["python"]="${GET_BASHED_GIT_SOURCES["asdf_python_plugin"]}"
+readonly GET_BASHED_ASDF_PLUGIN_SOURCES
+
+declare -gA GET_BASHED_ASDF_DEFAULT_VERSIONS=()
+GET_BASHED_ASDF_DEFAULT_VERSIONS["java"]="temurin-21.0.10+7.0.LTS"
+GET_BASHED_ASDF_DEFAULT_VERSIONS["nodejs"]="24.14.1"
+GET_BASHED_ASDF_DEFAULT_VERSIONS["python"]="3.14.4"
+readonly GET_BASHED_ASDF_DEFAULT_VERSIONS
+
+declare -gA GET_BASHED_PIPX_PACKAGES=()
+GET_BASHED_PIPX_PACKAGES["bashate"]="bashate==2.1.1"
+GET_BASHED_PIPX_PACKAGES["pre_commit"]="pre-commit==4.5.1"
+readonly GET_BASHED_PIPX_PACKAGES
+
+declare -gA GET_BASHED_PIP_PACKAGES=()
+GET_BASHED_PIP_PACKAGES["pipx"]="pipx==1.11.1"
+GET_BASHED_PIP_PACKAGES["uv"]="uv==0.9.9"
+readonly GET_BASHED_PIP_PACKAGES
+
+declare -gA GET_BASHED_NODE_GLOBAL_PACKAGES=()
+GET_BASHED_NODE_GLOBAL_PACKAGES["gemini"]="@google/gemini-cli@0.38.1"
+GET_BASHED_NODE_GLOBAL_PACKAGES["sonar"]="@sonar/scan@4.3.6"
+readonly GET_BASHED_NODE_GLOBAL_PACKAGES
+
+declare -gr GET_BASHED_ACTIONLINT_VERSION="1.7.12"
+declare -gr GET_BASHED_ACTIONLINT_TAG="v${GET_BASHED_ACTIONLINT_VERSION}"
+
+declare -gA GET_BASHED_ACTIONLINT_SHA256=()
+GET_BASHED_ACTIONLINT_SHA256["darwin_amd64"]="5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644"
+GET_BASHED_ACTIONLINT_SHA256["darwin_arm64"]="aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f"
+GET_BASHED_ACTIONLINT_SHA256["linux_amd64"]="8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
+GET_BASHED_ACTIONLINT_SHA256["linux_arm64"]="325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6"
+readonly GET_BASHED_ACTIONLINT_SHA256
diff --git a/installers/tools.sh b/installers/tools.sh
index 77e0f69..78f35c1 100644
--- a/installers/tools.sh
+++ b/installers/tools.sh
@@ -21,6 +21,7 @@ declare -A TOOL_CURL_CMD
declare -A TOOL_HANDLER
declare -A TOOL_OPT_DEPS
declare -A TOOL_BIN
+declare -A TOOL_TARGET_DIR
tool_register() {
local id="$1" desc="$2" deps="$3" platforms="$4" methods="$5"
@@ -66,6 +67,11 @@ tool_bin() {
TOOL_BIN["$id"]="$bin"
}
+tool_target_dir() {
+ local id="$1" target="$2"
+ TOOL_TARGET_DIR["$id"]="$target"
+}
+
# @internal
load_installers() {
INSTALLERS=""
@@ -77,7 +83,7 @@ load_installers() {
}
# Core tools
tool_register brew "Homebrew/Linuxbrew installer" "" "macos,linux,wsl" "curl"
-tool_curl brew "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" "/bin/bash"
+tool_curl brew "${GET_BASHED_CURL_SOURCES["brew"]}" "${GET_BASHED_CURL_CMD["brew"]}"
tool_bin brew "brew"
tool_register asdf "asdf version manager" "" "macos,linux,wsl" "handler"
@@ -87,15 +93,20 @@ tool_register bash "Latest GNU Bash" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacm
tool_pkgs bash "bash" "bash" "bash" "bash" "bash"
tool_register bash_it "bash-it framework" "git" "macos,linux,wsl" "git"
-tool_git bash_it "https://github.com/Bash-it/bash-it.git"
+tool_git bash_it "${GET_BASHED_GIT_SOURCES["bash_it"]}"
+tool_target_dir bash_it "bash-it"
tool_register vimrc "amix/vimrc (awesome/basic)" "git" "macos,linux,wsl" "git"
-tool_git vimrc "https://github.com/amix/vimrc.git"
+tool_git vimrc "${GET_BASHED_GIT_SOURCES["vimrc"]}"
tool_handler vimrc "install_vimrc"
tool_register shdoc "shdoc (shell script doc generator)" "" "macos,linux,wsl" "handler"
tool_handler shdoc "install_shdoc"
+tool_register uv "uv" "" "macos,linux,wsl" "brew,pip"
+tool_pkgs uv "uv" "" "" "" ""
+tool_bin uv "uvx"
+
tool_register dialog "curses dialog UI" "" "macos,linux,wsl" "brew,apt,dnf,yum"
tool_pkgs dialog "dialog" "dialog" "dialog" "dialog" ""
@@ -103,7 +114,9 @@ tool_register pipx "pipx" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacman,pip"
tool_pkgs pipx "pipx" "pipx" "pipx" "pipx" "python-pipx"
tool_register pre_commit "pre-commit" "pipx" "macos,linux,wsl" "pipx"
+tool_bin pre_commit "pre-commit"
tool_register bashate "bashate" "pipx" "macos,linux,wsl" "pipx"
+tool_bin bashate "bashate"
tool_register shellcheck "shellcheck" "" "macos,linux,wsl" "brew,apt,dnf,yum,pacman"
tool_pkgs shellcheck "shellcheck" "shellcheck" "ShellCheck" "ShellCheck" "shellcheck"
@@ -132,6 +145,7 @@ tool_opt_deps git "GET_BASHED_GIT_SIGNING:gnupg"
tool_register git_lfs "git-lfs" "" "macos,linux,wsl" "brew,apt,dnf,yum"
tool_pkgs git_lfs "git-lfs" "git-lfs" "git-lfs" "git-lfs" ""
+tool_bin git_lfs "git-lfs"
tool_register gh "GitHub CLI" "" "macos,linux,wsl" "brew,apt"
tool_pkgs gh "gh" "gh" "" "" ""
@@ -181,6 +195,7 @@ tool_pkgs terraform "terraform" "" "" "" ""
tool_register awscli "AWS CLI" "" "macos,linux,wsl" "brew,apt"
tool_pkgs awscli "awscli" "awscli" "" "" ""
+tool_bin awscli "aws"
tool_register kubectl "kubectl" "" "macos,linux,wsl" "brew"
tool_pkgs kubectl "kubectl" "" "" "" ""
diff --git a/installlib/config.sh b/installlib/config.sh
new file mode 100644
index 0000000..5e10370
--- /dev/null
+++ b/installlib/config.sh
@@ -0,0 +1,186 @@
+# @description Print usage help.
+# @noargs
+usage() {
+ cat <<'USAGE'
+Usage: install.sh [--prefix PATH] [--force] [--with-ui]
+ [--auto] [--yes]
+ [--profiles minimal|dev|ops[,..]]
+ [--features gnu_over_bsd,build_flags,...]
+ [--install brew,asdf,doppler,...]
+ [--vimrc-mode awesome|basic]
+ [--link-dotfiles]
+ [--name "Full Name"] [--email "me@example.com"]
+ [--list] [--list-profiles] [--list-features] [--list-installers]
+ [--dry-run]
+
+Notes:
+- --auto disables prompts.
+- --yes auto-accepts prompts.
+- profiles set defaults; features override defaults.
+USAGE
+}
+
+init_install_state() {
+ PREFIX="${GET_BASHED_HOME:-$HOME/.get-bashed}"
+ FORCE=0
+ WITH_UI=0
+ AUTO=0
+ YES=0
+ PROFILES=""
+ FEATURES=""
+ INSTALLS=""
+ GROUP_INSTALLS=""
+ LIST=0
+ DRY_RUN=0
+ LIST_PROFILES=0
+ LIST_FEATURES=0
+ LIST_INSTALLERS=0
+ VIMRC_MODE="awesome"
+ LINK_DOTFILES=0
+ USER_NAME=""
+ USER_EMAIL=""
+
+ GET_BASHED_GNU=0
+ GET_BASHED_BUILD_FLAGS=0
+ GET_BASHED_AUTO_TOOLS=0
+ GET_BASHED_SSH_AGENT=0
+ GET_BASHED_USE_DOPPLER=0
+ GET_BASHED_USE_BASH_IT=0
+ GET_BASHED_GIT_SIGNING=0
+}
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --prefix)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --prefix requires a value" >&2
+ usage
+ exit 1
+ }
+ PREFIX="$2"
+ shift 2
+ ;;
+ --force)
+ FORCE=1
+ shift
+ ;;
+ --with-ui)
+ WITH_UI=1
+ shift
+ ;;
+ --auto|-a)
+ AUTO=1
+ shift
+ ;;
+ --yes|-y)
+ YES=1
+ shift
+ ;;
+ --profiles|-w)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --profiles requires a value" >&2
+ usage
+ exit 1
+ }
+ PROFILES="$2"
+ shift 2
+ ;;
+ --features)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --features requires a value" >&2
+ usage
+ exit 1
+ }
+ FEATURES="$2"
+ shift 2
+ ;;
+ --install|-i)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --install requires a value" >&2
+ usage
+ exit 1
+ }
+ INSTALLS="$2"
+ shift 2
+ ;;
+ --vimrc-mode)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --vimrc-mode requires a value" >&2
+ usage
+ exit 1
+ }
+ VIMRC_MODE="$2"
+ shift 2
+ ;;
+ --link-dotfiles)
+ LINK_DOTFILES=1
+ shift
+ ;;
+ --name|-n)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --name requires a value" >&2
+ usage
+ exit 1
+ }
+ USER_NAME="$2"
+ shift 2
+ ;;
+ --email|-e)
+ [[ $# -ge 2 ]] || {
+ echo "Error: --email requires a value" >&2
+ usage
+ exit 1
+ }
+ USER_EMAIL="$2"
+ shift 2
+ ;;
+ --list)
+ LIST=1
+ shift
+ ;;
+ --list-profiles)
+ LIST_PROFILES=1
+ shift
+ ;;
+ --list-features)
+ LIST_FEATURES=1
+ shift
+ ;;
+ --list-installers)
+ LIST_INSTALLERS=1
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+ done
+}
+
+prepare_interactive_mode() {
+ if [[ "$YES" -eq 1 || "$AUTO" -eq 1 ]]; then
+ export GET_BASHED_AUTO_APPROVE=1
+ fi
+
+ if [[ ! -t 0 ]] && [[ "$AUTO" -eq 0 ]]; then
+ AUTO=1
+ fi
+
+ if [[ "$WITH_UI" -eq 1 ]] && [[ "$AUTO" -eq 0 ]] && [[ "$DRY_RUN" -eq 0 ]]; then
+ install_dialog || true
+ fi
+
+ load_installers
+ : "${INSTALLERS:=}"
+}
diff --git a/installlib/filesystem.sh b/installlib/filesystem.sh
new file mode 100644
index 0000000..cc83612
--- /dev/null
+++ b/installlib/filesystem.sh
@@ -0,0 +1,4 @@
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/managed_files.sh"
+# shellcheck disable=SC1091
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/runtime_files.sh"
diff --git a/installlib/installers.sh b/installlib/installers.sh
new file mode 100644
index 0000000..0f27f9a
--- /dev/null
+++ b/installlib/installers.sh
@@ -0,0 +1,74 @@
+get_deps() {
+ local id="$1"
+ local deps opt flag dep
+
+ _ensure_tools_loaded
+ deps="${TOOL_DEPS[$id]:-}"
+ opt="${TOOL_OPT_DEPS[$id]:-}"
+
+ if [[ -n "$opt" ]]; then
+ IFS=',' read -r -a _opt_specs <<<"$opt"
+ for spec in "${_opt_specs[@]}"; do
+ flag="${spec%%:*}"
+ dep="${spec#*:}"
+ if [[ -n "${!flag:-}" && "${!flag}" != "0" ]]; then
+ deps="$(append_csv_unique "$deps" "$dep")"
+ fi
+ done
+ fi
+
+ echo "$deps"
+}
+
+run_selected_installers() {
+ local -A install_in_progress=()
+ local -A install_done=()
+ local id
+
+ is_done() {
+ local current_id="$1"
+ [[ "${install_done[$current_id]:-}" == "1" ]]
+ }
+
+ mark_done() {
+ local current_id="$1"
+ install_done["$current_id"]=1
+ }
+
+ run_install() {
+ local current_id="$1"
+ local deps dep
+
+ if is_done "$current_id"; then
+ return 0
+ fi
+
+ if [[ "${install_in_progress[$current_id]:-}" == "1" ]]; then
+ echo "Circular dependency detected while installing $current_id" >&2
+ return 1
+ fi
+
+ install_in_progress["$current_id"]=1
+ deps="$(get_deps "$current_id")"
+ if [[ -n "$deps" ]]; then
+ for dep in $(split_csv "$deps"); do
+ run_install "$dep" || return 1
+ done
+ fi
+
+ if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo "would install: $current_id"
+ else
+ install_tool "$current_id"
+ fi
+
+ unset "install_in_progress[$current_id]"
+ mark_done "$current_id"
+ }
+
+ [[ -n "$INSTALLS" ]] || return 0
+
+ for id in $(split_csv "$INSTALLS"); do
+ run_install "$id"
+ done
+}
diff --git a/installlib/managed_files.sh b/installlib/managed_files.sh
new file mode 100644
index 0000000..9e1b664
--- /dev/null
+++ b/installlib/managed_files.sh
@@ -0,0 +1,173 @@
+ensure_block() {
+ local file="$1"
+ local marker="$2"
+ local snippet="$3"
+
+ mkdir -p "$(dirname "$file")"
+ if [[ -r "$file" ]] && grep -Fq "$marker" "$file"; then
+ return 0
+ fi
+
+ {
+ echo ""
+ echo "$marker"
+ echo "$snippet"
+ } >> "$file"
+}
+
+backup_file() {
+ local file="$1"
+ local backup_dir="$PREFIX/backup"
+ local base ts
+
+ [[ -e "$file" ]] || return 0
+
+ mkdir -p "$backup_dir"
+ chmod 700 "$backup_dir"
+ base="$(basename "$file")"
+ base="${base#.}"
+ ts="$(date +%s)"
+ mv "$file" "$backup_dir/${base}.${ts}"
+}
+
+link_dotfile() {
+ local name="$1"
+ local src="$PREFIX/$name"
+ local dest="$HOME/.${name}"
+ local current
+
+ [[ -e "$src" ]] || return 0
+
+ if [[ -L "$dest" ]]; then
+ current="$(readlink "$dest" || true)"
+ if [[ "$current" == "$src" ]]; then
+ return 0
+ fi
+ backup_file "$dest"
+ elif [[ -e "$dest" ]]; then
+ backup_file "$dest"
+ fi
+
+ ln -s "$src" "$dest"
+}
+
+managed_manifest_path() {
+ echo "$PREFIX/.get-bashed-manifest"
+}
+
+collect_managed_entries() {
+ local -n entries_ref=$1
+ local file
+
+ entries_ref=(bashrc bash_profile bash_aliases inputrc vimrc gitconfig)
+
+ shopt -s nullglob
+ for file in "$REPO_DIR/bashrc.d"/*.sh; do
+ entries_ref+=("bashrc.d/$(basename "$file")")
+ done
+ shopt -u nullglob
+}
+
+load_manifest_entries() {
+ local manifest="$1"
+ local -n entries_ref=$2
+ entries_ref=()
+
+ [[ -r "$manifest" ]] || return 0
+
+ while IFS= read -r line; do
+ [[ -n "$line" ]] && entries_ref+=("$line")
+ done < "$manifest"
+}
+
+manifest_contains() {
+ local needle="$1"
+ shift
+ local entry
+
+ for entry in "$@"; do
+ [[ "$entry" == "$needle" ]] && return 0
+ done
+
+ return 1
+}
+
+write_manifest_entries() {
+ local manifest="$1"
+ shift
+
+ mkdir -p "$(dirname "$manifest")"
+ printf '%s\n' "$@" > "$manifest"
+}
+
+sync_managed_entry() {
+ local entry="$1"
+ local src="$REPO_DIR/$entry"
+ local dest="$PREFIX/$entry"
+
+ mkdir -p "$(dirname "$dest")"
+ cp -f "$src" "$dest"
+}
+
+remove_stale_managed_entries() {
+ local -a current_entries=("$@")
+ local -a previous_entries=()
+ local manifest entry
+
+ manifest="$(managed_manifest_path)"
+ load_manifest_entries "$manifest" previous_entries
+
+ for entry in "${previous_entries[@]}"; do
+ if ! manifest_contains "$entry" "${current_entries[@]}"; then
+ rm -f "$PREFIX/$entry"
+ fi
+ done
+}
+
+sync_managed_assets() {
+ local -a current_entries=()
+ local manifest entry
+
+ collect_managed_entries current_entries
+ manifest="$(managed_manifest_path)"
+
+ mkdir -p "$PREFIX"
+ [[ "$FORCE" -eq 1 ]] && remove_stale_managed_entries "${current_entries[@]}"
+
+ for entry in "${current_entries[@]}"; do
+ sync_managed_entry "$entry"
+ done
+
+ write_manifest_entries "$manifest" "${current_entries[@]}"
+}
+
+copy_legacy_file() {
+ local src="$1"
+ local dest_dir="$2"
+ local base candidate suffix=1
+
+ base="$(basename "$src")"
+ candidate="$dest_dir/$base"
+
+ while [[ -e "$candidate" ]]; do
+ candidate="$dest_dir/migrated-${suffix}-${base}"
+ suffix=$((suffix + 1))
+ done
+
+ cp -p "$src" "$candidate"
+}
+
+ensure_secrets_stub() {
+ mkdir -p "$PREFIX/secrets.d"
+ chmod 700 "$PREFIX/secrets.d"
+
+ if [[ ! -e "$PREFIX/secrets.d/00-local.sh" ]]; then
+ (
+ umask 077
+ cat <<'SECRETS' > "$PREFIX/secrets.d/00-local.sh"
+# Place local secrets here. Example:
+# export FOO="bar"
+SECRETS
+ )
+ fi
+}
diff --git a/installlib/resolve.sh b/installlib/resolve.sh
new file mode 100644
index 0000000..40fe762
--- /dev/null
+++ b/installlib/resolve.sh
@@ -0,0 +1,252 @@
+# @description Apply a built-in profile.
+# @arg $1 string Profile name.
+# @exitcode 0 If applied.
+# @exitcode 1 If unknown.
+apply_profile() {
+ local profile="$1"
+ case "$profile" in
+ minimal)
+ GET_BASHED_GNU=0
+ GET_BASHED_BUILD_FLAGS=0
+ GET_BASHED_AUTO_TOOLS=0
+ GET_BASHED_SSH_AGENT=0
+ GET_BASHED_USE_DOPPLER=0
+ ;;
+ dev)
+ GET_BASHED_GNU=1
+ GET_BASHED_BUILD_FLAGS=1
+ GET_BASHED_AUTO_TOOLS=1
+ GET_BASHED_SSH_AGENT=0
+ GET_BASHED_USE_DOPPLER=0
+ ;;
+ ops)
+ GET_BASHED_GNU=1
+ GET_BASHED_BUILD_FLAGS=1
+ GET_BASHED_AUTO_TOOLS=1
+ GET_BASHED_SSH_AGENT=1
+ GET_BASHED_USE_DOPPLER=1
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+# @description Apply a feature toggle.
+# @arg $1 string Feature name (supports no- prefix).
+# @exitcode 0 If applied.
+# @exitcode 1 If unknown.
+apply_feature() {
+ local feature="$1"
+ local enabled=1
+
+ if [[ "$feature" == no-* ]]; then
+ enabled=0
+ feature="${feature#no-}"
+ fi
+
+ case "$feature" in
+ gnu_over_bsd) GET_BASHED_GNU=$enabled ;;
+ build_flags) GET_BASHED_BUILD_FLAGS=$enabled ;;
+ auto_tools) GET_BASHED_AUTO_TOOLS=$enabled ;;
+ ssh_agent) GET_BASHED_SSH_AGENT=$enabled ;;
+ doppler_env) GET_BASHED_USE_DOPPLER=$enabled ;;
+ bash_it)
+ GET_BASHED_USE_BASH_IT=$enabled
+ if [[ "$enabled" -eq 1 ]]; then
+ GROUP_INSTALLS="$(append_csv_unique "$GROUP_INSTALLS" "bash_it")"
+ fi
+ ;;
+ git_signing) GET_BASHED_GIT_SIGNING=$enabled ;;
+ dev_tools)
+ GROUP_INSTALLS="$(merge_csv_lists "$GROUP_INSTALLS" "rg,fd,bat,eza,fzf,jq,yq,tree,direnv,starship,nodejs,python,bash")"
+ ;;
+ ops_tools)
+ GROUP_INSTALLS="$(merge_csv_lists "$GROUP_INSTALLS" "gh,git_lfs,terraform,awscli,kubectl,helm,stern,doppler,eza,nodejs,python,java,bash")"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+# @description Split a comma-delimited list into space-delimited output.
+# @arg $1 string Comma list.
+# @stdout Space-delimited items.
+split_csv() {
+ local value="$1"
+ local IFS=','
+ read -r -a _parts <<<"$value"
+ printf '%s\n' "${_parts[@]}"
+}
+
+is_valid_profile() {
+ case "$1" in
+ minimal|dev|ops) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+apply_profile_selection() {
+ local profile="$1"
+ local installs_var="${2:-}"
+ local profile_file selected_features selected_installs feature
+ local saved_features="${FEATURES:-}"
+ local saved_installs="${INSTALLS:-}"
+
+ selected_installs=""
+ profile_file="$REPO_DIR/profiles/${profile}.env"
+
+ if [[ -r "$profile_file" ]]; then
+ FEATURES=""
+ INSTALLS=""
+ # shellcheck disable=SC1090
+ source "$profile_file"
+ selected_features="${FEATURES:-}"
+ selected_installs="${INSTALLS:-}"
+ FEATURES="$saved_features"
+ INSTALLS="$saved_installs"
+
+ if [[ -n "$selected_features" ]]; then
+ for feature in $(split_csv "$selected_features"); do
+ apply_feature "$feature" || return 1
+ done
+ fi
+ else
+ apply_profile "$profile" || return 1
+ fi
+
+ if [[ -n "$installs_var" ]]; then
+ printf -v "$installs_var" '%s' "$selected_installs"
+ fi
+}
+
+append_csv_unique() {
+ local csv="$1"
+ local item="$2"
+
+ [[ -n "$item" ]] || {
+ echo "$csv"
+ return 0
+ }
+
+ case ",$csv," in
+ *,"$item",*) echo "$csv" ;;
+ ,,) echo "$item" ;;
+ *) echo "${csv},${item}" ;;
+ esac
+}
+
+merge_csv_lists() {
+ local merged=""
+ local list item
+
+ for list in "$@"; do
+ [[ -n "$list" ]] || continue
+ for item in $(split_csv "$list"); do
+ merged="$(append_csv_unique "$merged" "$item")"
+ done
+ done
+
+ echo "$merged"
+}
+
+resolve_requested_state() {
+ local cli_features="$FEATURES"
+ local cli_installs="$INSTALLS"
+ local profile_install_accum=""
+ local profile profile_installs="" feature
+
+ FEATURES=""
+ INSTALLS=""
+
+ if [[ -n "$PROFILES" ]]; then
+ for profile in $(split_csv "$PROFILES"); do
+ if ! is_valid_profile "$profile"; then
+ echo "Invalid profile name: $profile" >&2
+ exit 1
+ fi
+
+ apply_profile_selection "$profile" profile_installs || {
+ echo "Unknown profile: $profile" >&2
+ exit 1
+ }
+ profile_install_accum="$(merge_csv_lists "$profile_install_accum" "$profile_installs")"
+ done
+ fi
+
+ FEATURES="$cli_features"
+ INSTALLS="$(merge_csv_lists "$profile_install_accum" "$cli_installs")"
+
+ if [[ -n "$FEATURES" ]]; then
+ for feature in $(split_csv "$FEATURES"); do
+ apply_feature "$feature" || {
+ echo "Unknown feature: $feature" >&2
+ exit 1
+ }
+ done
+ fi
+}
+
+print_feature_list() {
+ cat <<'FEATURES'
+gnu_over_bsd
+build_flags
+auto_tools
+ssh_agent
+doppler_env
+bash_it
+git_signing
+dev_tools
+ops_tools
+FEATURES
+}
+
+handle_list_commands() {
+ local id desc_var desc
+
+ if [[ "$LIST" -eq 1 ]]; then
+ echo "Profiles: minimal, dev, ops"
+ echo "Features:"
+ print_feature_list | sed 's/^/ /'
+ echo "Installers:"
+ for id in $INSTALLERS; do
+ echo " $id"
+ done
+ exit 0
+ fi
+
+ if [[ "$LIST_PROFILES" -eq 1 ]]; then
+ printf '%s\n' minimal dev ops
+ exit 0
+ fi
+
+ if [[ "$LIST_FEATURES" -eq 1 ]]; then
+ print_feature_list
+ exit 0
+ fi
+
+ if [[ "$LIST_INSTALLERS" -eq 1 ]]; then
+ for id in $INSTALLERS; do
+ desc_var="INSTALL_DESC_${id}"
+ desc="${!desc_var}"
+ [[ -n "$desc" ]] || desc="$id"
+ echo " - $id ($desc)"
+ done
+ exit 0
+ fi
+}
+
+finalize_requested_state() {
+ INSTALLS="$(merge_csv_lists "$INSTALLS" "$GROUP_INSTALLS")"
+ export GET_BASHED_HOME="$PREFIX"
+ export GET_BASHED_VIMRC_MODE="$VIMRC_MODE"
+}
+
+print_dry_run_summary() {
+ echo "Dry run enabled. No changes will be made."
+ echo " Prefix: $PREFIX"
+ echo " Profiles: ${PROFILES:-}"
+ echo " Features: gnu_over_bsd=${GET_BASHED_GNU} build_flags=${GET_BASHED_BUILD_FLAGS} auto_tools=${GET_BASHED_AUTO_TOOLS} ssh_agent=${GET_BASHED_SSH_AGENT} doppler_env=${GET_BASHED_USE_DOPPLER} bash_it=${GET_BASHED_USE_BASH_IT} git_signing=${GET_BASHED_GIT_SIGNING}"
+ echo " Installers: ${INSTALLS:-}"
+}
diff --git a/installlib/runtime_files.sh b/installlib/runtime_files.sh
new file mode 100644
index 0000000..5d5a61c
--- /dev/null
+++ b/installlib/runtime_files.sh
@@ -0,0 +1,130 @@
+apply_gitconfig() {
+ local cfg="$PREFIX/gitconfig"
+
+ [[ -r "$cfg" ]] || return 0
+ command -v git >/dev/null 2>&1 || return 0
+
+ if [[ -n "$USER_NAME" ]]; then
+ git config -f "$cfg" user.name "$USER_NAME"
+ fi
+ if [[ -n "$USER_EMAIL" ]]; then
+ git config -f "$cfg" user.email "$USER_EMAIL"
+ fi
+}
+
+migrate_legacy() {
+ local legacy_rc_d="$HOME/.bashrc.d"
+ local legacy_secrets_d="$HOME/.secrets.d"
+ local file
+
+ if [[ -d "$legacy_rc_d" && ! -L "$legacy_rc_d" ]]; then
+ echo "Migrating legacy .bashrc.d to $PREFIX/bashrc.d..."
+ mkdir -p "$PREFIX/bashrc.d"
+ while IFS= read -r -d '' file; do
+ copy_legacy_file "$file" "$PREFIX/bashrc.d"
+ done < <(find "$legacy_rc_d" -type f -print0)
+ rm -rf "$legacy_rc_d"
+ fi
+
+ if [[ -d "$legacy_secrets_d" && ! -L "$legacy_secrets_d" ]]; then
+ echo "Migrating legacy .secrets.d to $PREFIX/secrets.d..."
+ mkdir -p "$PREFIX/secrets.d"
+ chmod 700 "$PREFIX/secrets.d"
+ while IFS= read -r -d '' file; do
+ copy_legacy_file "$file" "$PREFIX/secrets.d"
+ done < <(find "$legacy_secrets_d" -type f -print0)
+ rm -rf "$legacy_secrets_d"
+ fi
+
+ for file in "$HOME/.bashrc" "$HOME/.bash_profile"; do
+ if [[ -f "$file" && ! -L "$file" ]] &&
+ (grep -q "@file bashrc" "$file" || grep -q "@file bash_profile" "$file"); then
+ if [[ "$LINK_DOTFILES" -eq 0 ]]; then
+ echo "Detected recursive loop hazard in $file. Cleaning..."
+ backup_file "$file"
+ echo "# get-bashed: recovered from loop" > "$file"
+ fi
+ fi
+ done
+}
+
+escape_config_value() {
+ local value="$1"
+
+ value="${value//\\/\\\\}"
+ value="${value//\$/\\\$}"
+ value="${value//\"/\\\"}"
+ value="${value//\`/\\\`}"
+ echo "$value"
+}
+
+write_config_file() {
+ local escaped
+
+ {
+ echo "# Generated by get-bashed installer"
+ echo "export GET_BASHED_GNU=${GET_BASHED_GNU}"
+ echo "export GET_BASHED_BUILD_FLAGS=${GET_BASHED_BUILD_FLAGS}"
+ echo "export GET_BASHED_AUTO_TOOLS=${GET_BASHED_AUTO_TOOLS}"
+ echo "export GET_BASHED_SSH_AGENT=${GET_BASHED_SSH_AGENT}"
+ echo "export GET_BASHED_USE_DOPPLER=${GET_BASHED_USE_DOPPLER}"
+ echo "export GET_BASHED_USE_BASH_IT=${GET_BASHED_USE_BASH_IT}"
+ echo "export GET_BASHED_GIT_SIGNING=${GET_BASHED_GIT_SIGNING}"
+ echo "export GET_BASHED_VIMRC_MODE=\"${GET_BASHED_VIMRC_MODE}\""
+ if [[ -n "$USER_NAME" ]]; then
+ escaped="$(escape_config_value "$USER_NAME")"
+ echo "export GET_BASHED_USER_NAME=\"${escaped}\""
+ fi
+ if [[ -n "$USER_EMAIL" ]]; then
+ escaped="$(escape_config_value "$USER_EMAIL")"
+ echo "export GET_BASHED_USER_EMAIL=\"${escaped}\""
+ fi
+ } > "$PREFIX/get-bashedrc.sh"
+}
+
+write_runtime_pin_file() {
+ {
+ echo "# Generated by get-bashed installer"
+ echo "GET_BASHED_GEMINI_CLI_PACKAGE_SPEC=\"${GET_BASHED_NODE_GLOBAL_PACKAGES["gemini"]}\""
+ echo "GET_BASHED_SONAR_SCAN_PACKAGE_SPEC=\"${GET_BASHED_NODE_GLOBAL_PACKAGES["sonar"]}\""
+ } > "$PREFIX/get-bashed-pins.sh"
+}
+
+wire_shell_startup() {
+ local bashrc_line bashrc_snip bash_profile_line bash_profile_snip
+
+ if [[ "$LINK_DOTFILES" -eq 1 ]]; then
+ link_dotfile bashrc
+ link_dotfile bash_profile
+ link_dotfile inputrc
+ link_dotfile bash_aliases
+ link_dotfile vimrc
+ if [[ -n "$USER_NAME" && -n "$USER_EMAIL" ]]; then
+ link_dotfile gitconfig
+ else
+ echo "Skipping gitconfig link (missing --name/--email)." >&2
+ fi
+ return 0
+ fi
+
+ # shellcheck disable=SC2016
+ bashrc_line="# get-bashed: source modular bashrc"
+ # shellcheck disable=SC2016
+ bashrc_snip="if [[ -r \"$PREFIX/bashrc\" ]]; then source \"$PREFIX/bashrc\"; fi"
+ bash_profile_line="# get-bashed: source login bash_profile"
+ # shellcheck disable=SC2016
+ bash_profile_snip="if [[ -r \"$PREFIX/bash_profile\" ]]; then source \"$PREFIX/bash_profile\"; fi"
+
+ ensure_block "$HOME/.bashrc" "$bashrc_line" "$bashrc_snip"
+ ensure_block "$HOME/.bash_profile" "$bash_profile_line" "$bash_profile_snip"
+}
+
+install_managed_assets() {
+ sync_managed_assets
+ migrate_legacy
+ ensure_secrets_stub
+ write_config_file
+ write_runtime_pin_file
+ apply_gitconfig
+ wire_shell_startup
+}
diff --git a/installlib/ui.sh b/installlib/ui.sh
new file mode 100644
index 0000000..dcca50b
--- /dev/null
+++ b/installlib/ui.sh
@@ -0,0 +1,161 @@
+install_dialog() {
+ if command -v dialog >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if _using_brew; then
+ brew_exec install dialog
+ elif command -v apt-get >/dev/null 2>&1; then
+ apt_install dialog
+ elif command -v dnf >/dev/null 2>&1; then
+ dnf_install dialog
+ elif command -v yum >/dev/null 2>&1; then
+ yum_install dialog
+ fi
+}
+
+prompt_yes_no() {
+ local label="$1"
+ local default="${2:-0}"
+ local answer
+ local prompt='[y/N]'
+
+ if [[ "$YES" -eq 1 ]]; then
+ return 0
+ fi
+
+ if [[ "$default" -eq 1 ]]; then
+ prompt='[Y/n]'
+ fi
+
+ read -r -p "$label $prompt: " answer
+ if [[ -z "$answer" ]]; then
+ [[ "$default" -eq 1 ]]
+ return
+ fi
+
+ [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+run_interactive_selection() {
+ if [[ "$AUTO" -eq 1 ]]; then
+ return 0
+ fi
+
+ if [[ "$WITH_UI" -eq 1 ]] && command -v dialog >/dev/null 2>&1; then
+ run_dialog_selection
+ return 0
+ fi
+
+ run_prompt_selection
+}
+
+run_dialog_selection() {
+ local profile_choice choices installs_dialog
+ local profile_installs="" current_installs=""
+ local id desc_var desc default_state
+ local action_opts=()
+
+ if [[ "$YES" -eq 1 ]]; then
+ return 0
+ fi
+
+ profile_choice=$(dialog --clear --title "get-bashed" --menu "Select a profile" 12 60 3 \
+ minimal "Minimal defaults" \
+ dev "Developer workstation" \
+ ops "Ops/Platform workstation" \
+ 3>&1 1>&2 2>&3) || true
+ if [[ -n "$profile_choice" ]]; then
+ apply_profile_selection "$profile_choice" profile_installs || true
+ INSTALLS="$(merge_csv_lists "$INSTALLS" "$profile_installs")"
+ fi
+
+ choices=$(dialog --clear --title "get-bashed" --checklist "Enable features" 18 70 8 \
+ gnu_over_bsd "Prefer GNU tools on macOS" "$( [[ "$GET_BASHED_GNU" -eq 1 ]] && echo on || echo off )" \
+ build_flags "Enable runtime build flags" "$( [[ "$GET_BASHED_BUILD_FLAGS" -eq 1 ]] && echo on || echo off )" \
+ auto_tools "Auto-install optional tools" "$( [[ "$GET_BASHED_AUTO_TOOLS" -eq 1 ]] && echo on || echo off )" \
+ ssh_agent "Auto-start ssh-agent" "$( [[ "$GET_BASHED_SSH_AGENT" -eq 1 ]] && echo on || echo off )" \
+ doppler_env "Enable Doppler integration" "$( [[ "$GET_BASHED_USE_DOPPLER" -eq 1 ]] && echo on || echo off )" \
+ bash_it "Enable bash-it (if installed)" "$( [[ "$GET_BASHED_USE_BASH_IT" -eq 1 ]] && echo on || echo off )" \
+ git_signing "Enable git signing (gnupg)" "$( [[ "$GET_BASHED_GIT_SIGNING" -eq 1 ]] && echo on || echo off )" \
+ dev_tools "Developer tool bundle" off \
+ ops_tools "Ops tool bundle" off \
+ 3>&1 1>&2 2>&3) || true
+
+ GET_BASHED_GNU=0
+ GET_BASHED_BUILD_FLAGS=0
+ GET_BASHED_AUTO_TOOLS=0
+ GET_BASHED_SSH_AGENT=0
+ GET_BASHED_USE_DOPPLER=0
+ GET_BASHED_USE_BASH_IT=0
+ GET_BASHED_GIT_SIGNING=0
+ GROUP_INSTALLS=""
+
+ local choice
+ for choice in $choices; do
+ apply_feature "${choice//\"/}" || true
+ done
+
+ current_installs="$(merge_csv_lists "$INSTALLS" "$GROUP_INSTALLS")"
+ for id in $INSTALLERS; do
+ desc_var="INSTALL_DESC_${id}"
+ desc="${!desc_var}"
+ [[ -n "$desc" ]] || desc="$id"
+ default_state="off"
+ case ",$current_installs," in
+ *,"$id",*) default_state="on" ;;
+ *) [[ "$id" == "dialog" ]] && default_state="on" ;;
+ esac
+ action_opts+=("$id" "$desc" "$default_state")
+ done
+
+ installs_dialog=$(dialog --clear --title "get-bashed" --checklist "Select installers" 20 80 12 \
+ "${action_opts[@]}" \
+ 3>&1 1>&2 2>&3) || true
+ if [[ -n "$installs_dialog" ]]; then
+ INSTALLS="${installs_dialog//\"/}"
+ INSTALLS="${INSTALLS// /,}"
+ fi
+
+ if [[ -z "$USER_NAME" ]]; then
+ USER_NAME=$(dialog --clear --title "get-bashed" --inputbox "Git user.name" 8 60 "$USER_NAME" 3>&1 1>&2 2>&3) || true
+ fi
+ if [[ -z "$USER_EMAIL" ]]; then
+ USER_EMAIL=$(dialog --clear --title "get-bashed" --inputbox "Git user.email" 8 60 "$USER_EMAIL" 3>&1 1>&2 2>&3) || true
+ fi
+}
+
+run_prompt_selection() {
+ local default_gnu="$GET_BASHED_GNU"
+ local default_build_flags="$GET_BASHED_BUILD_FLAGS"
+ local default_auto_tools="$GET_BASHED_AUTO_TOOLS"
+ local default_ssh_agent="$GET_BASHED_SSH_AGENT"
+ local default_doppler="$GET_BASHED_USE_DOPPLER"
+ local default_bash_it="$GET_BASHED_USE_BASH_IT"
+ local default_git_signing="$GET_BASHED_GIT_SIGNING"
+
+ if [[ "$YES" -eq 1 ]]; then
+ return 0
+ fi
+
+ if [[ "$YES" -eq 0 ]]; then
+ prompt_yes_no "Proceed with installation?" || exit 1
+ fi
+
+ GET_BASHED_GNU=0
+ GET_BASHED_BUILD_FLAGS=0
+ GET_BASHED_AUTO_TOOLS=0
+ GET_BASHED_SSH_AGENT=0
+ GET_BASHED_USE_DOPPLER=0
+ GET_BASHED_USE_BASH_IT=0
+ GET_BASHED_GIT_SIGNING=0
+ GROUP_INSTALLS=""
+
+ if prompt_yes_no "Enable GNU tools on macOS (gnu_over_bsd)?" "$default_gnu"; then apply_feature gnu_over_bsd; fi
+ if prompt_yes_no "Enable build flags (build_flags)?" "$default_build_flags"; then apply_feature build_flags; fi
+ if prompt_yes_no "Enable auto tools (auto_tools)?" "$default_auto_tools"; then apply_feature auto_tools; fi
+ if prompt_yes_no "Enable ssh-agent (ssh_agent)?" "$default_ssh_agent"; then apply_feature ssh_agent; fi
+ if prompt_yes_no "Enable Doppler integration (doppler_env)?" "$default_doppler"; then apply_feature doppler_env; fi
+ if prompt_yes_no "Enable bash-it (bash_it)?" "$default_bash_it"; then apply_feature bash_it; fi
+ if prompt_yes_no "Enable git signing (git_signing)?" "$default_git_signing"; then apply_feature git_signing; fi
+}
diff --git a/release-please-config.json b/release-please-config.json
index e9dae47..d833997 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -1,6 +1,8 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
+ "draft": true,
+ "force-tag-creation": true,
"packages": {
".": {
"exclude-paths": [
diff --git a/scripts/build_release_artifact.sh b/scripts/build_release_artifact.sh
new file mode 100755
index 0000000..0cd7f78
--- /dev/null
+++ b/scripts/build_release_artifact.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERSION_RAW="${1:?version required}"
+OUT_DIR="${2:?output dir required}"
+VERSION="${VERSION_RAW#v}"
+PYTHON_BIN="${PYTHON:-$(command -v python3 || command -v python || true)}"
+
+UNIX_NAME="get-bashed-${VERSION}-unix"
+WINDOWS_NAME="get-bashed-${VERSION}-windows"
+UNIX_ARCHIVE="${OUT_DIR}/${UNIX_NAME}.tar.gz"
+WINDOWS_ARCHIVE="${OUT_DIR}/${WINDOWS_NAME}.zip"
+TMPDIR="$(mktemp -d)"
+
+cleanup() {
+ rm -rf "$TMPDIR"
+}
+trap cleanup EXIT
+
+if [ -z "$PYTHON_BIN" ]; then
+ echo "python3 or python is required to build release archives" >&2
+ exit 1
+fi
+
+mkdir -p "$OUT_DIR"
+
+bundle_paths=(
+ install.sh
+ install.bash
+ bash_aliases
+ bash_profile
+ bashrc
+ gitconfig
+ inputrc
+ vimrc
+ LICENSE
+ README.md
+ CHANGELOG.md
+ TOOLS.md
+ SECURITY.md
+ bashrc.d
+ bin
+ installers
+ installlib
+ profiles
+ secrets.d
+)
+
+copy_bundle() {
+ local destination="$1"
+ mkdir -p "$destination"
+ (
+ cd "$ROOT_DIR"
+ tar \
+ --exclude='*/__pycache__' \
+ --exclude='*.pyc' \
+ --exclude='.DS_Store' \
+ -cf - "${bundle_paths[@]}"
+ ) | (
+ cd "$destination"
+ tar -xf -
+ )
+}
+
+write_unix_wrapper() {
+ local path="$1"
+ cat >"$path" <<'EOF'
+#!/usr/bin/env sh
+set -eu
+
+SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname "$0")" && pwd)"
+exec sh "$SCRIPT_DIR/install.sh" "$@"
+EOF
+ chmod +x "$path"
+}
+
+write_windows_wrappers() {
+ local root="$1"
+
+ cat >"$root/get-bashed.ps1" <<'EOF'
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$installSh = Join-Path $scriptDir 'install.sh'
+$forward = @($args)
+
+if (Get-Command wsl.exe -ErrorAction SilentlyContinue) {
+ $resolved = [System.IO.Path]::GetFullPath($installSh)
+ $drive = $resolved.Substring(0, 1).ToLowerInvariant()
+ $pathPart = $resolved.Substring(2).Replace('\', '/')
+ $wslPath = "/mnt/$drive$pathPart"
+ & wsl.exe --exec sh $wslPath @forward
+ exit $LASTEXITCODE
+}
+
+if (Get-Command bash.exe -ErrorAction SilentlyContinue) {
+ $resolved = [System.IO.Path]::GetFullPath($installSh)
+ $drive = $resolved.Substring(0, 1).ToLowerInvariant()
+ $pathPart = $resolved.Substring(2).Replace('\', '/')
+ $msysPath = "/$drive$pathPart"
+ & bash.exe -lc 'sh "$0" "$@"' $msysPath @forward
+ exit $LASTEXITCODE
+}
+
+Write-Error 'get-bashed requires WSL (preferred) or bash.exe from Git Bash on Windows.'
+exit 1
+EOF
+
+ cat >"$root/get-bashed.cmd" <<'EOF'
+@echo off
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0get-bashed.ps1" %*
+EOF
+}
+
+write_checksums() {
+ local artifact="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ (
+ cd "$(dirname "$artifact")"
+ sha256sum "$(basename "$artifact")"
+ ) >"${artifact}.sha256"
+ else
+ (
+ cd "$(dirname "$artifact")"
+ shasum -a 256 "$(basename "$artifact")"
+ ) >"${artifact}.sha256"
+ fi
+}
+
+unix_stage="${TMPDIR}/${UNIX_NAME}"
+windows_stage="${TMPDIR}/windows-root"
+
+copy_bundle "$unix_stage"
+copy_bundle "$windows_stage"
+write_unix_wrapper "$unix_stage/get-bashed"
+write_windows_wrappers "$windows_stage"
+
+tar -C "$TMPDIR" -czf "$UNIX_ARCHIVE" "$UNIX_NAME"
+
+"$PYTHON_BIN" - "$windows_stage" "$WINDOWS_ARCHIVE" <<'PY'
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile
+import sys
+
+stage = Path(sys.argv[1])
+archive = Path(sys.argv[2])
+
+with ZipFile(archive, "w", compression=ZIP_DEFLATED) as bundle:
+ for path in sorted(stage.rglob("*")):
+ if path.is_file():
+ bundle.write(path, path.relative_to(stage))
+PY
+
+write_checksums "$UNIX_ARCHIVE"
+write_checksums "$WINDOWS_ARCHIVE"
diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh
index 6c197d1..947b653 100755
--- a/scripts/ci-setup.sh
+++ b/scripts/ci-setup.sh
@@ -13,9 +13,20 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}"
export GET_BASHED_HOME="$PREFIX"
export PATH="$GET_BASHED_HOME/bin:$PATH"
+export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}"
+export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}"
+export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}"
INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}"
"$ROOT_DIR/install.sh" --auto --install "$INSTALLS"
+if [[ -n "${GITHUB_ENV:-}" ]]; then
+ printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV"
+fi
+
+if [[ -n "${GITHUB_PATH:-}" ]]; then
+ printf '%s\n' "$GET_BASHED_HOME/bin" >> "$GITHUB_PATH"
+fi
+
echo "CI tools installed to $GET_BASHED_HOME"
diff --git a/scripts/gen-docs.sh b/scripts/gen-docs.sh
index e417fd2..d262bb4 100755
--- a/scripts/gen-docs.sh
+++ b/scripts/gen-docs.sh
@@ -2,7 +2,7 @@
# @file gen-docs
# @brief Generate documentation for get-bashed.
# @description
-# Uses shdoc to generate markdown docs from shell scripts.
+# Uses shdoc plus registry metadata to generate installer documentation.
set -euo pipefail
@@ -13,14 +13,11 @@ command -v shdoc >/dev/null 2>&1 || {
exit 1
}
-shdoc < "$ROOT_DIR/install.bash" > "$ROOT_DIR/docs/INSTALLER.md"
-shdoc < "$ROOT_DIR/installers/_helpers.sh" > "$ROOT_DIR/docs/INSTALLERS_HELPERS.md"
-shdoc < "$ROOT_DIR/installers/tools.sh" > "$ROOT_DIR/docs/INSTALLERS.md"
-
fix_toc_anchors() {
- local file="$1" tmp
+ local file="$1"
+ local tmp
+
tmp="$(mktemp)"
- trap 'rm -f "$tmp"' RETURN
awk '
function anchorize(text, t) {
t = tolower(text)
@@ -42,42 +39,90 @@ fix_toc_anchors() {
}
' "$file" > "$tmp"
mv "$tmp" "$file"
+ rm -f "$tmp"
}
ensure_eof() {
local file="$1"
- python3 - "$file" <<'PY'
-import sys
-from pathlib import Path
-path = Path(sys.argv[1])
-data = path.read_bytes()
-if not data.endswith(b"\n"):
- path.write_bytes(data + b"\n")
-PY
+ local last_char
+
+ [[ -s "$file" ]] || return 0
+ last_char="$(tail -c 1 "$file" 2>/dev/null || true)"
+ [[ -n "$last_char" ]] && printf '\n' >> "$file"
+ return 0
}
-for doc in "$ROOT_DIR/docs/INSTALLER.md" "$ROOT_DIR/docs/INSTALLERS_HELPERS.md" "$ROOT_DIR/docs/INSTALLERS.md"; do
- fix_toc_anchors "$doc"
- ensure_eof "$doc"
-done
-
-# Combine all runtime modules
-TMP_MODULES="$(mktemp)"
-trap 'rm -f "$TMP_MODULES"' EXIT
-shopt -s nullglob
-for f in "$ROOT_DIR/bashrc.d"/*.sh; do
- {
- echo ""
- cat "$f"
- echo ""
- echo "# ----"
- echo ""
- } >> "$TMP_MODULES"
-done
-shopt -u nullglob
-shdoc < "$TMP_MODULES" > "$ROOT_DIR/docs/MODULES.md"
-fix_toc_anchors "$ROOT_DIR/docs/MODULES.md"
-ensure_eof "$ROOT_DIR/docs/MODULES.md"
-
-# Note: index.md is maintained manually for Sphinx toctree.
+generate_shdoc_doc() {
+ local output="$1"
+ shift
+ local tmp
+
+ tmp="$(mktemp)"
+
+ for source_file in "$@"; do
+ cat "$source_file" >> "$tmp"
+ printf '\n' >> "$tmp"
+ done
+
+ shdoc < "$tmp" > "$output"
+ rm -f "$tmp"
+ fix_toc_anchors "$output"
+ ensure_eof "$output"
+}
+
+generate_installers_catalog() {
+ local output="$ROOT_DIR/docs/INSTALLERS.md"
+
+ (
+ # shellcheck disable=SC1091
+ source "$ROOT_DIR/installers/_helpers.sh"
+ # shellcheck disable=SC1091
+ source "$ROOT_DIR/installers/tools.sh"
+
+ markdown_cell() {
+ printf '%s' "$1" | sed 's/|/\\|/g'
+ }
+
+ printf '# Tool Registry\n\n'
+ printf "Generated from \`installers/tools.sh\` and pinned source metadata.\n\n"
+ printf '| Tool | Description | Dependencies | Platforms | Methods |\n'
+ printf '|---|---|---|---|---|\n'
+
+ for id in "${TOOL_IDS[@]}"; do
+ printf "| \`%s\` | %s | %s | %s | %s |\n" \
+ "$id" \
+ "$(markdown_cell "${TOOL_DESC[$id]}")" \
+ "$(markdown_cell "${TOOL_DEPS[$id]:-}")" \
+ "$(markdown_cell "${TOOL_PLATFORMS[$id]:-}")" \
+ "$(markdown_cell "${TOOL_METHODS[$id]:-}")"
+ done
+ ) > "$output"
+
+ ensure_eof "$output"
+}
+
+generate_shdoc_doc \
+ "$ROOT_DIR/docs/INSTALLER.md" \
+ "$ROOT_DIR/install.bash" \
+ "$ROOT_DIR/installlib/config.sh" \
+ "$ROOT_DIR/installlib/resolve.sh" \
+ "$ROOT_DIR/installlib/ui.sh" \
+ "$ROOT_DIR/installlib/filesystem.sh" \
+ "$ROOT_DIR/installlib/managed_files.sh" \
+ "$ROOT_DIR/installlib/runtime_files.sh" \
+ "$ROOT_DIR/installlib/installers.sh"
+
+generate_shdoc_doc \
+ "$ROOT_DIR/docs/INSTALLERS_HELPERS.md" \
+ "$ROOT_DIR/installers/_helpers.sh" \
+ "$ROOT_DIR/installers/lib/core.sh" \
+ "$ROOT_DIR/installers/lib/system.sh" \
+ "$ROOT_DIR/installers/lib/packages.sh" \
+ "$ROOT_DIR/installers/lib/asdf.sh" \
+ "$ROOT_DIR/installers/lib/tool_runner.sh" \
+ "$ROOT_DIR/installers/lib/installers.sh" \
+ "$ROOT_DIR/installers/lib/languages.sh"
+
+generate_installers_catalog
+
echo "Docs generated under docs/"
diff --git a/scripts/generate_pkg_manifests.sh b/scripts/generate_pkg_manifests.sh
new file mode 100755
index 0000000..bc511f0
--- /dev/null
+++ b/scripts/generate_pkg_manifests.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+VERSION_RAW="${1:?version required}"
+CHECKSUMS="${2:?checksums file required}"
+OUT_DIR="${3:?output dir required}"
+VERSION="${VERSION_RAW#v}"
+REPO="jbcom/get-bashed"
+TAG="v${VERSION}"
+RELEASE_URL="https://github.com/${REPO}/releases/download/${TAG}"
+UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz"
+WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip"
+
+sha_of() {
+ local name="$1"
+ local sha
+ sha="$(grep " ${name}\$" "$CHECKSUMS" | awk '{print $1}')"
+ if [ -z "$sha" ]; then
+ echo "missing checksum for ${name}" >&2
+ exit 1
+ fi
+ printf '%s' "$sha"
+}
+
+SHA_UNIX="$(sha_of "$UNIX_ARCHIVE")"
+SHA_WINDOWS="$(sha_of "$WINDOWS_ARCHIVE")"
+
+mkdir -p "$OUT_DIR"
+
+cat >"$OUT_DIR/get-bashed.rb" <"$OUT_DIR/get-bashed.json" <"$OUT_DIR/get-bashed.nuspec" <
+
+
+ get-bashed
+ ${VERSION}
+ get-bashed
+ Jon B
+ jbcom
+ https://github.com/${REPO}
+ https://github.com/${REPO}/blob/main/LICENSE
+ https://github.com/${REPO}
+ https://github.com/jbcom/pkgs
+ https://jbcom.github.io/get-bashed/
+ https://github.com/${REPO}/issues
+ false
+ Managed Bash environment bootstrap and runtime for macOS, Linux, and WSL
+
+get-bashed installs a managed Bash profile under ~/.get-bashed, wires shell
+startup predictably, keeps installer choices reproducible, and ships Windows
+wrapper launchers that invoke the bundled installer through WSL.
+
+ bash shell bootstrap terminal wsl dotfiles cli
+ https://github.com/${REPO}/releases/tag/${TAG}
+
+
+
+
+
+EOF
+
+cat >"$OUT_DIR/chocolateyInstall.ps1" <"$OUT_DIR/VERIFICATION.txt" </dev/null 2>&1; then
+ echo "gh CLI is required for immutable release governance checks" >&2
+ exit 1
+ fi
+
+ if [ -z "$IMMUTABLE_RELEASE_PYTHON_BIN" ]; then
+ echo "python3 or python is required for immutable release governance checks" >&2
+ exit 1
+ fi
+
+ if ! gh auth status >/dev/null 2>&1; then
+ echo "gh auth is required for immutable release governance checks" >&2
+ exit 1
+ fi
+}
+
+immutable_release_fetch_repo_file() {
+ local repo="$1"
+ local branch="$2"
+ local path="$3"
+
+ gh api "repos/${repo}/contents/${path}?ref=${branch}" --jq '.content' \
+ | tr -d '\n' \
+ | "$IMMUTABLE_RELEASE_PYTHON_BIN" -c '
+import base64
+import sys
+
+payload = sys.stdin.read().strip()
+if not payload:
+ raise SystemExit(1)
+
+sys.stdout.write(base64.b64decode(payload).decode("utf-8"))
+'
+}
+
+immutable_release_branch_ready() {
+ local repo="$1"
+ local branch="$2"
+ local release_config=""
+ local cd_workflow=""
+ local release_workflow=""
+
+ release_config="$(immutable_release_fetch_repo_file "$repo" "$branch" "release-please-config.json" 2>/dev/null || true)"
+ cd_workflow="$(immutable_release_fetch_repo_file "$repo" "$branch" ".github/workflows/cd.yml" 2>/dev/null || true)"
+ release_workflow="$(immutable_release_fetch_repo_file "$repo" "$branch" ".github/workflows/release.yml" 2>/dev/null || true)"
+
+ [ -n "$release_config" ] || return 1
+ [ -n "$cd_workflow" ] || return 1
+ [ -n "$release_workflow" ] || return 1
+
+ [[ "$release_config" == *'"draft": true'* ]] || return 1
+ [[ "$release_config" == *'"force-tag-creation": true'* ]] || return 1
+
+ [[ "$cd_workflow" == *'id: release'* ]] || return 1
+ [[ "$cd_workflow" == *'scripts/publish_draft_release.sh'* ]] || return 1
+
+ [[ "$release_workflow" == *'workflow_dispatch:'* ]] || return 1
+ [[ "$release_workflow" == *'scripts/publish_draft_release.sh'* ]] || return 1
+ [[ "$release_workflow" != *'types: [published]'* ]] || return 1
+}
diff --git a/scripts/package.sh b/scripts/package.sh
index 5e79eb1..1b0480a 100755
--- a/scripts/package.sh
+++ b/scripts/package.sh
@@ -1,23 +1,16 @@
#!/usr/bin/env bash
# @file package
-# @brief Package get-bashed into a tarball.
+# @brief Compatibility wrapper around the release packager.
# @description
-# Produces a versioned tarball for releases.
+# Produces the Unix release tarball used by the newer release pipeline.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
-VERSION="${2:-$(git describe --tags --always --dirty)}"
+VERSION_RAW="${2:-$(git describe --tags --always --dirty)}"
+VERSION="${VERSION_RAW#v}"
-mkdir -p "$OUT_DIR"
-TARBALL="$OUT_DIR/get-bashed-${VERSION}.tar.gz"
+bash "$ROOT_DIR/scripts/build_release_artifact.sh" "$VERSION" "$OUT_DIR" >/dev/null
-tar -czf "$TARBALL" \
- --exclude-vcs \
- --exclude='./dist' \
- --exclude='./tests' \
- --exclude='./.github' \
- -C "$ROOT_DIR" .
-
-echo "$TARBALL"
+printf '%s\n' "$OUT_DIR/get-bashed-${VERSION}-unix.tar.gz"
diff --git a/scripts/pre-commit-ci.sh b/scripts/pre-commit-ci.sh
index 253d54d..bb5469e 100755
--- a/scripts/pre-commit-ci.sh
+++ b/scripts/pre-commit-ci.sh
@@ -7,9 +7,12 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}"
-# shellcheck disable=SC1091
-. "$ROOT_DIR/scripts/ci-setup.sh" "pre_commit,actionlint,shellcheck,bashate,shdoc"
+export GET_BASHED_HOME="$PREFIX"
+export PATH="$GET_BASHED_HOME/bin:$PATH"
+
+"$ROOT_DIR/scripts/ci-setup.sh" "pre_commit,actionlint,shellcheck,bashate,shdoc"
if command -v shdoc >/dev/null 2>&1; then
"$ROOT_DIR/scripts/gen-docs.sh"
diff --git a/scripts/publish_draft_release.sh b/scripts/publish_draft_release.sh
new file mode 100644
index 0000000..62a94e6
--- /dev/null
+++ b/scripts/publish_draft_release.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+TAG="${1:?release tag required (for example v0.1.0)}"
+DIST_DIR="${2:?dist dir required}"
+REPO="${3:-jbcom/get-bashed}"
+PUBLISH_RELEASE="${4:-${PUBLISH_RELEASE:-true}}"
+
+case "$TAG" in
+ v*) VERSION="${TAG#v}" ;;
+ *) VERSION="$TAG"; TAG="v$TAG" ;;
+esac
+
+UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz"
+WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip"
+CHECKSUMS_FILE="checksums.txt"
+
+require_file() {
+ local path="$1"
+ if [ ! -f "$path" ]; then
+ echo "missing release artifact: $path" >&2
+ exit 1
+ fi
+}
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "gh CLI is required for draft release publication" >&2
+ exit 1
+fi
+
+if ! gh auth status >/dev/null 2>&1; then
+ echo "gh auth is required for draft release publication" >&2
+ exit 1
+fi
+
+require_file "$DIST_DIR/$UNIX_ARCHIVE"
+require_file "$DIST_DIR/$WINDOWS_ARCHIVE"
+require_file "$DIST_DIR/$CHECKSUMS_FILE"
+
+is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')"
+if [ "$is_draft" != "true" ]; then
+ echo "release ${TAG} in ${REPO} must exist as a draft before assets can be uploaded" >&2
+ exit 1
+fi
+
+gh release upload "$TAG" \
+ "$DIST_DIR/$UNIX_ARCHIVE" \
+ "$DIST_DIR/$WINDOWS_ARCHIVE" \
+ "$DIST_DIR/$CHECKSUMS_FILE" \
+ --repo "$REPO" \
+ --clobber
+
+if [ "$PUBLISH_RELEASE" = "true" ]; then
+ gh release edit "$TAG" --repo "$REPO" --draft=false >/dev/null
+ is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')"
+ if [ "$is_draft" != "false" ]; then
+ echo "release ${TAG} in ${REPO} did not publish successfully" >&2
+ exit 1
+ fi
+ printf 'published release %s in %s\n' "$TAG" "$REPO"
+else
+ printf 'uploaded assets to draft release %s in %s\n' "$TAG" "$REPO"
+fi
diff --git a/scripts/publish_pkg_pr.sh b/scripts/publish_pkg_pr.sh
new file mode 100755
index 0000000..cd4f84c
--- /dev/null
+++ b/scripts/publish_pkg_pr.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+VERSION_RAW="${1:?version required}"
+MANIFEST_DIR_INPUT="${2:?manifest directory required}"
+VERSION="${VERSION_RAW#v}"
+TARGET_REPO="${TARGET_REPO:-jbcom/pkgs}"
+TARGET_BASE_BRANCH="${TARGET_BASE_BRANCH:-main}"
+GIT_NAME="${GIT_NAME:-jbcom-bot}"
+GIT_EMAIL="${GIT_EMAIL:-noreply@jonbogaty.com}"
+
+if [ -z "${GH_TOKEN:-}" ]; then
+ echo "GH_TOKEN is required to publish package PRs" >&2
+ exit 1
+fi
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "gh CLI is required to publish package PRs" >&2
+ exit 1
+fi
+
+TARGET_REPO_URL="${TARGET_REPO_URL:-https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git}"
+
+resolve_manifest_dir() {
+ local candidate="$1"
+ if [ -f "$candidate/get-bashed.rb" ] && [ -f "$candidate/get-bashed.json" ]; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ if [ -d "$candidate/pkg" ] && [ -f "$candidate/pkg/get-bashed.rb" ] && [ -f "$candidate/pkg/get-bashed.json" ]; then
+ printf '%s\n' "$candidate/pkg"
+ return 0
+ fi
+ echo "manifest directory does not contain generated package files: $candidate" >&2
+ exit 1
+}
+
+MANIFEST_DIR="$(resolve_manifest_dir "$MANIFEST_DIR_INPUT")"
+
+tmpdir="$(mktemp -d)"
+cleanup() {
+ rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+git clone "$TARGET_REPO_URL" "$tmpdir/pkgs"
+cd "$tmpdir/pkgs"
+
+branch="get-bashed/bump-${VERSION}"
+git checkout -B "$branch" "origin/${TARGET_BASE_BRANCH}"
+mkdir -p Formula bucket choco/get-bashed/tools
+
+cp "$MANIFEST_DIR/get-bashed.rb" Formula/get-bashed.rb
+cp "$MANIFEST_DIR/get-bashed.json" bucket/get-bashed.json
+cp "$MANIFEST_DIR/get-bashed.nuspec" choco/get-bashed/get-bashed.nuspec
+cp "$MANIFEST_DIR/chocolateyInstall.ps1" choco/get-bashed/tools/chocolateyInstall.ps1
+cp "$MANIFEST_DIR/VERIFICATION.txt" choco/get-bashed/tools/VERIFICATION.txt
+
+git config user.name "$GIT_NAME"
+git config user.email "$GIT_EMAIL"
+git add Formula/get-bashed.rb bucket/get-bashed.json choco/get-bashed
+
+if git diff --cached --quiet; then
+ echo "No package manifest changes for ${VERSION}"
+ exit 0
+fi
+
+git commit -m "feat(get-bashed): bump to ${VERSION}"
+git push -u origin "$branch" --force-with-lease
+
+pr_url="$(
+ gh pr list \
+ --repo "$TARGET_REPO" \
+ --head "$branch" \
+ --state open \
+ --json url \
+ --jq '.[0].url // ""'
+)"
+
+if [ -z "$pr_url" ]; then
+ pr_url="$(
+ gh pr create \
+ --repo "$TARGET_REPO" \
+ --base "$TARGET_BASE_BRANCH" \
+ --head "$branch" \
+ --title "feat(get-bashed): bump to ${VERSION}" \
+ --body "Auto-generated from get-bashed release pipeline for v${VERSION}."
+ )"
+fi
+
+gh pr merge --repo "$TARGET_REPO" --auto --squash "$pr_url"
diff --git a/scripts/reconcile_codeql_governance.sh b/scripts/reconcile_codeql_governance.sh
new file mode 100644
index 0000000..6aa369c
--- /dev/null
+++ b/scripts/reconcile_codeql_governance.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+REPO="${1:-jbcom/get-bashed}"
+BRANCH="${2:-main}"
+
+required_codeql_checks=(
+ "CodeQL (actions)"
+ "CodeQL (python)"
+)
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "gh CLI is required for CodeQL governance reconciliation" >&2
+ exit 1
+fi
+
+if ! gh auth status >/dev/null 2>&1; then
+ echo "gh auth is required for CodeQL governance reconciliation" >&2
+ exit 1
+fi
+
+if ! gh api "repos/${REPO}/contents/.github/workflows/codeql.yml?ref=${BRANCH}" >/dev/null 2>&1; then
+ echo "codeql.yml must be present on ${REPO}:${BRANCH} before retiring default setup" >&2
+ exit 1
+fi
+
+default_state="$(gh api "repos/${REPO}/code-scanning/default-setup" --jq '.state')"
+if [ "$default_state" != "not-configured" ]; then
+ gh api -X PATCH "repos/${REPO}/code-scanning/default-setup" -f state='not-configured' >/dev/null
+ echo "retired GitHub default CodeQL setup for ${REPO}"
+else
+ echo "GitHub default CodeQL setup already retired for ${REPO}"
+fi
+
+strict="$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" --jq '.strict')"
+mapfile -t current_contexts < <(
+ gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" --jq '.contexts[]'
+)
+
+mapfile -t merged_contexts < <(
+ printf '%s\n' "${current_contexts[@]}" "${required_codeql_checks[@]}" \
+ | awk 'NF && !seen[$0]++' \
+ | LC_ALL=C sort
+)
+
+patch_args=(
+ api
+ -X PATCH
+ "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks"
+ -F "strict=${strict}"
+)
+
+for context in "${merged_contexts[@]}"; do
+ patch_args+=(-f "contexts[]=${context}")
+done
+
+gh "${patch_args[@]}" >/dev/null
+
+printf 'updated required status checks for %s:%s\n' "${REPO}" "${BRANCH}"
+printf 'required checks now include: %s\n' "${required_codeql_checks[*]}"
+printf 'next: bash ./scripts/verify_branch_protection.sh %s %s\n' "${REPO}" "${BRANCH}"
diff --git a/scripts/reconcile_immutable_release_governance.sh b/scripts/reconcile_immutable_release_governance.sh
new file mode 100644
index 0000000..ef5129c
--- /dev/null
+++ b/scripts/reconcile_immutable_release_governance.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=scripts/lib/immutable_release_flow.sh
+. "$SCRIPT_DIR/lib/immutable_release_flow.sh"
+
+REPO="${1:-jbcom/get-bashed}"
+BRANCH="${2:-main}"
+
+immutable_release_require_gh
+
+if ! immutable_release_branch_ready "$REPO" "$BRANCH"; then
+ printf 'draft-first release flow must be present on %s:%s before enabling immutable releases\n' \
+ "$REPO" "$BRANCH" >&2
+ exit 1
+fi
+
+immutable_enabled="$(gh api "repos/${REPO}/immutable-releases" --jq '.enabled' 2>/dev/null || printf 'false')"
+if [ "$immutable_enabled" != "true" ]; then
+ gh api -X PUT "repos/${REPO}/immutable-releases" >/dev/null
+ printf 'enabled immutable releases for %s:%s\n' "$REPO" "$BRANCH"
+else
+ printf 'immutable releases already enabled for %s:%s\n' "$REPO" "$BRANCH"
+fi
+
+printf 'next: bash ./scripts/verify_immutable_release_governance.sh %s %s\n' "$REPO" "$BRANCH"
diff --git a/scripts/release_validate.sh b/scripts/release_validate.sh
new file mode 100755
index 0000000..779ad37
--- /dev/null
+++ b/scripts/release_validate.sh
@@ -0,0 +1,246 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERSION_RAW="${1:?version required}"
+DIST_DIR="${2:?dist dir required}"
+VERSION="${VERSION_RAW#v}"
+PYTHON_BIN="${PYTHON:-$(command -v python3 || command -v python || true)}"
+UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz"
+WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip"
+
+if [ -z "$PYTHON_BIN" ]; then
+ echo "python3 or python is required for release validation" >&2
+ exit 1
+fi
+
+pick_port() {
+ "$PYTHON_BIN" - <<'PY'
+import socket
+
+sock = socket.socket()
+sock.bind(("127.0.0.1", 0))
+print(sock.getsockname()[1])
+sock.close()
+PY
+}
+
+wait_for_http() {
+ local url="$1"
+ local attempt
+ for attempt in $(seq 1 50); do
+ if "$PYTHON_BIN" - "$url" <<'PY' >/dev/null 2>&1
+from urllib.request import urlopen
+import sys
+
+with urlopen(sys.argv[1], timeout=1):
+ pass
+PY
+ then
+ return 0
+ fi
+ sleep 0.1
+ done
+ echo "timed out waiting for ${url}" >&2
+ exit 1
+}
+
+emulate_homebrew_install() {
+ local source_root="$1"
+ local version="$2"
+ local prefix_root="$3"
+ local stage_dir="$source_root/get-bashed-${version}-unix"
+ local libexec_dir="$prefix_root/libexec"
+ local bin_dir="$prefix_root/bin"
+
+ test -d "$stage_dir"
+ mkdir -p "$libexec_dir" "$bin_dir"
+ cp -R "$stage_dir"/. "$libexec_dir"/
+
+ cat >"$bin_dir/get-bashed" </dev/null
+ test -f "$destination/get-bashed.cmd"
+ test -f "$destination/get-bashed.ps1"
+}
+
+install_fake_wget() {
+ local bindir="$1"
+ local python_bin="$2"
+
+ cat >"$bindir/fake_wget.py" <<'PY'
+from pathlib import Path
+from urllib.request import urlopen
+import sys
+
+argv = sys.argv[1:]
+if len(argv) == 2 and argv[0] == "-qO-":
+ with urlopen(argv[1], timeout=10) as response:
+ sys.stdout.buffer.write(response.read())
+ raise SystemExit(0)
+
+if len(argv) == 3 and argv[0] == "-qO":
+ target = Path(argv[1])
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with urlopen(argv[2], timeout=10) as response:
+ target.write_bytes(response.read())
+ raise SystemExit(0)
+
+raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}")
+PY
+
+ cat >"$bindir/wget" </dev/null 2>&1; then
+ (
+ cd "$DIST_DIR"
+ grep " ${archive}\$" checksums.txt | sha256sum -c -
+ )
+ else
+ (
+ cd "$DIST_DIR"
+ grep " ${archive}\$" checksums.txt | shasum -a 256 -c -
+ )
+ fi
+}
+
+for archive in "$UNIX_ARCHIVE" "$WINDOWS_ARCHIVE"; do
+ test -f "$DIST_DIR/$archive"
+ test -f "$DIST_DIR/$archive.sha256"
+done
+
+cat "$DIST_DIR/$UNIX_ARCHIVE.sha256" "$DIST_DIR/$WINDOWS_ARCHIVE.sha256" >"$DIST_DIR/checksums.txt"
+
+verify_checksum "$UNIX_ARCHIVE"
+verify_checksum "$WINDOWS_ARCHIVE"
+
+bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$DIST_DIR/$UNIX_ARCHIVE"
+bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$DIST_DIR/$WINDOWS_ARCHIVE"
+
+pkg_dir="$DIST_DIR/pkg"
+rm -rf "$pkg_dir"
+bash "$ROOT_DIR/scripts/generate_pkg_manifests.sh" "$VERSION" "$DIST_DIR/checksums.txt" "$pkg_dir"
+
+test -f "$pkg_dir/get-bashed.rb"
+test -f "$pkg_dir/get-bashed.json"
+test -f "$pkg_dir/get-bashed.nuspec"
+test -f "$pkg_dir/chocolateyInstall.ps1"
+test -f "$pkg_dir/VERIFICATION.txt"
+ruby -c "$pkg_dir/get-bashed.rb" >/dev/null
+"$PYTHON_BIN" -m json.tool "$pkg_dir/get-bashed.json" >/dev/null
+"$PYTHON_BIN" - <<'PY' "$pkg_dir/get-bashed.nuspec"
+import sys
+import xml.etree.ElementTree as ET
+
+ET.parse(sys.argv[1])
+PY
+
+install_home="$(mktemp -d)"
+latest_home="$(mktemp -d)"
+brew_stage="$(mktemp -d)"
+brew_prefix="$(mktemp -d)"
+windows_stage="$(mktemp -d)"
+wget_bin="$(mktemp -d)"
+server_log="$DIST_DIR/http-server.log"
+port="$(pick_port)"
+cleanup() {
+ rm -rf "$install_home"
+ rm -rf "$latest_home"
+ rm -rf "$brew_stage"
+ rm -rf "$brew_prefix"
+ rm -rf "$windows_stage"
+ rm -rf "$wget_bin"
+ kill "${server_pid:-0}" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+"$PYTHON_BIN" -m http.server "$port" --directory "$DIST_DIR" >"$server_log" 2>&1 &
+server_pid=$!
+wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+
+mkdir -p "$install_home/home"
+mkdir -p "$latest_home/home"
+install_fake_wget "$wget_bin" "$PYTHON_BIN"
+cat >"$DIST_DIR/latest.json" <&2
+ exit 1
+ fi
+
+ "$PYTHON_BIN" - "$ARCHIVE_PATH" "$TMPDIR" <<'PY'
+from pathlib import Path
+from zipfile import ZipFile
+import sys
+
+archive = Path(sys.argv[1])
+destination = Path(sys.argv[2])
+
+with ZipFile(archive) as bundle:
+ bundle.extractall(destination)
+PY
+}
+
+case "$(basename "$ARCHIVE_PATH")" in
+ "get-bashed-${VERSION}-unix.tar.gz")
+ tar -xzf "$ARCHIVE_PATH" -C "$TMPDIR"
+ stage_dir="$TMPDIR/get-bashed-${VERSION}-unix"
+ test -f "$stage_dir/install.sh"
+ test -f "$stage_dir/install.bash"
+ test -x "$stage_dir/get-bashed"
+
+ test_home="$TMPDIR/home"
+ mkdir -p "$test_home"
+ HOME="$test_home" "$stage_dir/get-bashed" --auto --profiles minimal --prefix "$test_home/.get-bashed"
+
+ test -f "$test_home/.get-bashed/get-bashedrc.sh"
+ test -d "$test_home/.get-bashed/bashrc.d"
+ ;;
+ "get-bashed-${VERSION}-windows.zip")
+ extract_zip
+ test -f "$TMPDIR/install.sh"
+ test -f "$TMPDIR/install.bash"
+ test -f "$TMPDIR/get-bashed.cmd"
+ test -f "$TMPDIR/get-bashed.ps1"
+ test -d "$TMPDIR/bashrc.d"
+ grep -F 'wsl.exe' "$TMPDIR/get-bashed.ps1" >/dev/null
+ grep -F 'bash.exe' "$TMPDIR/get-bashed.ps1" >/dev/null
+ grep -F 'powershell -NoProfile' "$TMPDIR/get-bashed.cmd" >/dev/null
+ ;;
+ *)
+ echo "unsupported archive path: $ARCHIVE_PATH" >&2
+ exit 64
+ ;;
+esac
diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh
new file mode 100644
index 0000000..b3936e4
--- /dev/null
+++ b/scripts/supply_chain_verify.sh
@@ -0,0 +1,277 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+NC='\033[0m'
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+failed=0
+
+pass() {
+ printf "%bPASS%b %s\n" "$GREEN" "$NC" "$1"
+}
+
+fail() {
+ printf "%bFAIL%b %s\n" "$RED" "$NC" "$1"
+ failed=1
+}
+
+echo
+echo "get-bashed Supply Chain Verification"
+echo
+
+workflow_dir="$REPO_ROOT/.github/workflows"
+if [ -d "$workflow_dir" ]; then
+ external_uses=$(grep -rE '^[[:space:]]*uses:' "$workflow_dir"/*.yml | grep -v 'uses:[[:space:]]\+\./' || true)
+ if [ -n "$external_uses" ] && ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then
+ pass "external GitHub Actions are SHA-pinned"
+ else
+ fail "one or more external GitHub Actions are not SHA-pinned"
+ fi
+else
+ fail "workflow directory missing"
+fi
+
+permission_locked_workflows=(
+ "$REPO_ROOT/.github/workflows/ci.yml"
+ "$REPO_ROOT/.github/workflows/codeql.yml"
+ "$REPO_ROOT/.github/workflows/cd.yml"
+ "$REPO_ROOT/.github/workflows/release.yml"
+ "$REPO_ROOT/.github/workflows/scorecard.yml"
+ "$REPO_ROOT/.github/workflows/automerge.yml"
+)
+
+workflow_permissions_ok="true"
+for workflow in "${permission_locked_workflows[@]}"; do
+ if ! rg -q '^permissions: \{\}$' "$workflow"; then
+ workflow_permissions_ok="false"
+ break
+ fi
+done
+
+if [ "$workflow_permissions_ok" = "true" ]; then
+ pass "workflows declare explicit top-level least-privilege permissions"
+else
+ fail "one or more workflows are missing top-level permissions lockdown"
+fi
+
+if [ -f "$REPO_ROOT/installers/bootstrap_sources.sh" ] \
+ && [ -f "$REPO_ROOT/installers/sources.sh" ] \
+ && ! rg -q 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \
+ "$REPO_ROOT/install.sh" \
+ "$REPO_ROOT/installers/bootstrap_sources.sh" \
+ "$REPO_ROOT/installers/sources.sh"; then
+ pass "bootstrap and fallback download sources are pinned"
+else
+ fail "bootstrap or fallback download sources are not fully pinned"
+fi
+
+if rg -q 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \
+ && rg -q 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then
+ pass "actionlint fallback includes pinned per-platform checksums"
+else
+ fail "actionlint fallback checksums are incomplete"
+fi
+
+if [ -f "$REPO_ROOT/docs/public/install.sh" ] \
+ && [ -f "$REPO_ROOT/scripts/release_validate.sh" ] \
+ && [ -f "$REPO_ROOT/scripts/publish_draft_release.sh" ] \
+ && [ -f "$REPO_ROOT/scripts/verify_published_release.sh" ] \
+ && [ -f "$REPO_ROOT/scripts/publish_pkg_pr.sh" ]; then
+ pass "release installer and publication scripts are checked into the repo"
+else
+ fail "release installer or publication scripts are missing"
+fi
+
+if rg -q '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \
+ && rg -q '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then
+ pass "release-please is configured for draft-first releases with eager tag creation"
+else
+ fail "release-please draft-first or force-tag-creation settings are missing"
+fi
+
+release_workflow="$REPO_ROOT/.github/workflows/release.yml"
+cd_workflow="$REPO_ROOT/.github/workflows/cd.yml"
+if [ -f "$release_workflow" ] \
+ && [ -f "$cd_workflow" ] \
+ && rg -q 'steps.release.outputs.release_created' "$cd_workflow" \
+ && rg -q 'scripts/publish_draft_release\.sh' "$cd_workflow" \
+ && rg -q 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \
+ && rg -q 'scripts/build_release_artifact\.sh' "$release_workflow" \
+ && rg -q 'scripts/release_validate\.sh' "$release_workflow" \
+ && rg -q 'scripts/publish_draft_release\.sh' "$release_workflow" \
+ && rg -q 'scripts/verify_published_release\.sh' "$release_workflow" \
+ && rg -q 'scripts/publish_pkg_pr\.sh' "$release_workflow" \
+ && rg -q 'workflow_dispatch:' "$release_workflow" \
+ && ! rg -q 'types: \[published\]' "$release_workflow" \
+ && ! rg -q '\|\| true' "$release_workflow"; then
+ pass "release workflows use repo-owned draft-first validation and publication scripts"
+else
+ fail "release workflows are missing repo-owned draft-first validation/publication steps or swallow failures"
+fi
+
+if [ -f "$REPO_ROOT/.github/workflows/scorecard.yml" ] \
+ && rg -q 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then
+ pass "Scorecard workflow is present as a separate security signal"
+else
+ fail "Scorecard workflow is missing"
+fi
+
+codeql_workflow="$REPO_ROOT/.github/workflows/codeql.yml"
+if [ -f "$codeql_workflow" ] \
+ && rg -q '^name: CodeQL$' "$codeql_workflow" \
+ && rg -q 'language: \[actions, python\]' "$codeql_workflow" \
+ && rg -q 'queries: security-extended' "$codeql_workflow" \
+ && rg -q 'github/codeql-action/init@' "$codeql_workflow" \
+ && rg -q 'github/codeql-action/autobuild@' "$codeql_workflow" \
+ && rg -q 'github/codeql-action/analyze@' "$codeql_workflow"; then
+ pass "repo-owned CodeQL workflow is checked into the repository"
+else
+ fail "repo-owned CodeQL workflow is missing or incomplete"
+fi
+
+if [ -f "$REPO_ROOT/.github/dependabot.yml" ] \
+ && rg -q 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then
+ pass "Dependabot configuration is checked into the repo"
+else
+ fail "Dependabot configuration is missing or incomplete"
+fi
+
+if rg -q 'docs-linkcheck' "$REPO_ROOT/tox.ini" \
+ && rg -q 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then
+ pass "docs link validation is wired into tox and CI"
+else
+ fail "docs link validation is missing from tox or CI"
+fi
+
+if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
+ automated_security_fixes_enabled="$(gh api repos/jbcom/get-bashed/automated-security-fixes --jq '.enabled' 2>/dev/null || printf 'false')"
+ vulnerability_alerts_enabled="false"
+ if gh api repos/jbcom/get-bashed/vulnerability-alerts -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then
+ vulnerability_alerts_enabled="true"
+ fi
+ dependabot_security_updates_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')"
+ secret_scanning_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')"
+ push_protection_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')"
+ validity_checks_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')"
+ non_provider_patterns_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')"
+ live_codeql_workflow="false"
+ if gh api "repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then
+ live_codeql_workflow="true"
+ fi
+ if [ "$live_codeql_workflow" = "true" ]; then
+ live_codeql_default_state="$(gh api repos/jbcom/get-bashed/code-scanning/default-setup --jq '.state' 2>/dev/null || printf 'unknown')"
+ if [ "$live_codeql_default_state" = "not-configured" ]; then
+ pass "live GitHub default CodeQL setup is disabled in favor of the repo-owned workflow"
+ else
+ fail "live GitHub default CodeQL setup is still enabled after codeql.yml landed on main"
+ fi
+ else
+ pass "live GitHub default CodeQL setup retirement will be checked after codeql.yml lands on main"
+ fi
+
+ if [ "$automated_security_fixes_enabled" = "true" ]; then
+ pass "automated Dependabot security fixes are enabled"
+ else
+ fail "automated Dependabot security fixes are disabled"
+ fi
+
+ if [ "$vulnerability_alerts_enabled" = "true" ]; then
+ pass "vulnerability alerts are enabled"
+ else
+ fail "vulnerability alerts are disabled"
+ fi
+
+ if [ "$dependabot_security_updates_enabled" = "enabled" ]; then
+ pass "Dependabot security updates are enabled in repository security settings"
+ else
+ fail "Dependabot security updates are disabled in repository security settings"
+ fi
+
+ if [ "$secret_scanning_enabled" = "enabled" ]; then
+ pass "secret scanning is enabled"
+ else
+ fail "secret scanning is disabled"
+ fi
+
+ if [ "$push_protection_enabled" = "enabled" ]; then
+ pass "secret scanning push protection is enabled"
+ else
+ fail "secret scanning push protection is disabled"
+ fi
+
+ if [ "$validity_checks_enabled" = "enabled" ]; then
+ pass "secret scanning validity checks are enabled"
+ else
+ fail "secret scanning validity checks are disabled"
+ fi
+
+ if [ "$non_provider_patterns_enabled" = "enabled" ]; then
+ pass "non-provider secret scanning patterns are enabled"
+ else
+ fail "non-provider secret scanning patterns are disabled"
+ fi
+else
+ pass "live GitHub security settings checks skipped: gh auth unavailable"
+fi
+
+if [ -f "$REPO_ROOT/scripts/verify_branch_protection.sh" ] \
+ && rg -q '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then
+ if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
+ if bash "$REPO_ROOT/scripts/verify_branch_protection.sh" >/dev/null; then
+ pass "branch protection matches the documented required CI contexts"
+ else
+ fail "branch protection does not match the documented required CI contexts"
+ fi
+ else
+ pass "branch protection verification is present (live check skipped: gh auth unavailable)"
+ fi
+else
+ fail "branch protection verification is missing"
+fi
+
+if [ -f "$REPO_ROOT/scripts/reconcile_codeql_governance.sh" ] \
+ && rg -q '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then
+ pass "post-merge CodeQL governance reconciliation is scripted in the repo"
+else
+ fail "post-merge CodeQL governance reconciliation is missing"
+fi
+
+if [ -f "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" ] \
+ && [ -f "$REPO_ROOT/scripts/reconcile_immutable_release_governance.sh" ] \
+ && rg -q '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \
+ && rg -q '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then
+ if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
+ if bash "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" >/dev/null; then
+ pass "immutable release governance is scripted and verified"
+ else
+ fail "immutable release governance does not match the draft-first release policy"
+ fi
+ else
+ pass "immutable release governance is scripted (live check skipped: gh auth unavailable)"
+ fi
+else
+ fail "immutable release governance verification or reconciliation is missing"
+fi
+
+if rg -q '^verify-security:' "$REPO_ROOT/Makefile" \
+ && rg -q 'make verify-security' "$REPO_ROOT/README.md" \
+ && rg -q 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \
+ && rg -q 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then
+ pass "security verification is exposed through make and documented"
+else
+ fail "security verification target or docs are missing"
+fi
+
+if [ "$failed" -ne 0 ]; then
+ echo
+ printf "%bSupply chain verification failed%b\n" "$RED" "$NC"
+ exit 1
+fi
+
+echo
+printf "%bAll supply chain checks passed%b\n" "$GREEN" "$NC"
diff --git a/scripts/test-setup.sh b/scripts/test-setup.sh
index aa9622a..8663d82 100755
--- a/scripts/test-setup.sh
+++ b/scripts/test-setup.sh
@@ -8,21 +8,72 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LIB_DIR="$ROOT_DIR/tests/lib"
+LOCK_DIR="${TEST_SETUP_LOCK_DIR:-$LIB_DIR/.setup.lock}"
+
+# shellcheck disable=SC1091
+source "$ROOT_DIR/installers/sources.sh"
mkdir -p "$LIB_DIR"
+release_setup_lock() {
+ [[ -d "$LOCK_DIR" ]] || return 0
+ rm -rf "$LOCK_DIR"
+}
+
+acquire_setup_lock() {
+ local owner_pid=""
+ local attempts=0
+
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
+ if [[ -r "$LOCK_DIR/pid" ]]; then
+ owner_pid="$(<"$LOCK_DIR/pid")"
+ if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" 2>/dev/null; then
+ rm -rf "$LOCK_DIR"
+ continue
+ fi
+ fi
+
+ attempts=$(( attempts + 1 ))
+ if (( attempts > 300 )); then
+ echo "Timed out waiting for test helper setup lock: $LOCK_DIR" >&2
+ return 1
+ fi
+ sleep 0.1
+ done
+
+ printf '%s\n' "$$" > "$LOCK_DIR/pid"
+ trap release_setup_lock EXIT
+}
+
clone_lib() {
local name="$1" repo="$2" sha="$3"
local dest="$LIB_DIR/$name"
+ local current_remote current_sha
+
if [[ -d "$dest/.git" ]]; then
- return 0
+ current_remote="$(git -C "$dest" remote get-url origin 2>/dev/null || true)"
+ current_sha="$(git -C "$dest" rev-parse HEAD 2>/dev/null || true)"
+ if [[ "$current_remote" == "$repo" && "$current_sha" == "$sha" ]]; then
+ return 0
+ fi
+ rm -rf "$dest"
+ fi
+ if [[ -d "$dest" ]]; then
+ rm -rf "$dest"
fi
git clone --quiet "$repo" "$dest"
git -C "$dest" checkout --quiet "$sha"
}
-clone_lib "bats-support" "https://github.com/bats-core/bats-support.git" "64e7436962affbe15974d181173c37e1fac70073"
-clone_lib "bats-assert" "https://github.com/bats-core/bats-assert.git" "123860c029685bc0a4150ed57ee97fc7f7cc9d31"
-clone_lib "bats-file" "https://github.com/bats-core/bats-file.git" "13ad5e2ffcc360281432db3d43a306f7b3667d60"
+main() {
+ acquire_setup_lock
+ clone_lib "bats-support" "${GET_BASHED_GIT_SOURCES["bats_support"]}" "${GET_BASHED_GIT_REFS["bats_support"]}"
+ clone_lib "bats-assert" "${GET_BASHED_GIT_SOURCES["bats_assert"]}" "${GET_BASHED_GIT_REFS["bats_assert"]}"
+ clone_lib "bats-file" "${GET_BASHED_GIT_SOURCES["bats_file"]}" "${GET_BASHED_GIT_REFS["bats_file"]}"
+
+ echo "Bats libs ready in $LIB_DIR"
+}
-echo "Bats libs ready in $LIB_DIR"
+if [[ "${TEST_SETUP_SKIP_MAIN:-0}" != "1" ]]; then
+ main "$@"
+fi
diff --git a/scripts/validate-docs.sh b/scripts/validate-docs.sh
new file mode 100755
index 0000000..54fe213
--- /dev/null
+++ b/scripts/validate-docs.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+required=(
+ "$REPO_ROOT/docs/conf.py"
+ "$REPO_ROOT/docs/index.md"
+ "$REPO_ROOT/docs/getting-started/index.md"
+ "$REPO_ROOT/docs/getting-started/downloads.md"
+ "$REPO_ROOT/docs/getting-started/install-and-verify.md"
+ "$REPO_ROOT/docs/reference/index.md"
+ "$REPO_ROOT/docs/reference/architecture.md"
+ "$REPO_ROOT/docs/reference/security.md"
+ "$REPO_ROOT/docs/reference/testing.md"
+ "$REPO_ROOT/docs/reference/supply-chain.md"
+ "$REPO_ROOT/docs/reference/release-checklist.md"
+ "$REPO_ROOT/docs/reference/release-verification.md"
+ "$REPO_ROOT/docs/api/index.md"
+ "$REPO_ROOT/docs/public/install.sh"
+ "$REPO_ROOT/scripts/build_release_artifact.sh"
+ "$REPO_ROOT/scripts/generate_pkg_manifests.sh"
+ "$REPO_ROOT/scripts/release_validate.sh"
+ "$REPO_ROOT/scripts/verify_published_release.sh"
+ "$REPO_ROOT/scripts/publish_pkg_pr.sh"
+ "$REPO_ROOT/scripts/verify_branch_protection.sh"
+ "$REPO_ROOT/scripts/supply_chain_verify.sh"
+)
+
+for file in "${required[@]}"; do
+ test -f "$file"
+done
+
+grep -q "getting-started/index" "$REPO_ROOT/docs/index.md"
+grep -q "reference/index" "$REPO_ROOT/docs/index.md"
+grep -q "api/index" "$REPO_ROOT/docs/index.md"
+grep -q "get-bashed--unix.tar.gz" "$REPO_ROOT/docs/getting-started/index.md"
+grep -q "get-bashed--windows.zip" "$REPO_ROOT/docs/getting-started/downloads.md"
+grep -q "jbcom/pkgs" "$REPO_ROOT/docs/getting-started/downloads.md"
+grep -q "verify-published-release" "$REPO_ROOT/docs/reference/release-verification.md"
+grep -q "verify-branch-protection" "$REPO_ROOT/docs/reference/release-verification.md"
+grep -q "verify-branch-protection" "$REPO_ROOT/docs/reference/release-checklist.md"
+grep -q "verify-security" "$REPO_ROOT/docs/TESTING.md"
+grep -q "verify-security" "$REPO_ROOT/docs/reference/security.md"
+grep -q "release-validate" "$REPO_ROOT/docs/reference/release-checklist.md"
+grep -q "INSTALLERS_HELPERS" "$REPO_ROOT/docs/api/index.md"
diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh
index db3818d..d1a3907 100755
--- a/scripts/verify-install.sh
+++ b/scripts/verify-install.sh
@@ -12,7 +12,8 @@ trap 'rm -rf "$TMPDIR"' EXIT
TEST_HOME="$TMPDIR/home"
mkdir -p "$TEST_HOME"
-HOME="$TEST_HOME" "$ROOT_DIR/install.sh" --auto --profiles minimal --link-dotfiles --name "Test User" --email "test@example.com"
+HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" \
+ "$ROOT_DIR/install.sh" --auto --profiles minimal --link-dotfiles --name "Test User" --email "test@example.com"
[[ -L "$TEST_HOME/.bashrc" ]]
[[ -L "$TEST_HOME/.bash_profile" ]]
diff --git a/scripts/verify_branch_protection.sh b/scripts/verify_branch_protection.sh
new file mode 100644
index 0000000..0bad112
--- /dev/null
+++ b/scripts/verify_branch_protection.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+REPO="${1:-jbcom/get-bashed}"
+BRANCH="${2:-main}"
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "gh CLI is required for branch protection verification" >&2
+ exit 1
+fi
+
+if ! gh auth status >/dev/null 2>&1; then
+ echo "gh auth is required for branch protection verification" >&2
+ exit 1
+fi
+
+expected_checks=(
+ "Quality (ubuntu-latest)"
+ "Quality (macos-latest)"
+ "Quality (wsl-ubuntu)"
+ "SonarQube Scan"
+)
+
+if gh api "repos/${REPO}/contents/.github/workflows/codeql.yml?ref=${BRANCH}" >/dev/null 2>&1; then
+ expected_checks+=(
+ "CodeQL (actions)"
+ "CodeQL (python)"
+ )
+fi
+
+mapfile -t actual_checks < <(
+ gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_status_checks.contexts[]' \
+ | LC_ALL=C sort
+)
+
+mapfile -t expected_sorted < <(printf '%s\n' "${expected_checks[@]}" | LC_ALL=C sort)
+
+if [ "${#actual_checks[@]}" -ne "${#expected_sorted[@]}" ]; then
+ printf 'expected %d required checks, found %d\n' "${#expected_sorted[@]}" "${#actual_checks[@]}" >&2
+ printf 'actual: %s\n' "${actual_checks[*]-}" >&2
+ exit 1
+fi
+
+for index in "${!expected_sorted[@]}"; do
+ if [ "${expected_sorted[$index]}" != "${actual_checks[$index]}" ]; then
+ printf 'required checks mismatch at index %s: expected %s got %s\n' \
+ "$index" "${expected_sorted[$index]}" "${actual_checks[$index]}" >&2
+ exit 1
+ fi
+done
+
+actual_strict="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_status_checks.strict')"
+actual_reviews="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.required_approving_review_count')"
+actual_dismiss_stale="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.dismiss_stale_reviews')"
+actual_codeowners="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_pull_request_reviews.require_code_owner_reviews')"
+actual_admins="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.enforce_admins.enabled')"
+actual_linear_history="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_linear_history.enabled')"
+actual_conversation_resolution="$(gh api "repos/${REPO}/branches/${BRANCH}/protection" --jq '.required_conversation_resolution.enabled')"
+
+if [ "$actual_strict" != "true" ]; then
+ printf 'expected strict status checks, got %s\n' "$actual_strict" >&2
+ exit 1
+fi
+
+if [ "$actual_reviews" != "1" ]; then
+ printf 'expected 1 approving review, got %s\n' "$actual_reviews" >&2
+ exit 1
+fi
+
+if [ "$actual_dismiss_stale" != "true" ]; then
+ printf 'expected stale reviews to be dismissed, got %s\n' "$actual_dismiss_stale" >&2
+ exit 1
+fi
+
+if [ "$actual_codeowners" != "true" ]; then
+ printf 'expected code owner reviews enabled, got %s\n' "$actual_codeowners" >&2
+ exit 1
+fi
+
+if [ "$actual_admins" != "true" ]; then
+ printf 'expected admin enforcement enabled, got %s\n' "$actual_admins" >&2
+ exit 1
+fi
+
+if [ "$actual_linear_history" != "true" ]; then
+ printf 'expected linear history enabled, got %s\n' "$actual_linear_history" >&2
+ exit 1
+fi
+
+if [ "$actual_conversation_resolution" != "true" ]; then
+ printf 'expected conversation resolution enabled, got %s\n' "$actual_conversation_resolution" >&2
+ exit 1
+fi
+
+printf 'branch protection OK for %s:%s\n' "${REPO}" "${BRANCH}"
diff --git a/scripts/verify_immutable_release_governance.sh b/scripts/verify_immutable_release_governance.sh
new file mode 100644
index 0000000..cf336cb
--- /dev/null
+++ b/scripts/verify_immutable_release_governance.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=scripts/lib/immutable_release_flow.sh
+. "$SCRIPT_DIR/lib/immutable_release_flow.sh"
+
+REPO="${1:-jbcom/get-bashed}"
+BRANCH="${2:-main}"
+
+immutable_release_require_gh
+
+if ! immutable_release_branch_ready "$REPO" "$BRANCH"; then
+ printf 'immutable release governance check deferred until draft-first release flow lands on %s:%s\n' \
+ "$REPO" "$BRANCH"
+ exit 0
+fi
+
+immutable_enabled="$(gh api "repos/${REPO}/immutable-releases" --jq '.enabled' 2>/dev/null || printf 'false')"
+if [ "$immutable_enabled" = "true" ]; then
+ printf 'immutable releases enabled for %s:%s\n' "$REPO" "$BRANCH"
+ exit 0
+fi
+
+printf 'immutable releases are not enabled after the draft-first release flow landed on %s:%s\n' \
+ "$REPO" "$BRANCH" >&2
+exit 1
diff --git a/scripts/verify_published_release.sh b/scripts/verify_published_release.sh
new file mode 100755
index 0000000..f0a67be
--- /dev/null
+++ b/scripts/verify_published_release.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TAG="${1:?release tag required (for example v0.1.0)}"
+REPO="${2:-jbcom/get-bashed}"
+OWNER="${3:-jbcom}"
+case "$TAG" in
+ v*) VERSION="${TAG#v}" ;;
+ *) VERSION="$TAG"; TAG="v$TAG" ;;
+esac
+UNIX_ARCHIVE="get-bashed-${VERSION}-unix.tar.gz"
+WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip"
+EXPECTED_ASSETS=("checksums.txt" "$UNIX_ARCHIVE" "$WINDOWS_ARCHIVE")
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "gh CLI is required for published release verification" >&2
+ exit 1
+fi
+
+if ! gh auth status >/dev/null 2>&1; then
+ echo "gh auth is required for published release verification" >&2
+ exit 1
+fi
+
+is_draft="$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')"
+if [ "$is_draft" != "false" ]; then
+ printf 'release %s in %s is still a draft\n' "$TAG" "$REPO" >&2
+ exit 1
+fi
+
+mapfile -t actual_assets < <(
+ gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort
+)
+mapfile -t expected_sorted < <(printf '%s\n' "${EXPECTED_ASSETS[@]}" | LC_ALL=C sort)
+
+if [ "${#actual_assets[@]}" -ne "${#expected_sorted[@]}" ]; then
+ printf 'unexpected asset count for %s: expected %d, got %d\n' \
+ "$TAG" "${#expected_sorted[@]}" "${#actual_assets[@]}" >&2
+ printf 'actual assets:\n%s\n' "$(printf '%s\n' "${actual_assets[@]}")" >&2
+ exit 1
+fi
+
+for idx in "${!expected_sorted[@]}"; do
+ if [ "${expected_sorted[$idx]}" != "${actual_assets[$idx]}" ]; then
+ printf 'asset mismatch for %s: expected %s, got %s\n' \
+ "$TAG" "${expected_sorted[$idx]}" "${actual_assets[$idx]}" >&2
+ exit 1
+ fi
+done
+
+tmpdir="$(mktemp -d)"
+cleanup() {
+ rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+gh release download "$TAG" --repo "$REPO" \
+ -p "$UNIX_ARCHIVE" \
+ -p "$WINDOWS_ARCHIVE" \
+ -p "checksums.txt" \
+ --dir "$tmpdir"
+
+verify_checksum() {
+ local artifact="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ (
+ cd "$tmpdir"
+ grep " ${artifact}\$" checksums.txt | sha256sum -c -
+ )
+ else
+ (
+ cd "$tmpdir"
+ grep " ${artifact}\$" checksums.txt | shasum -a 256 -c -
+ )
+ fi
+}
+
+verify_checksum "$UNIX_ARCHIVE"
+verify_checksum "$WINDOWS_ARCHIVE"
+
+(
+ cd "$tmpdir"
+ gh attestation verify "$UNIX_ARCHIVE" --owner "$OWNER"
+ gh attestation verify "$WINDOWS_ARCHIVE" --owner "$OWNER"
+)
+
+bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$tmpdir/$UNIX_ARCHIVE"
+bash "$ROOT_DIR/scripts/smoke_test_release_artifact.sh" "$VERSION" "$tmpdir/$WINDOWS_ARCHIVE"
+
+printf 'published release verified for %s\n' "$TAG"
diff --git a/scripts/wsl-quality.sh b/scripts/wsl-quality.sh
new file mode 100755
index 0000000..0c0ae82
--- /dev/null
+++ b/scripts/wsl-quality.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# @file wsl-quality
+# @brief Run CI quality checks inside WSL.
+# @description
+# Normalizes the nested GitHub Actions environment and reuses the same
+# quality targets as local and hosted Linux/macOS runs.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+export RUNNER_TEMP=/tmp
+export RUNNER_TOOL_CACHE=/tmp
+unset GITHUB_ENV GITHUB_PATH
+
+make lint
+make test
diff --git a/tests/asdf_pins.bats b/tests/asdf_pins.bats
new file mode 100644
index 0000000..c05b5e8
--- /dev/null
+++ b/tests/asdf_pins.bats
@@ -0,0 +1,119 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "install_asdf_runtime uses pinned plugin source and version" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ ASDF_DATA_DIR="$TMPDIR/asdf"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/calls.log"
+ mkdir -p "$HOME" "$ASDF_DATA_DIR" "$FAKEBIN"
+
+ cat > "$FAKEBIN/asdf" <> "$LOG"
+case "\$1 \$2" in
+ "plugin list")
+ exit 0
+ ;;
+ "plugin add")
+ mkdir -p "$ASDF_DATA_DIR/plugins/nodejs/.git"
+ exit 0
+ ;;
+ "install nodejs")
+ exit 0
+ ;;
+ "set --home")
+ exit 0
+ ;;
+esac
+exit 0
+EOF
+ chmod +x "$FAKEBIN/asdf"
+
+ cat > "$FAKEBIN/git" <> "$LOG"
+exit 0
+EOF
+ chmod +x "$FAKEBIN/git"
+
+ run env HOME="$HOME" ASDF_DATA_DIR="$ASDF_DATA_DIR" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; install_asdf_runtime nodejs'
+ assert_success
+
+ run cat "$LOG"
+ assert_output --partial "plugin list"
+ assert_output --partial "plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git"
+ assert_output --partial "git -C $ASDF_DATA_DIR/plugins/nodejs checkout 779c8dc84b3bdab38c2c80622d315c2c3267f74b"
+ assert_output --partial "install nodejs 24.14.1"
+ assert_output --partial "set --home nodejs 24.14.1"
+}
+
+@test "install_asdf_runtime fails when no pin exists" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ mkdir -p "$HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/asdf" <<'EOF'
+#!/bin/sh
+exit 0
+EOF
+ chmod +x "$FAKEBIN/asdf"
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; install_asdf_runtime unknown_runtime'
+ assert_failure
+ assert_output --partial "No pinned asdf version configured for unknown_runtime."
+}
+
+@test "pipx_install uses pinned package spec when configured" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/pipx.log"
+ PREFIX="$TMPDIR/prefix"
+ mkdir -p "$HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/pipx" <> "$LOG"
+exit 0
+EOF
+ chmod +x "$FAKEBIN/pipx"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; pipx_install pre_commit'
+ assert_success
+
+ run cat "$LOG"
+ assert_output --partial "HOME=$PREFIX/pipx"
+ assert_output --partial "BIN=$PREFIX/bin"
+ assert_output --partial "MAN=$PREFIX/share/man"
+ assert_output --partial "CMD=install pre-commit==4.5.1"
+}
+
+@test "pip fallback uses pinned package spec when configured" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/pip.log"
+ PREFIX="$TMPDIR/prefix"
+ mkdir -p "$HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/python3" <> "$LOG"
+if [ "\$1" = "-m" ] && [ "\$2" = "pip" ] && [ "\$3" = "--version" ]; then
+ exit 0
+fi
+exit 0
+EOF
+ chmod +x "$FAKEBIN/python3"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; source installers/tools.sh; _using_brew(){ return 1; }; install_tool pipx'
+ assert_success
+
+ run cat "$LOG"
+ assert_output --partial "-m pip install --prefix $PREFIX pipx==1.11.1"
+}
diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats
new file mode 100644
index 0000000..32acdfb
--- /dev/null
+++ b/tests/bootstrap.bats
@@ -0,0 +1,148 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "install.sh ignores PATH bash 3.x when a modern absolute bash is available" {
+ [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform"
+
+ TMPDIR="$(mktemp -d)"
+ FAKEBIN="$TMPDIR/bin"
+ MARKER="$TMPDIR/path-bash-used"
+ mkdir -p "$FAKEBIN"
+
+ cat > "$FAKEBIN/bash" < "$MARKER"
+exit 99
+EOF
+ chmod +x "$FAKEBIN/bash"
+
+ run env PATH="$FAKEBIN:/usr/bin:/bin" ./install.sh --help
+ assert_success
+ assert_file_not_exist "$MARKER"
+}
+
+@test "install.sh bootstraps Homebrew before installing bash when only an old bash is available" {
+ [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform"
+
+ TMPDIR="$(mktemp -d)"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/bootstrap.log"
+ INSTALLER_PAYLOAD="$TMPDIR/homebrew-install.sh"
+ mkdir -p "$FAKEBIN"
+
+ cat > "$FAKEBIN/bash" <<'EOF'
+#!/bin/sh
+if [ "$1" = "-c" ]; then
+ printf '3'
+ exit 0
+fi
+exit 99
+EOF
+ chmod +x "$FAKEBIN/bash"
+
+ cat > "$INSTALLER_PAYLOAD" < "$FAKEBIN/brew" <<'BREW'
+#!/bin/sh
+printf '%s\n' "\$*" >> "$LOG"
+if [ "\$1" = "install" ] && [ "\$2" = "bash" ]; then
+ cat > "$FAKEBIN/bash" <<'MODERN'
+#!/bin/sh
+if [ "\$1" = "-c" ]; then
+ printf '5'
+ exit 0
+fi
+exec "$MODERN_BASH" "\$@"
+MODERN
+ chmod +x "$FAKEBIN/bash"
+ exit 0
+fi
+exit 1
+BREW
+chmod +x "$FAKEBIN/brew"
+EOF
+ chmod +x "$INSTALLER_PAYLOAD"
+
+ cat > "$FAKEBIN/curl" < "$ARCHIVE_ROOT/install.bash" < "$MARKER"
+EOF
+ chmod +x "$ARCHIVE_ROOT/install.bash"
+ : > "$ARCHIVE_ROOT/installers/_helpers.sh"
+
+ tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main
+
+ cat > "$FAKEBIN/bash" < "$FAKEBIN/curl" <"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ query="${*: -1}"
+ case "$query" in
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh
+ assert_success
+ assert_output --partial "branch protection OK"
+
+ rm -rf "$tmpdir"
+}
+
+@test "verify_branch_protection fails when the required contexts drift" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ query="${*: -1}"
+ case "$query" in
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Docs Deployment'
+ ;;
+ *'.required_status_checks.strict'*) printf 'false\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '0\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'false\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'false\n' ;;
+ *'.enforce_admins.enabled'*) printf 'false\n' ;;
+ *'.required_linear_history.enabled'*) printf 'false\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'false\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh
+ assert_failure
+ assert_output --partial "expected 4 required checks"
+
+ rm -rf "$tmpdir"
+}
+
+@test "verify_branch_protection adds repo-owned CodeQL checks once codeql.yml is live" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ query="${*: -1}"
+ case "$query" in
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 0 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'CodeQL (actions)' \
+ 'CodeQL (python)' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_branch_protection.sh
+ assert_success
+ assert_output --partial "branch protection OK"
+
+ rm -rf "$tmpdir"
+}
diff --git a/tests/brew_runtime.bats b/tests/brew_runtime.bats
new file mode 100644
index 0000000..cde4c3b
--- /dev/null
+++ b/tests/brew_runtime.bats
@@ -0,0 +1,177 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+setup_fake_brew() {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ BREW_PREFIX="$TMPDIR/linuxbrew"
+
+ mkdir -p "$HOME" "$FAKEBIN" "$BREW_PREFIX"
+
+ cat > "$FAKEBIN/brew" < "$BREW_PREFIX/etc/profile.d/bash_completion.sh" <<'EOF'
+export BREW_COMPLETION_LOADED=1
+EOF
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/40-completions.sh; printf "loaded=%s user_dir=%s\n" "${BREW_COMPLETION_LOADED:-0}" "${BASH_COMPLETION_USER_DIR:-}"'
+ assert_success
+ assert_output "loaded=1 user_dir=$HOME/.local/share/bash-completion"
+}
+
+@test "completions module is idempotent across repeated sourcing" {
+ setup_fake_brew
+ mkdir -p "$BREW_PREFIX/etc/profile.d"
+
+ cat > "$BREW_PREFIX/etc/profile.d/bash_completion.sh" <<'EOF'
+export BREW_COMPLETION_COUNT=$(( ${BREW_COMPLETION_COUNT:-0} + 1 ))
+EOF
+
+ cat > "$FAKEBIN/asdf" <<'EOF'
+#!/bin/sh
+if [ "$1" = "completion" ] && [ "$2" = "bash" ]; then
+ printf 'export ASDF_COMPLETION_COUNT=$(( ${ASDF_COMPLETION_COUNT:-0} + 1 ))\n'
+fi
+EOF
+ chmod +x "$FAKEBIN/asdf"
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/40-completions.sh; source bashrc.d/40-completions.sh; printf "brew=%s asdf=%s user_dir=%s\n" "${BREW_COMPLETION_COUNT:-0}" "${ASDF_COMPLETION_COUNT:-0}" "${BASH_COMPLETION_USER_DIR:-}"'
+ assert_success
+ assert_output "brew=1 asdf=1 user_dir=$HOME/.local/share/bash-completion"
+}
+
+@test "asdf module sources Homebrew asdf from brew prefix" {
+ setup_fake_brew
+ mkdir -p "$BREW_PREFIX/opt/asdf/libexec"
+
+ cat > "$BREW_PREFIX/opt/asdf/libexec/asdf.sh" <<'EOF'
+export ASDF_BREW_LOADED=1
+EOF
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/60-asdf.sh; printf "loaded=%s\n" "${ASDF_BREW_LOADED:-0}"'
+ assert_success
+ assert_output "loaded=1"
+}
+
+@test "bash_profile evaluates brew shellenv for login startup" {
+ setup_fake_brew
+ PROFILE_PATH="$(cd "$BATS_TEST_DIRNAME/.." && pwd)/bash_profile"
+ mkdir -p "$HOME/.get-bashed"
+
+ cat > "$HOME/.get-bashed/bashrc" <<'EOF'
+:
+EOF
+
+ run env -u HOMEBREW_PREFIX -u HOMEBREW_CELLAR -u HOMEBREW_REPOSITORY HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_HOME="$HOME/.get-bashed" "$MODERN_BASH" --noprofile --norc -ic "exec 2>/dev/null; source \"$PROFILE_PATH\"; printf 'prefix=%s\npath=%s\n' \"\${HOMEBREW_PREFIX:-}\" \"\$PATH\""
+ assert_success
+ assert_output --partial "prefix=$BREW_PREFIX"
+ assert_output --partial "path=$BREW_PREFIX/bin:$BREW_PREFIX/sbin:$FAKEBIN:/usr/bin:/bin"
+}
+
+@test "runtime helper resolves brew from fallback candidates outside PATH" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ BREW_PREFIX="$TMPDIR/linuxbrew"
+ BREW_BIN="$BREW_PREFIX/bin/brew"
+ mkdir -p "$HOME" "$BREW_PREFIX/bin"
+
+ cat > "$BREW_BIN" < "$HOME/.get-bashed/bashrc" <<'EOF'
+:
+EOF
+
+ cat > "$BREW_BIN" </dev/null; source \"$PROFILE_PATH\"; printf 'prefix=%s\npath=%s\n' \"\${HOMEBREW_PREFIX:-}\" \"\$PATH\""
+ assert_success
+ assert_output --partial "prefix=$BREW_PREFIX"
+ assert_output --partial "path=$BREW_PREFIX/bin:$BREW_PREFIX/sbin:/usr/bin:/bin"
+}
+
+@test "installer helper resolves brew from fallback candidates outside PATH" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ BREW_PREFIX="$TMPDIR/linuxbrew"
+ BREW_BIN="$BREW_PREFIX/bin/brew"
+ mkdir -p "$HOME" "$BREW_PREFIX/bin"
+
+ cat > "$BREW_BIN" <"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ case "$*" in
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;;
+ *) exit 1 ;;
+ esac
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_codeql_governance.sh
+ assert_failure
+ assert_output --partial "codeql.yml must be present"
+
+ rm -rf "$tmpdir"
+}
+
+@test "reconcile_codeql_governance retires default setup and patches required checks" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ state_file="$tmpdir/checks.txt"
+ log_file="$tmpdir/gh.log"
+ mkdir -p "$bindir"
+ cat >"$state_file" <<'EOF'
+Quality (ubuntu-latest)
+Quality (macos-latest)
+Quality (wsl-ubuntu)
+SonarQube Scan
+EOF
+
+ cat >"$bindir/gh" <> "\$log_file"
+ exit 0
+ ;;
+ *'protection/required_status_checks'*'.strict'*)
+ printf 'true\n'
+ exit 0
+ ;;
+ *'protection/required_status_checks'*'.contexts[]'*)
+ cat "\$state_file"
+ exit 0
+ ;;
+ *'-X PATCH repos/jbcom/get-bashed/branches/main/protection/required_status_checks'*)
+ printf '%s\n' "\$*" >> "\$log_file"
+ {
+ printf 'CodeQL (actions)\n'
+ printf 'CodeQL (python)\n'
+ cat "\$state_file"
+ } | awk 'NF && !seen[\$0]++' | LC_ALL=C sort > "\$state_file.new"
+ mv "\$state_file.new" "\$state_file"
+ exit 0
+ ;;
+ *)
+ exit 1
+ ;;
+ esac
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_codeql_governance.sh
+ assert_success
+ assert_output --partial "retired GitHub default CodeQL setup"
+ assert_output --partial "updated required status checks"
+
+ run grep -F 'patched-default-setup' "$log_file"
+ assert_success
+
+ run grep -F 'contexts[]=CodeQL (actions)' "$log_file"
+ assert_success
+
+ run grep -F 'contexts[]=CodeQL (python)' "$log_file"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
diff --git a/tests/config_output.bats b/tests/config_output.bats
index 2e38fea..b288d4c 100644
--- a/tests/config_output.bats
+++ b/tests/config_output.bats
@@ -11,7 +11,7 @@ load test_helper
USER_NAME="Jane Doe"
USER_EMAIL="jane@example.com"
- HOME="$TEST_HOME" bash ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
run grep -F "GET_BASHED_USER_NAME=\"${USER_NAME}\"" "$TEST_HOME/.get-bashed/get-bashedrc.sh"
assert_success
@@ -25,7 +25,7 @@ load test_helper
mkdir -p "$HOME"
TEST_HOME="$HOME"
- HOME="$TEST_HOME" bash ./install.sh --auto --profiles minimal --features gnu_over_bsd --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --profiles minimal --features gnu_over_bsd --prefix "$TEST_HOME/.get-bashed" --force
run grep -F "export GET_BASHED_GNU=1" "$TEST_HOME/.get-bashed/get-bashedrc.sh"
assert_success
@@ -40,7 +40,7 @@ load test_helper
USER_NAME='Jane "Danger" Doe'
USER_EMAIL='jane"quote"@example.com'
- HOME="$TEST_HOME" bash ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
run grep -F "GET_BASHED_USER_NAME=\"Jane \\\"Danger\\\" Doe\"" "$TEST_HOME/.get-bashed/get-bashedrc.sh"
assert_success
diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats
new file mode 100644
index 0000000..deb9741
--- /dev/null
+++ b/tests/docs_contract.bats
@@ -0,0 +1,325 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "documented config keys match generated config output" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ mkdir -p "$HOME"
+
+ env HOME="$HOME" ./install.sh --auto --prefix "$HOME/.get-bashed" --name "Jane Doe" --email "jane@example.com" >/dev/null
+
+ while IFS= read -r line; do
+ key="${line#export }"
+ key="${key%%=*}"
+ run grep -F "$key" docs/CONFIG.md
+ assert_success
+ done < <(grep '^export GET_BASHED_' "$HOME/.get-bashed/get-bashedrc.sh")
+}
+
+@test "documentation references only current workflow files" {
+ run rg -n "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs
+ assert_failure
+
+ run grep -F "cd.yml" README.md
+ assert_success
+ run grep -F "ci.yml" AGENTS.md
+ assert_success
+ run grep -F "release.yml" README.md
+ assert_success
+ run grep -F "scorecard.yml" AGENTS.md
+ assert_success
+ run grep -F "scorecard.yml" docs/README.md
+ assert_success
+ run grep -F "scorecard.yml" docs/reference/supply-chain.md
+ assert_success
+}
+
+@test "ci workflow includes dedicated WSL validation on windows-2025" {
+ run grep -F 'runs-on: windows-2025' .github/workflows/ci.yml
+ assert_success
+
+ run grep -F 'wsl.exe --install --distribution $distro --no-launch --web-download' .github/workflows/ci.yml
+ assert_success
+
+ run rg -n 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs
+ assert_failure
+}
+
+@test "installer catalog documentation is populated" {
+ run grep -F '| `bash` |' docs/INSTALLERS.md
+ assert_success
+}
+
+@test "docs surface includes getting-started, reference, api, and docs-site installer" {
+ run grep -F 'html_extra_path = ["public"]' docs/conf.py
+ assert_success
+
+ run grep -F 'getting-started/index' docs/index.md
+ assert_success
+
+ run grep -F 'reference/index' docs/index.md
+ assert_success
+
+ run grep -F 'api/index' docs/index.md
+ assert_success
+
+ run grep -F 'security' docs/reference/index.md
+ assert_success
+
+ run grep -F 'get-bashed--unix.tar.gz' docs/getting-started/index.md
+ assert_success
+
+ run grep -F 'jbcom/pkgs' docs/getting-started/downloads.md
+ assert_success
+
+ run test -f docs/public/install.sh
+ assert_success
+}
+
+@test "docs tooling includes linkcheck and a local ci target" {
+ run grep -F 'env_list = docs, docs-linkcheck' tox.ini
+ assert_success
+
+ run grep -F 'sphinx-build docs/ docs/_build/linkcheck -b linkcheck' tox.ini
+ assert_success
+
+ run grep -F 'uvx tox -e docs,docs-linkcheck' Makefile
+ assert_success
+
+ run grep -F 'ci:' Makefile
+ assert_success
+
+ run grep -F 'uvx tox -e docs,docs-linkcheck' .github/workflows/ci.yml
+ assert_success
+}
+
+@test "repository includes a supply-chain verifier and documents verify-security" {
+ run test -f scripts/supply_chain_verify.sh
+ assert_success
+
+ run grep -F 'verify-security:' Makefile
+ assert_success
+
+ run grep -F 'make verify-security' README.md docs/README.md docs/TESTING.md docs/reference/testing.md docs/reference/security.md
+ assert_success
+
+ run grep -F './scripts/supply_chain_verify.sh' .github/workflows/ci.yml
+ assert_success
+}
+
+@test "repository carries a repo-owned dependabot configuration" {
+ run test -f .github/dependabot.yml
+ assert_success
+
+ run grep -F 'package-ecosystem: "github-actions"' .github/dependabot.yml
+ assert_success
+
+ run grep -F 'commit-message:' .github/dependabot.yml
+ assert_success
+
+ run grep -F 'automated Dependabot security fixes' README.md docs/reference/security.md docs/reference/supply-chain.md
+ assert_success
+
+ run grep -F 'secret scanning push protection' README.md docs/reference/security.md docs/reference/supply-chain.md docs/TESTING.md
+ assert_success
+}
+
+@test "repository includes a branch protection verifier and documents exact required checks" {
+ run test -f scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'Quality (ubuntu-latest)' scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'Quality (macos-latest)' scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'Quality (wsl-ubuntu)' scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'SonarQube Scan' scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'require_code_owner_reviews' scripts/verify_branch_protection.sh
+ assert_success
+
+ run grep -F 'make verify-branch-protection' docs/CONFIG.md docs/TESTING.md docs/reference/release-checklist.md docs/reference/release-verification.md docs/reference/testing.md README.md
+ assert_success
+}
+
+@test "repository includes a scripted CodeQL governance reconciliation path" {
+ run test -f scripts/reconcile_codeql_governance.sh
+ assert_success
+
+ run grep -F 'reconcile-codeql-governance:' Makefile
+ assert_success
+
+ run grep -F 'make reconcile-codeql-governance' README.md docs/TESTING.md docs/reference/security.md docs/reference/supply-chain.md docs/reference/release-checklist.md
+ assert_success
+}
+
+@test "release workflows use draft-first checked-in publication scripts" {
+ run grep -F '"draft": true' release-please-config.json
+ assert_success
+
+ run grep -F '"force-tag-creation": true' release-please-config.json
+ assert_success
+
+ run grep -F 'id: release' .github/workflows/cd.yml
+ assert_success
+
+ run grep -F 'steps.release.outputs.release_created' .github/workflows/cd.yml
+ assert_success
+
+ run grep -F 'scripts/publish_draft_release.sh' .github/workflows/cd.yml
+ assert_success
+
+ run grep -F 'secrets.CI_GITHUB_TOKEN || github.token' .github/workflows/cd.yml
+ assert_success
+
+ run grep -F 'scripts/build_release_artifact.sh' .github/workflows/release.yml
+ assert_success
+
+ run grep -F 'scripts/release_validate.sh' .github/workflows/release.yml
+ assert_success
+
+ run grep -F 'scripts/publish_draft_release.sh' .github/workflows/release.yml
+ assert_success
+
+ run grep -F 'scripts/verify_published_release.sh' .github/workflows/release.yml
+ assert_success
+
+ run grep -F 'scripts/publish_pkg_pr.sh' .github/workflows/release.yml
+ assert_success
+
+ run grep -F 'workflow_dispatch' .github/workflows/release.yml
+ assert_success
+
+ run rg -n 'types: \[published\]' .github/workflows/release.yml
+ assert_failure
+
+ run grep -F 'gh attestation verify "$WINDOWS_ARCHIVE"' scripts/verify_published_release.sh
+ assert_success
+
+ run grep -F 'verify-immutable-release-governance:' Makefile
+ assert_success
+
+ run grep -F 'reconcile-immutable-release-governance:' Makefile
+ assert_success
+
+ run test -f scripts/verify_immutable_release_governance.sh
+ assert_success
+
+ run test -f scripts/reconcile_immutable_release_governance.sh
+ assert_success
+}
+
+@test "repository includes a dedicated scorecard workflow" {
+ run test -f .github/workflows/scorecard.yml
+ assert_success
+
+ run grep -F 'ossf/scorecard-action' .github/workflows/scorecard.yml
+ assert_success
+}
+
+@test "installer code does not resolve asdf latest at runtime" {
+ run rg -n 'asdf (latest|install .+ latest)' installers installlib bin scripts
+ assert_failure
+}
+
+@test "curl fallbacks are pinned instead of using moving HEAD URLs" {
+ run rg -n 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --glob '!scripts/supply_chain_verify.sh'
+ assert_failure
+}
+
+@test "runtime and pipx helpers do not use floating package specs" {
+ run rg -n 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d
+ assert_failure
+
+ run rg -n 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers
+ assert_failure
+}
+
+@test "actionlint fallback pins per-platform checksums" {
+ run grep -F 'GET_BASHED_ACTIONLINT_SHA256["linux_amd64"]' installers/sources.sh
+ assert_success
+
+ run grep -F 'GET_BASHED_ACTIONLINT_SHA256["darwin_arm64"]' installers/sources.sh
+ assert_success
+}
+
+@test "shell-facing files stay within the 300 line cap" {
+ run "$MODERN_BASH" -c '
+ set -euo pipefail
+ status=0
+ for file in install.sh install.bash bashrc bash_profile bin/ram_usage installers/_helpers.sh installers/tools.sh scripts/*.sh installlib/*.sh installers/lib/*.sh bashrc.d/*.sh; do
+ lines=$(wc -l < "$file")
+ if [[ "$lines" -gt 300 ]]; then
+ printf "%s %s\n" "$lines" "$file"
+ status=1
+ fi
+ done
+ exit "$status"
+ '
+ assert_success
+}
+
+@test "bash_it installs to the runtime path expected by bash-it integration" {
+ run grep -F 'tool_target_dir bash_it "bash-it"' installers/tools.sh
+ assert_success
+}
+
+@test "tests do not hardcode a macOS-only bash path in commands" {
+ run rg -n '/opt/homebrew/bin/bash' tests --glob '!test_helper.bash' --glob '!docs_contract.bats'
+ assert_failure
+}
+
+@test "make targets bootstrap the same helper path as CI" {
+ run grep -F './scripts/ci-setup.sh "shdoc,uv"' Makefile
+ assert_success
+
+ run grep -F './scripts/ci-setup.sh "bats"' Makefile
+ assert_success
+}
+
+@test "ci bootstrap disables Homebrew auto-update for deterministic tool installs" {
+ run grep -F 'HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}"' scripts/ci-setup.sh
+ assert_success
+
+ run grep -F 'HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}"' scripts/ci-setup.sh
+ assert_success
+}
+
+@test "runtime modules resolve Homebrew state through brew --prefix" {
+ run rg -n 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d
+ assert_failure
+
+ run grep -F 'prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' bashrc.d/10-helpers.sh
+ assert_success
+
+ run rg -n 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh
+ assert_success
+}
+
+@test "bash_profile uses brew shellenv instead of hand-rolled exports" {
+ run rg -n 'brew shellenv' bash_profile
+ assert_success
+
+ run rg -n 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile
+ assert_failure
+}
+
+@test "bootstrap uses the shared pinned Homebrew source manifest" {
+ run grep -F 'installers/bootstrap_sources.sh' install.sh
+ assert_success
+
+ run grep -F 'GET_BASHED_CURL_SOURCES["brew"]="${GET_BASHED_BOOTSTRAP_BREW_URL}"' installers/sources.sh
+ assert_success
+
+ run grep -F 'GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}"' installers/sources.sh
+ assert_success
+
+ run rg -n 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs
+ assert_failure
+}
diff --git a/tests/dry_run.bats b/tests/dry_run.bats
new file mode 100644
index 0000000..99b1026
--- /dev/null
+++ b/tests/dry_run.bats
@@ -0,0 +1,40 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "dry-run does not create files or modify shell startup" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ mkdir -p "$HOME"
+
+ run env HOME="$HOME" ./install.sh --auto --profiles minimal --prefix "$HOME/.get-bashed" --dry-run
+ assert_success
+ assert_output --partial "Dry run enabled. No changes will be made."
+
+ assert_dir_not_exist "$HOME/.get-bashed"
+ assert_file_not_exist "$HOME/.bashrc"
+ assert_file_not_exist "$HOME/.bash_profile"
+}
+
+@test "dry-run with --with-ui does not try to install dialog in non-interactive mode" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/brew.log"
+ mkdir -p "$HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/brew" < "$LOG"
+exit 99
+EOF
+ chmod +x "$FAKEBIN/brew"
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" ./install.sh --with-ui --prefix "$HOME/.get-bashed" --dry-run
+ assert_success
+ assert_output --partial "Dry run enabled. No changes will be made."
+
+ if [[ -e "$LOG" ]]; then
+ fail "dry-run should not try to install dialog before switching to non-interactive mode"
+ fi
+}
diff --git a/tests/git_sources.bats b/tests/git_sources.bats
new file mode 100644
index 0000000..150ffe5
--- /dev/null
+++ b/tests/git_sources.bats
@@ -0,0 +1,70 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+setup_git_repo() {
+ local repo="$1"
+
+ git init -q "$repo"
+ git -C "$repo" config user.name "Test User"
+ git -C "$repo" config user.email "test@example.com"
+}
+
+@test "git-backed tool installs realign existing clone to pinned ref and target dir" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ REPO="$TMPDIR/bash-it-repo"
+ mkdir -p "$HOME" "$PREFIX/vendor"
+
+ setup_git_repo "$REPO"
+ printf '#!/bin/sh\nexit 0\n' > "$REPO/bash_it.sh"
+ git -C "$REPO" add bash_it.sh
+ git -C "$REPO" commit -m first >/dev/null
+ SHA1="$(git -C "$REPO" rev-parse HEAD)"
+ git -C "$REPO" tag v3.2.0 "$SHA1"
+
+ printf '#!/bin/sh\necho second\n' > "$REPO/bash_it.sh"
+ git -C "$REPO" commit -am second >/dev/null
+ SHA2="$(git -C "$REPO" rev-parse HEAD)"
+
+ git config --global url."$REPO".insteadOf "https://github.com/Bash-it/bash-it.git"
+ git clone "$REPO" "$PREFIX/vendor/bash-it" >/dev/null 2>&1
+ git -C "$PREFIX/vendor/bash-it" checkout "$SHA2" >/dev/null 2>&1
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" "$MODERN_BASH" -c 'source installers/_helpers.sh; source installers/tools.sh; install_tool bash_it'
+ assert_success
+
+ run git -C "$PREFIX/vendor/bash-it" rev-parse HEAD
+ assert_output "$SHA1"
+ assert_dir_exist "$PREFIX/vendor/bash-it"
+ assert_file_not_exist "$PREFIX/vendor/bash_it/bash_it.sh"
+}
+
+@test "git-based asdf install realigns an existing clone to the pinned ref" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ REPO="$TMPDIR/asdf-repo"
+ mkdir -p "$HOME"
+
+ setup_git_repo "$REPO"
+ printf 'first\n' > "$REPO/README.md"
+ git -C "$REPO" add README.md
+ git -C "$REPO" commit -m first >/dev/null
+ SHA1="$(git -C "$REPO" rev-parse HEAD)"
+ git -C "$REPO" tag v0.18.1 "$SHA1"
+
+ printf 'second\n' > "$REPO/README.md"
+ git -C "$REPO" commit -am second >/dev/null
+ SHA2="$(git -C "$REPO" rev-parse HEAD)"
+
+ git config --global url."$REPO".insteadOf "https://github.com/asdf-vm/asdf.git"
+ git clone "$REPO" "$HOME/.asdf" >/dev/null 2>&1
+ git -C "$HOME/.asdf" checkout "$SHA2" >/dev/null 2>&1
+
+ run env HOME="$HOME" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_asdf(){ return 1; }; _using_brew(){ return 1; }; install_asdf'
+ assert_success
+
+ run git -C "$HOME/.asdf" rev-parse HEAD
+ assert_output "$SHA1"
+}
diff --git a/tests/immutable_release_governance.bats b/tests/immutable_release_governance.bats
new file mode 100644
index 0000000..109c0c3
--- /dev/null
+++ b/tests/immutable_release_governance.bats
@@ -0,0 +1,198 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+encode_text() {
+ python3 - <<'PY' "$1"
+import base64
+import sys
+
+print(base64.b64encode(sys.argv[1].encode("utf-8")).decode("ascii"))
+PY
+}
+
+@test "verify_immutable_release_governance defers until the draft-first flow is on the target branch" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ case "$*" in
+ *'contents/release-please-config.json?ref='*) exit 1 ;;
+ *) exit 1 ;;
+ esac
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/verify_immutable_release_governance.sh
+ assert_success
+ assert_output --partial "deferred until draft-first release flow lands"
+
+ rm -rf "$tmpdir"
+}
+
+@test "verify_immutable_release_governance fails when immutable releases remain disabled after rollout" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ release_config_b64="$(encode_text "$(cat <<'EOF'
+{
+ "draft": true,
+ "force-tag-creation": true
+}
+EOF
+)")"
+ cd_workflow_b64="$(encode_text "$(cat <<'EOF'
+- id: release
+ uses: googleapis/release-please-action@deadbeef
+ with:
+ token: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+- if: steps.release.outputs.release_created
+ run: bash scripts/publish_draft_release.sh
+EOF
+)")"
+ release_workflow_b64="$(encode_text "$(cat <<'EOF'
+on:
+ workflow_dispatch:
+ inputs:
+ publish_release:
+jobs:
+ publish:
+ steps:
+ - run: bash scripts/publish_draft_release.sh
+ - run: bash scripts/verify_published_release.sh
+ - run: bash scripts/publish_pkg_pr.sh
+EOF
+)")"
+
+ cat >"$bindir/gh" <"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ case "$*" in
+ *'contents/release-please-config.json?ref='*) exit 1 ;;
+ *) exit 1 ;;
+ esac
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_immutable_release_governance.sh
+ assert_failure
+ assert_output --partial "draft-first release flow must be present"
+
+ rm -rf "$tmpdir"
+}
+
+@test "reconcile_immutable_release_governance enables immutable releases once rollout is live" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ log_file="$tmpdir/gh.log"
+ mkdir -p "$bindir"
+
+ release_config_b64="$(encode_text "$(cat <<'EOF'
+{
+ "draft": true,
+ "force-tag-creation": true
+}
+EOF
+)")"
+ cd_workflow_b64="$(encode_text "$(cat <<'EOF'
+- id: release
+ uses: googleapis/release-please-action@deadbeef
+ with:
+ token: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+- if: steps.release.outputs.release_created
+ run: bash scripts/publish_draft_release.sh
+EOF
+)")"
+ release_workflow_b64="$(encode_text "$(cat <<'EOF'
+on:
+ workflow_dispatch:
+ inputs:
+ publish_release:
+jobs:
+ publish:
+ steps:
+ - run: bash scripts/publish_draft_release.sh
+ - run: bash scripts/verify_published_release.sh
+ - run: bash scripts/publish_pkg_pr.sh
+EOF
+)")"
+
+ cat >"$bindir/gh" <>"$log_file"
+if [[ "\$1 \$2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "\$1" == "api" ]]; then
+ case "\$*" in
+ *'contents/release-please-config.json?ref='*) printf '%s\n' "$release_config_b64" ;;
+ *'contents/.github/workflows/cd.yml?ref='*) printf '%s\n' "$cd_workflow_b64" ;;
+ *'contents/.github/workflows/release.yml?ref='*) printf '%s\n' "$release_workflow_b64" ;;
+ *'repos/jbcom/get-bashed/immutable-releases'*'.enabled'*) printf 'false\n' ;;
+ *'-X PUT repos/jbcom/get-bashed/immutable-releases'*) exit 0 ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/reconcile_immutable_release_governance.sh
+ assert_success
+ assert_output --partial "enabled immutable releases"
+
+ run grep -F 'api -X PUT repos/jbcom/get-bashed/immutable-releases' "$log_file"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
diff --git a/tests/install.bats b/tests/install.bats
index 79d71bd..c1b4550 100755
--- a/tests/install.bats
+++ b/tests/install.bats
@@ -5,7 +5,7 @@ load test_helper
@test "installer writes to prefix and wires bashrc" {
TMPDIR="$(mktemp -d)"
TEST_HOME="$TMPDIR"
- HOME="$TEST_HOME" bash ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force
assert_file_exist "$TEST_HOME/.get-bashed/bashrc"
assert_dir_exist "$TEST_HOME/.get-bashed/bashrc.d"
@@ -16,3 +16,104 @@ load test_helper
run grep -F "# get-bashed: source login bash_profile" "$TEST_HOME/.bash_profile"
assert_success
}
+
+@test "installer splits comma-delimited install lists" {
+ TMPDIR="$(mktemp -d)"
+ TEST_HOME="$TMPDIR/home"
+ mkdir -p "$TEST_HOME"
+
+ HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --dry-run --install "pre_commit,actionlint,shellcheck" > "$TMPDIR/out"
+
+ run grep -F "would install: pre_commit" "$TMPDIR/out"
+ assert_success
+ run grep -F "would install: actionlint" "$TMPDIR/out"
+ assert_success
+ run grep -F "would install: shellcheck" "$TMPDIR/out"
+ assert_success
+}
+
+@test "actionlint fallback verifies pinned checksum before install" {
+ TMPDIR="$(mktemp -d)"
+ TEST_HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/log"
+ mkdir -p "$TEST_HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/curl" <> "$LOG"
+outfile=
+while [ "\$#" -gt 0 ]; do
+ if [ "\$1" = "-o" ]; then
+ shift
+ outfile="\$1"
+ break
+ fi
+ shift
+done
+printf 'archive' > "\$outfile"
+EOF
+ chmod +x "$FAKEBIN/curl"
+
+ cat > "$FAKEBIN/sha256sum" <<'EOF'
+#!/bin/sh
+printf '%s %s\n' 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f' "$1"
+EOF
+ chmod +x "$FAKEBIN/sha256sum"
+
+ cat > "$FAKEBIN/tar" <<'EOF'
+#!/bin/sh
+dest="."
+while [ "$#" -gt 0 ]; do
+ if [ "$1" = "-C" ]; then
+ shift
+ dest="$1"
+ fi
+ shift
+done
+printf '#!/bin/sh\nexit 0\n' > "$dest/actionlint"
+EOF
+ chmod +x "$FAKEBIN/tar"
+
+ run env HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_brew(){ return 1; }; apt_install(){ return 1; }; install_actionlint'
+ assert_success
+ assert_file_exist "$TEST_HOME/.get-bashed/bin/actionlint"
+}
+
+@test "actionlint fallback aborts on checksum mismatch" {
+ TMPDIR="$(mktemp -d)"
+ TEST_HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ mkdir -p "$TEST_HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/curl" <<'EOF'
+#!/bin/sh
+outfile=
+while [ "$#" -gt 0 ]; do
+ if [ "$1" = "-o" ]; then
+ shift
+ outfile="$1"
+ break
+ fi
+ shift
+done
+printf 'archive' > "$outfile"
+EOF
+ chmod +x "$FAKEBIN/curl"
+
+ cat > "$FAKEBIN/sha256sum" <<'EOF'
+#!/bin/sh
+printf '%s %s\n' 'deadbeef' "$1"
+EOF
+ chmod +x "$FAKEBIN/sha256sum"
+
+ cat > "$FAKEBIN/tar" <<'EOF'
+#!/bin/sh
+exit 0
+EOF
+ chmod +x "$FAKEBIN/tar"
+
+ run env HOME="$TEST_HOME" GET_BASHED_HOME="$TEST_HOME/.get-bashed" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source installers/_helpers.sh; _using_brew(){ return 1; }; apt_install(){ return 1; }; install_actionlint'
+ assert_failure
+ assert_output --partial "Actionlint checksum mismatch"
+}
diff --git a/tests/interactive_features.bats b/tests/interactive_features.bats
new file mode 100644
index 0000000..a1eea40
--- /dev/null
+++ b/tests/interactive_features.bats
@@ -0,0 +1,193 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "prompt selection applies bash_it through the shared feature resolver" {
+ run "$MODERN_BASH" -c '
+ set -euo pipefail
+ REPO_DIR="$PWD"
+ source installers/_helpers.sh
+ source installers/tools.sh
+ source installlib/config.sh
+ source installlib/resolve.sh
+ source installlib/ui.sh
+
+ init_install_state
+ load_installers
+ input_file="$(mktemp)"
+ cat > "$input_file" <<'"'"'INPUT'"'"'
+y
+n
+n
+n
+n
+n
+y
+n
+INPUT
+ run_prompt_selection < "$input_file"
+ rm -f "$input_file"
+ printf "bash_it=%s installs=%s\n" "$GET_BASHED_USE_BASH_IT" "${GROUP_INSTALLS:-}"
+ '
+ assert_success
+ assert_output "bash_it=1 installs=bash_it"
+}
+
+@test "prompt selection can disable feature defaults inherited from a profile" {
+ run "$MODERN_BASH" -c '
+ set -euo pipefail
+ REPO_DIR="$PWD"
+ source installers/_helpers.sh
+ source installers/tools.sh
+ source installlib/config.sh
+ source installlib/resolve.sh
+ source installlib/ui.sh
+
+ init_install_state
+ PROFILES=dev
+ resolve_requested_state
+
+ input_file="$(mktemp)"
+ cat > "$input_file" <<'"'"'INPUT'"'"'
+y
+n
+n
+n
+n
+n
+n
+n
+INPUT
+ run_prompt_selection < "$input_file"
+ rm -f "$input_file"
+ printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \
+ "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \
+ "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS"
+ '
+ assert_success
+ assert_output "gnu=0 build=0 auto=0 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash"
+}
+
+@test "prompt selection preserves current feature defaults on empty answers" {
+ run "$MODERN_BASH" -c '
+ set -euo pipefail
+ REPO_DIR="$PWD"
+ source installers/_helpers.sh
+ source installers/tools.sh
+ source installlib/config.sh
+ source installlib/resolve.sh
+ source installlib/ui.sh
+
+ init_install_state
+ PROFILES=dev
+ resolve_requested_state
+
+ input_file="$(mktemp)"
+ cat > "$input_file" <<'"'"'INPUT'"'"'
+y
+
+
+
+
+
+
+
+INPUT
+ run_prompt_selection < "$input_file"
+ rm -f "$input_file"
+ printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \
+ "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \
+ "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS"
+ '
+ assert_success
+ assert_output "gnu=1 build=1 auto=1 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash"
+}
+
+@test "prompt selection with --yes preserves resolved defaults instead of enabling every feature" {
+ run "$MODERN_BASH" -c '
+ set -euo pipefail
+ REPO_DIR="$PWD"
+ source installers/_helpers.sh
+ source installers/tools.sh
+ source installlib/config.sh
+ source installlib/resolve.sh
+ source installlib/ui.sh
+
+ init_install_state
+ YES=1
+ PROFILES=dev
+ resolve_requested_state
+ run_prompt_selection
+ printf "gnu=%s build=%s auto=%s ssh=%s doppler=%s bash_it=%s signing=%s installs=%s\n" \
+ "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$GET_BASHED_SSH_AGENT" \
+ "$GET_BASHED_USE_DOPPLER" "$GET_BASHED_USE_BASH_IT" "$GET_BASHED_GIT_SIGNING" "$INSTALLS"
+ '
+ assert_success
+ assert_output "gnu=1 build=1 auto=1 ssh=0 doppler=0 bash_it=0 signing=0 installs=brew,asdf,gnu_tools,rg,fd,bat,fzf,jq,yq,tree,direnv,starship,nodejs,python,pipx,bashate,shellcheck,actionlint,bats,shdoc,bash"
+}
+
+@test "dialog profile selection carries the profile installer bundle into the installer checklist defaults" {
+ TMPDIR="$(mktemp -d)"
+ FAKEBIN="$TMPDIR/bin"
+ DIALOG_COUNT="$TMPDIR/dialog-count"
+ mkdir -p "$FAKEBIN"
+
+ cat > "$FAKEBIN/dialog" < "$DIALOG_COUNT"
+if [ "\$count" -eq 1 ]; then
+ printf 'dev\n' >&2
+ exit 0
+fi
+shift 8
+first=1
+while [ "\$#" -ge 3 ]; do
+ tag="\$1"
+ shift 2
+ state="\$1"
+ shift
+ if [ "\$state" = "on" ]; then
+ if [ "\$first" -eq 0 ]; then
+ printf ' ' >&2
+ fi
+ printf '\"%s\"' "\$tag" >&2
+ first=0
+ fi
+done
+EOF
+ chmod +x "$FAKEBIN/dialog"
+
+ run env PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c '
+ set -euo pipefail
+ REPO_DIR="$PWD"
+ source installers/_helpers.sh
+ source installers/tools.sh
+ source installlib/config.sh
+ source installlib/resolve.sh
+ source installlib/ui.sh
+
+ init_install_state
+ USER_NAME="Jane Doe"
+ USER_EMAIL="jane@example.com"
+ load_installers
+ run_dialog_selection
+ printf "gnu=%s build=%s auto=%s installs=%s\n" \
+ "$GET_BASHED_GNU" "$GET_BASHED_BUILD_FLAGS" "$GET_BASHED_AUTO_TOOLS" "$INSTALLS"
+ '
+ assert_success
+ assert_output --partial "gnu=1 build=1 auto=1"
+ assert_output --partial "installs="
+ [[ "$output" == *"brew"* ]]
+ [[ "$output" == *"asdf"* ]]
+ [[ "$output" == *"gnu_tools"* ]]
+ [[ "$output" == *"rg"* ]]
+ [[ "$output" == *"direnv"* ]]
+ [[ "$output" == *"python"* ]]
+ [[ "$output" == *"bashate"* ]]
+ [[ "$output" != *"terraform"* ]]
+}
diff --git a/tests/link_dotfiles.bats b/tests/link_dotfiles.bats
index 4df5c2c..e5e0423 100644
--- a/tests/link_dotfiles.bats
+++ b/tests/link_dotfiles.bats
@@ -11,7 +11,7 @@ load test_helper
USER_NAME="Jane Doe"
USER_EMAIL="jane@example.com"
- HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --name "$USER_NAME" --email "$USER_EMAIL" --prefix "$TEST_HOME/.get-bashed" --force
run test -L "$TEST_HOME/.bashrc"
assert_success
@@ -39,7 +39,7 @@ load test_helper
TEST_HOME="$HOME"
echo "legacy" > "$HOME/.bashrc"
- HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force
run test -L "$TEST_HOME/.bashrc"
assert_success
@@ -57,7 +57,7 @@ load test_helper
echo "legacy" > "$TEST_HOME/other/.bashrc"
ln -s "$TEST_HOME/other/.bashrc" "$TEST_HOME/.bashrc"
- HOME="$TEST_HOME" bash ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force
+ HOME="$TEST_HOME" ./install.sh --auto --link-dotfiles --prefix "$TEST_HOME/.get-bashed" --force
run readlink "$TEST_HOME/.bashrc"
assert_success
diff --git a/tests/managed_assets.bats b/tests/managed_assets.bats
new file mode 100644
index 0000000..0c0f60a
--- /dev/null
+++ b/tests/managed_assets.bats
@@ -0,0 +1,27 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "installer refreshes managed files and preserves unmanaged files under force" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ mkdir -p "$PREFIX/bashrc.d"
+
+ echo "legacy" > "$PREFIX/bashrc"
+ echo "custom" > "$PREFIX/bashrc.d/77-user.sh"
+ echo "stale" > "$PREFIX/old-managed-file"
+ printf 'bashrc\nold-managed-file\n' > "$PREFIX/.get-bashed-manifest"
+
+ run env HOME="$HOME" ./install.sh --auto --force --prefix "$PREFIX"
+ assert_success
+
+ assert_file_exist "$PREFIX/bashrc"
+ assert_file_exist "$PREFIX/bashrc.d/77-user.sh"
+ assert_file_not_exist "$PREFIX/old-managed-file"
+
+ run grep -F "@file bashrc" "$PREFIX/bashrc"
+ assert_success
+ run cat "$PREFIX/bashrc.d/77-user.sh"
+ assert_output "custom"
+}
diff --git a/tests/migration.bats b/tests/migration.bats
new file mode 100644
index 0000000..55e7b9a
--- /dev/null
+++ b/tests/migration.bats
@@ -0,0 +1,20 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "legacy bashrc.d collisions are migrated without overwriting managed modules" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ mkdir -p "$HOME/.bashrc.d"
+ PREFIX="$HOME/.get-bashed"
+
+ echo "# custom legacy module" > "$HOME/.bashrc.d/20-path.sh"
+
+ run env HOME="$HOME" ./install.sh --auto --prefix "$PREFIX"
+ assert_success
+
+ assert_file_exist "$PREFIX/bashrc.d/20-path.sh"
+ run grep -F "@file 20-path" "$PREFIX/bashrc.d/20-path.sh"
+ assert_success
+ assert_file_exist "$PREFIX/bashrc.d/migrated-1-20-path.sh"
+}
diff --git a/tests/optional_deps.bats b/tests/optional_deps.bats
index 8cc326d..7b56a03 100644
--- a/tests/optional_deps.bats
+++ b/tests/optional_deps.bats
@@ -8,7 +8,7 @@ load test_helper
mkdir -p "$HOME"
TEST_HOME="$HOME"
- HOME="$TEST_HOME" bash ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force --features git_signing --install git --dry-run > "$TMPDIR/out"
+ HOME="$TEST_HOME" ./install.sh --auto --prefix "$TEST_HOME/.get-bashed" --force --features git_signing --install git --dry-run > "$TMPDIR/out"
run grep -F "would install: gnupg" "$TMPDIR/out"
assert_success
diff --git a/tests/registry_idempotent.bats b/tests/registry_idempotent.bats
index 666f439..66321dc 100644
--- a/tests/registry_idempotent.bats
+++ b/tests/registry_idempotent.bats
@@ -8,7 +8,7 @@ load test_helper
mkdir -p "$HOME"
TEST_HOME="$HOME"
- HOME="$TEST_HOME" bash ./install.sh --auto --list-installers > "$TMPDIR/list"
+ HOME="$TEST_HOME" ./install.sh --auto --list-installers > "$TMPDIR/list"
run grep -F " - git" "$TMPDIR/list"
assert_success
diff --git a/tests/release_pipeline.bats b/tests/release_pipeline.bats
new file mode 100644
index 0000000..97c7b78
--- /dev/null
+++ b/tests/release_pipeline.bats
@@ -0,0 +1,425 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+pick_port() {
+ python3 - <<'PY'
+import socket
+
+sock = socket.socket()
+sock.bind(("127.0.0.1", 0))
+print(sock.getsockname()[1])
+sock.close()
+PY
+}
+
+wait_for_http() {
+ local url="$1"
+ local attempt
+ for attempt in $(seq 1 50); do
+ if python3 - "$url" <<'PY' >/dev/null 2>&1
+from urllib.request import urlopen
+import sys
+
+with urlopen(sys.argv[1], timeout=1):
+ pass
+PY
+ then
+ return 0
+ fi
+ sleep 0.1
+ done
+
+ return 1
+}
+
+@test "release artifacts build and smoke test successfully" {
+ tmpdir="$(mktemp -d)"
+
+ run "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.9 "$tmpdir"
+ assert_success
+
+ assert_file_exist "$tmpdir/get-bashed-9.9.9-unix.tar.gz"
+ assert_file_exist "$tmpdir/get-bashed-9.9.9-unix.tar.gz.sha256"
+ assert_file_exist "$tmpdir/get-bashed-9.9.9-windows.zip"
+ assert_file_exist "$tmpdir/get-bashed-9.9.9-windows.zip.sha256"
+
+ run tar -tzf "$tmpdir/get-bashed-9.9.9-unix.tar.gz"
+ assert_success
+ refute_output --partial "__pycache__"
+ refute_output --partial ".pyc"
+
+ run python3 - <<'PY' "$tmpdir/get-bashed-9.9.9-windows.zip"
+from zipfile import ZipFile
+import sys
+
+with ZipFile(sys.argv[1]) as bundle:
+ for name in bundle.namelist():
+ print(name)
+PY
+ assert_success
+ refute_output --partial "__pycache__"
+ refute_output --partial ".pyc"
+
+ run "$MODERN_BASH" ./scripts/smoke_test_release_artifact.sh 9.9.9 "$tmpdir/get-bashed-9.9.9-unix.tar.gz"
+ assert_success
+
+ run "$MODERN_BASH" ./scripts/smoke_test_release_artifact.sh 9.9.9 "$tmpdir/get-bashed-9.9.9-windows.zip"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
+@test "publish_draft_release uploads assets to a draft release and publishes it" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ state_file="$tmpdir/state"
+ log="$tmpdir/gh.log"
+ mkdir -p "$bindir" "$tmpdir/dist"
+ printf 'true\n' >"$state_file"
+ touch \
+ "$tmpdir/dist/get-bashed-9.9.20-unix.tar.gz" \
+ "$tmpdir/dist/get-bashed-9.9.20-windows.zip" \
+ "$tmpdir/dist/checksums.txt"
+
+ cat >"$bindir/gh" <>"\$log"
+if [[ "\$1 \$2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "\$1 \$2" == "release view" ]]; then
+ cat "\$state_file"
+ exit 0
+fi
+if [[ "\$1 \$2" == "release upload" ]]; then
+ exit 0
+fi
+if [[ "\$1 \$2" == "release edit" ]]; then
+ printf 'false\n' >"\$state_file"
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/publish_draft_release.sh v9.9.20 "$tmpdir/dist" jbcom/get-bashed
+ assert_success
+ assert_output --partial "published release v9.9.20"
+
+ run grep -F 'release upload v9.9.20' "$log"
+ assert_success
+
+ run grep -F 'release edit v9.9.20 --repo jbcom/get-bashed --draft=false' "$log"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
+@test "release validation exercises docs installer and generates package manifests" {
+ tmpdir="$(mktemp -d)"
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.10 "$tmpdir"
+
+ run "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir"
+ assert_success
+
+ assert_file_exist "$tmpdir/checksums.txt"
+ assert_file_exist "$tmpdir/pkg/get-bashed.rb"
+ assert_file_exist "$tmpdir/pkg/get-bashed.json"
+ assert_file_exist "$tmpdir/pkg/get-bashed.nuspec"
+ assert_file_exist "$tmpdir/pkg/chocolateyInstall.ps1"
+ assert_file_exist "$tmpdir/pkg/VERIFICATION.txt"
+
+ run grep -F 'stage_dir = Dir["get-bashed-*"].find { |path| File.directory?(path) }' "$tmpdir/pkg/get-bashed.rb"
+ assert_success
+
+ run grep -F '#!/bin/sh' "$tmpdir/pkg/get-bashed.rb"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
+@test "docs-site installer accepts bare release versions against a local release mirror" {
+ tmpdir="$(mktemp -d)"
+ mkdir -p "$tmpdir/home"
+ port="$(pick_port)"
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.11 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.11-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.11-windows.zip.sha256" >"$tmpdir/checksums.txt"
+
+ python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 &
+ server_pid=$!
+ wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+
+ run env \
+ HOME="$tmpdir/home" \
+ GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \
+ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
+ sh ./docs/public/install.sh --version 9.9.11 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed"
+ assert_success
+
+ assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh"
+
+ kill "$server_pid" 2>/dev/null || true
+ rm -rf "$tmpdir"
+}
+
+@test "docs-site installer can use the supported wget fallback against a local release mirror" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$tmpdir/home" "$bindir"
+ port="$(pick_port)"
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.16 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.16-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.16-windows.zip.sha256" >"$tmpdir/checksums.txt"
+
+ python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 &
+ server_pid=$!
+ wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+
+ cat >"$bindir/wget" <<'EOF'
+#!/usr/bin/env python3
+from pathlib import Path
+from urllib.request import urlopen
+import sys
+
+argv = sys.argv[1:]
+if len(argv) == 2 and argv[0] == "-qO-":
+ with urlopen(argv[1], timeout=10) as response:
+ sys.stdout.buffer.write(response.read())
+ raise SystemExit(0)
+
+if len(argv) == 3 and argv[0] == "-qO":
+ target = Path(argv[1])
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with urlopen(argv[2], timeout=10) as response:
+ target.write_bytes(response.read())
+ raise SystemExit(0)
+
+raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}")
+EOF
+ chmod +x "$bindir/wget"
+
+ run env \
+ HOME="$tmpdir/home" \
+ PATH="$bindir:$PATH" \
+ GET_BASHED_DOWNLOAD_TOOL="wget" \
+ GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \
+ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
+ sh ./docs/public/install.sh --version 9.9.16 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed"
+ assert_success
+
+ assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh"
+
+ kill "$server_pid" 2>/dev/null || true
+ rm -rf "$tmpdir"
+}
+
+@test "publish_pkg_pr can stage pkgs changes into a local target repo and call gh" {
+ tmpdir="$(mktemp -d)"
+ remote="$tmpdir/pkgs.git"
+ worktree="$tmpdir/worktree"
+ manifests="$tmpdir/manifests"
+ bindir="$tmpdir/bin"
+ log="$tmpdir/gh.log"
+
+ git init --bare --initial-branch=main "$remote" >/dev/null
+ git clone "$remote" "$worktree" >/dev/null
+ (
+ cd "$worktree"
+ git config user.name Test
+ git config user.email test@example.com
+ touch .keep
+ git add .keep
+ git commit -m "init" >/dev/null
+ git push origin main >/dev/null
+ )
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.12 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.12-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.12-windows.zip.sha256" >"$tmpdir/checksums.txt"
+ "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.12 "$tmpdir/checksums.txt" "$manifests"
+
+ mkdir -p "$bindir"
+ cat >"$bindir/gh" <>"$log"
+if [[ "\$1 \$2" == "pr create" ]]; then
+ printf 'https://example.test/pr/1\n'
+fi
+EOF
+ chmod +x "$bindir/gh"
+
+ run env \
+ GH_TOKEN=fake-token \
+ TARGET_REPO=jbcom/pkgs \
+ TARGET_REPO_URL="$remote" \
+ PATH="$bindir:$PATH" \
+ "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.12 "$manifests"
+ assert_success
+
+ run git ls-remote --heads "$remote" "refs/heads/get-bashed/bump-9.9.12"
+ assert_success
+ assert_output --partial "get-bashed/bump-9.9.12"
+
+ checkout="$tmpdir/checkout"
+ git clone --branch get-bashed/bump-9.9.12 "$remote" "$checkout" >/dev/null
+ assert_file_exist "$checkout/Formula/get-bashed.rb"
+ assert_file_exist "$checkout/bucket/get-bashed.json"
+ assert_file_exist "$checkout/choco/get-bashed/get-bashed.nuspec"
+ assert_file_exist "$checkout/choco/get-bashed/tools/chocolateyInstall.ps1"
+ assert_file_exist "$checkout/choco/get-bashed/tools/VERIFICATION.txt"
+ assert_file_exist "$log"
+
+ run grep -F 'pr create --repo jbcom/pkgs --base main --head get-bashed/bump-9.9.12' "$log"
+ assert_success
+
+ run grep -F 'pr merge --repo jbcom/pkgs --auto --squash https://example.test/pr/1' "$log"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
+@test "publish_pkg_pr accepts a downloaded artifact layout with nested pkg directory" {
+ tmpdir="$(mktemp -d)"
+ remote="$tmpdir/pkgs.git"
+ worktree="$tmpdir/worktree"
+ manifests="$tmpdir/artifact/pkg"
+ bindir="$tmpdir/bin"
+ log="$tmpdir/gh.log"
+
+ git init --bare --initial-branch=main "$remote" >/dev/null
+ git clone "$remote" "$worktree" >/dev/null
+ (
+ cd "$worktree"
+ git config user.name Test
+ git config user.email test@example.com
+ touch .keep
+ git add .keep
+ git commit -m "init" >/dev/null
+ git push origin main >/dev/null
+ )
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.14 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.14-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.14-windows.zip.sha256" >"$tmpdir/checksums.txt"
+ mkdir -p "$manifests"
+ "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.14 "$tmpdir/checksums.txt" "$manifests"
+
+ mkdir -p "$bindir"
+ cat >"$bindir/gh" <>"$log"
+if [[ "\$1 \$2" == "pr create" ]]; then
+ printf 'https://example.test/pr/2\n'
+fi
+EOF
+ chmod +x "$bindir/gh"
+
+ run env \
+ GH_TOKEN=fake-token \
+ TARGET_REPO=jbcom/pkgs \
+ TARGET_REPO_URL="$remote" \
+ PATH="$bindir:$PATH" \
+ "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.14 "$tmpdir/artifact"
+ assert_success
+
+ run git ls-remote --heads "$remote" "refs/heads/get-bashed/bump-9.9.14"
+ assert_success
+ assert_output --partial "get-bashed/bump-9.9.14"
+
+ run grep -F 'pr create --repo jbcom/pkgs --base main --head get-bashed/bump-9.9.14' "$log"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
+@test "publish_pkg_pr reuses an existing branch and open pr on rerun" {
+ tmpdir="$(mktemp -d)"
+ remote="$tmpdir/pkgs.git"
+ worktree="$tmpdir/worktree"
+ manifests="$tmpdir/manifests"
+ bindir="$tmpdir/bin"
+ log="$tmpdir/gh.log"
+
+ git init --bare --initial-branch=main "$remote" >/dev/null
+ git clone "$remote" "$worktree" >/dev/null
+ (
+ cd "$worktree"
+ git config user.name Test
+ git config user.email test@example.com
+ touch .keep
+ git add .keep
+ git commit -m "init" >/dev/null
+ git push origin main >/dev/null
+ )
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.15 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.15-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.15-windows.zip.sha256" >"$tmpdir/checksums.txt"
+ "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.15 "$tmpdir/checksums.txt" "$manifests"
+
+ mkdir -p "$bindir"
+ cat >"$bindir/gh" <>"$log"
+if [[ "\$1 \$2" == "pr list" ]]; then
+ if [[ "\${EXISTING_PR:-0}" == "1" ]]; then
+ printf 'https://example.test/pr/3\n'
+ fi
+ exit 0
+fi
+if [[ "\$1 \$2" == "pr create" ]]; then
+ printf 'https://example.test/pr/3\n'
+ exit 0
+fi
+EOF
+ chmod +x "$bindir/gh"
+
+ run env \
+ GH_TOKEN=fake-token \
+ TARGET_REPO=jbcom/pkgs \
+ TARGET_REPO_URL="$remote" \
+ PATH="$bindir:$PATH" \
+ EXISTING_PR=0 \
+ "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.15 "$manifests"
+ assert_success
+
+ run env \
+ GH_TOKEN=fake-token \
+ TARGET_REPO=jbcom/pkgs \
+ TARGET_REPO_URL="$remote" \
+ PATH="$bindir:$PATH" \
+ EXISTING_PR=1 \
+ "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.15 "$manifests"
+ assert_success
+
+ run grep -F 'pr list --repo jbcom/pkgs --head get-bashed/bump-9.9.15 --state open --json url --jq .[0].url // ""' "$log"
+ assert_success
+
+ create_count="$(grep -c '^pr create ' "$log" || true)"
+ [ "$create_count" -eq 1 ]
+
+ merge_count="$(grep -c '^pr merge --repo jbcom/pkgs --auto --squash https://example.test/pr/3$' "$log" || true)"
+ [ "$merge_count" -eq 2 ]
+
+ rm -rf "$tmpdir"
+}
+
+@test "publish_pkg_pr fails cleanly when GH_TOKEN is missing" {
+ tmpdir="$(mktemp -d)"
+ manifests="$tmpdir/manifests"
+ mkdir -p "$manifests"
+ touch "$manifests/get-bashed.rb" "$manifests/get-bashed.json" \
+ "$manifests/get-bashed.nuspec" "$manifests/chocolateyInstall.ps1" "$manifests/VERIFICATION.txt"
+
+ run env -u GH_TOKEN "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.13 "$manifests"
+ assert_failure
+ assert_output --partial "GH_TOKEN is required"
+
+ rm -rf "$tmpdir"
+}
diff --git a/tests/runtime_modules.bats b/tests/runtime_modules.bats
new file mode 100644
index 0000000..9e128c7
--- /dev/null
+++ b/tests/runtime_modules.bats
@@ -0,0 +1,393 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "secrets module sources only local secrets and not Doppler output" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ FAKEBIN="$TMPDIR/bin"
+ mkdir -p "$PREFIX/secrets.d" "$FAKEBIN"
+
+ cat > "$PREFIX/secrets.d/10-local.sh" <<'EOF'
+export LOCAL_SECRET=1
+EOF
+
+ cat > "$FAKEBIN/doppler" <<'EOF'
+#!/bin/sh
+echo 'export DOPPLER_SECRET=1'
+exit 0
+EOF
+ chmod +x "$FAKEBIN/doppler"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_DOPPLER=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/99-secrets.sh; printf "local=%s doppler=%s\n" "${LOCAL_SECRET:-0}" "${DOPPLER_SECRET:-0}"'
+ assert_success
+ assert_output "local=1 doppler=0"
+}
+
+@test "doppler module exposes explicit doppler_shell helper" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ MARKER="$TMPDIR/doppler-args"
+ mkdir -p "$FAKEBIN"
+
+ cat > "$FAKEBIN/doppler" < "$MARKER"
+exit 0
+EOF
+ chmod +x "$FAKEBIN/doppler"
+
+ run env HOME="$HOME" GET_BASHED_USE_DOPPLER=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/66-doppler.sh; doppler_shell'
+ assert_success
+ run cat "$MARKER"
+ assert_output "run -- bash"
+}
+
+@test "asdf module activates git installs without command pre-existence" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ mkdir -p "$HOME/.asdf/bin" "$HOME/.asdf/shims"
+
+ cat > "$HOME/.asdf/asdf.sh" <<'EOF'
+export ASDF_ACTIVATED=1
+EOF
+
+ run env HOME="$HOME" PATH="/usr/bin:/bin" "$MODERN_BASH" -lc 'source bashrc.d/10-helpers.sh; source bashrc.d/20-path.sh; source bashrc.d/60-asdf.sh; printf "activated=%s path=%s\n" "${ASDF_ACTIVATED:-0}" "$PATH"'
+ assert_success
+ assert_output --partial "activated=1"
+ assert_output --partial "$HOME/.asdf/bin"
+ assert_output --partial "$HOME/.asdf/shims"
+}
+
+@test "auto_tools uses generated pinned npm package specs" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/asdf.log"
+ mkdir -p "$HOME" "$PREFIX" "$FAKEBIN"
+
+ cat > "$PREFIX/get-bashed-pins.sh" <<'EOF'
+GET_BASHED_GEMINI_CLI_PACKAGE_SPEC="@google/gemini-cli@0.38.1"
+GET_BASHED_SONAR_SCAN_PACKAGE_SPEC="@sonar/scan@4.3.6"
+EOF
+
+ cat > "$FAKEBIN/asdf" <> "$LOG"
+case "\$*" in
+ "exec npm list -g --depth=0 @google/gemini-cli@0.38.1"|"exec npm list -g --depth=0 @sonar/scan@4.3.6")
+ exit 1
+ ;;
+esac
+exit 0
+EOF
+ chmod +x "$FAKEBIN/asdf"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_AUTO_TOOLS=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/65-tools.sh'
+ assert_success
+
+ run cat "$LOG"
+ assert_output --partial "exec npm --version"
+ assert_output --partial "exec npm list -g --depth=0 @google/gemini-cli@0.38.1"
+ assert_output --partial "exec npm list -g --depth=0 @sonar/scan@4.3.6"
+ assert_output --partial "exec npm install -g @google/gemini-cli@0.38.1"
+ assert_output --partial "exec npm install -g @sonar/scan@4.3.6"
+}
+
+@test "auto_tools skips reinstall when pinned npm packages already exist" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/asdf.log"
+ mkdir -p "$HOME" "$PREFIX" "$FAKEBIN"
+
+ cat > "$PREFIX/get-bashed-pins.sh" <<'EOF'
+GET_BASHED_GEMINI_CLI_PACKAGE_SPEC="@google/gemini-cli@0.38.1"
+GET_BASHED_SONAR_SCAN_PACKAGE_SPEC="@sonar/scan@4.3.6"
+EOF
+
+ cat > "$FAKEBIN/asdf" <> "$LOG"
+exit 0
+EOF
+ chmod +x "$FAKEBIN/asdf"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_AUTO_TOOLS=1 PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/65-tools.sh'
+ assert_success
+
+ run cat "$LOG"
+ assert_output --partial "exec npm list -g --depth=0 @google/gemini-cli@0.38.1"
+ assert_output --partial "exec npm list -g --depth=0 @sonar/scan@4.3.6"
+ refute_output --partial "exec npm install -g @google/gemini-cli@0.38.1"
+ refute_output --partial "exec npm install -g @sonar/scan@4.3.6"
+}
+
+@test "buildflags module avoids empty path segments and prefixes existing values cleanly" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/homebrew/bin"
+ BREW_ROOT="$TMPDIR/homebrew"
+ mkdir -p "$HOME" "$FAKEBIN"
+ mkdir -p \
+ "$BREW_ROOT/opt/openssl@3/lib/pkgconfig" \
+ "$BREW_ROOT/opt/openssl@3/include" \
+ "$BREW_ROOT/opt/readline/lib/pkgconfig" \
+ "$BREW_ROOT/opt/readline/include"
+
+ cat > "$FAKEBIN/brew" < "$HOME/.cargo/env" <<'EOF'
+export CARGO_ENV_LOADED=1
+EOF
+
+ cat > "$FAKEBIN/starship" <<'EOF'
+#!/bin/sh
+if [ "$1" = "init" ] && [ "$2" = "bash" ]; then
+ printf 'export STARSHIP_INIT=1\n'
+fi
+EOF
+ chmod +x "$FAKEBIN/starship"
+
+ cat > "$FAKEBIN/direnv" <<'EOF'
+#!/bin/sh
+if [ "$1" = "hook" ] && [ "$2" = "bash" ]; then
+ printf 'export DIRENV_HOOKED=1\n'
+fi
+EOF
+ chmod +x "$FAKEBIN/direnv"
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/50-tool-init.sh; printf "cargo=%s starship=%s direnv=%s\n" "${CARGO_ENV_LOADED:-0}" "${STARSHIP_INIT:-0}" "${DIRENV_HOOKED:-0}"'
+ assert_success
+ assert_output "cargo=1 starship=1 direnv=1"
+}
+
+@test "tool-init module is idempotent when sourced multiple times" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ mkdir -p "$HOME/.cargo" "$HOME/.cargo/bin" "$FAKEBIN"
+
+ cat > "$HOME/.cargo/env" <<'EOF'
+export CARGO_ENV_COUNT=$(( ${CARGO_ENV_COUNT:-0} + 1 ))
+export PATH="$HOME/.cargo/bin${PATH+:$PATH}"
+EOF
+
+ cat > "$FAKEBIN/starship" <<'EOF'
+#!/bin/sh
+if [ "$1" = "init" ] && [ "$2" = "bash" ]; then
+ printf 'export STARSHIP_INIT_COUNT=$(( ${STARSHIP_INIT_COUNT:-0} + 1 ))\n'
+ printf 'export PROMPT_COMMAND="starship${PROMPT_COMMAND:+;%s}"\n' "${PROMPT_COMMAND:-}"
+fi
+EOF
+ chmod +x "$FAKEBIN/starship"
+
+ cat > "$FAKEBIN/direnv" <<'EOF'
+#!/bin/sh
+if [ "$1" = "hook" ] && [ "$2" = "bash" ]; then
+ printf 'export DIRENV_HOOK_COUNT=$(( ${DIRENV_HOOK_COUNT:-0} + 1 ))\n'
+ printf 'export PROMPT_COMMAND="direnv${PROMPT_COMMAND:+;%s}"\n' "${PROMPT_COMMAND:-}"
+fi
+EOF
+ chmod +x "$FAKEBIN/direnv"
+
+ run env HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" "$MODERN_BASH" -c 'source bashrc.d/10-helpers.sh; source bashrc.d/50-tool-init.sh; source bashrc.d/50-tool-init.sh; printf "cargo=%s starship=%s direnv=%s prompt=%s path=%s\n" "${CARGO_ENV_COUNT:-0}" "${STARSHIP_INIT_COUNT:-0}" "${DIRENV_HOOK_COUNT:-0}" "${PROMPT_COMMAND:-}" "$PATH"'
+ assert_success
+ assert_output --partial "cargo=1 starship=1 direnv=1"
+ assert_output --partial "prompt=direnv;starship"
+ refute_output --partial "$HOME/.cargo/bin:$HOME/.cargo/bin"
+}
+
+@test "bash-it module sources installed framework and applies queued search once" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ LOG="$TMPDIR/bash-it.log"
+ mkdir -p "$PREFIX/vendor/bash-it"
+
+ cat > "$PREFIX/vendor/bash-it/bash_it.sh" <<'EOF'
+#!/usr/bin/env bash
+export BASH_IT_LOADED=1
+bash-it() {
+ printf '%s\n' "$*" >> "$BASH_IT_LOG"
+}
+EOF
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_BASH_IT=1 GET_BASHED_BASH_IT_SEARCH="git,docker" GET_BASHED_BASH_IT_ACTION=enable GET_BASHED_BASH_IT_REFRESH=1 BASH_IT_LOG="$LOG" "$MODERN_BASH" -c 'source bashrc.d/70-bash-it.sh; get_bashed_component disable aliases; printf "loaded=%s applied=%s\n" "${BASH_IT_LOADED:-0}" "${GET_BASHED_BASH_IT_APPLIED:-0}"'
+ assert_success
+ assert_output "loaded=1 applied=1"
+
+ run cat "$LOG"
+ assert_output --partial "search git docker --enable --refresh"
+ assert_output --partial "search aliases --disable"
+}
+
+@test "bash-it module is idempotent when sourced multiple times" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ LOG="$TMPDIR/bash-it.log"
+ mkdir -p "$PREFIX/vendor/bash-it"
+
+ cat > "$PREFIX/vendor/bash-it/bash_it.sh" <<'EOF'
+#!/usr/bin/env bash
+export BASH_IT_LOAD_COUNT=$(( ${BASH_IT_LOAD_COUNT:-0} + 1 ))
+bash-it() {
+ printf '%s\n' "$*" >> "$BASH_IT_LOG"
+}
+EOF
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" GET_BASHED_USE_BASH_IT=1 GET_BASHED_BASH_IT_SEARCH="git" BASH_IT_LOG="$LOG" "$MODERN_BASH" -c 'source bashrc.d/70-bash-it.sh; source bashrc.d/70-bash-it.sh; printf "loaded=%s applied=%s\n" "${BASH_IT_LOAD_COUNT:-0}" "${GET_BASHED_BASH_IT_APPLIED:-0}"'
+ assert_success
+ assert_output "loaded=1 applied=1"
+
+ run awk 'END { print NR }' "$LOG"
+ assert_success
+ assert_output "1"
+}
+
+@test "ssh-agent module creates ~/.ssh and starts agent on first run" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ LOG="$TMPDIR/ssh-agent.log"
+ mkdir -p "$HOME" "$FAKEBIN"
+
+ cat > "$FAKEBIN/ssh-agent" < "$LOG"
+sock=""
+while [ "\$#" -gt 0 ]; do
+ if [ "\$1" = "-a" ]; then
+ shift
+ sock="\$1"
+ fi
+ shift
+done
+printf 'SSH_AUTH_SOCK=%s; export SSH_AUTH_SOCK; SSH_AGENT_PID=123; export SSH_AGENT_PID; echo Agent pid 123;\n' "\$sock"
+EOF
+ chmod +x "$FAKEBIN/ssh-agent"
+
+ cat > "$FAKEBIN/ssh-add" <<'EOF'
+#!/bin/sh
+exit 1
+EOF
+ chmod +x "$FAKEBIN/ssh-add"
+
+ run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; printf "sock=%s pid=%s\n" "${SSH_AUTH_SOCK:-}" "${SSH_AGENT_PID:-}"'
+ assert_success
+ assert_output --partial "sock=$HOME/.ssh/agent.sock"
+ assert_output --partial "pid=123"
+ assert_dir_exist "$HOME/.ssh"
+
+ run cat "$LOG"
+ assert_output "-a $HOME/.ssh/agent.sock -s"
+}
+
+@test "ssh-agent module reuses an existing socket without starting a new agent" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ SOCK="$HOME/.ssh/agent.sock"
+ LOG="$TMPDIR/ssh-agent.log"
+ REAL_SSH_AGENT="$(command -v ssh-agent)"
+ mkdir -p "$HOME/.ssh" "$FAKEBIN"
+
+ [[ -n "$REAL_SSH_AGENT" ]] || fail "expected ssh-agent to be available for fixture setup"
+ eval "$("$REAL_SSH_AGENT" -a "$SOCK" -s)" >/dev/null
+ FIXTURE_SSH_AGENT_PID="$SSH_AGENT_PID"
+ [[ -S "$SOCK" ]] || fail "expected reusable socket fixture at $SOCK"
+
+ cat > "$FAKEBIN/ssh-add" <<'EOF'
+#!/bin/sh
+exit 1
+EOF
+ chmod +x "$FAKEBIN/ssh-add"
+
+ cat > "$FAKEBIN/ssh-agent" < "$LOG"
+exit 99
+EOF
+ chmod +x "$FAKEBIN/ssh-agent"
+
+ run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; printf "sock=%s\n" "${SSH_AUTH_SOCK:-}"'
+
+ kill "$FIXTURE_SSH_AGENT_PID" 2>/dev/null || true
+
+ assert_success
+ assert_output --partial "sock=$SOCK"
+
+ if [[ -e "$LOG" ]]; then
+ fail "ssh-agent should not have been invoked when an existing socket was reusable"
+ fi
+}
+
+@test "ssh-agent module only auto-adds default keys once per agent" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ FAKEBIN="$TMPDIR/bin"
+ SOCK="$HOME/.ssh/agent.sock"
+ LOG="$TMPDIR/ssh-add.log"
+ REAL_SSH_AGENT="$(command -v ssh-agent)"
+ mkdir -p "$HOME/.ssh" "$FAKEBIN"
+
+ [[ -n "$REAL_SSH_AGENT" ]] || fail "expected ssh-agent to be available for fixture setup"
+ printf 'rsa\n' > "$HOME/.ssh/id_rsa"
+ printf 'ed25519\n' > "$HOME/.ssh/id_ed25519"
+ eval "$("$REAL_SSH_AGENT" -a "$SOCK" -s)" >/dev/null
+ FIXTURE_SSH_AGENT_PID="$SSH_AGENT_PID"
+ [[ -S "$SOCK" ]] || fail "expected reusable socket fixture at $SOCK"
+
+ cat > "$FAKEBIN/ssh-add" <> "$LOG"
+exit 0
+EOF
+ chmod +x "$FAKEBIN/ssh-add"
+
+ run env -u SSH_AUTH_SOCK -u SSH_AGENT_PID HOME="$HOME" PATH="$FAKEBIN:/usr/bin:/bin" GET_BASHED_SSH_AGENT=1 GET_BASHED_TEST_TTY=1 "$MODERN_BASH" -c 'source bashrc.d/95-ssh-agent.sh; source bashrc.d/95-ssh-agent.sh; printf "sock=%s added_for=%s\n" "${SSH_AUTH_SOCK:-}" "${GET_BASHED_SSH_KEYS_ADDED_FOR:-}"'
+
+ kill "$FIXTURE_SSH_AGENT_PID" 2>/dev/null || true
+
+ assert_success
+ assert_output --partial "sock=$SOCK"
+ assert_output --partial "added_for=$SOCK:"
+
+ run awk 'END { print NR }' "$LOG"
+ assert_success
+ assert_output "2"
+}
diff --git a/tests/supply_chain_verify.bats b/tests/supply_chain_verify.bats
new file mode 100644
index 0000000..3abbc9b
--- /dev/null
+++ b/tests/supply_chain_verify.bats
@@ -0,0 +1,272 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "supply_chain_verify passes against the checked-in repository" {
+ run "$MODERN_BASH" ./scripts/supply_chain_verify.sh
+ assert_success
+ assert_output --partial "All supply chain checks passed"
+}
+
+@test "supply_chain_verify defers default-setup retirement until codeql.yml lands on main" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ args="$*"
+ case "$args" in
+ *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;;
+ *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;;
+ *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;;
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh
+ assert_success
+ assert_output --partial "repo-owned CodeQL workflow is checked into the repository"
+ assert_output --partial "live GitHub default CodeQL setup retirement will be checked after codeql.yml lands on main"
+
+ rm -rf "$tmpdir"
+}
+
+@test "supply_chain_verify fails when main has codeql.yml but default setup is still enabled" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ args="$*"
+ case "$args" in
+ *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;;
+ *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;;
+ *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) printf '{}\n' ;;
+ *'repos/jbcom/get-bashed/code-scanning/default-setup'*'.state'*) printf 'configured\n' ;;
+ *'contents/.github/workflows/codeql.yml?ref='*) printf '{}\n' ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'CodeQL (actions)' \
+ 'CodeQL (python)' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh
+ assert_failure
+ assert_output --partial "live GitHub default CodeQL setup is still enabled after codeql.yml landed on main"
+
+ rm -rf "$tmpdir"
+}
+
+@test "supply_chain_verify fails when secret scanning protections drift" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ args="$*"
+ case "$args" in
+ *'repos/jbcom/get-bashed/automated-security-fixes'*'.enabled'*) printf 'true\n' ;;
+ *'repos/jbcom/get-bashed'*'dependabot_security_updates.status'*) printf 'enabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning.status'*) printf 'disabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_push_protection.status'*) printf 'disabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_validity_checks.status'*) printf 'disabled\n' ;;
+ *'repos/jbcom/get-bashed'*'secret_scanning_non_provider_patterns.status'*) printf 'disabled\n' ;;
+ *'repos/jbcom/get-bashed/vulnerability-alerts'*) printf '{}\n' ;;
+ *'repos/jbcom/get-bashed/contents/release-please-config.json?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/cd.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/release.yml?ref=main'*) exit 1 ;;
+ *'repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;;
+ *'contents/.github/workflows/codeql.yml?ref='*) exit 1 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh
+ assert_failure
+ assert_output --partial "secret scanning is disabled"
+ assert_output --partial "secret scanning push protection is disabled"
+ assert_output --partial "secret scanning validity checks are disabled"
+ assert_output --partial "non-provider secret scanning patterns are disabled"
+
+ rm -rf "$tmpdir"
+}
+
+@test "supply_chain_verify fails when immutable releases stay disabled after draft-first rollout" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ release_config_b64="$(python3 - <<'PY'
+import base64
+payload = '{\n "draft": true,\n "force-tag-creation": true\n}\n'
+print(base64.b64encode(payload.encode()).decode())
+PY
+)"
+ cd_workflow_b64="$(python3 - <<'PY'
+import base64
+payload = '''- id: release
+ uses: googleapis/release-please-action@deadbeef
+ with:
+ token: ${{ secrets.CI_GITHUB_TOKEN || github.token }}
+- if: steps.release.outputs.release_created
+ run: bash scripts/publish_draft_release.sh
+'''
+print(base64.b64encode(payload.encode()).decode())
+PY
+)"
+ release_workflow_b64="$(python3 - <<'PY'
+import base64
+payload = '''on:
+ workflow_dispatch:
+ inputs:
+ publish_release:
+jobs:
+ publish:
+ steps:
+ - run: bash scripts/publish_draft_release.sh
+ - run: bash scripts/verify_published_release.sh
+ - run: bash scripts/publish_pkg_pr.sh
+'''
+print(base64.b64encode(payload.encode()).decode())
+PY
+)"
+
+ cat >"$bindir/gh" </dev/null
+fi
+
load "${BATS_TEST_DIRNAME}/lib/bats-support/load"
load "${BATS_TEST_DIRNAME}/lib/bats-assert/load"
load "${BATS_TEST_DIRNAME}/lib/bats-file/load"
+
+detect_modern_bash() {
+ local candidate version
+ local -a candidates=()
+
+ [[ -n "${GET_BASHED_TEST_BASH:-}" ]] && candidates+=("$GET_BASHED_TEST_BASH")
+ if command -v bash >/dev/null 2>&1; then
+ candidates+=("$(command -v bash)")
+ fi
+ candidates+=("/opt/homebrew/bin/bash" "/usr/local/bin/bash" "/bin/bash")
+
+ for candidate in "${candidates[@]}"; do
+ [[ -n "$candidate" && -x "$candidate" ]] || continue
+ # shellcheck disable=SC2016
+ version="$("$candidate" -c 'printf "%s" "${BASH_VERSINFO[0]}"' 2>/dev/null || true)"
+ [[ "$version" =~ ^[0-9]+$ ]] || continue
+ if (( version >= 4 )); then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+MODERN_BASH="${MODERN_BASH:-$(detect_modern_bash)}"
+export MODERN_BASH
diff --git a/tests/test_setup.bats b/tests/test_setup.bats
new file mode 100644
index 0000000..7deebc4
--- /dev/null
+++ b/tests/test_setup.bats
@@ -0,0 +1,57 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "test-setup realigns an existing helper repo to the pinned sha" {
+ TMPDIR="$(mktemp -d)"
+ REPO="$TMPDIR/repo"
+ DEST="$TMPDIR/lib"
+ mkdir -p "$DEST"
+
+ git init "$REPO" >/dev/null
+ git -C "$REPO" config user.name "Test User"
+ git -C "$REPO" config user.email "test@example.com"
+
+ printf 'first\n' > "$REPO/file.txt"
+ git -C "$REPO" add file.txt
+ git -C "$REPO" commit -m first >/dev/null
+ SHA1="$(git -C "$REPO" rev-parse HEAD)"
+
+ printf 'second\n' > "$REPO/file.txt"
+ git -C "$REPO" commit -am second >/dev/null
+ SHA2="$(git -C "$REPO" rev-parse HEAD)"
+
+ git clone "$REPO" "$DEST/sample" >/dev/null 2>&1
+ git -C "$DEST/sample" checkout "$SHA2" >/dev/null 2>&1
+
+ run env TEST_SETUP_SKIP_MAIN=1 "$MODERN_BASH" -c 'source scripts/test-setup.sh; LIB_DIR="'"$DEST"'"; clone_lib sample "'"$REPO"'" "'"$SHA1"'"'
+ assert_success
+
+ run git -C "$DEST/sample" rev-parse HEAD
+ assert_output "$SHA1"
+}
+
+@test "test-setup waits for an active helper lock instead of clobbering concurrent setup" {
+ TMPDIR="$(mktemp -d)"
+ LIB_ROOT="$TMPDIR/lib"
+ LOCK_ROOT="$LIB_ROOT/.setup.lock"
+ mkdir -p "$LIB_ROOT"
+
+ run env TEST_SETUP_SKIP_MAIN=1 "$MODERN_BASH" -c '
+ source scripts/test-setup.sh
+ LIB_DIR="'"$LIB_ROOT"'"
+ LOCK_DIR="'"$LOCK_ROOT"'"
+ mkdir -p "$LOCK_DIR"
+ printf "%s\n" "$$" > "$LOCK_DIR/pid"
+ (
+ sleep 1
+ rm -rf "$LOCK_DIR"
+ ) &
+ acquire_setup_lock
+ printf "pid=%s lock=%s\n" "$(cat "$LOCK_DIR/pid")" "$LOCK_DIR"
+ release_setup_lock
+ '
+ assert_success
+ assert_output --partial "lock=$LOCK_ROOT"
+ assert_dir_not_exist "$LOCK_ROOT"
+}
diff --git a/tests/workflow_permissions.bats b/tests/workflow_permissions.bats
new file mode 100644
index 0000000..596fd88
--- /dev/null
+++ b/tests/workflow_permissions.bats
@@ -0,0 +1,28 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "workflows declare top-level least-privilege permissions" {
+ for workflow in \
+ .github/workflows/ci.yml \
+ .github/workflows/codeql.yml \
+ .github/workflows/cd.yml \
+ .github/workflows/release.yml \
+ .github/workflows/scorecard.yml \
+ .github/workflows/automerge.yml
+ do
+ run grep -F 'permissions: {}' "$workflow"
+ assert_success
+ done
+}
+
+@test "mutable GitHub scopes are granted at the job level where needed" {
+ run grep -F 'pull-requests: write' .github/workflows/cd.yml .github/workflows/automerge.yml
+ assert_success
+
+ run grep -F 'pages: write' .github/workflows/cd.yml
+ assert_success
+
+ run grep -F 'security-events: write' .github/workflows/codeql.yml .github/workflows/scorecard.yml
+ assert_success
+}
diff --git a/tox.ini b/tox.ini
index ff6e435..1b3ef3e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,13 +1,23 @@
[tox]
-envlist = docs
+requires = tox>=4.21
+env_list = docs, docs-linkcheck
skipsdist = True
-[testenv:docs]
-description = Build Sphinx documentation
+[testenv]
deps =
myst-parser>=4.0.1
shibuya>=2024.5.15
sphinx>=8.2.3
sphinxcontrib-mermaid>=1.0.0
+
+[testenv:docs]
+description = Build Sphinx documentation
+deps = {[testenv]deps}
commands =
sphinx-build docs/ docs/_build/html -b html
+
+[testenv:docs-linkcheck]
+description = Validate outbound documentation links
+deps = {[testenv]deps}
+commands =
+ sphinx-build docs/ docs/_build/linkcheck -b linkcheck
From fb532c9e09c8cd27c6685afe54a48c564d5ad015 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 15:54:33 -0500
Subject: [PATCH 2/7] fix: keep bootstrap bash path clean
---
install.sh | 18 +++++++-----
tests/bootstrap.bats | 69 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 7 deletions(-)
diff --git a/install.sh b/install.sh
index e9a541d..6355c0a 100755
--- a/install.sh
+++ b/install.sh
@@ -159,6 +159,10 @@ bootstrap_homebrew() {
rm -rf "$tmpdir"
}
+run_install_command() {
+ "$@" >&2
+}
+
ensure_modern_bash() {
brew_bin="$(find_brew_bin 2>/dev/null || true)"
@@ -168,23 +172,23 @@ ensure_modern_bash() {
fi
if [ -n "$brew_bin" ]; then
- "$brew_bin" install bash
+ run_install_command "$brew_bin" install bash
elif [ -n "$GET_BASHED_BOOTSTRAP_BREW_URL" ]; then
bootstrap_homebrew
brew_bin="$(find_brew_bin 2>/dev/null || true)"
if [ -z "$brew_bin" ]; then
fail "Bash 4+ is required, and Homebrew bootstrap did not produce a brew executable."
fi
- "$brew_bin" install bash
+ run_install_command "$brew_bin" install bash
elif command -v apt-get >/dev/null 2>&1; then
- sudo apt-get update -y
- sudo apt-get install -y bash
+ run_install_command sudo apt-get update -y
+ run_install_command sudo apt-get install -y bash
elif command -v dnf >/dev/null 2>&1; then
- sudo dnf install -y bash
+ run_install_command sudo dnf install -y bash
elif command -v yum >/dev/null 2>&1; then
- sudo yum install -y bash
+ run_install_command sudo yum install -y bash
elif command -v pacman >/dev/null 2>&1; then
- sudo pacman -Sy --noconfirm bash
+ run_install_command sudo pacman -Sy --noconfirm bash
else
fail "Bash 4+ is required but no supported installer was found."
fi
diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats
index 32acdfb..22fe707 100644
--- a/tests/bootstrap.bats
+++ b/tests/bootstrap.bats
@@ -91,6 +91,75 @@ EOF
assert_output "install bash"
}
+@test "install.sh ignores package-manager stdout while resolving the bootstrap bash path" {
+ [[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform"
+
+ TMPDIR="$(mktemp -d)"
+ FAKEBIN="$TMPDIR/bin"
+ INSTALLER_PAYLOAD="$TMPDIR/homebrew-install.sh"
+ MARKER="$TMPDIR/install-bash-ran"
+ LOG="$TMPDIR/bootstrap.log"
+ mkdir -p "$FAKEBIN"
+
+ cat > "$FAKEBIN/bash" <<'EOF'
+#!/bin/sh
+if [ "$1" = "-c" ]; then
+ printf '3'
+ exit 0
+fi
+exit 99
+EOF
+ chmod +x "$FAKEBIN/bash"
+
+ cat > "$INSTALLER_PAYLOAD" < "$FAKEBIN/brew" <<'BREW'
+#!/bin/sh
+printf 'BREW STDOUT NOISE\n'
+printf '%s\n' "\$*" >> "$LOG"
+if [ "\$1" = "install" ] && [ "\$2" = "bash" ]; then
+ cat > "$FAKEBIN/bash" <<'MODERN'
+#!/bin/sh
+if [ "\$1" = "-c" ]; then
+ printf '5'
+ exit 0
+fi
+printf 'repo bootstrap ok\n' > "$MARKER"
+exec "$MODERN_BASH" "\$@"
+MODERN
+ chmod +x "$FAKEBIN/bash"
+ exit 0
+fi
+exit 1
+BREW
+chmod +x "$FAKEBIN/brew"
+EOF
+ chmod +x "$INSTALLER_PAYLOAD"
+
+ cat > "$FAKEBIN/curl" <
Date: Thu, 16 Apr 2026 16:20:03 -0500
Subject: [PATCH 3/7] fix: address review hardening feedback
---
README.md | 6 +-
bashrc.d/99-secrets.sh | 34 ++++++-
docs/MODULES.md | 4 +-
docs/TESTING.md | 2 +-
docs/reference/release-verification.md | 2 +-
docs/reference/security.md | 2 +-
docs/reference/supply-chain.md | 2 +-
install.sh | 66 +++++++++++++-
installers/bootstrap_sources.sh | 10 +-
installers/lib/asdf.sh | 2 +
installers/lib/core.sh | 2 +
installers/lib/installers.sh | 2 +
installers/lib/languages.sh | 2 +
installers/lib/packages.sh | 2 +
installers/lib/system.sh | 2 +
installers/lib/tool_runner.sh | 2 +
installers/sources.sh | 2 +-
installlib/config.sh | 4 +
installlib/filesystem.sh | 2 +
installlib/installers.sh | 2 +
installlib/managed_files.sh | 14 ++-
installlib/resolve.sh | 4 +
installlib/runtime_files.sh | 2 +
installlib/ui.sh | 7 +-
scripts/generate_pkg_manifests.sh | 2 +-
scripts/lib/supply_chain_common.sh | 42 +++++++++
scripts/publish_pkg_pr.sh | 8 +-
.../reconcile_immutable_release_governance.sh | 2 +-
scripts/release_validate.sh | 51 ++---------
scripts/supply_chain_verify.sh | 85 ++++++++---------
.../verify_immutable_release_governance.sh | 2 +-
tests/bootstrap.bats | 72 ++++++++++++++-
tests/brew_runtime.bats | 1 +
tests/docs_contract.bats | 1 +
tests/interactive_features.bats | 1 +
tests/release_pipeline.bats | 91 ++++++++++++++-----
tests/runtime_modules.bats | 19 ++++
tests/supply_chain_verify.bats | 51 +++++++++++
tests/test_setup.bats | 1 +
39 files changed, 472 insertions(+), 136 deletions(-)
create mode 100644 scripts/lib/supply_chain_common.sh
diff --git a/README.md b/README.md
index 029f9fc..9809628 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ If you are working from a checkout instead of a published release, the source-tr
sh install.sh
```
-The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision before execing `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer first and then installs Bash through Homebrew.
+The source-tree bootstrap installs or locates Bash 4+ automatically before handing off to the full installer. When the repo-root `install.sh` is run as a standalone downloaded file, it fetches the repo tree pinned to that bootstrap revision, verifies the pinned archive checksum, and then execs `install.bash`. On a fresh macOS machine without Homebrew, it bootstraps Homebrew from the repo-pinned installer, verifies that installer checksum, and then installs Bash through Homebrew.
To symlink shell dotfiles and set git identity:
@@ -162,7 +162,7 @@ Package-manager install paths:
## Runtime and secrets
-Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh`.
+Modules in `bashrc.d/` load in numeric order. Local secrets live in `~/.get-bashed/secrets.d/*.sh` and are sourced by `bashrc.d/99-secrets.sh` only when those files use owner-only permissions.
Doppler is explicit only: enabling `doppler_env` exposes `doppler_shell`, but startup never fetches or injects Doppler secrets automatically.
@@ -199,7 +199,7 @@ make smoke-release
make release-validate
```
-`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, including the docs installer fallback downloader path.
+`make lint` uses the same bootstrap path as CI. `make test` bootstraps `bats`, fetches pinned BATS helpers, and runs install verification. `make docs` bootstraps both `shdoc` and `uv`, regenerates installer docs, validates the docs contract, and builds the Sphinx site. `make docs-check` adds Sphinx link checking so outbound docs links are exercised locally and in CI. `make verify-security` runs the checked-in supply-chain verifier across workflow pinning, explicit top-level workflow permission lockdown, pinned download sources, repo-owned CodeQL/Scorecard presence, draft-first release publication wiring, docs link validation wiring, immutable-release governance, and branch-protection verification availability. `make package-release`, `make smoke-release`, and `make release-validate` exercise the checked-in release pipeline locally, while the release-pipeline BATS suite also forces the docs installer through its supported `wget` fallback path.
The shared bootstrap disables Homebrew auto-update during these tool installs so local runs and CI stay deterministic.
`make verify-branch-protection` is the authenticated governance check for the live `main` branch policy; it verifies the exact required CI contexts plus the enforced review, code owner, and branch-safety settings instead of relying on stale prose about workflow files. Once `.github/workflows/codeql.yml` lands on `main`, it also expects `CodeQL (actions)` and `CodeQL (python)` to become required branch checks.
`make reconcile-codeql-governance` is the one-time post-merge cutover for that transition: it retires GitHub default CodeQL setup and patches the live required status-check list to include the repo-owned CodeQL jobs after `codeql.yml` is on `main`.
diff --git a/bashrc.d/99-secrets.sh b/bashrc.d/99-secrets.sh
index 01144fa..e4d99a9 100644
--- a/bashrc.d/99-secrets.sh
+++ b/bashrc.d/99-secrets.sh
@@ -9,8 +9,40 @@
GET_BASHED_HOME="${GET_BASHED_HOME:-$HOME/.get-bashed}"
GET_BASHED_SECRETS_DIR="${GET_BASHED_SECRETS_DIR:-$GET_BASHED_HOME/secrets.d}"
+get_bashed_secret_mode() {
+ local file="$1"
+
+ if stat -c '%a' "$file" >/dev/null 2>&1; then
+ stat -c '%a' "$file"
+ return 0
+ fi
+ if stat -f '%Lp' "$file" >/dev/null 2>&1; then
+ stat -f '%Lp' "$file"
+ return 0
+ fi
+
+ return 1
+}
+
+get_bashed_secret_is_private() {
+ local file="$1"
+ local mode_raw
+ local mode
+
+ mode_raw="$(get_bashed_secret_mode "$file" || true)"
+ [[ "$mode_raw" =~ ^[0-7]{3,4}$ ]] || return 1
+ mode=$((8#$mode_raw))
+ (( (mode & 077) == 0 ))
+}
+
if [[ -d "$GET_BASHED_SECRETS_DIR" ]]; then
for f in "$GET_BASHED_SECRETS_DIR"/*.sh; do
- [[ -r "$f" ]] && source "$f"
+ [[ -e "$f" ]] || continue
+ [[ -f "$f" && -r "$f" ]] || continue
+ if get_bashed_secret_is_private "$f"; then
+ source "$f"
+ else
+ printf 'get-bashed: skipping %s; require owner-only permissions on secret files\n' "$f" >&2
+ fi
done
fi
diff --git a/docs/MODULES.md b/docs/MODULES.md
index cf3fc59..835a637 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -24,10 +24,10 @@ This document is maintained manually because the runtime modules are better desc
| `80-aliases` | Common aliases | none | aliases only |
| `90-functions` | Shell helper functions | none | defines functions only |
| `95-ssh-agent` | Optional ssh-agent bootstrap | `GET_BASHED_SSH_AGENT` | starts/reuses ssh-agent |
-| `99-secrets` | Local secrets snippets | none | sources `~/.get-bashed/secrets.d/*.sh` |
+| `99-secrets` | Local secrets snippets | none | sources owner-only `~/.get-bashed/secrets.d/*.sh` |
## Notes
-- The only secret-loading path is `99-secrets`.
+- The only secret-loading path is `99-secrets`, and it skips secret files that are readable by group or other users.
- `doppler_env` does not inject secrets at startup.
- `auto_tools` remains opt-in, intentionally narrow, and checks pinned npm package state before installing.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 971a103..9c41ff1 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -49,7 +49,7 @@ The docs targets now bootstrap both `shdoc` and `uv` so `uvx` is available even
`make verify-security` runs the repo-owned supply-chain verifier across workflow SHA pinning, the checked-in `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, Dependabot/security-fix posture, secret scanning posture, draft-first release publication wiring, immutable-release governance, docs-link wiring, and branch-protection verification availability. Once `codeql.yml` lands on `main`, that same verifier expects GitHub default CodeQL setup to be turned off.
-`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. That checked-in validation now forces the installer through both its default downloader path and its supported `wget` fallback path.
+`make release-validate` builds the Unix and Windows release bundles, validates their checksums and archive contents, generates the Homebrew/Scoop/Chocolatey manifests, and exercises `docs/public/install.sh` against a local HTTP server backed by the built release archives. The release-pipeline BATS coverage separately forces the installer through its supported `wget` fallback path so the non-default downloader surface also stays exercised.
`make verify-branch-protection` is a separate authenticated governance check. It uses `gh api` to verify that GitHub branch protection for `main` still requires the exact CI job names this repo depends on and still enforces the expected review, code owner, and branch-safety settings.
`make reconcile-codeql-governance` is the one-time authenticated cutover for moving live `main` from GitHub default CodeQL setup to the checked-in `.github/workflows/codeql.yml` path once that workflow has landed on `main`.
diff --git a/docs/reference/release-verification.md b/docs/reference/release-verification.md
index 6fd3f71..0d0d44b 100644
--- a/docs/reference/release-verification.md
+++ b/docs/reference/release-verification.md
@@ -24,7 +24,7 @@ make release-validate
`make smoke-release` checks the Unix installer path plus the Windows wrapper bundle structure.
-`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. That validation now exercises both the default downloader path and the supported `wget` fallback path so the documented downloader surface stays real.
+`make release-validate` verifies the archive checksums, generated package manifests, and the docs-site `install.sh` path against a local HTTP server backed by the built artifacts. The release-pipeline BATS suite separately forces the installer through the supported `wget` fallback path so the documented downloader surface stays real without making the checked-in release validator depend on a downloader shim.
The checked-in GitHub workflow follows the same draft-first order GitHub recommends for immutable releases: create a draft, attach the validated assets, then publish it.
## Verify A Published Release
diff --git a/docs/reference/security.md b/docs/reference/security.md
index 7cb8f04..e9dd4bb 100644
--- a/docs/reference/security.md
+++ b/docs/reference/security.md
@@ -35,7 +35,7 @@ The repo’s security-oriented checks now include:
- `make reconcile-immutable-release-governance`
- `make release-validate`
-`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, pinned installer download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow.
+`make verify-security` is the repo-owned supply-chain gate. It checks workflow SHA pinning, explicit top-level workflow permission lockdown, the repo-owned `codeql.yml` and `scorecard.yml` workflows, checksum-verified bootstrap download sources, checked-in Dependabot config, live vulnerability-alert/security-fix settings, secret scanning, secret scanning push protection, validity checks, non-provider secret patterns, draft-first release/publication wiring, immutable-release governance, docs-link validation wiring, and branch-protection verification availability when `gh` auth is available. Once `codeql.yml` lands on `main`, the same verifier expects GitHub default CodeQL setup to be retired in favor of the checked-in workflow.
`make verify-branch-protection` is the authenticated live-policy check for the `main` branch itself.
`make reconcile-codeql-governance` is the repo-owned post-merge cutover for that change; it retires GitHub default CodeQL setup and patches the live branch required-check list to include the repo-owned CodeQL jobs.
`make verify-immutable-release-governance` is the authenticated live-policy check for GitHub immutable releases once the draft-first release flow is on `main`.
diff --git a/docs/reference/supply-chain.md b/docs/reference/supply-chain.md
index 2566d5e..c2d8dbc 100644
--- a/docs/reference/supply-chain.md
+++ b/docs/reference/supply-chain.md
@@ -8,7 +8,7 @@ status: current
`get-bashed` is intentionally pinned at every external boundary that it controls:
-- bootstrap Homebrew download sources live in `installers/bootstrap_sources.sh`
+- bootstrap Homebrew download sources and SHA-256 checksums live in `installers/bootstrap_sources.sh`
- git and curl fallbacks live in `installers/sources.sh`
- `asdf` default runtime versions are pinned in `installers/sources.sh`
- release packaging is driven by checked-in scripts rather than ad hoc archive commands
diff --git a/install.sh b/install.sh
index 6355c0a..ef319e0 100755
--- a/install.sh
+++ b/install.sh
@@ -10,8 +10,10 @@ if [ -r "$BOOTSTRAP_SOURCES_FILE" ]; then
fi
: "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}"
+: "${GET_BASHED_BOOTSTRAP_BREW_SHA256:=dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91}"
: "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}"
: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}"
+: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:=87aeecd8e12143ccb019cec367df9923b56977c6730df578384795c0d688a630}"
fail() {
printf '%s\n' "$*" >&2
@@ -44,13 +46,26 @@ is_modern_bash() {
find_modern_bash() {
candidates="${GET_BASHED_BOOTSTRAP_BASH_CANDIDATES:-/opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash}"
-
- for candidate in $candidates; do
+ had_noglob=0
+ old_ifs=$IFS
+
+ case "$-" in
+ *f*) had_noglob=1 ;;
+ esac
+ IFS=' '
+ set -f
+ # shellcheck disable=SC2086
+ set -- $candidates
+ IFS=$old_ifs
+
+ for candidate in "$@"; do
if is_modern_bash "$candidate"; then
+ [ "$had_noglob" -eq 1 ] || set +f
printf '%s\n' "$candidate"
return 0
fi
done
+ [ "$had_noglob" -eq 1 ] || set +f
path_bash=""
if command -v bash >/dev/null 2>&1; then
@@ -66,18 +81,31 @@ find_modern_bash() {
find_brew_bin() {
candidates="${GET_BASHED_BOOTSTRAP_BREW_CANDIDATES:-/opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew}"
+ had_noglob=0
+ old_ifs=$IFS
if command -v brew >/dev/null 2>&1; then
command -v brew
return 0
fi
- for candidate in $candidates; do
+ case "$-" in
+ *f*) had_noglob=1 ;;
+ esac
+ IFS=' '
+ set -f
+ # shellcheck disable=SC2086
+ set -- $candidates
+ IFS=$old_ifs
+
+ for candidate in "$@"; do
if [ -x "$candidate" ]; then
+ [ "$had_noglob" -eq 1 ] || set +f
printf '%s\n' "$candidate"
return 0
fi
done
+ [ "$had_noglob" -eq 1 ] || set +f
return 1
}
@@ -98,6 +126,36 @@ download_bootstrap_asset() {
return 1
}
+sha256_file() {
+ file="$1"
+
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$file" | awk '{print $1}'
+ return 0
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$file" | awk '{print $1}'
+ return 0
+ fi
+ if command -v openssl >/dev/null 2>&1; then
+ openssl dgst -sha256 -r "$file" | awk '{print $1}'
+ return 0
+ fi
+
+ return 1
+}
+
+verify_bootstrap_asset() {
+ file="$1"
+ expected_sha="$2"
+ label="$3"
+
+ [ -n "$expected_sha" ] || return 0
+ actual_sha="$(sha256_file "$file" || true)"
+ [ -n "$actual_sha" ] || fail "Could not verify ${label}: no SHA-256 tool is available."
+ [ "$actual_sha" = "$expected_sha" ] || fail "${label} checksum verification failed."
+}
+
bootstrap_repo_tree() {
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t get-bashed)"
archive="$tmpdir/get-bashed.tar.gz"
@@ -108,6 +166,7 @@ bootstrap_repo_tree() {
rm -rf "$tmpdir"
fail "Standalone bootstrap requires curl or wget to fetch the get-bashed sources."
fi
+ verify_bootstrap_asset "$archive" "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:-}" "get-bashed source archive"
mkdir -p "$extract_dir"
if ! tar -xzf "$archive" -C "$extract_dir"; then
@@ -149,6 +208,7 @@ bootstrap_homebrew() {
rm -rf "$tmpdir"
fail "Homebrew bootstrap requires curl or wget."
fi
+ verify_bootstrap_asset "$installer" "${GET_BASHED_BOOTSTRAP_BREW_SHA256:-}" "Homebrew installer"
if [ "${CI:-}" = "1" ] || [ ! -t 0 ]; then
NONINTERACTIVE=1 "$GET_BASHED_BOOTSTRAP_BREW_CMD" "$installer"
diff --git a/installers/bootstrap_sources.sh b/installers/bootstrap_sources.sh
index c90f5b6..b834b32 100644
--- a/installers/bootstrap_sources.sh
+++ b/installers/bootstrap_sources.sh
@@ -1,5 +1,9 @@
#!/bin/sh
-GET_BASHED_BOOTSTRAP_BREW_URL='https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh'
-GET_BASHED_BOOTSTRAP_BREW_CMD='/bin/bash'
-GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL='https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz'
+# shellcheck disable=SC2034
+
+: "${GET_BASHED_BOOTSTRAP_BREW_URL:=https://raw.githubusercontent.com/Homebrew/install/de0b0bddf1c78731dcd16d953b2f5d29d070e229/install.sh}"
+: "${GET_BASHED_BOOTSTRAP_BREW_SHA256:=dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91}"
+: "${GET_BASHED_BOOTSTRAP_BREW_CMD:=/bin/bash}"
+: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_URL:=https://github.com/jbcom/get-bashed/archive/22eff2b26037a7db4548e3996e587173cf2aa053.tar.gz}"
+: "${GET_BASHED_BOOTSTRAP_REPO_ARCHIVE_SHA256:=87aeecd8e12143ccb019cec367df9923b56977c6730df578384795c0d688a630}"
diff --git a/installers/lib/asdf.sh b/installers/lib/asdf.sh
index 8445548..dbe9389 100644
--- a/installers/lib/asdf.sh
+++ b/installers/lib/asdf.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @description Check if an asdf plugin is installed.
# @arg $1 string Plugin name.
# @exitcode 0 If installed.
diff --git a/installers/lib/core.sh b/installers/lib/core.sh
index d3e843c..fec4b01 100644
--- a/installers/lib/core.sh
+++ b/installers/lib/core.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# shellcheck disable=SC1091
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/system.sh"
# shellcheck disable=SC1091
diff --git a/installers/lib/installers.sh b/installers/lib/installers.sh
index b503b0f..9327ac1 100644
--- a/installers/lib/installers.sh
+++ b/installers/lib/installers.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @description Install GNU tools (handler).
install_gnu_tools() {
if _using_brew; then
diff --git a/installers/lib/languages.sh b/installers/lib/languages.sh
index cd76749..12c0c4f 100644
--- a/installers/lib/languages.sh
+++ b/installers/lib/languages.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @description Install asdf (handler).
install_asdf() {
if _using_asdf; then
diff --git a/installers/lib/packages.sh b/installers/lib/packages.sh
index 9484073..f51cfb8 100644
--- a/installers/lib/packages.sh
+++ b/installers/lib/packages.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @description Run a command with auto-approval when configured.
# @arg $1 string Command name.
# @arg $2 string Optional flag to auto-approve (e.g., -y, --noconfirm).
diff --git a/installers/lib/system.sh b/installers/lib/system.sh
index 70fa231..0ce8702 100644
--- a/installers/lib/system.sh
+++ b/installers/lib/system.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @internal
_using_asdf() { command -v asdf >/dev/null 2>&1; }
diff --git a/installers/lib/tool_runner.sh b/installers/lib/tool_runner.sh
index fde1fa8..e73d3dc 100644
--- a/installers/lib/tool_runner.sh
+++ b/installers/lib/tool_runner.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# @description Install a component using available methods.
# @arg $1 string Action (enable|disable|install).
# @arg $2 string Term to resolve/install.
diff --git a/installers/sources.sh b/installers/sources.sh
index 4e23c20..9506615 100644
--- a/installers/sources.sh
+++ b/installers/sources.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-# shellcheck disable=SC1091
+# shellcheck disable=SC1091,SC2034
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap_sources.sh"
declare -gA GET_BASHED_GIT_SOURCES=()
diff --git a/installlib/config.sh b/installlib/config.sh
index 5e10370..165713b 100644
--- a/installlib/config.sh
+++ b/installlib/config.sh
@@ -1,3 +1,7 @@
+#!/usr/bin/env bash
+
+# shellcheck disable=SC2034
+
# @description Print usage help.
# @noargs
usage() {
diff --git a/installlib/filesystem.sh b/installlib/filesystem.sh
index cc83612..98d0c95 100644
--- a/installlib/filesystem.sh
+++ b/installlib/filesystem.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
# shellcheck disable=SC1091
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/managed_files.sh"
# shellcheck disable=SC1091
diff --git a/installlib/installers.sh b/installlib/installers.sh
index 0f27f9a..b3e0523 100644
--- a/installlib/installers.sh
+++ b/installlib/installers.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
get_deps() {
local id="$1"
local deps opt flag dep
diff --git a/installlib/managed_files.sh b/installlib/managed_files.sh
index 9e1b664..50510e8 100644
--- a/installlib/managed_files.sh
+++ b/installlib/managed_files.sh
@@ -1,3 +1,7 @@
+#!/usr/bin/env bash
+
+# shellcheck disable=SC2178
+
ensure_block() {
local file="$1"
local marker="$2"
@@ -18,7 +22,7 @@ ensure_block() {
backup_file() {
local file="$1"
local backup_dir="$PREFIX/backup"
- local base ts
+ local base ts backup_path suffix
[[ -e "$file" ]] || return 0
@@ -27,7 +31,13 @@ backup_file() {
base="$(basename "$file")"
base="${base#.}"
ts="$(date +%s)"
- mv "$file" "$backup_dir/${base}.${ts}"
+ backup_path="$backup_dir/${base}.${ts}"
+ suffix=0
+ while [[ -e "$backup_path" ]]; do
+ suffix=$((suffix + 1))
+ backup_path="$backup_dir/${base}.${ts}.${suffix}"
+ done
+ mv "$file" "$backup_path"
}
link_dotfile() {
diff --git a/installlib/resolve.sh b/installlib/resolve.sh
index 40fe762..848456d 100644
--- a/installlib/resolve.sh
+++ b/installlib/resolve.sh
@@ -1,3 +1,7 @@
+#!/usr/bin/env bash
+
+# shellcheck disable=SC2153
+
# @description Apply a built-in profile.
# @arg $1 string Profile name.
# @exitcode 0 If applied.
diff --git a/installlib/runtime_files.sh b/installlib/runtime_files.sh
index 5d5a61c..f312898 100644
--- a/installlib/runtime_files.sh
+++ b/installlib/runtime_files.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
apply_gitconfig() {
local cfg="$PREFIX/gitconfig"
diff --git a/installlib/ui.sh b/installlib/ui.sh
index dcca50b..76c03cc 100644
--- a/installlib/ui.sh
+++ b/installlib/ui.sh
@@ -1,3 +1,7 @@
+#!/usr/bin/env bash
+
+# shellcheck disable=SC2153
+
install_dialog() {
if command -v dialog >/dev/null 2>&1; then
return 0
@@ -21,7 +25,8 @@ prompt_yes_no() {
local prompt='[y/N]'
if [[ "$YES" -eq 1 ]]; then
- return 0
+ [[ "$default" -eq 1 ]]
+ return
fi
if [[ "$default" -eq 1 ]]; then
diff --git a/scripts/generate_pkg_manifests.sh b/scripts/generate_pkg_manifests.sh
index bc511f0..92360ee 100755
--- a/scripts/generate_pkg_manifests.sh
+++ b/scripts/generate_pkg_manifests.sh
@@ -15,7 +15,7 @@ WINDOWS_ARCHIVE="get-bashed-${VERSION}-windows.zip"
sha_of() {
local name="$1"
local sha
- sha="$(grep " ${name}\$" "$CHECKSUMS" | awk '{print $1}')"
+ sha="$(awk -v name="$name" '$2 == name {print $1}' "$CHECKSUMS")"
if [ -z "$sha" ]; then
echo "missing checksum for ${name}" >&2
exit 1
diff --git a/scripts/lib/supply_chain_common.sh b/scripts/lib/supply_chain_common.sh
new file mode 100644
index 0000000..1329eaf
--- /dev/null
+++ b/scripts/lib/supply_chain_common.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+has_regex() {
+ local pattern="$1"
+ shift
+ grep -Eq "$pattern" "$@"
+}
+
+resolve_repo_slug() {
+ local remote_url
+ local repo_slug
+
+ if [ -n "${GET_BASHED_REPO_SLUG:-}" ]; then
+ printf '%s\n' "$GET_BASHED_REPO_SLUG"
+ return 0
+ fi
+
+ if command -v git >/dev/null 2>&1; then
+ remote_url="$(git -C "$REPO_ROOT" config --get remote.origin.url 2>/dev/null || true)"
+ if [ -n "$remote_url" ]; then
+ repo_slug="${remote_url#git@github.com:}"
+ repo_slug="${repo_slug#https://github.com/}"
+ repo_slug="${repo_slug#ssh://git@github.com/}"
+ repo_slug="${repo_slug#git://github.com/}"
+ repo_slug="${repo_slug%.git}"
+ if [[ "$repo_slug" == */* ]]; then
+ printf '%s\n' "$repo_slug"
+ return 0
+ fi
+ fi
+ fi
+
+ if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
+ repo_slug="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)"
+ if [ -n "$repo_slug" ]; then
+ printf '%s\n' "$repo_slug"
+ return 0
+ fi
+ fi
+
+ printf '%s\n' 'jbcom/get-bashed'
+}
diff --git a/scripts/publish_pkg_pr.sh b/scripts/publish_pkg_pr.sh
index cd4f84c..b3dfc0b 100755
--- a/scripts/publish_pkg_pr.sh
+++ b/scripts/publish_pkg_pr.sh
@@ -20,8 +20,6 @@ if ! command -v gh >/dev/null 2>&1; then
exit 1
fi
-TARGET_REPO_URL="${TARGET_REPO_URL:-https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git}"
-
resolve_manifest_dir() {
local candidate="$1"
if [ -f "$candidate/get-bashed.rb" ] && [ -f "$candidate/get-bashed.json" ]; then
@@ -44,7 +42,11 @@ cleanup() {
}
trap cleanup EXIT
-git clone "$TARGET_REPO_URL" "$tmpdir/pkgs"
+if [ -n "${TARGET_REPO_URL:-}" ]; then
+ git clone "$TARGET_REPO_URL" "$tmpdir/pkgs"
+else
+ gh repo clone "$TARGET_REPO" "$tmpdir/pkgs"
+fi
cd "$tmpdir/pkgs"
branch="get-bashed/bump-${VERSION}"
diff --git a/scripts/reconcile_immutable_release_governance.sh b/scripts/reconcile_immutable_release_governance.sh
index ef5129c..baff671 100644
--- a/scripts/reconcile_immutable_release_governance.sh
+++ b/scripts/reconcile_immutable_release_governance.sh
@@ -3,7 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=scripts/lib/immutable_release_flow.sh
+# shellcheck disable=SC1090,SC1091
. "$SCRIPT_DIR/lib/immutable_release_flow.sh"
REPO="${1:-jbcom/get-bashed}"
diff --git a/scripts/release_validate.sh b/scripts/release_validate.sh
index 779ad37..1ea6b9a 100755
--- a/scripts/release_validate.sh
+++ b/scripts/release_validate.sh
@@ -28,8 +28,7 @@ PY
wait_for_http() {
local url="$1"
- local attempt
- for attempt in $(seq 1 50); do
+ for _ in $(seq 1 50); do
if "$PYTHON_BIN" - "$url" <<'PY' >/dev/null 2>&1
from urllib.request import urlopen
import sys
@@ -107,39 +106,6 @@ PY
test -f "$destination/get-bashed.ps1"
}
-install_fake_wget() {
- local bindir="$1"
- local python_bin="$2"
-
- cat >"$bindir/fake_wget.py" <<'PY'
-from pathlib import Path
-from urllib.request import urlopen
-import sys
-
-argv = sys.argv[1:]
-if len(argv) == 2 and argv[0] == "-qO-":
- with urlopen(argv[1], timeout=10) as response:
- sys.stdout.buffer.write(response.read())
- raise SystemExit(0)
-
-if len(argv) == 3 and argv[0] == "-qO":
- target = Path(argv[1])
- target.parent.mkdir(parents=True, exist_ok=True)
- with urlopen(argv[2], timeout=10) as response:
- target.write_bytes(response.read())
- raise SystemExit(0)
-
-raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}")
-PY
-
- cat >"$bindir/wget" </dev/null 2>&1; then
@@ -191,7 +157,6 @@ latest_home="$(mktemp -d)"
brew_stage="$(mktemp -d)"
brew_prefix="$(mktemp -d)"
windows_stage="$(mktemp -d)"
-wget_bin="$(mktemp -d)"
server_log="$DIST_DIR/http-server.log"
port="$(pick_port)"
cleanup() {
@@ -200,22 +165,22 @@ cleanup() {
rm -rf "$brew_stage"
rm -rf "$brew_prefix"
rm -rf "$windows_stage"
- rm -rf "$wget_bin"
kill "${server_pid:-0}" 2>/dev/null || true
}
trap cleanup EXIT
-"$PYTHON_BIN" -m http.server "$port" --directory "$DIST_DIR" >"$server_log" 2>&1 &
-server_pid=$!
-wait_for_http "http://127.0.0.1:${port}/checksums.txt"
-
mkdir -p "$install_home/home"
mkdir -p "$latest_home/home"
-install_fake_wget "$wget_bin" "$PYTHON_BIN"
cat >"$DIST_DIR/latest.json" <"$server_log" 2>&1 &
+server_pid=$!
+wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+wait_for_http "http://127.0.0.1:${port}/${UNIX_ARCHIVE}"
+wait_for_http "http://127.0.0.1:${port}/latest.json"
+
HOME="$install_home/home" \
GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \
GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
@@ -224,8 +189,6 @@ GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
test -f "$install_home/home/.get-bashed/get-bashedrc.sh"
HOME="$latest_home/home" \
-PATH="$wget_bin:$PATH" \
-GET_BASHED_DOWNLOAD_TOOL="wget" \
GET_BASHED_RELEASE_METADATA_URL="http://127.0.0.1:${port}/latest.json" \
GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \
GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh
index b3936e4..9c78057 100644
--- a/scripts/supply_chain_verify.sh
+++ b/scripts/supply_chain_verify.sh
@@ -8,6 +8,8 @@ NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+# shellcheck disable=SC1091
+source "$SCRIPT_DIR/lib/supply_chain_common.sh"
failed=0
@@ -27,7 +29,7 @@ echo
workflow_dir="$REPO_ROOT/.github/workflows"
if [ -d "$workflow_dir" ]; then
external_uses=$(grep -rE '^[[:space:]]*uses:' "$workflow_dir"/*.yml | grep -v 'uses:[[:space:]]\+\./' || true)
- if [ -n "$external_uses" ] && ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then
+ if [ -z "$external_uses" ] || ! printf '%s\n' "$external_uses" | grep -vE '@[a-f0-9]{40}' >/dev/null; then
pass "external GitHub Actions are SHA-pinned"
else
fail "one or more external GitHub Actions are not SHA-pinned"
@@ -47,7 +49,7 @@ permission_locked_workflows=(
workflow_permissions_ok="true"
for workflow in "${permission_locked_workflows[@]}"; do
- if ! rg -q '^permissions: \{\}$' "$workflow"; then
+ if ! has_regex '^permissions: \{\}$' "$workflow"; then
workflow_permissions_ok="false"
break
fi
@@ -61,7 +63,7 @@ fi
if [ -f "$REPO_ROOT/installers/bootstrap_sources.sh" ] \
&& [ -f "$REPO_ROOT/installers/sources.sh" ] \
- && ! rg -q 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \
+ && ! has_regex 'archive/refs/heads/.+\.tar\.gz|raw\.githubusercontent\.com/.+/HEAD/' \
"$REPO_ROOT/install.sh" \
"$REPO_ROOT/installers/bootstrap_sources.sh" \
"$REPO_ROOT/installers/sources.sh"; then
@@ -70,8 +72,8 @@ else
fail "bootstrap or fallback download sources are not fully pinned"
fi
-if rg -q 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \
- && rg -q 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then
+if has_regex 'GET_BASHED_ACTIONLINT_SHA256\["linux_amd64"\]' "$REPO_ROOT/installers/sources.sh" \
+ && has_regex 'GET_BASHED_ACTIONLINT_SHA256\["darwin_arm64"\]' "$REPO_ROOT/installers/sources.sh"; then
pass "actionlint fallback includes pinned per-platform checksums"
else
fail "actionlint fallback checksums are incomplete"
@@ -87,8 +89,8 @@ else
fail "release installer or publication scripts are missing"
fi
-if rg -q '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \
- && rg -q '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then
+if has_regex '"draft":[[:space:]]*true' "$REPO_ROOT/release-please-config.json" \
+ && has_regex '"force-tag-creation":[[:space:]]*true' "$REPO_ROOT/release-please-config.json"; then
pass "release-please is configured for draft-first releases with eager tag creation"
else
fail "release-please draft-first or force-tag-creation settings are missing"
@@ -98,24 +100,24 @@ release_workflow="$REPO_ROOT/.github/workflows/release.yml"
cd_workflow="$REPO_ROOT/.github/workflows/cd.yml"
if [ -f "$release_workflow" ] \
&& [ -f "$cd_workflow" ] \
- && rg -q 'steps.release.outputs.release_created' "$cd_workflow" \
- && rg -q 'scripts/publish_draft_release\.sh' "$cd_workflow" \
- && rg -q 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \
- && rg -q 'scripts/build_release_artifact\.sh' "$release_workflow" \
- && rg -q 'scripts/release_validate\.sh' "$release_workflow" \
- && rg -q 'scripts/publish_draft_release\.sh' "$release_workflow" \
- && rg -q 'scripts/verify_published_release\.sh' "$release_workflow" \
- && rg -q 'scripts/publish_pkg_pr\.sh' "$release_workflow" \
- && rg -q 'workflow_dispatch:' "$release_workflow" \
- && ! rg -q 'types: \[published\]' "$release_workflow" \
- && ! rg -q '\|\| true' "$release_workflow"; then
+ && has_regex 'steps.release.outputs.release_created' "$cd_workflow" \
+ && has_regex 'scripts/publish_draft_release\.sh' "$cd_workflow" \
+ && has_regex 'secrets.CI_GITHUB_TOKEN \|\| github.token' "$cd_workflow" \
+ && has_regex 'scripts/build_release_artifact\.sh' "$release_workflow" \
+ && has_regex 'scripts/release_validate\.sh' "$release_workflow" \
+ && has_regex 'scripts/publish_draft_release\.sh' "$release_workflow" \
+ && has_regex 'scripts/verify_published_release\.sh' "$release_workflow" \
+ && has_regex 'scripts/publish_pkg_pr\.sh' "$release_workflow" \
+ && has_regex 'workflow_dispatch:' "$release_workflow" \
+ && ! has_regex 'types: \[published\]' "$release_workflow" \
+ && ! has_regex '\|\| true' "$release_workflow"; then
pass "release workflows use repo-owned draft-first validation and publication scripts"
else
fail "release workflows are missing repo-owned draft-first validation/publication steps or swallow failures"
fi
if [ -f "$REPO_ROOT/.github/workflows/scorecard.yml" ] \
- && rg -q 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then
+ && has_regex 'ossf/scorecard-action@' "$REPO_ROOT/.github/workflows/scorecard.yml"; then
pass "Scorecard workflow is present as a separate security signal"
else
fail "Scorecard workflow is missing"
@@ -123,48 +125,49 @@ fi
codeql_workflow="$REPO_ROOT/.github/workflows/codeql.yml"
if [ -f "$codeql_workflow" ] \
- && rg -q '^name: CodeQL$' "$codeql_workflow" \
- && rg -q 'language: \[actions, python\]' "$codeql_workflow" \
- && rg -q 'queries: security-extended' "$codeql_workflow" \
- && rg -q 'github/codeql-action/init@' "$codeql_workflow" \
- && rg -q 'github/codeql-action/autobuild@' "$codeql_workflow" \
- && rg -q 'github/codeql-action/analyze@' "$codeql_workflow"; then
+ && has_regex '^name: CodeQL$' "$codeql_workflow" \
+ && has_regex 'language: \[actions, python\]' "$codeql_workflow" \
+ && has_regex 'queries: security-extended' "$codeql_workflow" \
+ && has_regex 'github/codeql-action/init@' "$codeql_workflow" \
+ && has_regex 'github/codeql-action/autobuild@' "$codeql_workflow" \
+ && has_regex 'github/codeql-action/analyze@' "$codeql_workflow"; then
pass "repo-owned CodeQL workflow is checked into the repository"
else
fail "repo-owned CodeQL workflow is missing or incomplete"
fi
if [ -f "$REPO_ROOT/.github/dependabot.yml" ] \
- && rg -q 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then
+ && has_regex 'package-ecosystem: "github-actions"' "$REPO_ROOT/.github/dependabot.yml"; then
pass "Dependabot configuration is checked into the repo"
else
fail "Dependabot configuration is missing or incomplete"
fi
-if rg -q 'docs-linkcheck' "$REPO_ROOT/tox.ini" \
- && rg -q 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then
+if has_regex 'docs-linkcheck' "$REPO_ROOT/tox.ini" \
+ && has_regex 'uvx tox -e docs,docs-linkcheck' "$REPO_ROOT/.github/workflows/ci.yml"; then
pass "docs link validation is wired into tox and CI"
else
fail "docs link validation is missing from tox or CI"
fi
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
- automated_security_fixes_enabled="$(gh api repos/jbcom/get-bashed/automated-security-fixes --jq '.enabled' 2>/dev/null || printf 'false')"
+ repo_slug="$(resolve_repo_slug)"
+ automated_security_fixes_enabled="$(gh api "repos/${repo_slug}/automated-security-fixes" --jq '.enabled' 2>/dev/null || printf 'false')"
vulnerability_alerts_enabled="false"
- if gh api repos/jbcom/get-bashed/vulnerability-alerts -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then
+ if gh api "repos/${repo_slug}/vulnerability-alerts" -H 'Accept: application/vnd.github+json' >/dev/null 2>&1; then
vulnerability_alerts_enabled="true"
fi
- dependabot_security_updates_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')"
- secret_scanning_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')"
- push_protection_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')"
- validity_checks_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')"
- non_provider_patterns_enabled="$(gh api repos/jbcom/get-bashed --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')"
+ dependabot_security_updates_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.dependabot_security_updates.status' 2>/dev/null || printf 'disabled')"
+ secret_scanning_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null || printf 'disabled')"
+ push_protection_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_push_protection.status' 2>/dev/null || printf 'disabled')"
+ validity_checks_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_validity_checks.status' 2>/dev/null || printf 'disabled')"
+ non_provider_patterns_enabled="$(gh api "repos/${repo_slug}" --jq '.security_and_analysis.secret_scanning_non_provider_patterns.status' 2>/dev/null || printf 'disabled')"
live_codeql_workflow="false"
- if gh api "repos/jbcom/get-bashed/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then
+ if gh api "repos/${repo_slug}/contents/.github/workflows/codeql.yml?ref=main" >/dev/null 2>&1; then
live_codeql_workflow="true"
fi
if [ "$live_codeql_workflow" = "true" ]; then
- live_codeql_default_state="$(gh api repos/jbcom/get-bashed/code-scanning/default-setup --jq '.state' 2>/dev/null || printf 'unknown')"
+ live_codeql_default_state="$(gh api "repos/${repo_slug}/code-scanning/default-setup" --jq '.state' 2>/dev/null || printf 'unknown')"
if [ "$live_codeql_default_state" = "not-configured" ]; then
pass "live GitHub default CodeQL setup is disabled in favor of the repo-owned workflow"
else
@@ -220,7 +223,7 @@ else
fi
if [ -f "$REPO_ROOT/scripts/verify_branch_protection.sh" ] \
- && rg -q '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then
+ && has_regex '^verify-branch-protection:' "$REPO_ROOT/Makefile"; then
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if bash "$REPO_ROOT/scripts/verify_branch_protection.sh" >/dev/null; then
pass "branch protection matches the documented required CI contexts"
@@ -235,7 +238,7 @@ else
fi
if [ -f "$REPO_ROOT/scripts/reconcile_codeql_governance.sh" ] \
- && rg -q '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then
+ && has_regex '^reconcile-codeql-governance:' "$REPO_ROOT/Makefile"; then
pass "post-merge CodeQL governance reconciliation is scripted in the repo"
else
fail "post-merge CodeQL governance reconciliation is missing"
@@ -243,8 +246,8 @@ fi
if [ -f "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" ] \
&& [ -f "$REPO_ROOT/scripts/reconcile_immutable_release_governance.sh" ] \
- && rg -q '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \
- && rg -q '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then
+ && has_regex '^verify-immutable-release-governance:' "$REPO_ROOT/Makefile" \
+ && has_regex '^reconcile-immutable-release-governance:' "$REPO_ROOT/Makefile"; then
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if bash "$REPO_ROOT/scripts/verify_immutable_release_governance.sh" >/dev/null; then
pass "immutable release governance is scripted and verified"
diff --git a/scripts/verify_immutable_release_governance.sh b/scripts/verify_immutable_release_governance.sh
index cf336cb..6ca0565 100644
--- a/scripts/verify_immutable_release_governance.sh
+++ b/scripts/verify_immutable_release_governance.sh
@@ -3,7 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=scripts/lib/immutable_release_flow.sh
+# shellcheck disable=SC1090,SC1091
. "$SCRIPT_DIR/lib/immutable_release_flow.sh"
REPO="${1:-jbcom/get-bashed}"
diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats
index 22fe707..3883ac3 100644
--- a/tests/bootstrap.bats
+++ b/tests/bootstrap.bats
@@ -2,6 +2,16 @@
load test_helper
+sha256_of() {
+ python3 - "$1" <<'PY'
+from hashlib import sha256
+from pathlib import Path
+import sys
+
+print(sha256(Path(sys.argv[1]).read_bytes()).hexdigest())
+PY
+}
+
@test "install.sh ignores PATH bash 3.x when a modern absolute bash is available" {
[[ -n "$MODERN_BASH" ]] || skip "No Bash 4+ candidate on this platform"
@@ -67,6 +77,7 @@ BREW
chmod +x "$FAKEBIN/brew"
EOF
chmod +x "$INSTALLER_PAYLOAD"
+ BREW_SHA="$(sha256_of "$INSTALLER_PAYLOAD")"
cat > "$FAKEBIN/curl" < "$FAKEBIN/curl" < "$ARCHIVE_ROOT/installers/_helpers.sh"
tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main
+ ARCHIVE_SHA="$(sha256_of "$ARCHIVE")"
cat > "$FAKEBIN/bash" < "$ARCHIVE_ROOT/install.bash" <<'EOF'
+#!/usr/bin/env bash
+exit 0
+EOF
+ chmod +x "$ARCHIVE_ROOT/install.bash"
+ : > "$ARCHIVE_ROOT/installers/_helpers.sh"
+
+ tar -czf "$ARCHIVE" -C "$TMPDIR/archive" get-bashed-main
+
+ cat > "$FAKEBIN/bash" < "$FAKEBIN/curl" </dev/null 2>&1
from urllib.request import urlopen
import sys
@@ -124,8 +124,7 @@ EOF
"$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.10 "$tmpdir"
- run "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir"
- assert_success
+ "$MODERN_BASH" ./scripts/release_validate.sh 9.9.10 "$tmpdir"
assert_file_exist "$tmpdir/checksums.txt"
assert_file_exist "$tmpdir/pkg/get-bashed.rb"
@@ -154,6 +153,7 @@ EOF
python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 &
server_pid=$!
wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+ wait_for_http "http://127.0.0.1:${port}/get-bashed-9.9.11-unix.tar.gz"
run env \
HOME="$tmpdir/home" \
@@ -180,38 +180,32 @@ EOF
python3 -m http.server "$port" --directory "$tmpdir" >"$tmpdir/server.log" 2>&1 &
server_pid=$!
wait_for_http "http://127.0.0.1:${port}/checksums.txt"
+ wait_for_http "http://127.0.0.1:${port}/get-bashed-9.9.16-unix.tar.gz"
cat >"$bindir/wget" <<'EOF'
-#!/usr/bin/env python3
-from pathlib import Path
-from urllib.request import urlopen
-import sys
+#!/bin/sh
+set -eu
-argv = sys.argv[1:]
-if len(argv) == 2 and argv[0] == "-qO-":
- with urlopen(argv[1], timeout=10) as response:
- sys.stdout.buffer.write(response.read())
- raise SystemExit(0)
+if [ "$#" -eq 2 ] && [ "$1" = "-qO-" ]; then
+ exec curl -fsSL "$2"
+fi
-if len(argv) == 3 and argv[0] == "-qO":
- target = Path(argv[1])
- target.parent.mkdir(parents=True, exist_ok=True)
- with urlopen(argv[2], timeout=10) as response:
- target.write_bytes(response.read())
- raise SystemExit(0)
+if [ "$#" -eq 3 ] && [ "$1" = "-qO" ]; then
+ exec curl -fsSL -o "$2" "$3"
+fi
-raise SystemExit(f"unsupported fake wget arguments: {' '.join(argv)}")
+echo "unsupported fake wget arguments: $*" >&2
+exit 1
EOF
chmod +x "$bindir/wget"
- run env \
+ env \
HOME="$tmpdir/home" \
PATH="$bindir:$PATH" \
GET_BASHED_DOWNLOAD_TOOL="wget" \
GET_BASHED_RELEASE_BASE_URL="http://127.0.0.1:${port}" \
GET_BASHED_RELEASE_CHECKSUMS_URL="http://127.0.0.1:${port}/checksums.txt" \
sh ./docs/public/install.sh --version 9.9.16 --auto --profiles minimal --prefix "$tmpdir/home/.get-bashed"
- assert_success
assert_file_exist "$tmpdir/home/.get-bashed/get-bashedrc.sh"
@@ -284,6 +278,59 @@ EOF
rm -rf "$tmpdir"
}
+@test "publish_pkg_pr uses gh repo clone when a direct target repo url is not provided" {
+ tmpdir="$(mktemp -d)"
+ remote="$tmpdir/pkgs.git"
+ worktree="$tmpdir/worktree"
+ manifests="$tmpdir/manifests"
+ bindir="$tmpdir/bin"
+ log="$tmpdir/gh.log"
+
+ git init --bare --initial-branch=main "$remote" >/dev/null
+ git clone "$remote" "$worktree" >/dev/null
+ (
+ cd "$worktree"
+ git config user.name Test
+ git config user.email test@example.com
+ touch .keep
+ git add .keep
+ git commit -m "init" >/dev/null
+ git push origin main >/dev/null
+ )
+
+ "$MODERN_BASH" ./scripts/build_release_artifact.sh 9.9.17 "$tmpdir"
+ cat "$tmpdir/get-bashed-9.9.17-unix.tar.gz.sha256" "$tmpdir/get-bashed-9.9.17-windows.zip.sha256" >"$tmpdir/checksums.txt"
+ "$MODERN_BASH" ./scripts/generate_pkg_manifests.sh 9.9.17 "$tmpdir/checksums.txt" "$manifests"
+
+ mkdir -p "$bindir"
+ cat >"$bindir/gh" <>"$log"
+if [[ "\$1 \$2" == "repo clone" ]]; then
+ git clone "$remote" "\$4" >/dev/null
+ exit 0
+fi
+if [[ "\$1 \$2" == "pr create" ]]; then
+ printf 'https://example.test/pr/17\n'
+ exit 0
+fi
+EOF
+ chmod +x "$bindir/gh"
+
+ run env \
+ GH_TOKEN=fake-token \
+ TARGET_REPO=jbcom/pkgs \
+ PATH="$bindir:$PATH" \
+ "$MODERN_BASH" ./scripts/publish_pkg_pr.sh 9.9.17 "$manifests"
+ assert_success
+
+ run grep -F 'repo clone jbcom/pkgs' "$log"
+ assert_success
+
+ rm -rf "$tmpdir"
+}
+
@test "publish_pkg_pr accepts a downloaded artifact layout with nested pkg directory" {
tmpdir="$(mktemp -d)"
remote="$tmpdir/pkgs.git"
diff --git a/tests/runtime_modules.bats b/tests/runtime_modules.bats
index 9e128c7..44a69d8 100644
--- a/tests/runtime_modules.bats
+++ b/tests/runtime_modules.bats
@@ -1,4 +1,5 @@
#!/usr/bin/env bats
+# shellcheck disable=SC2016
load test_helper
@@ -12,6 +13,7 @@ load test_helper
cat > "$PREFIX/secrets.d/10-local.sh" <<'EOF'
export LOCAL_SECRET=1
EOF
+ chmod 600 "$PREFIX/secrets.d/10-local.sh"
cat > "$FAKEBIN/doppler" <<'EOF'
#!/bin/sh
@@ -25,6 +27,23 @@ EOF
assert_output "local=1 doppler=0"
}
+@test "secrets module skips non-private secret snippets" {
+ TMPDIR="$(mktemp -d)"
+ HOME="$TMPDIR/home"
+ PREFIX="$HOME/.get-bashed"
+ mkdir -p "$PREFIX/secrets.d"
+
+ cat > "$PREFIX/secrets.d/10-world-readable.sh" <<'EOF'
+export LOCAL_SECRET=1
+EOF
+ chmod 644 "$PREFIX/secrets.d/10-world-readable.sh"
+
+ run env HOME="$HOME" GET_BASHED_HOME="$PREFIX" "$MODERN_BASH" -lc 'source bashrc.d/99-secrets.sh; printf "local=%s\n" "${LOCAL_SECRET:-0}"'
+ assert_success
+ assert_output --partial "local=0"
+ assert_output --partial "require owner-only permissions"
+}
+
@test "doppler module exposes explicit doppler_shell helper" {
TMPDIR="$(mktemp -d)"
HOME="$TMPDIR/home"
diff --git a/tests/supply_chain_verify.bats b/tests/supply_chain_verify.bats
index 3abbc9b..1f28ec2 100644
--- a/tests/supply_chain_verify.bats
+++ b/tests/supply_chain_verify.bats
@@ -64,6 +64,57 @@ EOF
rm -rf "$tmpdir"
}
+@test "supply_chain_verify uses the resolved repo slug instead of a hard-coded upstream slug" {
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+
+ cat >"$bindir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "auth status" ]]; then
+ exit 0
+fi
+if [[ "$1" == "api" ]]; then
+ args="$*"
+ case "$args" in
+ *'repos/acme/get-bashed-fork/automated-security-fixes'*'.enabled'*) printf 'true\n' ;;
+ *'repos/acme/get-bashed-fork'*'dependabot_security_updates.status'*) printf 'enabled\n' ;;
+ *'repos/acme/get-bashed-fork'*'secret_scanning.status'*) printf 'enabled\n' ;;
+ *'repos/acme/get-bashed-fork'*'secret_scanning_push_protection.status'*) printf 'enabled\n' ;;
+ *'repos/acme/get-bashed-fork'*'secret_scanning_validity_checks.status'*) printf 'enabled\n' ;;
+ *'repos/acme/get-bashed-fork'*'secret_scanning_non_provider_patterns.status'*) printf 'enabled\n' ;;
+ *'repos/acme/get-bashed-fork/vulnerability-alerts'*) printf '{}\n' ;;
+ *'repos/acme/get-bashed-fork/contents/.github/workflows/codeql.yml?ref=main'*) exit 1 ;;
+ *'.required_status_checks.contexts[]'*)
+ printf '%s\n' \
+ 'Quality (ubuntu-latest)' \
+ 'Quality (macos-latest)' \
+ 'Quality (wsl-ubuntu)' \
+ 'SonarQube Scan'
+ ;;
+ *'.required_status_checks.strict'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.required_approving_review_count'*) printf '1\n' ;;
+ *'.required_pull_request_reviews.dismiss_stale_reviews'*) printf 'true\n' ;;
+ *'.required_pull_request_reviews.require_code_owner_reviews'*) printf 'true\n' ;;
+ *'.enforce_admins.enabled'*) printf 'true\n' ;;
+ *'.required_linear_history.enabled'*) printf 'true\n' ;;
+ *'.required_conversation_resolution.enabled'*) printf 'true\n' ;;
+ *) exit 1 ;;
+ esac
+ exit 0
+fi
+exit 1
+EOF
+ chmod +x "$bindir/gh"
+
+ run env GET_BASHED_REPO_SLUG="acme/get-bashed-fork" PATH="$bindir:$PATH" "$MODERN_BASH" ./scripts/supply_chain_verify.sh
+ assert_success
+ assert_output --partial "All supply chain checks passed"
+
+ rm -rf "$tmpdir"
+}
+
@test "supply_chain_verify fails when main has codeql.yml but default setup is still enabled" {
tmpdir="$(mktemp -d)"
bindir="$tmpdir/bin"
diff --git a/tests/test_setup.bats b/tests/test_setup.bats
index 7deebc4..aa8582a 100644
--- a/tests/test_setup.bats
+++ b/tests/test_setup.bats
@@ -1,4 +1,5 @@
#!/usr/bin/env bats
+# shellcheck disable=SC2016
load test_helper
From 849523500bdf41426484b30064bc6b474a4f3dd7 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 16:24:30 -0500
Subject: [PATCH 4/7] fix: remove final rg dependency from verifier
---
scripts/supply_chain_verify.sh | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh
index 9c78057..14fdedc 100644
--- a/scripts/supply_chain_verify.sh
+++ b/scripts/supply_chain_verify.sh
@@ -261,10 +261,10 @@ else
fail "immutable release governance verification or reconciliation is missing"
fi
-if rg -q '^verify-security:' "$REPO_ROOT/Makefile" \
- && rg -q 'make verify-security' "$REPO_ROOT/README.md" \
- && rg -q 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \
- && rg -q 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then
+if has_regex '^verify-security:' "$REPO_ROOT/Makefile" \
+ && has_regex 'make verify-security' "$REPO_ROOT/README.md" \
+ && has_regex 'make verify-security' "$REPO_ROOT/docs/TESTING.md" \
+ && has_regex 'make verify-security' "$REPO_ROOT/docs/reference/security.md"; then
pass "security verification is exposed through make and documented"
else
fail "security verification target or docs are missing"
From 2f79f7de6462cecb371c2305a9abb255491c46a1 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 16:32:48 -0500
Subject: [PATCH 5/7] fix: make ci tool paths portable
---
scripts/ci-setup.sh | 26 ++++++++++++++++++----
tests/docs_contract.bats | 37 ++++++++++++++++++++-----------
tests/test_helper.bash | 48 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 94 insertions(+), 17 deletions(-)
diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh
index 947b653..6ebb58f 100755
--- a/scripts/ci-setup.sh
+++ b/scripts/ci-setup.sh
@@ -9,6 +9,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+append_ci_path() {
+ local path_entry="$1"
+
+ [[ -n "$path_entry" && -d "$path_entry" ]] || return 0
+ case ":$PATH:" in
+ *":$path_entry:"*) ;;
+ *) export PATH="$path_entry:$PATH" ;;
+ esac
+
+ if [[ -n "${GITHUB_PATH:-}" ]]; then
+ printf '%s\n' "$path_entry" >> "$GITHUB_PATH"
+ fi
+}
+
# Prefer RUNNER_TEMP, then RUNNER_TOOL_CACHE, then /tmp
PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}"
export GET_BASHED_HOME="$PREFIX"
@@ -21,12 +35,16 @@ INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}"
"$ROOT_DIR/install.sh" --auto --install "$INSTALLS"
-if [[ -n "${GITHUB_ENV:-}" ]]; then
- printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV"
+append_ci_path "$GET_BASHED_HOME/bin"
+
+if command -v brew >/dev/null 2>&1; then
+ brew_prefix="$(brew --prefix 2>/dev/null || true)"
+ append_ci_path "$brew_prefix/bin"
+ append_ci_path "$brew_prefix/sbin"
fi
-if [[ -n "${GITHUB_PATH:-}" ]]; then
- printf '%s\n' "$GET_BASHED_HOME/bin" >> "$GITHUB_PATH"
+if [[ -n "${GITHUB_ENV:-}" ]]; then
+ printf 'GET_BASHED_HOME=%s\n' "$GET_BASHED_HOME" >> "$GITHUB_ENV"
fi
echo "CI tools installed to $GET_BASHED_HOME"
diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats
index 562eb10..e4b983c 100644
--- a/tests/docs_contract.bats
+++ b/tests/docs_contract.bats
@@ -19,7 +19,7 @@ load test_helper
}
@test "documentation references only current workflow files" {
- run rg -n "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs
+ run repo_search "docs.yml|pr-title.yml|release-please.yml|autofix.yml|dependabot-automerge.yml" README.md AGENTS.md STANDARDS.md docs
assert_failure
run grep -F "cd.yml" README.md
@@ -43,7 +43,7 @@ load test_helper
run grep -F 'wsl.exe --install --distribution $distro --no-launch --web-download' .github/workflows/ci.yml
assert_success
- run rg -n 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs
+ run repo_search 'rather than dedicated runner validation|Linux-compatible path rather than a dedicated WSL runner' README.md AGENTS.md docs
assert_failure
}
@@ -197,7 +197,7 @@ load test_helper
run grep -F 'workflow_dispatch' .github/workflows/release.yml
assert_success
- run rg -n 'types: \[published\]' .github/workflows/release.yml
+ run repo_search 'types: \[published\]' .github/workflows/release.yml
assert_failure
run grep -F 'gh attestation verify "$WINDOWS_ARCHIVE"' scripts/verify_published_release.sh
@@ -225,20 +225,20 @@ load test_helper
}
@test "installer code does not resolve asdf latest at runtime" {
- run rg -n 'asdf (latest|install .+ latest)' installers installlib bin scripts
+ run repo_search 'asdf (latest|install .+ latest)' installers installlib bin scripts
assert_failure
}
@test "curl fallbacks are pinned instead of using moving HEAD URLs" {
- run rg -n 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --glob '!scripts/supply_chain_verify.sh'
+ run repo_search 'raw\\.githubusercontent\\.com/.+/HEAD/' installers scripts README.md TOOLS.md docs AGENTS.md --exclude 'scripts/supply_chain_verify.sh'
assert_failure
}
@test "runtime and pipx helpers do not use floating package specs" {
- run rg -n 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d
+ run repo_search 'npm install -g (@google/gemini-cli|@sonar/scan)([[:space:]]|$)' bashrc.d
assert_failure
- run rg -n 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers
+ run repo_search 'pipx install \"\\$pkg\"|python3 -m pip install --user \"\\$(id|pkg)\"' installers
assert_failure
}
@@ -272,7 +272,7 @@ load test_helper
}
@test "tests do not hardcode a macOS-only bash path in commands" {
- run rg -n '/opt/homebrew/bin/bash' tests --glob '!test_helper.bash' --glob '!docs_contract.bats'
+ run repo_search '/opt/homebrew/bin/bash' tests --exclude 'test_helper.bash' --exclude 'docs_contract.bats'
assert_failure
}
@@ -292,22 +292,33 @@ load test_helper
assert_success
}
+@test "ci setup persists tool bins for later workflow steps" {
+ run grep -F 'append_ci_path "$GET_BASHED_HOME/bin"' scripts/ci-setup.sh
+ assert_success
+
+ run grep -F 'brew_prefix="$(brew --prefix 2>/dev/null || true)"' scripts/ci-setup.sh
+ assert_success
+
+ run grep -F 'append_ci_path "$brew_prefix/bin"' scripts/ci-setup.sh
+ assert_success
+}
+
@test "runtime modules resolve Homebrew state through brew --prefix" {
- run rg -n 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d
+ run repo_search 'dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"|/opt/homebrew/etc/profile\\.d/bash_completion\\.sh|/usr/local/etc/profile\\.d/bash_completion\\.sh|/opt/homebrew/opt/asdf/libexec/asdf\\.sh|/usr/local/opt/asdf/libexec/asdf\\.sh' bashrc.d
assert_failure
run grep -F 'prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' bashrc.d/10-helpers.sh
assert_success
- run rg -n 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh
+ run repo_search 'get_brew_prefix' bashrc.d/20-path.sh bashrc.d/30-buildflags.sh bashrc.d/40-completions.sh bashrc.d/60-asdf.sh
assert_success
}
@test "bash_profile uses brew shellenv instead of hand-rolled exports" {
- run rg -n 'brew shellenv' bash_profile
+ run repo_search 'brew shellenv' bash_profile
assert_success
- run rg -n 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile
+ run repo_search 'HOMEBREW_CELLAR=.*Cellar|dirname "\\$\\(dirname "\\$\\(command -v brew\\)\\)"' bash_profile
assert_failure
}
@@ -321,6 +332,6 @@ load test_helper
run grep -F 'GET_BASHED_CURL_CMD["brew"]="${GET_BASHED_BOOTSTRAP_BREW_CMD}"' installers/sources.sh
assert_success
- run rg -n 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs
+ run repo_search 'archive/refs/heads/main\\.tar\\.gz|archive/refs/heads/.+\\.tar\\.gz' install.sh installers/bootstrap_sources.sh README.md docs
assert_failure
}
diff --git a/tests/test_helper.bash b/tests/test_helper.bash
index 02ae761..0cfe113 100644
--- a/tests/test_helper.bash
+++ b/tests/test_helper.bash
@@ -39,3 +39,51 @@ detect_modern_bash() {
MODERN_BASH="${MODERN_BASH:-$(detect_modern_bash)}"
export MODERN_BASH
+
+repo_search() {
+ local pattern="$1"
+ shift
+
+ local path file exclude skip
+ local -a files=()
+ local -a filtered=()
+ local -a excludes=()
+
+ while (($#)); do
+ case "$1" in
+ --exclude)
+ excludes+=("$2")
+ shift 2
+ ;;
+ *)
+ path="$1"
+ if [[ -d "$path" ]]; then
+ while IFS= read -r file; do
+ files+=("$file")
+ done < <(find "$path" -type f | sort)
+ elif [[ -e "$path" ]]; then
+ files+=("$path")
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ [[ "${#files[@]}" -gt 0 ]] || return 1
+
+ for file in "${files[@]}"; do
+ skip=0
+ for exclude in "${excludes[@]}"; do
+ case "$file" in
+ "$exclude"|*/"$exclude")
+ skip=1
+ break
+ ;;
+ esac
+ done
+ [[ "$skip" -eq 0 ]] && filtered+=("$file")
+ done
+
+ [[ "${#filtered[@]}" -gt 0 ]] || return 1
+ grep -nE -- "$pattern" "${filtered[@]}"
+}
From e456c27446327e14c2a49e6d91b5afe3dccbdd42 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 16:38:02 -0500
Subject: [PATCH 6/7] fix: detect brew path in ci setup
---
scripts/ci-setup.sh | 21 +++++++++++++++++++--
tests/docs_contract.bats | 8 +++++++-
2 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh
index 6ebb58f..8f7c2a6 100755
--- a/scripts/ci-setup.sh
+++ b/scripts/ci-setup.sh
@@ -23,6 +23,23 @@ append_ci_path() {
fi
}
+find_brew_bin() {
+ local candidate
+
+ if command -v brew >/dev/null 2>&1; then
+ command -v brew
+ return 0
+ fi
+
+ for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do
+ [[ -x "$candidate" ]] || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+
+ return 1
+}
+
# Prefer RUNNER_TEMP, then RUNNER_TOOL_CACHE, then /tmp
PREFIX="${GET_BASHED_HOME:-${RUNNER_TEMP:-${RUNNER_TOOL_CACHE:-/tmp}}/get-bashed}"
export GET_BASHED_HOME="$PREFIX"
@@ -37,8 +54,8 @@ INSTALLS="${1:-shdoc,actionlint,shellcheck,bashate}"
append_ci_path "$GET_BASHED_HOME/bin"
-if command -v brew >/dev/null 2>&1; then
- brew_prefix="$(brew --prefix 2>/dev/null || true)"
+if brew_bin="$(find_brew_bin)"; then
+ brew_prefix="$("$brew_bin" --prefix 2>/dev/null || true)"
append_ci_path "$brew_prefix/bin"
append_ci_path "$brew_prefix/sbin"
fi
diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats
index e4b983c..cfadccc 100644
--- a/tests/docs_contract.bats
+++ b/tests/docs_contract.bats
@@ -296,7 +296,13 @@ load test_helper
run grep -F 'append_ci_path "$GET_BASHED_HOME/bin"' scripts/ci-setup.sh
assert_success
- run grep -F 'brew_prefix="$(brew --prefix 2>/dev/null || true)"' scripts/ci-setup.sh
+ run grep -F 'find_brew_bin()' scripts/ci-setup.sh
+ assert_success
+
+ run grep -F '/home/linuxbrew/.linuxbrew/bin/brew' scripts/ci-setup.sh
+ assert_success
+
+ run grep -F 'brew_prefix="$("$brew_bin" --prefix 2>/dev/null || true)"' scripts/ci-setup.sh
assert_success
run grep -F 'append_ci_path "$brew_prefix/bin"' scripts/ci-setup.sh
From 7dc6807c62fa11339a093a4e2c7d504b63da940e Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 16 Apr 2026 16:43:31 -0500
Subject: [PATCH 7/7] fix: harden supply-chain verifier invocation
---
.github/workflows/ci.yml | 2 +-
scripts/supply_chain_verify.sh | 0
tests/docs_contract.bats | 5 ++++-
3 files changed, 5 insertions(+), 2 deletions(-)
mode change 100644 => 100755 scripts/supply_chain_verify.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index de48cc5..ff77a77 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,7 +44,7 @@ jobs:
- name: Build Sphinx docs and linkcheck
run: uvx tox -e docs,docs-linkcheck
- name: Verify supply chain posture
- run: ./scripts/supply_chain_verify.sh
+ run: bash ./scripts/supply_chain_verify.sh
wsl-quality:
name: Quality (wsl-ubuntu)
diff --git a/scripts/supply_chain_verify.sh b/scripts/supply_chain_verify.sh
old mode 100644
new mode 100755
diff --git a/tests/docs_contract.bats b/tests/docs_contract.bats
index cfadccc..9b4e16e 100644
--- a/tests/docs_contract.bats
+++ b/tests/docs_contract.bats
@@ -99,13 +99,16 @@ load test_helper
run test -f scripts/supply_chain_verify.sh
assert_success
+ run test -x scripts/supply_chain_verify.sh
+ assert_success
+
run grep -F 'verify-security:' Makefile
assert_success
run grep -F 'make verify-security' README.md docs/README.md docs/TESTING.md docs/reference/testing.md docs/reference/security.md
assert_success
- run grep -F './scripts/supply_chain_verify.sh' .github/workflows/ci.yml
+ run grep -F 'bash ./scripts/supply_chain_verify.sh' .github/workflows/ci.yml
assert_success
}