diff --git a/.cursor/skills/verify-samehere/.gitignore b/.cursor/skills/verify-samehere/.gitignore new file mode 100644 index 0000000..d198262 --- /dev/null +++ b/.cursor/skills/verify-samehere/.gitignore @@ -0,0 +1,4 @@ +.run/ +helpers/node_modules/ +artifacts/** +!artifacts/README.md diff --git a/.cursor/skills/verify-samehere/SKILL.md b/.cursor/skills/verify-samehere/SKILL.md new file mode 100644 index 0000000..9f8a931 --- /dev/null +++ b/.cursor/skills/verify-samehere/SKILL.md @@ -0,0 +1,151 @@ +--- +name: verify-samehere +description: Drive the samehere Next.js web app (landing, signup, login, pricing, public profiles) in a real Chrome session the way a user does. Use when proving UI behavior, checking a local instance, or capturing verification evidence. +--- + +# Verify samehere + +samehere is a Next.js 16 App Router web app (`npm run dev`, default product port 3000). There is no Playwright/Cypress suite and no CLI surface. Verification launches an isolated `next dev` on **127.0.0.1:4173**, then drives Chrome through `control-samehere browser` (Playwright-core over CDP against system `google-chrome`). + +Read [features/README.md](features/README.md) before driving. The map is the source of truth; proving one convenient entry point is incomplete when a feature file lists others. + +Signed-in product surfaces (`/feed`, `/search`, `/messages`, `/settings`, `/jobs`, `/community`, `/dashboard`) require a real Supabase session. This skill does not mint accounts. If `doctor` reports `env_mode=scaffolded`, treat those routes as unreachable (they 307 to `/signup`). + +## Launch + +One verification instance per checkout. `next dev` writes `.next/` in the repo; a second Next process on the same worktree will corrupt the session. `launch` refuses if port 4173 is taken or if another `next` is already running from this repo. + +```bash +# From the repo root. Install helper deps once per machine. +npm install --prefix .cursor/skills/verify-samehere/helpers --omit=dev --no-fund --no-audit + +.cursor/skills/verify-samehere/helpers/control-samehere launch +# optional: --port 4173 --host 127.0.0.1 +``` + +Ready when `launch` prints `ok url=http://127.0.0.1:4173` and `GET /` returns HTML containing `samehere`. The helper polls for up to 90s (first compile downloads `next/font` Google faces). + +What launch does: + +- Starts `npm run dev -- --hostname 127.0.0.1 --port 4173` in its own process group. +- Sets `SAMEHERE_VERIFY=1`, `SAMEHERE_VERIFY_RUN_ID`, `PORT`, `HOSTNAME`. +- If `.env.local` exists, Next loads it (`env_mode=repo`). Do not overwrite it. +- If `.env.local` is missing, launch exports a **verification-scaffolding** Supabase URL/anon JWT and `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4173` (`env_mode=scaffolded`). Those values live only in the process environment and `.run/`; they are not written into the repo. Landing, login/signup forms, pricing, and legal pages still render. Auth mutations and live profile RPCs do not. +- Unsets `INVITE_ONLY` in the scaffolded case so OAuth buttons stay visible. A repo `.env.local` with `INVITE_ONLY=1` hides them (`invite_only=yes`). + +State: `.cursor/skills/verify-samehere/.run/` (pid, port, run id, next.log, chrome profile). Gitignored. + +Teardown is `control-samehere cleanup` (see Cleanup). Never `pkill -f next`. + +## Doctor + +Read-only. Run before the first drive, after any failed drive, and whenever the instance looks off. + +```bash +.cursor/skills/verify-samehere/helpers/control-samehere doctor +``` + +Pass means all of: + +- `.run/pid` is alive. +- Port `4173` (or the launched port) is owned by that process group. +- `GET /` is `200` and the body contains `samehere`. +- Printed `url`, `run_id`, `env_mode`, `invite_only`, `landing=yes|no`, and `
` (the code pre-fills `Referral code` / `Invite code`).
+- Hit any signed-out gated route (`/feed`, `/search`, `/messages`, …); middleware sends the browser to `/signup`.
+
+## Driving it with control-samehere
+
+Preconditions:
+
+- samehere is healthy at `http://127.0.0.1:4173`.
+- `control-samehere doctor` reports this run's URL.
+- You will not submit a unique valid email+username against `env_mode=repo` (that creates a real account).
+- Note `invite_only` from doctor before asserting OAuth or the referral label.
+
+- **Open from landing.** Choose header `Join free`. Run `control-samehere browser goto --path /` and `control-samehere browser click --role link --name "Join free" --nth 0`. The heading reads `Create your account` and the URL is `/signup`.
+- **Open from gated route.** Request the feed as a logged-out user. Run `control-samehere http get --path /feed --expect-status 307`. `Location` contains `/signup`. Then `control-samehere browser goto --path /feed` and confirm the heading `Create your account`.
+- **Read fields.** On `/signup` confirm textboxes `Email`, `Username`, `Password`, and either `Referral code` (`invite_only=no`) or `Invite code` (`invite_only=yes`). The submit button is `Create account`.
+- **OAuth visibility.** If `invite_only=no`, buttons `Continue with Google` and `Continue with GitHub` are visible. Do not click them. If `invite_only=yes`, those buttons are absent and the hint `samehere is in private beta — ask a member for their code.` is visible.
+- **Short username.** Fill a legal email, username `ab`, and an 8+ character password. Run `control-samehere browser fill --role textbox --name "Email" --value "verify@school.edu"`, `control-samehere browser fill --role textbox --name "Username" --value "ab"`, `control-samehere browser fill --role textbox --name "Password" --value "password1"`, and `control-samehere browser click --role button --name "Create account"`. An `alert` reads `Username must be 3-20 characters: lowercase letters, numbers, or underscores.` The URL stays `/signup`.
+- **Reserved username.** Replace the username with `edit` and submit again. Run `control-samehere browser fill --role textbox --name "Username" --value "edit"` and `control-samehere browser click --role button --name "Create account"`. An `alert` reads `That username is reserved.`
+- **Short password.** Use username `verifyuser` and password `short`. Run `control-samehere browser fill --role textbox --name "Username" --value "verifyuser"`, `control-samehere browser fill --role textbox --name "Password" --value "short"`, and `control-samehere browser click --role button --name "Create account"`. An `alert` reads `Password must be at least 8 characters.`
+- **Scaffolded submit (only when `env_mode=scaffolded`).** Use valid-looking fields (`verifyuser` / 8+ password / unused email) and submit. The page stays on `/signup`. Expect an `alert` such as `Couldn't create your account. Try again in a moment.` — proof that the form ran and did not create a session. If the heading becomes `Check your email`, the scaffolding is not what you think; treat that as a failed isolation and cleanup.
+- **Cross-link.** Choose `Log in`. Run `control-samehere browser click --role link --name "Log in" --nth 0`. The URL is `/login` and the heading reads `Log in`.
+- **Proof.** Capture the form with a validation `alert` visible. Run `control-samehere browser snapshot --aria --path artifacts/signup/invalid-username.aria.txt` and `control-samehere browser screenshot --path artifacts/signup/invalid-username.png`. Both show `Create your account` and the alert text. Write `artifacts/signup/run.json`.
+
+## Gotchas
+
+- A successful submit against a live project sends a confirmation email and shows `Check your email`. That is a real user. Do not do this in verification.
+- HTML5 `required` on Email / Username / Password can block submit before the server `alert` appears. If no alert shows, snapshot the still-focused form — do not invent the message.
+- `ref` in the query populates the code field; it does not by itself prove the code is valid.
+- Username `edit` is reserved because `/profile/edit` is the editor. Other reserved names include `feed`, `login`, `signup`, `admin`, `profile`, `search`, `saved`.
+- OAuth on this page would create an account on first provider login and skip the invite gate. Never start it from a verification run.
diff --git a/.cursor/skills/verify-samehere/helpers/control-samehere b/.cursor/skills/verify-samehere/helpers/control-samehere
new file mode 100755
index 0000000..57d2136
--- /dev/null
+++ b/.cursor/skills/verify-samehere/helpers/control-samehere
@@ -0,0 +1,288 @@
+#!/usr/bin/env bash
+# Drive a disposable samehere verification instance. See SKILL.md.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=lib/common.sh
+source "$SCRIPT_DIR/lib/common.sh"
+
+usage() {
+ cat <<'EOF'
+control-samehere — launch, inspect, and drive a disposable samehere instance.
+
+ control-samehere launch [--port 4173] [--host 127.0.0.1]
+ control-samehere doctor
+ control-samehere browser goto --path /
+ control-samehere browser click --role link --name "Join free" [--nth 0] [--exact]
+ control-samehere browser fill --role textbox --name "Email" --value "you@school.edu"
+ control-samehere browser press --key Enter [--role textbox --name "Email"]
+ control-samehere browser wait --text "Create your account"
+ control-samehere browser snapshot --aria --path artifacts/landing/home.aria.txt
+ control-samehere browser screenshot --path artifacts/landing/home.png [--full]
+ control-samehere browser url
+ control-samehere http get --path /feed [--expect-status 307]
+ control-samehere cleanup
+
+State lives in $SAMEHERE_VERIFY_STATE_DIR (default: this skill's .run/).
+Evidence lives in $SAMEHERE_VERIFY_ARTIFACTS_DIR (default: this skill's artifacts/).
+cleanup never deletes evidence.
+EOF
+}
+
+cmd_launch() {
+ local host port
+ host="$(default_host)"
+ port="$(default_port)"
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --host) host="$2"; shift 2 ;;
+ --port) port="$2"; shift 2 ;;
+ *) echo "control-samehere launch: unknown arg $1" >&2; return 2 ;;
+ esac
+ done
+
+ local root skill state
+ root="$(repo_root)"
+ skill="$(skill_dir)"
+ state="$(state_dir)"
+ mkdir -p "$state"
+
+ local existing
+ existing="$(read_state pid)"
+ if pid_alive "$existing"; then
+ echo "control-samehere launch: instance already running (pid $existing). Run cleanup first." >&2
+ return 1
+ fi
+
+ local listeners
+ listeners="$(port_pids "$port")"
+ if [[ -n "$listeners" ]]; then
+ echo "control-samehere launch: port $port is already in use by pid(s): $listeners" >&2
+ echo "Refuse to share a port. Pick --port or stop the other listener." >&2
+ return 1
+ fi
+
+ local other_next
+ other_next="$(pgrep -f "$root/node_modules/next/dist/bin/next" || true)"
+ if [[ -n "$other_next" ]]; then
+ echo "control-samehere launch: another Next.js process is already using this checkout:" >&2
+ echo "$other_next" >&2
+ echo "Refuse to double-drive a shared .next. Stop it, or verify from a separate worktree." >&2
+ return 1
+ fi
+
+ local run_id env_mode invite_only
+ run_id="${SAMEHERE_VERIFY_RUN_ID:-$(date +%Y%m%dT%H%M%S)-$$}"
+ env_mode="repo"
+ invite_only="no"
+
+ # Next does not overwrite existing process env with .env.local. Export
+ # verification identity always; only inject placeholder Supabase when the
+ # checkout has no .env.local (marked scaffolding — cleanup leaves no file).
+ export SAMEHERE_VERIFY=1
+ export SAMEHERE_VERIFY_RUN_ID="$run_id"
+ export PORT="$port"
+ export HOSTNAME="$host"
+
+ if [[ -f "$root/.env.local" ]]; then
+ env_mode="repo"
+ if grep -q '^INVITE_ONLY=1' "$root/.env.local"; then
+ invite_only="yes"
+ fi
+ else
+ env_mode="scaffolded"
+ export NEXT_PUBLIC_SUPABASE_URL="${NEXT_PUBLIC_SUPABASE_URL:-$(placeholder_supabase_url)}"
+ export NEXT_PUBLIC_SUPABASE_ANON_KEY="${NEXT_PUBLIC_SUPABASE_ANON_KEY:-$(placeholder_anon_key)}"
+ export NEXT_PUBLIC_SITE_URL="${NEXT_PUBLIC_SITE_URL:-http://$host:$port}"
+ unset INVITE_ONLY || true
+ printf '%s\n' "$NEXT_PUBLIC_SUPABASE_URL" > "$state/scaffold_supabase_url"
+ fi
+
+ write_state host "$host"
+ write_state port "$port"
+ write_state run_id "$run_id"
+ write_state env_mode "$env_mode"
+ write_state invite_only "$invite_only"
+ write_state repo "$root"
+ write_state skill "$skill"
+ : > "$state/next.log"
+
+ (
+ cd "$root"
+ exec setsid npm run dev -- --hostname "$host" --port "$port"
+ ) >>"$state/next.log" 2>&1 &
+ local pid=$!
+ write_state pid "$pid"
+
+ local ready=0
+ local i
+ for i in $(seq 1 90); do
+ if ! pid_alive "$pid"; then
+ echo "control-samehere launch: Next.js exited before it was ready. Log: $state/next.log" >&2
+ tail -n 40 "$state/next.log" >&2 || true
+ return 1
+ fi
+ if curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "http://$host:$port/" 2>/dev/null | grep -qE '200|307|308'; then
+ # First compile of / can still be in flight after the port opens.
+ if curl -sS --max-time 8 "http://$host:$port/" 2>/dev/null | grep -q 'samehere'; then
+ ready=1
+ break
+ fi
+ fi
+ sleep 1
+ done
+
+ if [[ "$ready" -ne 1 ]]; then
+ echo "control-samehere launch: timed out waiting for http://$host:$port/ . Log: $state/next.log" >&2
+ tail -n 40 "$state/next.log" >&2 || true
+ cmd_cleanup || true
+ return 1
+ fi
+
+ echo "ok url=http://$host:$port pid=$pid run_id=$run_id env_mode=$env_mode invite_only=$invite_only"
+}
+
+cmd_doctor() {
+ require_state || return 1
+ local pid host port url env_mode invite_only run_id
+ pid="$(read_state pid)"
+ host="$(read_state host)"
+ port="$(read_state port)"
+ env_mode="$(read_state env_mode)"
+ invite_only="$(read_state invite_only)"
+ run_id="$(read_state run_id)"
+ url="$(base_url)"
+
+ local listeners
+ listeners="$(port_pids "$port")"
+ if [[ -z "$listeners" ]]; then
+ echo "control-samehere doctor: nothing is listening on $port" >&2
+ return 1
+ fi
+ if ! printf '%s\n' "$listeners" | grep -qx "$pid"; then
+ # Next.js workers may own the socket; accept if they share our process group.
+ local pgid child
+ pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
+ local owned=0
+ for child in $listeners; do
+ if [[ "$(ps -o pgid= -p "$child" 2>/dev/null | tr -d ' ')" == "$pgid" ]]; then
+ owned=1
+ break
+ fi
+ done
+ if [[ "$owned" -ne 1 ]]; then
+ echo "control-samehere doctor: port $port is not owned by this run (pid $pid, listeners: $listeners)" >&2
+ return 1
+ fi
+ fi
+
+ local tmp status body
+ tmp="$(mktemp)"
+ status="$(curl -sS -o "$tmp" -w '%{http_code}' --max-time 10 "$url/" || true)"
+ body="$(cat "$tmp")"
+ rm -f "$tmp"
+
+ if [[ "$status" != "200" ]]; then
+ echo "control-samehere doctor: GET / returned $status (expected 200)" >&2
+ return 1
+ fi
+ if ! printf '%s' "$body" | grep -q 'samehere'; then
+ echo "control-samehere doctor: GET / body does not identify samehere" >&2
+ return 1
+ fi
+
+ local landing="no"
+ if printf '%s' "$body" | grep -q 'landing-xai' && printf '%s' "$body" | grep -q 'Join free'; then
+ landing="yes"
+ fi
+
+ local title
+ title="$(printf '%s' "$body" | sed -n 's/.*\([^<]*\)<\/title>.*/\1/p' | head -n 1)"
+
+ echo "ok url=$url pid=$pid port=$port run_id=$run_id env_mode=$env_mode invite_only=$invite_only landing=$landing title=$title"
+ if [[ "$env_mode" == "scaffolded" ]]; then
+ echo "note auth mutations and live public profiles are unreachable without a real .env.local"
+ fi
+}
+
+cmd_http() {
+ local action="${1:-}"
+ shift || true
+ if [[ "$action" != "get" ]]; then
+ echo "control-samehere http: only 'get' is supported" >&2
+ return 2
+ fi
+ require_state || return 1
+ local path="/" expect=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --path) path="$2"; shift 2 ;;
+ --expect-status) expect="$2"; shift 2 ;;
+ *) echo "control-samehere http get: unknown arg $1" >&2; return 2 ;;
+ esac
+ done
+ [[ "$path" == /* ]] || path="/$path"
+ local url tmp status location
+ url="$(base_url)$path"
+ tmp="$(mktemp)"
+ status="$(curl -sS -D "$tmp.hdr" -o "$tmp" -w '%{http_code}' --max-time 15 "$url" || true)"
+ location="$(grep -i '^location:' "$tmp.hdr" | awk '{print $2}' | tr -d '\r' | tail -n 1)"
+ rm -f "$tmp" "$tmp.hdr"
+ if [[ -n "$expect" && "$status" != "$expect" ]]; then
+ echo "control-samehere http get: $url returned $status (expected $expect)" >&2
+ return 1
+ fi
+ echo "ok url=$url status=$status location=${location:-}"
+}
+
+cmd_browser() {
+ require_state || return 1
+ local helpers
+ helpers="$(helpers_dir)"
+ if [[ ! -d "$helpers/node_modules/playwright-core" ]]; then
+ echo "control-samehere browser: installing playwright-core into helpers/ (one-time)" >&2
+ (cd "$helpers" && npm install --omit=dev --no-fund --no-audit)
+ fi
+ export SAMEHERE_VERIFY_STATE_DIR
+ SAMEHERE_VERIFY_STATE_DIR="$(state_dir)"
+ export SAMEHERE_VERIFY_ARTIFACTS_DIR
+ SAMEHERE_VERIFY_ARTIFACTS_DIR="$(artifacts_dir)"
+ node "$helpers/lib/browser.mjs" "$@"
+}
+
+cmd_cleanup() {
+ local state pid chrome
+ state="$(state_dir)"
+ pid="$(read_state pid)"
+ chrome="$(read_state chrome_pid)"
+ if pid_alive "$chrome"; then
+ kill_pid_tree "$chrome"
+ fi
+ if pid_alive "$pid"; then
+ kill_pid_tree "$pid"
+ fi
+ # Drop launch/browser scratch only. Evidence stays in artifacts/.
+ if [[ -d "$state" ]]; then
+ rm -rf "$state"
+ fi
+ echo "ok cleaned state=$state evidence=$(artifacts_dir)"
+}
+
+main() {
+ local cmd="${1:-}"
+ if [[ -z "$cmd" || "$cmd" == "-h" || "$cmd" == "--help" ]]; then
+ usage
+ return 0
+ fi
+ shift || true
+ case "$cmd" in
+ launch) cmd_launch "$@" ;;
+ doctor) cmd_doctor "$@" ;;
+ browser) cmd_browser "$@" ;;
+ http) cmd_http "$@" ;;
+ cleanup) cmd_cleanup "$@" ;;
+ *) echo "control-samehere: unknown command '$cmd'" >&2; usage >&2; return 2 ;;
+ esac
+}
+
+main "$@"
diff --git a/.cursor/skills/verify-samehere/helpers/lib/browser.mjs b/.cursor/skills/verify-samehere/helpers/lib/browser.mjs
new file mode 100644
index 0000000..d4120ab
--- /dev/null
+++ b/.cursor/skills/verify-samehere/helpers/lib/browser.mjs
@@ -0,0 +1,319 @@
+#!/usr/bin/env node
+import { spawn } from "node:child_process";
+import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import { setTimeout as delay } from "node:timers/promises";
+import { chromium } from "playwright-core";
+
+const stateDir = process.env.SAMEHERE_VERIFY_STATE_DIR;
+if (!stateDir) {
+ fail("SAMEHERE_VERIFY_STATE_DIR is not set");
+}
+
+const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
+
+function fail(message, extra) {
+ if (extra) console.error(extra);
+ console.error(`control-samehere browser: ${message}`);
+ process.exit(1);
+}
+
+function readState(key) {
+ const file = join(stateDir, key);
+ if (!existsSync(file)) return "";
+ return readFileSync(file, "utf8").trim();
+}
+
+function writeState(key, value) {
+ mkdirSync(stateDir, { recursive: true });
+ writeFileSync(join(stateDir, key), `${value}\n`);
+}
+
+function baseUrl() {
+ const host = readState("host") || "127.0.0.1";
+ const port = readState("port") || "4173";
+ return `http://${host}:${port}`;
+}
+
+function parseArgs(argv) {
+ const out = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const token = argv[i];
+ if (token.startsWith("--")) {
+ const key = token.slice(2);
+ const next = argv[i + 1];
+ if (!next || next.startsWith("--")) {
+ out[key] = true;
+ } else {
+ out[key] = next;
+ i++;
+ }
+ } else {
+ out._.push(token);
+ }
+ }
+ return out;
+}
+
+function chromePath() {
+ const fromEnv = process.env.SAMEHERE_VERIFY_CHROME;
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+ // Prefer the real binary. /usr/local/bin/google-chrome is often a wrapper
+ // that forces the desktop user-data-dir and debug port 9222.
+ const candidates = [
+ "/opt/google/chrome/chrome",
+ "/opt/google/chrome/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/google-chrome",
+ ];
+ return candidates.find((p) => existsSync(p)) || "";
+}
+
+function cdpPort() {
+ return process.env.SAMEHERE_VERIFY_CDP_PORT || "14173";
+}
+
+async function waitForJsonVersion(port, timeoutMs = 20000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
+ if (res.ok) return { port: String(port), path: "" };
+ } catch {
+ // Chrome is still binding.
+ }
+ const marker = join(stateDir, "chrome-profile", "DevToolsActivePort");
+ if (existsSync(marker)) {
+ const [filePort] = readFileSync(marker, "utf8").trim().split("\n");
+ if (filePort && Number(filePort) > 0) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${filePort}/json/version`);
+ if (res.ok) return { port: filePort, path: "" };
+ } catch {
+ // keep polling
+ }
+ }
+ }
+ await delay(150);
+ }
+ fail(`Chrome DevTools port ${port} never became ready`);
+}
+
+async function ensureChrome() {
+ const existingPid = readState("chrome_pid");
+ const existingPort = readState("cdp_port") || cdpPort();
+ if (existingPid) {
+ try {
+ process.kill(Number(existingPid), 0);
+ const res = await fetch(`http://127.0.0.1:${existingPort}/json/version`);
+ if (res.ok) {
+ writeState("cdp_port", String(existingPort));
+ return String(existingPort);
+ }
+ } catch {
+ // Relaunch.
+ }
+ }
+ try {
+ const res = await fetch(`http://127.0.0.1:${cdpPort()}/json/version`);
+ if (res.ok) {
+ writeState("cdp_port", cdpPort());
+ return cdpPort();
+ }
+ } catch {
+ // Need a new Chrome.
+ }
+
+ const chrome = chromePath();
+ if (!chrome) fail("google-chrome / chromium not found. Set SAMEHERE_VERIFY_CHROME.");
+
+ const userData = join(stateDir, "chrome-profile");
+ rmSync(join(userData, "DevToolsActivePort"), { force: true });
+ mkdirSync(userData, { recursive: true });
+ const port = cdpPort();
+
+ const child = spawn(
+ chrome,
+ [
+ "--headless=new",
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ "--use-gl=angle",
+ "--use-angle=swiftshader-webgl",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-background-networking",
+ "--disable-sync",
+ "--disable-extensions",
+ `--remote-debugging-port=${port}`,
+ "--remote-debugging-address=127.0.0.1",
+ `--user-data-dir=${userData}`,
+ `--window-size=${DEFAULT_VIEWPORT.width},${DEFAULT_VIEWPORT.height}`,
+ "about:blank",
+ ],
+ { detached: true, stdio: "ignore" },
+ );
+ child.unref();
+ writeState("chrome_pid", String(child.pid));
+
+ const ready = await waitForJsonVersion(port);
+ writeState("cdp_port", ready.port);
+ return ready.port;
+}
+
+async function connectPage() {
+ const port = await ensureChrome();
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: 15000 });
+ const context = browser.contexts()[0] || (await browser.newContext({ viewport: DEFAULT_VIEWPORT }));
+ await context.setDefaultTimeout(15000);
+ let page = context.pages().find((p) => !p.url().startsWith("devtools://")) || context.pages()[0];
+ if (!page) page = await context.newPage();
+ await page.setViewportSize(DEFAULT_VIEWPORT);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ return { browser, page };
+}
+
+function locatorFor(page, args) {
+ const role = args.role;
+ const name = args.name;
+ const nth = Number(args.nth ?? 0);
+ if (!role) fail("missing --role");
+ if (!name) fail("missing --name");
+ return page.getByRole(role, { name, exact: args.exact === true }).nth(nth);
+}
+
+function resolveArtifactPath(p) {
+ if (!p) fail("missing --path");
+ if (isAbsolute(p)) return p;
+ const artifacts = process.env.SAMEHERE_VERIFY_ARTIFACTS_DIR;
+ if (artifacts && (p.startsWith("artifacts/") || p.startsWith("artifacts\\"))) {
+ return resolve(artifacts, p.slice("artifacts/".length));
+ }
+ if (artifacts && !p.includes("/")) return resolve(artifacts, p);
+ return resolve(process.cwd(), p);
+}
+
+function printOk(fields) {
+ const parts = Object.entries(fields)
+ .filter(([, v]) => v !== undefined && v !== "")
+ .map(([k, v]) => `${k}=${String(v).replace(/\n/g, " ")}`);
+ console.log(`ok ${parts.join(" ")}`);
+}
+
+async function cmdGoto(page, args) {
+ const path = args.path ?? args._[0] ?? "/";
+ const url = path.startsWith("http") ? path : `${baseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await page.locator("main").first().waitFor({ state: "visible", timeout: 20000 });
+ printOk({
+ url: page.url(),
+ status: response?.status() ?? "",
+ title: await page.title(),
+ });
+}
+
+async function cmdClick(page, args) {
+ const loc = locatorFor(page, args);
+ await loc.waitFor({ state: "visible" });
+ const before = page.url();
+ await loc.click();
+ try {
+ await page.waitForURL((url) => url.href !== before, { timeout: 8000 });
+ } catch {
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
+ }
+ await page.locator("main").first().waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
+ await delay(250);
+ printOk({
+ url: page.url(),
+ title: await page.title(),
+ clicked: `${args.role}:${args.name}`,
+ });
+}
+
+async function cmdFill(page, args) {
+ if (args.value === undefined) fail("missing --value");
+ const loc = locatorFor(page, args);
+ await loc.waitFor({ state: "visible" });
+ await loc.fill(String(args.value));
+ printOk({
+ url: page.url(),
+ filled: `${args.role}:${args.name}`,
+ });
+}
+
+async function cmdPress(page, args) {
+ const key = args.key;
+ if (!key) fail("missing --key");
+ if (args.role && args.name) {
+ await locatorFor(page, args).press(key);
+ } else {
+ await page.keyboard.press(key);
+ }
+ await delay(200);
+ printOk({ url: page.url(), key });
+}
+
+async function cmdSnapshot(page, args) {
+ const dest = resolveArtifactPath(args.path);
+ mkdirSync(dirname(dest), { recursive: true });
+ const aria = await page.locator("body").ariaSnapshot({ timeout: 15000 });
+ writeFileSync(dest, `${aria}\n`);
+ printOk({ path: dest, url: page.url(), title: await page.title() });
+}
+
+async function cmdScreenshot(page, args) {
+ const dest = resolveArtifactPath(args.path);
+ mkdirSync(dirname(dest), { recursive: true });
+ await page.screenshot({ path: dest, fullPage: args.full === true, timeout: 15000 });
+ printOk({ path: dest, url: page.url(), title: await page.title() });
+}
+
+async function cmdUrl(page) {
+ printOk({ url: page.url(), title: await page.title() });
+}
+
+async function cmdWait(page, args) {
+ if (args.text) {
+ await page.getByText(args.text, { exact: args.exact === true }).first().waitFor({ state: "visible" });
+ printOk({ url: page.url(), text: args.text });
+ return;
+ }
+ if (args.url) {
+ await page.waitForURL(args.url);
+ printOk({ url: page.url() });
+ return;
+ }
+ if (args.role && args.name) {
+ await locatorFor(page, args).waitFor({ state: "visible" });
+ printOk({ url: page.url(), waited: `${args.role}:${args.name}` });
+ return;
+ }
+ fail("wait needs --text, --url, or --role and --name");
+}
+
+const commands = {
+ goto: cmdGoto,
+ click: cmdClick,
+ fill: cmdFill,
+ press: cmdPress,
+ snapshot: cmdSnapshot,
+ screenshot: cmdScreenshot,
+ url: cmdUrl,
+ wait: cmdWait,
+};
+
+const argv = process.argv.slice(2);
+const command = argv.shift();
+if (!command || !commands[command]) {
+ fail(`unknown command '${command || ""}'. Use goto|click|fill|press|snapshot|screenshot|url|wait`);
+}
+
+const args = parseArgs(argv);
+const { page } = await connectPage();
+await commands[command](page, args);
+// CDP keeps the event loop alive; do not browser.close() (that kills Chrome).
+process.exit(0);
diff --git a/.cursor/skills/verify-samehere/helpers/lib/common.sh b/.cursor/skills/verify-samehere/helpers/lib/common.sh
new file mode 100644
index 0000000..b9a4560
--- /dev/null
+++ b/.cursor/skills/verify-samehere/helpers/lib/common.sh
@@ -0,0 +1,185 @@
+# Shared path and state helpers for control-samehere. Sourced, not executed.
+
+helpers_dir() {
+ cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
+}
+
+skill_dir() {
+ cd "$(helpers_dir)/.." && pwd
+}
+
+repo_root() {
+ local d
+ d="$(cd "$(skill_dir)/../../.." && pwd)"
+ if [[ -f "$d/package.json" ]] && grep -q '"name": "samehere"' "$d/package.json"; then
+ printf '%s\n' "$d"
+ return 0
+ fi
+ d="$(pwd)"
+ while [[ "$d" != "/" ]]; do
+ if [[ -f "$d/package.json" ]] && grep -q '"name": "samehere"' "$d/package.json"; then
+ printf '%s\n' "$d"
+ return 0
+ fi
+ d="$(dirname "$d")"
+ done
+ echo "control-samehere: cannot find the samehere package.json" >&2
+ return 1
+}
+
+state_dir() {
+ printf '%s\n' "${SAMEHERE_VERIFY_STATE_DIR:-$(skill_dir)/.run}"
+}
+
+artifacts_dir() {
+ printf '%s\n' "${SAMEHERE_VERIFY_ARTIFACTS_DIR:-$(skill_dir)/artifacts}"
+}
+
+state_file() {
+ printf '%s/%s\n' "$(state_dir)" "$1"
+}
+
+read_state() {
+ local key="$1"
+ local file
+ file="$(state_file "$key")"
+ if [[ -f "$file" ]]; then
+ cat "$file"
+ fi
+}
+
+write_state() {
+ local key="$1"
+ local value="$2"
+ mkdir -p "$(state_dir)"
+ printf '%s\n' "$value" > "$(state_file "$key")"
+}
+
+default_host() {
+ printf '%s\n' "${SAMEHERE_VERIFY_HOST:-127.0.0.1}"
+}
+
+default_port() {
+ printf '%s\n' "${SAMEHERE_VERIFY_PORT:-4173}"
+}
+
+base_url() {
+ local host port
+ host="$(read_state host)"
+ port="$(read_state port)"
+ host="${host:-$(default_host)}"
+ port="${port:-$(default_port)}"
+ printf 'http://%s:%s\n' "$host" "$port"
+}
+
+placeholder_supabase_url() {
+ printf '%s\n' "https://verify-placeholder.supabase.co"
+}
+
+placeholder_anon_key() {
+ # Well-formed JWT so @supabase/ssr will construct a client. Signature is unused.
+ printf '%s\n' "eyJhbGciOiJub25lIn0.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTl9."
+}
+
+pid_alive() {
+ local pid="$1"
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
+}
+
+port_pids() {
+ local port="$1"
+ {
+ python3 - "$port" <<'PY'
+import os, sys
+port = sys.argv[1]
+hex_port = f"{int(port):04X}"
+inodes = set()
+for path in ("/proc/net/tcp", "/proc/net/tcp6"):
+ try:
+ lines = open(path, encoding="utf-8").read().splitlines()[1:]
+ except OSError:
+ continue
+ for line in lines:
+ parts = line.split()
+ if len(parts) < 10 or parts[3] != "0A":
+ continue
+ if parts[1].split(":")[-1].upper() == hex_port:
+ inodes.add(parts[9])
+if not inodes:
+ sys.exit(0)
+seen = set()
+for pid in os.listdir("/proc"):
+ if not pid.isdigit():
+ continue
+ fd_dir = f"/proc/{pid}/fd"
+ try:
+ fds = os.listdir(fd_dir)
+ except OSError:
+ continue
+ for fd in fds:
+ try:
+ target = os.readlink(f"{fd_dir}/{fd}")
+ except OSError:
+ continue
+ if target.startswith("socket:[") and target[8:-1] in inodes:
+ if pid not in seen:
+ print(pid)
+ seen.add(pid)
+ break
+PY
+ if command -v netstat >/dev/null 2>&1; then
+ netstat -ltnp 2>/dev/null | awk -v port="$port" '
+ $6 == "LISTEN" {
+ n = split($4, a, ":")
+ if (a[n] == port) {
+ split($7, b, "/")
+ if (b[1] ~ /^[0-9]+$/) print b[1]
+ }
+ }'
+ fi
+ } | sort -u
+}
+
+kill_descendants() {
+ local pid="$1" child
+ for child in $(pgrep -P "$pid" 2>/dev/null || true); do
+ kill_descendants "$child"
+ done
+ if pid_alive "$pid"; then
+ kill "$pid" 2>/dev/null || true
+ fi
+}
+
+kill_pid_tree() {
+ local pid="$1"
+ if ! pid_alive "$pid"; then
+ return 0
+ fi
+ local pgid
+ pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
+ if [[ -n "$pgid" ]]; then
+ kill -- "-$pgid" 2>/dev/null || true
+ fi
+ kill_descendants "$pid"
+ sleep 0.4
+ if [[ -n "$pgid" ]] && pid_alive "$pid"; then
+ kill -9 -- "-$pgid" 2>/dev/null || true
+ fi
+ if pid_alive "$pid"; then
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+}
+
+require_state() {
+ local pid
+ pid="$(read_state pid)"
+ if [[ -z "$pid" ]]; then
+ echo "control-samehere doctor: no launch state. Run launch first." >&2
+ return 1
+ fi
+ if ! pid_alive "$pid"; then
+ echo "control-samehere doctor: recorded pid $pid is not running." >&2
+ return 1
+ fi
+ return 0
+}
diff --git a/.cursor/skills/verify-samehere/helpers/package-lock.json b/.cursor/skills/verify-samehere/helpers/package-lock.json
new file mode 100644
index 0000000..2cb3d4b
--- /dev/null
+++ b/.cursor/skills/verify-samehere/helpers/package-lock.json
@@ -0,0 +1,25 @@
+{
+ "name": "samehere-verify-helpers",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "samehere-verify-helpers",
+ "dependencies": {
+ "playwright-core": "^1.55.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/.cursor/skills/verify-samehere/helpers/package.json b/.cursor/skills/verify-samehere/helpers/package.json
new file mode 100644
index 0000000..684e692
--- /dev/null
+++ b/.cursor/skills/verify-samehere/helpers/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "samehere-verify-helpers",
+ "private": true,
+ "type": "module",
+ "description": "Playwright-core CDP driver for the verify-samehere skill. Not part of the app.",
+ "dependencies": {
+ "playwright-core": "^1.55.2"
+ }
+}