diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82b92c37..b00cef71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,19 @@ jobs: node-version: "24" - run: make preflight - - run: | + - run: ./setup.sh --dry-run + + # The real Linux path, twice: the second run must find nothing to do. + - if: runner.os == 'Linux' + run: | + export HOME="$(mktemp -d)" + ./setup.sh + ./setup.sh | tee second-run.log + grep -q '^skip: deploy$' second-run.log + + # The macOS path needs brew and sudo; keep exercising the deploy engine alone. + - if: runner.os == 'macOS' + run: | export HOME="$(mktemp -d)" ./deploy.sh apply ./deploy.sh audit diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..97582c4e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,31 @@ +name: pages + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Never cancel a deploy halfway; let the newer push queue behind it. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + - run: make check-site site SITE_DIR=_site + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index aa5c6fdc..86749a34 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ home/.vim/.netrwhist home/bin.Darwin/local home/bin.Linux/local + +_site/ diff --git a/CLAUDE.md b/CLAUDE.md index b56416a1..1dd98b3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,12 @@ This repo is also consumed downstream by the user's agentic alter-ego, @nonreage ## Commands - `make test` — full suite: `test/test_deploy.sh` + `test/test_shell.sh`. Run either script directly for one suite; both sandbox a throwaway `$HOME` under mktemp and never touch the real one. +- `./setup.sh [--dry-run]` (alias `make setup ARGS=...`) — the one entry point for a new or existing machine, also the target of the README's curl line. Steps are guarded make targets in order. Design: `docs/superpowers/specs/2026-09-25-setup-script-design.md`. +- `make site` / `make check-site` — build nonration.al (`site/index.html` plus the root `setup.sh`) into `_site/`, and check the built copy of `setup.sh` matches and the page shows the curl line. `.github/workflows/pages.yml` deploys it to GitHub Pages on every push to main. - `./deploy.sh apply|audit [--dry-run]` — manifest-driven symlink deploy/verify. `make deploy` = apply plus re-asserting the skip-worktree flag (see Gotchas). - `./scripts/macos-defaults.sh doctor|check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. **`make macos-doctor` first on any new Mac**: this tooling needs Full Disk Access granted to your terminal, or Safari's and Mail's rows cannot be read and silently skip. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = doctor, then apply, then the imperative remainder in `scripts/macos-bootstrap.sh`, then a restart. - `make check-symlinks` — fail on any dangling tracked symlink; `make check-skills` — same, scoped to `home/.agents/skills` (safe in CI); `make check-copilot-instructions` — self-heals the per-file mirror of rules into `home/.copilot/instructions/` (run after renaming anything in `home/.agents/rules/`); `make check-skill-frontmatter` — needs PyYAML; `make check-editorconfig` — needs `editorconfig-checker`. -- `make preflight` — the one definition of "safe to commit": `test`, every `check-*` target, and the offline eval checks (`eval-validate`, `eval-test`; Node ≥ 24, whose npm 11 wrote the lockfile). CI runs this same target (plus a deploy apply+audit against a temp `$HOME`, kept separate since it exercises the deploy mechanism rather than checking committed content), on macOS and ubuntu — a new `check-*` target is picked up by both without a second edit to `ci.yml`. +- `make preflight` — the one definition of "safe to commit": `test`, every `check-*` target, and the offline eval checks (`eval-validate`, `eval-test`; Node ≥ 24, whose npm 11 wrote the lockfile). CI runs this same target on macOS and ubuntu, plus a check of the setup mechanism kept separate because it exercises deploy rather than committed content: on ubuntu `setup.sh` runs twice against a temp `$HOME` and the second pass must skip deploy; on macOS it keeps a deploy apply+audit — a new `check-*` target is picked up by both without a second edit to `ci.yml`. - `make eval SKILL=` — full keyed run of one skill's `evals/` suite (one Markdown case per file, `evals/README.md` for the suite; see `docs/superpowers/specs/2026-09-25-eval-cases-markdown-design.md`) through promptfoo, gated by `evals/bin/check-gate.mjs`; spends subscription usage. `make eval-compare SKILL=` runs skill vs baseline side by side. Unset `ANTHROPIC_API_KEY` first, or `claude -p` bills the API instead. - `make eval-local SKILL=` — runs the same suite against a local Ollama model (default `gemma4:latest`, override `EVAL_LOCAL_MODEL`), free, about a minute per case. It puts the skill in the system prompt under `--bare`, so it checks that cases render and grade, not how the skill loads. Sonnet runs (`make eval`) stay the reference. diff --git a/Makefile b/Makefile index 6b5ff358..aad89810 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,33 @@ MAKEFLAGS += -B default: @echo "Cowardly refusing to run on $(shell uname). Use platform specific targets." +BREW ?= /opt/homebrew/bin/brew +BREW_BASH ?= /opt/homebrew/bin/bash +SHELLS_FILE ?= /etc/shells + brew-install: - /bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + @if [ -x "$(BREW)" ]; then \ + echo "brew already installed at $(BREW)"; \ + else \ + /bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; \ + fi + +# Make the brew bash the login shell. Replaces the README's inline block; +# safe to re-run, and the only step of setup.sh that needs sudo without a +# defaults write behind it. +set-shell: + @[ -x "$(BREW_BASH)" ] || { echo "error: $(BREW_BASH) missing; run make brew-bundle first" >&2; exit 1; } + @grep -qx "$(BREW_BASH)" "$(SHELLS_FILE)" || echo "$(BREW_BASH)" | sudo tee -a "$(SHELLS_FILE)" >/dev/null + @if [ "$$(dscl . -read ~ UserShell | awk '{print $$2}')" = "$(BREW_BASH)" ]; then \ + echo "login shell already $(BREW_BASH)"; \ + else \ + chsh -s "$(BREW_BASH)"; \ + fi + +# setup.sh is the one entry point for a new or existing machine; `make setup` +# is the alias, ARGS passes flags through (make setup ARGS=--dry-run). +setup: + ./setup.sh $(ARGS) brew-bundle: /opt/homebrew/bin/brew shellenv > /tmp/brew-shell.env @@ -29,6 +54,20 @@ macos-accept: check-macos-defaults: @./scripts/macos-defaults.sh check +# nonration.al is GitHub Pages built from site/ plus the root setup.sh, so the +# served script is always main's. The pages workflow and check-site share this. +SITE_DIR ?= _site +site: + @rm -rf "$(SITE_DIR)" && mkdir -p "$(SITE_DIR)" + @cp site/index.html setup.sh "$(SITE_DIR)/" + +check-site: + @d="$$(mktemp -d)" && $(MAKE) -s site SITE_DIR="$$d" \ + && cmp -s "$$d/setup.sh" setup.sh \ + && grep -qF 'curl -sS https://nonration.al/setup.sh | bash' "$$d/index.html" \ + || { echo "check-site: built site is missing setup.sh or the curl line" >&2; rm -rf "$$d"; exit 1; }; \ + rm -rf "$$d" + macos: macos-doctor macos-apply ./scripts/macos-bootstrap.sh osascript -e 'tell app "loginwindow" to «event aevtrrst»' @@ -88,6 +127,7 @@ test: ./test/test_kitty.sh ./test/test_fish.sh ./test/test_macos_defaults.sh + ./test/test_setup.sh deploy: ./deploy.sh apply @@ -150,7 +190,7 @@ check-copilot-instructions: # The one definition of "safe to commit" -- CI runs this same target, so a # new check-* target is covered by both the moment it's added here. -preflight: test check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions check-macos-defaults eval-validate eval-test +preflight: test check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions check-macos-defaults check-site eval-validate eval-test # Skill eval suite (evals/). eval-validate and eval-test are offline and free, # and run in preflight. eval and eval-compare spend subscription usage: a @@ -188,13 +228,24 @@ eval-compare: evals/node_modules link-karabiner: # don't link entire .config directory because it may contain secrets - mkdir -p $$HOME/.config - ln -s $$PWD/karabiner $$HOME/.config/karabiner + @mkdir -p $$HOME/.config + @if [ "$$(readlink $$HOME/.config/karabiner 2>/dev/null)" = "$$PWD/karabiner" ]; then \ + echo "karabiner already linked"; \ + elif [ -e $$HOME/.config/karabiner ] && [ ! -L $$HOME/.config/karabiner ]; then \ + echo "error: $$HOME/.config/karabiner exists and is not a symlink; move it aside" >&2; exit 1; \ + else \ + ln -sfn $$PWD/karabiner $$HOME/.config/karabiner; \ + fi link-sublime: - git clone https://github.com/nonrational/sublime3 $$HOME/.sublime3 - rm -rf $$HOME/Library/Application\ Support/Sublime\ Text - ln -s $$HOME/.sublime3 $$HOME/Library/Application\ Support/Sublime\ Text + @[ -d $$HOME/.sublime3/.git ] || git clone https://github.com/nonrational/sublime3 $$HOME/.sublime3 + @if [ "$$(readlink "$$HOME/Library/Application Support/Sublime Text" 2>/dev/null)" = "$$HOME/.sublime3" ]; then \ + echo "Sublime Text already linked"; \ + elif [ -e "$$HOME/Library/Application Support/Sublime Text" ] && [ ! -L "$$HOME/Library/Application Support/Sublime Text" ]; then \ + echo "error: $$HOME/Library/Application Support/Sublime Text exists and is not a symlink; move it aside" >&2; exit 1; \ + else \ + ln -sfn $$HOME/.sublime3 "$$HOME/Library/Application Support/Sublime Text"; \ + fi # (Re)load the clipboard bridge launch agent: a socket-activated responder on # 127.0.0.1:2224 that hands the pasteboard image to exe.dev VMs (see @@ -219,7 +270,8 @@ restore-preferences: macos-disable-restore-apps-on-login: # See https://apple.stackexchange.com/a/322787 - # clear the file if it isn't empty + # clear the file if it isn't empty (lifting the flag a previous run set) + find ~/Library/Preferences/ByHost/ -name 'com.apple.loginwindow*' -exec chflags nouimmutable {} \; find ~/Library/Preferences/ByHost/ -name 'com.apple.loginwindow*' ! -size 0 -exec tee {} \; < /dev/null # set the user immutable flag find ~/Library/Preferences/ByHost/ -name 'com.apple.loginwindow*' -exec chflags uimmutable {} \; diff --git a/README.md b/README.md index 10a8da4d..6909541d 100644 --- a/README.md +++ b/README.md @@ -6,54 +6,21 @@ _Architectural Digest_ for `$HOME`. # Installation -## macOS +## Any machine ```shell -#!/usr/bin/env sh -xcode-select --install - -git clone https://github.com/nonrational/dotfiles .dotfiles -cd .dotfiles - -make brew-install -make brew-bundle - -if [ -f /opt/homebrew/bin/bash ]; then - echo '/opt/homebrew/bin/bash' | sudo tee -a /etc/shells - chsh -s '/opt/homebrew/bin/bash' -else - echo 'Unable to set default shell to `/opt/homebrew/bin/bash`' -fi - -# Better get a new terminal at this point. -make init-submodules -make deploy -make link-karabiner -make clipboard-bridge # launch agent that serves clipboard images to exe.dev VMs - -# Authenticate with `gh` to clone private repo(s) -gh auth login -make link-sublime -make restore-preferences - -# Almost there! Good idea to restart iTerm now, and take the opportunity to -# ensure it has full disk access. -make macos-reset-dock -make macos-disable-restore-apps-on-login -make macos +curl -sS https://nonration.al/setup.sh | bash ``` -## GNU/Linux +That is the whole sequence, for a new Mac, a Linux host, or a machine catching up after months. `setup.sh` clones this repo into `~/.dotfiles` if it has to, then runs every step whose end state does not already hold; re-run it any time with `./setup.sh` or `make setup` (`make setup ARGS=--dry-run` shows what a run would do). On a Mac it halts at three checkpoints, each printed with a number and the instruction to re-run when done: -```shell -#!/usr/bin/env sh +1. The Command Line Tools installer, on a machine without them. +2. Full Disk Access for the terminal, which the `defaults` audit needs. +3. The reboot at the end. -git clone git@github.com:nonrational/dotfiles .dotfiles -cd .dotfiles +The Dock reset and the iTerm, Amphetamine and Moom preference restore run only when `setup.sh` had to clone, so a re-run on an existing machine never wipes either. Linux runs only the submodule init and the deploy. -make init-submodules -make deploy -``` +A failed step prints `failed: ` and exits 2; re-run after fixing it. # Clipboard bridge (exe.dev VMs) diff --git a/docs/superpowers/plans/2026-10-29-setup-script-split.md b/docs/superpowers/plans/2026-10-29-setup-script-split.md new file mode 100644 index 00000000..dc65f282 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-setup-script-split.md @@ -0,0 +1,203 @@ +# Split PR #74 into a Mac setup PR and an exe-box PR: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace PR #74 with two independent PRs: (A) `curl -sS https://nonration.al/setup.sh | bash` provisions a new Mac for @nonrational, halting at numbered checkpoints; (B) `make exe-box NAME=` creates or reuses `.exe.xyz` and provisions it as an @nonreagent host. + +**Architecture:** PR A salvages `setup.sh`, the re-runnable make targets, and the CI and README changes from branch `setup-script`, folds `scripts/setup-lib.sh` back into `setup.sh` (no downstream consumer remains), applies the four findings from the operator's VM run, and adds a Cloudflare Worker that serves `setup.sh` at `nonration.al/dotfiles/setup.sh` with a 200. PR B salvages `scripts/new-exe-box.sh` and points it at nonreagent/dotfiles' existing `install.sh` instead of a downstream `setup.sh` that will not exist. Neither PR touches nonreagent/dotfiles. + +**Tech Stack:** bash (3.2-compatible), make, Cloudflare Workers (ES module, `wrangler`), `node --test` (Node 24), GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-25-setup-script-design.md` on branch `setup-script` (commit `f1ea01a`). PR A amends it in Task A1; PR B carries its own section as `docs/superpowers/specs/2026-10-29-exe-box-design.md`. The operator's QA comment on PR #74 (2026-10-13) is the source for Task A4. + +## Global Constraints + +- Source branch for salvage: `origin/setup-script`. Commit SHAs below refer to it: `f1ea01a` spec, `b91ca99` make targets, `2cd967f` harness, `4c17af4` setup.sh, `488f102` CI/README, `70d6f81` + `27a3f6e` exe box. `986173d` (the old plan) is not carried forward; this plan supersedes it. +- Each PR branches from `origin/main` in its own worktree: `.worktrees/setup-mac` (branch `setup-mac`, already holds this plan) and `.worktrees/exe-box` (branch `exe-box`). Never switch branches in the shared checkout. +- `setup.sh` runs under macOS's `/bin/bash` 3.2 on a fresh Mac: no `mapfile`, associative arrays, `${var,,}`, or `|&`. +- Exit codes for `setup.sh`: `0` converged (or dry run finished), `1` halted at a checkpoint, `2` a step failed or a usage error. +- Public URL, verbatim: `https://nonration.al/setup.sh`. Upstream it proxies: `https://raw.githubusercontent.com/nonrational/dotfiles/main/setup.sh`. +- The documented command is `curl -sS https://nonration.al/setup.sh | bash`, never with `-f`: `-f` drops the Worker's error body, so bash reads empty input and the pipeline exits 0 silently. Without `-f`, the error body runs and exits 1. +- `node --test` takes a quoted glob (`'cloudflare/dotfiles-proxy/*.test.mjs'`), never a directory: Node 22+ treats a directory argument as a module and fails. +- exe.dev VM hostnames are `.exe.xyz` (not `.exe.dev`). Every ssh call passes `-o StrictHostKeyChecking=accept-new`. +- The GitHub token travels on ssh stdin only, never in an argument, `--env`, or a setup-script. +- Any test file that runs `git init` unsets `GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` at its top. +- Commit subjects are plain sentences (no `feat:`-style prefixes) and end with `Co-Authored-By: Claude Opus 5.5 `. +- PR bodies: issue line first, then exactly `### Problem`, `### Motivation`, `### Proposed Solution`, `### Feedback`. No AI attribution. Request review from `nonrational` on open. +- `make preflight` passes at every commit. + +## Review Focus + +1. **`curl … | bash` against an error.** The documented line has no `-f`, so whatever body the Worker returns runs in bash. A 404 or upstream failure must return a body that bash executes as `echo "" >&2; exit 1`, never HTML. Pinned in Task A5. +2. **A run function with two commands under `"$run" || exit 2`.** `||` suspends errexit inside the function, so `apply_macos` would run `macos-bootstrap.sh` after a failed `macos-apply`. Pinned in Task A4. +3. **Re-running `make exe-box` against a box that already exists.** Expected: no second `exe.dev new`, provisioning re-runs and converges. Pinned in Task B1. +4. **The reboot step on a Mac reached over ssh.** The operator saw osascript `-1708` and a bare exit 1. Expected: checkpoint 3 telling the human to restart by hand. Pinned in Task A4. +5. **A `.gitmodules` entry with no gitlink, or `git` failing.** Expected: the stale entry does not make `init-submodules` run every pass; a failing `git` makes the guard false (run the step) rather than true. Pinned in Task A4. +6. **A brand-new macOS 26 Mac at `macos-apply`.** The operator found `com.apple.addressbook ABShowDebugMenu` unwritable on macOS 26 (`origin/main:macos-defaults:575`), and `scripts/macos-defaults.sh` runs under `set -euf`, so apply aborts there on every run and the reboot never comes. Fixed in Task A4. + +--- + +## PR A: `setup-mac`, the Mac setup command + +### Task A1: Amend the spec for the split + +**Files:** +- Create: `docs/superpowers/specs/2026-09-25-setup-script-design.md` (as it stands on `origin/setup-script`, which includes the later edits from `4c17af4` and `70d6f81`) +- Already present: `docs/superpowers/plans/2026-10-29-setup-script-split.md` (this file) + +- [ ] **Step 1:** In `.worktrees/setup-mac`, `git show origin/setup-script:docs/superpowers/specs/2026-09-25-setup-script-design.md > docs/superpowers/specs/2026-09-25-setup-script-design.md`. Taking the final version here, not `f1ea01a`, is what lets A3's cherry-pick drop its spec hunks. +- [ ] **Step 2:** Edit the spec, quoting these anchors: + - Status line `**Status:** design, awaiting review` → `**Status:** amended 2026-10-29: split into a Mac PR and an exe-box PR`. + - Under `### Entry: \`setup.sh\` at the repo root`, the curl line becomes `curl -sS https://nonration.al/setup.sh | bash`; add one paragraph: a Cloudflare Worker on route `nonration.al/dotfiles/*` proxies `raw.githubusercontent.com/nonrational/dotfiles/main/setup.sh` with a 200, so bare `curl` works without `-L`. + - Replace the section `### Split: \`scripts/setup-lib.sh\` holds the harness, \`setup.sh\` holds the steps` with `### One file`: the harness functions (`step`, `first_run_step`, `checkpoint`, `run_setup`) live in `setup.sh`; nothing downstream vendors them. + - Replace `## Downstream: nonreagent/dotfiles` and `## \`scripts/new-exe-box.sh\`…` with one paragraph: nonreagent/dotfiles keeps its own `install.sh` unchanged; the exe-box wrapper ships separately (`docs/superpowers/specs/2026-10-29-exe-box-design.md`, PR B). + - In `## Out of scope`, drop the bullet `nonreagent's own \`setup.sh\`…`. In `## Decisions taken during design`, replace `**Lib split from day one.**` with `**One file.** The lib split existed for downstream vendoring, which the split dropped.` + - Add exit code `2` to the `### Checkpoints` paragraph: a failed step prints `failed: ` and exits 2. +- [ ] **Step 3:** Verify: `grep -nE 'setup-lib|new-exe-box|curl -fsSL' docs/superpowers/specs/2026-09-25-setup-script-design.md` prints nothing except the one line pointing at the exe-box spec. +- [ ] **Step 4:** Commit both files: `Design one setup command for a Mac, and plan its split from the exe box`. + +### Task A2: Make the setup targets re-runnable + +**Files:** +- Modify (cherry-pick `b91ca99`): `Makefile`, Create: `test/test_setup.sh` + +**Interfaces:** +- Produces: make targets `brew-install`, `set-shell`, `link-karabiner`, `link-sublime`, `macos-disable-restore-apps-on-login` all re-runnable; `setup` alias (`./setup.sh $(ARGS)`); vars `BREW`, `BREW_BASH`, `SHELLS_FILE`. `test/test_setup.sh` with helpers `sandbox`, `stub [stdout]`, `mk`, `ok`, `bad`, and `STUB_FAIL`/`STUB_EXIT` semantics as on `setup-script`. + +- [ ] **Step 1:** `git cherry-pick b91ca99`. The `setup` target references a file that does not exist until A3; that is fine, nothing calls it yet. +- [ ] **Step 2:** Run `./test/test_setup.sh`. Expected: the eight make-target tests (`test_link_karabiner_is_idempotent` through `test_set_shell_requires_brew_bash`) PASS, `0 failed`. +- [ ] **Step 3:** `make preflight` passes. Keep the cherry-picked subject. + +### Task A3: Add `setup.sh` as one file + +**Files:** +- Create: `setup.sh` (content of `4c17af4`'s `setup.sh` plus `2cd967f`'s `scripts/setup-lib.sh`, merged) +- Modify: `test/test_setup.sh` (`b91ca99` already added it to the `test:` recipe; do not add it again) +- Do not create: `scripts/setup-lib.sh`. Do not touch: `docs/` (A1 owns the spec) + +**Interfaces:** +- Consumes: A2's targets and test helpers; `scripts/host-id.sh` for `os_id`. +- Produces: `setup.sh` functions `step `, `first_run_step `, `checkpoint `, `run_setup "$@"`, `setup_darwin`, `setup_linux`, `setup_self_bootstrap "$@"`; env `SETUP_REPO_URL`, `SETUP_CLONE_DIR`, `SETUP_BRANCH`, `SETUP_BREW`, `SETUP_BREW_BASH`, `SETUP_ETC_SHELLS`, `SETUP_FIRST_RUN`, `SETUP_DRY_RUN`. + +- [ ] **Step 1:** `git cherry-pick --no-commit 2cd967f 4c17af4`; if the spec conflicts, `git checkout HEAD -- docs/` (A1 already holds its final text). Move the body of `scripts/setup-lib.sh` (minus its header and the `SETUP_ROOT` assertion) into `setup.sh` above the guards; `git rm scripts/setup-lib.sh`. `setup_self_bootstrap` detects a checkout by `[ -f "$here/scripts/host-id.sh" ]` instead of the lib. `main` sources `"$SETUP_ROOT/scripts/host-id.sh"` and calls `run_setup`. Rewrite the header comment: one file, no mention of vendoring. +- [ ] **Step 2:** In `test/test_setup.sh`, drop `scripts/setup-lib.sh` from `setup_repo`'s `cp` line (the suite aborts under `set -e` otherwise), and delete the `# --- setup-lib.sh ---` block (`lib_fixture`, `fixture`, and the eight `test_*` it defines). Port the three with no `setup.sh` equivalent, driving the real `setup.sh` through the existing `setup_run` helper: + - `test_unknown_flag_is_usage_error`: `setup_run --bogus` → status `2`, output contains `usage: setup.sh [--dry-run]`, stub `make` never called. + - `test_unsupported_os_fails`: `uname` stubbed to `FreeBSD` → status `2` (a failure, not a checkpoint; change `run_setup`'s `exit 1` there), output contains `error: unsupported OS FreeBSD`. + - `test_run_without_tty_survives`: `setup_run ` to stderr and `exit 2` when the run function returns nonzero; a counter `SETUP_PENDING` incremented on every `would:` line; dry-run summary line `setup: dry run (), would run`. + +- [ ] **Step 1: Write the failing tests** in `test/test_setup.sh`: + - `test_failed_step_exits_2_with_name`: converged Mac with `STUB_FAIL='gh auth status|gh auth login'` → status `2`, stderr contains `failed: gh auth login`, and no `run:` line follows it. + - `test_failed_apply_skips_bootstrap`: `STUB_MACOS_AUDIT=1`, `STUB_FAIL='make -C macos-apply'` → status `2`, the stub `macos-bootstrap.sh` log is empty, output contains `failed: macos-apply + macos-bootstrap`. + - Fixture change first: `setup_repo` keeps its fake `.git`, and a `git` stub answers `git -C submodule status` by printing `$STUB_SUBMODULES`. `converged_mac` sets it to ` 0123abc etc/sub`; the first-run and Linux tests set `-0123abc etc/sub`, so `MAC_ORDER` and `test_linux_runs_only_submodules_and_deploy` still see `init-submodules` run. + - `test_stale_gitmodules_entry_does_not_rerun_init`: builds its own real repo (`git init -q`, no `git` stub on PATH) whose `.gitmodules` lists `etc/stale` with no gitlink; with every other guard holding, output contains `skip: init-submodules`. This test proves git's own behavior, which the stub cannot. + - `test_git_failure_runs_init`: `STUB_FAIL='git'` → output contains `run: init-submodules`. + - `test_dry_run_summary_counts_pending`: Mac with `brew` absent and deploy audit failing, `--dry-run` → status `0`, last line `setup: dry run (Darwin), would run` where `` equals `grep -c '^would:'` of the same output, and no line `setup: converged`. + - `test_reboot_failure_is_checkpoint_3`: `STUB_MACOS_AUDIT=1`, `STUB_FAIL='osascript'` → status `1`, exactly one line matching `^checkpoint 3:`, and that line starts `checkpoint 3: the reboot request failed`. +- [ ] **Step 2:** Run `./test/test_setup.sh`. Expected: the six new tests FAIL, the rest PASS. +- [ ] **Step 3: Implement.** + - In `step` and `first_run_step`: `"$run" || { echo "failed: $name" >&2; exit 2; }`. Because `||` disables errexit inside `$run`, chain every multi-command run function with `&&`: `apply_macos() { mk macos-apply && "$SETUP_ROOT/scripts/macos-bootstrap.sh" && SETUP_MACOS_APPLIED=1; }`. `checkpoint` keeps calling `exit 1` directly, which the `||` does not intercept. + - `submodules_present`: `local out; out="$(git -C "$SETUP_ROOT" submodule status 2>/dev/null)" || return 1; ! grep -q '^-' <<<"$out"`. `git submodule status` lists only paths with a gitlink, so the stale `.gitmodules` entry drops out. Leave `.gitmodules` itself alone; deleting `etc/tomorrow-theme` is a separate cleanup. + - `run_setup` end: under dry run print `setup: dry run ($(os_id)), $SETUP_PENDING would run`; otherwise the existing `setup: converged ()`. + - `reboot_mac`: the pre-reboot echo loses its `checkpoint 3:` label (`rebooting. Run $0 once more afterwards; it should report every step as skip.`), then `osascript … || checkpoint 3 "the reboot request failed (no GUI session?). Restart this Mac by hand, then re-run setup.sh once more."` + - Delete the `ABShowDebugMenu` row from `macos-defaults`. Verify `make check-macos-defaults` still passes. +- [ ] **Step 4:** Run `./test/test_setup.sh`. Expected: all PASS, `0 failed`. `make preflight` passes. +- [ ] **Step 5:** Commit: `Exit 2 on a failed setup step, and fix the rough edges the VM run found`. + +### Task A5: Serve `setup.sh` at `nonration.al/dotfiles/setup.sh` + +> **Amended 2026-10-29, after PR A opened:** the operator chose GitHub Pages from this repo over the Worker, at `/setup.sh` rather than `/dotfiles/setup.sh`, with a plain landing page at the root. The Worker below was built, reviewed, then replaced by `site/index.html`, `.github/workflows/pages.yml`, and `make site` / `make check-site`. The old splash page and its `.well-known/keybase.txt` are retired. + +**Files:** +- Create: `cloudflare/dotfiles-proxy/worker.mjs`, `cloudflare/dotfiles-proxy/worker.test.mjs`, `cloudflare/dotfiles-proxy/wrangler.toml` +- Modify: `Makefile` (new `check-worker` target; add it to the `preflight:` prerequisite list) + +**Interfaces:** +- Produces: `export default { async fetch(request: Request): Promise }`; exported `UPSTREAM = "https://raw.githubusercontent.com/nonrational/dotfiles/main/setup.sh"`. + +- [ ] **Step 1: Write the failing tests** (`node:test`, `node:assert/strict`, stubbing `globalThis.fetch`): + - `GET https://nonration.al/setup.sh` fetches exactly `UPSTREAM`; with upstream `200` body `echo hi`, returns status `200`, body `echo hi`, `content-type: text/plain; charset=utf-8`, `cache-control: no-store`. + - Upstream `404` → status `502`, body matches `/^echo "nonration\.al: setup\.sh unavailable \(upstream 404\)" >&2; exit 1\n$/`. + - `GET /dotfiles/anything-else` and `GET /dotfiles/` → status `404`, body `echo "nonration.al: no such file" >&2; exit 1\n`, and `fetch` never called. + - Upstream `fetch` rejecting → status `502`, body starts `echo "nonration.al:` and ends `exit 1\n`. + - Every error body, written to a temp file and run with `bash`, exits `1` (spawn via `node:child_process`). +- [ ] **Step 2:** `node --test 'cloudflare/dotfiles-proxy/*.test.mjs'` → FAIL (cannot import `./worker.mjs`). +- [ ] **Step 3:** Implement `worker.mjs`. Only the exact path `/dotfiles/setup.sh` proxies; no general path mapping. `wrangler.toml`: `name = "nonration-al-dotfiles"`, `main = "worker.mjs"`, `compatibility_date = "2026-10-01"`, `routes = [{ pattern = "nonration.al/dotfiles/*", zone_name = "nonration.al" }]`. `check-worker:` runs `node --test 'cloudflare/dotfiles-proxy/*.test.mjs'`. +- [ ] **Step 4:** `make check-worker` and `make preflight` pass. +- [ ] **Step 5:** Commit: `Serve setup.sh at nonration.al/dotfiles/setup.sh through a Cloudflare Worker`. + +Deployment is the operator's, after PR A merges (the Worker proxies `main`, which has no `setup.sh` until then): `cd cloudflare/dotfiles-proxy && npx wrangler@4 login && npx wrangler@4 deploy`. Acceptance: `curl -s https://nonration.al/setup.sh | cmp - setup.sh` exits 0, and `curl -sI` shows `HTTP/2 200`. The PR's Feedback section says so. + +### Task A6: CI, README, and CLAUDE.md + +**Files:** +- Modify (cherry-pick `488f102`): `.github/workflows/ci.yml`, `README.md`, `CLAUDE.md`, `scripts/macos-bootstrap.sh` +- Modify: `setup.sh` (header and checkpoint-1 text) + +- [ ] **Step 1:** `git cherry-pick --no-commit 488f102`. Resolve against current `main` (README and CLAUDE.md moved since 2026-09-25; fish landed in #95). +- [ ] **Step 2:** Replace the URL: README's install block becomes `curl -sS https://nonration.al/setup.sh | bash`; `setup.sh`'s header usage line likewise; the self-bootstrap checkpoint-1 line reads `re-run: curl -sS https://nonration.al/setup.sh | bash`. In the CLAUDE.md Commands bullet, drop the sentence about `scripts/setup-lib.sh` and vendoring; add a bullet `make check-worker` — tests the Worker in `cloudflare/dotfiles-proxy/` that serves `setup.sh` at nonration.al. +- [ ] **Step 3:** In README, under the three checkpoints, add: a failed step prints `failed: ` and exits 2; re-run after fixing it. +- [ ] **Step 4:** Verify: `git grep -nE 'raw.githubusercontent.com/nonrational/dotfiles/main/setup.sh|curl -fsSL https://nonration' -- . ':!cloudflare' ':!docs'` prints nothing; `./setup.sh --dry-run` on Linux exits 0 with last line `setup: dry run (Linux), would run`; `make preflight` passes. +- [ ] **Step 5:** Commit: `Run setup.sh in CI and make the nonration.al curl line the README's install sequence`. + +### Task A7: Open PR A + +- [ ] **Step 1:** `git rebase --exec 'env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE make preflight' origin/main` passes at every commit. +- [ ] **Step 2:** Push `setup-mac`; open the PR titled `One setup command for a new Mac`, body first line `Resolves #28`. Feedback names: the Worker deploy and acceptance `curl` as the operator's post-merge step; the errexit-under-`||` change in `apply_macos`; the `link-sublime` refusal carried from #74; that the operator's VM run predates A4. +- [ ] **Step 3:** `gh pr edit --add-reviewer nonrational`. Remove the worktree. + +--- + +## PR B: `exe-box`, one command for a new @nonreagent box + +### Task B1: `scripts/new-exe-box.sh` against nonreagent's existing install + +**Files:** +- Create: `scripts/new-exe-box.sh` (from `27a3f6e`), `test/test_exe_box.sh`, `docs/superpowers/specs/2026-10-29-exe-box-design.md` +- Modify: `Makefile` (`exe-box` target as in `70d6f81`; `./test/test_exe_box.sh` appended to `test:`), `README.md` (a new `# New exe.dev box` section directly above `# Clipboard bridge (exe.dev VMs)`: the `make exe-box NAME=` line and two sentences on what it does) + +**Interfaces:** +- Produces: `scripts/new-exe-box.sh `; env `EXE_BOX_REPO_URL` (default `https://github.com/nonreagent/dotfiles`), `EXE_BOX_GH_TOKEN_REF` (default `op://Private/nonreagent-github/token`), `EXE_BOX_WAIT` (300), `EXE_BOX_POLL` (5). `EXE_BOX_SETUP_URL` is gone. + +- [ ] **Step 1:** In a new worktree `.worktrees/exe-box` on branch `exe-box` from `origin/main`, restore the files: `git show 27a3f6e:scripts/new-exe-box.sh > scripts/new-exe-box.sh`. Write the spec: the `## \`scripts/new-exe-box.sh\`` section of the spec as it stands on `origin/setup-script` (after `70d6f81`; not `f1ea01a`, which still has the superseded `--setup-script` design), amended so step 4 is the clone-and-install command below and the "Order of delivery" paragraph is gone (nothing downstream needs to land first). +- [ ] **Step 2: Write the failing tests** in `test/test_exe_box.sh`, a standalone suite in the shape of the other `test/test_*.sh` files: copy `sandbox`, `stub`, `ok`, `bad` and `stub_ssh` from `setup-script`'s `test/test_setup.sh` (lines 16-48 and 499-522). `stub_ssh` keeps one failure counter across every ` true` call, so the existence probe consumes the first failure, and the default `STUB_SSH_NOT_READY=0` takes the reuse path. The `exe_box` helper sets `EXE_BOX_REPO_URL=https://example.invalid/repo` in place of `EXE_BOX_SETUP_URL`. Tests: + - `test_exe_box_creates_waits_injects_and_installs`: `STUB_SSH_NOT_READY=2` → status `0`; log has `exe.dev new --name mybox --json`; `mybox.exe.xyz true` three times (the failed probe, one failed wait, one answered wait); `gh auth login --with-token && gh auth setup-git`; then exactly `mybox.exe.xyz { git -C ~/.dotfiles pull --ff-only || git clone https://example.invalid/repo ~/.dotfiles; } && ~/.dotfiles/install.sh && ~/.claude/sync-plugins.sh`; second-to-last output line `ready: mybox.exe.xyz https://mybox.exe.xyz/`; last line `optional: ssh mybox.exe.xyz ~/bin/setup-review-watcher`. + - `test_exe_box_reuses_existing_box`: default stub (answers at once) → status `0`, output contains `using existing mybox.exe.xyz`, log has no `exe.dev new`, and the install command still runs. + - `test_bad_token_ref_creates_nothing`: `op` stub fails → status nonzero, log has no `ssh` line at all. (Already true of `27a3f6e`; it pins the order against regression.) + - `test_token_goes_over_stdin_only`, `test_exe_box_requires_name`: ported unchanged. `test_exe_box_times_out_waiting`: ported, still `STUB_SSH_NOT_READY=1000000`, so the probe fails and the wait times out. +- [ ] **Step 3:** `./test/test_exe_box.sh` → the first two FAIL; the rest PASS. +- [ ] **Step 4: Implement.** In `new-exe-box.sh`: after reading the token, probe `ssh $ssh_opts -o ConnectTimeout=5 "$host" true`; on success print `using existing $host` and skip `new` and the wait loop. Replace the curl step with `ssh $ssh_opts "$host" "{ git -C ~/.dotfiles pull --ff-only || git clone $EXE_BOX_REPO_URL ~/.dotfiles; } && ~/.dotfiles/install.sh && ~/.claude/sync-plugins.sh"`. A re-run pulls first, so it converges on current `main`; a pull that fails on an existing clone sends `git clone` into a non-empty directory, which fails and stops the chain. Print the `ready:` line, then the `optional:` line. Update the header comment: runs nonreagent/dotfiles' `install.sh`; no `setup.sh` downstream. +- [ ] **Step 5:** `./test/test_exe_box.sh` all PASS; `make preflight` passes. +- [ ] **Step 6:** Commit: `Add new-exe-box.sh: create or reuse an exe.dev VM and provision it as an @nonreagent host`. + +### Task B2: Acceptance on a real throwaway box + +Needs the operator's terminal on the Mac (exe.dev key, unlocked `op`); the agent hands over the commands. + +- [ ] **Step 1:** Operator confirms `EXE_BOX_GH_TOKEN_REF`'s 1Password path and runs `ssh exe.dev help new` to confirm `--name` and `--json` still exist. +- [ ] **Step 2:** `make exe-box NAME=nr-probe-$(date +%m%d)`. Expected: exit 0 and the `ready:` line. Then `ssh nr-probe-….exe.xyz 'gh auth status && ~/.dotfiles/deploy.sh audit && command -v claude'` exits 0. +- [ ] **Step 3:** Run `make exe-box NAME=` again. Expected: `using existing`, exit 0, no second VM in `ssh exe.dev ls`. +- [ ] **Step 4:** Any fix found here amends B1's commit (`git commit --fixup` + autosquash). Delete the probe box: `ssh exe.dev rm `. + +### Task B3: Open PR B + +- [ ] **Step 1:** Push `exe-box`; open the PR titled `One command for a new @nonreagent exe.dev box`, body first line `Part of #28`. Feedback names: the token ref default; that B2's probe ran (with its output) or did not; that whichever of A and B merges second resolves a one-line conflict in the `test:` recipe by keeping both `./test/test_setup.sh` and `./test/test_exe_box.sh`. +- [ ] **Step 2:** `gh pr edit --add-reviewer nonrational`. Remove the worktree. + +--- + +## Close PR #74 + +- [ ] **Step 1:** After A and B are open, comment on #74: `Split into # (Mac setup, plus the four VM-run findings) and # (exe box). Closing in favor of those.` Then `gh pr close 74`. +- [ ] **Step 2:** Leave `origin/setup-script` in place until both merge; the salvage SHAs above point into it. Delete it after. diff --git a/docs/superpowers/specs/2026-09-25-setup-script-design.md b/docs/superpowers/specs/2026-09-25-setup-script-design.md new file mode 100644 index 00000000..f6e47f4f --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-setup-script-design.md @@ -0,0 +1,157 @@ +# One setup command for a Mac, and one for an exe box + +**Issue:** [#28](https://github.com/nonrational/dotfiles/issues/28) \ +**Date:** 2026-09-25 \ +**Status:** amended 2026-10-29: split into a Mac PR and an exe-box PR + +## Problem + +Bringing a machine to the state this repo describes means executing the README's shell block by hand, in order, and knowing which lines to skip on a re-run. Nothing but prose encodes the ordering, five of the make targets fail when run twice, and two of them are destructive on every run after the first. The same shape repeats downstream: an exe.dev VM for @nonreagent is a clone, `./install.sh`, and two prose steps. + +The spike question the issue asked: which of the README's steps are genuinely idempotent, and can the rest be expressed as checkpoints that print and halt rather than steps that silently no-op? If the checkpoint set is small, a single setup command is honest. + +## Spike findings + +Every README step, classified from the Makefile and probed on this machine (macOS 26.6.2). + +| Step | Bucket | Evidence | +|---|---|---| +| `xcode-select --install` | checkpoint | Opens a GUI installer; exits 1 once installed. Guard: `xcode-select -p`. | +| `git clone` | n/a | The script runs from the clone. | +| `make brew-install` | idempotent with guard | Skip when `/opt/homebrew/bin/brew` exists. | +| `make brew-bundle` | idempotent | `brew bundle` converges. | +| `/etc/shells` + `chsh` | idempotent with guard | Check the file and `dscl . -read ~ UserShell`. Needs sudo, not a human. | +| "get a new terminal" | not a step | Only puts `brew` on PATH. `eval "$(brew shellenv)"` in-script replaces it. | +| `make init-submodules` | idempotent | | +| `make deploy` | idempotent | `deploy.sh audit` already proves convergence. | +| `make link-karabiner` | idempotent with guard | Bare `ln -s` fails on re-run today. | +| `make clipboard-bridge` | idempotent | bootout, then bootstrap. | +| `gh auth login` | interactive, inline | Guard: `gh auth status`. Runs on `/dev/tty`; no halt. | +| `make link-sublime` | idempotent with guard | `git clone` fails on re-run today. | +| `make restore-preferences` | first run only | Clobbers live iTerm, Amphetamine and Moom preferences. | +| restart iTerm, grant Full Disk Access | checkpoint | `make macos-doctor` detects the FDA half and halts. | +| `make macos-reset-dock` | first run only | Wipes the Dock on every run. | +| `make macos-disable-restore-apps-on-login` | idempotent | Re-run's `tee` fails against the immutable file, cosmetically. | +| `make macos` | idempotent, ends in a checkpoint | Table apply converges; `scripts/macos-bootstrap.sh` is re-runnable; the reboot ends the run. | + +**Answer.** Three checkpoints halt the run: the Command Line Tools installer, Full Disk Access, and the reboot. Five targets need a guard. Two steps are first-run-only and destructive. Everything else converges. That is a small checkpoint set, so the command is honest. + +Two things the issue did not anticipate. The dependency graph is shallow: brew before bundle, bundle before `set-shell` and before any brew-installed tool, deploy before `clipboard-bridge` and `make macos`. And the terminal restart disappears entirely once the script evaluates `brew shellenv` itself. + +## Design + +### Entry: `setup.sh` at the repo root + +One file, two ways in: + +``` +curl -sS https://nonration.al/setup.sh | bash +./setup.sh # or make setup, any time after +``` + +"Setup" reads as both first install and catch-up, which is the point. "Install" implies once, "bootstrap" is taken by the macOS script, and "converge" names the mechanism rather than the job. + +The body is wrapped in a function invoked on the last line, so a half-downloaded script does nothing. When the script is not running from inside a git checkout (`BASH_SOURCE` unset, or its directory has no `.git`), it self-bootstraps: install the Command Line Tools if `xcode-select -p` fails (a checkpoint, see below), clone `https://github.com/nonrational/dotfiles` into `~/.dotfiles` since a fresh Mac has no SSH keys, export `SETUP_FIRST_RUN=1`, and exec `~/.dotfiles/setup.sh`. Every prompt (sudo, `gh auth login`, the screen-lock password) reads from `/dev/tty`, because under the pipe stdin is the script. + +nonration.al is GitHub Pages published from this repo by `.github/workflows/pages.yml`. On every push to main, `make site` copies `site/index.html` (a plain page showing the command) and the root `setup.sh` into the artifact, so the served script is always main's and bare `curl` gets a 200 without `-L`. A missing file returns Pages' HTML 404, which bash rejects with a syntax error and a nonzero exit; `make check-site` keeps that from happening by failing preflight when the built site lacks `setup.sh` or the command. + +The OS branch is by `uname`, the way the manifest's `os=` condition is. + +### One file + +The harness functions (`step`, `first_run_step`, `checkpoint`, `run_setup`) live in `setup.sh`; nothing downstream vendors them. The self-clone is a short function at the top, parameterized by `SETUP_REPO_URL` and `SETUP_CLONE_DIR`, because under `curl | bash` no other file exists on disk yet. + +The harness exposes four functions and nothing else: + +- `step `: if the guard exits 0, print `skip: ` and move on; otherwise run, or under `--dry-run` print `would: `. +- `first_run_step `: as `step`, but only when `SETUP_FIRST_RUN=1`; otherwise print `skip: (first run only)`. +- `checkpoint `: print the numbered instruction, tell the user to re-run `setup.sh`, exit 1. +- `run_setup`: argument parsing, the `/dev/tty` reopen, the OS dispatch to `setup_darwin` or `setup_linux`, and the summary line. + +The step and guard functions live in `setup.sh` and call make targets. `setup.sh` contributes ordering, `brew shellenv`, the checkpoints, and the first-run steps. Nothing else. + +### macOS steps, in order + +1. Command Line Tools. Guard `xcode-select -p`. On miss: run `xcode-select --install` and checkpoint 1, "finish the installer, then re-run". +2. `brew-install`. Guard: `/opt/homebrew/bin/brew` is executable. +3. `eval "$(/opt/homebrew/bin/brew shellenv)"`. Always; not a step, a fact the rest of the run needs. +4. `brew-bundle`. No guard; `brew bundle` is the guard. +5. `set-shell` (new target). Guard: `/etc/shells` lists the brew bash and `dscl . -read ~ UserShell` returns it. +6. `init-submodules`. Guard: no submodule path is empty. +7. `deploy`. Guard: `./deploy.sh audit` exits 0. +8. `link-karabiner`. Guard: `~/.config/karabiner` resolves to the repo's `karabiner`. +9. `clipboard-bridge`. Guard: `launchctl list` shows the label. +10. `gh auth login`. Guard: `gh auth status`. Interactive, inline on `/dev/tty`; no checkpoint. +11. `link-sublime`. Guard: `~/.sublime3` is a git checkout and the Application Support link resolves to it. +12. First run only: `restore-preferences`, then `macos-reset-dock`. +13. `macos-disable-restore-apps-on-login`. Guard: the ByHost loginwindow files are empty and immutable. +14. `macos-doctor`. On failure: checkpoint 2, "grant Full Disk Access to this terminal, restart it, then re-run". +15. `macos-apply`, then `scripts/macos-bootstrap.sh`. Guard: `scripts/macos-defaults.sh audit` exits 0, the same pattern `deploy` uses; it runs after the doctor step so Full Disk Access is already granted. +16. Reboot: checkpoint 3 by nature. Runs only when this run applied the table (step 15's own flag, not the audit), so a Mac that was already converged before this run skips it instead of rebooting every re-run. The script prints that it is rebooting and runs the existing osascript. + +The `make macos` target keeps working on its own (doctor, apply, bootstrap, reboot) but `setup.sh` calls its three parts separately so the doctor failure is a checkpoint rather than a make error. + +### Linux steps, in order + +1. `init-submodules`. 2. `deploy`. Same guards, same first-run flag, no checkpoints. This is the path the nonreagent hosts take and the one CI can run end to end. + +### Makefile changes + +Guards move into the targets that own them, so each target becomes re-runnable on its own and `setup.sh` never re-implements a target's logic: + +- `brew-install`: skip with a message when brew exists. +- `set-shell`: new. Appends the brew bash to `/etc/shells` if absent, `chsh` if the user shell differs. Replaces the README's inline block. +- `link-karabiner`, `link-sublime`: skip the clone or link when it is already correct; replace a wrong link. +- `macos-disable-restore-apps-on-login`: `chflags nouimmutable` before the `tee`, so a re-run does not print a failure. +- `setup`: alias for `./setup.sh "$(ARGS)"`. +- `.PHONY`: drop `macos-setup` and `init-post-reboot`, which name no target, and add `setup` and `set-shell`. + +### First run + +The self-clone is the only thing that sets `SETUP_FIRST_RUN`. The two destructive targets run only under it. Running `./setup.sh` from an existing clone never touches the Dock or the preference files, and there is no flag to force it. Re-doing first setup on a machine that has a clone means deleting the clone, which is the right bar for a destructive action. + +### Checkpoints + +A checkpoint prints one numbered instruction, says how to re-run it (the curl line before a clone exists, the script's own path after), and exits 1. A failed step prints `failed: ` and exits 2. Re-running starts from the top; every earlier step is a no-op by then, so there is no state file to keep or corrupt. Exit 1 rather than 0 so a wrapper (CI, the exe-box script) can tell "halted for a human" from "converged". + +### `--dry-run` + +Prints each step with its guard's verdict (`skip:` or `would:`) and runs nothing, including the self-clone. This is what CI runs on both OSes and what a human runs to see what a re-run would do. + +### Tests + +`test/test_setup.sh`, sandboxed like `test_deploy.sh`. It puts stub `xcode-select`, `brew`, `gh`, `launchctl`, `dscl` and `make` on PATH under a temp `$HOME`, with each stub's behavior set by an environment variable, and asserts: + +- macOS order: the stub `make` logs the targets it was called with, in the order above. +- Guards skip: with every stub reporting "already done", the run is all `skip:` lines and calls no target. +- Each checkpoint halts: stub `xcode-select -p` failing yields exit 1 and the checkpoint 1 message with nothing after it; stub `make macos-doctor` failing yields checkpoint 2. +- First run: the destructive targets appear only with `SETUP_FIRST_RUN=1`, and `--dry-run` never sets it. +- Linux: `uname` stubbed to Linux runs exactly `init-submodules` and `deploy`. +- Self-bootstrap: piping the script into bash from outside a checkout, with a stub `git`, clones to `$HOME/.dotfiles` and re-execs. + +Added to `make test`, so both CI legs pick it up. + +### CI + +`ci.yml` adds `./setup.sh --dry-run` on both runners. The ubuntu leg replaces its bare `./deploy.sh apply` and `audit` calls with `HOME=$(mktemp -d) ./setup.sh`, which runs the real Linux path and proves it converges; the macOS leg keeps `deploy.sh apply` and `audit` as they are, since the real macOS path needs brew and sudo. + +### Docs + +The README's two shell blocks collapse to the curl line, the local re-run line, the three checkpoints, and a sentence saying the first-run steps happen only when the script clones. `CLAUDE.md` gains one Commands line for `setup.sh` and `make setup`. `scripts/macos-bootstrap.sh`'s header gains "run by setup.sh after the table applies, immediately before the reboot". + +## Downstream + +nonreagent/dotfiles keeps its own `install.sh` unchanged. The exe-box wrapper ships separately (`docs/superpowers/specs/2026-10-29-exe-box-design.md`, PR B). + +## Out of scope + +- `topgrade` or any package-manager updating beyond `brew bundle`. Updating a machine and converging it are different verbs; if the first is ever wanted it is a separate command with a checked-in config, as recorded on the issue. +- Resume state. Idempotent steps make it unnecessary. +- A flag to force the first-run steps on an existing clone. +- Windows. + +## Decisions taken during design + +- **Automatic first-run steps.** The destructive targets run without asking on a fresh machine, because "one command for a new Mac" was the goal; the self-clone is what sets the first-run flag. +- **Name.** `setup.sh`, over `converge`, `install` and `bootstrap`. +- **One file.** The lib split existed for downstream vendoring, which the split dropped. diff --git a/macos-defaults b/macos-defaults index d14a766c..e5c47f20 100644 --- a/macos-defaults +++ b/macos-defaults @@ -569,10 +569,8 @@ com.apple.ActivityMonitor SortDirection int 0 # Address Book, TextEdit, and QuickTime Player # ############################################################################### # Dropped 2026-09-25: the Calendar debug menu (pre-10.8) and both Disk Utility -# rows (the El Capitan rewrite reads neither). - -# Enable the debug menu in Address Book -com.apple.addressbook ABShowDebugMenu bool true noaudit=unset +# rows (the El Capitan rewrite reads neither). Dropped 2026-10-29: the +# Address Book ABShowDebugMenu row (macOS 26 refuses the write). # Use plain text mode for new TextEdit documents com.apple.TextEdit RichText int 0 noaudit=unset diff --git a/scripts/macos-bootstrap.sh b/scripts/macos-bootstrap.sh index aee5c46d..6c798f47 100755 --- a/scripts/macos-bootstrap.sh +++ b/scripts/macos-bootstrap.sh @@ -5,7 +5,8 @@ # a row in ../macos-defaults; run `make macos-audit` to compare them against the # machine. What is left cannot be expressed as a domain/key/value: nvram, # systemsetup, sysadminctl, PlistBuddy, chflags, lsregister, tmutil, and the -# app restarts. Re-runnable; `make macos` runs it after applying the table. +# app restarts. Re-runnable; setup.sh (and `make macos`) runs it after the +# table applies, immediately before the reboot. # https://macos-defaults.com/ diff --git a/setup.sh b/setup.sh new file mode 100755 index 00000000..e8734886 --- /dev/null +++ b/setup.sh @@ -0,0 +1,269 @@ +#!/bin/bash +# setup.sh: bring this machine to the state the repo describes. One file: a +# step runner with guards, a first-run flag, checkpoints that halt for a +# human, --dry-run, and OS dispatch, followed by the steps themselves. +# Re-runnable: every step skips when its guard holds, three checkpoints halt +# for a human, and the two destructive steps run only when this script had to +# clone. +# Spec: docs/superpowers/specs/2026-09-25-setup-script-design.md +# +# curl -sS https://nonration.al/setup.sh | bash +# ./setup.sh [--dry-run] +set -euf -o pipefail + +SETUP_REPO_URL="${SETUP_REPO_URL:-https://github.com/nonrational/dotfiles}" +SETUP_CLONE_DIR="${SETUP_CLONE_DIR:-$HOME/.dotfiles}" +# Set to test an unmerged branch; empty clones the default branch. +SETUP_BRANCH="${SETUP_BRANCH:-}" +SETUP_BREW="${SETUP_BREW:-/opt/homebrew/bin/brew}" +SETUP_BREW_BASH="${SETUP_BREW_BASH:-/opt/homebrew/bin/bash}" +SETUP_ETC_SHELLS="${SETUP_ETC_SHELLS:-/etc/shells}" +SETUP_FIRST_RUN="${SETUP_FIRST_RUN:-0}" +# Records whether this run applied the macos-defaults table, so the reboot follows an apply and only an apply. +SETUP_MACOS_APPLIED=0 + +# Under `curl | bash` there is no file on disk, so there is no checkout to +# run from. Clone, mark the first run, and exec the on-disk copy; everything +# after this runs from a checkout. +setup_self_bootstrap() { + local here="" + if [ -n "${BASH_SOURCE[0]:-}" ]; then + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + fi + if [ -n "$here" ] && [ -f "$here/scripts/host-id.sh" ]; then + SETUP_ROOT="$here" + return 0 + fi + case " $* " in + *" --dry-run "*) + echo "would: clone $SETUP_REPO_URL -> $SETUP_CLONE_DIR" + exit 0 + ;; + esac + if [ "$(uname)" = Darwin ] && ! xcode-select -p >/dev/null 2>&1; then + xcode-select --install >/dev/null 2>&1 || true + echo + echo "checkpoint 1: finish the Command Line Tools installer." + echo "re-run: curl -sS https://nonration.al/setup.sh | bash" + exit 1 + fi + if [ ! -d "$SETUP_CLONE_DIR/.git" ]; then + git clone ${SETUP_BRANCH:+--branch "$SETUP_BRANCH"} "$SETUP_REPO_URL" "$SETUP_CLONE_DIR" \ + || { echo "failed: clone" >&2; exit 2; } + # A marker, not just the env flag: a step failing before the + # first-run steps must not lose them on the re-run. + touch "$SETUP_CLONE_DIR/.git/setup-first-run" + export SETUP_FIRST_RUN=1 + fi + if [ ! -x "$SETUP_CLONE_DIR/setup.sh" ]; then + echo "error: $SETUP_CLONE_DIR has no setup.sh; run git -C $SETUP_CLONE_DIR pull, then re-run" >&2 + exit 2 + fi + exec "$SETUP_CLONE_DIR/setup.sh" "$@" +} + +# --- harness: step runner, checkpoints, dispatch ---------------------------- + +SETUP_DRY_RUN=0 +# Counts the would: lines so a dry run can say how much is pending. +SETUP_PENDING=0 + +# step : skip when the guard exits 0; otherwise run, or +# under --dry-run say what would run. Guards and runs are function names. +step() { + local name="$1" guard="$2" run="$3" + if "$guard"; then + echo "skip: $name" + return 0 + fi + if [ "$SETUP_DRY_RUN" = 1 ]; then + echo "would: $name" + SETUP_PENDING=$((SETUP_PENDING + 1)) + return 0 + fi + echo "run: $name" + "$run" || { echo "failed: $name" >&2; exit 2; } +} + +# first_run_step : runs only when the self-clone marked this run +# as the first on the machine. There is no guard: these steps are the +# destructive ones, and the clone is the only evidence they are wanted. +first_run_step() { + local name="$1" run="$2" + if [ "$SETUP_FIRST_RUN" != 1 ] && [ ! -e "$SETUP_ROOT/.git/setup-first-run" ]; then + echo "skip: $name (first run only)" + return 0 + fi + if [ "$SETUP_DRY_RUN" = 1 ]; then + echo "would: $name" + SETUP_PENDING=$((SETUP_PENDING + 1)) + return 0 + fi + echo "run: $name" + "$run" || { echo "failed: $name" >&2; exit 2; } +} + +# checkpoint : halt for a human. Exit 1 so a wrapper can tell +# "halted" (1) from "converged" (0) and from a failed step's own nonzero exit. +checkpoint() { + local n="$1" + shift + echo + echo "checkpoint $n: $*" + echo "re-run $0 when done." + exit 1 +} + +setup_usage() { + echo "usage: setup.sh [--dry-run]" +} + +run_setup() { + local arg + for arg in "$@"; do + case "$arg" in + --dry-run) SETUP_DRY_RUN=1 ;; + -h | --help) setup_usage; exit 0 ;; + *) setup_usage >&2; exit 2 ;; + esac + done + # Under `curl | bash` stdin is the script itself. Give sudo and gh their + # prompts back when there is a terminal to give; CI has none, so skip. + if [ ! -t 0 ] && [ "$SETUP_DRY_RUN" != 1 ] && ( : /dev/null; then + exec &2 + exit 2 + ;; + esac + echo + if [ "$SETUP_DRY_RUN" = 1 ]; then + echo "setup: dry run ($(os_id)), $SETUP_PENDING would run" + else + echo "setup: converged ($(os_id))" + fi +} + +# --- steps ------------------------------------------------------------------ + +mk() { + make -C "$SETUP_ROOT" "$@" +} + +# --- guards: exit 0 when the step's end state already holds ----------------- + +have_clt() { xcode-select -p >/dev/null 2>&1; } +have_brew() { [ -x "$SETUP_BREW" ]; } +shell_is_brew() { + grep -qx "$SETUP_BREW_BASH" "$SETUP_ETC_SHELLS" 2>/dev/null \ + && [ "$(dscl . -read ~ UserShell 2>/dev/null | awk '{print $2}')" = "$SETUP_BREW_BASH" ] +} +submodules_present() { + # `submodule status` lists only paths with a gitlink, so a stale + # .gitmodules entry drops out; a leading '-' marks an uninitialized one. + local out + out="$(git -C "$SETUP_ROOT" submodule status 2>/dev/null)" || return 1 + ! grep -q '^-' <<<"$out" +} +deployed() { "$SETUP_ROOT/deploy.sh" audit >/dev/null 2>&1; } +karabiner_linked() { [ "$(readlink "$HOME/.config/karabiner" 2>/dev/null)" = "$SETUP_ROOT/karabiner" ]; } +# No pipe: under pipefail a SIGPIPE'd launchctl would read as not loaded. +bridge_loaded() { launchctl list org.nonrational.clipboard-bridge >/dev/null 2>&1; } +gh_authed() { gh auth status >/dev/null 2>&1; } +sublime_linked() { + [ -d "$HOME/.sublime3/.git" ] \ + && [ "$(readlink "$HOME/Library/Application Support/Sublime Text" 2>/dev/null)" = "$HOME/.sublime3" ] +} +restore_disabled() { + [ -d "$HOME/Library/Preferences/ByHost" ] \ + && [ -z "$(find "$HOME/Library/Preferences/ByHost" -name 'com.apple.loginwindow*' ! -size 0 2>/dev/null)" ] +} +# Guards macos-apply: the table's own audit, run after doctor so FDA is +# granted. A converged Mac skips it instead of applying every run. +macos_converged() { "$SETUP_ROOT/scripts/macos-defaults.sh" audit >/dev/null 2>&1; } +# Guards reboot: only this run's own apply should trigger it, not a stale +# "not converged" reading left over from a prior failed run. Under --dry-run +# apply_macos never runs and so never sets the flag, so fall back to the same +# audit apply's own guard previews against, keeping the two would: lines in +# step. +macos_not_applied() { + if [ "$SETUP_DRY_RUN" = 1 ]; then + macos_converged + else + [ "$SETUP_MACOS_APPLIED" != 1 ] + fi +} +never() { return 1; } + +# --- runs ------------------------------------------------------------------- + +clt_checkpoint() { + xcode-select --install >/dev/null 2>&1 || true + checkpoint 1 "finish the Command Line Tools installer." +} +brew_install() { mk brew-install; } +brew_bundle() { mk brew-bundle; } +set_shell() { mk set-shell; } +init_submodules() { mk init-submodules; } +deploy() { mk deploy; } +link_karabiner() { mk link-karabiner; } +clipboard_bridge() { mk clipboard-bridge; } +gh_login() { gh auth login; } +link_sublime() { mk link-sublime; } +restore_preferences() { mk restore-preferences; } +# The last first-run step; clearing the marker here ends the first run. +reset_dock() { mk macos-reset-dock && rm -f "$SETUP_ROOT/.git/setup-first-run"; } +disable_restore() { mk macos-disable-restore-apps-on-login; } +doctor_or_halt() { + mk macos-doctor \ + || checkpoint 2 "grant Full Disk Access to this terminal (System Settings > Privacy & Security), then restart the terminal." +} +apply_macos() { + # && chain: the caller's || disables errexit inside this function. + mk macos-apply && "$SETUP_ROOT/scripts/macos-bootstrap.sh" && SETUP_MACOS_APPLIED=1 +} +reboot_mac() { + echo "rebooting. Run $0 once more afterwards; it should report every step as skip." + osascript -e 'tell app "loginwindow" to «event aevtrrst»' \ + || checkpoint 3 "the reboot request failed (no GUI session?). Restart this Mac by hand, then re-run setup.sh once more." +} + +setup_darwin() { + step "command line tools" have_clt clt_checkpoint + step "brew-install" have_brew brew_install + if have_brew; then + eval "$("$SETUP_BREW" shellenv)" + fi + step "brew-bundle" never brew_bundle + step "set-shell" shell_is_brew set_shell + step "init-submodules" submodules_present init_submodules + step "deploy" deployed deploy + step "link-karabiner" karabiner_linked link_karabiner + step "clipboard-bridge" bridge_loaded clipboard_bridge + step "gh auth login" gh_authed gh_login + step "link-sublime" sublime_linked link_sublime + first_run_step "restore-preferences" restore_preferences + first_run_step "macos-reset-dock" reset_dock + step "macos-disable-restore-apps-on-login" restore_disabled disable_restore + step "macos-doctor" never doctor_or_halt + step "macos-apply + macos-bootstrap" macos_converged apply_macos + step "reboot" macos_not_applied reboot_mac +} + +setup_linux() { + step "init-submodules" submodules_present init_submodules + step "deploy" deployed deploy +} + +main() { + setup_self_bootstrap "$@" + # shellcheck source=scripts/host-id.sh + . "$SETUP_ROOT/scripts/host-id.sh" + run_setup "$@" +} + +main "$@" diff --git a/site/index.html b/site/index.html new file mode 100644 index 00000000..927ae05c --- /dev/null +++ b/site/index.html @@ -0,0 +1,29 @@ + + + + + +nonration.al + + + +

hi!

+

New Mac? Open Terminal and run:

+
curl -sS https://nonration.al/setup.sh | bash
+

It stops at three checkpoints and tells you what to do at each one. Re-run the same command after each:

+
    +
  1. Finish the Command Line Tools installer.
  2. +
  3. Grant your terminal Full Disk Access, then restart it.
  4. +
  5. Let the Mac reboot, then run it once more.
  6. +
+
+ + diff --git a/test/test_setup.sh b/test/test_setup.sh new file mode 100755 index 00000000..bee912b5 --- /dev/null +++ b/test/test_setup.sh @@ -0,0 +1,583 @@ +#!/bin/bash +# Tests for setup.sh and the make targets they call. +# Everything runs under a mktemp sandbox with stub commands on PATH, so no +# test touches the real $HOME, brew, gh, or exe.dev. +set -euf -o pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="$(mktemp -d "${TMPDIR:-/tmp}/test-setup.XXXXXX")" +BASE="$(cd "$BASE" && pwd)" +trap 'rm -rf "$BASE"' EXIT + +pass=0 +fail=0 +sb_count=0 + +ok() { pass=$((pass + 1)); echo "PASS: $1"; } +bad() { fail=$((fail + 1)); echo "FAIL: $1"; } + +# Fresh sandbox: $FAKEHOME is $HOME, $SB/bin holds stubs, $SB/calls.log records +# every stub invocation as " ". +sandbox() { + sb_count=$((sb_count + 1)) + SB="$BASE/$sb_count" + FAKEHOME="$SB/home" + LOG="$SB/calls.log" + mkdir -p "$FAKEHOME" "$SB/bin" + : > "$LOG" +} + +# stub [stdout]: a command on PATH that logs its invocation, prints the +# given stdout, and exits $STUB_EXIT (default 1) when " " starts +# with any '|'-separated entry of $STUB_FAIL (e.g. STUB_FAIL='make -C /r +# macos-doctor|gh auth status'). +stub() { + cat > "$SB/bin/$1" <> "$LOG" +IFS='|' +for f in \${STUB_FAIL:-}; do + case "\$call" in "\$f"*) exit "\${STUB_EXIT:-1}" ;; esac +done +printf '%s\n' '${2:-}' +EOF + chmod +x "$SB/bin/$1" +} + +# Run make in the real repo with the sandbox HOME and stubs first on PATH. +mk() { + set +e + out="$(cd "$ROOT" && HOME="$FAKEHOME" PATH="$SB/bin:$PATH" make "$@" 2>&1)" + status=$? + set -e +} + +test_link_karabiner_is_idempotent() { + sandbox + mk link-karabiner + mk link-karabiner + if [ "$status" -eq 0 ] && [ "$(readlink "$FAKEHOME/.config/karabiner")" = "$ROOT/karabiner" ]; then + ok "link-karabiner runs twice and leaves the link pointing at the repo" + else + bad "link-karabiner second run: status=$status out=$out" + fi +} + +test_link_karabiner_refuses_real_directory() { + sandbox + mkdir -p "$FAKEHOME/.config/karabiner" + mk link-karabiner + if [ "$status" -ne 0 ] && echo "$out" | grep -q "not a symlink" && [ -d "$FAKEHOME/.config/karabiner" ]; then + ok "link-karabiner refuses to replace a real directory" + else + bad "link-karabiner real dir: status=$status out=$out" + fi +} + +test_link_sublime_skips_existing_clone() { + sandbox + stub git + mkdir -p "$FAKEHOME/.sublime3/.git" "$FAKEHOME/Library/Application Support" + mk link-sublime + mk link-sublime + if [ "$status" -eq 0 ] && ! grep -q '^git clone' "$LOG" \ + && [ "$(readlink "$FAKEHOME/Library/Application Support/Sublime Text")" = "$FAKEHOME/.sublime3" ]; then + ok "link-sublime skips the clone when ~/.sublime3 exists and links once" + else + bad "link-sublime existing: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_link_sublime_refuses_real_directory() { + sandbox + stub git + mkdir -p "$FAKEHOME/.sublime3/.git" "$FAKEHOME/Library/Application Support/Sublime Text" + mk link-sublime + if [ "$status" -ne 0 ] && echo "$out" | grep -q "not a symlink" && [ -d "$FAKEHOME/Library/Application Support/Sublime Text" ]; then + ok "link-sublime refuses to replace a real directory" + else + bad "link-sublime real dir: status=$status out=$out" + fi +} + +test_brew_install_skips_when_present() { + sandbox + stub curl + printf '#!/bin/bash\n' > "$SB/brew"; chmod +x "$SB/brew" + mk brew-install BREW="$SB/brew" + if [ "$status" -eq 0 ] && echo "$out" | grep -q "already installed" && ! grep -q '^curl' "$LOG"; then + ok "brew-install skips the installer when brew exists" + else + bad "brew-install skip: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_set_shell_skips_when_already_set() { + sandbox + stub sudo + stub chsh + stub dscl "UserShell: $SB/bash" + printf '#!/bin/bash\n' > "$SB/bash"; chmod +x "$SB/bash" + echo "$SB/bash" > "$SB/shells" + mk set-shell BREW_BASH="$SB/bash" SHELLS_FILE="$SB/shells" + if [ "$status" -eq 0 ] && ! grep -q '^chsh' "$LOG" && ! grep -q '^sudo' "$LOG"; then + ok "set-shell does nothing when /etc/shells and the login shell already match" + else + bad "set-shell skip: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_set_shell_appends_and_changes() { + sandbox + stub chsh + stub dscl "UserShell: /bin/zsh" + # sudo stub that actually runs its command so `sudo tee -a` appends. + printf '#!/bin/bash\necho "sudo $*" >> "%s"\nexec "$@"\n' "$LOG" > "$SB/bin/sudo"; chmod +x "$SB/bin/sudo" + printf '#!/bin/bash\n' > "$SB/bash"; chmod +x "$SB/bash" + : > "$SB/shells" + mk set-shell BREW_BASH="$SB/bash" SHELLS_FILE="$SB/shells" + if [ "$status" -eq 0 ] && grep -qx "$SB/bash" "$SB/shells" && grep -q "^chsh -s $SB/bash" "$LOG"; then + ok "set-shell appends the shell and runs chsh when they differ" + else + bad "set-shell change: status=$status out=$out shells=$(cat "$SB/shells") log=$(cat "$LOG")" + fi +} + +test_set_shell_requires_brew_bash() { + sandbox + mk set-shell BREW_BASH="$SB/missing" SHELLS_FILE="$SB/shells" + if [ "$status" -ne 0 ] && echo "$out" | grep -q "brew-bundle"; then + ok "set-shell fails with a pointer to brew-bundle when the shell is missing" + else + bad "set-shell missing: status=$status out=$out" + fi +} + +# --- setup.sh -------------------------------------------------------------- + +# A sandbox repo: the real setup.sh, a stub deploy.sh whose audit +# result is $STUB_DEPLOY_AUDIT (0/1), a stub scripts/macos-defaults.sh whose +# audit result is $STUB_MACOS_AUDIT (0/1), a stub macos-bootstrap.sh, a +# karabiner dir, one declared submodule, and a .git dir so the checkout +# detection holds. +setup_repo() { + REPO="$SB/repo" + mkdir -p "$REPO/scripts" "$REPO/karabiner" "$REPO/.git" "$REPO/etc/sub" + cp "$ROOT/setup.sh" "$REPO/setup.sh" + cp "$ROOT/scripts/host-id.sh" "$REPO/scripts/" + printf '[submodule "sub"]\n\tpath = etc/sub\n\turl = x\n' > "$REPO/.gitmodules" + printf '#!/bin/bash\necho "deploy.sh $*" >> "%s"\n[ "$1" = audit ] && exit "${STUB_DEPLOY_AUDIT:-1}"\nexit 0\n' "$LOG" > "$REPO/deploy.sh" + printf '#!/bin/bash\necho "macos-defaults.sh $*" >> "%s"\n[ "$1" = audit ] && exit "${STUB_MACOS_AUDIT:-1}"\nexit 0\n' "$LOG" > "$REPO/scripts/macos-defaults.sh" + printf '#!/bin/bash\necho "macos-bootstrap.sh" >> "%s"\n' "$LOG" > "$REPO/scripts/macos-bootstrap.sh" + chmod +x "$REPO/deploy.sh" "$REPO/scripts/macos-defaults.sh" "$REPO/scripts/macos-bootstrap.sh" + printf '#!/bin/bash\n[ "$1" = shellenv ] && exit 0\n' > "$SB/brew"; chmod +x "$SB/brew" + printf '#!/bin/bash\n' > "$SB/bash"; chmod +x "$SB/bash" + echo "$SB/bash" > "$SB/shells" + # The fake .git above is not a real repo, so answer `git submodule status` + # from $STUB_SUBMODULES; a leading '-' marks an uninitialized submodule. + cat > "$SB/bin/git" <> "$LOG" +IFS='|' +for f in \${STUB_FAIL:-}; do + case "\$call" in "\$f"*) exit "\${STUB_EXIT:-1}" ;; esac +done +case "\$call" in "git -C $REPO submodule status") printf '%s\n' "\${STUB_SUBMODULES:-}" ;; esac +EOF + chmod +x "$SB/bin/git" + export STUB_SUBMODULES='-0123abc etc/sub' +} + +# Stubs describing a fully converged Mac; tests knock out one at a time. +converged_mac() { + stub uname Darwin + stub xcode-select + stub make + stub dscl "UserShell: $SB/bash" + stub launchctl + stub gh + stub osascript + echo x > "$REPO/etc/sub/file" + mkdir -p "$FAKEHOME/.config" "$FAKEHOME/.sublime3/.git" "$FAKEHOME/Library/Application Support" "$FAKEHOME/Library/Preferences/ByHost" + ln -s "$REPO/karabiner" "$FAKEHOME/.config/karabiner" + ln -s "$FAKEHOME/.sublime3" "$FAKEHOME/Library/Application Support/Sublime Text" + : > "$FAKEHOME/Library/Preferences/ByHost/com.apple.loginwindow.ABC.plist" + export STUB_DEPLOY_AUDIT=0 + export STUB_MACOS_AUDIT=0 + export STUB_SUBMODULES=' 0123abc etc/sub' +} + +setup_run() { + set +e + out="$(HOME="$FAKEHOME" PATH="$SB/bin:$PATH" SETUP_BREW="$SB/brew" SETUP_BREW_BASH="$SB/bash" \ + SETUP_ETC_SHELLS="$SB/shells" "$REPO/setup.sh" "$@" 2>&1 "$REPO/.gitmodules" + (unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE; git init -q "$REPO") + setup_run + if [ "$status" -eq 0 ] && echo "$out" | grep -q '^skip: init-submodules$'; then + ok "a .gitmodules entry with no gitlink does not rerun init-submodules" + else + bad "stale gitmodules: status=$status out=$out" + fi +} + +test_git_failure_runs_init() { + sandbox; setup_repo; converged_mac + STUB_FAIL='git' setup_run + if echo "$out" | grep -q '^run: init-submodules$'; then + ok "a failing git submodule status runs init-submodules" + else + bad "git failure: status=$status out=$out" + fi +} + +test_dry_run_summary_counts_pending() { + sandbox; setup_repo; converged_mac + rm "$SB/brew" + export STUB_DEPLOY_AUDIT=1 + setup_run --dry-run + local n + n="$(echo "$out" | grep -c '^would:' || true)" + if [ "$status" -eq 0 ] && [ "$n" -gt 0 ] && [ "$(echo "$out" | tail -n 1)" = "setup: dry run (Darwin), $n would run" ] \ + && ! echo "$out" | grep -q '^setup: converged'; then + ok "--dry-run ends with a count of the would: lines, not converged" + else + bad "dry summary: status=$status n=$n out=$out" + fi +} + +test_reboot_failure_is_checkpoint_3() { + sandbox; setup_repo; converged_mac + export STUB_MACOS_AUDIT=1 + STUB_FAIL='osascript' setup_run + if [ "$status" -eq 1 ] && [ "$(echo "$out" | grep -c '^checkpoint 3:')" -eq 1 ] \ + && echo "$out" | grep '^checkpoint 3:' | grep -q '^checkpoint 3: the reboot request failed'; then + ok "a failed reboot request halts at checkpoint 3" + else + bad "reboot failure: status=$status out=$out" + fi +} + +test_linux_runs_only_submodules_and_deploy() { + sandbox; setup_repo + stub uname Linux; stub make + export STUB_DEPLOY_AUDIT=1 + SETUP_FIRST_RUN=1 setup_run + local targets + targets="$(grep '^make -C' "$LOG" | awk '{print $4}' | tr '\n' ' ' | sed 's/ $//')" + if [ "$status" -eq 0 ] && [ "$targets" = "init-submodules deploy" ]; then + ok "Linux runs init-submodules and deploy and nothing else" + else + bad "linux: status=$status targets=[$targets] out=$out" + fi +} + +# Piped runs: `cat setup.sh | bash` from outside any checkout. +pipe_run() { + set +e + out="$(cd "$SB" && cat "$ROOT/setup.sh" | HOME="$FAKEHOME" PATH="$SB/bin:$PATH" \ + SETUP_REPO_URL="https://example.invalid/dotfiles" bash -s -- "$@" 2>&1)" + status=$? + set -e +} + +# A git stub whose clone creates a fake checkout with a setup.sh that reports +# how it was exec'd. +stub_git_clone() { + cat > "$SB/bin/git" <> "$LOG" +if [ "\$1" = clone ]; then + mkdir -p "\$3/.git" + cat > "\$3/setup.sh" <<'INNER' +#!/bin/bash +echo "exec-ed first_run=\${SETUP_FIRST_RUN:-0} args=\$*" +INNER + chmod +x "\$3/setup.sh" +fi +EOF + chmod +x "$SB/bin/git" +} + +test_pipe_clones_and_execs_as_first_run() { + sandbox; stub uname Linux; stub_git_clone + pipe_run + if [ "$status" -eq 0 ] && grep -q "^git clone https://example.invalid/dotfiles $FAKEHOME/.dotfiles$" "$LOG" \ + && echo "$out" | grep -q '^exec-ed first_run=1 args=$' && [ -f "$FAKEHOME/.dotfiles/.git/setup-first-run" ]; then + ok "piped from outside a checkout, setup.sh clones and execs the clone as a first run" + else + bad "pipe clone: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_pipe_reuses_existing_clone() { + sandbox; stub uname Linux; stub_git_clone + mkdir -p "$FAKEHOME/.dotfiles/.git" + printf '#!/bin/bash\necho "exec-ed first_run=${SETUP_FIRST_RUN:-0} args=$*"\n' > "$FAKEHOME/.dotfiles/setup.sh" + chmod +x "$FAKEHOME/.dotfiles/setup.sh" + pipe_run + if [ "$status" -eq 0 ] && ! grep -q '^git clone' "$LOG" && echo "$out" | grep -q '^exec-ed first_run=0 args=$'; then + ok "piped with ~/.dotfiles present, setup.sh execs it without marking a first run" + else + bad "pipe reuse: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_pipe_dry_run_does_not_clone() { + sandbox; stub uname Linux; stub_git_clone + pipe_run --dry-run + if [ "$status" -eq 0 ] && ! grep -q '^git clone' "$LOG" && echo "$out" | grep -q '^would: clone https://example.invalid/dotfiles'; then + ok "piped --dry-run reports the clone and does nothing" + else + bad "pipe dry-run: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_pipe_on_mac_without_clt_is_checkpoint_1() { + sandbox; stub uname Darwin; stub xcode-select; stub_git_clone + STUB_FAIL='xcode-select -p' pipe_run + if [ "$status" -eq 1 ] && echo "$out" | grep -q '^checkpoint 1: ' && ! grep -q '^git clone' "$LOG"; then + ok "piped on a Mac without Command Line Tools halts at checkpoint 1 before cloning" + else + bad "pipe clt: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_unknown_flag_is_usage_error() { + sandbox; setup_repo; stub uname Darwin; stub make + setup_run --bogus + if [ "$status" -eq 2 ] && echo "$out" | grep -q 'usage: setup.sh \[--dry-run\]' && ! grep -q '^make' "$LOG"; then + ok "an unknown flag prints usage, exits 2, and calls no target" + else + bad "usage: status=$status out=$out log=$(cat "$LOG")" + fi +} + +test_unsupported_os_fails() { + sandbox; setup_repo; stub uname FreeBSD; stub make + setup_run + if [ "$status" -eq 2 ] && echo "$out" | grep -q 'error: unsupported OS FreeBSD'; then + ok "an unsupported OS exits 2 with its name" + else + bad "unsupported: status=$status out=$out" + fi +} + +test_run_without_tty_survives() { + # setup_run redirects stdin from /dev/null; with no controlling terminal + # (CI) the /dev/tty reopen must be skipped, not fatal. + sandbox; setup_repo; converged_mac + setup_run + if [ "$status" -eq 0 ] && [ "$(echo "$out" | tail -n 1)" = "setup: converged (Darwin)" ]; then + ok "a run with stdin not a tty completes" + else + bad "no-tty: status=$status out=$out" + fi +} + +test_first_run_steps_skip_without_flag() { + sandbox; setup_repo; converged_mac + unset SETUP_FIRST_RUN + setup_run + if [ "$status" -eq 0 ] && echo "$out" | grep -q '^skip: restore-preferences (first run only)$' \ + && echo "$out" | grep -q '^skip: macos-reset-dock (first run only)$'; then + ok "first-run steps skip when SETUP_FIRST_RUN is unset" + else + bad "first-run skip: status=$status out=$out" + fi +} + +test_first_run_survives_failed_step() { + sandbox; setup_repo; converged_mac + touch "$REPO/.git/setup-first-run" + export STUB_DEPLOY_AUDIT=1 + STUB_EXIT=2 STUB_FAIL="make -C $REPO deploy" setup_run + local s1="$status" + unset SETUP_FIRST_RUN + : > "$LOG" + STUB_DEPLOY_AUDIT=0 setup_run + local s2="$status" ran2 + ran2="$(grep -c 'restore-preferences\|macos-reset-dock' "$LOG" || true)" + : > "$LOG" + setup_run + if [ "$s1" -eq 2 ] && [ "$s2" -eq 0 ] && [ "$ran2" -eq 2 ] && [ "$status" -eq 0 ] \ + && ! grep -q 'restore-preferences\|macos-reset-dock' "$LOG" \ + && echo "$out" | grep -q '^skip: restore-preferences (first run only)$' \ + && echo "$out" | grep -q '^skip: macos-reset-dock (first run only)$'; then + ok "a failed step on the first run does not lose the first-run steps" + else + bad "first-run survive: s1=$s1 s2=$s2 ran2=$ran2 s3=$status out=$out" + fi +} + +test_pipe_old_clone_without_setup_sh() { + sandbox; stub uname Linux; stub_git_clone + mkdir -p "$FAKEHOME/.dotfiles/.git" + pipe_run + if [ "$status" -eq 2 ] && echo "$out" | grep -q "^error: $FAKEHOME/.dotfiles has no setup.sh; run git -C $FAKEHOME/.dotfiles pull, then re-run$"; then + ok "piped with a pre-setup.sh clone, setup.sh exits 2 and says to pull" + else + bad "pipe old clone: status=$status out=$out" + fi +} + +test_pipe_clone_failure_exits_2() { + sandbox; stub uname Linux + stub git + STUB_EXIT=128 STUB_FAIL='git clone' pipe_run + if [ "$status" -eq 2 ] && echo "$out" | grep -q '^failed: clone$'; then + ok "a failed clone exits 2, not git's 128" + else + bad "pipe clone fail: status=$status out=$out" + fi +} + +test_link_karabiner_is_idempotent +test_link_karabiner_refuses_real_directory +test_link_sublime_skips_existing_clone +test_link_sublime_refuses_real_directory +test_brew_install_skips_when_present +test_set_shell_skips_when_already_set +test_set_shell_appends_and_changes +test_set_shell_requires_brew_bash +test_mac_runs_every_target_in_order_on_first_run +test_mac_converged_is_all_skips +test_mac_dry_run_runs_nothing +test_missing_clt_is_checkpoint_1 +test_doctor_failure_is_checkpoint_2 +test_failed_step_stops_run +test_failed_step_exits_2_with_name +test_failed_apply_skips_bootstrap +test_stale_gitmodules_entry_does_not_rerun_init +test_git_failure_runs_init +test_dry_run_summary_counts_pending +test_reboot_failure_is_checkpoint_3 +test_linux_runs_only_submodules_and_deploy +test_unknown_flag_is_usage_error +test_unsupported_os_fails +test_run_without_tty_survives +test_first_run_steps_skip_without_flag +test_pipe_clones_and_execs_as_first_run +test_pipe_reuses_existing_clone +test_pipe_dry_run_does_not_clone +test_pipe_on_mac_without_clt_is_checkpoint_1 +test_first_run_survives_failed_step +test_pipe_old_clone_without_setup_sh +test_pipe_clone_failure_exits_2 + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ]

The script and everything it installs live at nonrational/dotfiles.