diff --git a/.agents/skills/shell-codestyle/SKILL.md b/.agents/skills/shell-codestyle/SKILL.md index 50b1f604..eaa4a583 100644 --- a/.agents/skills/shell-codestyle/SKILL.md +++ b/.agents/skills/shell-codestyle/SKILL.md @@ -3,8 +3,9 @@ name: shell-codestyle description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the - pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the - why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever + pipefail-versus-early-reader pitfall, self-locating scripts, the shellcheck-plus-shfmt + clean-compile, and the why-not-what comment rule. Use this whenever writing, reviewing, or + editing a shell script (a `.sh` file, or an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, @@ -31,6 +32,12 @@ depend on one. Everything else is Python, with a test under the scripts tree's ` ## Rules +- **`shellcheck` is the linter and `shfmt` the formatter.** The clean-compile is `shellcheck` + clean at default severity plus `shfmt -d`, both reporting nothing before a commit. CI enforces + both, per `GOVERNANCE.md` "Running the Linters Locally", and `scripts/docker_lint.py` runs the + same pair headless. Neither is scoped to the `*.sh` glob alone: a tracked, extension-less + script whose shebang names bash or sh (the shape a script meant to run as a bare command takes) + joins the target list too. - **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that nothing executes unguarded rather than which line number it lands on. Without `-e` a failed diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 56fa3556..cc914f52 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -b8df12b7c8013f3d +85b7db1858ecd00a diff --git a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md index 50b1f604..eaa4a583 100644 --- a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md @@ -3,8 +3,9 @@ name: shell-codestyle description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the - pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the - why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever + pipefail-versus-early-reader pitfall, self-locating scripts, the shellcheck-plus-shfmt + clean-compile, and the why-not-what comment rule. Use this whenever writing, reviewing, or + editing a shell script (a `.sh` file, or an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, @@ -31,6 +32,12 @@ depend on one. Everything else is Python, with a test under the scripts tree's ` ## Rules +- **`shellcheck` is the linter and `shfmt` the formatter.** The clean-compile is `shellcheck` + clean at default severity plus `shfmt -d`, both reporting nothing before a commit. CI enforces + both, per `GOVERNANCE.md` "Running the Linters Locally", and `scripts/docker_lint.py` runs the + same pair headless. Neither is scoped to the `*.sh` glob alone: a tracked, extension-less + script whose shebang names bash or sh (the shape a script meant to run as a bare command takes) + joins the target list too. - **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that nothing executes unguarded rather than which line number it lands on. Without `-e` a failed diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md index 50b1f604..eaa4a583 100644 --- a/.github/skills/shell-codestyle/SKILL.md +++ b/.github/skills/shell-codestyle/SKILL.md @@ -3,8 +3,9 @@ name: shell-codestyle description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the - pipefail-versus-early-reader pitfall, self-locating scripts, shellcheck cleanliness, and the - why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, whenever + pipefail-versus-early-reader pitfall, self-locating scripts, the shellcheck-plus-shfmt + clean-compile, and the why-not-what comment rule. Use this whenever writing, reviewing, or + editing a shell script (a `.sh` file, or an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, @@ -31,6 +32,12 @@ depend on one. Everything else is Python, with a test under the scripts tree's ` ## Rules +- **`shellcheck` is the linter and `shfmt` the formatter.** The clean-compile is `shellcheck` + clean at default severity plus `shfmt -d`, both reporting nothing before a commit. CI enforces + both, per `GOVERNANCE.md` "Running the Linters Locally", and `scripts/docker_lint.py` runs the + same pair headless. Neither is scoped to the `*.sh` glob alone: a tracked, extension-less + script whose shebang names bash or sh (the shape a script meant to run as a bare command takes) + joins the target list too. - **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above it, as `repo-config/configure.sh` and the `host-setup/` scripts do, since what matters is that nothing executes unguarded rather than which line number it lands on. Without `-e` a failed diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 7acc3260..ac8a8dd8 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -60,13 +60,51 @@ jobs: run: docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest # The file list comes from git rather than a glob, and the docker run is skipped rather than invoked on an empty argument list. - # That is how a repo with no shell scripts stays clean instead of failing on shellcheck's own no-file usage error. + # That is how a repo with no shell scripts stays clean instead of failing on a linter's own no-file usage error. + # A `*.sh` glob alone misses a bare-command script with no extension, so a shebang-matched file joins the list too. - name: Check shell scripts step run: | set -Eeuo pipefail + # Tokenizes rather than substring-matches, so `#!/usr/bin/python bash` is not mistaken for a shell script. + # `docker_lint.py`'s shell_shebang_interpreter is the same logic; keep both in sync on a change here. + is_shell_shebang() { + local line="$1" + [[ "$line" == '#!'* ]] || return 1 + local -a tokens + read -ra tokens <<< "${line#\#!}" + [ "${#tokens[@]}" -gt 0 ] || return 1 + local interpreter="${tokens[0]##*/}" + if [[ "$interpreter" == "bash" || "$interpreter" == "sh" ]]; then + return 0 + fi + [[ "$interpreter" == "env" ]] || return 1 + local -a args=("${tokens[@]:1}") + while [ "${#args[@]}" -gt 0 ] && [[ "${args[0]}" == -* ]] && [[ "${args[0]}" != "--" ]]; do + if [[ "${args[0]}" == "-S" ]]; then + args=("${args[@]:1}") + break + fi + args=("${args[@]:1}") + done + if [ "${#args[@]}" -gt 0 ] && [[ "${args[0]}" == "--" ]]; then + args=("${args[@]:1}") + fi + [ "${#args[@]}" -gt 0 ] || return 1 + local cmd="${args[0]##*/}" + [[ "$cmd" == "bash" || "$cmd" == "sh" ]] + } mapfile -t scripts < <(git ls-files '*.sh') + mapfile -d '' -t candidates < <(git ls-files -z) + for file in "${candidates[@]}"; do + base="${file##*/}" + if [[ "$base" != *.* ]] && [ -f "$file" ] && IFS= read -r first_line < "$file" \ + && is_shell_shebang "$first_line"; then + scripts+=("$file") + fi + done if [ "${#scripts[@]}" -gt 0 ]; then - docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" + docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable -- "${scripts[@]}" + docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt mvdan/shfmt:latest -d -- "${scripts[@]}" else echo "no shell scripts are tracked" fi diff --git a/CODESTYLE.md b/CODESTYLE.md index 0e714c72..2569b380 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -51,7 +51,7 @@ This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codes ## Shell -Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on one. Everything else is Python, with a test under the scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-locating scripts, `shellcheck` cleanliness, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on one. Everything else is Python, with a test under the scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index e170a8c4..4b6dad89 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -218,7 +218,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither one has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. @@ -242,7 +242,7 @@ After all pulls, the wrapper reports that repository mounts are about to begin. Every primary Docker command has a five-minute timeout by default. Use `--timeout` to select another positive bound. The wrapper emits a start and completion line for each primary command. Timeout cleanup has a separate maximum of 30 seconds and emits its result through the failed lint step. The wrapper reports the checked-file count for every linter, including tools that produce no success output. Timeout, container failure, zero-target execution, and successful quiet completion have distinct result lines. The wrapper names each lint container and removes it after a timeout. -Use repeated `--linter` options for a subset. The supported names are `editorconfig-checker`, `actionlint`, `markdownlint`, `cspell`, `shellcheck`, and `PSScriptAnalyzer`. editorconfig-checker reads the mounted tree. actionlint reads eligible workflows and includes shellcheck for `run:` blocks. markdownlint reads tracked and unignored Markdown files. CSpell reads `README.md` and `HISTORY.md` only. shellcheck and PSScriptAnalyzer run only when matching scripts are tracked or unignored. +Use repeated `--linter` options for a subset. The supported names are `editorconfig-checker`, `actionlint`, `markdownlint`, `cspell`, `shellcheck`, `shfmt`, and `PSScriptAnalyzer`. editorconfig-checker reads the mounted tree. actionlint reads eligible workflows and includes shellcheck for `run:` blocks. markdownlint reads tracked and unignored Markdown files. CSpell reads `README.md` and `HISTORY.md` only. shellcheck, shfmt, and PSScriptAnalyzer run only when matching scripts are tracked or unignored. shellcheck and shfmt share one target list, a tracked `.sh` file plus a tracked, extension-less file whose shebang names bash or sh. In a configured editor the `DavidAnson.vscode-markdownlint` extension is enough for Markdown. Use the wrapper for a headless run or before pushing. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index c29797a9..0820b966 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -1,4 +1,5 @@ #!/bin/sh +# shellcheck disable=SC1091 # Generated by Husky install, not present at lint time. . "$(dirname "$0")/_/husky.sh" # Local pre-commit: language formatting and style only (no Docker). diff --git a/host-setup/agent-safety/install.sh b/host-setup/agent-safety/install.sh index 8088abff..2d4698e5 100755 --- a/host-setup/agent-safety/install.sh +++ b/host-setup/agent-safety/install.sh @@ -11,10 +11,14 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The installer and the hook use Python 3 syntax, so a bare `python` that is Python 2 is rejected rather than handed the script, which would fail on import. py="" for c in python3 python; do - if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)' 2>/dev/null; then - py="$c"; break - fi + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)' 2>/dev/null; then + py="$c" + break + fi done -[ -n "$py" ] || { echo "Python 3 is required and was not found on PATH (tried python3, python)." >&2; exit 1; } +[ -n "$py" ] || { + echo "Python 3 is required and was not found on PATH (tried python3, python)." >&2 + exit 1 +} exec "$py" "$here/install.py" "$@" diff --git a/host-setup/bootstrap.sh b/host-setup/bootstrap.sh index 75d0ad42..84526cda 100755 --- a/host-setup/bootstrap.sh +++ b/host-setup/bootstrap.sh @@ -75,7 +75,7 @@ EOF # --- Fetch --- fetch() { - command -v curl > /dev/null || return 1 + command -v curl >/dev/null || return 1 curl -fsSL --retry 2 --connect-timeout 15 "$@" } @@ -83,7 +83,7 @@ fetch() { # The plain-text accept header returns the commit alone, which keeps this free of a JSON parser on a host that has none. resolve_ref() { local sha - sha=$(fetch -H 'Accept: application/vnd.github.sha' "https://api.github.com/repos/$REPO/commits/$REF" 2> /dev/null) || sha="" + sha=$(fetch -H 'Accept: application/vnd.github.sha' "https://api.github.com/repos/$REPO/commits/$REF" 2>/dev/null) || sha="" if [[ -n $sha ]]; then RESOLVED="$sha" @@ -230,16 +230,16 @@ menu() { local choice read -r -p "Choose: " choice case "$choice" in - 1) MODE="report" ;; - 2) MODE="upgrade" ;; - 3) MODE="tools" ;; - 4) MODE="github" ;; - 5) MODE="skills" ;; - 6) MODE="sudo" ;; - 7) MODE="host" ;; - 8) MODE="dev" ;; - q | Q) exit 0 ;; - *) die "Not one of the choices" ;; + 1) MODE="report" ;; + 2) MODE="upgrade" ;; + 3) MODE="tools" ;; + 4) MODE="github" ;; + 5) MODE="skills" ;; + 6) MODE="sudo" ;; + 7) MODE="host" ;; + 8) MODE="dev" ;; + q | Q) exit 0 ;; + *) die "Not one of the choices" ;; esac } @@ -250,36 +250,36 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -r | --report) actions+=(report) ;; - --host) actions+=(host) ;; - --dev) actions+=(dev) ;; - --upgrade) actions+=(upgrade) ;; - --tools) actions+=(tools) ;; - --github) actions+=(github) ;; - --skills) actions+=(skills) ;; - --sudo) actions+=(sudo) ;; - --release) actions+=(release) ;; - -y | --yes) ASSUME_YES=true ;; - -n | --dry-run) DRY_RUN=true ;; - --keep) KEEP=true ;; - --ref) - [[ $# -ge 2 ]] || die "--ref takes a branch, tag, pull request ref, or commit" - REF="$2" - shift - ;; - --dir) - [[ $# -ge 2 ]] || die "--dir takes a path" - # An absolute path, and never the root, since everything below is created and removed under it. - [[ $2 == /* ]] || die "--dir takes an absolute path, and \"$2\" is relative" - [[ $2 != "/" ]] || die "--dir may not be the root directory" - DIR="${2%/}" - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) die "Unknown option \"$1\", --help lists the options" ;; + -r | --report) actions+=(report) ;; + --host) actions+=(host) ;; + --dev) actions+=(dev) ;; + --upgrade) actions+=(upgrade) ;; + --tools) actions+=(tools) ;; + --github) actions+=(github) ;; + --skills) actions+=(skills) ;; + --sudo) actions+=(sudo) ;; + --release) actions+=(release) ;; + -y | --yes) ASSUME_YES=true ;; + -n | --dry-run) DRY_RUN=true ;; + --keep) KEEP=true ;; + --ref) + [[ $# -ge 2 ]] || die "--ref takes a branch, tag, pull request ref, or commit" + REF="$2" + shift + ;; + --dir) + [[ $# -ge 2 ]] || die "--dir takes a path" + # An absolute path, and never the root, since everything below is created and removed under it. + [[ $2 == /* ]] || die "--dir takes an absolute path, and \"$2\" is relative" + [[ $2 != "/" ]] || die "--dir may not be the root directory" + DIR="${2%/}" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; esac shift done @@ -295,9 +295,9 @@ parse_args() { main() { parse_args "$@" - command -v curl > /dev/null || + command -v curl >/dev/null || die "curl is required to fetch the tooling. Install it with this host's package manager, then run this again." - command -v tar > /dev/null || die "tar is required to unpack the tooling" + command -v tar >/dev/null || die "tar is required to unpack the tooling" # A run with no action and no terminal reports rather than guessing, which is what a pipe into a shell is. # The remedy is printed rather than assumed, since somebody reaching this has just pasted a one-line install. @@ -318,15 +318,15 @@ main() { download_tree case "$MODE" in - report) report ;; - upgrade) run_tool upgrade-host.sh --packages ;; - tools) run_tool install-tools.sh --install ;; - github) run_tool setup-github.sh --configure ;; - skills) SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh ;; - sudo) run_tool install-tools.sh --sudo-timestamp ;; - release) run_tool upgrade-host.sh --release ;; - host) stand_up host ;; - dev) stand_up dev ;; + report) report ;; + upgrade) run_tool upgrade-host.sh --packages ;; + tools) run_tool install-tools.sh --install ;; + github) run_tool setup-github.sh --configure ;; + skills) SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh ;; + sudo) run_tool install-tools.sh --sudo-timestamp ;; + release) run_tool upgrade-host.sh --release ;; + host) stand_up host ;; + dev) stand_up dev ;; esac step "Done" diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md index d05d9540..7ac24486 100644 --- a/host-setup/linux/README.md +++ b/host-setup/linux/README.md @@ -123,7 +123,7 @@ The sibling scripts are independently fetchable, and this one deliberately is no ## Why There Is No Linter Category -Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. +Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. ## bootstrap.sh @@ -162,7 +162,7 @@ Two of those are guards rather than previews. `--release --dry-run` on a Proxmox `--repo` adds the repository's `install.linux` entries to the report or action. Only `apt` package names are accepted. The script does not execute the declaration's `remedy` text. Reading the JSON needs `jq`, which the ordinary fleet install provides. -The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. +The scripts are checked by `shellcheck` and `shfmt`, which run in CI over every `.sh` file `git ls-files` returns, plus a tracked, extension-less file whose shebang names bash or sh. A local run uses the same `koalaman/shellcheck:stable` and `mvdan/shfmt:latest` containers. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. diff --git a/host-setup/linux/install-skills.sh b/host-setup/linux/install-skills.sh index 29bfc869..058fd216 100755 --- a/host-setup/linux/install-skills.sh +++ b/host-setup/linux/install-skills.sh @@ -50,14 +50,14 @@ EOF parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -r | --report) MODE="report" ;; - -n | --dry-run) DRY_RUN=true ;; - -y | --yes) ;; - -h | --help) - usage - exit 0 - ;; - *) die "Unknown option \"$1\", --help lists the options" ;; + -r | --report) MODE="report" ;; + -n | --dry-run) DRY_RUN=true ;; + -y | --yes) ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; esac shift done @@ -77,8 +77,8 @@ refuse_sudo() { find_python() { local candidate for candidate in python3 python; do - if command -v "$candidate" > /dev/null && - "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 7) else 1)' 2> /dev/null; then + if command -v "$candidate" >/dev/null && + "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 7) else 1)' 2>/dev/null; then printf '%s' "$candidate" return 0 fi diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index 076ce7dc..54fc22c3 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -131,16 +131,16 @@ detect_host() { DISTRO_ID="debian" fi - command -v apt-get > /dev/null || die "apt-get not found, this script installs apt packages" + command -v apt-get >/dev/null || die "apt-get not found, this script installs apt packages" ARCH=$(dpkg --print-architecture) # WSL has no kernel of its own, and docker there comes only from Docker Desktop's own WSL integration, never a native install. - if grep -qi microsoft /proc/version 2> /dev/null || [[ -n ${WSL_DISTRO_NAME:-} ]]; then + if grep -qi microsoft /proc/version 2>/dev/null || [[ -n ${WSL_DISTRO_NAME:-} ]]; then IS_WSL=true fi if [[ $EUID -ne 0 ]]; then - command -v sudo > /dev/null || die "Not running as root and sudo is not installed" + command -v sudo >/dev/null || die "Not running as root and sudo is not installed" SUDO=(sudo) fi } @@ -169,7 +169,7 @@ confirm() { # A minimal install carries no curl, and without this guard the caller sees "command not found" from inside a command substitution, which reads as an answer rather than as a failure. fetch() { - command -v curl > /dev/null || return 1 + command -v curl >/dev/null || return 1 curl -fsSL --retry 2 --connect-timeout 10 "$@" } @@ -177,14 +177,14 @@ fetch() { apt_installed_version() { local version - version=$(apt-cache policy "$1" 2> /dev/null | awk '/Installed:/ { print $2 }') + version=$(apt-cache policy "$1" 2>/dev/null | awk '/Installed:/ { print $2 }') [[ $version == "(none)" ]] && version="" printf '%s' "$version" } apt_candidate_version() { local version - version=$(apt-cache policy "$1" 2> /dev/null | awk '/Candidate:/ { print $2 }') + version=$(apt-cache policy "$1" 2>/dev/null | awk '/Candidate:/ { print $2 }') [[ $version == "(none)" ]] && version="" printf '%s' "$version" } @@ -218,7 +218,7 @@ apt_install() { # Whether a package is installed is asked of dpkg rather than of apt, here and in the sibling scripts, because dpkg answers about the installed state while apt-cache also carries a candidate, and the two disagree on a host whose lists are stale. # The candidate is still what apt_candidate_version reports, since that is the question the report asks. package_installed() { - dpkg-query -W -f='${Status}' "$1" 2> /dev/null | grep -q "^install ok installed" + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "^install ok installed" } apt_missing() { @@ -242,7 +242,7 @@ apt_install_displacing() { fi local -a removals=() - readarray -t removals < <(apt-get -s install "$package" 2> /dev/null | awk '/^Remv / { print $2 }') + readarray -t removals < <(apt-get -s install "$package" 2>/dev/null | awk '/^Remv / { print $2 }') if [[ ${#removals[@]} -gt 0 ]]; then log " Installing $package removes ${#removals[@]} package(s): ${removals[*]}" log " Their dependencies are left installed, for a later apt autoremove to clean up" @@ -263,18 +263,18 @@ install_keyring() { staged="$TMP_DIR/$(basename "$path")" if [[ $armored == true ]]; then - fetch "$url" | gpg --dearmor > "$staged" + fetch "$url" | gpg --dearmor >"$staged" else fetch -o "$staged" "$url" fi # The check is the reason to trust the key at all, so a host without gpgv stops here rather than installing the key unverified. # Prerequisites install it before any of this runs. - command -v gpgv > /dev/null || + command -v gpgv >/dev/null || die "gpgv is not installed, so the key at $url cannot be checked against the repository it signs" fetch -o "$TMP_DIR/InRelease" "$release_url" || die "Cannot read the repository metadata at $release_url" - gpgv --keyring "$staged" "$TMP_DIR/InRelease" > /dev/null 2>&1 || + gpgv --keyring "$staged" "$TMP_DIR/InRelease" >/dev/null 2>&1 || die "The key at $url does not sign the repository at $release_url, refusing to trust it" if [[ -f $path ]] && cmp -s "$staged" "$path"; then @@ -304,7 +304,7 @@ write_sources() { return 0 fi - printf '%s' "$content" | "${SUDO[@]}" tee "$path" > /dev/null + printf '%s' "$content" | "${SUDO[@]}" tee "$path" >/dev/null return 0 } @@ -384,8 +384,8 @@ gh_install() { jq_source() { printf 'jqlang/jq'; } jq_version() { - command -v jq > /dev/null || return 0 - jq --version 2> /dev/null | sed 's/^jq-//' + command -v jq >/dev/null || return 0 + jq --version 2>/dev/null | sed 's/^jq-//' } jq_target() { @@ -413,7 +413,7 @@ git_restore_mtime_version() { path=$(command -v git-restore-mtime) || return 0 version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$path" | head -1) if [[ -z $version ]]; then - version=$(git restore-mtime --version 2> /dev/null | awk '{ print $NF }') + version=$(git restore-mtime --version 2>/dev/null | awk '{ print $NF }') fi printf '%s' "$version" } @@ -454,7 +454,7 @@ node_lts_major() { local index="$TMP_DIR/node-index.json" version [[ -s $index ]] || fetch -o "$index" "https://nodejs.org/dist/index.json" || return 1 - if command -v jq > /dev/null; then + if command -v jq >/dev/null; then version=$(jq -r 'first(.[] | select(.lts != false)) | .version' "$index") else # One pass over the file rather than a pipeline: upstream serves this index without spaces today and nothing promises that, so the whitespace JSON allows is tolerated, and a reader that stopped early would leave its producer writing to a closed pipe under pipefail. @@ -523,9 +523,9 @@ ripgrep_target() { apt_candidate_version ripgrep; } ripgrep_download_path() { local resolved - resolved=$(type -P rg 2> /dev/null || true) + resolved=$(type -P rg 2>/dev/null || true) [[ $resolved == /* ]] || return 0 - dpkg-query -S "$resolved" > /dev/null 2>&1 || printf '%s' "$resolved" + dpkg-query -S "$resolved" >/dev/null 2>&1 || printf '%s' "$resolved" } ripgrep_remove_download() { @@ -551,8 +551,8 @@ ripgrep_install() { uv_source() { printf 'astral-sh/uv'; } uv_version() { - command -v uv > /dev/null || return 0 - uv --version 2> /dev/null | awk '{ print $2 }' + command -v uv >/dev/null || return 0 + uv --version 2>/dev/null | awk '{ print $2 }' } # Upstream tags without a leading v today, and the installed version never carries one, so the tag is normalized rather than trusted: a tag that gains a prefix would otherwise read as permanently outdated and re-download on every run. @@ -565,11 +565,11 @@ uv_target() { # Upstream names its assets by rust target triple rather than by dpkg architecture. uv_triple() { case "$ARCH" in - amd64) printf 'x86_64-unknown-linux-gnu' ;; - arm64) printf 'aarch64-unknown-linux-gnu' ;; - armhf) printf 'armv7-unknown-linux-gnueabihf' ;; - i386) printf 'i686-unknown-linux-gnu' ;; - *) return 1 ;; + amd64) printf 'x86_64-unknown-linux-gnu' ;; + arm64) printf 'aarch64-unknown-linux-gnu' ;; + armhf) printf 'armv7-unknown-linux-gnueabihf' ;; + i386) printf 'i686-unknown-linux-gnu' ;; + *) return 1 ;; esac } @@ -605,14 +605,14 @@ docker_source() { # The daemon is asked first and its own banner is the fallback, because the docker on PATH inside a WSL distribution can be a separately packaged client talking to Docker Desktop's engine, and the two carry different versions (issue #751 recorded a 29.1.3 client against a 29.7.2 engine). # A stopped or unreachable daemon makes the first reading exit non-zero, which is what the banner answers, so this reports the weaker number rather than nothing. docker_version() { - command -v docker > /dev/null || return 0 + command -v docker >/dev/null || return 0 local engine - engine=$(docker version --format '{{.Server.Version}}' 2> /dev/null) || engine="" + engine=$(docker version --format '{{.Server.Version}}' 2>/dev/null) || engine="" if [[ $engine =~ ^[0-9]+(\.[0-9]+)*$ ]]; then printf '%s' "$engine" return 0 fi - docker --version 2> /dev/null | sed -n 's/^Docker version \([0-9][0-9.]*\).*/\1/p' + docker --version 2>/dev/null | sed -n 's/^Docker version \([0-9][0-9.]*\).*/\1/p' } # Stripped of the epoch and the Debian package revision apt_candidate_version otherwise carries (e.g. "5:29.7.2-1~debian.13~trixie"), so this compares like for like against docker_version's plain reading rather than against dpkg's own packaging metadata. @@ -633,7 +633,7 @@ docker_install() { if [[ $IS_WSL == true ]]; then warn "This is a WSL distribution, and docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly. Enable it in Docker Desktop under Settings, Resources, WSL integration, or check it from Windows with setup-wsl.ps1 -Status. Skipping the native install." # A skip is success only where the integration already answers, since --install/--upgrade otherwise exits 0 having neither installed docker nor found it working. - command -v docker > /dev/null && return 0 + command -v docker >/dev/null && return 0 warn "docker is not on PATH here either, so Docker Desktop's WSL integration is not enabled for this distribution yet." return 1 fi @@ -683,7 +683,7 @@ dotnet_source() { # SDK packages apt can see, lowest major first. # An empty result means no feed carries one yet. dotnet_sdk_packages() { - apt-cache search --names-only '^dotnet-sdk-[0-9]+\.[0-9]+$' 2> /dev/null | awk '{ print $1 }' | sort -V + apt-cache search --names-only '^dotnet-sdk-[0-9]+\.[0-9]+$' 2>/dev/null | awk '{ print $1 }' | sort -V } dotnet_sdk_latest() { dotnet_sdk_packages | tail -1; } @@ -785,11 +785,11 @@ powershell_install() { # This applies the same ownership test as tool_unshadow to a shadowing copy. powershell_path_is_unowned() { local path="$1" status=0 - dpkg-query -S "$path" > /dev/null 2>&1 || status=$? + dpkg-query -S "$path" >/dev/null 2>&1 || status=$? case "$status" in - 0) return 1 ;; - 1) return 0 ;; - *) die "Cannot determine dpkg ownership of $path" ;; + 0) return 1 ;; + 1) return 0 ;; + *) die "Cannot determine dpkg ownership of $path" ;; esac } @@ -813,11 +813,11 @@ powershell_non_apt_paths() { # A relative PATH entry such as "." or "./bin" is never trusted. # This is the same guard used by tool_shadow_path. local resolved - resolved=$(type -P pwsh 2> /dev/null || true) + resolved=$(type -P pwsh 2>/dev/null || true) if [[ $resolved == /* ]] && powershell_path_is_unowned "$resolved"; then case "$resolved" in - /usr/local/bin/pwsh | /opt/microsoft/powershell/7/pwsh) ;; - *) die "Non-apt pwsh at $resolved is outside the known cleanup paths, remove it before installing powershell" ;; + /usr/local/bin/pwsh | /opt/microsoft/powershell/7/pwsh) ;; + *) die "Non-apt pwsh at $resolved is outside the known cleanup paths, remove it before installing powershell" ;; esac fi @@ -846,9 +846,9 @@ tool_function() { # A tool installed from the distro while its upstream repository is unconfigured reads as current against the distro's own version, which is the one thing the report must not say, so it reads as unmanaged instead. tool_configured() { case "$1" in - gh) [[ -f "$SOURCES_DIR/github-cli.sources" ]] ;; - node) [[ -f "$SOURCES_DIR/nodesource.sources" ]] ;; - *) return 0 ;; + gh) [[ -f "$SOURCES_DIR/github-cli.sources" ]] ;; + node) [[ -f "$SOURCES_DIR/nodesource.sources" ]] ;; + *) return 0 ;; esac } @@ -872,7 +872,7 @@ tool_status() { printf 'missing' return 0 fi - if [[ $installed == "$target" ]] || dpkg --compare-versions "$installed" ge "$target" 2> /dev/null; then + if [[ $installed == "$target" ]] || dpkg --compare-versions "$installed" ge "$target" 2>/dev/null; then printf 'current' else printf 'outdated' @@ -884,7 +884,7 @@ tool_status() { # "type -P" skips aliases and shell functions, which "command -v" answers for with no file behind them. tool_shadow_path() { local name="$1" resolved - resolved=$(type -P "$name" 2> /dev/null || true) + resolved=$(type -P "$name" 2>/dev/null || true) # A relative PATH entry (".", "./bin") makes this relative to the caller's current directory, not a real shadow. # What this returns gets removed by tool_unshadow, so only an absolute path is ever trusted as one. [[ $resolved == /* && $resolved != "$BIN_DIR/$name" ]] && printf '%s' "$resolved" @@ -897,70 +897,70 @@ tool_note() { local -a missing=() case "$tool" in - python) - local -a packages=() - readarray -t packages < <(python_packages) - readarray -t missing < <(apt_missing "${packages[@]}") + python) + local -a packages=() + readarray -t packages < <(python_packages) + readarray -t missing < <(apt_missing "${packages[@]}") + if [[ ${#missing[@]} -gt 0 ]]; then + note "python" "${#missing[@]} package(s) not installed: ${missing[*]}" + fi + if [[ $WITH_OPTIONAL == false ]]; then + note "python" "optional set not selected: ${PYTHON_OPTIONAL[*]}" + fi + ;; + ripgrep) + local resolved + resolved=$(ripgrep_download_path) + if [[ -n $resolved ]]; then + note "ripgrep" "$resolved is an unowned downloaded copy and an install or upgrade removes it before apt installs Ripgrep" + fi + ;; + dotnet) + local -a sdks=() + readarray -t sdks < <(dotnet_sdk_packages) + if [[ ${#sdks[@]} -eq 0 ]]; then + note "dotnet" "no SDK package available, Microsoft's feed is not configured" + else + readarray -t missing < <(apt_missing "${sdks[@]}") if [[ ${#missing[@]} -gt 0 ]]; then - note "python" "${#missing[@]} package(s) not installed: ${missing[*]}" - fi - if [[ $WITH_OPTIONAL == false ]]; then - note "python" "optional set not selected: ${PYTHON_OPTIONAL[*]}" - fi - ;; - ripgrep) - local resolved - resolved=$(ripgrep_download_path) - if [[ -n $resolved ]]; then - note "ripgrep" "$resolved is an unowned downloaded copy and an install or upgrade removes it before apt installs Ripgrep" + note "dotnet" "SDK line(s) not installed: ${missing[*]}" fi - ;; - dotnet) - local -a sdks=() - readarray -t sdks < <(dotnet_sdk_packages) - if [[ ${#sdks[@]} -eq 0 ]]; then - note "dotnet" "no SDK package available, Microsoft's feed is not configured" + fi + ;; + jq | uv | git-restore-mtime) + # A copy earlier on the PATH keeps answering after this script installs a newer one, which reads as an upgrade that did not take. + # --upgrade removes it. --install removes it only when nothing managed exists yet, and warns instead when it leaves one in place. + local resolved + resolved=$(tool_shadow_path "$tool") + if [[ -n $resolved ]]; then + if [[ -x "$BIN_DIR/$tool" ]]; then + note "$tool" "$resolved comes first on the PATH and shadows the managed copy at $BIN_DIR/$tool" else - readarray -t missing < <(apt_missing "${sdks[@]}") - if [[ ${#missing[@]} -gt 0 ]]; then - note "dotnet" "SDK line(s) not installed: ${missing[*]}" - fi - fi - ;; - jq | uv | git-restore-mtime) - # A copy earlier on the PATH keeps answering after this script installs a newer one, which reads as an upgrade that did not take. - # --upgrade removes it. --install removes it only when nothing managed exists yet, and warns instead when it leaves one in place. - local resolved - resolved=$(tool_shadow_path "$tool") - if [[ -n $resolved ]]; then - if [[ -x "$BIN_DIR/$tool" ]]; then - note "$tool" "$resolved comes first on the PATH and shadows the managed copy at $BIN_DIR/$tool" - else - note "$tool" "$resolved is installed outside $BIN_DIR and keeps answering once the managed copy is installed" - fi - fi - ;; - gh | node) - local name="github-cli" - if [[ $tool == "node" ]]; then - name="nodesource" - fi - if [[ ! -f "$SOURCES_DIR/$name.sources" ]]; then - note "$tool" "upstream repository not configured, the available version is the distro's" + note "$tool" "$resolved is installed outside $BIN_DIR and keeps answering once the managed copy is installed" fi - ;; - docker) - if [[ $IS_WSL == true ]]; then - note "docker" "this is a WSL distribution, docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly, so --install/--upgrade skip it" - fi - ;; - powershell) - # A host without the feed reads no candidate at all, since the distro never carries a powershell package, so the note says why rather than reading as absent. - if [[ ! -f "$SOURCES_DIR/microsoft-prod.sources" && ! -f "$SOURCES_DIR/microsoft-prod.list" ]]; then - note "powershell" "Microsoft's repository is not configured, so no package is available yet" - fi - ;; - *) ;; + fi + ;; + gh | node) + local name="github-cli" + if [[ $tool == "node" ]]; then + name="nodesource" + fi + if [[ ! -f "$SOURCES_DIR/$name.sources" ]]; then + note "$tool" "upstream repository not configured, the available version is the distro's" + fi + ;; + docker) + if [[ $IS_WSL == true ]]; then + note "docker" "this is a WSL distribution, docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly, so --install/--upgrade skip it" + fi + ;; + powershell) + # A host without the feed reads no candidate at all, since the distro never carries a powershell package, so the note says why rather than reading as absent. + if [[ ! -f "$SOURCES_DIR/microsoft-prod.sources" && ! -f "$SOURCES_DIR/microsoft-prod.list" ]]; then + note "powershell" "Microsoft's repository is not configured, so no package is available yet" + fi + ;; + *) ;; esac return 0 @@ -972,7 +972,7 @@ report() { # shellcheck disable=SC2059 # Format string is a constant defined above. printf "$format" "TOOL" "INSTALLED" "AVAILABLE" "SOURCE" "STATUS" - if ! command -v curl > /dev/null; then + if ! command -v curl >/dev/null; then note "report" "curl is not installed, so an upstream that is not an apt repository cannot be read yet" fi @@ -985,8 +985,8 @@ report() { printf "$format" "$tool" "${installed:--}" "${target:--}" "apt:${REPO_PACKAGES[$tool]}" "$(tool_status "$installed" "$target")" continue fi - installed=$("$(tool_function "$tool" version)" 2> /dev/null || true) - target=$("$(tool_function "$tool" target)" 2> /dev/null || true) + installed=$("$(tool_function "$tool" version)" 2>/dev/null || true) + target=$("$(tool_function "$tool" target)" 2>/dev/null || true) # shellcheck disable=SC2059 # Format string is a constant defined above. printf "$format" "$tool" "${installed:--}" "${target:--}" \ "$("$(tool_function "$tool" source)")" "$(tool_effective_status "$tool" "$installed" "$target")" @@ -1008,13 +1008,13 @@ tool_unshadow() { local tool="$1" local -a names=() case "$tool" in - jq | git-restore-mtime) names=("$tool") ;; - ripgrep) - ripgrep_remove_download - return 0 - ;; - uv) names=(uv uvx) ;; - *) return 0 ;; + jq | git-restore-mtime) names=("$tool") ;; + ripgrep) + ripgrep_remove_download + return 0 + ;; + uv) names=(uv uvx) ;; + *) return 0 ;; esac # A loop, not one check, since PATH can stack more than one shadow ahead of $BIN_DIR. @@ -1027,7 +1027,7 @@ tool_unshadow() { # A distro package's own file, found only when PATH puts it ahead of $BIN_DIR, which this script does not set up. # Removing it directly would desync dpkg's database from the filesystem, so it stays, and the fix is the PATH order. - if dpkg-query -S "$resolved" > /dev/null 2>&1; then + if dpkg-query -S "$resolved" >/dev/null 2>&1; then warn "$tool: $resolved belongs to a distro package and stays, put $BIN_DIR ahead of it on PATH instead" break fi @@ -1061,7 +1061,7 @@ apply_tool() { installed=$(apt_installed_version "$package") target=$(apt_candidate_version "$package") status=$(tool_status "$installed" "$target") - if [[ $status == "current" || ( $MODE == "install" && $status == "outdated" ) ]]; then + if [[ $status == "current" || ($MODE == "install" && $status == "outdated") ]]; then log "$tool: $status at ${installed}, leaving it alone" return 0 fi @@ -1082,8 +1082,8 @@ apply_tool() { log "$tool: still shadowed on PATH, --upgrade removes it, --install leaves it to avoid downgrading what's shadowing it" fi - installed=$("$(tool_function "$tool" version)" 2> /dev/null || true) - target=$("$(tool_function "$tool" target)" 2> /dev/null || true) + installed=$("$(tool_function "$tool" version)" 2>/dev/null || true) + target=$("$(tool_function "$tool" target)" 2>/dev/null || true) status=$(tool_effective_status "$tool" "$installed" "$target") # A package set can be part installed while the tool that names it reads as current, so python and dotnet always carry on into the install and the mode decides what apt is asked to do. @@ -1118,7 +1118,7 @@ apply_tool() { fi local now - now=$("$(tool_function "$tool" version)" 2> /dev/null || true) + now=$("$(tool_function "$tool" version)" 2>/dev/null || true) if [[ $now != "$installed" ]]; then CHANGED+=("$tool ${installed:--} -> ${now:--}") fi @@ -1188,7 +1188,7 @@ load_repo_tools() { [[ -n $REPO ]] || return 0 local declaration="$REPO/host-tools.json" [[ -f $declaration ]] || die "$REPO carries no host-tools.json" - command -v jq > /dev/null || die "--repo needs jq to read constrained package metadata. Install the fleet tools first, then run this command again." + command -v jq >/dev/null || die "--repo needs jq to read constrained package metadata. Install the fleet tools first, then run this command again." local name package manager rows key rows=$(jq -r ' @@ -1216,7 +1216,7 @@ load_repo_tools() { done [[ $known == false ]] || die "$name is already managed by the fleet installer and repository metadata cannot replace it" MANAGED_TOOLS+=("$name") - done <<< "$rows" + done <<<"$rows" } resolve_selection() { @@ -1265,7 +1265,7 @@ sudo_target_user() { # The drop-in, scoped to one user, so every other account on this host keeps sudo's per-terminal default. sudo_timestamp_content() { local user="$1" - cat << EOF + cat < /dev/null || return 0 + command -v update-alternatives >/dev/null || return 0 local query - query=$(update-alternatives --query sudo 2> /dev/null) || return 0 - awk '$1 == "Alternative:" { alt = $2 } alt != "" && $1 == "visudo" { print alt, $2 }' <<< "$query" + query=$(update-alternatives --query sudo 2>/dev/null) || return 0 + awk '$1 == "Alternative:" { alt = $2 } alt != "" && $1 == "visudo" { print alt, $2 }' <<<"$query" } # A visudo named by a bare name resolved to a path, since a Debian host keeps /usr/sbin off an unprivileged PATH and the name alone would read there as an implementation that is not installed. @@ -1289,7 +1289,7 @@ sudo_visudo_path() { printf '%s' "$name" return 0 fi - if candidate=$(command -v "$name" 2> /dev/null); then + if candidate=$(command -v "$name" 2>/dev/null); then printf '%s' "$candidate" return 0 fi @@ -1307,7 +1307,7 @@ sudo_visudo_path() { sudo_parses() { local file="$1" checker checker=$(sudo_visudo_path "$2") || return 1 - "$checker" -cqf "$file" > /dev/null 2>&1 + "$checker" -cqf "$file" >/dev/null 2>&1 } # What sudo itself reports as in effect, which is the only answer that accounts for the order it reads the drop-ins in. @@ -1316,16 +1316,16 @@ sudo_timestamp_report() { local user="$1" defaults="" effective="" if [[ $EUID -eq 0 ]]; then - defaults=$(sudo -n -l -U "$user" 2> /dev/null) || defaults="" + defaults=$(sudo -n -l -U "$user" 2>/dev/null) || defaults="" else - defaults=$(sudo -n -l 2> /dev/null) || defaults="" + defaults=$(sudo -n -l 2>/dev/null) || defaults="" fi # An unreadable list and a list naming no timestamp option are different answers, and only the raw read separates them. if [[ -z $defaults ]]; then info "Could not read the settings back, so check them with \"sudo -l\" as $user" return 0 fi - effective=$(grep -oE 'timestamp_(type|timeout)=[^, ]+' <<< "$defaults" | tr '\n' ' ') || effective="" + effective=$(grep -oE 'timestamp_(type|timeout)=[^, ]+' <<<"$defaults" | tr '\n' ' ') || effective="" effective="${effective% }" # A dry run reports the state it found rather than one this run reached, since nothing was written to reach it. @@ -1350,8 +1350,8 @@ sudo_timestamp_user_re() { for ((i = 0; i < ${#user}; i++)); do c="${user:i:1}" case "$c" in - '.' | '[' | $'\\' | '^' | '$' | '(' | ')' | '*' | '+' | '?' | '{' | '}' | '|') out+="\\$c" ;; - *) out+="$c" ;; + '.' | '[' | $'\\' | '^' | '$' | '(' | ')' | '*' | '+' | '?' | '{' | '}' | '|') out+="\\$c" ;; + *) out+="$c" ;; esac done printf '%s' "$out" @@ -1365,7 +1365,7 @@ sudo_timestamp_file_is_pure() { user_re=$(sudo_timestamp_user_re "$user") # $ here is bash's own end-of-string, and the double quotes strip a backslash before it, so grep receives a plain $ (ERE's end-of-line anchor), never a literal one. allow="^[[:space:]]*(#.*)?$|^[[:space:]]*Defaults:${user_re}[[:space:]]+timestamp_(type|timeout)=[^,[:space:]]+(,[[:space:]]*timestamp_(type|timeout)=[^,[:space:]]+)*[[:space:]]*$" - "${SUDO[@]}" grep -vE "$allow" "$file" > /dev/null 2>&1 && status=0 || status=$? + "${SUDO[@]}" grep -vE "$allow" "$file" >/dev/null 2>&1 && status=0 || status=$? # Exit 1 means every line matched the allowlist (pure), 0 means one did not (impure), and anything else is a read failure this cannot tell apart from either, so it is never treated as pure. [[ $status -eq 1 ]] } @@ -1374,12 +1374,12 @@ sudo_timestamp_file_is_pure() { configure_sudo_timestamp() { local user staged user=$(sudo_target_user) - id -u "$user" > /dev/null 2>&1 || die "This host has no account named \"$user\"" + id -u "$user" >/dev/null 2>&1 || die "This host has no account named \"$user\"" log "Sudo timestamp: one credential cache for $user across every terminal, valid $SUDO_TIMESTAMP_TIMEOUT minutes" staged="$TMP_DIR/sudo-timestamp" - sudo_timestamp_content "$user" > "$staged" + sudo_timestamp_content "$user" >"$staged" # The implementation already in place is preferred, so a host carrying the original sudo changes nothing but the drop-in. local switch_to="" switch_checker="" alternative checker @@ -1397,24 +1397,24 @@ configure_sudo_timestamp() { fi # A parse error in any file sudo reads makes every sudo on the host fail, so the set is proved to parse before this adds to it. - "${SUDO[@]}" visudo -cq > /dev/null 2>&1 || + "${SUDO[@]}" visudo -cq >/dev/null 2>&1 || die "This host's sudoers does not parse as it stands, so fix that before adding to it (\"visudo -c\" names the file)" # An implementation this run would switch to has to parse the set as well, since switching to one that cannot is what locks every user out. if [[ -n $switch_checker ]]; then - "${SUDO[@]}" "$switch_checker" -cq > /dev/null 2>&1 || + "${SUDO[@]}" "$switch_checker" -cq >/dev/null 2>&1 || die "$switch_to does not parse this host's sudoers, so switching to it would lock every user out. This host is unchanged." fi local own_current=false - "${SUDO[@]}" cmp -s "$staged" "$SUDOERS_FILE" 2> /dev/null && own_current=true + "${SUDO[@]}" cmp -s "$staged" "$SUDOERS_FILE" 2>/dev/null && own_current=true # Another file setting either option is named rather than merged into, since which one wins is the order sudo reads them in and not something this can decide. local elsewhere # A name holding a dot or ending in a tilde is one sudo skips, this run's own staged file included, so a setting in it is an override sudo never reads. elsewhere=$("${SUDO[@]}" grep -rnsE '^[[:space:]]*Defaults.*timestamp_(type|timeout)' \ --exclude='*.*' --exclude='*~' --exclude="${SUDOERS_FILE##*/}" \ - /etc/sudoers /etc/sudoers.d 2> /dev/null) || elsewhere="" + /etc/sudoers /etc/sudoers.d 2>/dev/null) || elsewhere="" # Only this user's own entry is ever a delete candidate; a different user's entry, or one with no user named at all, changes something beyond what this run was asked to change, so it is reported and left alone. local -a delete_files=() unsafe_files=() @@ -1429,7 +1429,7 @@ configure_sudo_timestamp() { else unsafe_files+=("$candidate") fi - done < <(grep -E "Defaults:${user_re}[[:space:]]+.*timestamp_(type|timeout)=" <<< "$elsewhere" | awk -F: '{print $1}' | sort -u) + done < <(grep -E "Defaults:${user_re}[[:space:]]+.*timestamp_(type|timeout)=" <<<"$elsewhere" | awk -F: '{print $1}' | sort -u) fi # A standing unsafe file still reaches the die below even when this run's own file needs no change, since silently returning here would report success over a same-user conflict this cannot resolve on its own. @@ -1444,7 +1444,7 @@ configure_sudo_timestamp() { local line while read -r line; do info "$line" - done <<< "$elsewhere" + done <<<"$elsewhere" fi if [[ ${#unsafe_files[@]} -gt 0 ]]; then @@ -1474,7 +1474,7 @@ configure_sudo_timestamp() { local pending="${SUDOERS_FILE%/*}/.${SUDOERS_FILE##*/}.pending" run_root install -m 0440 -o root -g root "$staged" "$pending" if [[ $DRY_RUN == false ]]; then - "${SUDO[@]}" visudo -cqf "$pending" > /dev/null 2>&1 || { + "${SUDO[@]}" visudo -cqf "$pending" >/dev/null 2>&1 || { run_root rm -f "$pending" die "The staged drop-in does not parse where sudo would read it, so this host is unchanged" } @@ -1494,7 +1494,7 @@ configure_sudo_timestamp() { return 0 fi - "${SUDO[@]}" visudo -cq > /dev/null 2>&1 || + "${SUDO[@]}" visudo -cq >/dev/null 2>&1 || die "sudoers stopped parsing once this run's changes landed. Remove $SUDOERS_FILE from a root shell to restore sudo, and recreate ${delete_files[*]:-any file this run deleted} if it turns out to have been needed." [[ $own_current == false ]] && log "Wrote $SUDOERS_FILE" @@ -1508,27 +1508,27 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -r | --report) actions+=(report) ;; - -i | --install) actions+=(install) ;; - -u | --upgrade) actions+=(upgrade) ;; - -l | --list) actions+=(list) ;; - --sudo-timestamp) actions+=(sudo-timestamp) ;; - -n | --dry-run) DRY_RUN=true ;; - -y | --yes) ASSUME_YES=true ;; - -o | --optional) WITH_OPTIONAL=true ;; - --repo) - [[ $# -gt 1 ]] || die "--repo needs a path" - REPO="$2" - shift - ;; - -h | --help) - usage - exit 0 - ;; - -*) die "Unknown option \"$1\", --help lists the options" ;; - *) - REQUESTED+=("$1") - ;; + -r | --report) actions+=(report) ;; + -i | --install) actions+=(install) ;; + -u | --upgrade) actions+=(upgrade) ;; + -l | --list) actions+=(list) ;; + --sudo-timestamp) actions+=(sudo-timestamp) ;; + -n | --dry-run) DRY_RUN=true ;; + -y | --yes) ASSUME_YES=true ;; + -o | --optional) WITH_OPTIONAL=true ;; + --repo) + [[ $# -gt 1 ]] || die "--repo needs a path" + REPO="$2" + shift + ;; + -h | --help) + usage + exit 0 + ;; + -*) die "Unknown option \"$1\", --help lists the options" ;; + *) + REQUESTED+=("$1") + ;; esac shift done @@ -1562,9 +1562,9 @@ main() { detect_host case "$MODE" in - report) report ;; - install | upgrade) apply ;; - sudo-timestamp) configure_sudo_timestamp ;; + report) report ;; + install | upgrade) apply ;; + sudo-timestamp) configure_sudo_timestamp ;; esac } diff --git a/host-setup/linux/setup-github.sh b/host-setup/linux/setup-github.sh index a8e87e08..652ca7c7 100755 --- a/host-setup/linux/setup-github.sh +++ b/host-setup/linux/setup-github.sh @@ -108,14 +108,14 @@ confirm() { } fetch() { - command -v curl > /dev/null || return 1 + command -v curl >/dev/null || return 1 curl -fsSL --retry 2 --connect-timeout 10 "$@" } # --- Prerequisites --- package_installed() { - dpkg-query -W -f='${Status}' "$1" 2> /dev/null | grep -q "^install ok installed" + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "^install ok installed" } prerequisites_missing() { @@ -132,7 +132,7 @@ ensure_prerequisites() { [[ ${#packages[@]} -eq 0 ]] && return 0 # Everything above this point works anywhere, and installing is the first step that does not, so a host without apt is told that rather than meeting a missing command part way through. - command -v apt-get > /dev/null || + command -v apt-get >/dev/null || die "apt-get not found, and installing ${packages[*]} needs it. Install them with this host's package manager, then run this again." step "Installing ${packages[*]}" @@ -164,7 +164,7 @@ ensure_key() { info "[dry run] ssh-keygen -y -f $KEY > $KEY.pub" return 0 fi - ssh-keygen -y -f "$KEY" > "$KEY.pub" + ssh-keygen -y -f "$KEY" >"$KEY.pub" chmod 644 "$KEY.pub" return 0 fi @@ -179,24 +179,24 @@ ensure_key() { # The first enrollment is the one moment a substituted host key would be accepted for good, and every later connection trusts whatever was recorded here. # So what github.com offers is checked against the fingerprints GitHub publishes before it is written down, and a check that cannot run is a refusal rather than a warning, since recording an unchecked key is what this prevents. ensure_known_host() { - ssh-keygen -F github.com > /dev/null 2>&1 && return 0 + ssh-keygen -F github.com >/dev/null 2>&1 && return 0 step "Trusting the github.com host key" local scanned offered published fingerprint - scanned=$(ssh-keyscan github.com 2> /dev/null) || die "github.com did not answer a host key scan" + scanned=$(ssh-keyscan github.com 2>/dev/null) || die "github.com did not answer a host key scan" [[ -n $scanned ]] || die "github.com offered no host key" - offered=$(ssh-keygen -lf - <<< "$scanned" | awk '{ print $2 }' | sed 's/^SHA256://' | sort -u) + offered=$(ssh-keygen -lf - <<<"$scanned" | awk '{ print $2 }' | sed 's/^SHA256://' | sort -u) published=$(fetch "https://api.github.com/meta" | tr ',' '\n' | sed -n 's/.*"SHA256_[A-Z0-9]*": *"\([^"]*\)".*/\1/p' | sort -u) || published="" [[ -n $published ]] || die "Cannot read the host key fingerprints GitHub publishes, so the key it offered cannot be checked. Compare it by hand against https://docs.github.com/authentication/keeping-your-account-secure/githubs-ssh-key-fingerprints and record it with ssh-keyscan." while read -r fingerprint; do - grep -qxF "$fingerprint" <<< "$published" || + grep -qxF "$fingerprint" <<<"$published" || die "github.com offered a host key GitHub does not publish (SHA256:$fingerprint), so it is not being recorded" - done <<< "$offered" + done <<<"$offered" info "The offered host key matches what GitHub publishes" run mkdir -p "$HOME/.ssh" @@ -205,7 +205,7 @@ ensure_known_host() { info "[dry run] record the checked host key in $HOME/.ssh/known_hosts" return 0 fi - printf '%s\n' "$scanned" >> "$HOME/.ssh/known_hosts" + printf '%s\n' "$scanned" >>"$HOME/.ssh/known_hosts" } # --- GitHub, read only --- @@ -274,7 +274,7 @@ github_auth_key_registered() { local body keys body=$(key_body) || return 2 keys=$(fetch "https://github.com/$GITHUB_USER.keys") || return 2 - grep -qxF "$body" <<< "$keys" + grep -qxF "$body" <<<"$keys" } github_signing_key_registered() { @@ -286,11 +286,11 @@ github_signing_key_registered() { # Under pipefail that became the pipeline's status and mapped to "could not be read", which reported a definite no as a network problem and skipped the registration prompt. # An account with no signing key is exactly the account this function exists to prompt. # Grep's 1 is therefore an empty list and only a higher status is a failure to read. - matches=$(grep -oE '"key": *"[^"]*"' <<< "$payload") || rc=$? + matches=$(grep -oE '"key": *"[^"]*"' <<<"$payload") || rc=$? ((rc <= 1)) || return 2 - keys=$(sed 's/"key": *"//; s/"$//' <<< "$matches") + keys=$(sed 's/"key": *"//; s/"$//' <<<"$matches") - grep -qxF "$body" <<< "$keys" + grep -qxF "$body" <<<"$keys" } # A registration is a browser step, so this prints what to paste and where, then stops. @@ -316,7 +316,7 @@ registration_needed() { # --- git configuration --- -git_config_get() { git config --global --get "$1" 2> /dev/null || true; } +git_config_get() { git config --global --get "$1" 2>/dev/null || true; } # Set a value only where it differs, so a re-run is silent rather than rewriting the same file. git_config_set() { @@ -331,7 +331,7 @@ git_config_set() { # The safe.directory setting is multi valued, so setting it again appends a duplicate rather than replacing it. git_config_add_once() { local key="$1" value="$2" - git config --global --get-all "$key" 2> /dev/null | grep -qxF "$value" && return 1 + git config --global --get-all "$key" 2>/dev/null | grep -qxF "$value" && return 1 run git config --global --add "$key" "$value" return 0 @@ -409,13 +409,13 @@ configure_signing() { # The allowed signers file is what verifies a signature locally, and it is appended to rather than rewritten, since it can carry other identities. local entry - entry="$EMAIL namespaces=\"git\" $(cat "$KEY.pub" 2> /dev/null || true)" - if ! grep -qxF "$entry" "$ALLOWED_SIGNERS" 2> /dev/null; then + entry="$EMAIL namespaces=\"git\" $(cat "$KEY.pub" 2>/dev/null || true)" + if ! grep -qxF "$entry" "$ALLOWED_SIGNERS" 2>/dev/null; then run mkdir -p "$(dirname "$ALLOWED_SIGNERS")" if [[ $DRY_RUN == true ]]; then info "[dry run] append this host's key to $ALLOWED_SIGNERS" else - printf '%s\n' "$entry" >> "$ALLOWED_SIGNERS" + printf '%s\n' "$entry" >>"$ALLOWED_SIGNERS" fi changed=$((changed + 1)) fi @@ -434,16 +434,16 @@ signing_works() { local repo="$TMP_DIR/signing-check" rm -rf "$repo" mkdir -p "$repo" - git init -q "$repo" 2> /dev/null || return 1 - git -C "$repo" commit -q --allow-empty -m "signing check" > /dev/null 2>&1 || return 1 - git -C "$repo" verify-commit HEAD > /dev/null 2>&1 + git init -q "$repo" 2>/dev/null || return 1 + git -C "$repo" commit -q --allow-empty -m "signing check" >/dev/null 2>&1 || return 1 + git -C "$repo" verify-commit HEAD >/dev/null 2>&1 } # --- Actions --- status() { log "Host identity" - if command -v git > /dev/null; then + if command -v git >/dev/null; then ok "git installed" else missing "git installed, --configure installs it, so the settings below read as unset" @@ -455,7 +455,7 @@ status() { fi local known=true - ssh-keygen -F github.com > /dev/null 2>&1 || known=false + ssh-keygen -F github.com >/dev/null 2>&1 || known=false resolve_github_user if [[ -n $GITHUB_USER ]]; then @@ -482,17 +482,17 @@ status() { local rc=0 github_auth_key_registered || rc=$? case "$rc" in - 0) ok "Authentication key registered" ;; - 2) missing "Authentication key registered, GitHub could not be reached to check" ;; - *) missing "Authentication key registered" ;; + 0) ok "Authentication key registered" ;; + 2) missing "Authentication key registered, GitHub could not be reached to check" ;; + *) missing "Authentication key registered" ;; esac rc=0 github_signing_key_registered || rc=$? case "$rc" in - 0) ok "Signing key registered" ;; - 2) missing "Signing key registered, GitHub could not be reached to check" ;; - *) missing "Signing key registered, commits show as unverified on GitHub" ;; + 0) ok "Signing key registered" ;; + 2) missing "Signing key registered, GitHub could not be reached to check" ;; + *) missing "Signing key registered, commits show as unverified on GitHub" ;; esac fi @@ -511,7 +511,7 @@ status() { # The shared checkout settings are reported apart, because absent is the right state for a host one account uses and listing them as missing would read as two gaps to close. local -a shared=() - readarray -t shared < <(git config --global --get-all safe.directory 2> /dev/null || true) + readarray -t shared < <(git config --global --get-all safe.directory 2>/dev/null || true) if [[ ${#shared[@]} -gt 0 ]]; then local -a unique=() readarray -t unique < <(printf '%s\n' "${shared[@]}" | sort -u) @@ -580,12 +580,12 @@ configure() { local signing_rc=0 github_signing_key_registered || signing_rc=$? case "$signing_rc" in - 0) info "Registered as a signing key" ;; - 2) warn "GitHub could not be reached, so whether the signing key is registered is unknown" ;; - *) - registration_needed "signing" "Signing" - warn "Commits sign locally but show as unverified on GitHub until the key is registered" - ;; + 0) info "Registered as a signing key" ;; + 2) warn "GitHub could not be reached, so whether the signing key is registered is unknown" ;; + *) + registration_needed "signing" "Signing" + warn "Commits sign locally but show as unverified on GitHub until the key is registered" + ;; esac step "Done" @@ -598,30 +598,30 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -s | --status) actions+=(status) ;; - -c | --configure) actions+=(configure) ;; - -n | --dry-run) DRY_RUN=true ;; - -y | --yes) ASSUME_YES=true ;; - --name) - [[ $# -ge 2 ]] || die "--name takes a value" - NAME="$2" - shift - ;; - --email) - [[ $# -ge 2 ]] || die "--email takes a value" - EMAIL="$2" - shift - ;; - --shared-checkout) - [[ $# -ge 2 ]] || die "--shared-checkout takes the path of the shared checkout" - SHARED_CHECKOUT="$2" - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) die "Unknown option \"$1\", --help lists the options" ;; + -s | --status) actions+=(status) ;; + -c | --configure) actions+=(configure) ;; + -n | --dry-run) DRY_RUN=true ;; + -y | --yes) ASSUME_YES=true ;; + --name) + [[ $# -ge 2 ]] || die "--name takes a value" + NAME="$2" + shift + ;; + --email) + [[ $# -ge 2 ]] || die "--email takes a value" + EMAIL="$2" + shift + ;; + --shared-checkout) + [[ $# -ge 2 ]] || die "--shared-checkout takes the path of the shared checkout" + SHARED_CHECKOUT="$2" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; esac shift done @@ -638,16 +638,16 @@ main() { parse_args "$@" if [[ $EUID -ne 0 ]]; then - command -v sudo > /dev/null || die "Not running as root and sudo is not installed" + command -v sudo >/dev/null || die "Not running as root and sudo is not installed" SUDO=(sudo) fi case "$MODE" in - status) status ;; - configure) - confirm "Configure git and GitHub on this host?" || die "Declined" - configure - ;; + status) status ;; + configure) + confirm "Configure git and GitHub on this host?" || die "Declined" + configure + ;; esac } diff --git a/host-setup/linux/upgrade-host.sh b/host-setup/linux/upgrade-host.sh index 5b0cb34e..95abeb64 100755 --- a/host-setup/linux/upgrade-host.sh +++ b/host-setup/linux/upgrade-host.sh @@ -81,20 +81,20 @@ detect_host() { DISTRO_VERSION="${VERSION_ID:-unknown}" CODENAME="${VERSION_CODENAME:-}" - command -v apt-get > /dev/null || die "apt-get not found, this script upgrades apt based hosts" + command -v apt-get >/dev/null || die "apt-get not found, this script upgrades apt based hosts" # Proxmox reports itself as its Debian base, so the marker is its own tooling. - if command -v pveversion > /dev/null; then + if command -v pveversion >/dev/null; then IS_PROXMOX=true fi # WSL has no kernel of its own to reboot into, and the shutdown is driven from Windows. - if grep -qi microsoft /proc/version 2> /dev/null || [[ -n ${WSL_DISTRO_NAME:-} ]]; then + if grep -qi microsoft /proc/version 2>/dev/null || [[ -n ${WSL_DISTRO_NAME:-} ]]; then IS_WSL=true fi if [[ $EUID -ne 0 ]]; then - command -v sudo > /dev/null || die "Not running as root and sudo is not installed" + command -v sudo >/dev/null || die "Not running as root and sudo is not installed" SUDO=(sudo) fi @@ -139,7 +139,7 @@ confirm() { # A minimal install carries no curl, and without this guard the caller sees "command not found" from inside a command substitution, which reads as an answer rather than as a failure. fetch() { - command -v curl > /dev/null || return 1 + command -v curl >/dev/null || return 1 curl -fsSL --retry 2 --connect-timeout 10 "$@" } @@ -166,15 +166,15 @@ upgrade_packages() { # Snap runs on an Ubuntu host with snapd active, which a container and a WSL distribution usually are not. refresh_snaps() { - command -v snap > /dev/null || return 0 - systemctl is-active --quiet snapd 2> /dev/null || return 0 + command -v snap >/dev/null || return 0 + systemctl is-active --quiet snapd 2>/dev/null || return 0 step "Refreshing snaps" run_root snap refresh } upgradable_count() { - apt list --upgradable 2> /dev/null | grep -c '/' || true + apt list --upgradable 2>/dev/null | grep -c '/' || true } # --- Reboot --- @@ -189,7 +189,7 @@ reboot_required() { # Debian does not always write the marker, so compare what is installed against what is running. local running latest running=$(uname -r) - latest=$(find /boot -maxdepth 1 -name 'vmlinuz-*' -printf '%f\n' 2> /dev/null | + latest=$(find /boot -maxdepth 1 -name 'vmlinuz-*' -printf '%f\n' 2>/dev/null | sed 's/^vmlinuz-//' | sort -V | tail -1) || return 1 [[ -n $latest && $latest != "$running" ]] } @@ -209,7 +209,7 @@ report_reboot() { if reboot_required; then info "Restart needed, running $(uname -r)" - [[ -f /var/run/reboot-required.pkgs ]] && info "Asked for by: $(tr '\n' ' ' < /var/run/reboot-required.pkgs)" + [[ -f /var/run/reboot-required.pkgs ]] && info "Asked for by: $(tr '\n' ' ' /dev/null | tr '\n' ' ') + held=$(apt-mark showhold 2>/dev/null | tr '\n' ' ') if [[ -n ${held// /} ]]; then die "Held packages block a release upgrade, unhold them first: $held" fi local audit - audit=$("${SUDO[@]}" dpkg --audit 2> /dev/null || true) + audit=$("${SUDO[@]}" dpkg --audit 2>/dev/null || true) if [[ -n $audit ]]; then die "dpkg reports half-configured packages, fix them first: $audit" fi @@ -260,7 +260,7 @@ release_preconditions() { # A status report says so rather than installing anything, and a release run is already changing the host, so it installs. ensure_release_prerequisites() { [[ $DISTRO_ID == "debian" ]] || return 0 - command -v curl > /dev/null && return 0 + command -v curl >/dev/null && return 0 step "Installing curl, which the release index is read with" apt_get update @@ -291,8 +291,8 @@ backup_sources() { # Under pipefail a reader that stops early, as a parser looking for one line does, leaves the fetch writing to a closed pipe, and a successful download then reports itself as a failure. debian_suite_codename() { local release - release=$(fetch "https://deb.debian.org/debian/dists/$1/Release" 2> /dev/null) || return 1 - awk '/^Codename:/ { print $2; exit }' <<< "$release" + release=$(fetch "https://deb.debian.org/debian/dists/$1/Release" 2>/dev/null) || return 1 + awk '/^Codename:/ { print $2; exit }' <<<"$release" } # The release after the one this host is on. @@ -408,11 +408,11 @@ debian_rewrite_sources() { } /^[[:space:]]*$/ { flush(); print ""; next } { buf[++n] = $0 } - END { flush() }' "$file" > "$staged" + END { flush() }' "$file" >"$staged" else # shellcheck disable=SC2016 # $0 belongs to awk, not to the shell. awk -v from="$from" -v to="$to" \ - '{ if ($0 ~ /\.debian\.org/) gsub(from, to); print }' "$file" > "$staged" + '{ if ($0 ~ /\.debian\.org/) gsub(from, to); print }' "$file" >"$staged" fi run_root install -m 0644 "$staged" "$file" @@ -475,7 +475,7 @@ debian_release_upgrade() { ubuntu_release_upgrade() { step "Checking for a new Ubuntu release" - if ! command -v do-release-upgrade > /dev/null; then + if ! command -v do-release-upgrade >/dev/null; then apt_get install update-manager-core fi @@ -510,38 +510,38 @@ release_summary() { fi case "$DISTRO_ID" in - debian) - local next status=0 - next=$(debian_next_codename "$CODENAME") || status=$? - case "$status" in - 0) printf '%s to %s' "$CODENAME" "$next" ;; - 2) - # The two reasons have different remedies, so they are reported apart. - if command -v curl > /dev/null; then - printf 'unknown, the Debian release index at deb.debian.org could not be reached' - else - printf 'unknown, curl is not installed and the Debian release index is read with it' - fi - ;; - *) printf '%s is the current stable release, nothing to move to' "$CODENAME" ;; - esac - ;; - ubuntu) - if ! command -v do-release-upgrade > /dev/null; then - printf 'unknown until update-manager-core is installed' - return 0 - fi - # The check exits non-zero when nothing is offered, and prints its progress either way, so the exit code decides and the output only names the release. - # It reads the same as any user, so it runs without sudo and a status report never asks for a password. - local check summary - if check=$(do-release-upgrade -c 2>&1); then - summary=$(grep -i -m 1 "new release" <<< "$check" || true) - printf '%s' "${summary:-a new release is offered}" + debian) + local next status=0 + next=$(debian_next_codename "$CODENAME") || status=$? + case "$status" in + 0) printf '%s to %s' "$CODENAME" "$next" ;; + 2) + # The two reasons have different remedies, so they are reported apart. + if command -v curl >/dev/null; then + printf 'unknown, the Debian release index at deb.debian.org could not be reached' else - printf "none offered under this host's upgrade policy (/etc/update-manager/release-upgrades)" + printf 'unknown, curl is not installed and the Debian release index is read with it' fi ;; - *) printf 'not supported on this host' ;; + *) printf '%s is the current stable release, nothing to move to' "$CODENAME" ;; + esac + ;; + ubuntu) + if ! command -v do-release-upgrade >/dev/null; then + printf 'unknown until update-manager-core is installed' + return 0 + fi + # The check exits non-zero when nothing is offered, and prints its progress either way, so the exit code decides and the output only names the release. + # It reads the same as any user, so it runs without sudo and a status report never asks for a password. + local check summary + if check=$(do-release-upgrade -c 2>&1); then + summary=$(grep -i -m 1 "new release" <<<"$check" || true) + printf '%s' "${summary:-a new release is offered}" + else + printf "none offered under this host's upgrade policy (/etc/update-manager/release-upgrades)" + fi + ;; + *) printf 'not supported on this host' ;; esac } @@ -573,8 +573,8 @@ upgrade() { if [[ $MODE == "release" ]]; then case "$DISTRO_ID" in - debian) debian_release_upgrade ;; - ubuntu) ubuntu_release_upgrade ;; + debian) debian_release_upgrade ;; + ubuntu) ubuntu_release_upgrade ;; esac fi @@ -589,16 +589,16 @@ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - -s | --status) actions+=(status) ;; - -p | --packages) actions+=(packages) ;; - -r | --release) actions+=(release) ;; - -n | --dry-run) DRY_RUN=true ;; - -y | --yes) ASSUME_YES=true ;; - -h | --help) - usage - exit 0 - ;; - *) die "Unknown option \"$1\", --help lists the options" ;; + -s | --status) actions+=(status) ;; + -p | --packages) actions+=(packages) ;; + -r | --release) actions+=(release) ;; + -n | --dry-run) DRY_RUN=true ;; + -y | --yes) ASSUME_YES=true ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; esac shift done @@ -616,8 +616,8 @@ main() { detect_host case "$MODE" in - status) status ;; - packages | release) upgrade ;; + status) status ;; + packages | release) upgrade ;; esac } diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 0a86248e..d574a594 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -59,7 +59,7 @@ These are listed apart, left alone, and printed with **no command beside them**. ## Why There Is No Linter Category -Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. +Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. Each of those runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check: the image tag fixes the version. Installing native copies through `winget` would put a second, unpinned version of each on the host, and a local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are `docker` and `uv`, and both are already in the registry. diff --git a/repo-config/configure.sh b/repo-config/configure.sh index d655314f..d8ec85e6 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -30,11 +30,19 @@ set -Eeuo pipefail # ----- Command + target + model ----- cmd=apply -case "${1:-}" in apply|check) cmd="$1"; shift ;; esac +case "${1:-}" in apply | check) + cmd="$1" + shift + ;; +esac repo_arg="${1:-}" model="${2:-}" # Allow the model as the sole positional (`configure.sh check operational`): a model name is not a repo. -case "$repo_arg" in release|operational) model="$repo_arg"; repo_arg="" ;; esac +case "$repo_arg" in release | operational) + model="$repo_arg" + repo_arg="" + ;; +esac repo="${repo_arg:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -57,9 +65,12 @@ if [ -z "$model" ]; then fi fi case "$model" in - release) develop_ruleset="$script_dir/develop.json" ;; - operational) develop_ruleset="$script_dir/operational/develop.json" ;; - *) echo "Unknown workflow model '$model' (expected release or operational)." >&2; exit 1 ;; +release) develop_ruleset="$script_dir/develop.json" ;; +operational) develop_ruleset="$script_dir/operational/develop.json" ;; +*) + echo "Unknown workflow model '$model' (expected release or operational)." >&2 + exit 1 + ;; esac main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" @@ -174,7 +185,8 @@ cmd_apply() { # ----- General repository settings ----- # Discussions are enabled on public repos only by fleet policy, and never on a private one. private="$(gh api "repos/$repo" --jq '.private')" - disc=false; [ "$private" = "false" ] && disc=true + disc=false + [ "$private" = "false" ] && disc=true # The default branch is main, but only point it there once main exists. # Never set the default to a missing branch, as on a repo still living on a rework branch. if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then @@ -203,13 +215,20 @@ cmd_apply() { FAILED=0 note() { printf ' %s\n' "$*"; } pass() { printf ' ok %s\n' "$*"; } -fail() { printf ' FAIL %s\n' "$*"; FAILED=1; } +fail() { + printf ' FAIL %s\n' "$*" + FAILED=1 +} # Run a test command with `assert MESSAGE TEST...`, passing on success and failing on non-zero. # It is a proper if/else rather than the `A && B || C` footgun. # Do not redirect the assert call's own stdout, which would swallow the pass or fail line. # A command that prints, such as jq, goes through jq_has, which silences only itself. -assert() { local msg="$1"; shift; if "$@"; then pass "$msg"; else fail "$msg"; fi; } +assert() { + local msg="$1" + shift + if "$@"; then pass "$msg"; else fail "$msg"; fi +} # Test with `jq_has FILTER...`, which is true only when the filter selects a truthy value. # The jq output is discarded, not the caller's. @@ -223,12 +242,27 @@ gh_ok() { gh api "$@" >/dev/null 2>&1; } check_ruleset() { # payload-file - the live ruleset must match the committed policy, driven by the payload local file="$1" rname id live t want got want_enf - if [ ! -e "$file" ]; then fail "ruleset payload $file missing"; return; fi + if [ ! -e "$file" ]; then + fail "ruleset payload $file missing" + return + fi rname="$(jq -r '.name // empty' "$file")" - if [ -z "$rname" ]; then fail "ruleset payload $file has no name"; return; fi - if ! id="$(ruleset_id "$rname")"; then fail "ruleset '$rname' - could not resolve id"; return; fi - if [ -z "$id" ]; then fail "ruleset '$rname' missing"; return; fi - if ! live="$(gh api "repos/$repo/rulesets/$id")"; then fail "ruleset '$rname' - could not read live state"; return; fi + if [ -z "$rname" ]; then + fail "ruleset payload $file has no name" + return + fi + if ! id="$(ruleset_id "$rname")"; then + fail "ruleset '$rname' - could not resolve id" + return + fi + if [ -z "$id" ]; then + fail "ruleset '$rname' missing" + return + fi + if ! live="$(gh api "repos/$repo/rulesets/$id")"; then + fail "ruleset '$rname' - could not read live state" + return + fi want_enf="$(jq -r '.enforcement' "$file")" assert "ruleset '$rname' enforcement = $want_enf" test "$(jq -r '.enforcement' <<<"$live")" = "$want_enf" # The live rule-type set must equal the payload's, compared in both directions. @@ -236,9 +270,13 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol # That is drift this script exists to catch, and it passed as clean before. local want_types got_types if ! want_types="$(jq -r '[.rules[].type] | sort | join(",")' "$file")"; then - fail "ruleset payload $file did not parse"; return + fail "ruleset payload $file did not parse" + return + fi + if [ -z "$want_types" ]; then + fail "ruleset payload $file declares no rules" + return fi - if [ -z "$want_types" ]; then fail "ruleset payload $file declares no rules"; return; fi got_types="$(jq -r '[.rules[].type] | sort | join(",")' <<<"$live")" assert "'$rname' rule set = $want_types" test "$got_types" = "$want_types" # The bypass list is reported and never asserted, because no payload declares one. @@ -279,8 +317,14 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol check_settings() { local live key want got private wantdisc - if [ ! -e "$settings_file" ]; then fail "settings payload $settings_file missing"; return; fi - if ! live="$(gh api "repos/$repo")"; then fail "could not read repository settings"; return; fi + if [ ! -e "$settings_file" ]; then + fail "settings payload $settings_file missing" + return + fi + if ! live="$(gh api "repos/$repo")"; then + fail "could not read repository settings" + return + fi # Static settings are driven from settings.json, so the check never drifts from the file. # Add a key there and it is audited here automatically. # The payload is parsed into a variable before the loop rather than streamed from a process substitution. @@ -288,10 +332,14 @@ check_settings() { # Every static setting would then report as checked and passing while nothing was compared, a false clean. local pairs if ! pairs="$(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$settings_file")"; then - fail "settings payload $settings_file did not parse"; return + fail "settings payload $settings_file did not parse" + return fi # A payload that parses to nothing is a floor failure rather than a clean run, so it is asserted. - if [ -z "$pairs" ]; then fail "settings payload $settings_file declares no keys"; return; fi + if [ -z "$pairs" ]; then + fail "settings payload $settings_file declares no keys" + return + fi while IFS=$'\t' read -r key want; do # shellcheck disable=SC2016 # $k is a jq --arg variable, not a shell expansion got="$(jq -r --arg k "$key" '.[$k]' <<<"$live")" @@ -299,7 +347,8 @@ check_settings() { done <<<"$pairs" # Dynamic settings apply sets: has_discussions (public repos only), default_branch (main, if it exists). private="$(jq -r '.private' <<<"$live")" - wantdisc=true; [ "$private" = "true" ] && wantdisc=false + wantdisc=true + [ "$private" = "true" ] && wantdisc=false assert "has_discussions = $wantdisc" test "$(jq -r '.has_discussions' <<<"$live")" = "$wantdisc" if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then assert "default_branch = main" test "$(jq -r '.default_branch' <<<"$live")" = main @@ -336,11 +385,14 @@ cmd_check() { # Secrets are per-repo (spec/secrets.json) and not readable by value. # A standalone carry has no registry to derive the required set from, so they are verified by hand rather than asserted here. note "verify manually: the repo's required secrets (see spec/secrets.json) are present with valid values" - if [ "$FAILED" -ne 0 ]; then echo "Configuration drift detected on $repo."; exit 1; fi + if [ "$FAILED" -ne 0 ]; then + echo "Configuration drift detected on $repo." + exit 1 + fi echo "Configuration matches on $repo." } case "$cmd" in - apply) cmd_apply ;; - check) cmd_check ;; +apply) cmd_apply ;; +check) cmd_check ;; esac diff --git a/scripts/docker_lint.py b/scripts/docker_lint.py index e7117466..0f76a932 100755 --- a/scripts/docker_lint.py +++ b/scripts/docker_lint.py @@ -5,6 +5,7 @@ import argparse import os +import shlex import subprocess import sys from collections.abc import Callable, Sequence @@ -25,6 +26,9 @@ class Linter: patterns: tuple[str, ...] workdir: str arguments: tuple[str, ...] = () + # A `*.sh` pattern alone misses a tracked script meant to run as a bare command (no extension). + # Shell linters set this so their target list also matches an extensionless shebang script. + discover_shebang: bool = False LINTERS = ( @@ -49,7 +53,15 @@ class Linter: "/workdir", arguments=("--no-progress",), ), - Linter("shellcheck", "koalaman/shellcheck:stable", ("*.sh",), "/mnt"), + Linter("shellcheck", "koalaman/shellcheck:stable", ("*.sh",), "/mnt", discover_shebang=True), + Linter( + "shfmt", + "mvdan/shfmt:latest", + ("*.sh",), + "/mnt", + arguments=("-d",), + discover_shebang=True, + ), Linter("PSScriptAnalyzer", "mcr.microsoft.com/powershell:latest", ("*.ps1",), "/mnt"), ) @@ -98,8 +110,10 @@ def run_command( raise CommandFailed(f"could not start command: {error}") from error -def tracked_files(root: Path, linter: Linter) -> list[str]: - """Return the tracked files the linter can inspect.""" +def ls_files( + root: Path, patterns: Sequence[str] = (), *, include_untracked: bool = True +) -> list[str]: + """Return tracked paths, plus unignored untracked ones unless told to skip those.""" command = [ "git", "-C", @@ -107,11 +121,11 @@ def tracked_files(root: Path, linter: Linter) -> list[str]: "ls-files", "-z", "--cached", - "--others", - "--exclude-standard", ] - if linter.patterns: - command.extend(["--", *linter.patterns]) + if include_untracked: + command.extend(["--others", "--exclude-standard"]) + if patterns: + command.extend(["--", *patterns]) try: result = subprocess.run(command, check=True, capture_output=True) except FileNotFoundError as error: @@ -124,6 +138,75 @@ def tracked_files(root: Path, linter: Linter) -> list[str]: return [os.fsdecode(entry) for entry in result.stdout.split(b"\0") if entry] +def shell_shebang_interpreter(line: str) -> str | None: + """Return the shebang's direct interpreter, bash or sh, or None otherwise. + + Tokenizes rather than substring-matches, so a plain-argument `bash` is not the interpreter. + An `env` shebang walks past its own flags to the command it selects. + """ + if not line.startswith("#!"): + return None + try: + tokens = shlex.split(line[2:]) + except ValueError: + return None + if not tokens: + return None + interpreter = tokens[0].rsplit("/", 1)[-1] + if interpreter in {"bash", "sh"}: + return interpreter + if interpreter != "env": + return None + args = tokens[1:] + while args and args[0].startswith("-") and args[0] != "--": + if args[0] == "-S": + args = args[1:] + break + args = args[1:] + if args and args[0] == "--": + args = args[1:] + if args and args[0].rsplit("/", 1)[-1] in {"bash", "sh"}: + return args[0].rsplit("/", 1)[-1] + return None + + +def has_shell_shebang(root: Path, relative_path: str) -> bool: + """Report whether a tracked file's shebang directly names bash or sh.""" + try: + with (root / relative_path).open("rb") as handle: + first_line = handle.readline(256) + except OSError: + return False + try: + text = first_line.decode("utf-8").rstrip("\n") + except UnicodeDecodeError: + return False + return shell_shebang_interpreter(text) is not None + + +def extensionless_shell_scripts(root: Path) -> list[str]: + """Return tracked, extension-less files whose shebang names bash or sh. + + A `*.sh` glob misses a script meant to run as a bare command, extension-less by design. + Its shebang is the only signal `git ls-files` cannot glob for. + Tracked only, matching CI's plain `git ls-files`. + An untracked bare script would otherwise lint here but never in CI. + """ + return [ + path + for path in ls_files(root, include_untracked=False) + if "." not in Path(path).name and has_shell_shebang(root, path) + ] + + +def tracked_files(root: Path, linter: Linter) -> list[str]: + """Return the tracked files the linter can inspect.""" + files = ls_files(root, linter.patterns) + if linter.discover_shebang: + files = sorted(set(files) | set(extensionless_shell_scripts(root))) + return files + + def docker_mount(root: Path, destination: str) -> str: """Build the read-only repository mount argument.""" source = str(root).replace('"', '""') @@ -132,7 +215,7 @@ def docker_mount(root: Path, destination: str) -> str: def file_batches(linter: Linter, files: Sequence[str]) -> list[list[str]]: """Split file arguments before the host command-line limit becomes relevant.""" - if linter.name not in {"markdownlint", "cspell", "shellcheck", "PSScriptAnalyzer"}: + if linter.name not in {"markdownlint", "cspell", "shellcheck", "shfmt", "PSScriptAnalyzer"}: return [list(files)] batches: list[list[str]] = [] @@ -239,7 +322,7 @@ def container_command(root: Path, linter: Linter, digest: str, files: Sequence[s ) else: command.extend([digest, *linter.arguments]) - if linter.name in {"markdownlint", "cspell", "shellcheck"}: + if linter.name in {"markdownlint", "cspell", "shellcheck", "shfmt"}: command.append("--") command.extend(files) return command diff --git a/scripts/skills_install.sh b/scripts/skills_install.sh index c499eb6c..8ec39414 100755 --- a/scripts/skills_install.sh +++ b/scripts/skills_install.sh @@ -12,10 +12,14 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Python-2 `python` is rejected rather than handed the script, which would fail to parse. py="" for c in python3 python; do - if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 7) else 1)' 2>/dev/null; then - py="$c"; break - fi + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 7) else 1)' 2>/dev/null; then + py="$c" + break + fi done -[ -n "$py" ] || { echo "Python 3.7+ is required and was not found on PATH (tried python3, python)." >&2; exit 1; } +[ -n "$py" ] || { + echo "Python 3.7+ is required and was not found on PATH (tried python3, python)." >&2 + exit 1 +} exec "$py" "$here/skills_install.py" "$@" diff --git a/scripts/tests/test_docker_lint.py b/scripts/tests/test_docker_lint.py index c560cdf2..7b3d8d1b 100755 --- a/scripts/tests/test_docker_lint.py +++ b/scripts/tests/test_docker_lint.py @@ -129,6 +129,63 @@ def test_shellcheck_literal_marker_precedes_option_shaped_filename(self) -> None ) self.assertEqual(["--", "-release.sh"], command[-2:]) + def test_shfmt_literal_marker_precedes_option_shaped_filename(self) -> None: + linter = next(linter for linter in docker_lint.LINTERS if linter.name == "shfmt") + command = docker_lint.container_command( + self.root, linter, "example@sha256:123", ["-release.sh"] + ) + self.assertEqual(["-d", "--", "-release.sh"], command[-3:]) + + def test_shellcheck_and_shfmt_pick_up_an_extensionless_shebang_script(self) -> None: + self.track("ops/vps-backup-pull", "#!/usr/bin/env bash\nset -Eeuo pipefail\necho hi\n") + for name in ("shellcheck", "shfmt"): + linter = next(linter for linter in docker_lint.LINTERS if linter.name == name) + self.assertEqual(["ops/vps-backup-pull"], docker_lint.tracked_files(self.root, linter)) + + def test_extensionless_non_shell_file_is_not_picked_up(self) -> None: + self.track("ops/README", "not a script\n") + self.track("ops/run-me", "#!/usr/bin/env python3\nprint('hi')\n") + linter = next(linter for linter in docker_lint.LINTERS if linter.name == "shellcheck") + self.assertEqual([], docker_lint.tracked_files(self.root, linter)) + + def test_extensionless_shebang_script_merges_with_glob_matched_scripts(self) -> None: + self.track("regular.sh", "#!/usr/bin/env bash\necho hi\n") + self.track("ops/vps-backup-pull", "#!/bin/sh\necho hi\n") + linter = next(linter for linter in docker_lint.LINTERS if linter.name == "shellcheck") + self.assertEqual( + ["ops/vps-backup-pull", "regular.sh"], + docker_lint.tracked_files(self.root, linter), + ) + + def test_extensionless_untracked_shebang_script_is_not_picked_up(self) -> None: + path = self.root / "ops" / "vps-backup-pull" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/usr/bin/env bash\necho hi\n", encoding="utf-8") + linter = next(linter for linter in docker_lint.LINTERS if linter.name == "shellcheck") + self.assertEqual([], docker_lint.tracked_files(self.root, linter)) + + def test_shell_shebang_interpreter_rejects_bash_as_a_plain_argument(self) -> None: + cases = { + "#!/bin/bash": "bash", + "#!/bin/sh": "sh", + "#!/usr/bin/env bash": "bash", + "#!/usr/bin/env\tbash": "bash", + "#!/usr/bin/env -S bash -e": "bash", + "#!/usr/bin/python bash": None, + "#!/usr/bin/env python sh": None, + "#!/usr/bin/env -S python -m sh": None, + "#!/usr/bin/env": None, + "not a shebang": None, + } + for line, expected in cases.items(): + with self.subTest(line=line): + self.assertEqual(expected, docker_lint.shell_shebang_interpreter(line)) + + def test_extensionless_script_naming_bash_only_as_an_argument_is_excluded(self) -> None: + self.track("ops/run-me", "#!/usr/bin/python bash\nprint('hi')\n") + linter = next(linter for linter in docker_lint.LINTERS if linter.name == "shellcheck") + self.assertEqual([], docker_lint.tracked_files(self.root, linter)) + def test_cspell_literal_marker_precedes_option_shaped_filename(self) -> None: linter = next(linter for linter in docker_lint.LINTERS if linter.name == "cspell") command = docker_lint.container_command(