# Typoena firmware — common commands.
# Recipes source ~/export-esp.sh themselves (LIBCLANG_PATH + Xtensa GCC),
# so no per-shell setup is needed before calling just.

# Load firmware/.env (Wi-Fi creds for the network spikes) into recipe env.
# Absent .env is fine — the editor build ignores TW_* entirely.
set dotenv-load := true

esp_env := ". ~/export-esp.sh &&"
elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
elf_splash := "target/xtensa-esp32s3-espidf/release/splash"
elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke"
elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push"
elf_git_sync := "target/xtensa-esp32s3-espidf/release/git_sync"

# Custom partition table (adds the `storage` FAT partition for the git working
# copy). Only the git-push flash applies it — the editor flash keeps the default
# single-app layout, so this change is scoped to Spike 7's finish.
partition_table := justfile_directory() + "/partitions.csv"

# Spike 7 Path 2 — env for the git2/libgit2 build. LIBGIT2_SRC points at the
# vendored libgit2 submodule (v1.9.4, matches libgit2-sys 0.18.5). pkgconfig/
# holds fake .pc files so libgit2-sys + libz-sys run in system mode and the
# symbols come from the esp-idf libgit2 component. Only the git recipes set this,
# so the editor build never compiles libgit2.
libgit2_src := justfile_directory() + "/components/libgit2/vendor"
git_env := "LIBGIT2_SRC=" + libgit2_src + " LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_LIBDIR=" + justfile_directory() + "/pkgconfig"

# list recipes
default:
    @just --list

# compile (release) — the nominal product build: full firmware WITH git
# publishing (compiles libgit2 + mbedTLS, links git2). For fast iteration on the
# editor / EPD / USB / SD without paying for libgit2, use `build-light`.
build:
    {{esp_env}} {{git_env}} cargo build --release --bin firmware --features git

# build + flash + open serial monitor (full firmware, see `build`)
flash:
    {{esp_env}} {{git_env}} cargo run --release --bin firmware --features git

# LIGHT editor build — no git: no libgit2 component (LIBGIT2_SRC unset) and no
# git2 crate (`git` feature off), so it builds much faster. `:sync` saves locally
# and skips the push. Use for anything but exercising publish.
build-light:
    {{esp_env}} cargo build --release --bin firmware

# light build + flash + open serial monitor (see `build-light`)
flash-light:
    {{esp_env}} cargo run --release --bin firmware

# flash + monitor the ALREADY-BUILT firmware — no cargo, no rebuild. Flashes
# whatever `build`/`build-light` last produced (both write the same ELF).
# Same espflash flags as the cargo runner in .cargo/config.toml.
flash-only:
    espflash flash --monitor --baud 921600 {{elf}}

# serial monitor only, with decoded backtraces
monitor:
    espflash monitor --elf {{elf}}

# Spike 6 — build the Wi-Fi + TLS spike (needs TW_WIFI_SSID / TW_WIFI_PASS in .env)
build-wifi:
    {{esp_env}} cargo build --release --bin wifi_tls

# Spike 6 — flash + monitor the Wi-Fi + TLS spike
flash-wifi:
    {{esp_env}} cargo run --release --bin wifi_tls

# serial monitor for the Wi-Fi spike, with decoded backtraces
monitor-wifi:
    espflash monitor --elf {{elf_wifi}}

# Spike 3 — build the SD/FAT spike (no .env needed)
build-sd:
    {{esp_env}} cargo build --release --bin sd_fat

# Spike 3 — flash + monitor the SD/FAT spike
flash-sd:
    {{esp_env}} cargo run --release --bin sd_fat

# serial monitor for the SD spike, with decoded backtraces
monitor-sd:
    espflash monitor --elf {{elf_sd}}

# SD primitive-op micro-benchmark — attributes the ~700 ms/loose-object write
# floor (docs/tradeoff-curves/sync-commit-staging.md). Writes only to /sd/sdbench.
build-bench:
    {{esp_env}} cargo build --release --bin sd_bench

# build + flash + monitor the SD bench
flash-bench:
    {{esp_env}} cargo run --release --bin sd_bench

# serial monitor for the SD bench, with decoded backtraces
monitor-bench:
    espflash monitor --elf target/xtensa-esp32s3-espidf/release/sd_bench

# git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead
# (docs/tradeoff-curves/sync-commit-staging.md). Read-mostly on /sd/repo.
build-gitbench:
    {{esp_env}} {{git_env}} cargo build --release --bin git_bench --features git

# build + flash + monitor the git-level bench
flash-gitbench:
    {{esp_env}} {{git_env}} cargo run --release --bin git_bench --features git

# serial monitor for the git-level bench, with decoded backtraces
monitor-gitbench:
    espflash monitor --elf target/xtensa-esp32s3-espidf/release/git_bench

# Spike 9 — build the boot splash spike (no .env needed)
build-splash:
    {{esp_env}} cargo build --release --bin splash

# Spike 9 — flash + monitor the boot splash spike
flash-splash:
    {{esp_env}} cargo run --release --bin splash

# serial monitor for the splash spike, with decoded backtraces
monitor-splash:
    espflash monitor --elf {{elf_splash}}

# Spike 7 Path 2 — build the git2/libgit2 smoke (git2 safe API on device)
build-git:
    {{esp_env}} {{git_env}} cargo build --release --bin git_smoke --features git

# Spike 7 Path 2 — flash + monitor the git2/libgit2 smoke
flash-git:
    {{esp_env}} {{git_env}} cargo run --release --bin git_smoke --features git

# serial monitor for the git smoke, with decoded backtraces
monitor-git:
    espflash monitor --elf {{elf_git}}

# Spike 7 finish — build the on-device git push (Wi-Fi + SNTP + flash-FAT + libgit2)
build-git-push:
    {{esp_env}} {{git_env}} cargo build --release --bin git_push --features git

# Spike 7 finish — flash + monitor. espflash applies the custom partition table
# (so the `storage` FAT partition exists) and sets 16 MB flash. Uses espflash
# directly, not `cargo run`, so the table is applied only to this binary.
flash-git-push: build-git-push
    espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}

# serial monitor for the git push, with decoded backtraces
monitor-git-push:
    espflash monitor --elf {{elf_git_push}}

# Milestone #2A — build the persistent-clone publish cycle (clone + fast-forward push)
build-git-sync:
    {{esp_env}} {{git_env}} cargo build --release --bin git_sync --features git

# Milestone #2A — flash + monitor. Applies the same custom partition table as
# git-push (the `storage` FAT partition holds the persistent clone).
flash-git-sync: build-git-sync
    espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_sync}}

# serial monitor for the git sync, with decoded backtraces
monitor-git-sync:
    espflash monitor --elf {{elf_git_sync}}

# detect board, print chip/MAC/flash size
info:
    espflash board-info

# list USB serial devices
ports:
    @ls /dev/cu.usb* 2>/dev/null || echo "no USB serial device found"

# ─── SD card provisioning ────────────────────────────────────────────────────
# Prepare an SD card on a computer so it can go straight into Typoena (the
# decision in docs/notes/git-sync-images-and-repo-size.md): the device never
# cold-clones 566 MB over Wi-Fi + mbedTLS; a laptop copies the clone onto the
# card via a reader, and the device only ever takes the `open` + fast-forward
# path. Three entry points, each ejecting the card when done:
#
#   just init ~/code/notes    # full prep of a fresh card: repo + config
#   just load ~/code/notes    # (re)copy just the notes repo → /sd/repo
#   just provision            # (re)write just the config (derive + prompt)
#
# Config (Wi-Fi + PAT + git identity) needs no firmware/.env: each value is
# resolved from firmware/.env if present, else derived from tools the machine
# already has (git config, gh, the active Wi-Fi network + its Keychain
# password), else asked for at an interactive prompt with the derived value as
# the default. The PAT is always typed by hand — never derived (a broad
# `gh auth token` on a plaintext card would defeat the scoped-token model).
#
# Add a /Volumes/<name> as the last arg if more than one card is mounted.
# The `_`-prefixed recipes are shared internals (hidden from `just --list`).
sd_repo_dir := "repo"

# Full prep of a fresh card: copy the notes repo, seed the git-tracked config
# files (first-time only), write the runtime config, then eject. Run this once
# per card. <repo-src> is a clone made on this computer.
init repo_src sd_volume="":
    #!/usr/bin/env bash
    set -euo pipefail
    vol="$(just _card "{{sd_volume}}" | tail -n1)"
    just _load-repo "{{repo_src}}" "$vol"
    just _seed-configs "$vol"
    just _write-conf "$vol" "{{repo_src}}"
    just _eject "$vol"

# (Re)copy just the notes repo to /sd/repo: fast-forward the source clone from
# its remote first, repack it to a single pack if it has grown several (the
# device's push needs the one-pack layout — see _load-repo), then rsync it
# across (full clone, gitignored paths excluded) and eject — e.g. to refresh
# the card after the device pushed new notes.
# Refuses if the card's .typoena-dirty journal lists unpublished device edits
# (:sync from the device first, or TW_DISCARD_UNPUBLISHED=1 to back up+discard).
# --force wipes the card's repo/ + dirty journal first for a from-scratch copy
# (recovers a corrupt or half-written card clone; journaled files still get the
# rescue backup). Scoped to repo/: typoena.conf and /sd/local notes survive.
#   just load ~/code/notes --force
#   just load ~/code/notes SDCARD --force
[positional-arguments]
load repo_src *rest:
    #!/usr/bin/env bash
    set -euo pipefail
    repo_src="$1"; shift
    force=""; volname=""
    for a in "$@"; do
        case "$a" in
            --force) force=1 ;;
            *) volname="$a" ;;
        esac
    done
    vol="$(just _card "$volname" | tail -n1)"
    just _load-repo "$repo_src" "$vol" "$force"
    just _eject "$vol"

# (Re)write just the config (Wi-Fi + PAT + git identity), then eject — e.g. to
# rotate the PAT or switch networks without touching repo/. Values are derived +
# prompted (see the section header); firmware/.env is an optional override. With
# no repo arg, the git remote is derived from the card's existing clone.
provision sd_volume="":
    #!/usr/bin/env bash
    set -euo pipefail
    vol="$(just _card "{{sd_volume}}" | tail -n1)"
    just _write-conf "$vol"
    just _eject "$vol"

# Resolve the target card volume. Prints the /Volumes/<name> path as the last
# stdout line (callers capture it); all diagnostics go to stderr. Prefers an
# explicit name; else auto-detects exactly one removable/SD volume (covers cards
# in a built-in Mac reader, which report an "Internal" bus), and refuses on 0 or
# >1 — a wrong guess here means rsync --delete wipes the wrong disk's repo/.
_card sd_volume="":
    #!/usr/bin/env bash
    set -euo pipefail
    want="{{sd_volume}}"
    if [ -n "$want" ]; then
        vol="/Volumes/$want"
        [ -d "$vol" ] || { echo "error: '$vol' is not mounted" >&2; exit 1; }
    else
        cands=()
        for v in /Volumes/*; do
            [ -d "$v" ] || continue
            info="$(diskutil info "$v" 2>/dev/null || true)"
            # Treat a volume as a candidate card if ANY removable/SD signal is
            # present. A card in a Mac's *built-in* reader shows Protocol "Secure
            # Digital" + Removable Media "Removable", but sits on the "Internal"
            # bus with no whole-disk "Ejectable" line — so the old
            # "Ejectable: Yes AND external" test missed it entirely. None of these
            # match the fixed internal APFS disk; a spurious extra match just
            # trips the ">1 — name one" refusal below, which is safe.
            if echo "$info" | grep -qiE 'Protocol:[[:space:]]+Secure Digital' \
               || echo "$info" | grep -qiE 'Removable Media:[[:space:]]+(Removable|Yes)' \
               || echo "$info" | grep -qiE 'Ejectable:[[:space:]]+Yes' \
               || echo "$info" | grep -qiE 'Device Location:[[:space:]]+External'; then
                cands+=("$v")
            fi
        done
        case "${#cands[@]}" in
            0) echo "error: no removable card detected under /Volumes — insert an SD card (FAT32)" >&2; exit 1 ;;
            1) vol="${cands[0]}" ;;
            *) echo "error: multiple removable volumes — name one as the volume arg:" >&2
               printf '  %s\n' "${cands[@]#/Volumes/}" >&2; exit 1 ;;
        esac
    fi
    # The device won't mount a non-FAT card (esp_vfs_fat, format_if_mount_failed=false).
    fs="$(diskutil info "$vol" 2>/dev/null | grep -iE 'File System Personality' | sed 's/.*:[[:space:]]*//' || true)"
    case "$fs" in
        *FAT32*|*MS-DOS*) : ;;
        *) echo "warning: '$vol' is '$fs', not FAT32 — the device may fail to mount it" >&2 ;;
    esac
    echo "$vol"

# Copy a full clone of the notes repo to <vol>/repo. Everything the repo's
# .gitignore ignores is excluded (node_modules is 3.9 GB, gitignored, never in
# .git) — the device needs .git + the checkout (~720 MB), not the JS deps or
# secrets like firmware/.env. Copying (not a fresh `git clone` of the local
# path) preserves origin → GitHub so on-device fetch/push still work — except
# the URL scheme: the card copy's origin is rewritten to HTTPS at the end,
# because the device's libgit2 has no SSH transport (HTTPS+PAT over mbedTLS
# only). The source clone is never touched.
_load-repo repo_src vol wipe="":
    #!/usr/bin/env bash
    set -euo pipefail
    src="{{repo_src}}"; src="${src%/}"   # strip trailing slash
    wipe="{{wipe}}"
    [ -d "$src/.git" ] || { echo "error: '$src' is not a git repo (no .git/)"; exit 1; }

    # ── unpublished-work guard ───────────────────────────────────────────────
    # /sd/.typoena-dirty (card root — outside the repo/ rsync scope) lists the
    # repo-relative paths the device saved or `:delete`d but never confirmed
    # published; the device clears it only on a confirmed push, so it also
    # covers any stranded splice commit in repo/.git. Mirroring repo/ over
    # those edits while the journal survives is worse than losing them: the
    # device's next :sync splices the journaled paths from the mirrored tree,
    # committing Mac-state — or a *deletion* for a path the Mac clone lacks.
    # So refuse by default; TW_DISCARD_UNPUBLISHED=1 backs the files up to a
    # temp dir, clears the journal, then mirrors. Either way a completed load
    # leaves no stale journal behind.
    journal="{{vol}}/.typoena-dirty"
    if [ -s "$journal" ]; then
        echo "card carries unpublished device edits (.typoena-dirty):" >&2
        sed 's/^/    /' "$journal" >&2
        if [ -z "${TW_DISCARD_UNPUBLISHED:-}" ] && [ -z "$wipe" ]; then
            echo "error: loading would overwrite these with the Mac clone and strand the journal." >&2
            echo "       Put the card back in Typoena and :sync first (the normal path), or re-run" >&2
            echo "       with TW_DISCARD_UNPUBLISHED=1 to back them up to a temp dir and discard." >&2
            exit 1
        fi
        rescue="$(mktemp -d)/typoena-rescue"
        while IFS= read -r p; do
            p="$(echo "$p" | xargs)"; [ -n "$p" ] || continue
            if [ -f "{{vol}}/{{sd_repo_dir}}/$p" ]; then
                mkdir -p "$rescue/$(dirname "$p")"
                cp "{{vol}}/{{sd_repo_dir}}/$p" "$rescue/$p"
            else
                echo "  (no file on card for '$p' — was a device :delete, nothing to back up)" >&2
            fi
        done < "$journal"
        rm -f "$journal"
        echo "journal cleared; unpublished files backed up to $rescue" >&2
    fi

    origin="$(git -C "$src" remote get-url origin 2>/dev/null || true)"
    case "$origin" in
        https://*|git@*|ssh://*|git://*) echo "source origin: $origin" ;;
        "")  echo "warning: '$src' has no 'origin' remote — the device can't fetch/push after loading" ;;
        *)   echo "warning: origin is '$origin' (looks like a local path, not a remote) —"
             echo "         the device fetch/push will fail; set origin to the GitHub URL first" ;;
    esac
    if [ -n "${TW_REMOTE_URL:-}" ] && [ -n "$origin" ] && [ "$origin" != "${TW_REMOTE_URL}" ]; then
        echo "warning: origin ($origin) != TW_REMOTE_URL (${TW_REMOTE_URL})"
    fi

    # Refresh the source clone from its remote before mirroring, so the card
    # carries the latest — e.g. notes the device pushed since the last load.
    # Fast-forward-only and best-effort: a dirty tree, diverged history, detached
    # HEAD, or being offline just warns and copies the current on-disk state
    # rather than aborting the card prep. Set TW_NO_PULL=1 to skip the pull.
    case "$origin" in
        https://*|git@*|ssh://*|git://*)
            if [ -n "${TW_NO_PULL:-}" ]; then
                echo "TW_NO_PULL set — skipping pull, copying current on-disk state"
            else
                branch="$(git -C "$src" symbolic-ref --quiet --short HEAD || true)"
                if [ -z "$branch" ]; then
                    echo "warning: '$src' is in detached HEAD — skipping pull, copying current state" >&2
                elif git -C "$src" pull --ff-only origin "$branch"; then
                    :
                else
                    echo "warning: 'git pull --ff-only origin $branch' failed (dirty, diverged, or offline) —" >&2
                    echo "         copying the current on-disk state instead" >&2
                fi
            fi
            ;;
    esac

    # Single-pack card prep (kaizen: real-repo-sync). Each pack's whole .idx —
    # plus any multi-pack-index — is mmap'd OUTSIDE libgit2's window budget on
    # the device, and a multi-pack layout scatters the push's boundary-marking
    # walk across packs: run 8 (2026-07-13) spent 31 s marking and lost the
    # HTTP keep-alive; the same repo repacked to one pack marked in 3.5 s.
    # Repack the SOURCE clone (the card copy just mirrors it) only when the
    # layout needs it; best-effort like the pull — a failure warns, because a
    # multi-pack card usually still syncs, just slower and riskier.
    packs=$(find "$src/.git/objects/pack" -name '*.pack' 2>/dev/null | wc -l | tr -d ' ')
    if [ "$packs" -gt 1 ] || [ -e "$src/.git/objects/pack/multi-pack-index" ]; then
        echo "source clone has $packs packs — repacking to one for the device (git repack -ad)"
        git -C "$src" repack -ad \
            || echo "warning: repack failed — card will carry $packs packs; on-device push may hit the keep-alive race" >&2
    fi

    dest="{{vol}}/{{sd_repo_dir}}"
    # --force: delete the card's repo/ outright so rsync copies from scratch —
    # unlike the mirror, this also clears paths the exclude list protects from
    # `--delete` (a stray .env, build junk) and any corrupt .git state. Scoped
    # to repo/ + the journal (already cleared above if present): typoena.conf,
    # ca.pem and /sd/local never touched. Sanity-check vol first — with an
    # empty vol this rm would aim at "/repo".
    if [ -n "$wipe" ]; then
        [ -n "{{vol}}" ] && [ -d "{{vol}}" ] || { echo "error: bad volume '{{vol}}'" >&2; exit 1; }
        echo "--force: wiping $dest for a from-scratch copy"
        rm -rf "$dest"
        rm -f "$journal"
    fi
    echo "copying repo: $src  ->  $dest"
    mkdir -p "$dest"

    # Build the exclude list from git's own ignore resolution (.gitignore +
    # .git/info/exclude + global). --directory collapses a fully-ignored dir
    # (node_modules/, 3.9 GB) to one line. Driving off git — not rsync's own
    # dir-merge — means we never list a tracked file or anything under .git/, so
    # a .gitignore pattern like `logs/` or `*.pack` can't corrupt the clone.
    ignore_list="$(mktemp)"
    trap 'rm -f "$ignore_list"' EXIT
    git -C "$src" -c core.quotePath=false ls-files \
        --others --ignored --exclude-standard --directory --no-empty-directory \
        > "$ignore_list"
    echo "excluding $(wc -l < "$ignore_list" | tr -d ' ') gitignored path(s) (incl. node_modules, .env)"

    # -rt (no perms/owner/symlinks — meaningless on FAT), --modify-window=1 for
    # FAT's 2 s timestamp granularity, --delete to mirror (re-runs stay clean),
    # -P for progress on the ~700 MB copy. Scoped to repo/, never the card root.
    rsync -rtP --delete --modify-window=1 --exclude-from="$ignore_list" "$src/" "$dest/"

    # The device's libgit2 speaks HTTPS+PAT only (mbedTLS — no SSH transport),
    # so an SSH origin copied verbatim fails every on-device push/fetch with
    # "unsupported URL protocol" (found the hard way, 2026-07-13). Rewrite the
    # CARD copy's origin to the HTTPS equivalent; the source clone keeps its
    # own URL. Its embedded trust store carries GitHub's roots, hence the
    # non-github warning.
    case "$origin" in
        git@*:*)
            host="${origin#git@}"; host="${host%%:*}"
            card_origin="https://$host/${origin#git@*:}"
            ;;
        ssh://git@*)
            rest="${origin#ssh://git@}"
            card_origin="https://${rest%%[:/]*}/${rest#*/}"
            ;;
        https://*) card_origin="$origin" ;;
        *) card_origin="" ;;
    esac
    if [ -n "$card_origin" ]; then
        if [ "$card_origin" != "$origin" ]; then
            echo "rewriting card origin for the device: $origin -> $card_origin"
        fi
        git -C "$dest" remote set-url origin "$card_origin"
        case "$card_origin" in
            https://github.com/*) ;;
            *) echo "warning: origin host isn't github.com — the device's embedded trust store" \
                    "only carries GitHub roots, so on-device TLS will fail without its CA" ;;
        esac
    elif [ -n "$origin" ]; then
        echo "warning: origin '$origin' has no HTTPS equivalent I can derive —"
        echo "         on-device push/fetch will fail; set it by hand:"
        echo "         git -C '$dest' remote set-url origin https://github.com/<owner>/<repo>.git"
    fi

# First-time setup: seed the two git-tracked config files into the card's repo/
# so they commit + sync on the device's first `:sync`. Writes ONLY a file that is
# absent — a card whose clone already carries `.typoena.toml` /
# `.typoena.snippets.json` keeps its own, so a re-`init` never clobbers a synced
# library. The starter `.typoena.toml` mirrors the editor defaults; the snippet
# library is assembled from the curated catalog (snippets-catalog/, opt-in per
# group) with `jq`. Non-interactive runs take every default (all groups).
_seed-configs vol:
    #!/usr/bin/env bash
    set -euo pipefail
    repo="{{vol}}/{{sd_repo_dir}}"
    catalog="{{justfile_directory()}}/snippets-catalog"
    interactive=1; [ -t 0 ] || interactive=0

    # ask_yn "label" → 0 (yes) / 1 (no); default yes, non-interactive → yes.
    ask_yn() {
        local ans
        if [ "$interactive" = 0 ]; then return 0; fi
        read -r -p "  $1 [Y/n]: " ans || ans=""
        [[ "${ans:-Y}" =~ ^[Yy] ]]
    }

    # ── starter prefs (.typoena.toml) ────────────────────────────────────────
    prefs="$repo/.typoena.toml"
    if [ -e "$prefs" ]; then
        echo "keeping existing .typoena.toml"
    elif ask_yn "seed a starter .typoena.toml (editor defaults)?"; then
        # printf per line (matching _write-conf), so no heredoc indentation trap.
        {
            printf '# Typoena editor preferences — hand-editable, git-tracked.\n'
            printf '# Edit here, or change live from the Cmd-P palette (type `>`).\n'
            printf 'save_on_idle = true\n'
            printf 'format_on_save = true\n'
            printf 'line_numbers = true\n'
            printf 'theme = "light"\n'
            printf 'auto_sync = "10m"\n'
        } > "$prefs"
        echo "wrote .typoena.toml"
    fi

    # ── snippet library (.typoena.snippets.json) from the catalog ────────────
    snips="$repo/.typoena.snippets.json"
    if [ -e "$snips" ]; then
        echo "keeping existing .typoena.snippets.json"
    elif ! command -v jq >/dev/null 2>&1; then
        echo "warning: jq not found — skipping the snippet catalog (install jq, or" >&2
        echo "         hand-write $snips later)" >&2
    else
        echo "snippet catalog — choose groups to include:" >&2
        picked=()
        ask_yn "Symbols   (→ ≠ × · ° € œ)"                        && picked+=("$catalog/symbols.json")
        ask_yn "Structure (todo, link, img, table, code)"         && picked+=("$catalog/structure.json")
        ask_yn "Prose     (booknotes, reference, bias, capture, standard)" && picked+=("$catalog/prose.json")
        if [ "${#picked[@]}" -gt 0 ]; then
            jq -s 'add' "${picked[@]}" > "$snips"
            echo "wrote .typoena.snippets.json ($(jq 'length' "$snips") snippets, ${#picked[@]} group(s))"
        else
            echo "no groups chosen — skipping .typoena.snippets.json"
        fi
    fi

# Resolve + write <vol>/typoena.conf (Wi-Fi + PAT + git identity). Each value
# runs a ladder: firmware/.env (loaded via `set dotenv-load`) → derived from
# tools already on the machine (git config, gh, the active Wi-Fi network + its
# System-keychain password) → interactive prompt, with the derived value as the
# default. So a newcomer needs no .env: they Enter through the defaults and paste
# a PAT once. The PAT is never derived (a broad `gh auth token` on plaintext
# removable media would defeat the scoped-token model) and never echoed. FAT has
# no file permissions, so physical custody of the card is the control: use a
# fine-grained PAT (contents:write on just the notes repo) so a lost card is a
# one-token revoke. <repo_src> (optional) seeds the git remote; without it the
# remote is derived from the card's existing clone.
_write-conf vol repo_src="":
    #!/usr/bin/env bash
    set -euo pipefail
    conf="{{vol}}/typoena.conf"
    repo_src="{{repo_src}}"; repo_src="${repo_src%/}"
    interactive=1; [ -t 0 ] || interactive=0

    # ask "label" "default" → chosen value on stdout; prompt shown on stderr (so
    # it survives the $(...) capture). Non-interactive → returns the default.
    ask() {
        local label="$1" def="${2:-}" ans
        if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
        read -r -p "  $label${def:+ [$def]}: " ans || ans=""
        printf '%s' "${ans:-$def}"
    }
    # like ask but hides input (secrets). A present default reads as "keep current".
    ask_secret() {
        local label="$1" def="${2:-}" ans
        if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
        read -r -s -p "  $label${def:+ [keep current]}: " ans || ans=""; printf '\n' >&2
        printf '%s' "${ans:-$def}"
    }

    # ── derive defaults (silent; empty when a tool is missing/unauthed) ──────────
    # remote: .env → source repo's origin → the card's existing clone.
    remote="${TW_REMOTE_URL:-}"
    if [ -z "$remote" ] && [ -n "$repo_src" ] && [ -d "$repo_src/.git" ]; then
        remote="$(git -C "$repo_src" remote get-url origin 2>/dev/null || true)"
    fi
    if [ -z "$remote" ] && [ -d "{{vol}}/{{sd_repo_dir}}/.git" ]; then
        remote="$(git -C "{{vol}}/{{sd_repo_dir}}" remote get-url origin 2>/dev/null || true)"
    fi
    a_name="${TW_AUTHOR_NAME:-$(git config user.name 2>/dev/null || true)}"
    a_email="${TW_AUTHOR_EMAIL:-$(git config user.email 2>/dev/null || true)}"
    gh_user="${TW_GH_USER:-}"
    [ -z "$gh_user" ] && gh_user="$(gh api user --jq .login 2>/dev/null || true)"
    # ssid: .env → the Mac's active Wi-Fi network.
    wifi_if="$(networksetup -listallhardwareports 2>/dev/null | awk '/Wi-Fi/{getline; print $2; exit}')"
    ssid="${TW_WIFI_SSID:-}"
    [ -z "$ssid" ] && ssid="$(networksetup -getairportnetwork "${wifi_if:-en0}" 2>/dev/null | sed -n 's/^Current Wi-Fi Network: //p')"
    wifi_pass="${TW_WIFI_PASS:-}"
    pat="${TW_PAT:-}"

    # ── confirm / fill via prompts ──────────────────────────────────────────────
    [ "$interactive" = 1 ] && echo "configuring $conf — press Enter to accept each [default]:" >&2
    ssid="$(ask "Wi-Fi SSID" "$ssid")"
    # Keychain read happens after the SSID is final (the user may have edited it).
    # Reading a System-keychain Wi-Fi password can pop a macOS auth dialog — only
    # attempt it interactively so scripted runs never trigger a surprise prompt.
    if [ -z "$wifi_pass" ] && [ -n "$ssid" ] && [ "$interactive" = 1 ]; then
        echo "  looking up Wi-Fi password for '$ssid' in Keychain (approve the macOS dialog if it appears)…" >&2
        wifi_pass="$(security find-generic-password -wa "$ssid" 2>/dev/null || true)"
    fi
    wifi_pass="$(ask_secret "Wi-Fi password" "$wifi_pass")"
    remote="$(ask "Git remote URL" "$remote")"
    gh_user="$(ask "GitHub username" "$gh_user")"
    pat="$(ask_secret "GitHub PAT (fine-grained, contents:write)" "$pat")"
    a_name="$(ask "Commit author name" "$a_name")"
    a_email="$(ask "Commit author email" "$a_email")"

    # ── require the essentials (a blank config ships a dead device) ──────────────
    missing=""
    [ -n "$ssid" ]   || missing="$missing Wi-Fi-SSID"
    [ -n "$remote" ] || missing="$missing git-remote-URL"
    [ -n "$pat" ]    || missing="$missing GitHub-PAT"
    if [ -n "$missing" ]; then
        echo "error: missing required config:$missing" >&2
        [ "$interactive" = 0 ] && echo "       (no TTY — set these in firmware/.env or run interactively)" >&2
        exit 1
    fi

    # ── write ───────────────────────────────────────────────────────────────────
    {
        printf '# Typoena runtime config — generated by `just init`/`provision`.\n'
        printf '# Plaintext secrets on removable media: keep the card safe; scope TW_PAT\n'
        printf '# to contents:write on just the notes repo. `key=value`, `#` = comment.\n'
        printf 'TW_WIFI_SSID=%s\n'   "$ssid"
        printf 'TW_WIFI_PASS=%s\n'   "$wifi_pass"
        printf 'TW_REMOTE_URL=%s\n'  "$remote"
        printf 'TW_GH_USER=%s\n'     "$gh_user"
        printf 'TW_PAT=%s\n'         "$pat"
        printf 'TW_AUTHOR_NAME=%s\n' "$a_name"
        printf 'TW_AUTHOR_EMAIL=%s\n' "$a_email"
    } > "$conf"
    mask() { if [ -n "${1:-}" ]; then printf 'set'; else printf 'MISSING'; fi; }
    echo "wrote $conf (secrets never printed):"
    echo "  wifi: ssid=$(mask "$ssid") pass=$(mask "$wifi_pass")"
    echo "  git:  remote=$(mask "$remote") gh_user=$(mask "$gh_user") pat=$(mask "$pat")"
    echo "  author: name=$(mask "$a_name") email=$(mask "$a_email")"

# Flush + eject so the card can go straight into Typoena. Also strips the
# AppleDouble `._*` companions macOS writes on FAT for any file it decorates
# with xattrs: on a real card they reached .git/objects/pack/._pack-*.idx,
# which git's pack scan (Mac git AND the device's libgit2) picks up via its
# *.idx glob and tries to parse ("non-monotonic index" spam; 2045 of them
# found 2026-07-13). Best-effort — a missing dot_clean never blocks the eject.
_eject vol:
    #!/usr/bin/env bash
    set -euo pipefail
    dot_clean -m "{{vol}}" 2>/dev/null || true
    sync
    echo "ejecting {{vol}}"
    if diskutil eject "{{vol}}" >/dev/null 2>&1; then
        echo "✅ card ejected — remove it and insert into Typoena"
    else
        echo "⚠️  eject failed (a file may still be open) — eject '{{vol}}' from Finder before removing"
    fi
