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 ``. + +Fail → fix or `cleanup` + `launch`. Do not drive an instance this run did not start. + +`env_mode=scaffolded` prints a note: skip live signup/login success and published-profile proofs. + +## Drive + +Harness is `control-samehere`. Browser actions use Playwright `getByRole` against a **1280×800** desktop viewport (landing `Log in` / `Join free` are `hidden md:inline-flex`; a mobile width hides them). Each `browser` command connects over CDP, prints `ok …`, and exits so the WebSocket does not hold the shell; Chrome stays up until `cleanup`. + +```bash +BIN=.cursor/skills/verify-samehere/helpers/control-samehere + +$BIN browser goto --path / +$BIN browser click --role link --name "Join free" --nth 0 +$BIN browser fill --role textbox --name "Email" --value "you@school.edu" +$BIN browser press --key Enter --role textbox --name "Email" +$BIN browser wait --text "Create your account" +$BIN browser snapshot --aria --path artifacts/landing/signup.aria.txt +$BIN browser screenshot --path artifacts/landing/signup.png +$BIN browser url +$BIN http get --path /feed --expect-status 307 +``` + +Stable handles from this repo (prefer these over CSS or coordinates): + +| Surface | Handle | +|---|---| +| Brand | `link` name `samehere home` | +| Landing desktop nav | `link` `Log in`, `link` `Join free` (nth 0 is the header). Section links `Product`, `Community`, `Pricing` inside `navigation` `Page sections`. | +| Landing hero | heading text includes `Find your people` / `Show what you’re building.`; primary CTA `Join free`; secondary `Explore the community` (`#community`). | +| Community demo | `tablist` `Product areas` with tabs `Feed`, `Messages`, `Portfolio`. `#community` is this block, not the later invite band. | +| Pricing block | `#pricing`, heading `Share your work. Make your portfolio your own.`, articles `Share work and find peers` / `More room to present your work`, `Join free`, `View Pro`. | +| Footer | `navigation` `Footer`: `Pricing`, `Terms`, `Privacy`, `Sign up`. | +| Signup | heading `Create your account`; textboxes `Email`, `Username`, `Password`, `Referral code` (or `Invite code` when `invite_only=yes`); buttons `Create account`, `Continue with Google`, `Continue with GitHub`; link `Log in`. | +| Login | heading `Log in`; textboxes `Email`, `Password`; button `Log in`; links `Forgot password?`, `Create an account`. | +| Login errors | `alert` `Enter your email and password.` (empty submit if the browser allows it) or `Invalid email or password.` | +| Signup errors | `alert` with `Username must be 3-20 characters…`, `That username is reserved.`, `Password must be at least 8 characters.` | +| Pricing page | `/pricing`, heading same as the landing block, header `Join free`. | +| Missing profile | `/profile/<unknown>` → `Profile not found`. | + +`--nth` is 0-based among matches. Duplicate names (`Join free`, `Log in`) are everywhere; the feature file says which nth. + +Do not click `Continue with Google` / `Continue with GitHub` — those hit real IdPs. Do not submit a successful signup against a live `.env.local`. + +Anon `/feed` (and any other gated path) is `307` to `/signup`. Confirm with `http get`, not by treating the signup HTML as the feed. + +## Evidence + +Write under `.cursor/skills/verify-samehere/artifacts/<feature-id>/`. Relative `artifacts/...` paths given to `browser snapshot` / `screenshot` resolve there. Cleanup must not touch this tree. + +Proof standards: + +- Drive the real user path (click the same links a person uses). Do not call server actions, RPCs, or test-only URLs to fake a state. +- Capture the **action** and the **resulting state** (ARIA snapshot + screenshot). A final screen alone is not enough. +- UI proof must show samehere identity: the `samehere home` brand and the page heading. +- HTTP side effects: record status + `Location` for redirects (`http get`). +- Auth: HTML5 `required` may block an empty submit; that is a real user-visible outcome — snapshot the still-on-page form, do not invent an `alert`. +- Mocks: the scaffolded Supabase URL is a launch fallback so middleware can construct a client. It is not a stand-in for a successful login, signup, or published profile. Observe what it actually skips (`env_mode=scaffolded`, failed RPCs → `Profile not found`, login `alert` `Invalid email or password.` or a generic create-account failure). +- Record feature ID, entry point, URL, and `run_id` in `artifacts/<feature-id>/run.json`. + +## Cleanup + +```bash +.cursor/skills/verify-samehere/helpers/control-samehere cleanup +``` + +Kills the exact Next.js process group recorded at launch and the Chrome PID in `.run/chrome_pid`. Deletes `.run/` (logs, chrome profile, pid files). Leaves `artifacts/` in place. + +Run cleanup after every failed iteration too, then launch again. If launch itself fails mid-start, it already calls cleanup. + +Never `pkill -f next`, `pkill -f chrome`, or kill by window title. + +## Helpers + +All invocations assume the repo root. `control-samehere` is executable. + +```bash +chmod +x .cursor/skills/verify-samehere/helpers/control-samehere # once, if git dropped +x + +.cursor/skills/verify-samehere/helpers/control-samehere launch +.cursor/skills/verify-samehere/helpers/control-samehere doctor +.cursor/skills/verify-samehere/helpers/control-samehere browser goto --path / +.cursor/skills/verify-samehere/helpers/control-samehere cleanup +``` + +`helpers/lib/browser.mjs` is the CDP driver. `helpers/package.json` pins `playwright-core` only (system Chrome, no browser download). The first `browser` command runs `npm install` in `helpers/` if `node_modules` is missing. + +Optional env: + +- `SAMEHERE_VERIFY_PORT` / `--port` — default `4173` +- `SAMEHERE_VERIFY_HOST` / `--host` — default `127.0.0.1` +- `SAMEHERE_VERIFY_STATE_DIR` — default `<skill>/.run` +- `SAMEHERE_VERIFY_ARTIFACTS_DIR` — default `<skill>/artifacts` +- `SAMEHERE_VERIFY_CHROME` — Chrome binary (use the real binary, not a wrapper that shares the desktop profile) +- `SAMEHERE_VERIFY_CDP_PORT` — Chrome DevTools port, default `14173` +- `SAMEHERE_VERIFY_PROFILE_USERNAME` — live username for the published-profile path + +Keep the map honest with `/maintain-verification-skill` as the app changes. diff --git a/.cursor/skills/verify-samehere/artifacts/README.md b/.cursor/skills/verify-samehere/artifacts/README.md new file mode 100644 index 0000000..4333bf3 --- /dev/null +++ b/.cursor/skills/verify-samehere/artifacts/README.md @@ -0,0 +1,13 @@ +# Verification evidence + +Proof artifacts for a verify-samehere run live here, one subdirectory per feature ID (`landing/`, `signup/`, `login/`, `pricing/`, `public-profile/`). + +Cleanup tears down the Next.js process, the Chrome session, and `/tmp` scratch state. It never deletes this directory. + +Typical files for a UI proof: + +- `01-home.png` / `01-home.aria.txt` — starting surface +- `02-<action>.png` / `02-<action>.aria.txt` — the user action's resulting state +- `run.json` — feature ID, entry point, URL, run ID, doctor excerpt + +Name files so the action and the result stay paired. Do not overwrite another run's files; use a fresh subdirectory or a timestamped prefix if you need to keep both. diff --git a/.cursor/skills/verify-samehere/features/README.md b/.cursor/skills/verify-samehere/features/README.md new file mode 100644 index 0000000..22e4d60 --- /dev/null +++ b/.cursor/skills/verify-samehere/features/README.md @@ -0,0 +1,50 @@ +# samehere verification map + +This directory is the maintained source for verifying the user-facing behavior of samehere. Read the index before driving the app, then use the matching feature file as the recipe. + +## Baseline preconditions + +- Launch samehere at `http://127.0.0.1:4173` with `control-samehere launch`. +- Run `control-samehere doctor` and require `url=http://127.0.0.1:4173`, a live pid that owns that port, and `landing=yes`. +- Drive at a 1280×800 desktop viewport (the helper sets this). Mobile widths hide `Log in` / `Join free`. +- Never drive an instance that was not started by this verification run. +- If `env_mode=scaffolded`, do not report live signup, live login, or a published public profile as verified. Form chrome, validation alerts, marketing pages, and `Profile not found` remain in scope. +- If `invite_only=yes`, signup requires `Invite code` and OAuth buttons are hidden on login and signup. +- Do not click `Continue with Google` or `Continue with GitHub`. + +## Driving conventions + +- Start every recipe from the baseline state unless its preconditions say otherwise. +- Prefer ARIA roles and accessible names over CSS selectors or DOM position. +- Treat every command as literal. Keep quoted names and flags unchanged. +- Run browser actions through `control-samehere browser`. +- Run redirect checks through `control-samehere http get`. +- Restore the browser to `/` after a mutation-like navigation if the next recipe needs the landing. Do not remove proof artifacts during cleanup. + +## Proof and skip reporting + +- Capture the user action and the resulting state, not only the final screen. +- UI proof includes an ARIA snapshot and a screenshot with the samehere brand visible. +- Redirect proof includes status and `Location`. +- Record the feature ID and entry point used with every artifact (`artifacts/<id>/run.json`). +- Report an unreachable path with the attempted command and the unmet precondition (`env_mode=scaffolded`, missing `SAMEHERE_VERIFY_PROFILE_USERNAME`, no session). +- Do not report a skipped entry point as verified through a different path. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph describing the user-visible behavior. It then uses exactly four H2 sections in this order. + +1. `Sub-features` lists short IDs with one line for each behavior. +2. `How to get to it (user POV)` lists every user entry point. +3. `Driving it with control-samehere` starts with `Preconditions:` and uses labeled bullets that pair each user action with an exact command and observable result. +4. `Gotchas` lists traps that can waste or invalidate a verification run. + +Keep implementation details out of the map. Name only user paths, stable handles, required state, commands, and observable proof. + +## Features + +- [Landing](./landing.md) covers the logged-out home page: hero, section nav, community tabs, pricing band, and CTAs into signup/login. +- [Sign up](./signup.md) covers the create-account form, validation, footer cross-link, and the invite-only variant. +- [Log in](./login.md) covers the login form, forgot-password, and the anonymous redirect into auth. +- [Pricing](./pricing.md) covers `/pricing` and the landing `#pricing` band, including the Pro link. +- [Public profile](./public-profile.md) covers a missing username and, when a live project is configured, a published `/profile/<username>` page. diff --git a/.cursor/skills/verify-samehere/features/landing.md b/.cursor/skills/verify-samehere/features/landing.md new file mode 100644 index 0000000..da793fd --- /dev/null +++ b/.cursor/skills/verify-samehere/features/landing.md @@ -0,0 +1,44 @@ +# Landing + +The logged-out home page at `/` is the marketing surface: a hero, in-page Product / Community / Pricing sections, a community demo, a pricing band, and CTAs that take a visitor to signup or login. + +## Sub-features + +- `landing-open` renders the hero with samehere identity and the primary Join CTA. +- `landing-nav-login` opens `/login` from the desktop header. +- `landing-nav-join` opens `/signup` from the desktop header. +- `landing-nav-sections` jumps to `#product`, `#community`, and `#pricing` from `Page sections`. +- `landing-community-tabs` switches the Product areas tabs without leaving `/`. +- `landing-footer` reaches Pricing, Terms, Privacy, and Sign up from the footer. + +## How to get to it (user POV) + +- Open `http://127.0.0.1:4173/` in a logged-out browser. +- Choose the `samehere home` brand from login, signup, pricing, terms, or privacy. +- Follow a marketing link that points at `/` (no `?code=` query — that is forwarded to `/auth/callback`). + +## Driving it with control-samehere + +Preconditions: + +- samehere is healthy at `http://127.0.0.1:4173`. +- `control-samehere doctor` reports `landing=yes` and this run's URL. +- The browser has no `sb-*-auth-token` cookie (a signed-in visitor is redirected to `/feed`). + +- **Open landing.** Load `/`. Run `control-samehere browser goto --path /`. The document title contains `samehere`, a `link` named `samehere home` is visible, and the hero heading includes `people` and `Show what you’re building.` +- **Header login.** Choose `Log in`. Run `control-samehere browser click --role link --name "Log in" --nth 0`. The URL is `http://127.0.0.1:4173/login` and the heading reads `Log in`. +- **Return home.** Choose the brand. Run `control-samehere browser click --role link --name "samehere home" --nth 0`. The URL is `http://127.0.0.1:4173/` and the hero is visible again. +- **Header join.** Choose `Join free` in the header. Run `control-samehere browser click --role link --name "Join free" --nth 0`. The URL is `http://127.0.0.1:4173/signup` and the heading reads `Create your account`. +- **Hero join (second entry).** Return home, then choose the hero `Join free`. Run `control-samehere browser goto --path /` and `control-samehere browser click --role link --name "Join free" --nth 1`. The URL is `/signup` again. +- **Section nav.** Return home. Run `control-samehere browser goto --path /`, then `control-samehere browser click --role link --name "Product"`, `Community`, and `Pricing` in turn. After `Product` a heading `Give your project a story.` is in view. After `Community` a heading `Talk it through.` is in view. After `Pricing` a heading `Share your work. Make your portfolio your own.` is in view. The URL hash is `#product`, `#community`, and `#pricing` respectively. +- **Community tabs.** On `/` (or `#community`) choose `Messages`. Run `control-samehere browser click --role tab --name "Messages"`. The selected tab is `Messages` and the panel text includes `Talk with people on a similar path.` Choose `Portfolio`, then `Feed`, and confirm each selected tab's description: `Keep a shareable project page at your username.` / `Share what you are building, learning, or stuck on.` +- **Footer legal.** From `/` choose `Terms` in the footer. Run `control-samehere browser click --role link --name "Terms"`. The heading reads `Terms of Service`. Choose `samehere home`, then footer `Privacy`. The heading reads `Privacy Policy`. +- **Proof.** Recapture the populated landing. Run `control-samehere browser goto --path /`, `control-samehere browser snapshot --aria --path artifacts/landing/home.aria.txt`, and `control-samehere browser screenshot --path artifacts/landing/home.png`. Both artifacts show the samehere brand and the hero heading. After the join click, also keep `artifacts/landing/signup.png` and `artifacts/landing/signup.aria.txt` so the CTA result is paired with the start state. Write `artifacts/landing/run.json` with feature ID `landing`, the entry point used, the URL, and `run_id`. + +## Gotchas + +- At viewports below `md`, header `Log in` / `Join free` are not rendered. The helper viewport is 1280×800; do not shrink it and then report those links missing. +- Several `Join free` links exist (header, hero, community invite, pricing, finale). `--nth 0` is the header; `--nth 1` is the hero. Snapshot the URL after the click instead of guessing by visual position. +- `#community` is the "Talk it through." demo (`SocialPreview`), not the later "Bring your classmates…" invite band. +- A signed-in session on `/` 307s to `/feed`. If doctor still says `landing=yes` over HTTP but the browser shows the feed, the Chrome profile kept auth cookies — `cleanup` and relaunch. +- `/?code=...` is not the landing; middleware forwards it to `/auth/callback`. diff --git a/.cursor/skills/verify-samehere/features/login.md b/.cursor/skills/verify-samehere/features/login.md new file mode 100644 index 0000000..c41a1ee --- /dev/null +++ b/.cursor/skills/verify-samehere/features/login.md @@ -0,0 +1,46 @@ +# Log in + +Log in lets a returning visitor open the email/password form, reach forgot-password, and see a failed-sign-in alert without leaking whether an email exists. A signed-out visitor who hits a gated route is sent to signup, not login. + +## Sub-features + +- `login-open` shows the login form from the landing header and from signup. +- `login-fields` exposes Email, Password, `Log in`, and `Forgot password?`. +- `login-reject` shows `Invalid email or password.` (or a confirm-email alert) after a failed submit. +- `login-forgot` opens `/forgot-password` and accepts an email without revealing whether the account exists. +- `login-to-signup` follows `Create an account`. +- `login-oauth-visible` shows Google and GitHub when `invite_only=no`. + +## How to get to it (user POV) + +- Choose `Log in` in the landing header (desktop) or the landing mobile menu. +- Choose `Log in` on the signup footer. +- Open `http://127.0.0.1:4173/login` directly. +- Choose `Forgot password?` on the login form to reach `/forgot-password`. +- After a successful login the app navigates to `/feed` (not in scope without a real session). + +## 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 do not have (and will not use) a real password for a production user. + +- **Open from landing.** Choose header `Log in`. Run `control-samehere browser goto --path /` and `control-samehere browser click --role link --name "Log in" --nth 0`. The URL is `/login`, the heading reads `Log in`, and the aside heading `Welcome back.` is visible. +- **Open from signup.** Run `control-samehere browser goto --path /signup` and `control-samehere browser click --role link --name "Log in" --nth 0`. Same `/login` heading. +- **Read fields.** Textboxes `Email` and `Password`, button `Log in`, link `Forgot password?`. If `invite_only=no`, buttons `Continue with Google` and `Continue with GitHub` are visible — do not click them. +- **Failed password.** Fill a well-formed email and a password and submit. Run `control-samehere browser fill --role textbox --name "Email" --value "verify@school.edu"`, `control-samehere browser fill --role textbox --name "Password" --value "wrong-password"`, and `control-samehere browser click --role button --name "Log in"`. An `alert` reads `Invalid email or password.` The URL stays `/login`. (A confirmed-but-unverified account would instead read `Confirm your email first, check your inbox for the link.` — still a failure, still on `/login`.) +- **Empty submit.** Reload `/login` and choose `Log in` with empty fields. HTML5 validation may prevent navigation. The URL stays `/login` and there is no session cookie. Do not require an `alert` unless one appears (`Enter your email and password.`). +- **Forgot password.** Choose `Forgot password?`. Run `control-samehere browser click --role link --name "Forgot password?"`. The heading reads `Reset your password` and the URL is `/forgot-password`. Fill `School email` with `verify@school.edu` and choose `Send reset link`. Run `control-samehere browser fill --role textbox --name "School email" --value "verify@school.edu"` and `control-samehere browser click --role button --name "Send reset link"`. The heading becomes `Check your email` and a status reads `If an account exists for that email, we sent a link to reset your password.` +- **Cross-link.** From `/login` choose `Create an account`. Run `control-samehere browser goto --path /login` and `control-samehere browser click --role link --name "Create an account"`. The URL is `/signup`. +- **Gated route is signup, not login.** Run `control-samehere http get --path /messages --expect-status 307`. `Location` contains `/signup`, not `/login`. +- **Proof.** Capture `/login` with the invalid-password `alert`. Run `control-samehere browser snapshot --aria --path artifacts/login/invalid.aria.txt` and `control-samehere browser screenshot --path artifacts/login/invalid.png`. Both show heading `Log in` and the alert. Write `artifacts/login/run.json`. + +## Gotchas + +- Failed login copy is deliberately vague. `Invalid email or password.` does not prove the email is unknown. +- `Forgot password` always claims a link was sent. That page is not proof a message left the machine — check only the user-visible success copy. Against `env_mode=scaffolded` the network call to Supabase fails silently and the same copy still appears. +- A real success redirects to `/feed`. If that happens during verification, stop and cleanup; you used a live credential. +- `invite_only=yes` hides OAuth on this page for the same reason as signup (first OAuth login would create an account). +- Header `Log in` is desktop-only. Use `--nth 0` after `goto /` at 1280×800. diff --git a/.cursor/skills/verify-samehere/features/pricing.md b/.cursor/skills/verify-samehere/features/pricing.md new file mode 100644 index 0000000..d006e2c --- /dev/null +++ b/.cursor/skills/verify-samehere/features/pricing.md @@ -0,0 +1,38 @@ +# Pricing + +Pricing shows the Free and Pro plans — $0 forever versus $4.99/month · $12.99/semester — on both the dedicated `/pricing` page and the landing `#pricing` band, and offers `Join free` plus `View Pro`. + +## Sub-features + +- `pricing-page` renders `/pricing` with the two plan articles and the public header. +- `pricing-landing` renders the same plan copy on `/#pricing`. +- `pricing-join` takes `Join free` to `/signup`. +- `pricing-pro` follows `View Pro` (logged-out, this 307s to `/signup` because `/pro` is gated). +- `pricing-footer` reaches `/pricing` from the landing footer. + +## How to get to it (user POV) + +- Open `http://127.0.0.1:4173/pricing`. +- Choose `Pricing` in the landing `Page sections` nav or the footer `Pricing` link. +- Scroll the landing page to the band headed `Share your work. Make your portfolio your own.` + +## Driving it with control-samehere + +Preconditions: + +- samehere is healthy at `http://127.0.0.1:4173`. +- `control-samehere doctor` reports this run's URL. + +- **Open the page.** Run `control-samehere browser goto --path /pricing`. The document title contains `Pricing`, a `link` named `samehere home` is visible, the heading reads `Share your work. Make your portfolio your own.`, and two articles include `Share work and find peers` ($0 forever) and `More room to present your work` ($4.99 /month · $12.99/semester). +- **Header join.** On `/pricing` choose `Join free`. Run `control-samehere browser click --role link --name "Join free" --nth 0`. The URL is `/signup`. +- **Footer entry.** Run `control-samehere browser goto --path /` and `control-samehere browser click --role link --name "Pricing"`. Accept either `/pricing` (footer) or `/#pricing` (section nav). If the URL is `/`, the `#pricing` heading above is in view. If you need the dedicated page specifically, run `control-samehere browser click --role link --name "Pricing"` from the footer (`navigation` `Footer`) or `goto --path /pricing`. +- **View Pro, logged out.** On `/pricing` or `/#pricing` choose `View Pro`. Run `control-samehere browser goto --path /pricing` and `control-samehere browser click --role link --name "View Pro"`. `control-samehere http get --path /pro --expect-status 307` has `Location` containing `/signup`. The browser lands on `/signup` (heading `Create your account`), not a billing checkout. +- **Proof.** Capture `/pricing` with both plan names visible. Run `control-samehere browser goto --path /pricing`, `control-samehere browser snapshot --aria --path artifacts/pricing/page.aria.txt`, and `control-samehere browser screenshot --path artifacts/pricing/page.png`. Artifacts show `Pricing` identity (brand + heading) and both plan titles. Write `artifacts/pricing/run.json`. + +## Gotchas + +- Root layout appends ` · samehere` to the title. The visible `<title>` is `Pricing · samehere`, not `Pricing — samehere · samehere`. +- `View Pro` points at `/pro`. Logged-out visitors never see Stripe checkout; middleware sends them to signup first. Do not treat `/signup` as proof that billing is live. +- `NEXT_PUBLIC_BILLING_ENABLED` only changes `/pro` after a session exists. It is out of scope for this feature unless you have a real login. +- Landing `#pricing` and `/pricing` share copy but not chrome: `/pricing` has `PublicHeader` + `Join free`; the landing band sits under `LandingNav`. +- Two `Pricing` links exist on `/` (section nav and footer). Prefer `goto --path /pricing` when the recipe needs the dedicated page. diff --git a/.cursor/skills/verify-samehere/features/public-profile.md b/.cursor/skills/verify-samehere/features/public-profile.md new file mode 100644 index 0000000..55d2eeb --- /dev/null +++ b/.cursor/skills/verify-samehere/features/public-profile.md @@ -0,0 +1,38 @@ +# Public profile + +A published profile at `/profile/<username>` is reachable while logged out. A missing or unreadable username shows `Profile not found`. Live identity, school, counts, and portfolio sections require a real Supabase project and a username that exists. + +## Sub-features + +- `profile-missing` shows `Profile not found` for an unknown username. +- `profile-edit-gated` keeps `/profile/edit` off the anonymous public surface (307 to `/signup`). +- `profile-public` (only when `env_mode=repo` and `SAMEHERE_VERIFY_PROFILE_USERNAME` is set) renders that student's public page. +- `profile-brand` still offers a way off the missing-profile page (`Back to feed` / `Search`, which themselves gate to signup when logged out). + +## How to get to it (user POV) + +- Open `http://127.0.0.1:4173/profile/<username>` from a share link or by typing the URL. +- From a signed-in session, choose `Profile` in the left nav (out of scope here — needs a session). +- `/profile/edit` is the editor, not a public username. + +## Driving it with control-samehere + +Preconditions: + +- samehere is healthy at `http://127.0.0.1:4173`. +- `control-samehere doctor` reports this run's URL. +- For `profile-public` only: `env_mode=repo` and `SAMEHERE_VERIFY_PROFILE_USERNAME` is a username that `get_public_profile` returns. Otherwise skip `profile-public` and record it unreachable. + +- **Missing profile.** Open a username that does not exist. Run `control-samehere browser goto --path /profile/verify_no_such_user`. The page text includes `Profile not found` and `This student doesn’t exist, or the link is broken.` Links `Back to feed` and `Search` are visible. +- **Missing-profile exits, logged out.** Choose `Back to feed`. Run `control-samehere browser click --role link --name "Back to feed"`. The browser ends on `/signup` (heading `Create your account`), not `/feed`. Confirm with `control-samehere http get --path /feed --expect-status 307`. Repeat from the missing profile with `Search` if you need that entry; it also gates. +- **Edit is not public.** Run `control-samehere http get --path /profile/edit --expect-status 307`. `Location` contains `/signup`. +- **Published profile (optional).** If the precondition is met, run `control-samehere browser goto --path /profile/$SAMEHERE_VERIFY_PROFILE_USERNAME`. The page does **not** say `Profile not found`. The username or display name is visible, and a `samehere home` or in-app identity is present. Private accounts may hide posts and still show identity and counts — that is a valid published state. If the page 404s, the username is wrong or the RPC failed; report unreachable, do not substitute another user. +- **Proof.** Capture the missing-profile state (always in scope). Run `control-samehere browser goto --path /profile/verify_no_such_user`, `control-samehere browser snapshot --aria --path artifacts/public-profile/missing.aria.txt`, and `control-samehere browser screenshot --path artifacts/public-profile/missing.png`. Both show `Profile not found`. If `profile-public` ran, also write `artifacts/public-profile/published.png` and `published.aria.txt` for that username. Write `artifacts/public-profile/run.json` listing which sub-features ran. + +## Gotchas + +- `env_mode=scaffolded` makes every username look missing because the public RPC cannot run. That still proves `profile-missing`. It does not prove `profile-public`. +- `/profile/edit` matches the editor route, not username `edit`. Anon visitors never see a "Profile not found" for `edit`; they are redirected to signup. +- Do not use the anon key to enumerate profiles. Only open a username you were given (`SAMEHERE_VERIFY_PROFILE_USERNAME`) or the fixed missing fixture `verify_no_such_user`. +- `Back to feed` / `Search` on the not-found card are signed-in destinations. Logged-out proof is the redirect to signup, not a feed timeline. +- Public posts at `/post/<id>` are a different surface (not this feature). A single post can 404 independently of the profile. diff --git a/.cursor/skills/verify-samehere/features/signup.md b/.cursor/skills/verify-samehere/features/signup.md new file mode 100644 index 0000000..2b95905 --- /dev/null +++ b/.cursor/skills/verify-samehere/features/signup.md @@ -0,0 +1,49 @@ +# Sign up + +Create your account lets a visitor open the signup form from marketing CTAs, fill email / username / password (and an invite or referral code), see validation errors, and reach login without creating a throwaway production user. + +## Sub-features + +- `signup-open` shows the create-account form from each listed entry point. +- `signup-fields` exposes Email, Username, Password, and Referral/Invite code. +- `signup-validate-username` rejects short and reserved usernames with an `alert`. +- `signup-validate-password` rejects a password shorter than 8 characters with an `alert`. +- `signup-oauth-visible` shows Google and GitHub buttons when `invite_only=no`. +- `signup-invite-only` shows the invite hint and `Invite code` when `invite_only=yes`. +- `signup-to-login` follows `Log in` to `/login`. + +## How to get to it (user POV) + +- Choose `Join free`, `Join`, `Join to get a link`, or `Sign up` on the landing page. +- Open `http://127.0.0.1:4173/signup` directly. +- Choose `Create an account` on the login footer. +- Follow an invite or referral link of the form `/signup?ref=<code>` (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>\([^<]*\)<\/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" + } +}