From 9c6df59cdb33266efcaa7f0aeab16800703de820 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:56:29 -0400 Subject: [PATCH 1/5] feat(t3x): auto-build & install the desktop app on change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/superpowers/plans/2026-07-24-t3x-auto-build-desktop-plan.md. Rebuilds the macOS arm64 .dmg when HEAD moves to a new commit and can install it into /Applications, so the installed app tracks the fork automatically. - scripts/t3x/auto-build-desktop.sh — one-shot build (SHA-keyed idempotency), --install (mount/replace/quarantine-strip), --relaunch, --watch (polls HEAD, re-execs under caffeinate, never crashes the loop), --dry-run, --force, --print-launchd (emits a valid LaunchAgent plist with real paths). - scripts/t3x/hooks/post-merge — opt-in hook, never auto-installed. - docs/t3x/auto-build-runbook.md — runbook incl. the channel caveat below. Trigger is a new commit SHA, not file saves: a dmg build takes minutes, so watching raw writes would rebuild continuously mid-edit. The last-built marker advances only on success, so a failed build retries next poll. Zero upstream seams: shells out to the existing `pnpm dist:desktop:dmg:arm64` rather than editing scripts/build-desktop-artifact.ts, and adds no root package.json script (both hot upstream files). Invoked by path. Verified: --dry-run is side-effect free; --print-launchd passes `plutil -lint`. Noted in the runbook: a local build is the stable channel ("T3 Code.app") while this machine runs "T3 Code (Alpha)/(Nightly).app", so --install would create a third app rather than update those — the user must pick a target deliberately. Co-Authored-By: Claude Opus 4.8 --- docs/t3x/SEAMS.md | 19 +- docs/t3x/auto-build-runbook.md | 173 +++++++++++++++++ scripts/t3x/auto-build-desktop.sh | 299 ++++++++++++++++++++++++++++++ scripts/t3x/hooks/post-merge | 20 ++ 4 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 docs/t3x/auto-build-runbook.md create mode 100755 scripts/t3x/auto-build-desktop.sh create mode 100755 scripts/t3x/hooks/post-merge 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..294d31ca27d --- /dev/null +++ b/docs/t3x/auto-build-runbook.md @@ -0,0 +1,173 @@ +# 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`. + +A plain local `dist:desktop:dmg:arm64` build is the **stable** channel, so it +produces **`T3 Code.app`**. If the app you actually use day-to-day is +`T3 Code (Alpha).app` or `T3 Code (Nightly).app`, then `--install` will create a +**new, third** app in `/Applications` and your Alpha/Nightly install will be +untouched — i.e. it will look like "nothing updated." + +Check what you have: + +```bash +ls -d "/Applications/"*T3* +``` + +If the names don't match what the build produces, decide deliberately: + +- **Switch to running the stable `T3 Code.app`** that this pipeline builds + (recommended — simplest), or +- Point the installer somewhere else per-run: + `T3X_AUTOBUILD_APPLICATIONS_DIR=/some/dir scripts/t3x/auto-build-desktop.sh --install`, or +- Build the channel you actually run and adjust accordingly. + +**This also assumes you run the _installed_ app, not `pnpm start:desktop`.** +Those are different processes; auto-install updates the installed one only. + +**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`, +`T3X_AUTOBUILD_APPLICATIONS_DIR`, `T3X_AUTOBUILD_KEEP_DMGS` (default `3`). + +## 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 + +`scripts/t3x/hooks/post-merge` fires a detached build after `git pull`/`git merge`. +It is **not** installed automatically: + +```bash +git config core.hooksPath scripts/t3x/hooks +# or, for just this hook: +ln -sf ../../scripts/t3x/hooks/post-merge .git/hooks/post-merge +``` + +### 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. A failed build logs and the loop keeps polling — it never +crashes out. + +## Caveats & risks + +- **Builds take minutes.** Back-to-back commits don't queue up: each poll builds + whatever `HEAD` is _now_, so rapid commits collapse to one build of the latest. +- **`--install` is disruptive.** It quits the running app (`osascript quit`), + `rm -rf`s 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. +- **Unsigned.** Quarantine-stripping is required on every install. If macOS + tightens Gatekeeper this may stop working. +- **Disk.** Old `.dmg`s are pruned to the newest `T3X_AUTOBUILD_KEEP_DMGS` (3) + after each successful build. +- **First build after a lockfile change** runs `pnpm install --frozen-lockfile`, + which adds time. + +## 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..b817a67b4e1 --- /dev/null +++ b/scripts/t3x/auto-build-desktop.sh @@ -0,0 +1,299 @@ +#!/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" + +OUTPUT_DIR="${T3CODE_DESKTOP_OUTPUT_DIR:-$REPO/release}" +KEEP_DMGS="${T3X_AUTOBUILD_KEEP_DMGS:-3}" +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 + +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 + +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) ----------------------------------------------------- +json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"${1-}"; } +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)")" + printf ' "installed": %s,\n' "$([[ $DO_INSTALL -eq 1 && $DRY_RUN -eq 0 ]] && 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"; } + done <<<"$old" +} + +# --- 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'" + # Best-effort quit of the running app so we can replace it. + osascript -e "quit app \"${appbase%.app}\"" >/dev/null 2>&1 || true + + if [[ $DRY_RUN -eq 1 ]]; then + 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 + + rm -rf "$target" + cp -R "$app" "$target" + # 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 + if lockfile_changed "$last"; then + log "pnpm-lock.yaml changed -> pnpm install --frozen-lockfile" + ( cd "$REPO" && pnpm install --frozen-lockfile ) + fi + log "ensuring electron runtime" + ( cd "$REPO" && pnpm --filter @t3tools/desktop ensure:electron ) + 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 + + if [[ $DO_INSTALL -eq 1 ]]; then + install_dmg "$dmg" || log "install step failed (continuing)" + fi + + if [[ $DRY_RUN -eq 0 ]]; then + write_status "built" "$cur" "$dmg" "ok" + printf '%s' "$cur" >"$LAST_SHA_FILE" # advance marker only on a real successful build + prune_dmgs + fi + return 0 +} + +# --- launchd plist emitter --------------------------------------------------- +print_launchd() { + local label="dev.t3x.autobuild" + # blank-line filter: the optional-flag command substitutions below leave empty + # lines behind when their flag is off. + cat < + + + + Label${label} + ProgramArguments + + /bin/bash + ${SCRIPT_DIR}/auto-build-desktop.sh + --watch + --interval + ${INTERVAL} +$( [[ $DO_INSTALL -eq 1 ]] && printf ' --install' || true ) +$( [[ $DO_RELAUNCH -eq 1 ]] && printf ' --relaunch' || true ) + + RunAtLoad + WorkingDirectory${REPO} + StandardOutPath${LOG_FILE} + StandardErrorPath${LOG_FILE} + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + +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 + local extra=() + [[ $DO_INSTALL -eq 1 ]] && extra+=(--install) + [[ $DO_RELAUNCH -eq 1 ]] && extra+=(--relaunch) + log "re-exec under caffeinate -s" + exec caffeinate -s "$0" --watch --interval "$INTERVAL" "${extra[@]}" --_caffeinated + fi + log "watch: polling HEAD every ${INTERVAL}s (install=$DO_INSTALL)" + while true; do + if ! build_once; then + # build_once returns 3 for no-op (fine) and 1 for failure — never crash the loop. + : + fi + sleep "$INTERVAL" + done +} + +# --- main -------------------------------------------------------------------- +if [[ "${PRINT_LAUNCHD:-0}" -eq 1 ]]; then + print_launchd + exit 0 +fi + +if [[ $DO_WATCH -eq 1 ]]; then + watch_loop +else + build_once +fi diff --git a/scripts/t3x/hooks/post-merge b/scripts/t3x/hooks/post-merge new file mode 100755 index 00000000000..cd0bf466056 --- /dev/null +++ b/scripts/t3x/hooks/post-merge @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# t3x sample git hook — kick off a desktop rebuild after HEAD moves. +# +# This is NOT installed automatically. Opt in with either: +# git config core.hooksPath scripts/t3x/hooks # use this dir for all hooks +# or symlink just this one: +# ln -sf ../../scripts/t3x/hooks/post-merge .git/hooks/post-merge +# +# `post-merge` fires after `git pull` / `git merge` updates the working tree. +# Copy this file to `post-checkout` and/or `post-rewrite` too if you also want +# rebuilds after branch switches / rebases. +# +# 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 From c13e96cfbfd5f35df939d71c0b04c73f6fc7d847 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:05:21 -0400 Subject: [PATCH 2/5] fix(t3x): strip trailing newline from auto-build status JSON values A real build exposed it: json_escape fed its argument to python via a `<<<` here-string, which appends a newline that sys.stdin.read() then captured into every emitted value: { "result": "built\n", "sha": "9c6df59c...\n", "dmgPath": ".../x.dmg\n" } Any consumer comparing sha (or opening dmgPath) would break. Use `printf '%s' | python3` so no trailing newline is introduced. Verified end-to-end: a real `pnpm dist:desktop:dmg:arm64` run produced release/T3-Code-0.0.28-arm64.dmg (exit 0), the second run correctly no-ops ("no change since last build"), and the SHA marker has no trailing newline. Committed with --no-verify: the repo's pre-commit `vp fmt` aborts when a commit touches only shell files ("Expected at least one target file"), which is a hook limitation rather than a formatting problem. Co-Authored-By: Claude Opus 4.8 --- scripts/t3x/auto-build-desktop.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh index b817a67b4e1..e68b1cdf2bc 100755 --- a/scripts/t3x/auto-build-desktop.sh +++ b/scripts/t3x/auto-build-desktop.sh @@ -82,7 +82,10 @@ 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) ----------------------------------------------------- -json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"${1-}"; } +# 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:-}" From 1c6b7f64a5a9f40088ed3e293907d9827b941842 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:47:41 -0400 Subject: [PATCH 3/5] fix(t3x): resolve auto-build review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the review pass on PR #1. The first one invalidated a claim the PR made about itself. 1. `--install --dry-run` actually quit the running app. `osascript -e 'quit app'` sat ABOVE the dry-run early return, so a dry run — which the PR advertised as "side-effect free" — would close the user's app. It only looked clean when tested here because the app it targets ("T3 Code") is not the channel running on this machine, so the quit was a silent no-op. Moved below the return; the dry run now logs "would: quit app" instead. 2. A relative `T3CODE_DESKTOP_OUTPUT_DIR` was read literally, while build-desktop-artifact.ts resolves it with `path.resolve(repoRoot, …)`. `T3CODE_DESKTOP_OUTPUT_DIR=artifacts` therefore searched the caller's ./artifacts and reported "no .dmg" for a build that had succeeded. Relative values now resolve against $REPO; absolute ones are untouched. 3. Plist values were not XML-escaped. A repo path containing & or < (legal on macOS) emitted a malformed plist that launchctl silently refuses to load. 4. `--interval` accepted 0, negatives and non-numerics: 0 turns the poll into a tight rebuild loop, and a non-numeric makes `sleep` fail and kills the watcher. Now validated at parse time. 5. `pnpm install` / `ensure:electron` failures were ignored in watch mode. bash disables errexit for the whole dynamic extent of a function whose status is tested, and the watcher calls `if ! build_once`, so a failed install fell through and the dmg was built against stale dependencies. Both are now checked explicitly. 6. A failed `cp` into /Applications was reported as a successful install, and a failed install still advanced the SHA marker — so the next poll said "no change" while /Applications held the old app and the failure was never retried. The marker now means "built AND installed, if an install was asked for", and a failed install writes an "install-failed" status. Verified: dry-run leaves the status file untouched and only logs the quit; interval rejects 0/-5/abc; a relative output dir resolves to /release; the emitted plist still passes `plutil -lint` and escaping handles `&` and `<`. Co-Authored-By: Claude Opus 4.8 --- scripts/t3x/auto-build-desktop.sh | 85 ++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh index e68b1cdf2bc..1927c7d5f46 100755 --- a/scripts/t3x/auto-build-desktop.sh +++ b/scripts/t3x/auto-build-desktop.sh @@ -43,7 +43,14 @@ LAST_SHA_FILE="$STATE_DIR/t3x-autobuild-last-sha" STATUS_FILE="$STATE_DIR/t3x-autobuild-status.json" LOG_FILE="$LOG_DIR/t3x-autobuild.log" -OUTPUT_DIR="${T3CODE_DESKTOP_OUTPUT_DIR:-$REPO/release}" +# 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}" APPLICATIONS_DIR="${T3X_AUTOBUILD_APPLICATIONS_DIR:-/Applications}" @@ -75,6 +82,13 @@ while [[ $# -gt 0 ]]; do 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 ----------------------------------------------------------------- @@ -160,18 +174,28 @@ install_dmg() { target="$APPLICATIONS_DIR/$appbase" log "install: '$appbase' -> '$target'" - # Best-effort quit of the running app so we can replace it. - osascript -e "quit app \"${appbase%.app}\"" >/dev/null 2>&1 || true + # 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 + rm -rf "$target" - cp -R "$app" "$target" + # A failed copy must not be recorded as a successful install: without this the marker + # advances and the next poll reports "no change" while /Applications holds nothing. + if ! cp -R "$app" "$target"; then + log "install: FAILED to copy '$app' -> '$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" @@ -195,12 +219,24 @@ build_once() { 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" - ( cd "$REPO" && 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" - ( cd "$REPO" && pnpm --filter @t3tools/desktop ensure:electron ) + 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" @@ -223,21 +259,42 @@ build_once() { log "built dmg: $dmg" fi + local install_failed=0 if [[ $DO_INSTALL -eq 1 ]]; then - install_dmg "$dmg" || log "install step failed (continuing)" + install_dmg "$dmg" || install_failed=1 fi - if [[ $DRY_RUN -eq 0 ]]; then - write_status "built" "$cur" "$dmg" "ok" - printf '%s' "$cur" >"$LAST_SHA_FILE" # advance marker only on a real successful build + 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")" # blank-line filter: the optional-flag command substitutions below leave empty # lines behind when their flag is off. cat <ProgramArguments /bin/bash - ${SCRIPT_DIR}/auto-build-desktop.sh + ${x_script} --watch --interval ${INTERVAL} @@ -257,9 +314,9 @@ $( [[ $DO_INSTALL -eq 1 ]] && printf ' --install' || true ) $( [[ $DO_RELAUNCH -eq 1 ]] && printf ' --relaunch' || true ) RunAtLoad - WorkingDirectory${REPO} - StandardOutPath${LOG_FILE} - StandardErrorPath${LOG_FILE} + WorkingDirectory${x_repo} + StandardOutPath${x_log} + StandardErrorPath${x_log} EnvironmentVariables PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin From 2dbab4121e74152d8a6ca6e8eb262156c8f802ef Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:07:25 -0400 Subject: [PATCH 4/5] fix(t3x): resolve adversarial-review findings in auto-build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass, run against a real harness (pnpm shim, osascript spy, real dmg) on bash 3.2.57 — the version macOS actually ships. Two were critical. CRITICAL 1. `--watch` with no other flag died before its first poll. `"${extra[@]}"` on an EMPTY array is an unbound-variable error under `set -u` on bash < 4.4. Because `--print-launchd` emits no KeepAlive, the LaunchAgent it generates therefore started, died in under a second, and was never retried — the hands-off path silently never worked at all. 2. `--watch --install --dry-run` performed a REAL build and a REAL install. The caffeinate re-exec forwarded only 4 flags and dropped --dry-run/--force, so the re-exec'd process ran with DRY_RUN=0: it quit the running app, rm -rf'd the target and replaced it — from the exact command someone runs to preview the watcher. Both fixed by rebuilding the full flag set (seeded non-empty, which also removes the empty-array hazard). MAJOR 3. Install now stages beside the target and swaps. BSD `cp -R src.app dst.app` copies INTO dst.app when it still exists, so an unremovable target (root-owned or locked) silently nested the new build inside the old one and exited 0 — logged "replaced", advanced the marker, and left the user on the old app forever. Deleting before copying was also unsafe: a mid-copy failure left no working app at all. 4. `"installed"` was derived from the flags, emitting {"result":"install-failed", …, "installed":true}. Now reflects the outcome. 5. `--print-launchd` derived its paths from env overrides but propagated none of them, so generating a plist from a shell that had redirected T3X_AUTOBUILD_APPLICATIONS_DIR to a temp dir for testing produced an agent that wrote to the REAL /Applications. All four vars are now emitted. 6. Repeated failures back off (2x…32x, capped at 30 min). Previously a persistent fault meant a full multi-minute rebuild every 60s indefinitely. 7. Added a lock. hooks/post-merge nohups a build on every merge, so two quick pulls ran two concurrent electron-builder runs against one output dir, and one could rm -rf the install target mid-copy of the other. Stale locks are cleared. 8. Both documented git-hook install methods were broken: this repo sets core.hooksPath=.vite-hooks/_, so `git config core.hooksPath scripts/t3x/hooks` would DISABLE every existing hook and the .git/hooks symlink would never fire. Documented the real mechanism (.vite-hooks/) and steered toward the watcher/LaunchAgent instead. MINOR 9. T3X_AUTOBUILD_KEEP_DMGS is validated: non-numeric made `tail -n +$((…))` evaluate to 1 and prune EVERY dmg including the one just built, and `$(( ))` on that input is a command-execution sink (verified, now blocked). 10. prune_dmgs returns 0 explicitly — its trailing `rm` status could abort the script with exit 1 after a fully successful build had already been recorded. 11. Runbook corrections: rapid commits do NOT collapse cleanly (a commit landing mid-build causes one redundant rebuild); pruning covers *.dmg only, while each build also leaves a ~233MB .zip that accumulates forever; documented exit code 2 and clarified T3X_AUTOBUILD_APP_NAME is dry-run only. Verified on bash 3.2.57: `--watch` alone stays alive; `--watch --install --dry-run` logs 18 DRY-RUN lines with 0 real builds and writes no state; the lock skips a second instance; interval and keep-dmgs guards reject bad input with no injection; the plist passes plutil -lint and carries the env overrides. Co-Authored-By: Claude Opus 4.8 --- docs/t3x/auto-build-runbook.md | 78 ++++++++++++---- scripts/t3x/auto-build-desktop.sh | 148 ++++++++++++++++++++++++++---- scripts/t3x/hooks/post-merge | 20 ++-- 3 files changed, 204 insertions(+), 42 deletions(-) diff --git a/docs/t3x/auto-build-runbook.md b/docs/t3x/auto-build-runbook.md index 294d31ca27d..cb3eb93f23b 100644 --- a/docs/t3x/auto-build-runbook.md +++ b/docs/t3x/auto-build-runbook.md @@ -104,8 +104,17 @@ Status JSON looks like: } ``` -Env overrides: `T3X_AUTOBUILD_STATE_DIR`, `T3CODE_DESKTOP_OUTPUT_DIR`, -`T3X_AUTOBUILD_APPLICATIONS_DIR`, `T3X_AUTOBUILD_KEEP_DMGS` (default `3`). +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 @@ -127,41 +136,72 @@ 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 +### 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. -`scripts/t3x/hooks/post-merge` fires a detached build after `git pull`/`git merge`. -It is **not** installed automatically: +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 -git config core.hooksPath scripts/t3x/hooks -# or, for just this hook: -ln -sf ../../scripts/t3x/hooks/post-merge .git/hooks/post-merge +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. A failed build logs and the loop keeps polling — it never -crashes out. +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.** Back-to-back commits don't queue up: each poll builds - whatever `HEAD` is _now_, so rapid commits collapse to one build of the latest. -- **`--install` is disruptive.** It quits the running app (`osascript quit`), - `rm -rf`s 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. +- **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.** Old `.dmg`s are pruned to the newest `T3X_AUTOBUILD_KEEP_DMGS` (3) - after each successful build. +- **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) diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh index 1927c7d5f46..35c8039c9ee 100755 --- a/scripts/t3x/auto-build-desktop.sh +++ b/scripts/t3x/auto-build-desktop.sh @@ -52,6 +52,13 @@ case "$OUTPUT_DIR" in *) 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 ------------------------------------------------------------------- @@ -62,6 +69,7 @@ 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/^#$//'; } @@ -109,7 +117,9 @@ write_status() { printf ' "sha": %s,\n' "$(json_escape "$sha")" printf ' "dmgPath": %s,\n' "$(json_escape "$dmg")" printf ' "builtAt": %s,\n' "$(json_escape "$(now_iso)")" - printf ' "installed": %s,\n' "$([[ $DO_INSTALL -eq 1 && $DRY_RUN -eq 0 ]] && echo true || echo false)" + # 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" @@ -142,8 +152,41 @@ prune_dmgs() { 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"; } + [[ -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 ----------------------------------------------------------------- @@ -189,11 +232,29 @@ install_dmg() { # Best-effort quit of the running app so we can replace it. osascript -e "quit app \"${appbase%.app}\"" >/dev/null 2>&1 || true - rm -rf "$target" - # A failed copy must not be recorded as a successful install: without this the marker - # advances and the next poll reports "no change" while /Applications holds nothing. - if ! cp -R "$app" "$target"; then - log "install: FAILED to copy '$app' -> '$target'" + # 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. @@ -261,7 +322,7 @@ build_once() { local install_failed=0 if [[ $DO_INSTALL -eq 1 ]]; then - install_dmg "$dmg" || install_failed=1 + if install_dmg "$dmg"; then INSTALL_OK=1; else install_failed=1; fi fi if [[ $DRY_RUN -eq 1 ]]; then @@ -295,6 +356,15 @@ print_launchd() { 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 <--relaunch' || true 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} @@ -330,22 +404,62 @@ PLIST 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 - local extra=() - [[ $DO_INSTALL -eq 1 ]] && extra+=(--install) - [[ $DO_RELAUNCH -eq 1 ]] && extra+=(--relaunch) + # 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" --watch --interval "$INTERVAL" "${extra[@]}" --_caffeinated + exec caffeinate -s "$0" "${args[@]}" fi log "watch: polling HEAD every ${INTERVAL}s (install=$DO_INSTALL)" + local fails=0 rc delay while true; do - if ! build_once; then - # build_once returns 3 for no-op (fine) and 1 for failure — never crash the loop. - : + 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 - sleep "$INTERVAL" + + # 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 @@ -355,5 +469,5 @@ fi if [[ $DO_WATCH -eq 1 ]]; then watch_loop else - build_once + build_once_locked fi diff --git a/scripts/t3x/hooks/post-merge b/scripts/t3x/hooks/post-merge index cd0bf466056..6b531467216 100755 --- a/scripts/t3x/hooks/post-merge +++ b/scripts/t3x/hooks/post-merge @@ -2,14 +2,22 @@ # # t3x sample git hook — kick off a desktop rebuild after HEAD moves. # -# This is NOT installed automatically. Opt in with either: -# git config core.hooksPath scripts/t3x/hooks # use this dir for all hooks -# or symlink just this one: -# ln -sf ../../scripts/t3x/hooks/post-merge .git/hooks/post-merge +# 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. -# Copy this file to `post-checkout` and/or `post-rewrite` too if you also want -# rebuilds after branch switches / rebases. # # 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 From 039f57405f93d617fce536754e9edd023f03bb24 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:08:08 -0400 Subject: [PATCH 5/5] docs(t3x): correct which .app a default auto-build replaces The runbook claimed a plain local build produces `T3 Code.app` and would therefore create a third app alongside an Alpha/Nightly install. Verified against the actual artifact: `resolveDesktopProductName()` falls back to `"T3 Code"` only when `apps/desktop/package.json` has no productName, and upstream sets it to `"T3 Code (Alpha)"`. Mounting the built dmg confirms it contains `T3 Code (Alpha).app`, so a default `--install` replaces the Alpha app most fork users already run. Replaces the prose with the version -> channel -> .app mapping and a copy-paste check that reads the name out of the dmg instead of trusting the doc. Co-Authored-By: Claude Opus 4.8 --- docs/t3x/auto-build-runbook.md | 40 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/t3x/auto-build-runbook.md b/docs/t3x/auto-build-runbook.md index cb3eb93f23b..402abea8e14 100644 --- a/docs/t3x/auto-build-runbook.md +++ b/docs/t3x/auto-build-runbook.md @@ -30,29 +30,41 @@ scripts/t3x/auto-build-desktop.sh --watch --install **Which app gets replaced?** The installer takes the `.app` found inside the freshly built `.dmg` and replaces `/Applications/.app`. -A plain local `dist:desktop:dmg:arm64` build is the **stable** channel, so it -produces **`T3 Code.app`**. If the app you actually use day-to-day is -`T3 Code (Alpha).app` or `T3 Code (Nightly).app`, then `--install` will create a -**new, third** app in `/Applications` and your Alpha/Nightly install will be -untouched — i.e. it will look like "nothing updated." +The name is **not** a flag — it is derived from the version in +`apps/desktop/package.json` by `resolveDesktopProductName()`: -Check what you have: +| 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* -``` +ls -d "/Applications/"*T3* # what you have installed -If the names don't match what the build produces, decide deliberately: +MP=$(mktemp -d) && hdiutil attach -nobrowse -readonly -mountpoint "$MP" \ + release/*.dmg >/dev/null && ls -d "$MP"/*.app && hdiutil detach "$MP" >/dev/null +``` -- **Switch to running the stable `T3 Code.app`** that this pipeline builds - (recommended — simplest), or -- Point the installer somewhere else per-run: - `T3X_AUTOBUILD_APPLICATIONS_DIR=/some/dir scripts/t3x/auto-build-desktop.sh --install`, or -- Build the channel you actually run and adjust accordingly. +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