diff --git a/docs/t3x/SEAMS.md b/docs/t3x/SEAMS.md index adef75e0b55..25e86a1c919 100644 --- a/docs/t3x/SEAMS.md +++ b/docs/t3x/SEAMS.md @@ -14,8 +14,8 @@ Everything else the fork adds lives in new, upstream-invisible files (`apps/**/t ## Server (`apps/server`) -| Upstream file | Edit | Why unavoidable | -|---|---|---| +| Upstream file | Edit | Why unavoidable | +| --------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apps/server/src/server.ts` | +1 import of `T3xLayerLive`, +1 `Layer.provideMerge(T3xLayerLive)` | The root layer graph is composed here; a feature layer must be merged into it at exactly one point. All feature layers fan in through `t3x/index.ts`, so this seam stays 2 lines no matter how many features are added. | > The auto-resume reactor **self-starts** via `Effect.forkScoped` at layer construction, @@ -29,8 +29,8 @@ avoid a code seam). They don't conflict during rebase, but if upstream changes t original's behavior the mirror can drift silently — the daily sync agent must diff these originals when they change. -| Fork mirror | Mirrors upstream | Risk if upstream changes | -|---|---|---| +| Fork mirror | Mirrors upstream | Risk if upstream changes | +| --------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `apps/server/src/t3x/autoResume/guards.ts` (`hasOpenBlockingRequest`) | `decider.ts:57-80` (private, unexported) | Awaiting-approval guard could miss a new blocking-request activity kind and auto-resume into a prompt. | ## Web (`apps/web`) @@ -48,6 +48,15 @@ file, deliberately avoiding the upstream-owned migration registry.) ## New files owned entirely by the fork (not seams — listed for orientation) - `apps/server/src/t3x/**` — feature code + the `T3xLayerLive` aggregator. -- `scripts/t3x/**` — fork setup + upstream-sync scripts. +- `scripts/t3x/**` — fork setup, upstream-sync, and desktop auto-build scripts + (incl. `scripts/t3x/hooks/**`, opt-in git hooks that are never auto-installed). - `.github/workflows/t3x-upstream-sync.yml`, `.github/workflows/t3x-weekly-verify.yml`. - `docs/t3x/**`, `docs/superpowers/specs/2026-07-23-*`. + +### Desktop auto-build (`scripts/t3x/auto-build-desktop.sh`) + +**Zero seams.** Rebuilds/installs the macOS `.dmg` when `HEAD` moves. It shells +out to the existing `pnpm dist:desktop:dmg:arm64` rather than importing or +editing `scripts/build-desktop-artifact.ts` (a hot upstream file), and +deliberately adds **no** script entry to the root `package.json` (also hot) — +it is invoked by path. See `docs/t3x/auto-build-runbook.md`. diff --git a/docs/t3x/auto-build-runbook.md b/docs/t3x/auto-build-runbook.md new file mode 100644 index 00000000000..402abea8e14 --- /dev/null +++ b/docs/t3x/auto-build-runbook.md @@ -0,0 +1,225 @@ +# t3x — auto-build & install the desktop app + +Keeps the installed macOS desktop app current as the fork changes: rebuild the +arm64 `.dmg` when `HEAD` moves, and optionally install it — no manual +dmg-dragging. + +Everything lives in `scripts/t3x/`. **Zero upstream seams** — this feature adds +no edits to any upstream-owned file, so it can never conflict during the daily +rebase. The build itself is the existing `pnpm dist:desktop:dmg:arm64`; +`scripts/build-desktop-artifact.ts` is never modified. + +## TL;DR + +```bash +# one-shot: build a .dmg if HEAD changed since the last build +scripts/t3x/auto-build-desktop.sh + +# see exactly what an install would do, without touching /Applications +scripts/t3x/auto-build-desktop.sh --install --dry-run + +# build + actually install (replaces the app in /Applications) +scripts/t3x/auto-build-desktop.sh --install + +# poll HEAD every 60s and rebuild+install on every new commit +scripts/t3x/auto-build-desktop.sh --watch --install +``` + +## ⚠️ Read this before using `--install` + +**Which app gets replaced?** The installer takes the `.app` found inside the +freshly built `.dmg` and replaces `/Applications/.app`. + +The name is **not** a flag — it is derived from the version in +`apps/desktop/package.json` by `resolveDesktopProductName()`: + +| Version | Channel | `.app` produced | +| ------------------------------ | -------- | ----------------------- | +| plain (e.g. `0.0.28`) | `latest` | `T3 Code (Alpha).app` | +| `…-nightly.YYYYMMDD.N` | nightly | `T3 Code (Nightly).app` | + +The `latest` name comes from `"productName": "T3 Code (Alpha)"` in +`apps/desktop/package.json` — **not** the `"T3 Code"` fallback, which only +applies if that field is ever removed upstream. So a default local build +replaces **`T3 Code (Alpha).app`**, the app most fork users already run. + +Confirm for yourself before the first `--install` — this reads the real name out +of the built dmg rather than trusting the table above: + +```bash +ls -d "/Applications/"*T3* # what you have installed + +MP=$(mktemp -d) && hdiutil attach -nobrowse -readonly -mountpoint "$MP" \ + release/*.dmg >/dev/null && ls -d "$MP"/*.app && hdiutil detach "$MP" >/dev/null +``` + +If the built name doesn't match the app you actually launch, `--install` creates +a **new, separate** app and it will look like "nothing updated." In that case +either switch to running the app this build produces, or redirect the install: +`T3X_AUTOBUILD_APPLICATIONS_DIR=/some/dir scripts/t3x/auto-build-desktop.sh --install`. + +**This also assumes you run the _installed_ app, not `pnpm start:desktop`.** +Those are different processes; auto-install updates the installed one only. + +**A nightly-versioned checkout targets a different app.** If a sync ever lands a +`-nightly.*` version, the same command starts replacing `T3 Code (Nightly).app` +instead. Re-run the check above after a big upstream sync. + +**Gatekeeper.** Local builds are unsigned (`T3CODE_DESKTOP_SIGNED` defaults to +`false`), so macOS quarantines them. The installer runs +`xattr -dr com.apple.quarantine` on the installed app so it launches without a +Gatekeeper block. Nothing here is code-signed or notarized. + +## How the trigger works + +Rebuilds are keyed on a **new commit SHA**, not on file saves — a `.dmg` build +takes minutes, so watching raw file writes would rebuild continuously mid-edit. + +The last successfully built SHA is recorded in +`~/.t3/userdata/t3x-autobuild-last-sha`. If `git rev-parse HEAD` matches it, the +script logs "no change" and exits `3`. Use `--force` to rebuild anyway. + +Because the marker only advances on a **successful** build, a failed build is +retried on the next poll rather than being silently skipped. + +## Flags + +| Flag | Meaning | +| ----------------- | ------------------------------------------------------------------- | +| `--install` | After a successful build, install the `.app` into `/Applications`. | +| `--relaunch` | With `--install`, `open` the app afterwards. | +| `--watch` | Poll `HEAD` forever; build (and install, if asked) on each new SHA. | +| `--interval N` | Poll interval in seconds for `--watch` (default `60`). | +| `--dry-run` | Log every step; never build, never touch `/Applications`. | +| `--force` | Build even if `HEAD` is unchanged. | +| `--print-launchd` | Emit a ready-to-use LaunchAgent plist on stdout. | + +## Where things land + +| What | Path | +| --------------------- | --------------------------------------------------------- | +| Built `.dmg` | `/release/` (override: `T3CODE_DESKTOP_OUTPUT_DIR`) | +| Last-built SHA marker | `~/.t3/userdata/t3x-autobuild-last-sha` | +| Status JSON | `~/.t3/userdata/t3x-autobuild-status.json` | +| Log | `~/.t3/userdata/logs/t3x-autobuild.log` | + +Status JSON looks like: + +```json +{ + "result": "built", + "sha": "…", + "dmgPath": "…/release/T3 Code-…arm64.dmg", + "builtAt": "2026-07-24T01:12:33-0700", + "installed": false, + "detail": "ok" +} +``` + +Env overrides: `T3X_AUTOBUILD_STATE_DIR`, `T3CODE_DESKTOP_OUTPUT_DIR` (relative values +resolve against the repo root, matching `build-desktop-artifact.ts`), +`T3X_AUTOBUILD_APPLICATIONS_DIR`, `T3X_AUTOBUILD_KEEP_DMGS` (default `3`, must be a +positive integer). `--print-launchd` copies all of these into the emitted plist, so a +plist generated from a shell where you overrode them keeps those overrides. + +`T3X_AUTOBUILD_APP_NAME` only names the `.app` assumed during `--dry-run` (a real install +reads the actual name out of the mounted dmg); it does not redirect where you install. + +**Exit codes:** `0` success · `3` nothing to do (HEAD unchanged, or another instance holds +the lock) · `1` build or install failed · `2` bad flag or invalid env value. + +## Running it hands-off + +### Option 1 — LaunchAgent (starts at login) + +```bash +scripts/t3x/auto-build-desktop.sh --print-launchd --install --interval 120 \ + > ~/Library/LaunchAgents/dev.t3x.autobuild.plist +launchctl bootstrap "gui/$UID" ~/Library/LaunchAgents/dev.t3x.autobuild.plist +``` + +Stop / remove it: + +```bash +launchctl bootout "gui/$UID/dev.t3x.autobuild" +rm ~/Library/LaunchAgents/dev.t3x.autobuild.plist +``` + +`--print-launchd` substitutes the real repo path, script path, interval and log +path, and mirrors `--install` into the emitted `ProgramArguments`. + +### Option 2 — git hook (read the warning first) + +> **This repo already sets `core.hooksPath = .vite-hooks/_`** and ships a full hook set +> there (`pre-commit`, `commit-msg`, `pre-push`, `post-checkout`, its own `post-merge`, …). +> That breaks the two obvious install methods: +> +> - `git config core.hooksPath scripts/t3x/hooks` **silently disables every one of those +> hooks** — that directory contains only `post-merge`. Do not do this. +> - `ln -sf … .git/hooks/post-merge` **never fires at all**, because git ignores +> `.git/hooks` entirely once `core.hooksPath` is set. + +The dispatcher at `.vite-hooks/_/h` runs `.vite-hooks/` if that file exists, so +the correct way to add one here is to create the **user-hook** file: + +```bash +printf '%s\n' 'exec "$(git rev-parse --show-toplevel)/scripts/t3x/hooks/post-merge"' \ + > .vite-hooks/post-merge +chmod +x .vite-hooks/post-merge +``` + +`scripts/t3x/hooks/post-merge` is the sample body it delegates to; it backgrounds a +build-only run so `git pull` returns immediately. + +**Honestly, prefer Option 1 or 3.** The hook fires a detached build on *every* merge, so +two quick `git pull`s start two builds. They no longer corrupt each other (the script +takes a lock and the second exits immediately), but you still get a redundant queued +rebuild, and a `.vite-hooks/post-merge` file is one more thing for the daily upstream +rebase to trip over. + +### Option 3 — run the watcher in a terminal + +```bash +scripts/t3x/auto-build-desktop.sh --watch --install --interval 120 +``` + +In `--watch` mode the script re-execs itself under `caffeinate -s` so the Mac won't sleep +mid-build; the re-exec forwards every flag, so `--watch --install --dry-run` stays a dry +run. A failed build logs and the loop keeps polling (with backoff) — it never crashes +out. + +## Caveats & risks + +- **Builds take minutes.** Each poll builds whatever `HEAD` is _now_. Note this does + **not** perfectly collapse rapid commits: the SHA is sampled *before* the build and + recorded *after*, so a commit landing mid-build causes one redundant rebuild of an + already-current tree. The bias is deliberate — it can waste a build, never skip one. +- **`--install` is disruptive.** It quits the running app (`osascript quit`), replaces the + target in `/Applications`, and copies the new one in. Fine overnight; annoying + mid-session. There is no "skip if app is foregrounded" check yet. The new build is + staged alongside and swapped in, so a failed copy leaves your existing app intact. +- **Unsigned.** Quarantine-stripping is required on every install. If macOS + tightens Gatekeeper this may stop working. +- **Disk — bigger than the prune suggests.** `T3X_AUTOBUILD_KEEP_DMGS` (default 3) prunes + `*.dmg` **only**. electron-builder also writes a `.zip` of comparable size (~233 MB + next to a ~236 MB dmg), plus `.blockmap`s and `builder-debug.yml`, and **none of those + are pruned** — the zips accumulate one per version. Clear `release/` by hand + periodically, or set `T3CODE_DESKTOP_OUTPUT_DIR` somewhere you don't mind growing. +- **First build after a lockfile change** runs `pnpm install --frozen-lockfile`, + which adds time. +- **Repeated failures back off.** A failing build/install is retried with exponential + backoff (2×, 4×, … up to 32× the interval, capped at 30 min) rather than every + `INTERVAL`, so a persistent fault — an unwritable `/Applications` on a managed Mac, a + full disk, a committed type error — can't rebuild all night. +- **Only one runs at a time.** The build+install+marker sequence takes a lock in the state + dir; a second instance logs "another auto-build is already running" and skips. A lock + whose owner died is cleared automatically. + +## Out of scope (the "real" auto-update path) + +The fully hands-off alternative is to **code-sign + notarize** the build, publish +the `.dmg` to the fork's GitHub Releases, and let the app's built-in +`electron-updater` pull it (`T3CODE_DESKTOP_UPDATE_REPOSITORY=radroid/t3code`). +That removes the local watcher entirely but needs an Apple Developer signing +cert, notarization, and a macOS CI publish workflow. Deferred until the local +pipeline proves insufficient. diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh new file mode 100755 index 00000000000..35c8039c9ee --- /dev/null +++ b/scripts/t3x/auto-build-desktop.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# +# t3x auto-build & install the macOS desktop app on change. +# +# Keeps the installed desktop app current: when the fork's HEAD moves to a new +# commit, rebuild the macOS arm64 .dmg and (optionally) install it — no manual +# dmg-dragging. See docs/t3x/auto-build-runbook.md for the full runbook. +# +# The build itself is the existing `pnpm dist:desktop:dmg:arm64` — this script +# never edits scripts/build-desktop-artifact.ts (a hot upstream file). Zero +# upstream seams: everything here lives under scripts/t3x/. +# +# Usage: +# scripts/t3x/auto-build-desktop.sh # one-shot build if HEAD changed +# scripts/t3x/auto-build-desktop.sh --force # build even if HEAD unchanged +# scripts/t3x/auto-build-desktop.sh --install # build + install to /Applications +# scripts/t3x/auto-build-desktop.sh --install --relaunch +# scripts/t3x/auto-build-desktop.sh --install --dry-run # log the install steps, change nothing +# scripts/t3x/auto-build-desktop.sh --watch [--interval 60] [--install] +# scripts/t3x/auto-build-desktop.sh --print-launchd # emit a ready-to-use LaunchAgent plist +# scripts/t3x/auto-build-desktop.sh --help +# +# Exit codes (one-shot mode): +# 0 = built a fresh .dmg (and installed, if --install) +# 3 = no change since last build (no-op) +# 1 = build failed +# +# Env: +# T3X_AUTOBUILD_STATE_DIR (default: ~/.t3/userdata) +# T3CODE_DESKTOP_OUTPUT_DIR (default: /release) — where the .dmg lands +# T3X_AUTOBUILD_APP_NAME (default: derived from the .app inside the .dmg) +# T3X_AUTOBUILD_KEEP_DMGS (default: 3) — how many recent .dmgs to keep per prune +# +set -euo pipefail + +# --- resolve paths ----------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +STATE_DIR="${T3X_AUTOBUILD_STATE_DIR:-$HOME/.t3/userdata}" +LOG_DIR="$STATE_DIR/logs" +LAST_SHA_FILE="$STATE_DIR/t3x-autobuild-last-sha" +STATUS_FILE="$STATE_DIR/t3x-autobuild-status.json" +LOG_FILE="$LOG_DIR/t3x-autobuild.log" + +# Match scripts/build-desktop-artifact.ts, which does `path.resolve(repoRoot, outputDir)`: +# a RELATIVE override is repo-relative there, so resolving it against $PWD here would make +# us search/prune the caller's ./ and then report "no .dmg" for a build that succeeded. +OUTPUT_DIR="${T3CODE_DESKTOP_OUTPUT_DIR:-release}" +case "$OUTPUT_DIR" in + /*) ;; # absolute override: use as-is + *) OUTPUT_DIR="$REPO/$OUTPUT_DIR" ;; +esac +KEEP_DMGS="${T3X_AUTOBUILD_KEEP_DMGS:-3}" +# Validated because it is fed to `tail -n +$((KEEP_DMGS + 1))`: a non-numeric value makes +# the arithmetic yield 1, so `tail -n +1` prunes EVERY dmg including the one just built, +# and `$(( ))` on attacker-shaped input is a command-execution sink. +if ! [[ "$KEEP_DMGS" =~ ^[0-9]+$ ]] || [[ "$KEEP_DMGS" -lt 1 ]]; then + echo "T3X_AUTOBUILD_KEEP_DMGS must be a positive integer (got: '$KEEP_DMGS')" >&2 + exit 2 +fi +APPLICATIONS_DIR="${T3X_AUTOBUILD_APPLICATIONS_DIR:-/Applications}" + +# --- flags ------------------------------------------------------------------- +DO_INSTALL=0 +DO_RELAUNCH=0 +DO_WATCH=0 +DRY_RUN=0 +FORCE=0 +INTERVAL=60 +CAFFEINATED=0 +INSTALL_OK=0 # set only after an install actually succeeds; drives status JSON + +usage() { grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//;s/^#$//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --install) DO_INSTALL=1 ;; + --relaunch) DO_RELAUNCH=1 ;; + --watch) DO_WATCH=1 ;; + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + --interval) INTERVAL="${2:?--interval needs a value}"; shift ;; + --interval=*) INTERVAL="${1#*=}" ;; + --print-launchd) PRINT_LAUNCHD=1 ;; + --_caffeinated) CAFFEINATED=1 ;; # internal: set after re-exec under caffeinate + -h|--help) usage; exit 0 ;; + *) echo "unknown flag: $1" >&2; usage; exit 2 ;; + esac + shift +done + +# A non-numeric interval makes `sleep` fail and kills the watcher; 0 turns the poll into a +# tight loop that rebuilds continuously. Reject both up front rather than at 3am. +if ! [[ "$INTERVAL" =~ ^[0-9]+$ ]] || [[ "$INTERVAL" -lt 1 ]]; then + echo "--interval must be a positive integer (got: '$INTERVAL')" >&2 + exit 2 +fi + +mkdir -p "$STATE_DIR" "$LOG_DIR" + +# --- logging ----------------------------------------------------------------- +now_iso() { date '+%Y-%m-%dT%H:%M:%S%z'; } +log() { printf '%s %s\n' "$(now_iso)" "$*" | tee -a "$LOG_FILE" >&2; } + +# --- json status (no jq) ----------------------------------------------------- +# NOTE: `printf '%s'`, not a `<<<` here-string. A here-string appends a trailing +# newline, which sys.stdin.read() would capture into every emitted JSON value +# (e.g. "sha": "abc123\n") and break consumers that compare the SHA. +json_escape() { printf '%s' "${1-}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; } +write_status() { + # args: result sha dmg detail + local result="$1" sha="$2" dmg="$3" detail="${4:-}" + { + printf '{\n' + printf ' "result": %s,\n' "$(json_escape "$result")" + printf ' "sha": %s,\n' "$(json_escape "$sha")" + printf ' "dmgPath": %s,\n' "$(json_escape "$dmg")" + printf ' "builtAt": %s,\n' "$(json_escape "$(now_iso)")" + # Reflects the OUTCOME. Deriving this from the flags alone emitted + # {"result":"install-failed", …, "installed":true} — self-contradictory in one object. + printf ' "installed": %s,\n' "$([[ ${INSTALL_OK:-0} -eq 1 ]] && echo true || echo false)" + printf ' "detail": %s\n' "$(json_escape "$detail")" + printf '}\n' + } >"$STATUS_FILE" +} + +# --- helpers ----------------------------------------------------------------- +current_sha() { git -C "$REPO" rev-parse HEAD; } +read_last_sha() { [[ -f "$LAST_SHA_FILE" ]] && cat "$LAST_SHA_FILE" || printf ''; } + +lockfile_changed() { + # $1 = last sha ("" if unknown). True (0) when the lockfile differs or last is unknown. + local last="$1" + [[ -z "$last" ]] && return 0 + git -C "$REPO" cat-file -e "${last}^{commit}" 2>/dev/null || return 0 + git -C "$REPO" diff --name-only "$last" HEAD -- pnpm-lock.yaml | grep -q . && return 0 + return 1 +} + +# NOTE: deliberately `ls -t ` rather than `find … | xargs -0 ls -t`. On macOS +# xargs still runs the utility when its input is empty, so the find form would run a +# bare `ls -t` and return an unrelated file from the cwd when no .dmg exists. +# A non-matching glob makes `ls` fail into an empty string instead, which is what we want. +list_dmgs_newest_first() { ls -t "$OUTPUT_DIR"/*.dmg 2>/dev/null || true; } + +newest_dmg() { list_dmgs_newest_first | head -1; } + +prune_dmgs() { + # keep the newest $KEEP_DMGS .dmgs, delete the rest (best-effort) + local old + old="$(list_dmgs_newest_first | tail -n +"$((KEEP_DMGS + 1))")" + [[ -z "$old" ]] && return 0 + while IFS= read -r f; do + [[ -n "$f" ]] && { log "prune old dmg: $f"; rm -f "$f" || true; } + done <<<"$old" + # Explicit: without it the function's status is the last `rm`, and in one-shot mode + # errexit is live here — a single failing rm aborted the script with exit 1 *after* a + # fully successful build+install had already written "result":"built". + return 0 +} + +# --- mutual exclusion -------------------------------------------------------- +# Concurrency is realistic, not theoretical: hooks/post-merge nohups a detached build on +# EVERY merge, so two quick `git pull`s start two electron-builder runs writing the same +# output dir — and one can rm -rf the install target while the other is mid-copy. +# mkdir is atomic on every filesystem we care about; `flock` is not on stock macOS. +LOCK_DIR="$STATE_DIR/t3x-autobuild.lock" + +release_lock() { rm -rf "$LOCK_DIR" 2>/dev/null || true; } + +acquire_lock() { + if mkdir "$LOCK_DIR" 2>/dev/null; then + printf '%s' "$$" >"$LOCK_DIR/pid" 2>/dev/null || true + trap release_lock EXIT + return 0 + fi + # A crashed run must not wedge the watcher forever: steal the lock if its owner is gone. + local owner + owner="$(cat "$LOCK_DIR/pid" 2>/dev/null || printf '')" + if [[ -n "$owner" ]] && kill -0 "$owner" 2>/dev/null; then + return 1 + fi + log "clearing stale lock (owner pid ${owner:-unknown} is not running)" + rm -rf "$LOCK_DIR" + mkdir "$LOCK_DIR" 2>/dev/null || return 1 + printf '%s' "$$" >"$LOCK_DIR/pid" 2>/dev/null || true + trap release_lock EXIT + return 0 +} + +# --- install ----------------------------------------------------------------- +install_dmg() { + local dmg="$1" + [[ -z "$dmg" || ! -f "$dmg" ]] && { log "install: no .dmg to install ($dmg)"; return 1; } + + local mnt app appbase target + mnt="$(mktemp -d "${TMPDIR:-/tmp}/t3x-dmg.XXXXXX")" + # shellcheck disable=SC2064 + trap "hdiutil detach '$mnt' -quiet >/dev/null 2>&1 || true; rmdir '$mnt' 2>/dev/null || true" RETURN + + if [[ $DRY_RUN -eq 1 ]]; then + log "DRY-RUN install: hdiutil attach '$dmg' -> '$mnt'" + else + hdiutil attach -nobrowse -quiet "$dmg" -mountpoint "$mnt" + fi + + # In dry-run we can't read the mount, so infer the app name from env or default. + if [[ $DRY_RUN -eq 1 ]]; then + appbase="${T3X_AUTOBUILD_APP_NAME:-T3 Code}.app" + app="$mnt/$appbase" + else + app="$(find "$mnt" -maxdepth 1 -name '*.app' | head -1)" + [[ -z "$app" ]] && { log "install: no .app found in $dmg"; return 1; } + appbase="$(basename "$app")" + fi + target="$APPLICATIONS_DIR/$appbase" + + log "install: '$appbase' -> '$target'" + + # NOTE: the dry-run return must come BEFORE the quit below. `osascript quit` is a real + # side effect on the user's session, so running it here would make `--install --dry-run` + # close their running app — exactly what a dry run promises not to do. + if [[ $DRY_RUN -eq 1 ]]; then + log "DRY-RUN would: quit app '${appbase%.app}'" + log "DRY-RUN would: rm -rf '$target' && cp -R '$app' '$target'" + log "DRY-RUN would: xattr -dr com.apple.quarantine '$target' (unsigned local build)" + [[ $DO_RELAUNCH -eq 1 ]] && log "DRY-RUN would: open '$target'" + return 0 + fi + + # Best-effort quit of the running app so we can replace it. + osascript -e "quit app \"${appbase%.app}\"" >/dev/null 2>&1 || true + + # Stage beside the target, then swap. Copying straight onto the target is wrong twice: + # * BSD `cp -R src.app dst.app` copies INTO dst.app when dst.app still exists, silently + # nesting the new build inside the old one and exiting 0 — so the user keeps running + # the old app while every poll reports "no change". + # * Deleting the working app BEFORE the copy leaves nothing installed if the copy then + # fails (ENOSPC is realistic: each build writes ~470MB into release/). + # `install_dmg` is called as `install_dmg … || install_failed=1`, which disables errexit + # for its whole dynamic extent, so each step is checked explicitly. + local staged="$target.t3x-new" + rm -rf "$staged" + if ! cp -R "$app" "$staged"; then + log "install: FAILED to copy '$app' -> '$staged'" + rm -rf "$staged" + return 1 + fi + # Only now is the old app touched; a failure above leaves it intact and working. + if ! rm -rf "$target"; then + log "install: FAILED to remove existing '$target' (owned by root, or locked?)" + rm -rf "$staged" + return 1 + fi + if ! mv "$staged" "$target"; then + log "install: FAILED to move '$staged' -> '$target'" + return 1 + fi + # Unsigned local builds are quarantined by macOS; strip it so Gatekeeper allows launch. + xattr -dr com.apple.quarantine "$target" || true + log "install: replaced $target" + [[ $DO_RELAUNCH -eq 1 ]] && { log "install: relaunching $appbase"; open "$target" || true; } + return 0 +} + +# --- build ------------------------------------------------------------------- +build_once() { + local cur last + cur="$(current_sha)" + last="$(read_last_sha)" + + if [[ "$cur" == "$last" && $FORCE -eq 0 ]]; then + log "no change since last build ($cur); nothing to do" + return 3 + fi + + log "building desktop dmg for $cur (last built: ${last:-none})" + + if [[ $DRY_RUN -eq 1 ]]; then + log "DRY-RUN would: pnpm dist:desktop:dmg:arm64 (cwd $REPO)" + else + # These are checked explicitly rather than relying on `set -e`: build_once is called as + # `if ! build_once` by the watch loop, and bash disables errexit for the whole dynamic + # extent of a function whose status is being tested. Without these checks a failed + # install would fall through and the dmg would be built against stale dependencies. + if lockfile_changed "$last"; then + log "pnpm-lock.yaml changed -> pnpm install --frozen-lockfile" + if ! ( cd "$REPO" && pnpm install --frozen-lockfile ); then + write_status "build-failed" "$cur" "" "pnpm install --frozen-lockfile failed" + log "BUILD FAILED for $cur (dependency install)" + return 1 + fi + fi + log "ensuring electron runtime" + if ! ( cd "$REPO" && pnpm --filter @t3tools/desktop ensure:electron ); then + write_status "build-failed" "$cur" "" "ensure:electron failed" + log "BUILD FAILED for $cur (electron runtime)" + return 1 + fi + log "running: pnpm dist:desktop:dmg:arm64" + if ! ( cd "$REPO" && pnpm dist:desktop:dmg:arm64 ); then + write_status "build-failed" "$cur" "" "pnpm dist:desktop:dmg:arm64 failed" + log "BUILD FAILED for $cur" + return 1 + fi + fi + + local dmg + dmg="$(newest_dmg)" + if [[ $DRY_RUN -eq 0 && -z "$dmg" ]]; then + write_status "build-failed" "$cur" "" "no .dmg found under $OUTPUT_DIR" + log "BUILD FAILED: no .dmg under $OUTPUT_DIR" + return 1 + fi + if [[ $DRY_RUN -eq 1 ]]; then + # Nothing was built; this is whatever .dmg already exists (i.e. what --install would use). + log "DRY-RUN newest existing dmg: ${dmg:-}" + else + log "built dmg: $dmg" + fi + + local install_failed=0 + if [[ $DO_INSTALL -eq 1 ]]; then + if install_dmg "$dmg"; then INSTALL_OK=1; else install_failed=1; fi + fi + + if [[ $DRY_RUN -eq 1 ]]; then + return 0 + fi + + # The marker means "built AND installed, if an install was asked for". Advancing it after + # a failed install would make the next poll report "no change" while /Applications still + # holds the old app, so the failure would never be retried. + if [[ $install_failed -eq 1 ]]; then + write_status "install-failed" "$cur" "$dmg" "dmg built but install failed; marker not advanced so it retries" + log "INSTALL FAILED for $cur (dmg built at $dmg); will retry on next run" + prune_dmgs + return 1 + fi + + write_status "built" "$cur" "$dmg" "ok" + printf '%s' "$cur" >"$LAST_SHA_FILE" # advance marker only on a fully successful run + prune_dmgs + return 0 +} + +# --- launchd plist emitter --------------------------------------------------- +# A path containing & < > (legal on macOS) would otherwise emit a malformed plist that +# launchctl silently refuses to load. +xml_escape() { printf '%s' "${1-}" | sed 's/&/\&/g; s//\>/g'; } + +print_launchd() { + local label="dev.t3x.autobuild" + local x_script x_repo x_log + x_script="$(xml_escape "$SCRIPT_DIR/auto-build-desktop.sh")" + x_repo="$(xml_escape "$REPO")" + x_log="$(xml_escape "$LOG_FILE")" + # These MUST be emitted. The plist's own StandardOutPath/marker paths are derived from + # these vars, so without them a plist generated from a shell that overrode + # T3X_AUTOBUILD_APPLICATIONS_DIR (e.g. while testing against a temp dir) produces an + # agent that installs into the REAL /Applications, and one that overrode STATE_DIR + # produces an agent logging to the custom path while writing its marker to the default. + local x_state x_apps x_out + x_state="$(xml_escape "$STATE_DIR")" + x_apps="$(xml_escape "$APPLICATIONS_DIR")" + x_out="$(xml_escape "$OUTPUT_DIR")" + # blank-line filter: the optional-flag command substitutions below leave empty + # lines behind when their flag is off. + cat < + + + + Label${label} + ProgramArguments + + /bin/bash + ${x_script} + --watch + --interval + ${INTERVAL} +$( [[ $DO_INSTALL -eq 1 ]] && printf ' --install' || true ) +$( [[ $DO_RELAUNCH -eq 1 ]] && printf ' --relaunch' || true ) + + RunAtLoad + WorkingDirectory${x_repo} + StandardOutPath${x_log} + StandardErrorPath${x_log} + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + T3X_AUTOBUILD_STATE_DIR${x_state} + T3X_AUTOBUILD_APPLICATIONS_DIR${x_apps} + T3CODE_DESKTOP_OUTPUT_DIR${x_out} + T3X_AUTOBUILD_KEEP_DMGS${KEEP_DMGS} + + + +PLIST +} + +# --- watch loop -------------------------------------------------------------- +watch_loop() { + # Keep the Mac awake during overnight builds. Re-exec under caffeinate once. + if [[ $CAFFEINATED -eq 0 ]] && command -v caffeinate >/dev/null 2>&1; then + # Rebuild the FULL flag set, not a subset. Two bugs live here if you get it wrong: + # + # * Dropping --dry-run/--force makes the re-exec'd process run for real, so + # `--watch --install --dry-run` — the exact command someone runs to preview the + # watcher — would quit the app and rm -rf /Applications/. + # * Seeding the array empty and expanding "${arr[@]}" aborts under `set -u` on bash + # 3.2 (what macOS ships), so `--watch` with no other flag died before its first + # poll. Seeding it with --watch keeps it non-empty, which sidesteps that entirely. + local args=(--watch --interval "$INTERVAL" --_caffeinated) + [[ $DO_INSTALL -eq 1 ]] && args+=(--install) + [[ $DO_RELAUNCH -eq 1 ]] && args+=(--relaunch) + [[ $DRY_RUN -eq 1 ]] && args+=(--dry-run) + [[ $FORCE -eq 1 ]] && args+=(--force) + log "re-exec under caffeinate -s" + exec caffeinate -s "$0" "${args[@]}" + fi + log "watch: polling HEAD every ${INTERVAL}s (install=$DO_INSTALL)" + local fails=0 rc delay + while true; do + rc=0 + build_once_locked || rc=$? + # 0 = built, 3 = nothing to do. Anything else is a real failure. + if [[ $rc -eq 0 || $rc -eq 3 ]]; then + fails=0 + else + fails=$((fails + 1)) + fi + + # Back off on repeated failure. Without this a persistent fault (unwritable + # /Applications on a managed Mac, a full disk, a committed type error) means a full + # multi-minute rebuild every INTERVAL seconds, all night, forever. + delay="$INTERVAL" + if (( fails > 0 )); then + local mult=$(( 1 << (fails > 5 ? 5 : fails) )) # 2x,4x,8x,16x,32x then flat + delay=$(( INTERVAL * mult )) + (( delay > 1800 )) && delay=1800 # never wait more than 30 min + log "watch: ${fails} consecutive failure(s); next attempt in ${delay}s" + fi + # `|| true`: a sleep interrupted by a signal must not kill the watcher under errexit. + sleep "$delay" || true + done +} + +# Serialises the whole build+install+marker sequence against another instance. +build_once_locked() { + if ! acquire_lock; then + log "another auto-build is already running; skipping this tick" + return 3 + fi + local rc=0 + build_once || rc=$? + release_lock + trap - EXIT + return "$rc" +} + +# --- main -------------------------------------------------------------------- +if [[ "${PRINT_LAUNCHD:-0}" -eq 1 ]]; then + print_launchd + exit 0 +fi + +if [[ $DO_WATCH -eq 1 ]]; then + watch_loop +else + build_once_locked +fi diff --git a/scripts/t3x/hooks/post-merge b/scripts/t3x/hooks/post-merge new file mode 100755 index 00000000000..6b531467216 --- /dev/null +++ b/scripts/t3x/hooks/post-merge @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# t3x sample git hook — kick off a desktop rebuild after HEAD moves. +# +# NOT installed automatically, and the two obvious ways to install it are BOTH wrong +# in this repo, which already sets `core.hooksPath = .vite-hooks/_`: +# +# git config core.hooksPath scripts/t3x/hooks # DON'T: disables every existing hook +# ln -sf … .git/hooks/post-merge # DON'T: never fires; .git/hooks is +# # ignored once hooksPath is set +# +# The dispatcher `.vite-hooks/_/h` runs `.vite-hooks/` when present, so delegate +# to this file from there instead: +# +# printf '%s\n' 'exec "$(git rev-parse --show-toplevel)/scripts/t3x/hooks/post-merge"' \ +# > .vite-hooks/post-merge && chmod +x .vite-hooks/post-merge +# +# See docs/t3x/auto-build-runbook.md — the watcher or LaunchAgent is usually a better fit. +# +# `post-merge` fires after `git pull` / `git merge` updates the working tree. +# +# It launches the build detached so the git command returns immediately, and +# builds only (no --install) — add --install here if you want auto-install on +# every merge (see docs/t3x/auto-build-runbook.md for the caveats). +set -euo pipefail +REPO="$(git rev-parse --show-toplevel)" +nohup "$REPO/scripts/t3x/auto-build-desktop.sh" >/dev/null 2>&1 & +exit 0