From 586977276717da23b8c52efeced4ff4ff8216fa7 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:13:51 +0200 Subject: [PATCH] feat(tooling): opt-in phone-testing tunnel for /test-pr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of reDeploy's phone-testing tooling into the template. Adds an opt-in --phone mode to /test-pr that prints humanTest.launchPhone (start the app + a public cloudflared quick tunnel) instead of humanTest.launch, so a UI can be exercised from a real phone browser. - prepare-pr.sh: --phone / PHONE=1 flag prints launchPhone + a caveat block; default path unchanged. Also symlinks the main checkout's .env into the detached worktree when present (generic, apps that read a repo-root .env at runtime). - test-pr.md: documents --phone and its public-exposure caveats; preserves the ${CLAUDE_PLUGIN_ROOT:-.claude} path convention. - gates.json: adds a documented humanTest block (prepare/launch/ launchPhone/worktreeDir) — the launchPhone key was previously read by the script but undocumented here. - docs/USAGE.md: 'Testing on a phone' section + launchPhone example. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0193Wc7aqPQvNqVZjcndBaf6 --- .claude/commands/test-pr.md | 37 ++++++++++++++++++---- .claude/gates.json | 8 +++++ .claude/scripts/prepare-pr.sh | 58 ++++++++++++++++++++++++++++++++--- docs/USAGE.md | 15 +++++++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/.claude/commands/test-pr.md b/.claude/commands/test-pr.md index f01d7a4..d0efacd 100644 --- a/.claude/commands/test-pr.md +++ b/.claude/commands/test-pr.md @@ -1,15 +1,15 @@ --- description: Prepare an open PR for local human testing — checks out its branch into an isolated, ready-to-run worktree (deps built) and hands you the launch command. -argument-hint: +argument-hint: [--phone] --- You are preparing an OPEN pull request for the user to manually test locally, WITHOUT disturbing their main -working tree. The PR number is: **$ARGUMENTS** +working tree. The arguments are: **$ARGUMENTS** Do this: 1. Run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/prepare-pr.sh $ARGUMENTS`. It resolves the PR's head branch, fetches it, creates (or - refreshes) a **detached** worktree at `/pr-$ARGUMENTS` (default `.worktrees/pr-`), + refreshes) a **detached** worktree at `/pr-` (default `.worktrees/pr-`), and runs the project's `humanTest.prepare` command (install + build) inside it. The script is idempotent — re-running it on the same PR just fast-forwards the worktree to the latest pushed commit and rebuilds. @@ -26,9 +26,34 @@ Do this: 4. Offer (do not assume) to start the launch command for them in the background if they'd rather you drive it. +## Testing on a phone (opt-in): `--phone` + +`/test-pr --phone` (equivalently: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/prepare-pr.sh --phone`, +or set `PHONE=1` in the environment) does everything the default path does, but prints `humanTest.launchPhone` +instead of `humanTest.launch`. That command is expected to start the same dev server(s) **and** open a public +tunnel (e.g. a `cloudflared` quick tunnel) to the already-running app, so a phone (or anyone with the link) can +reach it over the internet. Nothing about the app changes — it's the same dev server, just tunneled. + +Before telling the user to run the printed command, make sure they see all four caveats: +1. **Public** — the printed tunnel URL (e.g. `https://.trycloudflare.com`) works for anyone who has it + while the tunnel is up, not just the phone it's meant for. +2. **Exposes the whole app** — the tunnel forwards the dev server, including any backend/API it proxies. A visitor + to the URL can do anything the app can, using whatever credentials/keys are configured in the worktree's `.env`. +3. **Ephemeral** — the tunnel and the dev server(s) are typically tied together (backgrounded servers + a trap); + killing the command (Ctrl-C) tears down everything at once. There is no persistence across runs. +4. **Attended use only** — don't leave it running unattended; treat the tunnel URL like a temporary, + unauthenticated view of the app for the duration it's up. + +As with step 4 above, offer (do not assume) to start the launch command in the background for the user; if you do +start `humanTest.launchPhone`, watch its output for the tunnel URL and surface that URL clearly once it appears, +so it's easy to open on the phone. + +If `humanTest.launchPhone` isn't configured in `.claude/gates.json`, the script says so and falls back to printing +the normal `humanTest.launch` command — report that to the user rather than treating it as a failure. + Notes: - This never edits the PR and never touches `main` — the worktree is an isolated, detached checkout, so it can coexist with the same branch checked out elsewhere (e.g. an agent worktree). -- The prepare/launch/worktree-dir commands are read from `.claude/gates.json` → `humanTest`, so this command is - project-agnostic. If `humanTest` is absent, the script still makes the worktree and tells the user to build/run - manually. +- The prepare/launch/launchPhone/worktree-dir commands are read from `.claude/gates.json` → `humanTest`, so this + command is project-agnostic. If `humanTest` is absent, the script still makes the worktree and tells the user to + build/run manually. diff --git a/.claude/gates.json b/.claude/gates.json index cb04b16..58e1ab4 100644 --- a/.claude/gates.json +++ b/.claude/gates.json @@ -36,6 +36,14 @@ "teardown": "" }, + "humanTest": { + "_note": "Optional support for the /test-pr command (.claude/scripts/prepare-pr.sh). `prepare` runs IN the PR worktree to make it runnable (install + build); `launch` is the command printed to start the app for manual testing; `launchPhone` is an OPT-IN variant (via `/test-pr --phone`) that starts the app AND opens a public tunnel (e.g. `cloudflared tunnel --url http://localhost: --http-host-header localhost`) so a phone can reach it — it exposes the app publicly, so keep it attended; `worktreeDir` is where PR worktrees are created (default '.worktrees'). Empty string = skip.", + "prepare": "", + "launch": "", + "launchPhone": "", + "worktreeDir": ".worktrees" + }, + "review": { "lenses": ["correctness", "tests", "security", "performance"], "consensus": "all", diff --git a/.claude/scripts/prepare-pr.sh b/.claude/scripts/prepare-pr.sh index f1bbd5c..0a0f760 100755 --- a/.claude/scripts/prepare-pr.sh +++ b/.claude/scripts/prepare-pr.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# prepare-pr.sh +# prepare-pr.sh [--phone] # Prepare a ready-to-run local checkout of an OPEN pull request so a human can # manually test it, WITHOUT touching the main working tree. Idempotent. # @@ -13,12 +13,32 @@ # Config (.claude/gates.json → "humanTest"): # prepare shell cmd run IN the worktree to make it runnable (optional) # launch shell cmd to start the app for manual testing (printed, optional) +# launchPhone shell cmd to start the app AND a public cloudflared tunnel for +# phone testing (printed instead of launch, opt-in, optional) # worktreeDir parent dir for PR worktrees (optional, default ".worktrees") # # gh reads go through bot-gh.sh for consistent auth; git ops stay local. +# +# --phone / PHONE=1: opt-in "phone testing" mode. Behavior is otherwise +# BYTE-IDENTICAL to the default path — it only changes which launch command is +# printed at the end (humanTest.launchPhone instead of humanTest.launch) and +# prints a caveat block, because launchPhone exposes the dev studio (and its +# /api proxy) publicly over a cloudflared quick tunnel. set -uo pipefail -pr="${1:?usage: prepare-pr.sh }" +phone=0 +rest=() +for arg in "$@"; do + case "$arg" in + --phone) phone=1 ;; + *) rest+=("$arg") ;; + esac +done +if [ -n "${PHONE:-}" ] && [ "$PHONE" != "0" ]; then + phone=1 +fi + +pr="${rest[0]:?usage: prepare-pr.sh [--phone] }" case "$pr" in ''|*[!0-9]*) echo "prepare-pr: PR number must be numeric (got '$pr')" >&2; exit 2 ;; esac @@ -33,6 +53,7 @@ read_gate() { } prepare_cmd="$(read_gate prepare)" launch_cmd="$(read_gate launch)" +launch_phone_cmd="$(read_gate launchPhone)" wt_parent="$(read_gate worktreeDir)"; wt_parent="${wt_parent:-.worktrees}" # Resolve the PR's head branch (gh through the bot wrapper for consistent auth). @@ -56,6 +77,12 @@ else git worktree add -f --detach "$wt" "$sha" || { echo "prepare-pr: git worktree add failed" >&2; exit 1; } fi +# Many apps read a gitignored repo-root .env at runtime (e.g. the deploy-server). +# A detached worktree has no .env, so symlink the main checkout's if present. +if [ -f "$root/.env" ] && [ ! -e "$wt/.env" ]; then + ln -s "$root/.env" "$wt/.env" && echo "▶ linked $wt/.env → $root/.env" +fi + if [ -n "$prepare_cmd" ]; then echo "▶ prepare: $prepare_cmd" ( cd "$wt" && eval "$prepare_cmd" ) || { echo "prepare-pr: humanTest.prepare failed" >&2; exit 1; } @@ -66,10 +93,31 @@ fi echo "" echo "✅ PR #$pr is ready to test. In your terminal:" echo " cd $wt" -if [ -n "$launch_cmd" ]; then - echo " $launch_cmd" +if [ "$phone" = "1" ]; then + if [ -n "$launch_phone_cmd" ]; then + echo " $launch_phone_cmd" + echo "" + echo "⚠️ Phone testing mode — before you run this, know that:" + echo " 1. The printed https://*.trycloudflare.com URL is PUBLIC while the tunnel is up —" + echo " anyone with the link can reach it, not just your phone." + echo " 2. It exposes the dev studio AND its /api proxy, which can trigger REAL deploys" + echo " using whatever RPC URL / private keys are in your .env." + echo " 3. It is ephemeral — the tunnel and both dev servers die together on Ctrl-C." + echo " 4. It must be attended — don't leave it running unattended." + else + echo " (humanTest.launchPhone not configured in gates.json — falling back to launch)" + if [ -n "$launch_cmd" ]; then + echo " $launch_cmd" + else + echo " (no humanTest.launch configured — start the app manually)" + fi + fi else - echo " (no humanTest.launch configured — start the app manually)" + if [ -n "$launch_cmd" ]; then + echo " $launch_cmd" + else + echo " (no humanTest.launch configured — start the app manually)" + fi fi echo "" echo "When done, tear it down with: git worktree remove $wt" diff --git a/docs/USAGE.md b/docs/USAGE.md index 00a7ab4..89d48db 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -191,9 +191,24 @@ Configure the commands once in `.claude/gates.json`: "humanTest": { "prepare": "pnpm install --prefer-offline && pnpm -r build", "launch": "pnpm dev", + "launchPhone": "sh -c 'pnpm dev & devpid=$!; trap \"kill $devpid 2>/dev/null\" EXIT INT TERM; cloudflared tunnel --url http://localhost:5173 --http-host-header localhost'", "worktreeDir": ".worktrees" } ``` Add `humanTest.worktreeDir` to `.gitignore`. Re-running `/test-pr` on the same PR fast-forwards the worktree to the latest commit (idempotent). Tear down with `git worktree remove `. + +### Testing on a phone (`/test-pr --phone`) + +To iterate on a UI from a phone (touch gestures, small-screen layout) rather than a desktop browser, configure +`humanTest.launchPhone` and run `/test-pr --phone`. It prepares the worktree exactly as above but prints +`launchPhone` instead of `launch`. `launchPhone` starts the dev server(s) **and** opens a public tunnel (e.g. a +`cloudflared` quick tunnel) to the running app, printing a `https://.trycloudflare.com` URL you open in the +phone's own browser. The `--http-host-header localhost` flag makes the tunnel send `Host: localhost`, so a dev +server with a strict host allow-list (e.g. Vite) accepts it with no config change. + +This is opt-in and attended, because the tunnel is **public** while up: anyone with the link reaches the app and +whatever backend/API it proxies, using the credentials in the worktree's `.env`. It's ephemeral — Ctrl-C tears down +the tunnel and the dev servers together. Don't leave it running unattended. Without `--phone`, behavior is +unchanged; if `launchPhone` isn't configured, `--phone` falls back to printing `launch`.