diff --git a/.devcontainer/config.fish b/.devcontainer/config.fish index 8098720695..61c84741b9 100644 --- a/.devcontainer/config.fish +++ b/.devcontainer/config.fish @@ -23,8 +23,10 @@ if test -f $HOME/.fnm/fnm end # Symlink a VSCode workspace as needed. - -if test -d /workspaces/aesthetic-computer +# On macOS the relationship is reversed: /workspaces is a synthetic.conf symlink +# to the user's home, so the repo already lives at ~/aesthetic-computer and the +# codespaces-style symlink would loop/fail. Skip on Darwin. +if test -d /workspaces/aesthetic-computer; and test (uname) != Darwin if test ! -L ~/aesthetic-computer echo "Symlinking /workspaces/aesthetic-computer to ~" ln -s /workspaces/aesthetic-computer ~ diff --git a/plans/MAC-NATIVE-DEVENV-PLAN.md b/plans/MAC-NATIVE-DEVENV-PLAN.md index 48f3fbfd82..ae822a1939 100644 --- a/plans/MAC-NATIVE-DEVENV-PLAN.md +++ b/plans/MAC-NATIVE-DEVENV-PLAN.md @@ -92,25 +92,27 @@ npm i -g @devcontainers/cli @anthropic-ai/claude-code \ --- -## 2. Path portability — `/workspaces/aesthetic-computer` symlink +## 2. Path portability — `/workspaces` via `synthetic.conf` [`.devcontainer/config.fish`](../.devcontainer/config.fish) hardcodes `/workspaces/aesthetic-computer` in **48 places** (e.g. `ac-tv`, `ac-keeps`, -`ac-kidlisp`, `ac-login`, `ac-pack`). Rather than fork the file, create a -system symlink so both paths resolve to the same tree: +`ac-kidlisp`, `ac-login`, `ac-pack`). macOS has had a read-only root volume +since Catalina, so `sudo mkdir /workspaces` fails with `Read-only file system`. +The sanctioned fix is `/etc/synthetic.conf`: ```sh -sudo mkdir -p /workspaces -sudo ln -s "$HOME/aesthetic-computer" /workspaces/aesthetic-computer +printf 'workspaces\t/Users/%s\n' "$USER" | sudo tee /etc/synthetic.conf +sudo /System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util -t ``` -The config at [config.fish:27-32](../.devcontainer/config.fish:27) already -checks for `/workspaces/aesthetic-computer` and will symlink the reverse -direction (`~/aesthetic-computer` → `/workspaces/...`); since the home path -already exists as a real directory it's effectively a no-op, and all the -hardcoded paths resolve via the symlink we just created. +This creates `/workspaces` as a synthetic symlink → `/Users/jas`, so +`/workspaces/aesthetic-computer` resolves automatically. No reboot needed. +Survives reboots. -This keeps future upstream edits to `config.fish` working with zero diff. +The check at [config.fish:27-32](../.devcontainer/config.fish:27) tries to +symlink `/workspaces/aesthetic-computer` → `~` the other direction and fails +with `File exists` on Mac. The plan now includes a one-line upstream fix +(`and test (uname) != Darwin`) so the block is skipped on macOS. --- @@ -288,13 +290,23 @@ render. ac-session ``` -Should bind `https://localhost:8889`. Confirm with `curl -fsSI -https://localhost:8889/healthz` (or whatever the current health path is — read -`session-server/session.mjs` to be sure). +Should bind `https://localhost:8889`. -`npm run aesthetic` launches *both* site + session + stripe + url concurrently -via `concurrently`, which is what the devcontainer uses by default. It should -also work on Mac once step 1 installs the `concurrently` / `stripe` globals. +**Known slow path on lightweight laptops:** [`chat-manager.mjs:141-156`](../session-server/chat-manager.mjs:141) runs a MongoDB `$unionWith` aggregate across `chat-system` + `logs` collections at startup, and +[`session.mjs:135`](../session-server/session.mjs:135) awaits it before +`server.listen()`. On this Mac the query takes >90s (possibly never +completes) against the production MongoDB at `silo.aesthetic.computer:27017` +— likely a network/replica-set reachability difference vs. the devcontainer. + +Workarounds if you need session locally: +1. Add a dev flag to `chat-manager.mjs` that skips the historical load when + `NODE_ENV=development` — messages will only appear for the current session. +2. Point `MONGODB_CONNECTION_STRING` at a local MongoDB with a `chat-system` + collection containing a small number of documents. +3. Use the production session-server (jamsocket) and only run lith locally. + +`npm run aesthetic` launches site + session + stripe + url concurrently. Until +the chat-load issue is resolved, prefer `ac-site` standalone. --- @@ -321,24 +333,38 @@ one specific dep — don't pre-install the whole Fedora manifest. ## 10. Reproducibility — roll-up script -Once verified interactively, commit a `scripts/mac-native-bootstrap.fish` -(or extend [`dotfiles/install.sh`](../dotfiles/install.sh) with a `macos` -branch) that performs steps 1–6 idempotently so the next fresh Mac can run: +Committed: [`scripts/mac-native-bootstrap.sh`](../scripts/mac-native-bootstrap.sh). +Idempotent. Run on a fresh Mac after `git clone`ing the repo and the vault: ```sh -curl -fsSL https://raw.githubusercontent.com/.../mac-native-bootstrap.fish | fish -``` - -Suggested layout: - -``` -scripts/ - mac-native-bootstrap.fish # brew + fnm + npm globals - mac-native-link-workspaces.sh # /workspaces symlink (needs sudo) - mac-native-fish-config.fish # writes ~/.config/fish/config.fish +cd ~/aesthetic-computer +./scripts/mac-native-bootstrap.sh ``` -Keep each step a separate function so reruns are cheap. +What it does (13 phases, ~4 min on a fresh machine, ~30s on a re-run): + +1. Writes a GUI askpass helper at `/tmp/ac-askpass.sh` and primes sudo +2. Installs Homebrew if missing +3. Installs brew formulas (fish, fnm, mkcert, jq, ripgrep, ffmpeg, caddy, + stripe, doctl, gnupg, pinentry-mac, …) +4. Creates `/etc/synthetic.conf` and triggers `apfs.util -t` for `/workspaces` +5. Installs the scoped NOPASSWD sudoers file +6. Installs node LTS-jod + 20.5.0 via fnm +7. npm globals: `@anthropic-ai/claude-code`, `netlify-cli`, `concurrently`, + `kill-port`, … + native `claude` binary from `claude.ai/install.sh` +8. Adds `/opt/homebrew/bin/fish` to `/etc/shells` and `dscl`-changes the + default shell +9. Writes `~/.config/fish/config.fish` that sources the repo's + `.devcontainer/config.fish` +10. Configures `gpg-agent` for `pinentry-mac` +11. Runs `mkcert -install` and generates `ssl-dev/localhost.pem` +12. Symlinks `aesthetic-computer-vault/session-server/.env` → + `session-server/.env` (skipped if vault is locked) +13. Smoke-tests `https://localhost:8888/` end-to-end and kills the test proc + +Vault unlock is **not** part of the bootstrap — run +`fish aesthetic-computer-vault/vault-tool.fish unlock` separately (see memory +`reference_vault_unlock.md`). --- @@ -366,13 +392,39 @@ Keep each step a separate function so reruns are cheap. ## 12. Definition of done -- [ ] `fish` is the login shell; new terminal tab has `ac-help` output ≥ 80 items -- [ ] `ac-site` brings up `https://localhost:8888` with a trusted cert (no - `-k` needed on `curl`) -- [ ] `ac-session` brings up `https://localhost:8889` -- [ ] `npm test` from repo root passes (or fails only on known - vault/Linux-only specs) -- [ ] `git status` in both `aesthetic-computer` and - `aesthetic-computer-vault` is clean (no accidental `.env` commits) -- [ ] Bootstrap script in `scripts/` reproduces the whole setup on a fresh - user account +Status as of 2026-04-17 on jas's 14" MBP (arm64, macOS 26.4): + +- [x] `fish` is the login shell; new terminal tab has **214 `ac-*` commands** +- [x] `ac-site` brings up `https://localhost:8888` with a **trusted cert** + (no `-k` on `curl`); `/`, `/prompt`, `/api/version` all return 200 +- [ ] `ac-session` brings up `https://localhost:8889` — **boots but hangs** + on chat-history MongoDB load (see §8) +- [ ] `npm test` from repo root — not yet exercised +- [x] `git status` clean in vault (no accidental plaintext commits) +- [x] [`scripts/mac-native-bootstrap.sh`](../scripts/mac-native-bootstrap.sh) + reproduces the setup + +## 13. Sudo on macOS — friction and workarounds + +Claude Code (and any non-interactive shell) can't read a sudo password +because it has no tty. Three tiers of mitigation, used in combination: + +1. **GUI askpass helper** — `/tmp/ac-askpass.sh` pops a native + `osascript display dialog` when `sudo -A` runs. Works for all `sudo -A` + invocations but NOT for third-party tools that call `sudo` directly + (e.g. Homebrew's installer, `mkcert -install`). +2. **Scoped NOPASSWD sudoers** at `/etc/sudoers.d/claude-ac-setup` covers the + exact commands this workflow invokes via third-party tooling: + `security` (for mkcert), `mkcert`, `tee -a /etc/shells`. Everything else + still prompts. +3. **One-off priming** — running `sudo -v` in a separate Terminal before + kicking off Homebrew; the timestamp is per-tty so this only helps when + you're running the installer yourself. + +Explicitly **not** enabled: + +- Global sudo timestamp (`Defaults timestamp_type=global`) +- Long `timestamp_timeout` extensions +- Blanket NOPASSWD for the user + +These were considered and rejected as too broad. diff --git a/scripts/mac-native-bootstrap.sh b/scripts/mac-native-bootstrap.sh new file mode 100755 index 0000000000..b3a4f06765 --- /dev/null +++ b/scripts/mac-native-bootstrap.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Bootstrap a lightweight Mac (Apple Silicon or Intel) to run aesthetic-computer +# natively — outside the devcontainer — with all `ac-*` fish commands available. +# +# Matches plans/MAC-NATIVE-DEVENV-PLAN.md. Safe to re-run (idempotent). +# Prereqs: macOS 11+, Xcode CLT (git), the aesthetic-computer repo checked +# out at $HOME/aesthetic-computer, the vault at $HOME/aesthetic-computer/aesthetic-computer-vault. +# +# This script does NOT unlock the vault — do that manually with +# `fish aesthetic-computer-vault/vault-tool.fish unlock` before or after. + +set -euo pipefail + +AC_ROOT="$HOME/aesthetic-computer" +VAULT="$AC_ROOT/aesthetic-computer-vault" +ASKPASS="/tmp/ac-askpass.sh" +SUDOERS_FILE="/etc/sudoers.d/claude-ac-setup" + +step() { printf "\n\033[1;34m▶ %s\033[0m\n" "$*"; } +ok() { printf " \033[1;32m✓\033[0m %s\n" "$*"; } +warn() { printf " \033[1;33m!\033[0m %s\n" "$*"; } +die() { printf " \033[1;31m✗\033[0m %s\n" "$*"; exit 1; } + +[[ "$(uname)" == "Darwin" ]] || die "this script is macOS-only" +[[ -d "$AC_ROOT" ]] || die "aesthetic-computer repo not found at $AC_ROOT" + +# ----------------------------------------------------------------------------- +step "1. GUI askpass helper for sudo -A" +# ----------------------------------------------------------------------------- +cat > "$ASKPASS" <<'EOF' +#!/bin/bash +/usr/bin/osascript -e 'display dialog "Bootstrap needs sudo — enter your password:" default answer "" with hidden answer with icon caution' -e 'text returned of result' 2>/dev/null +EOF +chmod +x "$ASKPASS" +export SUDO_ASKPASS="$ASKPASS" +sudo -A -v +ok "sudo primed via askpass" + +# ----------------------------------------------------------------------------- +step "2. Homebrew" +# ----------------------------------------------------------------------------- +if ! command -v brew >/dev/null 2>&1 && ! [[ -x /opt/homebrew/bin/brew ]]; then + NONINTERACTIVE=1 /bin/bash -c \ + "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +fi +eval "$(/opt/homebrew/bin/brew shellenv)" +ok "brew at $(which brew)" + +# ----------------------------------------------------------------------------- +step "3. Brew formulas" +# ----------------------------------------------------------------------------- +# Core — needed for fish, node, mkcert, site tooling +brew install --quiet fish fnm mkcert nss jq ripgrep bat gh tree \ + coreutils gnu-sed wget nmap ffmpeg \ + caddy ngrok/ngrok/ngrok redis \ + stripe/stripe-cli/stripe doctl awscli \ + gnupg pinentry-mac 2>&1 | tail -3 +ok "core brew formulas installed" + +# ----------------------------------------------------------------------------- +step "4. /workspaces → /Users/$USER via synthetic.conf" +# ----------------------------------------------------------------------------- +if [[ -L /workspaces ]] && [[ "$(readlink /workspaces)" == "/Users/$USER" ]]; then + ok "/workspaces already symlinked" +else + # macOS SIP prevents writes to /, but synthetic.conf is the sanctioned way. + printf 'workspaces\t/Users/%s\n' "$USER" | sudo -A tee /etc/synthetic.conf >/dev/null + sudo -A chmod 644 /etc/synthetic.conf + sudo -A /System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util -t || \ + warn "apfs.util trigger failed — a reboot will also apply synthetic.conf" + if [[ -L /workspaces ]]; then + ok "/workspaces → $(readlink /workspaces)" + else + warn "/workspaces not yet present; reboot to activate" + fi +fi + +# ----------------------------------------------------------------------------- +step "5. Scoped NOPASSWD sudoers" +# ----------------------------------------------------------------------------- +SUDOERS_TMP=$(mktemp) +cat > "$SUDOERS_TMP" </dev/null +sudo -A install -o root -g wheel -m 0440 "$SUDOERS_TMP" "$SUDOERS_FILE" +rm -f "$SUDOERS_TMP" +ok "sudoers file installed at $SUDOERS_FILE" + +# ----------------------------------------------------------------------------- +step "6. fnm + Node (lts-jod & 20.5.0)" +# ----------------------------------------------------------------------------- +eval "$(fnm env --shell bash)" +fnm install lts-jod +fnm install 20.5.0 +fnm default lts-jod +fnm use lts-jod +ok "node $(node --version) via fnm ($(fnm current))" + +# ----------------------------------------------------------------------------- +step "7. Global npm CLIs (incl. Claude Code)" +# ----------------------------------------------------------------------------- +npm i -g --silent \ + @anthropic-ai/claude-code \ + @devcontainers/cli \ + netlify-cli \ + prettier typescript typescript-language-server \ + concurrently kill-port http-server npm-check-updates 2>&1 | tail -1 +ok "npm globals installed" + +# Native Claude Code binary (matches Dockerfile:223) +if ! [[ -x "$HOME/.local/bin/claude" ]]; then + curl -fsSL https://claude.ai/install.sh | bash >/dev/null +fi +ok "claude native: $("$HOME/.local/bin/claude" --version | head -1)" + +# ----------------------------------------------------------------------------- +step "8. fish as default login shell" +# ----------------------------------------------------------------------------- +if ! grep -q "/opt/homebrew/bin/fish" /etc/shells; then + echo "/opt/homebrew/bin/fish" | sudo -n tee -a /etc/shells >/dev/null +fi +CURRENT_SHELL=$(dscl . -read "/Users/$USER" UserShell | awk '{print $2}') +if [[ "$CURRENT_SHELL" != "/opt/homebrew/bin/fish" ]]; then + sudo -A /usr/bin/dscl . -change "/Users/$USER" UserShell "$CURRENT_SHELL" /opt/homebrew/bin/fish +fi +ok "login shell: $(dscl . -read /Users/$USER UserShell | awk '{print $2}')" + +# ----------------------------------------------------------------------------- +step "9. ~/.config/fish/config.fish" +# ----------------------------------------------------------------------------- +mkdir -p "$HOME/.config/fish/conf.d" "$HOME/.config/fish/functions" +if ! [[ -f "$HOME/.config/fish/config.fish" ]] || \ + ! grep -q "$AC_ROOT/.devcontainer/config.fish" "$HOME/.config/fish/config.fish"; then + cat > "$HOME/.config/fish/config.fish" </dev/null; then + cat >> "$HOME/.gnupg/gpg-agent.conf" <<'EOF' +pinentry-program /opt/homebrew/bin/pinentry-mac +default-cache-ttl 3600 +max-cache-ttl 7200 +EOF +fi +gpgconf --kill gpg-agent >/dev/null 2>&1 || true +gpgconf --launch gpg-agent +ok "gpg-agent uses pinentry-mac" + +# ----------------------------------------------------------------------------- +step "11. mkcert CA + localhost dev certs" +# ----------------------------------------------------------------------------- +mkcert -install >/dev/null 2>&1 +ok "mkcert CA installed in System keychain" +cd "$AC_ROOT/ssl-dev" +if ! [[ -f localhost.pem ]]; then + env nogreet=true /opt/homebrew/bin/fish ./ssl-install.fish >/dev/null 2>&1 +fi +ok "ssl-dev/localhost.pem ($(date -r localhost.pem +%Y-%m-%d))" + +# ----------------------------------------------------------------------------- +step "12. Vault env links" +# ----------------------------------------------------------------------------- +# session-server reads .env relative to its own dir +if [[ -f "$VAULT/session-server/.env" ]]; then + ln -sfn "$VAULT/session-server/.env" "$AC_ROOT/session-server/.env" + ok "session-server/.env linked from vault" +else + warn "vault locked? $VAULT/session-server/.env missing" +fi +# system/.env is loaded by ac-lith if present — no default, optional for local dev + +# ----------------------------------------------------------------------------- +step "13. Smoke test: boot ac-site briefly" +# ----------------------------------------------------------------------------- +kill-port 8888 >/dev/null 2>&1 || true +(cd "$AC_ROOT/lith" && node server.mjs >/tmp/ac-bootstrap-lith.log 2>&1 &) +LITH_PID=$! +sleep 4 +if curl -sSI --fail https://localhost:8888/ -o /dev/null 2>/dev/null; then + ok "ac-site responds on https://localhost:8888 with trusted cert" +else + warn "ac-site smoke test failed — see /tmp/ac-bootstrap-lith.log" +fi +kill "$LITH_PID" 2>/dev/null || true +kill-port 8888 >/dev/null 2>&1 || true + +# ----------------------------------------------------------------------------- +printf "\n\033[1;32m✓ Bootstrap complete.\033[0m\n" +printf " Open a new Terminal tab (fish will be the default).\n" +printf " Run \`ac-help\` to list commands, then \`ac-site\` to boot the site.\n\n"