diff --git a/CLAUDE.md b/CLAUDE.md index 6ed080d..afe9d2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,9 @@ read by humans (and Claude Code). ``` bin/mbp — CLI entry point (subcommands: setup, audit, tour, update, status) lib/ - core.sh — logging, color, idempotency helpers, mbp_run_module + core.sh — logging, color, idempotency helpers, mbp_run_module, module path resolution platform.sh — macOS version detection, Homebrew prefix state.sh — state R/W (plain-text for modules 01-02, JSON from module 03 onward) - profile.sh — INI-style .conf parser, module path resolution audit.sh — drift detection (brew, mise, dotfiles, macOS defaults) modules/ 01-xcode.sh — Xcode CLT (critical — halts on failure) @@ -25,25 +24,20 @@ modules/ 08-secrets.sh — 1Password CLI 09-docker.sh — Docker Desktop 10-ai-tools.sh — Claude Code + gstack - 11-macos-defaults.sh — Dock, Finder, keyboard, screenshot defaults - 12-apps.sh — verify cask installs from active profile - 13-dev-dirs.sh — ~/Developer structure, ~/.mbp dirs + 11-macos-defaults.sh — Dock, Finder, keyboard, screenshot, widget defaults + 12-apps.sh — verify cask installs + 13-dev-dirs.sh — ~/.mbp infrastructure dirs dotfiles/ zshrc — Oh My Zsh config, mise activation, client() helper gitconfig — git identity, gh credential, GPG signing stub ssh-config — 1Password agent, github.com host, config.d Include tool-versions — global mise runtimes vimrc — minimal vim config -profiles/ - devizer-full.conf — all modules, full Brewfiles - client-minimal.conf — subset for client machines - personal.conf — full + personal tools brewfiles/ Brewfile.core — essentials every machine needs Brewfile.dev — developer tools (mise, bun, docker, cloud CLIs) - Brewfile.ai — AI tooling - Brewfile.apps — desktop applications - Brewfile.personal — personal preferences + Brewfile.ai — AI tooling (bundled only if ai-tools module selected) + Brewfile.apps — desktop applications (bundled only if apps module selected) tour/ steps.sh — interactive walkthrough (mbp tour) content/ — markdown files shown in the tour (one per module) @@ -58,6 +52,18 @@ Module 03 triggers migration to JSON (`~/.mbp/state.json`) via `state_migrate_fr State is keyed by module name (e.g. `homebrew`, `mise`). A completed module has status `ok` and is skipped on re-runs unless `MBP_FORCE=1`. +### Module selection + +On first run, an interactive picker lets the user choose which modules to install. +The selection is saved to `~/.mbp/selected_modules.txt` (plain text, one module per +line) since jq is not yet available. Module 03's state migration copies this into +`state.json` under a `selected_modules` array. On re-runs, the saved selection is used. +`--force` re-triggers the picker. + +Module 02 (homebrew) reads `selected_modules.txt` to determine which Brewfiles to +bundle: `core` and `dev` always run; `ai` only if `ai-tools` is selected; `apps` only +if `apps` is selected. + ## Module conventions Each module: @@ -70,21 +76,13 @@ Each module: Modules 01 and 02 use `state_txt_set` instead of the JSON functions. -## Profile format - -```ini -format = 1 -modules = homebrew,mise,shell,dotfiles,git,ssh,secrets,docker,ai-tools,macos-defaults,apps,dev-dirs -brewfiles = Brewfile.core Brewfile.dev Brewfile.ai Brewfile.apps -mise_tools = nodejs:22.0.0 ruby:3.3.0 python:3.12.0 -``` - ## Adding a module 1. Create `modules/NN-name.sh` -2. Add `name` to the relevant profile's `modules =` line -3. Add a tour content file at `tour/content/NN-name.md` if needed -4. Add a step to `tour/steps.sh` ALL_STEPS array +2. Add the module name to `MBP_DEFAULT_MODULES` in `bin/mbp` +3. Add a description to `MBP_MODULE_DESC` in `bin/mbp` +4. Add a tour content file at `tour/content/NN-name.md` if needed +5. Add a step to `tour/steps.sh` ALL_STEPS array ## Testing @@ -98,12 +96,11 @@ Re-run individual modules during development: ## Key environment variables - MBP_REPO — path to this repository (set by bin/mbp) - MBP_FORCE=1 — re-run completed modules - NO_COLOR=1 — disable ANSI color output - MBP_PROFILE_MODULES — space-separated module names (set by profile_load) - MBP_PROFILE_BREWFILES — space-separated Brewfile names - MBP_PROFILE_MISE_TOOLS — space-separated tool:version pairs + MBP_REPO — path to this repository (set by bin/mbp) + MBP_FORCE=1 — re-run completed modules + NO_COLOR=1 — disable ANSI color output + MBP_PROFILE_BREWFILES — space-separated Brewfile names (set from defaults in bin/mbp) + MBP_PROFILE_MISE_TOOLS — space-separated tool@version pairs (set from defaults in bin/mbp) ## Brand diff --git a/TODOS.md b/TODOS.md index 681db72..35e4bee 100644 --- a/TODOS.md +++ b/TODOS.md @@ -7,3 +7,11 @@ **How:** After the version-check workflow PR is merged, go to GitHub → Settings → Branches → Branch protection rules → main → Require status checks to pass → add `Version Bump Check`. **Added:** 2026-03-27 + +## Consider module registry instead of NN-name.sh glob resolution + +**Why:** Module names are resolved by globbing `modules/NN-.sh`. If someone adds a module with the wrong numeric prefix or a naming conflict, the glob silently picks the wrong file. A registry (e.g., an associative array in bin/mbp mapping names to paths) would be explicit and fail loudly. + +**How:** Replace `mbp_resolve_module_path()` glob with a declared mapping in bin/mbp. Could extend `MBP_MODULE_DESC` to include paths. + +**Added:** 2026-03-31 diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/bin/mbp b/bin/mbp index c1f8c97..67fc563 100755 --- a/bin/mbp +++ b/bin/mbp @@ -11,43 +11,136 @@ MBP_REPO="$(cd "$SCRIPT_DIR/.." && pwd)" source "$MBP_REPO/lib/core.sh" source "$MBP_REPO/lib/platform.sh" source "$MBP_REPO/lib/state.sh" -source "$MBP_REPO/lib/profile.sh" MBP_VERSION="$(<"$MBP_REPO/VERSION")" +# === Defaults (replaces profile system) === +MBP_DEFAULT_MODULES="xcode homebrew shell mise dotfiles git ssh secrets docker ai-tools macos-defaults apps dev-dirs" +MBP_DEFAULT_BREWFILES="core dev ai apps" +MBP_DEFAULT_MISE_TOOLS="node@22 ruby@3.3 python@3.12 bun@1.2" + +# === Module descriptions for picker === +declare -A MBP_MODULE_DESC=( + [xcode]="Xcode Command Line Tools (required)" + [homebrew]="Homebrew package manager (required)" + [shell]="Oh My Zsh + default shell" + [mise]="Runtime version manager (node, ruby, python)" + [dotfiles]="Symlink dotfiles (.zshrc, .gitconfig, etc.)" + [git]="Git config + SSH signing" + [ssh]="SSH key permissions + config" + [secrets]="1Password CLI" + [docker]="Docker Desktop" + [ai-tools]="Claude Code + gstack" + [macos-defaults]="Dock, Finder, keyboard, widget defaults" + [apps]="GUI applications (casks)" + [dev-dirs]="Create ~/.mbp infrastructure dirs" +) + # === Usage === usage() { printf "${MBP_COLOR_BRAND}${MBP_COLOR_BOLD}mbp${MBP_COLOR_RESET} v%s — The Living Machine by Devizer\n\n" "$MBP_VERSION" printf "Usage: mbp [options]\n\n" printf "Commands:\n" - printf " ${MBP_COLOR_BOLD}setup${MBP_COLOR_RESET} [--profile NAME] [--module NAME] [--force]\n" - printf " Provision this machine (default profile: devizer-full)\n" + printf " ${MBP_COLOR_BOLD}setup${MBP_COLOR_RESET} [--module NAME] [--force]\n" + printf " Provision this machine\n" printf " ${MBP_COLOR_BOLD}audit${MBP_COLOR_RESET} Check for drift from canonical config\n" printf " ${MBP_COLOR_BOLD}tour${MBP_COLOR_RESET} Interactive walkthrough of installed tools\n" printf " ${MBP_COLOR_BOLD}update${MBP_COLOR_RESET} Self-update mbp repo and optionally re-setup\n" - printf " ${MBP_COLOR_BOLD}status${MBP_COLOR_RESET} Show profile, module states, and last run\n" + printf " ${MBP_COLOR_BOLD}status${MBP_COLOR_RESET} Show module states and last run\n" printf "\nOptions:\n" - printf " --profile NAME Profile to use (default: devizer-full)\n" printf " --module NAME Run only this module (e.g., --module mise)\n" printf " --force Re-run modules even if already marked ok\n" printf " --version Print version and exit\n" printf "\nExamples:\n" printf " mbp setup\n" - printf " mbp setup --profile client-minimal\n" printf " mbp setup --module mise --force\n" printf " mbp audit\n" printf "\n" } +# === Interactive module picker === +# Shows on first run (no state exists). User toggles modules on/off. +module_picker() { + local -a all_modules + read -ra all_modules <<< "$MBP_DEFAULT_MODULES" + + # All selected by default + local -A selected=() + for mod in "${all_modules[@]}"; do + selected[$mod]=1 + done + + printf "\n${MBP_COLOR_BRAND}${MBP_COLOR_BOLD}Module Selection${MBP_COLOR_RESET}\n" + printf "${MBP_COLOR_DIM}Toggle modules by typing their number. Press Enter to confirm.${MBP_COLOR_RESET}\n\n" + + while true; do + local n=0 + for mod in "${all_modules[@]}"; do + n=$((n + 1)) + local mark="x" + [ "${selected[$mod]}" != "1" ] && mark=" " + local locked="" + if [ "$mod" = "xcode" ] || [ "$mod" = "homebrew" ]; then + locked=" ${MBP_COLOR_DIM}(required)${MBP_COLOR_RESET}" + fi + printf " %2d. [%s] %-16s %s%s\n" "$n" "$mark" "$mod" "${MBP_MODULE_DESC[$mod]:-}" "$locked" + done + + printf "\n${MBP_COLOR_BRAND}→${MBP_COLOR_RESET} Type number(s) to toggle (e.g. 10 to toggle ai-tools), Enter to confirm: " + read -r input + + # Empty input = confirm + if [ -z "$input" ]; then + break + fi + + # Process each number + for num in $input; do + # Validate numeric + case "$num" in + ''|*[!0-9]*) continue ;; + esac + + if [ "$num" -ge 1 ] && [ "$num" -le "${#all_modules[@]}" ]; then + local idx=$((num - 1)) + local mod="${all_modules[$idx]}" + + # Don't allow deselecting mandatory modules + if [ "$mod" = "xcode" ] || [ "$mod" = "homebrew" ]; then + printf " ${MBP_COLOR_WARN}⚠${MBP_COLOR_RESET} %s is required and cannot be deselected\n" "$mod" + continue + fi + + # Toggle + if [ "${selected[$mod]}" = "1" ]; then + selected[$mod]=0 + else + selected[$mod]=1 + fi + fi + done + printf "\n" + done + + # Save selection to plain text (jq not yet available) + mkdir -p "${HOME}/.mbp" + : > "$MBP_SELECTED_MODULES_FILE" + for mod in "${all_modules[@]}"; do + if [ "${selected[$mod]}" = "1" ]; then + echo "$mod" >> "$MBP_SELECTED_MODULES_FILE" + fi + done + + printf "\n${MBP_COLOR_SUCCESS}✓${MBP_COLOR_RESET} Module selection saved\n\n" +} + # === cmd_setup === cmd_setup() { - local profile="devizer-full" local single_module="" export MBP_FORCE=0 while [ $# -gt 0 ]; do case "$1" in - --profile) profile="$2"; shift 2 ;; --module) single_module="$2"; shift 2 ;; --force) export MBP_FORCE=1; shift ;; *) printf "Unknown option: %s\n" "$1" >&2; usage; exit 1 ;; @@ -55,44 +148,69 @@ cmd_setup() { done mbp_print_logo - printf "Profile: ${MBP_COLOR_BRAND}%s${MBP_COLOR_RESET}\n" "$profile" - # Load profile - local profile_path="$MBP_REPO/profiles/${profile}.conf" - profile_load "$profile_path" || exit 1 - state_init_json "$profile" "$MBP_VERSION" - state_set_profile "$profile" - state_set_last_run + # Pre-acquire sudo credentials for the session + printf "${MBP_COLOR_DIM}Some modules need admin access. You may be prompted for your password.${MBP_COLOR_RESET}\n" + sudo -v + # Keep sudo alive in background + while true; do sudo -n true; sleep 50; kill -0 "$$" || exit; done 2>/dev/null & - local setup_start; setup_start=$(date +%s) + # Set default env vars for modules + export MBP_PROFILE_BREWFILES="$MBP_DEFAULT_BREWFILES" + export MBP_PROFILE_MISE_TOOLS="$MBP_DEFAULT_MISE_TOOLS" + + # Determine module list + local module_list="" if [ -n "$single_module" ]; then - # Single-module mode - export MBP_FORCE=1 # Always force for explicit --module + # Single-module mode — skip picker + export MBP_FORCE=1 local module_path - module_path=$(profile_resolve_module_path "$single_module" "$MBP_REPO") || exit 1 + module_path=$(mbp_resolve_module_path "$single_module" "$MBP_REPO") || exit 1 + state_init_json "mbp" "$MBP_VERSION" + state_set_last_run mbp_run_module "$module_path" 1 1 - else - # Full setup: run all profile modules in order - # Build ordered list by expanding profile module names to paths - local module_paths=() - for module_name in $MBP_PROFILE_MODULES; do - local path - path=$(profile_resolve_module_path "$module_name" "$MBP_REPO") || continue - module_paths+=("$path") - done + mbp_print_summary "$(date +%s)" + return + fi - local total="${#module_paths[@]}" - local n=0 - for module_path in "${module_paths[@]}"; do - n=$((n + 1)) - # Pass profile env vars to module subprocess via exports - export MBP_PROFILE_MODULES MBP_PROFILE_BREWFILES MBP_PROFILE_MISE_TOOLS - export MBP_REPO MBP_FORCE - mbp_run_module "$module_path" "$n" "$total" - done + # First run: show interactive picker + if [ ! -f "$MBP_STATE_JSON" ] && [ ! -f "$MBP_SELECTED_MODULES_FILE" ]; then + if [ "${MBP_FORCE:-0}" = "1" ] || true; then + module_picker + fi + elif [ "${MBP_FORCE:-0}" = "1" ] && [ -z "$single_module" ]; then + # --force without --module: re-show picker + module_picker + fi + + # Load selected modules + if ! module_list=$(state_get_selected_modules); then + module_list="$MBP_DEFAULT_MODULES" fi + state_init_json "mbp" "$MBP_VERSION" + state_set_last_run + + local setup_start; setup_start=$(date +%s) + + # Build ordered list by expanding module names to paths + local module_paths=() + for module_name in $module_list; do + local path + path=$(mbp_resolve_module_path "$module_name" "$MBP_REPO") || continue + module_paths+=("$path") + done + + local total="${#module_paths[@]}" + local n=0 + for module_path in "${module_paths[@]}"; do + n=$((n + 1)) + export MBP_PROFILE_BREWFILES MBP_PROFILE_MISE_TOOLS + export MBP_REPO MBP_FORCE + mbp_run_module "$module_path" "$n" "$total" + done + mbp_print_summary "$setup_start" if [ -n "$MBP_FAILED_MODULES" ]; then @@ -104,11 +222,11 @@ cmd_setup() { cmd_audit() { source "$MBP_REPO/lib/audit.sh" - # Load active profile for context - local profile; profile=$(state_get_profile 2>/dev/null || echo "devizer-full") - profile_load "$MBP_REPO/profiles/${profile}.conf" 2>/dev/null || true + # Set defaults for audit (no profiles) + export MBP_PROFILE_BREWFILES="$MBP_DEFAULT_BREWFILES" + export MBP_PROFILE_MISE_TOOLS="$MBP_DEFAULT_MISE_TOOLS" - printf "${MBP_COLOR_BRAND}${MBP_COLOR_BOLD}mbp audit${MBP_COLOR_RESET} — checking against profile: %s\n" "$profile" + printf "${MBP_COLOR_BRAND}${MBP_COLOR_BOLD}mbp audit${MBP_COLOR_RESET}\n" audit_homebrew "$MBP_REPO" audit_mise @@ -177,12 +295,10 @@ cmd_status() { return 0 fi - local profile; profile=$(state_get_profile) local last_run; last_run=$(jq -r '.last_run // "never"' "$MBP_STATE_JSON" 2>/dev/null) local ok_count; ok_count=$(jq '[.modules[] | select(.status == "ok")] | length' "$MBP_STATE_JSON" 2>/dev/null || echo 0) local error_count; error_count=$(jq '[.modules[] | select(.status == "error")] | length' "$MBP_STATE_JSON" 2>/dev/null || echo 0) local total_count; total_count=$(jq '.modules | length' "$MBP_STATE_JSON" 2>/dev/null || echo 0) - printf " ${MBP_COLOR_DIM}Profile:${MBP_COLOR_RESET} %s\n" "${profile:-unknown}" printf " ${MBP_COLOR_DIM}Last run:${MBP_COLOR_RESET} %s\n" "$last_run" printf " ${MBP_COLOR_DIM}Modules:${MBP_COLOR_RESET} %s/%s ok" "$ok_count" "$total_count" [ "$error_count" -gt 0 ] && printf ", ${MBP_COLOR_ERROR}%s error${MBP_COLOR_RESET}" "$error_count" diff --git a/brewfiles/Brewfile.apps b/brewfiles/Brewfile.apps index aaba035..bf3d53e 100644 --- a/brewfiles/Brewfile.apps +++ b/brewfiles/Brewfile.apps @@ -1,9 +1,7 @@ # Brewfile.apps — GUI applications cask "ngrok" # Tunnel for local dev -cask "wireshark" # Network analysis cask "graphql-playground" # GraphQL IDE -cask "hyper" # Hyper terminal cask "icanhazshortcut" # Global keyboard shortcuts cask "xcodes" # Xcode version manager cask "secretive" # SSH keys in Secure Enclave diff --git a/brewfiles/Brewfile.core b/brewfiles/Brewfile.core index f2eb0a4..6e77fe5 100644 --- a/brewfiles/Brewfile.core +++ b/brewfiles/Brewfile.core @@ -24,3 +24,6 @@ brew "zstd" # Networking brew "openssh" brew "openssl@3" + +# Utilities +brew "blueutil" # Bluetooth CLI diff --git a/brewfiles/Brewfile.personal b/brewfiles/Brewfile.personal deleted file mode 100644 index d0a03bb..0000000 --- a/brewfiles/Brewfile.personal +++ /dev/null @@ -1,4 +0,0 @@ -# Brewfile.personal — Jensen's personal extras -# Not included in client-minimal profile. - -brew "blueutil" # Bluetooth CLI control diff --git a/install.sh b/install.sh index 3d24612..338e562 100755 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -euo pipefail MBP_REPO="${MBP_REPO:-$HOME/.mbp/repo}" MBP_REMOTE="${MBP_REMOTE:-https://github.com/devizerio/mbp.git}" MBP_BRANCH="${MBP_BRANCH:-main}" -MBP_PROFILE="${MBP_PROFILE:-devizer-full}" + # ── Colors ──────────────────────────────────────────────────────────────────── if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then @@ -64,8 +64,7 @@ fi printf " This script will:\n" printf " 1. Clone mbp to ${DIM}$MBP_REPO${RESET}\n" printf " 2. Add ${DIM}mbp${RESET} to your PATH\n" -printf " 3. Run ${DIM}mbp setup --profile $MBP_PROFILE${RESET}\n\n" -printf " Profile: ${BRAND}$MBP_PROFILE${RESET}\n" +printf " 3. Run ${DIM}mbp setup${RESET} (you'll choose which modules to install)\n\n" printf " Repo: ${DIM}$MBP_REPO${RESET}\n\n" printf " ${DIM}Press Enter to continue, or Ctrl+C to cancel...${RESET}" read -r @@ -112,4 +111,4 @@ printf "\n" log_step "Running mbp setup..." printf "\n" -exec "$MBP_BIN/mbp" setup --profile "$MBP_PROFILE" +exec "$MBP_BIN/mbp" setup diff --git a/lib/audit.sh b/lib/audit.sh index 1d73ffc..f527a82 100644 --- a/lib/audit.sh +++ b/lib/audit.sh @@ -5,6 +5,8 @@ source "$(dirname "$0")/../lib/core.sh" source "$(dirname "$0")/../lib/state.sh" +# Defaults (used when MBP_PROFILE_BREWFILES/MISE_TOOLS not set by caller) + AUDIT_ISSUES=0 AUDIT_CATEGORIES=0 diff --git a/lib/core.sh b/lib/core.sh index f91f100..bb84392 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -118,6 +118,31 @@ mbp_run_module() { return "$exit_code" } +# === Module path resolution === +# Resolve a bare module name (e.g., "mise") to its script path (e.g., "modules/04-mise.sh") +# Usage: mbp_resolve_module_path +mbp_resolve_module_path() { + local name="$1" + local repo_dir="${2:-$(pwd)}" + local modules_dir="$repo_dir/modules" + + # Glob for NN-name.sh + local found="" + for f in "$modules_dir"/[0-9][0-9]-"${name}".sh; do + if [ -f "$f" ]; then + found="$f" + break + fi + done + + if [ -z "$found" ]; then + printf "module '%s' not found in %s\n" "$name" "$modules_dir" >&2 + return 1 + fi + + echo "$found" +} + # === ASCII logo === mbp_print_logo() { printf "${MBP_COLOR_BRAND}" diff --git a/lib/profile.sh b/lib/profile.sh deleted file mode 100644 index 96f44fb..0000000 --- a/lib/profile.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -# lib/profile.sh — Profile config parser -# Reads INI-style .conf files from profiles/ directory. -# Exports: MBP_PROFILE_MODULES, MBP_PROFILE_BREWFILES, MBP_PROFILE_MISE_TOOLS (space-separated) - -profile_load() { - local path="$1" - - if [ ! -f "$path" ]; then - printf "profile not found: %s\n" "$path" >&2 - return 1 - fi - - # Defaults - MBP_PROFILE_MODULES="" - MBP_PROFILE_BREWFILES="" - MBP_PROFILE_MISE_TOOLS="" - local profile_format=0 - - while IFS= read -r raw_line; do - # Skip comments and blank lines - local line; line=$(echo "$raw_line" | sed 's/#.*//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - [ -z "$line" ] && continue - - # Split on first = - local key; key=$(echo "$line" | cut -d= -f1 | sed 's/[[:space:]]*$//') - local raw_val; raw_val=$(echo "$line" | cut -d= -f2- | sed 's/^[[:space:]]*//') - - case "$key" in - format) - profile_format="$raw_val" - if [ "$profile_format" != "1" ]; then - printf "profile: format version '%s' unknown, expected 1\n" "$profile_format" >&2 - fi - ;; - modules) - # Split on commas, trim each value, validate names (alphanumeric + hyphens only) - MBP_PROFILE_MODULES=$(echo "$raw_val" | tr ',' '\n' | \ - sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '\n' ' ' | sed 's/ $//') - for _mod in $MBP_PROFILE_MODULES; do - if ! echo "$_mod" | grep -qE '^[a-zA-Z0-9_-]+$'; then - printf "profile: invalid module name '%s' — only alphanumeric, hyphens, underscores allowed\n" "$_mod" >&2 - return 1 - fi - done - ;; - brewfiles) - MBP_PROFILE_BREWFILES=$(echo "$raw_val" | tr ',' '\n' | \ - sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '\n' ' ' | sed 's/ $//') - ;; - mise_tools) - MBP_PROFILE_MISE_TOOLS=$(echo "$raw_val" | tr ',' '\n' | \ - sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '\n' ' ' | sed 's/ $//') - ;; - *) - printf "profile: unknown key '%s' — ignored\n" "$key" - ;; - esac - done < "$path" - - export MBP_PROFILE_MODULES MBP_PROFILE_BREWFILES MBP_PROFILE_MISE_TOOLS -} - -# Resolve a bare module name (e.g., "mise") to its script path (e.g., "modules/04-mise.sh") -# Usage: profile_resolve_module_path -profile_resolve_module_path() { - local name="$1" - local repo_dir="${2:-$(pwd)}" - local modules_dir="$repo_dir/modules" - - # Glob for NN-name.sh - local found="" - for f in "$modules_dir"/[0-9][0-9]-"${name}".sh; do - if [ -f "$f" ]; then - found="$f" - break - fi - done - - if [ -z "$found" ]; then - printf "profile: module '%s' not found in %s\n" "$name" "$modules_dir" >&2 - return 1 - fi - - echo "$found" -} diff --git a/lib/state.sh b/lib/state.sh index 263b411..3e0281a 100644 --- a/lib/state.sh +++ b/lib/state.sh @@ -116,6 +116,19 @@ state_migrate_from_txt() { "$MBP_STATE_JSON" > "$tmp" && mv "$tmp" "$MBP_STATE_JSON" done < "$MBP_STATE_TXT" + # Migrate selected_modules.txt into state.json + local sel_file="${MBP_STATE_DIR}/selected_modules.txt" + if [ -f "$sel_file" ]; then + local modules_json="[]" + while IFS= read -r mod; do + [ -z "$mod" ] && continue + modules_json=$(echo "$modules_json" | jq --arg m "$mod" '. + [$m]') + done < "$sel_file" + local tmp; tmp=$(_mbp_mktemp) + jq --argjson sel "$modules_json" '.selected_modules = $sel' \ + "$MBP_STATE_JSON" > "$tmp" && mv "$tmp" "$MBP_STATE_JSON" + fi + # Rename plain-text file so migration only runs once mv "$MBP_STATE_TXT" "${MBP_STATE_TXT}.migrated" } @@ -190,6 +203,28 @@ state_set_last_run() { jq --arg ts "$ts" '.last_run = $ts' "$MBP_STATE_JSON" > "$tmp" && mv "$tmp" "$MBP_STATE_JSON" } +# === Selected modules === +MBP_SELECTED_MODULES_FILE="${MBP_STATE_DIR}/selected_modules.txt" + +# Get selected modules (space-separated). Checks state.json first, then txt fallback. +state_get_selected_modules() { + # Try JSON first + if command -v jq >/dev/null 2>&1 && [ -f "$MBP_STATE_JSON" ]; then + local mods + mods=$(jq -r '.selected_modules // [] | .[]' "$MBP_STATE_JSON" 2>/dev/null) + if [ -n "$mods" ]; then + echo "$mods" | tr '\n' ' ' | sed 's/ $//' + return 0 + fi + fi + # Fall back to plain-text + if [ -f "$MBP_SELECTED_MODULES_FILE" ]; then + tr '\n' ' ' < "$MBP_SELECTED_MODULES_FILE" | sed 's/ $//' + return 0 + fi + return 1 +} + # === Idempotency decision === # Returns 0 = should run, 1 = should skip state_module_should_run() { diff --git a/modules/02-homebrew.sh b/modules/02-homebrew.sh index 632a7d4..eb5e6fc 100755 --- a/modules/02-homebrew.sh +++ b/modules/02-homebrew.sh @@ -2,18 +2,29 @@ # Module 02: Homebrew + brew bundle # CRITICAL — setup halts if this fails. # Uses plain-text state (jq not yet available). +set -o pipefail source "$(dirname "$0")/../lib/core.sh" source "$(dirname "$0")/../lib/platform.sh" source "$(dirname "$0")/../lib/state.sh" BREWFILE_DIR="$(dirname "$0")/../brewfiles" +SELECTED_MODULES_FILE="${HOME}/.mbp/selected_modules.txt" # Install Homebrew if missing if ! mbp_command_exists brew; then mbp_log_step "Installing Homebrew..." - NONINTERACTIVE=1 /bin/bash -c \ - "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + mkdir -p "${HOME}/.mbp/logs" + local_log="${HOME}/.mbp/logs/$(date +%Y%m%d)-homebrew-install.log" + + if ! /bin/bash -c \ + "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \ + 2>&1 | tee -a "$local_log"; then + mbp_log_error "Homebrew installation failed — see log: $local_log" + state_txt_set "homebrew" "error" "1" + return 1 + fi # Add brew to PATH for this shell session eval "$("$MBP_HOMEBREW_PREFIX/bin/brew" shellenv)" @@ -31,21 +42,40 @@ fi mbp_log_step "Updating Homebrew..." brew update --quiet 2>&1 | tail -3 -# Run brew bundle for each profile Brewfile -BREWFILES="${MBP_PROFILE_BREWFILES:-core dev}" +# Determine which Brewfiles to bundle based on selected modules +BREWFILES="core dev" + +if [ -f "$SELECTED_MODULES_FILE" ]; then + if grep -qx "ai-tools" "$SELECTED_MODULES_FILE"; then + BREWFILES="$BREWFILES ai" + fi + if grep -qx "apps" "$SELECTED_MODULES_FILE"; then + BREWFILES="$BREWFILES apps" + fi +else + # No selection file — bundle all (default for re-runs after migration) + BREWFILES="${MBP_PROFILE_BREWFILES:-core dev ai apps}" +fi + +# Run brew bundle for each Brewfile +BUNDLE_FAILED=0 for bf in $BREWFILES; do BFPATH="$BREWFILE_DIR/Brewfile.$bf" if [ -f "$BFPATH" ]; then mbp_log_step "Bundling: Brewfile.$bf" - if ! brew bundle --file="$BFPATH" --no-upgrade 2>&1 | \ - grep -v "^Using " | grep -v "^Homebrew Bundle complete"; then - mbp_log_warn "brew bundle returned non-zero for Brewfile.$bf — some packages may have failed" + if ! brew bundle --file="$BFPATH" --no-upgrade 2>&1; then + mbp_log_warn "brew bundle failed for Brewfile.$bf — some packages may not have installed" + BUNDLE_FAILED=1 fi else mbp_log_warn "Brewfile.$bf not found, skipping" fi done +if [ "$BUNDLE_FAILED" -eq 1 ]; then + mbp_log_warn "Some brew packages failed to install — re-run mbp setup to retry" +fi + # Record package count for state migration PACKAGE_COUNT=$(brew list --formula 2>/dev/null | wc -l | tr -d ' ') mkdir -p "${HOME}/.mbp" diff --git a/modules/11-macos-defaults.sh b/modules/11-macos-defaults.sh index d01f98d..9efb45c 100755 --- a/modules/11-macos-defaults.sh +++ b/modules/11-macos-defaults.sh @@ -66,6 +66,10 @@ apply_default "NSGlobalDomain" "NSNavPanelExpandedStateForSaveMode" "bool" "true apply_default "NSGlobalDomain" "PMPrintingExpandedStateForPrint" "bool" "true" apply_default "NSGlobalDomain" "NSDocumentSaveNewDocumentsToCloud" "bool" "false" +mbp_log_step "Widgets..." +apply_default "com.apple.WindowManager" "StandardHideDesktopIcons" "bool" "true" +apply_default "com.apple.WindowManager" "EnableStandardClickToShowDesktop" "bool" "false" + mbp_log_step "TextEdit..." apply_default "com.apple.TextEdit" "RichText" "int" "0" apply_default "com.apple.TextEdit" "PlainTextEncoding" "int" "4" diff --git a/modules/12-apps.sh b/modules/12-apps.sh index 3b08c82..33282a0 100755 --- a/modules/12-apps.sh +++ b/modules/12-apps.sh @@ -10,7 +10,7 @@ EXPECTED_CASKS="" for bf in ${MBP_PROFILE_BREWFILES:-}; do case "$bf" in apps) - EXPECTED_CASKS="$EXPECTED_CASKS ngrok wireshark hyper xcodes secretive gpg-suite icanhazshortcut" + EXPECTED_CASKS="$EXPECTED_CASKS ngrok graphql-playground xcodes secretive gpg-suite icanhazshortcut" ;; dev) EXPECTED_CASKS="$EXPECTED_CASKS docker" diff --git a/modules/13-dev-dirs.sh b/modules/13-dev-dirs.sh index 1cdae72..da03b80 100755 --- a/modules/13-dev-dirs.sh +++ b/modules/13-dev-dirs.sh @@ -1,20 +1,12 @@ #!/usr/bin/env bash -# Module 13: Developer directory structure -# Creates ~/Developer subdirectories and ~/.mbp runtime directories. +# Module 13: mbp infrastructure directories +# Creates ~/.mbp runtime directories and ~/.local/bin. # NEVER removes or overwrites existing directories. source "$(dirname "$0")/../lib/core.sh" source "$(dirname "$0")/../lib/state.sh" -DEV="${HOME}/Developer" - -# Standard Devizer project structure DIRS=( - "${DEV}/Clients" - "${DEV}/agents" - "${DEV}/ai" - "${DEV}/playground" - "${DEV}/Routine" "${HOME}/.mbp/backups" "${HOME}/.mbp/logs" "${HOME}/.local/bin" @@ -30,4 +22,4 @@ for dir in "${DIRS[@]}"; do done state_set_module_ok "dev-dirs" -mbp_log_ok "Developer directories ready" +mbp_log_ok "Infrastructure directories ready" diff --git a/profiles/client-minimal.conf b/profiles/client-minimal.conf deleted file mode 100644 index a784667..0000000 --- a/profiles/client-minimal.conf +++ /dev/null @@ -1,8 +0,0 @@ -# client-minimal — Core dev tools only, no personal apps or preferences -# Use for client-owned machines or shared environments. -# format = 1 - -format = 1 -modules = xcode,homebrew,shell,mise,dotfiles,git,ssh,dev-dirs -brewfiles = core,dev -mise_tools = node@22,ruby@3.3,python@3.12 diff --git a/profiles/devizer-full.conf b/profiles/devizer-full.conf deleted file mode 100644 index 3e3a5df..0000000 --- a/profiles/devizer-full.conf +++ /dev/null @@ -1,8 +0,0 @@ -# devizer-full — Jensen Bernard's complete Devizer workstation -# All 13 modules, all tools, full macOS defaults. -# format = 1 - -format = 1 -modules = xcode,homebrew,shell,mise,dotfiles,git,ssh,secrets,docker,ai-tools,macos-defaults,apps,dev-dirs -brewfiles = core,dev,ai,apps,personal -mise_tools = node@22,ruby@3.3,python@3.12,bun@1.2 diff --git a/profiles/personal.conf b/profiles/personal.conf deleted file mode 100644 index 977a547..0000000 --- a/profiles/personal.conf +++ /dev/null @@ -1,7 +0,0 @@ -# personal — Full setup + personal apps, no client tooling required -# format = 1 - -format = 1 -modules = xcode,homebrew,shell,mise,dotfiles,git,ssh,secrets,docker,ai-tools,macos-defaults,apps,dev-dirs -brewfiles = core,dev,ai,apps,personal -mise_tools = node@22,ruby@3.3,python@3.12,bun@1.2