From fc4ce4d01732bce1d18c34b3bd19aebfd40e31ea Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 11:14:48 +0200 Subject: [PATCH] feat(provision): fill card config from env, git, keychain, or prompts typoena.conf no longer requires a hand-written firmware/.env. _write-conf now resolves each value through a ladder: .env if set, else derived from tools already on the machine (git config, gh, the active Wi-Fi network + its System keychain password), else an interactive prompt with the derived value as the default. The PAT is never derived (a broad gh token on a plaintext card would defeat the scoped-token model) and is always typed by hand. Missing required values now abort before touching the card instead of ejecting a blank config. --- firmware/justfile | 141 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 109 insertions(+), 32 deletions(-) diff --git a/firmware/justfile b/firmware/justfile index 2a78506..355ecca 100644 --- a/firmware/justfile +++ b/firmware/justfile @@ -123,7 +123,14 @@ ports: # # 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 from firmware/.env +# 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/ as the last arg if more than one card is mounted. # The `_`-prefixed recipes are shared internals (hidden from `just --list`). @@ -136,7 +143,7 @@ init repo_src sd_volume="": set -euo pipefail vol="$(just _card "{{sd_volume}}" | tail -n1)" just _load-repo "{{repo_src}}" "$vol" - just _write-conf "$vol" + just _write-conf "$vol" "{{repo_src}}" just _eject "$vol" # (Re)copy just the notes repo to /sd/repo (full clone, gitignored paths @@ -148,8 +155,10 @@ load repo_src sd_volume="": just _load-repo "{{repo_src}}" "$vol" just _eject "$vol" -# (Re)write just the config (Wi-Fi + PAT + git identity) from firmware/.env, -# then eject — e.g. to rotate the PAT or switch networks without touching repo/. +# (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 @@ -237,38 +246,106 @@ _load-repo repo_src vol: # -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/" -# Write /typoena.conf (Wi-Fi + PAT + git identity) from firmware/.env -# (already loaded via `set dotenv-load`), the same file the build uses — so no -# re-typing creds, no prompts, no network. The PAT is written to the file but -# only ever reported as set/MISSING, 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. -_write-conf vol: +# Resolve + write /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. (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" - if [ -n "${TW_WIFI_SSID:-}${TW_REMOTE_URL:-}${TW_PAT:-}" ]; then - { - printf '# Typoena runtime config — generated by `just provision` from firmware/.env.\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' "${TW_WIFI_SSID:-}" - printf 'TW_WIFI_PASS=%s\n' "${TW_WIFI_PASS:-}" - printf 'TW_REMOTE_URL=%s\n' "${TW_REMOTE_URL:-}" - printf 'TW_GH_USER=%s\n' "${TW_GH_USER:-}" - printf 'TW_PAT=%s\n' "${TW_PAT:-}" - printf 'TW_AUTHOR_NAME=%s\n' "${TW_AUTHOR_NAME:-}" - printf 'TW_AUTHOR_EMAIL=%s\n' "${TW_AUTHOR_EMAIL:-}" - } > "$conf" - mask() { if [ -n "${1:-}" ]; then printf 'set'; else printf 'MISSING'; fi; } - echo "wrote $conf (values reused from firmware/.env, never printed):" - echo " wifi: ssid=$(mask "${TW_WIFI_SSID:-}") pass=$(mask "${TW_WIFI_PASS:-}")" - echo " git: remote=$(mask "${TW_REMOTE_URL:-}") gh_user=$(mask "${TW_GH_USER:-}") pat=$(mask "${TW_PAT:-}")" - echo " author: name=$(mask "${TW_AUTHOR_NAME:-}") email=$(mask "${TW_AUTHOR_EMAIL:-}")" - else - echo "note: no TW_* values in firmware/.env — leaving any existing $conf untouched" - echo " (copy .env.example → firmware/.env and fill in Wi-Fi + PAT to provision)" + 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. _eject vol: