Files
typewriter/firmware/justfile
Julien Calixte 5d623151a8 chore(justfile): repack multi-pack source clones in _load-repo
Single-pack card prep is now enforced, not remembered: each pack's
whole .idx (plus any midx) is mmap'd outside the device's window
budget, and a multi-pack marking walk lost the HTTP keep-alive (run 8:
31s vs 3.5s repacked). Skipped when the clone is already one pack.
2026-07-13 22:07:26 +02:00

638 lines
31 KiB
Makefile
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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