diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..632d25e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + push: + branches: + - dev + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: Typecheck, lint, test, and build + runs-on: ubuntu-latest + env: + CI: true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Enable pnpm from packageManager + run: corepack enable && corepack prepare pnpm@10.33.0 --activate + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check types + run: pnpm check-types + + - name: Lint + run: pnpm lint + + - name: Unit and component tests + run: pnpm test + + - name: Build web + run: pnpm --filter web build + + - name: Verify database static checks + run: pnpm --filter @repo/db db:verify:static diff --git a/.gitignore b/.gitignore index 6e143a44..0dd7573c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ node_modules/ # Local env files .env* +!.env.example +!.env.*.example +!**/.env.example +!**/.env.*.example # Testing diff --git a/README.md b/README.md index be5af19b..d5ee2149 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ pnpm install Start local development: ```bash +pnpm run doctor pnpm dev +pnpm dev:web:tunnel pnpm dev:web:scan ``` @@ -36,9 +38,12 @@ pnpm --filter mobile dev pnpm self-host:up ``` -Run the main validation commands before opening a PR: +Run the main validation commands before handoff or review: ```bash +pnpm verify:static +pnpm verify +pnpm verify:full pnpm knip pnpm knip:full pnpm check-types @@ -48,15 +53,15 @@ pnpm test ## Development workflow -1. Create a focused branch for one change. +1. Work from the shared `dev` checkout for normal focused changes. 2. Make the smallest change that solves the problem. 3. Run the relevant checks locally. -4. Open a PR with a short summary of the user-facing outcome. +4. Commit to `dev` after checks pass; open a PR only for explicit review, release, or hotfix flows. ## Multi-Agent Workflow - Keep orchestration and review in the root checkout. -- For medium and large efforts, use one Worktrunk worktree per bounded lane under `~/GradientPeak/.worktrees/`. +- For medium and large efforts, use one worktree per bounded lane under `~/GradientPeak/.worktrees/`. - Keep all GradientPeak worktrees inside the repo-local `.worktrees/` directory. - Use branch names shaped like `spec//` for multi-lane efforts. - Require merge packets and a fan-in owner before integrating parallel lane outputs. @@ -66,9 +71,8 @@ pnpm test - `apps/web/README.md` covers web-specific setup and commands. - `apps/mobile/README.md` covers mobile-specific setup, testing, and E2E entrypoints. - `packages/db/README.md` covers database ownership and local DB tooling. -- `provider-onboarding.md` documents the recommended flow for onboarding a new third-party provider starting from the current Wahoo-first setup. -Environment files should live with the owning app or package, not at the repo root. +Environment files should live with the owning app or package, not at the repo root. Start from the checked-in `.env.example` files and keep secret-bearing `.env` files untracked. Git hooks are managed with `lefthook` and install automatically from `pnpm install`. diff --git a/apps/mobile/.env.e2e.example b/apps/mobile/.env.e2e.example new file mode 100644 index 00000000..36f0632c --- /dev/null +++ b/apps/mobile/.env.e2e.example @@ -0,0 +1,21 @@ +# GradientPeak mobile Maestro/E2E template. +# Copy to apps/mobile/.env.e2e for local Maestro runs. + +APP_ENV=development +EXPO_PUBLIC_MAESTRO_E2E=1 +EXPO_PUBLIC_API_URL=http://127.0.0.1:3000 +EXPO_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +EXPO_PUBLIC_MAESTRO_E2E_API_URL=http://10.0.2.2:3000 +EXPO_PUBLIC_MAESTRO_E2E_SUPABASE_URL=http://10.0.2.2:54321 +EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +EXPO_PUBLIC_REDIRECT_URI=gradientpeak-dev://integrations +EXPO_PUBLIC_GOOGLE_API_KEY= +MAESTRO_APP_ID=com.deancochran.gradientpeak.dev +MAESTRO_SIGNUP_EMAIL_PREFIX=gradientpeak.maestro +MAESTRO_SIGNUP_EMAIL_DOMAIN=example.com + +# Sentry and PostHog are normally disabled for E2E unless these are filled. +EXPO_PUBLIC_SENTRY_DSN= +EXPO_PUBLIC_ENABLE_SENTRY_IN_DEV=0 +EXPO_PUBLIC_POSTHOG_KEY= +EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 00000000..6c6a85a7 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,25 @@ +# GradientPeak mobile local development template. +# Copy to apps/mobile/.env and fill in real local or hosted values. + +APP_ENV=development +EXPO_PUBLIC_APP_SCHEME=gradientpeak-dev +EXPO_PUBLIC_API_URL=http://localhost:3000 +EXPO_PUBLIC_SUPABASE_URL=http://localhost:54321 +EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +EXPO_PUBLIC_REDIRECT_URI=gradientpeak-dev://integrations +EXPO_PUBLIC_GOOGLE_API_KEY= +EXPO_PUBLIC_ENABLE_SERVER_OVERRIDE=1 +EXPO_PUBLIC_ENABLE_MOBILE_LOGS=0 + +# Sentry and PostHog telemetry. Leave blank to disable local telemetry. +EXPO_PUBLIC_SENTRY_DSN= +EXPO_PUBLIC_ENABLE_SENTRY_IN_DEV=0 +EXPO_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=1 +EXPO_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1 +EXPO_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0 +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_AUTH_TOKEN= +EXPO_PUBLIC_POSTHOG_KEY= +EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +EXPO_PUBLIC_POSTHOG_DEBUG=0 diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a9a2164d..796f09ec 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -28,7 +28,7 @@ Run these from the repo root: ```bash pnpm --filter mobile dev -pnpm --filter mobile storybook +pnpm --filter mobile storybook:start pnpm --filter mobile check-types pnpm --filter mobile lint pnpm --filter mobile test @@ -40,13 +40,13 @@ pnpm --filter mobile test:e2e ```bash pnpm --filter mobile android:dev -pnpm --filter mobile android:e2e:build -pnpm --filter mobile generate:maestro -pnpm --filter mobile maestro:lane -- smoke +pnpm --filter mobile maestro:prepare +pnpm --filter mobile test:e2e +pnpm --filter mobile test:e2e:flow -- .maestro/flows/main ``` ## Related docs -- `apps/mobile/.maestro/README.md` for Maestro flow execution. +- `apps/mobile/scripts/maestro.mjs` wraps Maestro setup, launch, cleanup, and flow execution. - `README.md` for repo-level workflow and validation commands. - `packages/db/README.md` for local DB tooling used by app-backed workflows. diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..6cfaccd5 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,61 @@ +# GradientPeak web local development template. +# Copy to apps/web/.env.local and fill in provider/client secrets as needed. + +APP_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 +BETTER_AUTH_SECRET=local-development-secret-change-me + +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PRIVATE_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +NEXT_PRIVATE_SUPABASE_SECRET_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +SUPABASE_AUTH_SITE_URL=http://localhost:3000 +SUPABASE_AUTH_WEB_REDIRECT_URL=http://localhost:3000/auth/confirm +SUPABASE_AUTH_WEB_REDIRECT_URL_SECURE=http://localhost:3000/auth/confirm + +OAUTH_CALLBACK_BASE_URL=http://localhost:3000 +WAHOO_WEBHOOK_TOKEN= +STRAVA_CLIENT_ID= +STRAVA_CLIENT_SECRET= +WAHOO_CLIENT_ID= +WAHOO_CLIENT_SECRET= +TRAININGPEAKS_CLIENT_ID= +TRAININGPEAKS_CLIENT_SECRET= +GARMIN_CLIENT_ID= +GARMIN_CLIENT_SECRET= +ZWIFT_CLIENT_ID= +ZWIFT_CLIENT_SECRET= + +NEXT_PUBLIC_MOBILE_AUTH_REDIRECT_URI=gradientpeak-dev://auth/confirm +NEXT_PUBLIC_MOBILE_REDIRECT_URI=gradientpeak-dev://integrations +NEXT_PUBLIC_MOBILE_REDIRECT_FALLBACK=gradientpeak-dev://integrations +AUTH_ALLOWED_DEEP_LINK_PREFIXES=gradientpeak-dev://,gradientpeak:// +EXPO_PUBLIC_APP_SCHEME=gradientpeak-dev + +AUTH_EMAIL_MODE=smtp +AUTH_EMAIL_FROM=GradientPeak Local +AUTH_EMAIL_REPLY_TO=noreply@localhost +AUTH_SMTP_HOST=127.0.0.1 +AUTH_SMTP_PORT=54324 +AUTH_SMTP_USER= +AUTH_SMTP_PASS= +AUTH_SMTP_SECURE=false + +# Sentry and PostHog telemetry. These are safe to leave blank for local work. +# Fill them when you want the local dev server/browser to send events upstream. +APP_ENV=development +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=1 +VITE_SENTRY_DSN= +VITE_SENTRY_TRACES_SAMPLE_RATE=1 +POSTHOG_KEY= +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_SECRET_KEY= +POSTHOG_PERSONAL_API_KEY= +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST=https://us.i.posthog.com +VITE_POSTHOG_DEBUG=0 diff --git a/apps/web/README.md b/apps/web/README.md index f59cc64d..54ffa1fd 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -18,7 +18,8 @@ pnpm install Run the web app: ```bash -pnpm --filter web dev +pnpm --filter web dev:local +pnpm --filter web dev:tunnel ``` Run the main checks: @@ -103,10 +104,10 @@ SMTP sinks. ## Container Publishing -The production container workflow is defined in: +Production container publishing should be defined in: - `.github/workflows/publish-container.yml` -It now builds: +When restored/enabled, it should build: - `apps/web/Dockerfile` diff --git a/apps/web/package.json b/apps/web/package.json index d7bb2491..cc37e9ab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,13 +3,16 @@ "private": true, "type": "module", "scripts": { - "dev": "concurrently \"pnpm run dev:app\" \"pnpm run dev:ngrok\"", + "dev": "pnpm run dev:local", + "dev:local": "pnpm run dev:app", + "dev:tunnel": "concurrently \"pnpm run dev:app\" \"pnpm run dev:ngrok\"", "dev:app": "pnpm exec dotenv -e .env.local -- vite dev --host 0.0.0.0 --port 3000", "dev:wahoo-drain": "pnpm exec dotenv -e .env.local -- node scripts/drain-wahoo-provider-sync.mjs", "dev:wahoo-drain:direct": "pnpm exec dotenv -e .env.local -- vite-node scripts/drain-wahoo-provider-sync-direct.ts", "dev:wahoo-drain:once": "pnpm exec dotenv -e .env.local -- node scripts/drain-wahoo-provider-sync.mjs --once", "dev:ngrok": "ngrok http --domain=outgoing-ape-repeatedly.ngrok-free.app 3000", - "dev:scan": "concurrently \"VITE_ENABLE_REACT_SCAN=1 pnpm run dev:app\" \"pnpm run dev:ngrok\"", + "dev:scan": "VITE_ENABLE_REACT_SCAN=1 pnpm run dev:app", + "dev:scan:tunnel": "concurrently \"VITE_ENABLE_REACT_SCAN=1 pnpm run dev:app\" \"pnpm run dev:ngrok\"", "build": "vite build", "check-types": "tsc --noEmit", "preview": "vite preview", diff --git a/package.json b/package.json index 3c4a4109..4716646a 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,14 @@ "audit:forms": "node tooling/forms/audit-forms.mjs", "check": "biome check apps packages tooling .github README.md package.json pnpm-workspace.yaml turbo.json biome.json", "check:ci": "biome ci apps packages tooling .github README.md package.json pnpm-workspace.yaml turbo.json biome.json", - "dev": "pnpm --filter web dev", + "doctor": "node tooling/doctor.mjs", + "dev": "pnpm --filter web dev:local", "dev:all": "turbo run dev", "dev:clean": "pnpm exec k 3000 3100 8081 8082 || true && pnpm run self-host:down >/dev/null 2>&1 || true && printf '[dev-clean] local web/mobile dev servers and Supabase stopped\n'", "dev:e2e": "turbo run dev:e2e --parallel --ui tui", "dev:web": "pnpm --filter web dev", + "dev:web:local": "pnpm --filter web dev:local", + "dev:web:tunnel": "pnpm --filter web dev:tunnel", "dev:wahoo-drain": "pnpm --filter web dev:wahoo-drain", "dev:wahoo-drain:direct": "pnpm --filter web dev:wahoo-drain:direct", "dev:wahoo-drain:once": "pnpm --filter web dev:wahoo-drain:once", @@ -57,6 +60,9 @@ "test": "turbo run test", "test:parity": "pnpm exec vitest run --config vitest.parity.config.ts", "test:e2e:web": "pnpm --filter web test:e2e", + "verify:static": "pnpm check && pnpm check-types", + "verify": "pnpm verify:static && pnpm test", + "verify:full": "pnpm verify && pnpm knip", "quality:agent": "pnpm check && pnpm check-types && pnpm test && pnpm knip", "self-host:up": "pnpm --filter @repo/db self-host:up", "self-host:down": "pnpm --filter @repo/db self-host:down" diff --git a/packages/db/.env.example b/packages/db/.env.example new file mode 100644 index 00000000..e87d8127 --- /dev/null +++ b/packages/db/.env.example @@ -0,0 +1,6 @@ +# GradientPeak local database template. +# Copy to packages/db/.env when DB package commands need explicit connection values. + +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +SUPABASE_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres diff --git a/tooling/doctor.mjs b/tooling/doctor.mjs new file mode 100644 index 00000000..11d27bbf --- /dev/null +++ b/tooling/doctor.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createServer } from "node:net"; + +const checks = []; + +function addCheck(name, status, message, required = false) { + checks.push({ name, status, message, required }); +} + +function commandOutput(command, args = []) { + const result = spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) return null; + return result.stdout.trim(); +} + +function commandExists(command) { + const lookup = process.platform === "win32" ? "where" : "command"; + const args = process.platform === "win32" ? [command] : ["-v", command]; + return commandOutput(lookup, args) !== null; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function readMiseTools() { + const content = readFileSync("mise.toml", "utf8"); + return Object.fromEntries( + [...content.matchAll(/^([a-zA-Z0-9_-]+)\s*=\s*"([^"]+)"/gm)].map((match) => [ + match[1], + match[2], + ]), + ); +} + +function versionMajor(version) { + return version.replace(/^v/, "").split(".")[0]; +} + +async function isPortAvailable(port) { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +const packageJson = readJson("package.json"); +const miseTools = readMiseTools(); + +const expectedNode = miseTools.node; +const actualNode = process.version; +addCheck( + "Node", + versionMajor(actualNode) === expectedNode ? "pass" : "fail", + `expected ${expectedNode} from mise.toml, found ${actualNode}`, + true, +); + +const expectedPnpm = packageJson.packageManager?.replace(/^pnpm@/, ""); +const actualPnpm = commandOutput("pnpm", ["--version"]); +addCheck( + "pnpm", + actualPnpm === expectedPnpm ? "pass" : "fail", + `expected ${expectedPnpm}, found ${actualPnpm ?? "missing"}`, + true, +); + +for (const tool of ["supabase", "docker"]) { + addCheck( + tool, + commandExists(tool) ? "pass" : "warn", + commandExists(tool) + ? `${tool} is available` + : `${tool} is missing; local Supabase workflows may not run`, + ); +} + +for (const tool of ["maestro", "adb", "ngrok"]) { + addCheck( + tool, + commandExists(tool) ? "pass" : "warn", + commandExists(tool) + ? `${tool} is available` + : `${tool} is missing; related workflows are optional`, + ); +} + +const envPairs = [ + ["apps/web/.env.local", "apps/web/.env.example"], + ["apps/mobile/.env", "apps/mobile/.env.example"], + ["apps/mobile/.env.e2e", "apps/mobile/.env.e2e.example"], + ["packages/db/.env", "packages/db/.env.example"], +]; + +for (const [localPath, examplePath] of envPairs) { + if (!existsSync(examplePath)) { + addCheck(examplePath, "fail", "missing checked-in env template", true); + continue; + } + addCheck(examplePath, "pass", "env template exists"); + addCheck( + localPath, + existsSync(localPath) ? "pass" : "warn", + existsSync(localPath) + ? "local env file exists" + : `copy ${examplePath} when this surface is needed`, + ); +} + +for (const port of [3000, 8081, 54321, 54322, 54324]) { + const available = await isPortAvailable(port); + addCheck( + `port ${port}`, + available ? "pass" : "warn", + available ? "available" : "already in use; this may be expected if dev services are running", + ); +} + +const icons = { pass: "✓", warn: "!", fail: "✗" }; +for (const check of checks) { + console.log(`${icons[check.status]} ${check.name}: ${check.message}`); +} + +const failures = checks.filter((check) => check.status === "fail" && check.required); +if (failures.length > 0) { + console.error(`\nDoctor found ${failures.length} required issue(s).`); + process.exit(1); +} + +console.log("\nDoctor completed. Warnings are optional/local workflow notes.");