diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e091102..f0c62e4 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -14,6 +14,19 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Node dependencies + env: + HUSKY: "0" + run: npm ci + + - name: Run test suite + run: npm test + - name: Cache Homebrew uses: actions/cache@v4 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6b183d..13c3206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: HUSKY: "0" run: npm ci + - name: Run test suite + run: npm test + - name: Lint commit messages env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -113,8 +116,3 @@ jobs: - name: Run repository quality checks run: pre-commit run --all-files --show-diff-on-failure - - - name: Verify generated Zsh completion - run: | - bash scripts/generate-zsh-completion.sh - git diff --exit-code -- configs/zsh/completions/_mac diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 7391613..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Release Please - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: write - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Run Release Please - uses: googleapis/release-please-action@v4 - with: - release-type: simple - package-name: mac-dev-setup diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aedad36..d285fc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,13 @@ repos: hooks: - id: gitleaks + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [-x] + files: ^(scripts/.*\.sh|install\.sh)$ + - repo: local hooks: - id: markdownlint-cli2 diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..d597c6a --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +# Allow `# shellcheck source=...` directives to resolve sourced libraries. +external-sources=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4918c82..3f2afbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,10 @@ The format is inspired by Keep a Changelog, and the project follows semantic ver - Generated Zsh completion for `mac`, built from the command registry and verified in CI. - Structured logging helpers and unified error handling across the CLI. -- CLI smoke tests (`npm test`) and commit linting through commitlint. +- Opt-in `mac defaults`, `mac keyboard` and `mac vscode` commands wrapping the + macOS defaults, keyboard layout and VS Code extension scripts. +- A bats test suite covering the libraries, CLI, update and install flows, run + via `npm test` in CI. ### Changed @@ -35,16 +38,23 @@ The format is inspired by Keep a Changelog, and the project follows semantic ver writes to the global config. - macOS CI consolidated into a single gate, and commits are linted with commitlint across the pull request range. +- Shell scripts are linted with shellcheck in CI. +- VS Code extensions are no longer declared in the Brewfile (which made the + macOS CI depend on the flaky extension marketplace); install them with + `mac vscode`, the single source of truth being the configs/vscode lists. - `mac setup` writes its log to a user-level location instead of the current working directory. +- Releases are performed manually following the documented process; the + unreliable release-please workflow was removed. ### Fixed - `mac setup --help` and `mac doctor --help` print usage instead of failing or silently running diagnostics. - `mac doctor` exits non-zero when a required tool is missing. -- Setup no longer regenerates the versioned completion file and no longer fails - when completion is not yet active in the current shell. +- Setup no longer regenerates the versioned completion file, no longer fails + when completion is not yet active in the current shell, and reports a reliable + exit status. ## 0.4.1 - 2026-06-17 diff --git a/README.md b/README.md index 9331f8b..333aad8 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ Setup: maintenance. `full` installs the complete curated developer environment, including language -tooling, quality tools, GUI applications, VS Code extensions, and -container/database utilities. The root `Brewfile` is a compatibility link to -`profiles/full/Brewfile`. +tooling, quality tools, GUI applications, and container/database utilities. The +root `Brewfile` is a compatibility link to `profiles/full/Brewfile`. VS Code +extensions are managed separately and installed with `mac vscode`. ## CLI Usage @@ -120,6 +120,17 @@ Remove the installed checkout when it matches `~/.mac-dev-setup`: mac uninstall --remove-install-dir ``` +### Optional commands + +These commands apply opt-in changes and are not run by `mac setup`: + +```bash +mac defaults # apply curated macOS Finder/Dock/keyboard defaults +mac keyboard # install the Francais OSS Mac keyboard layout +mac vscode # install the curated VS Code extensions +mac vscode --with-optional +``` + ## Managed Files MacDevSetup may manage these user-level files: diff --git a/configs/zsh/completions/_mac b/configs/zsh/completions/_mac index ea5ef5d..346e1e9 100644 --- a/configs/zsh/completions/_mac +++ b/configs/zsh/completions/_mac @@ -7,10 +7,13 @@ _mac() { commands=( 'help:List available commands.' + 'defaults:Apply the curated macOS system defaults.' 'doctor:Run system diagnostics for the macOS development setup.' + 'keyboard:Install the French OSS keyboard layout.' 'setup:Install and configure the macOS development setup.' 'uninstall:Remove the mac CLI symlink and managed shell PATH entry.' 'update:Update the mac CLI from its git repository.' + 'vscode:Install the curated VS Code extensions.' ) if (( CURRENT == 2 )); then diff --git a/docs/keyboard/french-oss.md b/docs/keyboard/french-oss.md index 8adc7ca..5eed79f 100644 --- a/docs/keyboard/french-oss.md +++ b/docs/keyboard/french-oss.md @@ -52,7 +52,13 @@ The layout contains adaptations for the internal Apple keyboard, including corre ## Installation -Install the layout with: +Install the layout with the CLI: + +~~~bash +mac keyboard +~~~ + +Or run the script directly: ~~~bash ./scripts/install-keyboard-layout.sh diff --git a/docs/macos/macos-defaults.md b/docs/macos/macos-defaults.md index 6cc1992..d4f7850 100644 --- a/docs/macos/macos-defaults.md +++ b/docs/macos/macos-defaults.md @@ -73,7 +73,13 @@ Validate it with ShellCheck: shellcheck scripts/apply-macos-defaults.sh ~~~ -Apply the configuration: +Apply the configuration with the CLI: + +~~~bash +mac defaults +~~~ + +Or run the script directly: ~~~bash ./scripts/apply-macos-defaults.sh diff --git a/docs/releases/release-process.md b/docs/releases/release-process.md index 2b07c1d..112e84e 100644 --- a/docs/releases/release-process.md +++ b/docs/releases/release-process.md @@ -4,6 +4,11 @@ This document describes the release workflow for Mac Dev Setup. The project uses semantic versioning where applicable and maintains notable changes in the root `CHANGELOG.md` file. +Releases are performed **manually** by following the steps below. There is no +automated release bot: the repository's gitmoji-first commit convention does not +map cleanly onto conventional-commit release automation, so the changelog, +version bump, tag, and GitHub release are prepared by a maintainer. + ## Release preparation Before starting a release: diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md index ef24cbd..253f2b8 100644 --- a/docs/releases/v1.0.0.md +++ b/docs/releases/v1.0.0.md @@ -52,7 +52,7 @@ uninstall flows, and quality checks suitable for ongoing maintenance. - [x] Repository quality validation is configured in GitHub Actions. - [x] Homebrew validation is configured in GitHub Actions. - [x] macOS validation is configured in GitHub Actions. -- [x] Release Please automation is configured for future releases. +- [x] The release process is documented and performed manually (see release-process.md). - [x] Dependabot monitors GitHub Actions dependencies. ## Documentation diff --git a/docs/vscode/vscode.md b/docs/vscode/vscode.md index 23699ce..bdfb884 100644 --- a/docs/vscode/vscode.md +++ b/docs/vscode/vscode.md @@ -54,13 +54,19 @@ They cover the main PHP and Symfony development workflow: - DotEnv syntax support - YAML validation and completion -Install them with: +Install them with the CLI: + +~~~bash +mac vscode +~~~ + +Or run the script directly: ~~~bash ./scripts/install-vscode-extensions.sh ~~~ -The script is idempotent and skips extensions that are already installed. +The command is idempotent and skips extensions that are already installed. ## Optional extensions @@ -74,6 +80,12 @@ They include personal, visual, AI-assisted, and specialized workflow extensions. Install both recommended and optional extensions with: +~~~bash +mac vscode --with-optional +~~~ + +Or run the script directly: + ~~~bash ./scripts/install-vscode-extensions.sh --with-optional ~~~ diff --git a/package-lock.json b/package-lock.json index 046fbd9..ecbd23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", "@gitmoji/gitmoji-regex": "^1.0.0", + "bats": "^1.13.0", "commitlint-config-gitmoji": "^2.3.1", "husky": "^9.1.7" } @@ -443,6 +444,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bats": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz", + "integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==", + "dev": true, + "license": "MIT", + "bin": { + "bats": "bin/bats" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", diff --git a/package.json b/package.json index 53a958e..17dfc2f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "doc": "docs" }, "scripts": { - "test": "bash scripts/test-cli.sh", + "test": "bats tests", "prepare": "husky" }, "repository": { @@ -26,6 +26,7 @@ "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", "@gitmoji/gitmoji-regex": "^1.0.0", + "bats": "^1.13.0", "commitlint-config-gitmoji": "^2.3.1", "husky": "^9.1.7" } diff --git a/profiles/full/Brewfile b/profiles/full/Brewfile index bd376c3..d08995d 100644 --- a/profiles/full/Brewfile +++ b/profiles/full/Brewfile @@ -97,24 +97,6 @@ cask "visual-studio-code" # Rust-based terminal cask "warp" -# Editor extensions - -vscode "anthropic.claude-code" -vscode "avaly.restore-git-branch-tabs-improved" -vscode "bmewburn.vscode-intelephense-client" -vscode "christian-kohler.path-intellisense" -vscode "eamodio.gitlens" -vscode "github.vscode-github-actions" -vscode "hogashi.crontab-syntax-highlight" -vscode "mechatroner.rainbow-csv" -vscode "mehedidracula.php-namespace-resolver" -vscode "mikestead.dotenv" -vscode "monokai.theme-monokai-pro-vscode" -vscode "redhat.vscode-yaml" -vscode "sanderronde.phpstan-vscode" -vscode "whatwedo.twig" -vscode "xdebug.php-debug" - # Runtime-managed tools uv "claude-monitor" diff --git a/scripts/commands/defaults.sh b/scripts/commands/defaults.sh new file mode 100755 index 0000000..285be29 --- /dev/null +++ b/scripts/commands/defaults.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Description: Apply the curated macOS system defaults. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=scripts/lib/logging.sh +source "$REPO_DIR/scripts/lib/logging.sh" + +print_usage() { + log_line "Usage: mac defaults [--help]" + log_line "" + log_line "Apply the curated Finder, Dock, screenshot, and keyboard defaults." +} + +main() { + case "${1:-}" in + "") ;; + --help | -h) + print_usage + exit 0 + ;; + *) + error "Unknown option: $1" + print_usage >&2 + exit 1 + ;; + esac + + exec bash "$REPO_DIR/scripts/apply-macos-defaults.sh" +} + +main "$@" diff --git a/scripts/commands/keyboard.sh b/scripts/commands/keyboard.sh new file mode 100755 index 0000000..bdb212d --- /dev/null +++ b/scripts/commands/keyboard.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Description: Install the French OSS keyboard layout. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=scripts/lib/logging.sh +source "$REPO_DIR/scripts/lib/logging.sh" + +print_usage() { + log_line "Usage: mac keyboard [--help]" + log_line "" + log_line "Install the bundled Francais OSS Mac keyboard layout. Log out and" + log_line "back in, then enable it in macOS Input Sources." +} + +main() { + case "${1:-}" in + "") ;; + --help | -h) + print_usage + exit 0 + ;; + *) + error "Unknown option: $1" + print_usage >&2 + exit 1 + ;; + esac + + exec bash "$REPO_DIR/scripts/install-keyboard-layout.sh" +} + +main "$@" diff --git a/scripts/commands/vscode.sh b/scripts/commands/vscode.sh new file mode 100755 index 0000000..e2ac76f --- /dev/null +++ b/scripts/commands/vscode.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Description: Install the curated VS Code extensions. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=scripts/lib/logging.sh +source "$REPO_DIR/scripts/lib/logging.sh" + +print_usage() { + log_line "Usage: mac vscode [--with-optional] [--help]" + log_line "" + log_line "Install the recommended VS Code extensions, optionally including" + log_line "the optional set with --with-optional." +} + +main() { + case "${1:-}" in + --help | -h) + print_usage + exit 0 + ;; + esac + + exec bash "$REPO_DIR/scripts/install-vscode-extensions.sh" "$@" +} + +main "$@" diff --git a/scripts/setup.sh b/scripts/setup.sh index 997a5d7..9d45793 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -95,30 +95,41 @@ LOG_DIR="${MAC_DEV_SETUP_LOG_DIR:-$HOME/Library/Logs/mac-dev-setup}" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/setup.log" -exec > >(tee -a "$LOG_FILE") 2>&1 -info "Logging to $LOG_FILE" - -# ---------------------------- -# EXECUTION -# ---------------------------- -PROFILE="$PROFILE" bash "$SCRIPT_DIR/brew.sh" -bash "$SCRIPT_DIR/git.sh" -bash "$SCRIPT_DIR/zsh.sh" - -# ---------------------------- -# POST VALIDATION -# ---------------------------- -info "Post install validation" - -command -v brew >/dev/null && success "brew ok" -command -v git >/dev/null && success "git ok" -command -v zsh >/dev/null && success "zsh ok" +check_tool() { + # Report a tool without letting a missing one abort setup under "set -e". + if command -v "$1" >/dev/null; then + success "$1 ok" + else + warn "$1 not found after setup" + fi +} -# ---------------------------- -# CI MODE -# ---------------------------- -if [ "$CI" = "true" ]; then - info "Running in CI mode" -fi +run_setup() { + info "Logging to $LOG_FILE" + + # ---------------------------- + # EXECUTION + # ---------------------------- + PROFILE="$PROFILE" bash "$SCRIPT_DIR/brew.sh" + bash "$SCRIPT_DIR/git.sh" + bash "$SCRIPT_DIR/zsh.sh" + + # ---------------------------- + # POST VALIDATION + # ---------------------------- + info "Post install validation" + check_tool brew + check_tool git + check_tool zsh + + if [ "$CI" = "true" ]; then + info "Running in CI mode" + fi + + success "Mac Dev Setup completed successfully" +} -success "Mac Dev Setup completed successfully" +# Pipe through tee so the full transcript is captured reliably, and propagate +# the real exit status of the setup work (not tee's) via PIPESTATUS. +run_setup 2>&1 | tee -a "$LOG_FILE" +exit "${PIPESTATUS[0]}" diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh deleted file mode 100755 index dddb8b4..0000000 --- a/scripts/test-cli.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -assert_contains() { - haystack="$1" - needle="$2" - - if ! printf '%s\n' "$haystack" | grep -F "$needle" >/dev/null; then - printf 'Expected output to contain: %s\n' "$needle" >&2 - exit 1 - fi -} - -help_output="$(bash "$REPO_DIR/scripts/cli.sh" help)" -assert_contains "$help_output" "doctor" -assert_contains "$help_output" "setup" -assert_contains "$help_output" "uninstall" -assert_contains "$help_output" "update" - -setup_help_output="$(bash "$REPO_DIR/scripts/setup.sh" --help)" -assert_contains "$setup_help_output" "Usage: scripts/setup.sh" - -mac_setup_help_output="$(bash "$REPO_DIR/scripts/cli.sh" setup --help)" -assert_contains "$mac_setup_help_output" "Usage: mac setup" - -mac_doctor_help_output="$(bash "$REPO_DIR/scripts/cli.sh" doctor --help)" -assert_contains "$mac_doctor_help_output" "Usage: mac doctor" - -setup_dry_run_output="$(bash "$REPO_DIR/scripts/cli.sh" setup --profile minimal --dry-run)" -assert_contains "$setup_dry_run_output" "Dry run mode activated" - -if bash "$REPO_DIR/scripts/cli.sh" updte >/tmp/mac-dev-setup-cli-test.out 2>&1; then - printf 'Expected an unknown command to fail.\n' >&2 - exit 1 -fi -assert_contains "$(cat /tmp/mac-dev-setup-cli-test.out)" "mac update" -rm -f /tmp/mac-dev-setup-cli-test.out - -log_test_dir="$(mktemp -d)" -( - cd "$log_test_dir" - bash "$REPO_DIR/scripts/setup.sh" --profile minimal --dry-run >/dev/null -) -if [ -e "$log_test_dir/logs" ]; then - printf 'setup.sh must not create a logs directory in the current directory.\n' >&2 - rm -rf "$log_test_dir" - exit 1 -fi -rm -rf "$log_test_dir" - -if grep -Eq 'mkdir -p logs|"logs/setup\.log"' "$REPO_DIR/scripts/setup.sh"; then - printf 'setup.sh must use an absolute log path, not a relative logs/ directory.\n' >&2 - exit 1 -fi - -( - # shellcheck source=scripts/lib/profiles.sh - source "$REPO_DIR/scripts/lib/profiles.sh" - if ! profile_name_is_valid full; then exit 1; fi - if profile_name_is_valid "../etc"; then exit 1; fi - if ! profile_validate "$REPO_DIR" minimal; then exit 1; fi - if profile_validate "$REPO_DIR" no-such-profile; then exit 1; fi -) || { - printf 'Profile validation contract failed.\n' >&2 - exit 1 -} - -uninstall_home="$(mktemp -d)" -cp "$REPO_DIR/configs/zsh/.zprofile" "$uninstall_home/.zprofile" -printf 'unmanaged content\n' >"$uninstall_home/.zshrc" -uninstall_config_output="$(HOME="$uninstall_home" bash "$REPO_DIR/scripts/commands/uninstall.sh" --remove-config --dry-run 2>&1)" -assert_contains "$uninstall_config_output" "Would remove .zprofile" -assert_contains "$uninstall_config_output" ".zshrc differs" -rm -rf "$uninstall_home" - -doctor_bin="$(mktemp -d)" -for bootstrap_tool in bash dirname; do - ln -s "$(command -v "$bootstrap_tool")" "$doctor_bin/$bootstrap_tool" -done -for stub in sw_vers uname brew git zsh; do - printf '#!/bin/sh\nexit 0\n' >"$doctor_bin/$stub" - chmod +x "$doctor_bin/$stub" -done -# mac is intentionally absent so the diagnostic must report a failure. -if PATH="$doctor_bin" bash "$REPO_DIR/scripts/commands/doctor.sh" >/tmp/mac-dev-setup-doctor.out 2>&1; then - printf 'Expected doctor to exit non-zero when a required tool is missing.\n' >&2 - rm -rf "$doctor_bin" /tmp/mac-dev-setup-doctor.out - exit 1 -fi -assert_contains "$(cat /tmp/mac-dev-setup-doctor.out)" "mac CLI missing" -assert_contains "$(cat /tmp/mac-dev-setup-doctor.out)" "Doctor found problems" -rm -rf "$doctor_bin" /tmp/mac-dev-setup-doctor.out - -bash "$REPO_DIR/scripts/generate-zsh-completion.sh" >/dev/null -git -C "$REPO_DIR" diff --exit-code -- configs/zsh/completions/_mac >/dev/null - -printf 'CLI smoke tests passed.\n' diff --git a/tests/cli.bats b/tests/cli.bats new file mode 100644 index 0000000..d61edd4 --- /dev/null +++ b/tests/cli.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + CLI="$REPO_DIR/scripts/cli.sh" +} + +@test "mac help lists every command" { + run bash "$CLI" help + [ "$status" -eq 0 ] + [[ "$output" == *setup* ]] + [[ "$output" == *doctor* ]] + [[ "$output" == *update* ]] + [[ "$output" == *uninstall* ]] + [[ "$output" == *defaults* ]] + [[ "$output" == *keyboard* ]] + [[ "$output" == *vscode* ]] +} + +@test "the extra commands expose --help" { + for cmd in defaults keyboard vscode; do + run bash "$CLI" "$cmd" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac $cmd"* ]] + done +} + +@test "mac --help and -h behave like help" { + run bash "$CLI" --help + [ "$status" -eq 0 ] + [[ "$output" == *setup* ]] + run bash "$CLI" -h + [ "$status" -eq 0 ] + [[ "$output" == *setup* ]] +} + +@test "unknown command fails and suggests the nearest match" { + run bash "$CLI" updte + [ "$status" -ne 0 ] + [[ "$output" == *"mac update"* ]] +} + +@test "no arguments prints usage and fails" { + run bash "$CLI" + [ "$status" -ne 0 ] + [[ "$output" == *"mac help"* ]] +} + +@test "mac setup --help prints usage without running" { + run bash "$CLI" setup --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac setup"* ]] +} + +@test "mac doctor --help prints usage without diagnostics" { + run bash "$CLI" doctor --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac doctor"* ]] +} diff --git a/tests/command_registry.bats b/tests/command_registry.bats new file mode 100644 index 0000000..99f0e02 --- /dev/null +++ b/tests/command_registry.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + source "$REPO_DIR/scripts/lib/command_registry.sh" + COMMANDS_DIR="$REPO_DIR/scripts/commands" +} + +@test "registry build lists the known commands" { + run command_registry_build "$COMMANDS_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *setup* ]] + [[ "$output" == *doctor* ]] + [[ "$output" == *update* ]] + [[ "$output" == *uninstall* ]] +} + +@test "registry captures the Description header" { + run command_registry_build "$COMMANDS_DIR" + [[ "$output" == *diagnostics* ]] +} + +@test "command_exists distinguishes known and unknown commands" { + run command_registry_command_exists "$COMMANDS_DIR" setup + [ "$status" -eq 0 ] + run command_registry_command_exists "$COMMANDS_DIR" nope + [ "$status" -ne 0 ] +} + +@test "command_script_path resolves to the command file" { + run command_registry_command_path "$COMMANDS_DIR" doctor + [ "$status" -eq 0 ] + [[ "$output" == *"/commands/doctor.sh" ]] +} + +@test "@name and @description metadata headers override defaults" { + tmp_dir="$(mktemp -d)" + cat >"$tmp_dir/whatever.sh" <<'CMD' +#!/bin/bash +# @name greet +# @description Say hello to the user. +echo hi +CMD + chmod +x "$tmp_dir/whatever.sh" + + run command_registry_build "$tmp_dir" + rm -rf "$tmp_dir" + [ "$status" -eq 0 ] + # The name column is overridden to "greet" (the filename would be "whatever"). + [[ "$output" == greet$'\t'* ]] + [[ "$output" == *"Say hello to the user."* ]] +} + +@test "non-executable files are ignored" { + tmp_dir="$(mktemp -d)" + printf '#!/bin/bash\n# Description: ignored\n' >"$tmp_dir/skip.sh" + run command_registry_build "$tmp_dir" + rm -rf "$tmp_dir" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/tests/completion.bats b/tests/completion.bats new file mode 100644 index 0000000..5ecd85b --- /dev/null +++ b/tests/completion.bats @@ -0,0 +1,11 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +@test "the committed zsh completion matches the generator output" { + bash "$REPO_DIR/scripts/generate-zsh-completion.sh" >/dev/null + run git -C "$REPO_DIR" diff --exit-code -- configs/zsh/completions/_mac + [ "$status" -eq 0 ] +} diff --git a/tests/doctor.bats b/tests/doctor.bats new file mode 100644 index 0000000..1853dec --- /dev/null +++ b/tests/doctor.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +@test "doctor exits non-zero and reports the missing tool" { + bin="$(mktemp -d)" + for tool in bash dirname; do + ln -s "$(command -v "$tool")" "$bin/$tool" + done + # Everything resolves except mac, so the diagnostic must fail. + for stub in sw_vers uname brew git zsh; do + printf '#!/bin/sh\nexit 0\n' >"$bin/$stub" + chmod +x "$bin/$stub" + done + + run env PATH="$bin" bash "$REPO_DIR/scripts/commands/doctor.sh" + rm -rf "$bin" + + [ "$status" -ne 0 ] + [[ "$output" == *"mac CLI missing"* ]] + [[ "$output" == *"Doctor found problems"* ]] +} + +@test "doctor --help is read-only and exits cleanly" { + run bash "$REPO_DIR/scripts/commands/doctor.sh" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac doctor"* ]] +} diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..0d1a562 --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats + +# install.sh targets macOS and clones MAC_DEV_SETUP_REPO_URL. Point the URL at +# the local repository so the test runs offline, and use a unique CLI name so it +# never collides with a real "mac" already on PATH. + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + if [ "$(uname -s)" != "Darwin" ]; then + skip "install.sh targets macOS only" + fi + # Clear inherited git env (e.g. when run inside a husky hook) so the local + # clone in install.sh is not hijacked by the parent repo's GIT_DIR. + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX \ + GIT_OBJECT_DIRECTORY GIT_COMMON_DIR 2>/dev/null || true + WORK="$(mktemp -d)" + CLI_NAME="mds-test-cli" + + # Clone from a dedicated seed repo on a real "main" branch rather than the + # working checkout, which may be detached (e.g. a PR merge ref) on CI and + # would break the idempotent `git pull --ff-only` path. + SEED="$WORK/seed" + mkdir -p "$SEED" + cp -R "$REPO_DIR/scripts" "$SEED/scripts" + git -C "$SEED" init -q -b main + git -C "$SEED" config user.email "test@example.com" + git -C "$SEED" config user.name "Test" + git -C "$SEED" add . + git -C "$SEED" commit -qm "seed" + + export MAC_DEV_SETUP_REPO_URL="$SEED" + export MAC_DEV_SETUP_INSTALL_DIR="$WORK/install" + export MAC_DEV_SETUP_BIN_DIR="$WORK/bin" + export MAC_DEV_SETUP_SHELL_CONFIG="$WORK/profile" + export MAC_DEV_SETUP_CLI_NAME="$CLI_NAME" + INSTALL="$REPO_DIR/install.sh" +} + +teardown() { + rm -rf "$WORK" +} + +@test "install creates the CLI symlink and a managed PATH block" { + run bash "$INSTALL" + [ "$status" -eq 0 ] + [ -L "$WORK/bin/$CLI_NAME" ] + run grep -F "mac-dev-setup PATH" "$WORK/profile" + [ "$status" -eq 0 ] +} + +@test "install is idempotent" { + bash "$INSTALL" + run bash "$INSTALL" + [ "$status" -eq 0 ] + [[ "$output" == *"already installed"* ]] +} + +@test "uninstall removes the symlink and the PATH block" { + bash "$INSTALL" + run bash "$INSTALL" --uninstall + [ "$status" -eq 0 ] + [ ! -e "$WORK/bin/$CLI_NAME" ] + run grep -F "mac-dev-setup PATH" "$WORK/profile" + [ "$status" -ne 0 ] +} + +@test "install --help prints usage" { + run bash "$INSTALL" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: install.sh"* ]] +} diff --git a/tests/path_manager.bats b/tests/path_manager.bats new file mode 100644 index 0000000..0728ef3 --- /dev/null +++ b/tests/path_manager.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + source "$REPO_DIR/scripts/lib/path_manager.sh" + # path_manager.sh enables `set -euo pipefail`; relax it so the test driver + # behaves normally (the functions do not rely on it). + set +e +u +o pipefail + PROFILE_FILE="$(mktemp)" + BIN_DIR="$HOME/.local/bin" +} + +teardown() { + rm -f "$PROFILE_FILE" +} + +@test "install writes a managed PATH block" { + path_manager_install "$BIN_DIR" "$PROFILE_FILE" + run grep -F "$PATH_MANAGER_BEGIN_MARKER" "$PROFILE_FILE" + [ "$status" -eq 0 ] + run grep -F "$PATH_MANAGER_END_MARKER" "$PROFILE_FILE" + [ "$status" -eq 0 ] +} + +@test "install is idempotent" { + path_manager_install "$BIN_DIR" "$PROFILE_FILE" + path_manager_install "$BIN_DIR" "$PROFILE_FILE" + run grep -cF "$PATH_MANAGER_BEGIN_MARKER" "$PROFILE_FILE" + [ "$output" -eq 1 ] +} + +@test "uninstall removes the managed block" { + path_manager_install "$BIN_DIR" "$PROFILE_FILE" + path_manager_uninstall "$BIN_DIR" "$PROFILE_FILE" + run grep -F "$PATH_MANAGER_BEGIN_MARKER" "$PROFILE_FILE" + [ "$status" -ne 0 ] +} + +@test "uninstall preserves unrelated content" { + printf 'export EXISTING=1\n' >"$PROFILE_FILE" + path_manager_install "$BIN_DIR" "$PROFILE_FILE" + path_manager_uninstall "$BIN_DIR" "$PROFILE_FILE" + run grep -F "export EXISTING=1" "$PROFILE_FILE" + [ "$status" -eq 0 ] +} + +@test "uninstall on a clean file is a no-op" { + printf 'export EXISTING=1\n' >"$PROFILE_FILE" + run path_manager_uninstall "$BIN_DIR" "$PROFILE_FILE" + [ "$status" -eq 0 ] +} diff --git a/tests/profiles.bats b/tests/profiles.bats new file mode 100644 index 0000000..46bfeb2 --- /dev/null +++ b/tests/profiles.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + source "$REPO_DIR/scripts/lib/profiles.sh" +} + +@test "profile_default is full" { + [ "$(profile_default)" = "full" ] +} + +@test "profile_list returns discovered profiles, sorted" { + run profile_list "$REPO_DIR" + [ "$status" -eq 0 ] + [ "$output" = "full minimal" ] +} + +@test "profile_brewfile builds the expected path" { + [ "$(profile_brewfile "$REPO_DIR" minimal)" = "$REPO_DIR/profiles/minimal/Brewfile" ] +} + +@test "profile_validate accepts existing profiles" { + run profile_validate "$REPO_DIR" full + [ "$status" -eq 0 ] + run profile_validate "$REPO_DIR" minimal + [ "$status" -eq 0 ] +} + +@test "profile_validate rejects empty, unknown and traversal names" { + run profile_validate "$REPO_DIR" "" + [ "$status" -ne 0 ] + run profile_validate "$REPO_DIR" nope + [ "$status" -ne 0 ] + run profile_validate "$REPO_DIR" "../etc" + [ "$status" -ne 0 ] + run profile_validate "$REPO_DIR" "a/b" + [ "$status" -ne 0 ] +} + +@test "profile_name_is_valid enforces a safe charset" { + run profile_name_is_valid full + [ "$status" -eq 0 ] + run profile_name_is_valid my-profile_2 + [ "$status" -eq 0 ] + run profile_name_is_valid "with space" + [ "$status" -ne 0 ] + run profile_name_is_valid "../x" + [ "$status" -ne 0 ] +} diff --git a/tests/setup.bats b/tests/setup.bats new file mode 100644 index 0000000..668ac7c --- /dev/null +++ b/tests/setup.bats @@ -0,0 +1,31 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +@test "setup dry run reports the dry-run mode and exits cleanly" { + run bash "$REPO_DIR/scripts/cli.sh" setup --profile minimal --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Dry run mode activated"* ]] +} + +@test "setup dry run creates no logs directory in the working directory" { + tmp="$(mktemp -d)" + ( cd "$tmp" && bash "$REPO_DIR/scripts/setup.sh" --profile minimal --dry-run >/dev/null ) + result=1 + [ -e "$tmp/logs" ] || result=0 + rm -rf "$tmp" + [ "$result" -eq 0 ] +} + +@test "setup.sh uses an absolute log path, not a relative logs/ directory" { + run grep -Eq 'mkdir -p logs|"logs/setup\.log"' "$REPO_DIR/scripts/setup.sh" + [ "$status" -ne 0 ] +} + +@test "setup rejects an unsafe profile name" { + run bash "$REPO_DIR/scripts/cli.sh" setup --profile "no/such" + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid profile"* ]] +} diff --git a/tests/uninstall.bats b/tests/uninstall.bats new file mode 100644 index 0000000..ce095fe --- /dev/null +++ b/tests/uninstall.bats @@ -0,0 +1,25 @@ +#!/usr/bin/env bats + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +@test "uninstall --remove-config removes managed files only when identical" { + home="$(mktemp -d)" + cp "$REPO_DIR/configs/zsh/.zprofile" "$home/.zprofile" + printf 'unmanaged content\n' >"$home/.zshrc" + + run env HOME="$home" bash "$REPO_DIR/scripts/commands/uninstall.sh" \ + --remove-config --dry-run + rm -rf "$home" + + [ "$status" -eq 0 ] + [[ "$output" == *"Would remove .zprofile"* ]] + [[ "$output" == *".zshrc differs"* ]] +} + +@test "uninstall --help prints usage and exits cleanly" { + run bash "$REPO_DIR/scripts/commands/uninstall.sh" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac uninstall"* ]] +} diff --git a/tests/update.bats b/tests/update.bats new file mode 100644 index 0000000..2897488 --- /dev/null +++ b/tests/update.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +# Exercises scripts/commands/update.sh against a throwaway local remote, so no +# network access is required. update.sh resolves its REPO_DIR from its own +# location, so running the copy inside the clone operates on the clone. + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + # When the suite runs inside a git hook (husky), git exports GIT_DIR/ + # GIT_INDEX_FILE etc.; clear them so `git -C ` is not hijacked. + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX \ + GIT_OBJECT_DIRECTORY GIT_COMMON_DIR 2>/dev/null || true + WORK="$(mktemp -d)" + REMOTE="$WORK/remote.git" + SEED="$WORK/seed" + CLONE="$WORK/clone" + + # Pin the bare repo's default branch so it matches the pushed branch + # regardless of the runner's init.defaultBranch. + git init -q --bare -b main "$REMOTE" + + mkdir -p "$SEED" + cp -R "$REPO_DIR/scripts" "$SEED/scripts" + git -C "$SEED" init -q -b main + git -C "$SEED" config user.email "test@example.com" + git -C "$SEED" config user.name "Test" + git -C "$SEED" add . + git -C "$SEED" commit -qm "init" + git -C "$SEED" remote add origin "$REMOTE" + git -C "$SEED" push -q -u origin main + + git clone -q "$REMOTE" "$CLONE" + UPDATE="$CLONE/scripts/commands/update.sh" +} + +teardown() { + rm -rf "$WORK" +} + +seed_new_commit() { + echo "change $1" >>"$SEED/scripts/marker.txt" + git -C "$SEED" add . + git -C "$SEED" commit -qm "update $1" + git -C "$SEED" push -q origin main +} + +@test "update reports an up-to-date checkout" { + run bash "$UPDATE" + [ "$status" -eq 0 ] + [[ "$output" == *"already up to date"* ]] +} + +@test "dry run does not move HEAD" { + seed_new_commit a + before="$(git -C "$CLONE" rev-parse HEAD)" + run bash "$UPDATE" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Dry run OK"* ]] + [ "$(git -C "$CLONE" rev-parse HEAD)" = "$before" ] +} + +@test "update fast-forwards to the remote tip" { + seed_new_commit b + target="$(git -C "$SEED" rev-parse HEAD)" + run bash "$UPDATE" + [ "$status" -eq 0 ] + [ "$(git -C "$CLONE" rev-parse HEAD)" = "$target" ] +} + +@test "update refuses when tracked files are dirty" { + printf '\n# local edit\n' >>"$CLONE/scripts/cli.sh" + run bash "$UPDATE" + [ "$status" -ne 0 ] + [[ "$output" == *"local changes"* ]] +} + +@test "update --help prints usage" { + run bash "$UPDATE" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: mac update"* ]] +}