diff --git a/.gitea/workflows/build-next.yml b/.gitea/workflows/build-next.yml index 172a314..381a5d2 100644 --- a/.gitea/workflows/build-next.yml +++ b/.gitea/workflows/build-next.yml @@ -2,55 +2,86 @@ name: Build and Push Next App on: push: - branches: [main] + branches: + - main paths: - - 'apps/**' + - 'apps/next/**' - 'packages/**' - 'tools/**' - 'scripts/**' - 'docker/**' - '.gitea/workflows/build-next.yml' - - '.infisical.json' - 'package.json' - 'bun.lock' - 'turbo.json' +# One image build at a time; a newer push waits rather than racing. +concurrency: + group: build-next + cancel-in-progress: false + +# This template is not deployed. CI runs the quality gate and builds and pushes +# the image so the template stays known-good. Env comes from the Infisical +# `staging` environment: each job logs in as the read-only +# `convexmonorepo-base-ci` machine identity and writes it to a temp file +# (scripts/ci-export-env.mjs), so no values live in Gitea. jobs: + # Quality gate: workspace lint (sherif), eslint, typecheck and the unit, + # integration and component suites must pass before anything touches + # production. The local-stack e2e smoke check is skipped in CI. quality: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 - - run: bun install --frozen-lockfile - - name: Lint, typecheck, and test + bun-version: 1.3.14 + + - name: Install dependencies + run: ./scripts/ci-install + + - name: Lint and typecheck env: - DOTENV_PROD: ${{ secrets.DOTENV_PROD }} + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }} run: | env_file="$(mktemp)" trap 'rm -f "$env_file"' EXIT - printf '%s\n' "$DOTENV_PROD" > "$env_file" + node scripts/ci-export-env.mjs staging > "$env_file" bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check build-next: needs: [quality] runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + - name: Log in to container registry - run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.gbrown.org -u "${{ secrets.REGISTRY_USER }}" --password-stdin - - name: Build image + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.gbrown.org -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build app using project script env: - DOTENV_PROD: ${{ secrets.DOTENV_PROD }} + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }} run: | env_file="$(mktemp)" trap 'rm -f "$env_file"' EXIT - printf '%s\n' "$DOTENV_PROD" > "$env_file" + node scripts/ci-export-env.mjs staging > "$env_file" CI_ENV_FILE="$env_file" ./scripts/build-next-app staging - - name: Tag and push image + + - name: Tag built image run: | docker tag convexmonorepo-no-payload-next:latest git.gbrown.org/gib/convexmonorepo-no-payload-next:${{ gitea.sha }} docker tag convexmonorepo-no-payload-next:latest git.gbrown.org/gib/convexmonorepo-no-payload-next:latest + + - name: Push image tags + run: | docker push git.gbrown.org/gib/convexmonorepo-no-payload-next:${{ gitea.sha }} docker push git.gbrown.org/gib/convexmonorepo-no-payload-next:latest diff --git a/.gitignore b/.gitignore index 71c6ac5..17d259b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ apps/next/playwright-report/ # tanstack .tanstack + +# local command flags (machine-specific, e.g. which Infisical account to use) +scripts/flags/ diff --git a/.infisical.json b/.infisical.json index 96c2371..1c24aba 100644 --- a/.infisical.json +++ b/.infisical.json @@ -1,5 +1,6 @@ { - "workspaceId": "b2760150-c581-4e0d-acb5-b4da51cee58d", + "workspaceId": "93d78472-e1a0-4faf-8874-e57aa030a06f", "defaultEnvironment": "", - "gitBranchToEnvironmentMapping": null + "gitBranchToEnvironmentMapping": null, + "domain": "https://infisical.gbrown.org/api" } diff --git a/AGENTS.md b/AGENTS.md index 7effe7c..3271737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/ui`: shared shadcn-based UI components. - `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. -- Local development uses host-run apps, local Convex on ports 3210/3211, and +- Local development uses host-run apps, local Convex on ports 3250/3251, and its own data volume. Convex does not use Postgres unless a cloned project explicitly enables the commented optional `POSTGRES_URL` example. @@ -27,8 +27,18 @@ - Run `infisical login` and `infisical init` before local development. - Machine-generated values belong in `.local/.generated.env`; never put the generated Convex admin key in shared Infisical. -- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical. -- App code imports validated variables from `@/env`, never `process.env`. +- CI reads `staging` through `scripts/ci-export-env.mjs`, logged in as the + read-only `convexmonorepo-base-ci` machine identity (Gitea secrets + `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`). `scripts/export-env` + refuses to run in CI. +- This template's `staging` has no Convex admin key, so nothing here can deploy + to a real backend. A cloned app gets its own Infisical project. +- If you are logged in to more than one Infisical account, put + `INFISICAL_EMAIL=...` in `scripts/flags/infisical.env` (gitignored) and the + scripts switch the CLI to that account before exporting. +- Server code imports validated variables from `@/env`. Client components and + `instrumentation-client.ts` import from `@/publicEnv`, which keeps zod out of + the browser bundle. Never read `process.env` elsewhere. - Add cache-relevant variables to `turbo.json` `globalEnv`. ## Code and dependency rules @@ -50,13 +60,28 @@ bun db:up # start Convex and dashboard bun dev:next # host Next + deploy/watch local Convex functions bun db:down # stop and preserve Convex data bun db:down:wipe # remove Convex volume and generated admin key +bun db:sync-env # re-push Infisical dev values into the local Convex env +bun dev:kill # stop dev servers that survived a Ctrl-C ``` -Local URLs: app `http://localhost:3000`, Convex `http://localhost:3210`, -dashboard `http://localhost:6791`. +Local URLs: app `http://localhost:3000`, Convex `http://localhost:3250`, +dashboard `http://localhost:6795`. The Compose project and containers are named +`convexmonorepo-base-local*` so this stack can run beside the Payload template's. Use `INFISICAL_ENV=staging bun dev:next` for staging services. Normal `db:up` never connects to staging and does not start Postgres. +## Analytics and bundle size + +- Plausible (`NEXT_PUBLIC_PLAUSIBLE_SRC`, the per-site script URL) loads through + a same-origin proxy under `/pa`. PostHog (`NEXT_PUBLIC_POSTHOG_KEY`, + `NEXT_PUBLIC_POSTHOG_HOST`) loads after the page is idle through `/ingest`. + Both stay off when their env is unset. +- Session replay comes from PostHog. Sentry carries errors and traces only. +- `packages/ui/src/index.tsx` holds re-exports only (`cn` and `ccn` live in + `utils.ts`). With `optimizePackageImports` in `next.config.js`, each page + loads only the UI modules it imports. Declaring anything in the index breaks + that. + ## Validation Use `bun typecheck`, never a production build for routine validation. The full diff --git a/README.md b/README.md index 3bd7b2b..64d17f4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ shared UI/config packages, Vitest, and Docker deployment. ## Local setup -Requirements: Bun 1.3.10, Docker or Podman, Node 22, and the Infisical CLI. +Requirements: Bun 1.3.14, Docker or Podman, Node 22, the Infisical CLI, `jq`, and +`flock`. ```sh bun install --frozen-lockfile @@ -22,8 +23,8 @@ files. Select staging with `INFISICAL_ENV=staging bun dev:next`. Local services: - Next.js: `http://localhost:3000` -- Convex API: `http://localhost:3210` -- Convex dashboard: `http://localhost:6791` +- Convex API: `http://localhost:3250` +- Convex dashboard: `http://localhost:6795` Next and Expo run on the host. Convex uses a self-hosted data volume and does not use Postgres by default. The commented `POSTGRES_URL` in Compose is an @@ -46,7 +47,8 @@ Convex URL with the development host's LAN address when testing Expo on-device. - Local `dev` and `staging`: Infisical. - Generated local state: `.local/.generated.env`. -- CI/CD: Gitea `DOTENV_PROD`, materialized only as a temporary runner file. +- CI/CD: Infisical `staging`, read by the read-only `convexmonorepo-base-ci` + machine identity and materialized only as a temporary runner file. - Docker compilation: explicit Compose build args; `.env*` stays outside the image context. @@ -81,5 +83,11 @@ Shared dependency versions belong in root catalogs. Edit the root catalog, run Production Compose retains the self-hosted Convex backend/dashboard and has no Postgres service by default. A commented `POSTGRES_URL` remains only as an optional Convex-on-Postgres example for cloned projects. Gitea runs the quality -gate first, builds the Next image from a temporary Gitea-secret env file, then -pushes SHA and `latest` tags. CI never installs or invokes Infisical. +gate first, then builds the Next image and pushes SHA and `latest` tags. Each +job reads Infisical `staging` through `scripts/ci-export-env.mjs` as the +`convexmonorepo-base-ci` machine identity (Gitea secrets `INFISICAL_CLIENT_ID` +and `INFISICAL_CLIENT_SECRET`). + +The template itself is not deployed. A cloned app that is adds a `convex-deploy` +job and the VPS deploy step from an app that already has them (StPeteIT, +LashAddict, bill-tracker), plus its own Infisical project and machine identity. diff --git a/docker/Dockerfile b/docker/Dockerfile index cf75d44..4c7eaba 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,16 +1,18 @@ # syntax=docker/dockerfile:1 -FROM docker.io/oven/bun:1.3.10-alpine AS base +FROM docker.io/oven/bun:1.3.14-alpine AS base # Builder stage FROM base AS builder -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache curl libc6-compat WORKDIR /app ARG SENTRY_AUTH_TOKEN ARG SENTRY_DISABLE_AUTO_UPLOAD=false ARG NEXT_PUBLIC_SITE_URL ARG NEXT_PUBLIC_CONVEX_URL -ARG NEXT_PUBLIC_PLAUSIBLE_URL +ARG NEXT_PUBLIC_PLAUSIBLE_SRC +ARG NEXT_PUBLIC_POSTHOG_KEY +ARG NEXT_PUBLIC_POSTHOG_HOST ARG NEXT_PUBLIC_SENTRY_DSN ARG NEXT_PUBLIC_SENTRY_URL ARG NEXT_PUBLIC_SENTRY_ORG @@ -20,7 +22,9 @@ ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ENV NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL -ENV NEXT_PUBLIC_PLAUSIBLE_URL=$NEXT_PUBLIC_PLAUSIBLE_URL +ENV NEXT_PUBLIC_PLAUSIBLE_SRC=$NEXT_PUBLIC_PLAUSIBLE_SRC +ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY +ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG @@ -36,7 +40,7 @@ RUN bun install --frozen-lockfile # Build with proper environment ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -RUN cd apps/next && bun run build:docker +RUN sh scripts/build-next-bundle # Runner stage FROM docker.io/library/node:22-alpine AS runner diff --git a/docker/compose.local.yml b/docker/compose.local.yml index 1b89356..d8d3896 100644 --- a/docker/compose.local.yml +++ b/docker/compose.local.yml @@ -1,17 +1,17 @@ -name: convexmonorepo-local +name: convexmonorepo-base-local services: convex-backend: image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} - container_name: convexmonorepo-local-convex + container_name: convexmonorepo-base-local-convex ports: - - '${BACKEND_PORT:-3210}:3210' - - '${SITE_PROXY_PORT:-3211}:3211' + - '${BACKEND_PORT:-3250}:3210' + - '${SITE_PROXY_PORT:-3251}:3211' environment: - - INSTANCE_NAME=${LOCAL_INSTANCE_NAME:-convexmonorepo_local} + - INSTANCE_NAME=${LOCAL_INSTANCE_NAME:-convexmonorepo_base_local} - INSTANCE_SECRET=${LOCAL_INSTANCE_SECRET:-0000000000000000000000000000000000000000000000000000000000000000} - - CONVEX_CLOUD_ORIGIN=http://localhost:${BACKEND_PORT:-3210} - - CONVEX_SITE_ORIGIN=http://localhost:${SITE_PROXY_PORT:-3211} + - CONVEX_CLOUD_ORIGIN=http://localhost:${BACKEND_PORT:-3250} + - CONVEX_SITE_ORIGIN=http://localhost:${SITE_PROXY_PORT:-3251} - DISABLE_BEACON=true - REDACT_LOGS_TO_CLIENT=false - DO_NOT_REQUIRE_SSL=true @@ -31,10 +31,10 @@ services: convex-dashboard: image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} - container_name: convexmonorepo-local-convex-dashboard - ports: ['${DASHBOARD_PORT:-6791}:6791'] + container_name: convexmonorepo-base-local-convex-dashboard + ports: ['${DASHBOARD_PORT:-6795}:6791'] environment: - - NEXT_PUBLIC_DEPLOYMENT_URL=http://localhost:${BACKEND_PORT:-3210} + - NEXT_PUBLIC_DEPLOYMENT_URL=http://localhost:${BACKEND_PORT:-3250} depends_on: convex-backend: condition: service_healthy diff --git a/docker/compose.yml b/docker/compose.yml index 35d9286..d0a35aa 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -12,7 +12,9 @@ services: SENTRY_DISABLE_AUTO_UPLOAD: ${SENTRY_DISABLE_AUTO_UPLOAD:-false} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} NEXT_PUBLIC_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL} - NEXT_PUBLIC_PLAUSIBLE_URL: ${NEXT_PUBLIC_PLAUSIBLE_URL} + NEXT_PUBLIC_PLAUSIBLE_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SRC:-} + NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} + NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-} NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN} NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL} NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG} @@ -25,7 +27,9 @@ services: - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}} - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}} - - NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org} + - NEXT_PUBLIC_PLAUSIBLE_SRC=${NEXT_PUBLIC_PLAUSIBLE_SRC:-} + - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY:-} + - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-} - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry} diff --git a/scripts/build-next-bundle b/scripts/build-next-bundle new file mode 100755 index 0000000..a5597f3 --- /dev/null +++ b/scripts/build-next-bundle @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(cd -- "$(dirname -- "$0")/.." && pwd)" + +if [ -n "${SENTRY_AUTH_TOKEN:-}" ] && + [ -n "${NEXT_PUBLIC_SENTRY_URL:-}" ] && + ! curl \ + --connect-timeout 3 \ + --max-time 10 \ + --silent \ + --show-error \ + --output /dev/null \ + "${NEXT_PUBLIC_SENTRY_URL%/}/api/0/"; then + echo "Sentry is unreachable; continuing without source-map upload." >&2 + SENTRY_AUTH_TOKEN="" + SKIP_ENV_VALIDATION=true + NEXT_PUBLIC_SENTRY_URL="http://127.0.0.1:9" + export NEXT_PUBLIC_SENTRY_URL SENTRY_AUTH_TOKEN SKIP_ENV_VALIDATION +fi + +cd "$ROOT_DIR/apps/next" +exec bun run build:docker diff --git a/scripts/ci-export-env.mjs b/scripts/ci-export-env.mjs new file mode 100755 index 0000000..54c7f9c --- /dev/null +++ b/scripts/ci-export-env.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// ci-export-env.mjs [environment] +// +// CI's counterpart to scripts/export-env: prints an Infisical environment +// (default `staging`) as dotenv on stdout, in the same quoting export-env uses. +// It logs in as the `convexmonorepo-base-ci` machine identity through Universal Auth, using +// INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET from the job's Gitea secrets, +// and reads the project and host from .infisical.json. Plain Node, no CLI to +// install, so it runs in every job whether or not Bun is set up. +import { readFile } from 'node:fs/promises'; + +const environment = process.argv[2] ?? 'staging'; +const { INFISICAL_CLIENT_ID: clientId, INFISICAL_CLIENT_SECRET: clientSecret } = + process.env; + +const fail = (message) => { + console.error(`ci-export-env: ${message}`); + process.exit(1); +}; + +if (!clientId || !clientSecret) + fail('INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET must be set.'); + +const { workspaceId, domain } = JSON.parse( + await readFile(new URL('../.infisical.json', import.meta.url), 'utf8'), +); + +const request = async (path, init) => { + const response = await fetch(`${domain}${path}`, init); + if (!response.ok) + fail(`${init?.method ?? 'GET'} ${path} returned ${response.status}.`); + return response.json(); +}; + +const { accessToken } = await request('/v1/auth/universal-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId, clientSecret }), +}); + +const query = new URLSearchParams({ + workspaceId, + environment, + secretPath: '/', + expandSecretReferences: 'true', + include_imports: 'true', +}); +const { secrets, imports = [] } = await request(`/v3/secrets/raw?${query}`, { + headers: { Authorization: `Bearer ${accessToken}` }, +}); + +// Imported secrets first so the environment's own values win. +const values = new Map(); +for (const { secretKey, secretValue } of [ + ...imports.flatMap((folder) => folder.secrets), + ...secrets, +]) + values.set(secretKey, secretValue); + +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +for (const [key, value] of [...values].sort(([a], [b]) => a.localeCompare(b))) + process.stdout.write(`${key}=${quote(value)}\n`); diff --git a/scripts/ci-install b/scripts/ci-install new file mode 100755 index 0000000..aec0ed2 --- /dev/null +++ b/scripts/ci-install @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Install on a runner, and survive a half-written cache entry. +# +# Runners have failed twice with "Fail extracting tarball" on a different +# package each time, on different machines, and passed on a re-run. The cache +# is shared between jobs on a host, so a job that dies mid-write leaves an +# entry the next job cannot read. Clearing it and installing again costs a +# couple of minutes; a human re-running the job costs however long it takes +# someone to notice. +set -euo pipefail +if bun install --frozen-lockfile; then exit 0; fi +echo "Install failed. Clearing the bun cache and trying once more." >&2 +rm -rf "${BUN_INSTALL_CACHE_DIR:-$HOME/.bun/install/cache}" +bun install --frozen-lockfile diff --git a/scripts/db/up b/scripts/db/up index f7bba2d..b0728b6 100755 --- a/scripts/db/up +++ b/scripts/db/up @@ -44,9 +44,9 @@ refresh_env info "Starting local Convex and dashboard" dc up -d -info "Waiting for Convex at http://localhost:${BACKEND_PORT:-3210}" +info "Waiting for Convex at http://localhost:${BACKEND_PORT:-3250}" for i in $(seq 1 60); do - curl -fs "http://localhost:${BACKEND_PORT:-3210}/version" >/dev/null 2>&1 && break + curl -fs "http://localhost:${BACKEND_PORT:-3250}/version" >/dev/null 2>&1 && break [ "$i" -lt 60 ] || die "Convex did not become ready." sleep 2 done @@ -62,6 +62,11 @@ fi info "Deploying Convex schema and functions" (cd "$ROOT_DIR/packages/backend" && bun run setup) +# Env sync runs after the deploy: changing deployment env re-analyzes the +# deployed modules, which fails on a fresh volume before the first deploy. +info "Synchronizing the Convex function runtime environment" +sh "$ROOT_DIR/scripts/with-env" dev -- bash "$ROOT_DIR/scripts/sync-local-convex-env" + convex_env_names="$( sh "$ROOT_DIR/scripts/with-env" dev -- bash -c \ 'cd packages/backend && bunx convex env list' 2>/dev/null \ @@ -82,4 +87,4 @@ if ! printf '%s\n' "$convex_env_names" | grep -qx 'JWT_PRIVATE_KEY' \ ' fi -printf '\nLocal stack ready:\n App: http://localhost:3000\n Convex: http://localhost:%s\n Dashboard: http://localhost:%s\n' "${BACKEND_PORT:-3210}" "${DASHBOARD_PORT:-6791}" +printf '\nLocal stack ready:\n App: http://localhost:3000\n Convex: http://localhost:%s\n Dashboard: http://localhost:%s\n' "${BACKEND_PORT:-3250}" "${DASHBOARD_PORT:-6795}" diff --git a/scripts/dev-kill b/scripts/dev-kill new file mode 100755 index 0000000..220af79 --- /dev/null +++ b/scripts/dev-kill @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Kill any lingering host-level dev servers (Next.js, Expo Metro, Convex CLI +# watchers, turbo orchestrators) that survived a Ctrl-C. Does not touch the +# dockerized Convex/Postgres stack — use `bun db:down` for that. +# +# Usage: scripts/dev-kill (wired as `bun dev:kill`) +set -euo pipefail + +info() { printf '\033[36m▶ %s\033[0m\n' "$*"; } +ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; } + +PORTS=(3000 3001 8081 19000 19001 19002) +PATTERNS=('next-server' 'next dev' 'expo start' 'turbo run dev' 'convex dev') + +killed=false + +for port in "${PORTS[@]}"; do + pids="$(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -z "$pids" ] && continue + for pid in $pids; do + cmd="$(ps -p "$pid" -o comm= 2>/dev/null || true)" + info "Killing $cmd (pid $pid) on port $port" + kill "$pid" 2>/dev/null || true + killed=true + done +done + +for pattern in "${PATTERNS[@]}"; do + pids="$(pgrep -f "$pattern" 2>/dev/null || true)" + [ -z "$pids" ] && continue + for pid in $pids; do + info "Killing '$pattern' (pid $pid)" + kill "$pid" 2>/dev/null || true + killed=true + done +done + +if [ "$killed" = true ]; then + sleep 1 + ok "Dev servers stopped." +else + ok "No dev servers were running." +fi diff --git a/scripts/e2e b/scripts/e2e index 198c392..43490c0 100755 --- a/scripts/e2e +++ b/scripts/e2e @@ -15,8 +15,8 @@ container_running() { "$RUNTIME" ps --format '{{.Names}}' | grep -qx "$1" } -if container_running convexmonorepo-local-convex \ - && container_running convexmonorepo-local-convex-dashboard; then +if container_running convexmonorepo-base-local-convex \ + && container_running convexmonorepo-base-local-convex-dashboard; then SHOULD_STOP_STACK=false else SHOULD_STOP_STACK=true @@ -25,8 +25,8 @@ fi cleanup() { if [ "$SHOULD_STOP_STACK" = true ]; then "$RUNTIME" rm -f \ - convexmonorepo-local-convex-dashboard \ - convexmonorepo-local-convex \ + convexmonorepo-base-local-convex-dashboard \ + convexmonorepo-base-local-convex \ >/dev/null 2>&1 || true fi } diff --git a/scripts/export-env b/scripts/export-env index 14412fe..8c5ff7d 100755 --- a/scripts/export-env +++ b/scripts/export-env @@ -16,6 +16,12 @@ fi [ -f "$ROOT_DIR/.infisical.json" ] || { echo "export-env: run 'infisical init' in this repository." >&2; exit 1; } command -v infisical >/dev/null 2>&1 || { echo "export-env: Infisical CLI is required." >&2; exit 1; } +if [ -f "$ROOT_DIR/scripts/infisical-profile" ]; then + # shellcheck source=/dev/null + . "$ROOT_DIR/scripts/infisical-profile" + ensure_infisical_profile "$ROOT_DIR/scripts/flags/infisical.env" +fi + (cd "$ROOT_DIR" && infisical export --env="$ENVIRONMENT" --format=dotenv --silent) || { echo "export-env: failed to export '$ENVIRONMENT'; check login and project access." >&2 exit 1 diff --git a/scripts/infisical-profile b/scripts/infisical-profile new file mode 100755 index 0000000..e9941ad --- /dev/null +++ b/scripts/infisical-profile @@ -0,0 +1,117 @@ +#!/usr/bin/env sh +# +# ensure_infisical_profile +# +# Infisical's CLI keeps a single active login (~/.infisical/infisical-config.json) +# shared across every project on the machine. If you juggle multiple Infisical +# accounts (e.g. a client org vs. a personal org), running with-env in one repo +# can silently pull secrets while logged in as the wrong user. +# +# This switches the active `loggedInUserEmail` to the email configured in +# scripts/flags/infisical.env (INFISICAL_EMAIL=...) before infisical runs, +# provided that email is already logged in locally (`infisical login`). +# It is a no-op if the flag file is absent, only one user is logged in, or the +# expected user isn't logged in yet. +# +# POSIX sh, no bashisms: matches the rest of scripts/ which must stay +# busybox-compatible. + +warn_infisical_profile() { + echo "Warning: infisical-profile: $*" >&2 +} + +ensure_infisical_profile() { + profile_env_file="${1:-}" + + if [ -z "$profile_env_file" ] || [ ! -f "$profile_env_file" ]; then + return 0 + fi + + set -a + # shellcheck source=/dev/null + if ! . "$profile_env_file"; then + set +a + warn_infisical_profile "could not read $profile_env_file. Continuing with current Infisical profile." + return 0 + fi + set +a + + expected_email="${INFISICAL_EMAIL:-}" + if [ -z "$expected_email" ]; then + return 0 + fi + + config="${INFISICAL_CONFIG_PATH:-$HOME/.infisical/infisical-config.json}" + lock="${INFISICAL_LOCK_PATH:-$HOME/.infisical/.switch.lock}" + + if [ ! -f "$config" ]; then + warn_infisical_profile "Infisical config not found at $config. Continuing with current Infisical profile." + return 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + warn_infisical_profile "jq is required to select Infisical profile. Continuing with current Infisical profile." + return 0 + fi + + if ! command -v flock >/dev/null 2>&1; then + warn_infisical_profile "flock is required to safely select Infisical profile. Continuing with current Infisical profile." + return 0 + fi + + if ! mkdir -p "$(dirname "$lock")"; then + warn_infisical_profile "could not create Infisical lock directory. Continuing with current Infisical profile." + return 0 + fi + + ( + if ! flock 9; then + warn_infisical_profile "could not lock Infisical profile config. Continuing with current Infisical profile." + return 0 + fi + + user_count="$(jq -r '(.loggedInUsers // []) | length' "$config" 2>/dev/null)" || { + warn_infisical_profile "could not parse Infisical config. Continuing with current Infisical profile." + return 0 + } + + if [ "$user_count" -lt 2 ]; then + return 0 + fi + + current_email="$(jq -r '.loggedInUserEmail // ""' "$config" 2>/dev/null)" || { + warn_infisical_profile "could not read current Infisical profile. Continuing with current Infisical profile." + return 0 + } + + if [ "$current_email" = "$expected_email" ]; then + return 0 + fi + + domain="$( + jq -er --arg email "$expected_email" \ + '.loggedInUsers[] | select(.email == $email) | .domain' \ + "$config" 2>/dev/null + )" || { + warn_infisical_profile "Infisical profile $expected_email is not logged in locally. Continuing with current Infisical profile." + return 0 + } + + tmp="$(mktemp)" || { + warn_infisical_profile "could not create temp file for Infisical profile switch. Continuing with current Infisical profile." + return 0 + } + + if jq --arg email "$expected_email" --arg domain "$domain" \ + '.loggedInUserEmail = $email | .LoggedInUserDomain = $domain' \ + "$config" > "$tmp"; then + chmod 600 "$tmp" + mv "$tmp" "$config" + else + rm -f "$tmp" + warn_infisical_profile "failed to update Infisical profile. Continuing with current Infisical profile." + fi + ) 9>"$lock" + + return 0 +} diff --git a/scripts/sync-local-convex-env b/scripts/sync-local-convex-env new file mode 100755 index 0000000..a31a74a --- /dev/null +++ b/scripts/sync-local-convex-env @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +VALUE_FILE="" + +readonly RUNTIME_ENV_NAMES=( + AUTH_AUTHENTIK_ID + AUTH_AUTHENTIK_SECRET + AUTH_AUTHENTIK_ISSUER + USESEND_API_KEY + USESEND_URL + USESEND_FROM_EMAIL +) +readonly DEPLOYMENT_ENV_NAMES=( + CONVEX_SELF_HOSTED_URL + CONVEX_SELF_HOSTED_ADMIN_KEY +) + +die() { printf 'sync-local-convex-env: %s\n' "$*" >&2; exit 1; } +cleanup() { + if [ -n "$VALUE_FILE" ] && [ -f "$VALUE_FILE" ]; then + rm -f "$VALUE_FILE" + fi +} +trap cleanup EXIT INT TERM HUP + +for name in "${DEPLOYMENT_ENV_NAMES[@]}" "${RUNTIME_ENV_NAMES[@]}"; do + [ -n "${!name:-}" ] || die "Missing required environment variable: $name" +done + +VALUE_FILE="$(mktemp "${TMPDIR:-/tmp}/convex-monorepo-convex-env.value.XXXXXX")" +chmod 600 "$VALUE_FILE" + +for name in "${RUNTIME_ENV_NAMES[@]}"; do + printf '%s' "${!name}" > "$VALUE_FILE" + ( + cd "$ROOT_DIR/packages/backend" + env -u CONVEX_DEPLOY_KEY -u CONVEX_DEPLOYMENT \ + CONVEX_SELF_HOSTED_URL="$CONVEX_SELF_HOSTED_URL" \ + CONVEX_SELF_HOSTED_ADMIN_KEY="$CONVEX_SELF_HOSTED_ADMIN_KEY" \ + bunx convex env set "$name" --from-file "$VALUE_FILE" >/dev/null + ) +done + +printf 'Local Convex runtime environment synchronized.\n' diff --git a/turbo.json b/turbo.json index 5502045..0d35366 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://v2-9-18.turborepo.dev/schema.json", + "$schema": "https://v2-11-6.turborepo.dev/schema.json", "globalDependencies": [".infisical.json", ".local/*.env"], "globalEnv": [ "NODE_ENV", @@ -8,7 +8,9 @@ "SENTRY_DISABLE_AUTO_UPLOAD", "NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_CONVEX_URL", - "NEXT_PUBLIC_PLAUSIBLE_URL", + "NEXT_PUBLIC_PLAUSIBLE_SRC", + "NEXT_PUBLIC_POSTHOG_KEY", + "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_URL", "NEXT_PUBLIC_SENTRY_ORG", @@ -47,6 +49,7 @@ ], "globalPassThroughEnv": ["NODE_ENV"], "ui": "tui", + "agentGuidance": false, "tasks": { "topo": { "dependsOn": ["^topo"]