diff --git a/README.md b/README.md index 91cbd18..d476141 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ Open-source customer messaging platform - an alternative to Intercom. +## Quick Start + +After cloning the repo, run one setup command: + +```bash +./scripts/setup.sh +``` + +It installs dependencies, creates or reuses a Convex dev deployment, configures Convex Auth, creates or reuses a workspace, and writes the local `.env.local` files for the web, widget, mobile, landing, and SDK example apps. + +Prerequisites: Node.js 18+, PNPM 9+, and a Convex account. + ## Documentation ### Quick Links @@ -100,14 +112,14 @@ For the canonical setup paths (quickstart, self-host, env vars, deployment profi ### Quick Start (Self-Hosters) -The fastest way to get Opencom running locally: +The fastest supported local setup path is the bootstrap script: ```bash # Clone the repository git clone https://github.com/opencom-org/opencom.git cd opencom -# Run the setup script +# Run the one-command setup ./scripts/setup.sh ``` @@ -115,11 +127,11 @@ The setup script will: 1. Check prerequisites (Node.js 18+, PNPM 9+) 2. Install dependencies -3. Create a Convex project and deploy -4. Prompt for your admin email and password -5. Create your workspace and admin account -6. Generate all `.env.local` files -7. Start the web dashboard and widget +3. Configure or reuse a Convex dev deployment with the current CLI flow +4. Validate the local password-auth bootstrap env contract +5. Sign up or sign in through the repo's real Convex Auth password flow +6. Generate/update all supported `.env.local` files without deleting unrelated keys +7. Optionally offer to start the web dashboard and widget **Prerequisites:** @@ -133,31 +145,38 @@ The setup script will: ./scripts/setup.sh --email admin@example.com --password yourpassword --non-interactive --skip-dev ``` +**Force a reconfigure or create a new workspace on rerun:** + +```bash +./scripts/setup.sh --reconfigure +./scripts/setup.sh --create-workspace --workspace "My New Workspace" +``` + **Update environment files:** ```bash ./scripts/update-env.sh --url https://your-project.convex.cloud --workspace your_workspace_id ``` -### Manual Setup +### Manual Setup (Escape Hatch) -If you prefer manual setup: +Prefer `./scripts/setup.sh`. Use this path only when you need to debug or control each setup step yourself: ```bash # Install dependencies pnpm install -# Navigate to convex package -cd packages/convex +# Configure the local Convex dev deployment +pnpm --filter @opencom/convex exec convex dev --once -# Login to Convex -npx convex login +# Configure Convex Auth JWT_PRIVATE_KEY/JWKS and SITE_URL +pnpm --filter @opencom/convex exec convex auth add -# Initialize and deploy -npx convex dev +# Propagate the backend URL and workspace into the local app env files +./scripts/update-env.sh --url https://your-project.convex.cloud --workspace your_workspace_id ``` -Then create `.env.local` files manually (see Environment Variables Reference below). +You still need to create or reuse an admin/workspace yourself before `update-env.sh` can wire a real workspace ID into the local app env files. ## Development @@ -225,15 +244,17 @@ npx convex login # Create and deploy your project npx convex dev --once + +# Configure Convex Auth +pnpm exec convex auth add ``` -**2. Configure environment variables in Convex Dashboard:** +**2. Configure optional integration environment variables in Convex Dashboard:** -Go to your Convex dashboard → Settings → Environment Variables and set: +`pnpm exec convex auth add` configures the required Convex Auth `JWT_PRIVATE_KEY` and `JWKS` values. For optional email features, go to your Convex dashboard → Settings → Environment Variables and set: | Variable | Required | Description | | ---------------- | --------- | ------------------------------------------------------------------------ | -| `AUTH_SECRET` | Yes | Random string for JWT signing (generate with `openssl rand -base64 32`) | | `RESEND_API_KEY` | For email | API key from [Resend](https://resend.com) for sending emails | | `EMAIL_FROM` | For email | Email address to send from (e.g., `YourCompany`) | @@ -390,7 +411,8 @@ Set these in your Convex Dashboard → Settings → Environment Variables: | Variable | Required | Description | | --------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_SECRET` | Yes | Secret for JWT signing | +| `JWT_PRIVATE_KEY` | Yes | Private key for Convex Auth JWT signing | +| `JWKS` | Yes | Public key set matching `JWT_PRIVATE_KEY` for Convex Auth JWT verification | | `AUTH_RESEND_KEY` | For email | Resend API key for OTP email sending | | `RESEND_API_KEY` | For email | Resend API key for transactional/campaign emails | | `EMAIL_FROM` | For email | Sender email address (e.g., `Opencom `) | @@ -489,11 +511,9 @@ Opencom uses a dedicated Convex test deployment to avoid polluting development o **Setting up the test deployment:** ```bash -# Navigate to convex package -cd packages/convex - -# Create and deploy to test project (first time only) -npx convex dev --project opencom-test --once +# Create or reuse a dedicated test deployment, then configure Convex Auth +pnpm --filter @opencom/convex exec convex dev --once --configure +pnpm --filter @opencom/convex exec convex auth add # Create a local test env file from template cp .env.test.example .env.test @@ -568,8 +588,9 @@ When self-hosting Opencom, consider these security best practices: | `EMAIL_WEBHOOK_INTERNAL_SECRET` | Restrict webhook-only email handlers to trusted internal callers | Convex Dashboard | | `ENFORCE_WEBHOOK_SIGNATURES` | Fail closed on webhook signature/internal-secret validation (`true` by default) | Convex Dashboard | | `WEBHOOK_MAX_AGE_SECONDS` | Replay-window bound for webhook signatures (default: 300s) | Convex Dashboard | -| `AUTH_SECRET` | Sign authentication/session tokens | Convex Dashboard | -| `CONVEX_SITE_URL` | Auth callback domain used by Convex Auth provider | Convex Dashboard | +| `JWT_PRIVATE_KEY` | Sign authentication/session tokens | Convex Dashboard | +| `JWKS` | Verify authentication/session tokens | Convex Dashboard | +| `CONVEX_SITE_URL` | Auth issuer and JWKS endpoint used by Convex Auth provider | Convex-managed | | `ALLOW_TEST_DATA` | Enable/disable test data mutations | Convex Dashboard | | `TEST_ADMIN_SECRET` | Secure test admin gateway for internal test mutations | Convex Dashboard (test deployments only) | @@ -673,8 +694,8 @@ try { **"Could not determine Convex deployment URL"** -- Ensure you're logged into Convex: `npx convex whoami` -- Try running `npx convex dev --once` manually in `packages/convex/` +- Rerun `./scripts/setup.sh --reconfigure` and complete the Convex CLI login/project-selection flow. +- If you are debugging manually, run `pnpm --filter @opencom/convex exec convex dev --once --configure`. **"npx convex login" hangs or fails** diff --git a/apps/landing/.env.example b/apps/landing/.env.example index 27c427b..22a7559 100644 --- a/apps/landing/.env.example +++ b/apps/landing/.env.example @@ -9,9 +9,11 @@ NEXT_PUBLIC_WIDGET_URL=https://cdn.opencom.dev/widget.js NEXT_PUBLIC_OPENCOM_WEB_APP_URL= # Your Convex deployment URL +# Tip: ./scripts/update-env.sh fills this in automatically for local setup NEXT_PUBLIC_CONVEX_URL= # Your Opencom workspace ID +# Tip: ./scripts/update-env.sh fills this in automatically for local setup NEXT_PUBLIC_WORKSPACE_ID= # ─── Demo Seed ─────────────────────────────────────────────── diff --git a/apps/landing/README.md b/apps/landing/README.md index a4231c5..11c63dc 100644 --- a/apps/landing/README.md +++ b/apps/landing/README.md @@ -10,6 +10,8 @@ cp .env.example .env.local pnpm dev:landing ``` +If you already ran the repo bootstrap, `./scripts/update-env.sh` writes these values for you. + ## Widget Embed Behavior `src/components/widget-script.tsx` uses a declarative script tag with `data-opencom-*` attributes. @@ -40,7 +42,7 @@ CONVEX_URL= WORKSPACE_ID= TEST_ADMIN_SECRET= | ------------------ | ---------------------------------------------------------------------------------------------------------- | | Product Tour | 5-step tour targeting current landing hooks (hero CTA, features section, product-tour showcase, final CTA) | | Checklist | "Explore Opencom" with 5 tasks | -| Knowledge Base | 6 collections, 24 published articles covering hosted, self-hosting, SDKs, security, and operations docs | +| Knowledge Base | 6 collections, 24 published articles covering hosted, self-hosting, SDKs, security, and operations docs | | Outbound Messages | Welcome post (click → new conversation) + docs banner (click → URL) | | Survey | NPS survey after 60s on page | | Tooltips | 3 contextual hints on hero CTA, product-tour showcase, and GitHub nav link | diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..45cbf3c --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,6 @@ +# Opencom mobile app local defaults +# Copy to .env.local for manual setup, or run ./scripts/update-env.sh + +EXPO_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=https://your-deployment.convex.cloud +EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud +EXPO_PUBLIC_WORKSPACE_ID=your-workspace-id diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..bc545c3 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,14 @@ +# Opencom web dashboard local defaults +# Copy to .env.local for manual setup, or run ./scripts/update-env.sh + +# Backend URL used by the backend picker/discovery flow +NEXT_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=https://your-deployment.convex.cloud + +# Convex URL used by widget preview/demo and local helper flows +NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud + +# Optional: workspace used by the local widget demo page +NEXT_PUBLIC_TEST_WORKSPACE_ID=your-workspace-id + +# Optional: backend override used by Playwright helpers +E2E_BACKEND_URL=https://your-deployment.convex.cloud diff --git a/apps/widget/.env.example b/apps/widget/.env.example index 384c1ed..e371f0f 100644 --- a/apps/widget/.env.example +++ b/apps/widget/.env.example @@ -1,8 +1,8 @@ # Copy this file to .env.local and fill in your values # Convex deployment URL (required) +# Tip: ./scripts/update-env.sh fills this in automatically for local setup VITE_CONVEX_URL=https://your-deployment.convex.cloud -# Workspace ID from your mobile app Settings screen (required for dev mode) -# Go to Settings > Workspace > tap "Workspace ID" to copy +# Workspace ID from the local bootstrap or app settings screen (required for dev mode) VITE_WORKSPACE_ID=your_workspace_id_here diff --git a/docs/open-source/setup-self-host-and-deploy.md b/docs/open-source/setup-self-host-and-deploy.md index d6fc304..f5e493b 100644 --- a/docs/open-source/setup-self-host-and-deploy.md +++ b/docs/open-source/setup-self-host-and-deploy.md @@ -10,13 +10,21 @@ This guide is the canonical OSS setup and deployment reference for Opencom. ## Fastest Setup Path -Use the repo bootstrap script: +Use the repo bootstrap script. This is the preferred path for new users: ```bash ./scripts/setup.sh ``` -The script performs dependency install, Convex initialization, basic auth env setup, local `.env.local` generation, and optional dev server start. +The script now: + +- runs `pnpm install` +- configures or reuses the local Convex dev deployment with the current `convex dev --once` flow +- validates the local password-auth bootstrap env contract and generates `JWT_PRIVATE_KEY`/`JWKS` when needed +- signs up or signs in through the repo's real `auth:signIn` password flow +- resolves an existing workspace by default, with explicit opt-in workspace creation on reruns +- updates the supported local `.env.local` files non-destructively +- optionally offers to start the web/widget dev servers at the end Update generated env files later with: @@ -24,11 +32,24 @@ Update generated env files later with: ./scripts/update-env.sh --url https://.convex.cloud --workspace ``` +Helpful rerun flags: + +```bash +./scripts/setup.sh --reconfigure +./scripts/setup.sh --create-workspace --workspace "My New Workspace" +./scripts/setup.sh --non-interactive --email admin@example.com --password 'Opencom!123' --skip-dev +``` + ## Manual Setup (step-by-step) +Use this only when debugging or when you need to control each step yourself. +The one-command setup above also handles admin/workspace bootstrap; this manual path requires you to resolve a real workspace ID before running `update-env.sh`. + ```bash pnpm install -pnpm --filter @opencom/convex dev +pnpm --filter @opencom/convex exec convex dev --once +pnpm --filter @opencom/convex exec convex auth add +./scripts/update-env.sh --url https://.convex.cloud --workspace pnpm dev:web pnpm dev:widget ``` @@ -84,40 +105,44 @@ By default, each deploy publishes a unique immutable runtime key (`v/` | Admin email address | | `--password ` | Admin password | | `--name ` | Admin display name | -| `--workspace ` | Workspace name | -| `--skip-dev` | Skip starting dev server | +| `--workspace ` | Workspace name when creating a new workspace | +| `--reconfigure` | Force Convex CLI reconfiguration | +| `--create-workspace` | Create a new workspace on an existing deployment | +| `--skip-dev` | Skip the dev-server prompt entirely | +| `--start-dev` | Start web + widget dev servers automatically | | `--non-interactive` | Run without prompts (requires --email and --password) | **Non-interactive example (CI):** @@ -41,7 +45,7 @@ Interactive setup script for first-time Opencom installation. ### `update-env.sh` -Regenerates `.env.local` files after setup or configuration changes. +Refreshes the Opencom-managed local env keys after setup or configuration changes. ```bash ./scripts/update-env.sh --url https://your-project.convex.cloud --workspace your_workspace_id @@ -52,6 +56,8 @@ Regenerates `.env.local` files after setup or configuration changes. | `--url ` | Convex deployment URL | | `--workspace ` | Workspace ID | +`update-env.sh` preserves unrelated manual keys and comments in the target files instead of overwriting them wholesale. + ## Build & Deploy ### `build-widget-for-tests.sh` @@ -264,18 +270,18 @@ Files in `security/` configure CI gate behavior: ### Quality -| Command | Description | -| ------------------------ | ------------------------------------------------- | -| `pnpm web:lint` | Lint `@opencom/web` using ESLint CLI | +| Command | Description | +| ------------------------ | -------------------------------------------------------- | +| `pnpm web:lint` | Lint `@opencom/web` using ESLint CLI | | `pnpm convex:lint` | Lint `@opencom/convex` Help Center import/export modules | -| `pnpm quality:lint` | Run standardized lint gates for web + convex | -| `pnpm web:typecheck` | Typecheck `@opencom/web` | -| `pnpm convex:typecheck` | Typecheck `@opencom/convex` | -| `pnpm quality:typecheck` | Run standardized typecheck gates for web + convex | -| `pnpm lint` | Lint all packages (workspace-wide) | -| `pnpm format` | Format all files | -| `pnpm format:check` | Check formatting | -| `pnpm typecheck` | Typecheck all packages | +| `pnpm quality:lint` | Run standardized lint gates for web + convex | +| `pnpm web:typecheck` | Typecheck `@opencom/web` | +| `pnpm convex:typecheck` | Typecheck `@opencom/convex` | +| `pnpm quality:typecheck` | Run standardized typecheck gates for web + convex | +| `pnpm lint` | Lint all packages (workspace-wide) | +| `pnpm format` | Format all files | +| `pnpm format:check` | Check formatting | +| `pnpm typecheck` | Typecheck all packages | ### Testing diff --git a/docs/testing.md b/docs/testing.md index b17befe..0090e9e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,13 +19,11 @@ To avoid polluting development or production data, tests run against a dedicated **Initial Setup:** ```bash -cd packages/convex +# Create or reuse a dedicated Convex test deployment +pnpm --filter @opencom/convex exec convex dev --once --configure -# Login to Convex (if not already) -npx convex login - -# Create test deployment -npx convex dev --project opencom-test --once +# Configure Convex Auth JWT_PRIVATE_KEY/JWKS and SITE_URL +pnpm --filter @opencom/convex exec convex auth add ``` **Configuration:** diff --git a/openspec/changes/foolproof-local-convex-setup/.openspec.yaml b/openspec/changes/foolproof-local-convex-setup/.openspec.yaml new file mode 100644 index 0000000..863bff1 --- /dev/null +++ b/openspec/changes/foolproof-local-convex-setup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-17 diff --git a/openspec/changes/foolproof-local-convex-setup/design.md b/openspec/changes/foolproof-local-convex-setup/design.md new file mode 100644 index 0000000..78fd921 --- /dev/null +++ b/openspec/changes/foolproof-local-convex-setup/design.md @@ -0,0 +1,266 @@ +## Context + +The current setup flow has four structural problems: + +1. It depends on stale Convex CLI behavior. + - `npx convex whoami` is no longer a valid command in the current CLI. + - `--team`, `--project`, and `--dev-deployment` only work together with `convex dev --configure`, so the existing `convex dev --once --project ...` flow fails. +2. It uses the wrong auth bootstrap mechanism. + - This repo exposes auth through Convex Auth HTTP routes. + - Signup is performed via `auth:signIn` with `{ provider: "password", params: { flow: "signUp", ... } }`, not via `convex run auth:signup`. +3. It treats env files as disposable. + - `scripts/setup.sh` and `scripts/update-env.sh` overwrite `apps/*/.env.local` and `packages/convex/.env.local` wholesale. + - That risks deleting unrelated local values and makes reruns unsafe. +4. It does not model the setup contract explicitly. + - Some backend env keys are essential to bootstrap auth. + - Others are optional feature-specific values. + - The current scripts and docs do not clearly separate those two categories or validate them with actionable explanations. + +The result is a flow that works only for a narrow happy path and becomes harder to trust the moment a user has an existing project, existing envs, or a typo in one required value. + +## Goals / Non-Goals + +**Goals:** +- Make first-run local setup succeed for a contributor who starts with only Node, PNPM, and an existing Convex account. +- Let reruns reuse or reconfigure an existing Convex project instead of silently creating duplicates. +- Use the repo's real auth flow to create or reuse a bootstrap admin/workspace and derive a valid workspace ID automatically. +- Update all relevant local env files without deleting unrelated user-managed keys. +- Separate required core setup from optional feature add-ons and validate each with actionable messages. +- Add deterministic automated coverage for the setup orchestration in a clean environment. + +**Non-Goals:** +- Automating hosted production deploys for web, landing, widget CDN, or mobile build pipelines. +- Turning local setup into a fully headless CI-only bootstrap for real Convex cloud accounts. +- Enabling every optional third-party integration by default. +- Replacing the manual setup path entirely; manual setup should remain documented, but aligned with the same contract. + +## Decisions + +### 1) Keep the shell entrypoint, move orchestration into a standalone Node bootstrap + +Decision: +- Keep `./scripts/setup.sh` as the user-facing entrypoint for compatibility and simple prerequisite checks. +- Move the actual workflow into a Node script that uses built-in modules only, so it can run before dependency install finishes. +- Keep shared env merge/write logic in reusable setup modules so `setup` and `update-env` stop duplicating behavior. + +Rationale: +- Bash is a poor fit for JSON parsing, HTTP auth calls, stateful prompts, env merging, and deterministic tests. +- A standalone Node script can use `fetch`, `crypto`, `child_process`, and `readline/promises` without introducing extra bootstrap dependencies. +- Preserving the shell entrypoint avoids breaking docs and contributor muscle memory. + +Alternatives considered: +- Keep expanding the Bash script: rejected because the core failure modes are caused by Bash being hard to evolve safely here. +- Replace the entrypoint with a TypeScript/`tsx` script directly: rejected because it would depend on installed workspace packages before the script can even install them. + +### 2) Use `packages/convex/.env.local` plus authenticated workspace lookup as the canonical source of backend state + +Decision: +- Treat `packages/convex/.env.local` as the canonical local source for the configured deployment and backend URL after `convex dev --once`. +- Use authenticated backend queries, not manual free-form entry, to resolve the active workspace ID whenever possible. +- Fan out the resolved backend URL and workspace ID to app-specific env files through a shared mapping table. + +Rationale: +- The Convex CLI already writes the authoritative deployment metadata to `packages/convex/.env.local`. +- Deriving workspace state from authenticated backend queries is safer than asking users to copy IDs from dashboards. +- A central mapping table prevents web, widget, mobile, landing, and RN example envs from drifting. + +Alternatives considered: +- Make each app own its own setup independently: rejected because it repeats the same backend/workspace data in multiple places. +- Keep prompting users to paste workspace IDs manually: rejected because it is error-prone and does not help reruns against existing projects. + +### 3) Model setup as profiles: required core setup first, optional feature setup second + +Decision: +- Define a required core setup profile that blocks completion until the following are valid: + - Convex project/deployment configuration is present + - backend URL can be resolved from the configured deployment + - required auth bootstrap env keys are set on the deployment + - a valid workspace is resolved for local consumers + - the required local env mappings are written successfully +- Treat email, AI, and test/demo conveniences as optional add-on profiles with explicit warnings instead of hard blockers. + +Rationale: +- A contributor should be able to get a working local backend and app envs without already having Resend, AI provider keys, or production-origin settings. +- Optional features still need clear validation, but missing them should explain which features remain disabled rather than fail the entire bootstrap. + +Implementation note: +- Based on the current repo and the current Convex Auth manual setup docs, the setup flow MUST validate the auth bootstrap keys actually required for this repo's password auth path. At minimum, this includes the JWT key material required by Convex Auth. `SITE_URL` should be treated as conditional: required when enabling OTP/email redirect flows, not when the user is only bootstrapping password auth locally. + +Alternatives considered: +- Force every documented env key up front: rejected because it would make "minimal local setup" depend on unrelated integrations. +- Ignore optional features entirely: rejected because silent feature disablement is confusing and hard to debug later. + +### 4) Reruns must reuse by default, and create-new only on explicit choice + +Decision: +- On rerun, the setup flow should detect whether a Convex project is already configured locally and whether the target deployment already contains users/workspaces. +- The default path should be reuse: + - keep the current configured deployment unless the user explicitly asks to reconfigure + - if the deployment already has users, prompt the user to sign in as an existing admin and choose an existing workspace when possible + - only create a new bootstrap account/workspace if the user explicitly chooses that path + +Rationale: +- The current behavior risks duplicate projects and duplicate admin/workspace creation. +- Existing deployments are more common over time than pristine ones, so rerun safety must be a first-class path. + +Alternatives considered: +- Always create a new project and user: rejected because it is wasteful and makes local state harder to reason about. +- Never create anything automatically: rejected because a first-time user still needs a path that gets them to a usable workspace without dashboard spelunking. + +### 5) Manage env files with explicit Opencom-owned blocks instead of rewriting whole files + +Decision: +- Introduce managed env blocks or key-level merge logic for all Opencom-owned local env files. +- Preserve unrelated user-managed keys and comments outside the managed region. +- Never replace `packages/convex/.env.local` wholesale; update only the keys this bootstrap owns and leave existing secrets/config intact. + +Rationale: +- The current scripts can destroy user-managed values, which is especially dangerous in `packages/convex/.env.local`. +- Managed blocks make reruns predictable and keep the script idempotent. + +Alternatives considered: +- Continue overwriting whole files: rejected because it is unsafe. +- Refuse to touch files that already exist: rejected because reruns still need a reliable update path. + +### 6) Test the flow in layers: deterministic stubs first, disposable smoke second + +Decision: +- Add deterministic automated tests around the setup state machine using: + - temporary directories/home paths + - stubbed Convex CLI responses + - stubbed auth HTTP responses + - fixture env files for merge/update assertions +- Add a documented opt-in smoke harness for running the bootstrap in a disposable container or similarly isolated environment with a real Convex account. + +Rationale: +- A fully automated real-cloud test is not a good primary verification path because login and project selection are interactive and account-specific. +- The deterministic harness should own the branching logic, validation copy, and env merge behavior. +- A disposable-container/manual smoke pass is still useful to catch integration drift against the real CLI. + +Alternatives considered: +- Only test by hand in Docker: rejected because it is too slow and too account-dependent to be the main safety net. +- Only use unit tests with no process-level stubs: rejected because the setup logic is mainly orchestration and file/process behavior. + +## Proposed Flow + +### Phase 1: Preflight + +1. Validate `node` and `pnpm` presence/version. +2. Explain what the setup will do: + - install dependencies + - configure or reuse a Convex dev deployment + - validate required backend auth envs + - create or reuse a bootstrap admin/workspace + - write local envs across supported apps +3. Run `pnpm install` from repo root. + +### Phase 2: Convex project configuration + +1. Inspect `packages/convex/.env.local`. +2. If no usable Convex config exists: + - run `pnpm exec convex dev --once` in `packages/convex` with inherited stdio + - let the CLI drive login plus new/existing project selection interactively +3. If config exists: + - ask whether to keep it or reconfigure + - on reconfigure, run `pnpm exec convex dev --once --configure` +4. After the command completes, parse `packages/convex/.env.local` and require: + - `CONVEX_DEPLOYMENT` + - `CONVEX_URL` + - any other deployment metadata the downstream flow depends on + +### Phase 3: Backend env validation/bootstrap + +1. Build a setup manifest describing: + - required core deployment envs + - optional feature envs + - how each value is generated, prompted, defaulted, or skipped +2. Validate core auth bootstrap envs against the actual current repo contract. +3. If required envs are missing: + - generate values when appropriate, such as JWT key material + - prompt the user when the value must come from them + - set values on the Convex deployment via `convex env set` +4. Re-verify that the required envs are present before continuing. + +### Phase 4: Workspace resolution + +1. Query `setup:checkExistingSetup` on the configured deployment. +2. If the deployment is empty: + - collect bootstrap admin credentials + - call the Convex Auth HTTP action path for password signup + - authenticate with the returned token + - query the current user/workspace info and capture the workspace ID +3. If the deployment already has users: + - prompt to sign in with an existing admin account + - authenticate via the same HTTP auth surface + - query current user/workspaces + - let the user choose which existing workspace should populate local envs +4. Only offer "create a new bootstrap account/workspace" as an explicit opt-in when the deployment already has existing data. + +### Phase 5: Local env propagation + +1. Use a shared mapping table to write the required Opencom-owned keys into: + - `apps/web/.env.local` + - `apps/widget/.env.local` + - `apps/mobile/.env.local` + - `apps/landing/.env.local` + - `packages/react-native-sdk/example/.env.local` + - `packages/convex/.env.local` for the local keys this repo needs in shell scripts/tests +2. Use managed sections or key merge logic so unrelated keys survive reruns. +3. Re-read every file and confirm the expected values landed correctly. + +### Phase 6: Post-setup validation and next steps + +1. Print a summary of: + - configured deployment + - backend URL + - chosen workspace + - any optional features still disabled because their envs were skipped +2. Offer explicit next steps, such as: + - start local web/widget/mobile dev + - open the hosted app against the configured backend + - rerun the setup in "reconfigure" mode + +## Validation And Error Handling + +The bootstrap output should be optimized for self-correction: + +- Missing prerequisite: + - explain the exact command/version issue and how to fix it +- Convex CLI configuration failure: + - say whether the user needs to finish login, rerun with `--configure`, or inspect `packages/convex/.env.local` +- Missing required backend auth env: + - name the key, explain why it matters, and say whether the script can generate it automatically +- Auth signup/sign-in failure: + - preserve the backend error message + - add repo-specific guidance, e.g. missing JWT setup versus invalid credentials +- Workspace resolution failure: + - explain whether no workspace exists yet, auth failed, or the chosen workspace is no longer present +- Local env mismatch: + - show which file/key failed validation and whether the script preserved a conflicting manual value + +## Risks / Trade-offs + +- [Risk] Introducing a Node bootstrap adds more code than the current shell script. + - Mitigation: keep the flow modular, data-driven, and covered by deterministic orchestration tests. +- [Risk] The current docs may be materially wrong about required auth env keys. + - Mitigation: align the setup manifest and docs to the verified current repo/runtime contract during this change. +- [Risk] Real Convex CLI behavior can drift again. + - Mitigation: add a smoke harness plus targeted doc updates so CLI drift is easier to catch early. +- [Risk] Workspace reuse flows may be more interactive than the current script. + - Mitigation: prefer safe defaults and clearer prompts over silent duplicate creation. + +## Migration Plan + +1. Create a shared setup manifest and Node orchestration layer. +2. Convert `scripts/setup.sh` into a thin wrapper around the new bootstrap. +3. Refactor `scripts/update-env.sh` to reuse the shared env propagation logic instead of duplicating it. +4. Align the bootstrap with the real Convex Auth signup/sign-in flow and backend env requirements. +5. Update docs and env examples to match the new contract. +6. Add deterministic setup tests and the opt-in disposable smoke instructions. + +Rollback strategy: +- If the new bootstrap proves unstable, keep the shared env mapping/tests and temporarily fall back to a reduced shell entrypoint while retaining the non-destructive env merge behavior. + +## Open Questions + +- None. The core unknowns from the current flow are implementation details to verify during build-out, not product-level scope blockers. diff --git a/openspec/changes/foolproof-local-convex-setup/proposal.md b/openspec/changes/foolproof-local-convex-setup/proposal.md new file mode 100644 index 0000000..c9f54a5 --- /dev/null +++ b/openspec/changes/foolproof-local-convex-setup/proposal.md @@ -0,0 +1,41 @@ +## Why + +The current OSS setup path is brittle for first-time contributors. It assumes outdated Convex CLI commands, uses an auth bootstrap path that does not match the repo's actual Convex Auth surface, rewrites `.env.local` files destructively, and only configures a narrow subset of the backend and local environment contract. In practice, that means new users can get stuck before they ever reach a usable backend, and rerunning the script can create duplicate projects or wipe unrelated local configuration. + +The repo already contains the pieces needed for a smoother bootstrap, but they are disconnected: +- Convex CLI now expects project/team flags to be used with `--configure`, and no longer exposes the `whoami` command the script relies on. +- This repo's auth flow is powered by `auth:signIn` with `flow: "signUp"` over the Convex Auth HTTP surface, not `convex run auth:signup`. +- Local consumers read backend URL and workspace state from multiple app-specific `.env.local` files, but the current scripts overwrite those files instead of managing only the Opencom-owned keys. +- The documented backend env contract has drifted from the runtime reality, especially around Convex Auth bootstrap requirements such as JWT keys. + +We need one reliable setup experience that works for: +- a user who has a Convex account but is not logged in locally +- a repo with no local Convex project configured yet +- a rerun against an existing project where the user wants to reuse the deployment instead of creating duplicates +- a contributor who makes a typo or misses a required value and needs actionable, self-correctable output + +## What Changes + +- Replace the current happy-path-only setup orchestration with a stateful, rerun-safe local bootstrap flow that can guide project configuration, backend env setup, workspace resolution, and local env propagation. +- Preserve the `./scripts/setup.sh` entrypoint, but move the complex orchestration into a Node-based implementation that can parse JSON, call HTTP auth endpoints, merge env files safely, and emit structured validation errors. +- Define a canonical setup manifest for required core values, optional feature flags/secrets, and per-app local env mappings. +- Validate the actual Convex/Auth contract instead of stale assumptions, including required auth bootstrap keys and the current CLI behavior. +- Add automated coverage for fresh-environment and rerun scenarios using isolated temporary homes plus a fake/stubbed Convex CLI harness, and document an opt-in real smoke path for disposable-container/manual verification. +- Update setup docs and env examples so the documented manual path, automated path, and repo scripts all describe the same contract. + +## Capabilities + +### New Capabilities + +- `local-convex-setup-bootstrap`: The repository provides a rerun-safe local bootstrap flow that can configure or reuse a Convex project, validate backend auth requirements, resolve a workspace without duplicate creation by default, and write the required local env keys across supported app surfaces without destructive overwrite. + +### Modified Capabilities + +- None. + +## Impact + +- Setup scripts: `scripts/setup.sh`, `scripts/update-env.sh`, and new shared setup/bootstrap modules. +- Docs and templates: `docs/open-source/setup-self-host-and-deploy.md`, relevant `*.env.example` files, and any repo references that currently describe the stale setup contract. +- Verification: new focused tests for the setup flow and any fixtures/stubs needed to simulate Convex CLI/auth behavior in a clean environment. +- Contributor experience: first-run local setup, rerun/reconfigure behavior, and self-service debugging when setup validation fails. diff --git a/openspec/changes/foolproof-local-convex-setup/specs/local-convex-setup-bootstrap/spec.md b/openspec/changes/foolproof-local-convex-setup/specs/local-convex-setup-bootstrap/spec.md new file mode 100644 index 0000000..72cc006 --- /dev/null +++ b/openspec/changes/foolproof-local-convex-setup/specs/local-convex-setup-bootstrap/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: The repository MUST provide a rerun-safe local Convex setup bootstrap +The local setup entrypoint SHALL guide contributors through configuring or reusing a Convex development deployment without depending on stale CLI commands or silently creating duplicate projects by default. + +#### Scenario: First-time contributor configures a new Convex project +- **GIVEN** a contributor has no local Convex project configured for this repo +- **WHEN** they run the repository setup bootstrap +- **THEN** the flow SHALL guide them through the current Convex CLI login/project configuration path +- **AND** the bootstrap SHALL verify that the resulting deployment metadata is available before continuing + +#### Scenario: Contributor reruns setup with an existing local Convex configuration +- **GIVEN** the repo already has a usable local Convex deployment configured +- **WHEN** the contributor reruns the setup bootstrap +- **THEN** the flow SHALL offer to keep or reconfigure the existing deployment +- **AND** it SHALL default to reuse instead of silently creating a new project + +### Requirement: The setup bootstrap MUST validate the backend auth bootstrap contract before workspace provisioning +The setup flow SHALL validate the backend environment values required for the repo's local auth bootstrap path and stop with actionable guidance when required auth configuration is missing or invalid. + +#### Scenario: Required backend auth env is missing +- **WHEN** the setup bootstrap detects a required auth bootstrap env value is missing on the configured deployment +- **THEN** it SHALL name the missing key +- **AND** explain why that key is required for local auth/bootstrap +- **AND** either generate/set the value automatically or tell the contributor exactly how to provide it before retrying + +#### Scenario: Backend auth bootstrap is misconfigured +- **WHEN** the setup bootstrap attempts password signup or sign-in against the configured deployment and the backend returns an auth configuration error +- **THEN** the setup output SHALL preserve the backend error context +- **AND** add repo-specific guidance that helps the contributor self-correct the misconfiguration + +### Requirement: The setup bootstrap MUST resolve a valid workspace without duplicate creation by default +The setup flow SHALL derive the workspace used by local surfaces from authenticated backend state whenever possible, and SHALL prefer reuse of existing workspaces on rerun paths. + +#### Scenario: Empty deployment needs a bootstrap workspace +- **GIVEN** the configured deployment has no existing users or workspaces +- **WHEN** the contributor completes the required bootstrap credential prompts +- **THEN** the setup flow SHALL create the bootstrap account/workspace through the repo's supported auth path +- **AND** capture the resulting workspace identifier for local env propagation + +#### Scenario: Existing deployment already has users and workspaces +- **GIVEN** the configured deployment already contains users or workspaces +- **WHEN** the contributor reruns setup +- **THEN** the flow SHALL support signing in to an existing account and selecting an existing workspace for local env propagation +- **AND** it SHALL not create a new workspace unless the contributor explicitly asks for one + +### Requirement: The setup bootstrap MUST update local env files non-destructively +The repository setup tooling SHALL write the Opencom-owned local env keys needed by supported app surfaces while preserving unrelated user-managed keys and comments. + +#### Scenario: Setup writes app-specific backend envs +- **WHEN** the setup bootstrap has resolved a backend URL and workspace identifier +- **THEN** it SHALL write the required Opencom-owned keys to each supported local env file +- **AND** those keys SHALL be consistent across the web, widget, mobile, landing, RN example, and local shell/test surfaces that depend on them + +#### Scenario: Existing env files already contain unrelated values +- **GIVEN** one or more local env files already exist with unrelated user-managed entries +- **WHEN** the setup bootstrap updates the Opencom-managed keys +- **THEN** it SHALL preserve the unrelated entries instead of overwriting the whole file + +### Requirement: The repository MUST maintain automated verification for setup orchestration behavior +The setup tooling SHALL have deterministic automated coverage for its clean-environment, rerun, validation, and env-merge behavior, with a documented opt-in real smoke path for disposable-environment testing. + +#### Scenario: Clean-environment orchestration is exercised under automation +- **WHEN** the setup test harness runs against an isolated temporary environment with stubbed Convex/auth dependencies +- **THEN** it SHALL verify first-run configuration, required env validation, workspace resolution, and local env propagation behavior deterministically + +#### Scenario: Existing-env rerun behavior is exercised under automation +- **WHEN** the setup test harness runs against an environment that already has local env files and an existing configured deployment +- **THEN** it SHALL verify safe reuse/reconfigure branching and non-destructive env merging +- **AND** failures in those paths SHALL produce actionable diagnostic output diff --git a/openspec/changes/foolproof-local-convex-setup/tasks.md b/openspec/changes/foolproof-local-convex-setup/tasks.md new file mode 100644 index 0000000..ca2cdea --- /dev/null +++ b/openspec/changes/foolproof-local-convex-setup/tasks.md @@ -0,0 +1,24 @@ +## 1. Setup Bootstrap Architecture + +- [x] 1.1 Replace the current Bash-only orchestration with a standalone Node bootstrap while preserving `./scripts/setup.sh` as the public entrypoint. +- [x] 1.2 Define a shared setup manifest for required core values, optional feature values, env defaults, and per-file local env mappings. +- [x] 1.3 Refactor `scripts/update-env.sh` to reuse the shared env propagation logic instead of duplicating key/file handling. + +## 2. Convex Configuration And Auth Bootstrap + +- [x] 2.1 Implement first-run and rerun handling around the current Convex CLI contract, including safe reuse or `--configure` reconfiguration of the dev deployment. +- [x] 2.2 Replace the stale auth bootstrap path with the repo's actual Convex Auth password signup/sign-in flow and workspace resolution logic. +- [x] 2.3 Validate and set the required backend auth env keys needed for local password-auth bootstrap, with actionable errors when values are missing or invalid. + +## 3. Local Env Safety And User Guidance + +- [x] 3.1 Add non-destructive managed env updates for all supported local env files, preserving unrelated manual keys/comments. +- [x] 3.2 Write and verify the required backend URL/workspace mappings across web, widget, mobile, landing, RN example, and local Convex shell/test env files. +- [x] 3.3 Improve setup output so each failure states what went wrong, why it matters, and the exact self-correction path. + +## 4. Verification And Documentation + +- [x] 4.1 Add deterministic automated tests for clean-environment, rerun/reuse, invalid-value, and env-merge scenarios using isolated temp homes plus stubbed Convex/auth dependencies. +- [x] 4.2 Document an opt-in disposable-container smoke path for exercising the real setup flow against a fresh local environment and real Convex login/project selection. +- [x] 4.3 Update `docs/open-source/setup-self-host-and-deploy.md`, relevant env examples, and any setup references that currently describe stale commands or env requirements. +- [x] 4.4 Run focused verification for the new setup tooling and `openspec validate foolproof-local-convex-setup --strict --no-interactive`. diff --git a/package.json b/package.json index ad73a8b..b0d3c89 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "ci:check": "pnpm lint && pnpm typecheck && pnpm security:convex-auth-guard && pnpm security:convex-any-args-gate && pnpm security:secret-scan && pnpm security:headers-check && pnpm security:audit-gate && pnpm test:convex && pnpm --filter @opencom/web build", "test:summary": "node scripts/test-summary.js", "test:clear": "node scripts/test-summary.js clear", + "test:setup-bootstrap": "node --test scripts/tests/local-convex-setup.test.js", "test:timeout": "node scripts/run-with-timeout.js", "test:e2e:prod": "E2E_USE_PROD_BUILD=true pnpm web:test:e2e", "seed:landing": "pnpm dlx dotenv-cli -e apps/landing/.env.local -- tsx scripts/seed-landing-demo.ts", @@ -82,8 +83,8 @@ "pnpm": { "overrides": { "@isaacs/brace-expansion": "5.0.1", - "@xmldom/xmldom@0.7.13": "0.8.12", - "@xmldom/xmldom@0.8.11": "0.8.12", + "@xmldom/xmldom@0.7.13": "0.8.13", + "@xmldom/xmldom@0.8.11": "0.8.13", "esbuild": "^0.27.0", "fast-xml-parser": "4.5.5", "flatted": "3.4.2", diff --git a/packages/convex/.env.test.example b/packages/convex/.env.test.example index 0644199..d68a5c8 100644 --- a/packages/convex/.env.test.example +++ b/packages/convex/.env.test.example @@ -19,10 +19,11 @@ TEST_ADMIN_SECRET= # To set up a dedicated test deployment: # 1. cd packages/convex -# 2. npx convex login (if not already logged in) -# 3. npx convex dev --project opencom-test --once +# 2. pnpm exec convex dev --once --configure +# 3. Reuse or create a dedicated test deployment when the CLI prompts you # 4. Update CONVEX_URL above with the test deployment URL -# 5. Set SITE_URL in Convex dashboard: npx convex env set SITE_URL http://localhost:3000 -# 6. Set ALLOW_TEST_DATA: npx convex env set ALLOW_TEST_DATA true -# 7. Set TEST_ADMIN_SECRET: npx convex env set TEST_ADMIN_SECRET -# 8. Copy the same secret into TEST_ADMIN_SECRET above +# 5. Run ../../scripts/setup.sh once, or configure Convex Auth JWT_PRIVATE_KEY/JWKS with pnpm exec convex auth add +# 6. Set SITE_URL in Convex dashboard: pnpm exec convex env set SITE_URL http://localhost:3000 +# 7. Set ALLOW_TEST_DATA: pnpm exec convex env set ALLOW_TEST_DATA true +# 8. Set TEST_ADMIN_SECRET: pnpm exec convex env set TEST_ADMIN_SECRET +# 9. Copy the same secret into TEST_ADMIN_SECRET above diff --git a/packages/react-native-sdk/example/.env.example b/packages/react-native-sdk/example/.env.example index 75d70c1..f197a8e 100644 --- a/packages/react-native-sdk/example/.env.example +++ b/packages/react-native-sdk/example/.env.example @@ -1,5 +1,6 @@ # Opencom SDK Configuration # Copy this file to .env.local and fill in your values +# Tip: ./scripts/update-env.sh fills these in automatically for local setup EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud EXPO_PUBLIC_WORKSPACE_ID=your-workspace-id diff --git a/packages/react-native-sdk/example/README.md b/packages/react-native-sdk/example/README.md index 31fe5e9..6714e0d 100644 --- a/packages/react-native-sdk/example/README.md +++ b/packages/react-native-sdk/example/README.md @@ -23,6 +23,8 @@ EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud EXPO_PUBLIC_WORKSPACE_ID=your-workspace-id ``` +If you already ran the repo bootstrap, `./scripts/update-env.sh` writes these values for you. + ### 3. Run the App ```bash diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0c8952..6533fdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: overrides: '@isaacs/brace-expansion': 5.0.1 - '@xmldom/xmldom@0.7.13': 0.8.12 - '@xmldom/xmldom@0.8.11': 0.8.12 + '@xmldom/xmldom@0.7.13': 0.8.13 + '@xmldom/xmldom@0.8.11': 0.8.13 esbuild: ^0.27.0 fast-xml-parser: 4.5.5 flatted: 3.4.2 @@ -3145,8 +3145,8 @@ packages: '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} - '@xmldom/xmldom@0.8.12': - resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} abort-controller@3.0.0: @@ -8817,13 +8817,13 @@ snapshots: '@expo/plist@0.1.3': dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 14.0.0 '@expo/plist@0.4.8': dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -11095,7 +11095,7 @@ snapshots: '@vscode/sudo-prompt@9.3.2': {} - '@xmldom/xmldom@0.8.12': {} + '@xmldom/xmldom@0.8.13': {} abort-controller@3.0.0: dependencies: @@ -14387,7 +14387,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 diff --git a/scripts/lib/local-convex-setup-manifest.js b/scripts/lib/local-convex-setup-manifest.js new file mode 100644 index 0000000..6ae6216 --- /dev/null +++ b/scripts/lib/local-convex-setup-manifest.js @@ -0,0 +1,172 @@ +"use strict"; + +const LOCAL_SITE_URL = "http://localhost:3000"; + +const CORE_BACKEND_ENV = [ + { + id: "convex-auth-jwt-keys", + keys: ["JWT_PRIVATE_KEY", "JWKS"], + required: true, + description: + "Required by Convex Auth to sign password-auth sessions and expose the matching public JWKS.", + resolution: "generate-jwt-keypair", + }, + { + key: "SITE_URL", + required: false, + description: + "Recommended local default for Convex Auth callback/link generation. Needed for OTP/email flows.", + resolution: "default", + defaultValue: LOCAL_SITE_URL, + }, +]; + +const OPTIONAL_BACKEND_PROFILES = [ + { + id: "otp-email", + label: "OTP email sign-in", + description: "Passwordless email code sign-in for web/mobile auth flows.", + checks: [ + { + mode: "any", + keys: ["AUTH_RESEND_KEY", "RESEND_API_KEY"], + message: "set AUTH_RESEND_KEY or RESEND_API_KEY", + }, + { + mode: "all", + keys: ["EMAIL_FROM"], + message: "set EMAIL_FROM", + }, + { + mode: "all", + keys: ["SITE_URL"], + message: "set SITE_URL", + }, + ], + }, + { + id: "email-channel", + label: "Email channel + webhook handling", + description: "Inbound/outbound email conversations and webhook verification.", + checks: [ + { + mode: "all", + keys: ["RESEND_API_KEY"], + message: "set RESEND_API_KEY", + }, + { + mode: "all", + keys: ["EMAIL_FROM"], + message: "set EMAIL_FROM", + }, + { + mode: "all", + keys: ["RESEND_WEBHOOK_SECRET"], + message: "set RESEND_WEBHOOK_SECRET", + }, + { + mode: "any", + keys: ["EMAIL_WEBHOOK_INTERNAL_SECRET", "RESEND_WEBHOOK_SECRET"], + message: "set EMAIL_WEBHOOK_INTERNAL_SECRET (or reuse RESEND_WEBHOOK_SECRET)", + }, + ], + }, + { + id: "ai-agent", + label: "AI agent generation", + description: "Model discovery and generated AI responses.", + checks: [ + { + mode: "all", + keys: ["AI_GATEWAY_API_KEY"], + message: "set AI_GATEWAY_API_KEY", + }, + ], + }, + { + id: "test-demo", + label: "Test/demo helpers", + description: "Landing seed scripts and testAdmin-backed helper flows.", + checks: [ + { + mode: "all", + keys: ["ALLOW_TEST_DATA"], + message: "set ALLOW_TEST_DATA=true", + validate: (value) => value === "true", + }, + { + mode: "all", + keys: ["TEST_ADMIN_SECRET"], + message: "set TEST_ADMIN_SECRET", + }, + ], + }, +]; + +const LOCAL_ENV_TARGETS = [ + { + relativePath: "apps/web/.env.local", + description: "Web dashboard local backend + widget demo defaults", + managedComment: "Managed by Opencom local setup (web dashboard defaults)", + values: ({ convexUrl, workspaceId }) => ({ + E2E_BACKEND_URL: convexUrl, + NEXT_PUBLIC_CONVEX_URL: convexUrl, + NEXT_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL: convexUrl, + NEXT_PUBLIC_TEST_WORKSPACE_ID: workspaceId, + }), + }, + { + relativePath: "apps/widget/.env.local", + description: "Widget local dev bootstrap", + managedComment: "Managed by Opencom local setup (widget dev bootstrap)", + values: ({ convexUrl, workspaceId }) => ({ + VITE_CONVEX_URL: convexUrl, + VITE_WORKSPACE_ID: workspaceId, + }), + }, + { + relativePath: "apps/mobile/.env.local", + description: "Mobile admin app backend defaults", + managedComment: "Managed by Opencom local setup (mobile defaults)", + values: ({ convexUrl, workspaceId }) => ({ + EXPO_PUBLIC_CONVEX_URL: convexUrl, + EXPO_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL: convexUrl, + EXPO_PUBLIC_WORKSPACE_ID: workspaceId, + }), + }, + { + relativePath: "apps/landing/.env.local", + description: "Landing page widget demo defaults", + managedComment: "Managed by Opencom local setup (landing widget demo)", + values: ({ convexUrl, workspaceId }) => ({ + NEXT_PUBLIC_CONVEX_URL: convexUrl, + NEXT_PUBLIC_WORKSPACE_ID: workspaceId, + }), + }, + { + relativePath: "packages/react-native-sdk/example/.env.local", + description: "React Native SDK example defaults", + managedComment: "Managed by Opencom local setup (React Native SDK example)", + values: ({ convexUrl, workspaceId }) => ({ + EXPO_PUBLIC_CONVEX_URL: convexUrl, + EXPO_PUBLIC_WORKSPACE_ID: workspaceId, + }), + }, + { + relativePath: "packages/convex/.env.local", + description: "Local Convex shell/test helpers", + managedComment: "Managed by Opencom local setup (Convex shell/test helpers)", + values: ({ convexUrl, workspaceId }) => ({ + CONVEX_URL: convexUrl, + E2E_BACKEND_URL: convexUrl, + WORKSPACE_ID: workspaceId, + }), + }, +]; + +module.exports = { + CORE_BACKEND_ENV, + LOCAL_ENV_TARGETS, + LOCAL_SITE_URL, + OPTIONAL_BACKEND_PROFILES, +}; diff --git a/scripts/local-convex-setup.js b/scripts/local-convex-setup.js new file mode 100644 index 0000000..b38f5ef --- /dev/null +++ b/scripts/local-convex-setup.js @@ -0,0 +1,1558 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const os = require("node:os"); +const { spawn } = require("node:child_process"); +const readline = require("node:readline/promises"); + +const { + CORE_BACKEND_ENV, + LOCAL_ENV_TARGETS, + OPTIONAL_BACKEND_PROFILES, +} = require("./lib/local-convex-setup-manifest"); + +const ROOT_DIR = path.resolve(__dirname, ".."); +const CONVEX_ENV_FILE = path.join("packages", "convex", ".env.local"); +const COLORS = { + red: "\u001b[31m", + green: "\u001b[32m", + yellow: "\u001b[33m", + blue: "\u001b[34m", + bold: "\u001b[1m", + reset: "\u001b[0m", +}; + +class SetupError extends Error { + constructor({ summary, why, fix, details }) { + super(summary); + this.name = "SetupError"; + this.summary = summary; + this.why = why; + this.fix = Array.isArray(fix) ? fix : fix ? [fix] : []; + this.details = details || ""; + } +} + +function colorize(color, value, runtime) { + if (!runtime.output.isTTY) { + return value; + } + return `${color}${value}${COLORS.reset}`; +} + +function formatSectionTitle(value, runtime) { + return colorize(COLORS.bold + COLORS.blue, value, runtime); +} + +function logSection(runtime, title) { + runtime.log(`\n${formatSectionTitle(title, runtime)}`); +} + +function logSuccess(runtime, message) { + runtime.log(`${colorize(COLORS.green, "✓", runtime)} ${message}`); +} + +function logWarning(runtime, message) { + runtime.warn(`${colorize(COLORS.yellow, "!", runtime)} ${message}`); +} + +function parseEnvAssignment(line) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) { + return null; + } + + return { + key: match[1], + rawValue: match[2], + }; +} + +function unquoteEnvValue(rawValue) { + const trimmed = rawValue.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed + .slice(1, -1) + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\t/g, "\t") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + } + + if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +function parseEnvContent(content) { + const values = {}; + const normalized = content.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + for (const line of lines) { + const parsed = parseEnvAssignment(line); + if (!parsed) { + continue; + } + values[parsed.key] = unquoteEnvValue(parsed.rawValue); + } + return values; +} + +function formatEnvValue(value) { + const stringValue = String(value); + return `"${stringValue + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t")}"`; +} + +function formatConvexEnvFileValue(value) { + const stringValue = String(value); + if (stringValue.includes('"') && !stringValue.includes("'")) { + return `'${stringValue}'`; + } + return formatEnvValue(value); +} + +function mergeEnvFileContent(existingContent, desiredEntries, managedComment) { + const desiredKeys = Object.keys(desiredEntries); + const normalized = existingContent.replace(/\r\n/g, "\n"); + const hasContent = normalized.length > 0; + const lines = hasContent ? normalized.split("\n") : []; + const seenKeys = new Set(); + + const mergedLines = lines.map((line) => { + const parsed = parseEnvAssignment(line); + if (!parsed || !Object.prototype.hasOwnProperty.call(desiredEntries, parsed.key)) { + return line; + } + + seenKeys.add(parsed.key); + return `${parsed.key}=${formatEnvValue(desiredEntries[parsed.key])}`; + }); + + const missingKeys = desiredKeys.filter((key) => !seenKeys.has(key)); + if (missingKeys.length > 0) { + if (mergedLines.length > 0 && mergedLines[mergedLines.length - 1] !== "") { + mergedLines.push(""); + } + mergedLines.push(`# ${managedComment}`); + for (const key of missingKeys) { + mergedLines.push(`${key}=${formatEnvValue(desiredEntries[key])}`); + } + } + + let result = mergedLines.join("\n"); + if (!result.endsWith("\n")) { + result = `${result}\n`; + } + return result; +} + +function generateJwtKeyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicExponent: 0x10001, + }); + const jwtPrivateKey = privateKey + .export({ type: "pkcs8", format: "pem" }) + .trimEnd() + .replace(/\n/g, " "); + const publicJwk = publicKey.export({ format: "jwk" }); + return { + JWT_PRIVATE_KEY: jwtPrivateKey, + JWKS: JSON.stringify({ keys: [{ use: "sig", ...publicJwk }] }), + }; +} + +function isValidJwtPrivateKey(value) { + const normalized = String(value || "").trim(); + return ( + normalized.startsWith("-----BEGIN PRIVATE KEY-----") && + normalized.endsWith("-----END PRIVATE KEY-----") + ); +} + +function isValidJwks(value) { + try { + const parsed = JSON.parse(String(value || "")); + return ( + parsed && + typeof parsed === "object" && + Array.isArray(parsed.keys) && + parsed.keys.length > 0 + ); + } catch { + return false; + } +} + +function isValidCoreBackendEnvValue(entry, key, value) { + if (!value) { + return false; + } + if (entry.resolution !== "generate-jwt-keypair") { + return true; + } + if (key === "JWT_PRIVATE_KEY") { + return isValidJwtPrivateKey(value); + } + if (key === "JWKS") { + return isValidJwks(value); + } + return true; +} + +function trimCommandOutput(value) { + return value.replace(/\r\n/g, "\n").trim(); +} + +function toErrorMessage(error) { + if (error instanceof Error && error.message) { + return error.message; + } + return String(error); +} + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readEnvFile(filePath) { + if (!(await fileExists(filePath))) { + return {}; + } + const content = await fs.readFile(filePath, "utf8"); + return parseEnvContent(content); +} + +function createTerminalUi({ input = process.stdin, output = process.stdout } = {}) { + const rl = readline.createInterface({ input, output }); + + async function ask(question, options = {}) { + const suffix = + options.defaultValue !== undefined && options.defaultValue !== "" + ? ` [${options.defaultValue}]` + : ""; + + while (true) { + const answer = await rl.question(`${question}${suffix}: `); + const resolved = answer.trim() || options.defaultValue || ""; + if (resolved || !options.required) { + return resolved; + } + output.write("This value is required.\n"); + } + } + + async function askSecret(question, options = {}) { + if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") { + return ask(question, options); + } + + const suffix = + options.defaultValue !== undefined && options.defaultValue !== "" + ? ` [${"*".repeat(String(options.defaultValue).length)}]` + : ""; + + output.write(`${question}${suffix}: `); + const wasRaw = input.isRaw; + input.setRawMode(true); + input.resume(); + input.setEncoding("utf8"); + + return await new Promise((resolve, reject) => { + let value = ""; + + function cleanup() { + input.off("data", onData); + input.setRawMode(Boolean(wasRaw)); + input.pause(); + } + + function finish(resolvedValue) { + cleanup(); + output.write("\n"); + resolve(resolvedValue); + } + + function onData(chunk) { + const characters = Array.from(chunk); + for (const character of characters) { + if (character === "\u0003") { + cleanup(); + reject(new Error("Interrupted")); + return; + } + + if (character === "\r" || character === "\n") { + const resolved = value || options.defaultValue || ""; + if (!resolved && options.required) { + output.write("\nThis value is required.\n"); + output.write(`${question}${suffix}: `); + value = ""; + continue; + } + finish(resolved); + return; + } + + if (character === "\u007f" || character === "\b") { + if (value.length > 0) { + value = value.slice(0, -1); + output.write("\b \b"); + } + continue; + } + + value += character; + output.write("*"); + } + } + + input.on("data", onData); + }); + } + + async function confirm(question, defaultValue = true) { + const prompt = defaultValue ? " [Y/n]: " : " [y/N]: "; + const answer = (await rl.question(`${question}${prompt}`)).trim().toLowerCase(); + if (!answer) { + return defaultValue; + } + return answer === "y" || answer === "yes"; + } + + async function select(question, options, defaultIndex = 0) { + output.write(`${question}\n`); + options.forEach((option, index) => { + const marker = index === defaultIndex ? " (default)" : ""; + output.write(` ${index + 1}. ${option.label}${marker}\n`); + }); + + while (true) { + const answer = (await rl.question("Choose an option: ")).trim(); + const index = answer ? Number.parseInt(answer, 10) - 1 : defaultIndex; + if (Number.isInteger(index) && index >= 0 && index < options.length) { + return options[index].value; + } + output.write("Enter one of the numbered options above.\n"); + } + } + + return { + ask, + askSecret, + confirm, + select, + close: async () => { + rl.close(); + }, + }; +} + +function createRuntime(overrides = {}) { + const input = overrides.input || process.stdin; + const output = overrides.output || process.stdout; + const ui = overrides.ui || createTerminalUi({ input, output }); + return { + rootDir: overrides.rootDir || ROOT_DIR, + input, + output, + ui, + fetchImpl: overrides.fetchImpl || global.fetch, + generateJwtKeyPair: overrides.generateJwtKeyPair || generateJwtKeyPair, + log: overrides.log || ((message) => output.write(`${message}\n`)), + warn: overrides.warn || ((message) => output.write(`${message}\n`)), + error: overrides.error || ((message) => output.write(`${message}\n`)), + async readFile(filePath, encoding = "utf8") { + return fs.readFile(filePath, encoding); + }, + async writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf8"); + }, + async exists(filePath) { + return fileExists(filePath); + }, + async runCommand(command, args, options = {}) { + return runCommand(command, args, { + cwd: options.cwd || this.rootDir, + env: options.env, + stdio: options.stdio || "pipe", + }); + }, + }; +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, + stdio: options.stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + if (child.stdout) { + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + } + + if (child.stderr) { + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + } + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr, code }); + return; + } + + const error = new Error( + `Command failed: ${command} ${args.join(" ")}\n${trimCommandOutput(stderr || stdout)}` + ); + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + +function sanitizeConvexUrl(value) { + return String(value || "") + .trim() + .replace(/\/$/, ""); +} + +function convexCloudToSiteUrl(value) { + return sanitizeConvexUrl(value).replace(/\.convex\.cloud$/, ".convex.site"); +} + +async function getConvexConfig(runtime) { + const env = await readEnvFile(path.join(runtime.rootDir, CONVEX_ENV_FILE)); + const convexUrl = sanitizeConvexUrl(env.CONVEX_URL || env.E2E_BACKEND_URL || ""); + return { + env, + convexUrl, + deployment: String(env.CONVEX_DEPLOYMENT || "").trim(), + }; +} + +async function installDependencies(runtime) { + logSection(runtime, "1. Install Dependencies"); + runtime.log("Running pnpm install so the workspace and Convex CLI are ready for setup."); + try { + await runtime.runCommand("pnpm", ["install"], { stdio: "inherit" }); + } catch (error) { + throw new SetupError({ + summary: "pnpm install failed.", + why: "The local bootstrap cannot continue until the workspace dependencies are installed.", + fix: [ + "Resolve the pnpm install failure shown above, then rerun ./scripts/setup.sh.", + "If you are setting up pnpm for the first time, confirm `pnpm -v` works in this shell.", + ], + details: toErrorMessage(error), + }); + } + logSuccess(runtime, "Dependencies installed."); +} + +async function ensureConvexDeployment(runtime, options) { + logSection(runtime, "2. Configure Or Reuse The Convex Dev Deployment"); + const existing = await getConvexConfig(runtime); + let shouldReconfigure = Boolean(options.reconfigure); + + if (!existing.deployment || !existing.convexUrl) { + runtime.log( + "No usable local Convex deployment metadata was found. Starting the Convex CLI flow." + ); + } else if (!shouldReconfigure) { + runtime.log(`Found existing deployment ${existing.deployment} in packages/convex/.env.local.`); + if (!options.nonInteractive) { + const reuse = await runtime.ui.confirm( + "Reuse the currently configured Convex deployment?", + true + ); + shouldReconfigure = !reuse; + } + } + + if (!existing.deployment || !existing.convexUrl || shouldReconfigure) { + const args = ["--filter", "@opencom/convex", "exec", "convex", "dev", "--once"]; + if (shouldReconfigure) { + args.push("--configure"); + } + + try { + await runtime.runCommand("pnpm", args, { stdio: "inherit" }); + } catch (error) { + throw new SetupError({ + summary: "Convex project configuration failed.", + why: "The rest of the bootstrap needs a working dev deployment before it can validate auth or write local env files.", + fix: [ + "Finish any login/project-selection prompts from the Convex CLI, then rerun ./scripts/setup.sh.", + "If you wanted a different deployment, rerun ./scripts/setup.sh --reconfigure.", + "If the CLI completed successfully but the file still looks wrong, inspect packages/convex/.env.local.", + ], + details: toErrorMessage(error), + }); + } + } + + const refreshed = await getConvexConfig(runtime); + if (!refreshed.deployment || !refreshed.convexUrl) { + throw new SetupError({ + summary: "Convex setup did not produce the deployment metadata this repo expects.", + why: "Opencom needs both CONVEX_DEPLOYMENT and CONVEX_URL in packages/convex/.env.local to continue safely.", + fix: [ + "Rerun ./scripts/setup.sh --reconfigure and complete the Convex CLI flow.", + "If the CLI already succeeded, inspect packages/convex/.env.local for CONVEX_DEPLOYMENT and CONVEX_URL.", + ], + }); + } + + logSuccess(runtime, `Using deployment ${refreshed.deployment}.`); + logSuccess(runtime, `Resolved backend URL ${refreshed.convexUrl}.`); + return refreshed; +} + +async function getBackendEnvValue(runtime, key) { + try { + const result = await runtime.runCommand("pnpm", [ + "--filter", + "@opencom/convex", + "exec", + "convex", + "env", + "get", + key, + ]); + return trimCommandOutput(result.stdout); + } catch (error) { + const message = trimCommandOutput(error.stderr || error.stdout || ""); + if (/not set|could not find|No environment variable/i.test(message)) { + return ""; + } + throw error; + } +} + +async function setBackendEnvValue(runtime, key, value) { + await runtime.runCommand( + "pnpm", + ["--filter", "@opencom/convex", "exec", "convex", "env", "set", key, value], + { stdio: "inherit" } + ); +} + +async function setBackendEnvValues(runtime, values, options = {}) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencom-convex-env-")); + const tempEnvFile = path.join(tempDir, "env.values"); + const content = + Object.entries(values) + .map(([key, value]) => `${key}=${formatConvexEnvFileValue(value)}`) + .join("\n") + "\n"; + + try { + await runtime.writeFile(tempEnvFile, content); + const args = [ + "--filter", + "@opencom/convex", + "exec", + "convex", + "env", + "set", + "--from-file", + tempEnvFile, + ]; + if (options.force) { + args.push("--force"); + } + await runtime.runCommand("pnpm", args, { stdio: "inherit" }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function ensureCoreBackendEnv(runtime) { + logSection(runtime, "3. Validate Backend Auth Bootstrap Env"); + const resolvedValues = {}; + + for (const entry of CORE_BACKEND_ENV) { + if (entry.resolution === "generate-jwt-keypair") { + const keys = entry.keys || []; + const currentValues = {}; + for (const key of keys) { + currentValues[key] = await getBackendEnvValue(runtime, key); + } + + const missingKeys = keys.filter( + (key) => !isValidCoreBackendEnvValue(entry, key, currentValues[key]) + ); + if (missingKeys.length > 0) { + const generatedValues = runtime.generateJwtKeyPair(); + const desiredValues = Object.fromEntries(keys.map((key) => [key, generatedValues[key]])); + try { + await setBackendEnvValues(runtime, desiredValues, { force: true }); + Object.assign(currentValues, desiredValues); + } catch (error) { + throw new SetupError({ + summary: `Could not set required backend env ${missingKeys.join(", ")}.`, + why: entry.description, + fix: [ + "Run `pnpm --filter @opencom/convex exec convex auth add`, then rerun ./scripts/setup.sh.", + "If either JWT_PRIVATE_KEY or JWKS is missing, regenerate and set both values from the same key pair.", + ], + details: toErrorMessage(error), + }); + } + } + + for (const key of keys) { + resolvedValues[key] = currentValues[key]; + } + logSuccess(runtime, `${keys.join(" and ")} are configured.`); + continue; + } + + let currentValue = await getBackendEnvValue(runtime, entry.key); + if (!currentValue) { + if (entry.resolution === "default") { + currentValue = entry.defaultValue; + } + + try { + await setBackendEnvValue(runtime, entry.key, currentValue); + } catch (error) { + if (entry.required) { + throw new SetupError({ + summary: `Could not set required backend env ${entry.key}.`, + why: entry.description, + fix: [ + `Run \`pnpm --filter @opencom/convex exec convex env set ${entry.key} \`, then rerun ./scripts/setup.sh.`, + ], + details: toErrorMessage(error), + }); + } + + logWarning( + runtime, + `Could not set optional backend env ${entry.key}. ${entry.description}` + ); + continue; + } + } + + resolvedValues[entry.key] = currentValue; + if (entry.required) { + logSuccess(runtime, `${entry.key} is configured.`); + } else { + logSuccess(runtime, `${entry.key} is configured or defaulted for local use.`); + } + } + + return resolvedValues; +} + +async function callConvexJson(runtime, convexUrl, kind, pathName, args, token) { + if (typeof runtime.fetchImpl !== "function") { + throw new SetupError({ + summary: "Fetch is not available in this Node runtime.", + why: "The setup bootstrap uses HTTP calls to the configured Convex deployment for auth and workspace resolution.", + fix: ["Use Node.js 18+ and rerun ./scripts/setup.sh."], + }); + } + + const url = `${sanitizeConvexUrl(convexUrl)}/api/${kind}`; + const headers = { + "Content-Type": "application/json", + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const body = { + path: pathName, + args: args || {}, + }; + + if (kind === "action") { + body.format = "json"; + } + + let response; + try { + response = await runtime.fetchImpl(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + } catch (error) { + throw new SetupError({ + summary: `Could not reach ${pathName} on the configured Convex deployment.`, + why: "The bootstrap cannot verify auth or workspace state when the backend is unreachable.", + fix: [ + "Confirm the Convex dev deployment is running and that CONVEX_URL points at the correct instance.", + "If you reconfigured the deployment, rerun ./scripts/setup.sh --reconfigure.", + ], + details: toErrorMessage(error), + }); + } + + const responseText = await response.text(); + let payload; + try { + payload = responseText ? JSON.parse(responseText) : null; + } catch { + payload = responseText; + } + + if (!response.ok) { + const details = + typeof payload === "string" + ? payload + : JSON.stringify(payload, null, 2) || response.statusText; + throw new SetupError({ + summary: `${pathName} returned HTTP ${response.status}.`, + why: "The bootstrap step could not complete with the backend response it received.", + fix: [ + "Inspect the backend error below, fix the misconfiguration, then rerun ./scripts/setup.sh.", + ], + details, + }); + } + + if (payload && typeof payload === "object" && payload.status === "error") { + throw new Error(payload.errorMessage || JSON.stringify(payload)); + } + + if ( + payload && + typeof payload === "object" && + payload.status === "success" && + "value" in payload + ) { + return payload.value; + } + + return payload; +} + +async function getSetupState(runtime, convexUrl) { + return callConvexJson(runtime, convexUrl, "query", "setup:checkExistingSetup", {}); +} + +function emailPrefix(email) { + return String(email).split("@")[0] || "Opencom Admin"; +} + +async function collectSignupCredentials(runtime, options) { + const email = + options.adminEmail || + (options.nonInteractive ? "" : await runtime.ui.ask("Admin email", { required: true })); + const password = + options.adminPassword || + (options.nonInteractive + ? "" + : await runtime.ui.askSecret("Admin password", { required: true })); + + if (!email || !password) { + throw new SetupError({ + summary: "Email and password are required for non-interactive bootstrap.", + why: "The script cannot create the bootstrap admin account without credentials.", + fix: ["Pass --email and --password, or rerun ./scripts/setup.sh without --non-interactive."], + }); + } + + const adminName = + options.adminName || + (options.nonInteractive + ? emailPrefix(email) + : await runtime.ui.ask("Admin display name", { + defaultValue: emailPrefix(email), + })); + + const workspaceName = + options.workspaceName || + (options.nonInteractive + ? "" + : await runtime.ui.ask("Workspace name (leave blank to use the default)", { + defaultValue: "", + })); + + return { + email, + password, + adminName, + workspaceName, + }; +} + +async function collectSigninCredentials(runtime, options) { + const email = + options.adminEmail || + (options.nonInteractive + ? "" + : await runtime.ui.ask("Existing admin email", { required: true })); + const password = + options.adminPassword || + (options.nonInteractive + ? "" + : await runtime.ui.askSecret("Existing admin password", { required: true })); + + if (!email || !password) { + throw new SetupError({ + summary: "Existing admin credentials are required for non-interactive reruns.", + why: "The bootstrap needs to authenticate before it can list or reuse the existing workspaces on this deployment.", + fix: ["Pass --email and --password, or rerun ./scripts/setup.sh without --non-interactive."], + }); + } + + return { email, password }; +} + +function authErrorFixes(errorMessage) { + const normalized = errorMessage.toLowerCase(); + const fixes = []; + + if (normalized.includes("invalid credentials") || normalized.includes("account not found")) { + fixes.push( + "Use an existing admin account on this deployment, or rerun against a different deployment." + ); + } + + if ( + normalized.includes("auth_secret") || + normalized.includes("jwt_private_key") || + normalized.includes("jwks") || + normalized.includes("convex_site_url") || + normalized.includes("missing environment variable") || + normalized.includes("openid") || + normalized.includes("issuer") || + normalized.includes("site_url") + ) { + fixes.push( + "Confirm JWT_PRIVATE_KEY and JWKS are set from the same key pair, and that CONVEX_SITE_URL is available on the deployment." + ); + } + + if (fixes.length === 0) { + fixes.push( + "Check the backend error below, correct the auth configuration or credentials, then rerun the setup." + ); + } + + return fixes; +} + +async function signInWithPassword(runtime, convexUrl, params) { + try { + const response = await callConvexJson(runtime, convexUrl, "action", "auth:signIn", { + provider: "password", + params, + }); + const token = response?.tokens?.token; + if (!token) { + throw new Error("auth:signIn did not return a JWT token."); + } + return token; + } catch (error) { + const message = error instanceof SetupError ? error.summary : toErrorMessage(error); + const details = error instanceof SetupError ? error.details || toErrorMessage(error) : message; + const fixInput = `${message}\n${details}`; + throw new SetupError({ + summary: `Password auth bootstrap failed: ${message}`, + why: "The local setup flow relies on the repo's real Convex Auth password sign-in/sign-up path.", + fix: authErrorFixes(fixInput), + details, + }); + } +} + +async function getCurrentUser(runtime, convexUrl, token) { + return callConvexJson(runtime, convexUrl, "query", "auth:currentUser", {}, token); +} + +async function createWorkspace(runtime, convexUrl, token, workspaceName) { + try { + return await callConvexJson( + runtime, + convexUrl, + "mutation", + "workspaces:create", + { name: workspaceName }, + token + ); + } catch (error) { + throw new SetupError({ + summary: `Could not create workspace "${workspaceName}".`, + why: "The deployment already had users, and the bootstrap could not create the explicitly requested new workspace.", + fix: [ + "Try a different workspace name, or choose one of the existing workspaces on the deployment.", + ], + details: toErrorMessage(error), + }); + } +} + +async function switchWorkspace(runtime, convexUrl, token, workspaceId) { + await callConvexJson( + runtime, + convexUrl, + "mutation", + "auth:switchWorkspace", + { workspaceId }, + token + ); +} + +function findWorkspace(workspaces, workspaceId) { + return (workspaces || []).find((workspace) => workspace && workspace._id === workspaceId) || null; +} + +async function chooseWorkspace(runtime, options, currentUserPayload, convexUrl, token) { + const workspaces = currentUserPayload?.workspaces || []; + const activeWorkspaceId = currentUserPayload?.user?.workspaceId || null; + + if (workspaces.length === 0 && !options.createWorkspace) { + throw new SetupError({ + summary: "The authenticated account does not have any workspaces to reuse.", + why: "Opencom needs a workspace ID before it can populate the local env files.", + fix: [ + 'Create a workspace in the app and rerun the bootstrap, or rerun ./scripts/setup.sh --create-workspace --workspace "My Workspace".', + ], + }); + } + + if (options.createWorkspace) { + const workspaceName = + options.workspaceName || + (options.nonInteractive + ? "" + : await runtime.ui.ask("New workspace name", { required: true })); + if (!workspaceName) { + throw new SetupError({ + summary: + "A workspace name is required when creating a new workspace on an existing deployment.", + why: "The bootstrap cannot create a new workspace without a name.", + fix: [ + 'Pass --workspace "My Workspace", or rerun interactively and provide the name when prompted.', + ], + }); + } + + const workspaceId = await createWorkspace(runtime, convexUrl, token, workspaceName); + await switchWorkspace(runtime, convexUrl, token, workspaceId); + return { workspaceId, created: true }; + } + + if (options.nonInteractive || workspaces.length === 1) { + return { + workspaceId: activeWorkspaceId || workspaces[0]._id, + created: false, + }; + } + + const choices = workspaces.map((workspace) => ({ + label: + workspace._id === activeWorkspaceId + ? `${workspace.name} (${workspace.role}, current)` + : `${workspace.name} (${workspace.role})`, + value: workspace._id, + })); + choices.push({ + label: "Create a new workspace for this account", + value: "__create_workspace__", + }); + + const defaultIndex = Math.max( + choices.findIndex((choice) => choice.value === activeWorkspaceId), + 0 + ); + + const selected = await runtime.ui.select( + "Choose the workspace to wire into your local env files:", + choices, + defaultIndex + ); + + if (selected === "__create_workspace__") { + const workspaceName = await runtime.ui.ask("New workspace name", { required: true }); + const workspaceId = await createWorkspace(runtime, convexUrl, token, workspaceName); + await switchWorkspace(runtime, convexUrl, token, workspaceId); + return { workspaceId, created: true }; + } + + if (selected !== activeWorkspaceId) { + await switchWorkspace(runtime, convexUrl, token, selected); + } + + return { workspaceId: selected, created: false }; +} + +async function resolveWorkspace(runtime, options, convexConfig) { + logSection(runtime, "4. Create Or Reuse The Bootstrap Workspace"); + const state = await getSetupState(runtime, convexConfig.convexUrl); + + if (!state?.hasUsers) { + runtime.log( + "The deployment is empty, so the bootstrap will create the first admin account/workspace." + ); + const credentials = await collectSignupCredentials(runtime, options); + const token = await signInWithPassword(runtime, convexConfig.convexUrl, { + flow: "signUp", + email: credentials.email, + password: credentials.password, + name: credentials.adminName, + ...(credentials.workspaceName + ? { + workspaceName: credentials.workspaceName, + } + : {}), + }); + + const currentUser = await getCurrentUser(runtime, convexConfig.convexUrl, token); + const workspace = findWorkspace(currentUser?.workspaces, currentUser?.user?.workspaceId); + if (!workspace || !currentUser?.user?.workspaceId) { + throw new SetupError({ + summary: + "Sign-up succeeded, but the bootstrap could not resolve the newly created workspace.", + why: "The local env propagation step needs the authenticated workspace identifier.", + fix: [ + "Inspect auth:currentUser on the configured deployment, then rerun ./scripts/setup.sh once the account/workspace exists.", + ], + }); + } + + logSuccess(runtime, `Created bootstrap admin ${credentials.email}.`); + logSuccess(runtime, `Resolved workspace ${workspace.name} (${workspace._id}).`); + return { + adminEmail: credentials.email, + workspaceId: workspace._id, + workspaceName: workspace.name, + }; + } + + runtime.log( + "This deployment already has users. The bootstrap will reuse an existing admin account by default." + ); + const credentials = await collectSigninCredentials(runtime, options); + const token = await signInWithPassword(runtime, convexConfig.convexUrl, { + flow: "signIn", + email: credentials.email, + password: credentials.password, + }); + const currentUser = await getCurrentUser(runtime, convexConfig.convexUrl, token); + const selection = await chooseWorkspace( + runtime, + options, + currentUser, + convexConfig.convexUrl, + token + ); + const refreshedCurrentUser = await getCurrentUser(runtime, convexConfig.convexUrl, token); + const workspace = findWorkspace(refreshedCurrentUser?.workspaces, selection.workspaceId); + if (!workspace) { + throw new SetupError({ + summary: "The selected workspace could not be resolved after authentication.", + why: "Opencom cannot populate the local env files with a workspace that is not visible to the authenticated account.", + fix: [ + "Choose a different workspace, or confirm the account has access to the workspace you selected.", + ], + }); + } + + logSuccess( + runtime, + `${selection.created ? "Created" : "Using"} workspace ${workspace.name} (${workspace._id}).` + ); + return { + adminEmail: credentials.email, + workspaceId: workspace._id, + workspaceName: workspace.name, + }; +} + +async function writeManagedEnvFiles(runtime, context) { + logSection(runtime, "5. Propagate Local Env Files Safely"); + + for (const target of LOCAL_ENV_TARGETS) { + const absolutePath = path.join(runtime.rootDir, target.relativePath); + const desiredEntries = target.values(context); + const existingContent = (await runtime.exists(absolutePath)) + ? await runtime.readFile(absolutePath, "utf8") + : ""; + const nextContent = mergeEnvFileContent(existingContent, desiredEntries, target.managedComment); + + await runtime.writeFile(absolutePath, nextContent); + const verifiedValues = await readEnvFile(absolutePath); + + for (const [key, expectedValue] of Object.entries(desiredEntries)) { + if (String(verifiedValues[key] || "") !== String(expectedValue)) { + throw new SetupError({ + summary: `Failed to verify ${key} in ${target.relativePath}.`, + why: "The setup only succeeds if each local env file contains the same backend URL and workspace ID mapping it just resolved.", + fix: [ + `Inspect ${target.relativePath} and remove any conflicting manual value for ${key}, then rerun the setup.`, + ], + }); + } + } + + logSuccess(runtime, `Updated ${target.relativePath} without overwriting unrelated entries.`); + } +} + +async function evaluateOptionalProfiles(runtime) { + const results = []; + + for (const profile of OPTIONAL_BACKEND_PROFILES) { + const missingReasons = []; + for (const check of profile.checks) { + const values = []; + for (const key of check.keys) { + values.push(await getBackendEnvValue(runtime, key)); + } + + const passes = (() => { + if (check.mode === "any") { + return values.some((value) => value && (!check.validate || check.validate(value))); + } + return values.every((value) => value && (!check.validate || check.validate(value))); + })(); + + if (!passes) { + missingReasons.push(check.message); + } + } + + results.push({ + ...profile, + enabled: missingReasons.length === 0, + missingReasons, + }); + } + + return results; +} + +function printSetupSummary(runtime, summary, optionalProfiles) { + logSection(runtime, "6. Setup Summary"); + runtime.log(`Deployment: ${summary.deployment}`); + runtime.log(`Backend URL: ${summary.convexUrl}`); + runtime.log(`Workspace: ${summary.workspaceName} (${summary.workspaceId})`); + runtime.log(`Bootstrap admin: ${summary.adminEmail}`); + + const disabledProfiles = optionalProfiles.filter((profile) => !profile.enabled); + if (disabledProfiles.length > 0) { + runtime.log("\nOptional features still disabled:"); + for (const profile of disabledProfiles) { + runtime.log(`- ${profile.label}: ${profile.missingReasons.join("; ")}`); + } + } + + runtime.log("\nNext steps:"); + runtime.log("- pnpm dev:web"); + runtime.log("- pnpm dev:widget"); + runtime.log("- pnpm dev:landing"); + runtime.log("- pnpm dev:mobile"); +} + +async function maybeStartDevServers(runtime, options) { + if (options.skipDev) { + return; + } + + const shouldStart = options.startDev + ? true + : options.nonInteractive + ? false + : await runtime.ui.confirm("Start the web and widget dev servers now?", false); + + if (!shouldStart) { + return; + } + + logSection(runtime, "Starting Dev Servers"); + runtime.log("Launching pnpm dev:web and pnpm dev:widget. Press Ctrl+C to stop both."); + + const webProcess = spawn("pnpm", ["dev:web"], { + cwd: runtime.rootDir, + stdio: "inherit", + env: process.env, + }); + const widgetProcess = spawn("pnpm", ["dev:widget"], { + cwd: runtime.rootDir, + stdio: "inherit", + env: process.env, + }); + + const stopChildren = () => { + if (!webProcess.killed) { + webProcess.kill("SIGTERM"); + } + if (!widgetProcess.killed) { + widgetProcess.kill("SIGTERM"); + } + }; + + process.once("SIGINT", stopChildren); + process.once("SIGTERM", stopChildren); + + await new Promise((resolve, reject) => { + let settled = false; + + function finish(error) { + if (settled) { + return; + } + settled = true; + process.removeListener("SIGINT", stopChildren); + process.removeListener("SIGTERM", stopChildren); + if (error) { + reject(error); + return; + } + resolve(); + } + + webProcess.on("error", finish); + widgetProcess.on("error", finish); + webProcess.on("exit", (code) => { + if (code && code !== 0) { + finish(new Error(`pnpm dev:web exited with code ${code}.`)); + return; + } + stopChildren(); + finish(); + }); + widgetProcess.on("exit", (code) => { + if (code && code !== 0) { + finish(new Error(`pnpm dev:widget exited with code ${code}.`)); + return; + } + stopChildren(); + finish(); + }); + }); +} + +async function runSetup(options, runtime = createRuntime()) { + await installDependencies(runtime); + const convexConfig = await ensureConvexDeployment(runtime, options); + await ensureCoreBackendEnv(runtime); + const workspace = await resolveWorkspace(runtime, options, convexConfig); + await writeManagedEnvFiles(runtime, { + convexUrl: convexConfig.convexUrl, + workspaceId: workspace.workspaceId, + }); + const optionalProfiles = await evaluateOptionalProfiles(runtime); + printSetupSummary( + runtime, + { + adminEmail: workspace.adminEmail, + deployment: convexConfig.deployment, + convexUrl: convexConfig.convexUrl, + workspaceId: workspace.workspaceId, + workspaceName: workspace.workspaceName, + }, + optionalProfiles + ); + await maybeStartDevServers(runtime, options); +} + +async function resolveUpdateEnvInputs(runtime, options) { + const convexEnv = await readEnvFile(path.join(runtime.rootDir, CONVEX_ENV_FILE)); + const webEnv = await readEnvFile(path.join(runtime.rootDir, "apps/web/.env.local")); + const widgetEnv = await readEnvFile(path.join(runtime.rootDir, "apps/widget/.env.local")); + const landingEnv = await readEnvFile(path.join(runtime.rootDir, "apps/landing/.env.local")); + + const convexUrl = + options.convexUrl || + convexEnv.CONVEX_URL || + convexEnv.E2E_BACKEND_URL || + webEnv.NEXT_PUBLIC_CONVEX_URL || + webEnv.E2E_BACKEND_URL || + ""; + const workspaceId = + options.workspaceId || + convexEnv.WORKSPACE_ID || + widgetEnv.VITE_WORKSPACE_ID || + landingEnv.NEXT_PUBLIC_WORKSPACE_ID || + ""; + + const resolvedConvexUrl = + convexUrl || + (options.nonInteractive ? "" : await runtime.ui.ask("Convex URL", { required: true })); + const resolvedWorkspaceId = + workspaceId || + (options.nonInteractive ? "" : await runtime.ui.ask("Workspace ID", { required: true })); + + if (!resolvedConvexUrl || !resolvedWorkspaceId) { + throw new SetupError({ + summary: "Convex URL and workspace ID are both required to update local env files.", + why: "The shared env propagation logic needs both values before it can write consistent local app configuration.", + fix: ["Pass --url and --workspace, or rerun ./scripts/update-env.sh interactively."], + }); + } + + return { + convexUrl: sanitizeConvexUrl(resolvedConvexUrl), + workspaceId: resolvedWorkspaceId.trim(), + }; +} + +async function runUpdateEnv(options, runtime = createRuntime()) { + logSection(runtime, "Update Local Env Files"); + const resolved = await resolveUpdateEnvInputs(runtime, options); + await writeManagedEnvFiles(runtime, resolved); + runtime.log("\nUpdated local env files successfully."); + runtime.log(`Backend URL: ${resolved.convexUrl}`); + runtime.log(`Workspace ID: ${resolved.workspaceId}`); +} + +function printSetupUsage(runtime) { + runtime.log("Usage: ./scripts/setup.sh [options]"); + runtime.log(""); + runtime.log("Options:"); + runtime.log(" --email Bootstrap or existing admin email"); + runtime.log(" --password Bootstrap or existing admin password"); + runtime.log(" --name Bootstrap admin display name"); + runtime.log(" --workspace Workspace name (new workspace only)"); + runtime.log(" --reconfigure Force Convex CLI reconfiguration"); + runtime.log(" --create-workspace Create a new workspace on an existing deployment"); + runtime.log(" --skip-dev Never prompt to start dev servers"); + runtime.log(" --start-dev Start web + widget dev servers after setup"); + runtime.log(" --non-interactive Disable prompts (requires --email and --password)"); + runtime.log(" -h, --help Show this help message"); +} + +function printUpdateEnvUsage(runtime) { + runtime.log("Usage: ./scripts/update-env.sh [options]"); + runtime.log(""); + runtime.log("Options:"); + runtime.log(" --url Convex deployment URL"); + runtime.log(" --workspace Workspace ID"); + runtime.log(" --non-interactive Disable prompts"); + runtime.log(" -h, --help Show this help message"); +} + +function parseSetupArgs(argv) { + const options = { + adminEmail: "", + adminPassword: "", + adminName: "", + workspaceName: "", + createWorkspace: false, + reconfigure: false, + skipDev: false, + startDev: false, + nonInteractive: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + switch (argument) { + case "--email": + options.adminEmail = argv[index + 1] || ""; + index += 1; + break; + case "--password": + options.adminPassword = argv[index + 1] || ""; + index += 1; + break; + case "--name": + options.adminName = argv[index + 1] || ""; + index += 1; + break; + case "--workspace": + options.workspaceName = argv[index + 1] || ""; + index += 1; + break; + case "--create-workspace": + options.createWorkspace = true; + break; + case "--reconfigure": + options.reconfigure = true; + break; + case "--skip-dev": + options.skipDev = true; + break; + case "--start-dev": + options.startDev = true; + break; + case "--non-interactive": + options.nonInteractive = true; + break; + case "-h": + case "--help": + options.help = true; + break; + default: + throw new SetupError({ + summary: `Unknown option: ${argument}`, + fix: ["Run ./scripts/setup.sh --help to see the supported flags."], + }); + } + } + + return options; +} + +function parseUpdateEnvArgs(argv) { + const options = { + convexUrl: "", + workspaceId: "", + nonInteractive: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + switch (argument) { + case "--url": + options.convexUrl = argv[index + 1] || ""; + index += 1; + break; + case "--workspace": + options.workspaceId = argv[index + 1] || ""; + index += 1; + break; + case "--non-interactive": + options.nonInteractive = true; + break; + case "-h": + case "--help": + options.help = true; + break; + default: + throw new SetupError({ + summary: `Unknown option: ${argument}`, + fix: ["Run ./scripts/update-env.sh --help to see the supported flags."], + }); + } + } + + return options; +} + +function printSetupFailure(runtime, error) { + runtime.error(`\n${colorize(COLORS.red, "Setup failed.", runtime)}`); + runtime.error(`What went wrong: ${error.summary || toErrorMessage(error)}`); + if (error.why) { + runtime.error(`Why it matters: ${error.why}`); + } + if (error.fix && error.fix.length > 0) { + runtime.error("How to fix it:"); + for (const step of error.fix) { + runtime.error(`- ${step}`); + } + } + if (error.details) { + runtime.error("Backend/command details:"); + runtime.error(error.details); + } +} + +async function runSetupCli(argv, runtime = createRuntime()) { + let options; + try { + options = parseSetupArgs(argv); + } catch (error) { + printSetupFailure(runtime, error); + return 1; + } + + if (options.help) { + printSetupUsage(runtime); + return 0; + } + + try { + await runSetup(options, runtime); + return 0; + } catch (error) { + printSetupFailure(runtime, error); + return 1; + } finally { + if (runtime.ui?.close) { + await runtime.ui.close(); + } + } +} + +async function runUpdateEnvCli(argv, runtime = createRuntime()) { + let options; + try { + options = parseUpdateEnvArgs(argv); + } catch (error) { + printSetupFailure(runtime, error); + return 1; + } + + if (options.help) { + printUpdateEnvUsage(runtime); + return 0; + } + + try { + await runUpdateEnv(options, runtime); + return 0; + } catch (error) { + printSetupFailure(runtime, error); + return 1; + } finally { + if (runtime.ui?.close) { + await runtime.ui.close(); + } + } +} + +if (require.main === module) { + runSetupCli(process.argv.slice(2)).then((code) => { + process.exitCode = code; + }); +} + +module.exports = { + CONVEX_ENV_FILE, + SetupError, + convexCloudToSiteUrl, + createRuntime, + generateJwtKeyPair, + mergeEnvFileContent, + parseEnvContent, + readEnvFile, + runSetup, + runSetupCli, + runUpdateEnv, + runUpdateEnvCli, + sanitizeConvexUrl, +}; diff --git a/scripts/setup.sh b/scripts/setup.sh index 8e44465..260c9e0 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,350 +1,32 @@ #!/bin/bash -set -e +set -euo pipefail -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" -echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Opencom Self-Host Setup Script ║${NC}" -echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" - -# Parse arguments -ADMIN_EMAIL="" -ADMIN_PASSWORD="" -ADMIN_NAME="" -WORKSPACE_NAME="" -SKIP_DEV_SERVER=false -NON_INTERACTIVE=false - -while [[ $# -gt 0 ]]; do - case $1 in - --email) - ADMIN_EMAIL="$2" - shift 2 - ;; - --password) - ADMIN_PASSWORD="$2" - shift 2 - ;; - --name) - ADMIN_NAME="$2" - shift 2 - ;; - --workspace) - WORKSPACE_NAME="$2" - shift 2 - ;; - --skip-dev) - SKIP_DEV_SERVER=true - shift - ;; - --non-interactive) - NON_INTERACTIVE=true - shift - ;; - -h|--help) - echo "Usage: ./setup.sh [options]" - echo "" - echo "Options:" - echo " --email EMAIL Admin email address" - echo " --password PASSWORD Admin password" - echo " --name NAME Admin display name" - echo " --workspace NAME Workspace name (default: My Workspace)" - echo " --skip-dev Skip starting dev servers" - echo " --non-interactive Run without prompts (requires --email and --password)" - echo " -h, --help Show this help message" - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${NC}" - exit 1 - ;; - esac -done - -# Function to check command exists -check_command() { - if ! command -v "$1" &> /dev/null; then - echo -e "${RED}Error: $1 is not installed${NC}" - return 1 - fi - return 0 -} - -# Function to check version -check_version() { - local cmd=$1 - local required=$2 - local current - - if [ "$cmd" = "node" ]; then - current=$(node -v | sed 's/v//' | cut -d. -f1) - elif [ "$cmd" = "pnpm" ]; then - current=$(pnpm -v | cut -d. -f1) - fi - - if [ "$current" -lt "$required" ]; then - echo -e "${RED}Error: $cmd version $required+ required, found $current${NC}" - return 1 - fi - return 0 -} - -# Step 1: Check prerequisites -echo -e "${YELLOW}[1/8] Checking prerequisites...${NC}" - -if ! check_command "node"; then - echo -e "${RED}Please install Node.js 18+ from https://nodejs.org${NC}" +if ! command -v node >/dev/null 2>&1; then + echo "Error: node is required. Install Node.js 18+ and rerun ./scripts/setup.sh." >&2 exit 1 fi -if ! check_version "node" 18; then - echo -e "${RED}Please upgrade Node.js to version 18+${NC}" +if ! command -v pnpm >/dev/null 2>&1; then + echo "Error: pnpm is required. Install PNPM 9+ and rerun ./scripts/setup.sh." >&2 exit 1 fi -echo -e " ${GREEN}✓${NC} Node.js $(node -v)" -if ! check_command "pnpm"; then - echo -e "${RED}Please install pnpm: npm install -g pnpm${NC}" +NODE_VERSION="$(node -v)" +NODE_MAJOR="$(printf '%s\n' "$NODE_VERSION" | sed 's/^v//' | cut -d. -f1)" +if ! [[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] || [ "${NODE_MAJOR}" -lt 18 ]; then + echo "Error: Node.js 18+ is required. Found ${NODE_VERSION:-unknown}." >&2 exit 1 fi -if ! check_version "pnpm" 9; then - echo -e "${YELLOW}Warning: pnpm 9+ recommended, found $(pnpm -v)${NC}" -fi -echo -e " ${GREEN}✓${NC} pnpm $(pnpm -v)" - -# Step 2: Install dependencies -echo "" -echo -e "${YELLOW}[2/8] Installing dependencies...${NC}" -cd "$ROOT_DIR" -pnpm install - -# Step 3: Check Convex login -echo "" -echo -e "${YELLOW}[3/8] Checking Convex authentication...${NC}" -cd "$ROOT_DIR/packages/convex" - -if ! npx convex whoami &> /dev/null; then - echo -e "${YELLOW}You need to log in to Convex.${NC}" - echo -e "Running: npx convex login" - npx convex login -fi -echo -e " ${GREEN}✓${NC} Logged in to Convex" - -# Step 4: Deploy Convex -echo "" -echo -e "${YELLOW}[4/8] Setting up Convex project...${NC}" - -# Generate a unique project name -PROJECT_NAME="opencom-$(date +%s | tail -c 6)" - -# Run convex dev --once and capture output -echo -e "Creating Convex project: ${PROJECT_NAME}" -CONVEX_OUTPUT=$(npx convex dev --once --project "$PROJECT_NAME" 2>&1) || true - -# Extract the deployment URL -CONVEX_URL=$(echo "$CONVEX_OUTPUT" | grep -oE 'https://[a-zA-Z0-9-]+\.convex\.cloud' | head -1) - -if [ -z "$CONVEX_URL" ]; then - # Try to get it from .env.local if it was created - if [ -f ".env.local" ]; then - CONVEX_URL=$(grep "CONVEX_DEPLOYMENT" .env.local | cut -d'=' -f2 | sed 's/"//g') - if [ -z "$CONVEX_URL" ]; then - CONVEX_URL=$(grep "NEXT_PUBLIC_CONVEX_URL" .env.local | cut -d'=' -f2 | sed 's/"//g') - fi - fi -fi - -if [ -z "$CONVEX_URL" ]; then - echo -e "${RED}Could not determine Convex deployment URL${NC}" - echo "Please check if the deployment succeeded and try again." - echo "Output was: $CONVEX_OUTPUT" +PNPM_VERSION="$(pnpm -v)" +PNPM_MAJOR="${PNPM_VERSION%%.*}" +if ! [[ "$PNPM_MAJOR" =~ ^[0-9]+$ ]] || [ "${PNPM_MAJOR}" -lt 9 ]; then + echo "Error: PNPM 9+ is required. Found ${PNPM_VERSION:-unknown}. Install PNPM 9+ and rerun ./scripts/setup.sh." >&2 exit 1 fi -echo -e " ${GREEN}✓${NC} Convex deployed: $CONVEX_URL" - -# Step 5: Generate and set AUTH_SECRET -echo "" -echo -e "${YELLOW}[5/8] Setting up environment variables...${NC}" - -# Generate AUTH_SECRET -if command -v openssl &> /dev/null; then - AUTH_SECRET=$(openssl rand -base64 32) -else - # Fallback to Node.js - AUTH_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))") -fi - -echo -e " ${GREEN}✓${NC} Generated AUTH_SECRET" - -# Set AUTH_SECRET in Convex -echo "Setting AUTH_SECRET in Convex environment..." -npx convex env set AUTH_SECRET "$AUTH_SECRET" 2>/dev/null || echo -e "${YELLOW}Note: Could not set AUTH_SECRET automatically. Please set it manually in Convex dashboard.${NC}" - -# Set SITE_URL for Convex Auth (required for OTP email verification) -echo "Setting SITE_URL in Convex environment..." -npx convex env set SITE_URL "http://localhost:3000" 2>/dev/null || echo -e "${YELLOW}Note: Could not set SITE_URL automatically. Please set it manually in Convex dashboard.${NC}" -echo -e "${YELLOW}Note: Update SITE_URL in Convex dashboard when deploying to production.${NC}" - -# Step 6: Get admin credentials -echo "" -echo -e "${YELLOW}[6/8] Creating admin account...${NC}" - -if [ -z "$ADMIN_EMAIL" ]; then - if [ "$NON_INTERACTIVE" = true ]; then - echo -e "${RED}Error: --email required in non-interactive mode${NC}" - exit 1 - fi - read -p "Enter admin email: " ADMIN_EMAIL -fi - -if [ -z "$ADMIN_PASSWORD" ]; then - if [ "$NON_INTERACTIVE" = true ]; then - echo -e "${RED}Error: --password required in non-interactive mode${NC}" - exit 1 - fi - read -s -p "Enter admin password: " ADMIN_PASSWORD - echo "" -fi - -if [ -z "$ADMIN_NAME" ]; then - ADMIN_NAME="${ADMIN_EMAIL%%@*}" -fi - -if [ -z "$WORKSPACE_NAME" ]; then - WORKSPACE_NAME="My Workspace" -fi - -# Step 7: Create admin account via auth:signup -echo "" -echo -e "${YELLOW}[7/8] Creating workspace and admin user...${NC}" - -# Check if setup has already been done -EXISTING_SETUP=$(npx convex run setup:checkExistingSetup '{}' 2>&1) || true - -if echo "$EXISTING_SETUP" | grep -q '"hasUsers":true'; then - echo -e "${YELLOW}Note: Users already exist in this Convex deployment.${NC}" - echo -e "${YELLOW}Attempting to create new admin account anyway...${NC}" -fi - -# Use the existing auth:signup mutation (same code path as normal signup) -SIGNUP_ARGS="{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\",\"name\":\"$ADMIN_NAME\",\"workspaceName\":\"$WORKSPACE_NAME\"}" - -SIGNUP_RESULT=$(npx convex run auth:signup "$SIGNUP_ARGS" 2>&1) || true - -# Parse the workspace ID from the user object in the result -WORKSPACE_ID=$(echo "$SIGNUP_RESULT" | grep -oE '"workspaceId":\s*"[^"]+"' | sed 's/"workspaceId":\s*"//' | sed 's/"$//' | head -1) - -if [ -z "$WORKSPACE_ID" ]; then - # Try alternate parsing for Convex IDs - WORKSPACE_ID=$(echo "$SIGNUP_RESULT" | grep -oE '[a-z0-9]{32}' | head -1) -fi - -if echo "$SIGNUP_RESULT" | grep -q 'already exists'; then - echo -e "${RED}Error: User with email $ADMIN_EMAIL already exists${NC}" - echo -e "${YELLOW}You can log in with this email on the web dashboard.${NC}" - echo -e "${YELLOW}To get the workspace ID, check the Convex dashboard or web app settings.${NC}" - WORKSPACE_ID="YOUR_WORKSPACE_ID" -fi - -if [ -z "$WORKSPACE_ID" ] || [ "$WORKSPACE_ID" = "YOUR_WORKSPACE_ID" ]; then - echo -e "${YELLOW}Warning: Could not parse workspace ID from signup result.${NC}" - echo -e "${YELLOW}You may need to get it from Convex dashboard or web app settings.${NC}" - WORKSPACE_ID="YOUR_WORKSPACE_ID" -else - echo -e " ${GREEN}✓${NC} Workspace created: $WORKSPACE_ID" - echo -e " ${GREEN}✓${NC} Admin user created: $ADMIN_EMAIL" -fi - -# Step 8: Generate .env.local files -echo "" -echo -e "${YELLOW}[8/8] Generating environment files...${NC}" - -cd "$ROOT_DIR" - -# apps/web/.env.local -cat > apps/web/.env.local << EOF -NEXT_PUBLIC_CONVEX_URL=$CONVEX_URL -NEXT_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=$CONVEX_URL -EOF -echo -e " ${GREEN}✓${NC} Created apps/web/.env.local" - -# apps/widget/.env.local -cat > apps/widget/.env.local << EOF -VITE_CONVEX_URL=$CONVEX_URL -VITE_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Created apps/widget/.env.local" - -# apps/mobile/.env.local -cat > apps/mobile/.env.local << EOF -EXPO_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=$CONVEX_URL -EXPO_PUBLIC_CONVEX_URL=$CONVEX_URL -EXPO_PUBLIC_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Created apps/mobile/.env.local" - -# packages/react-native-sdk/example/.env.local -cat > packages/react-native-sdk/example/.env.local << EOF -EXPO_PUBLIC_CONVEX_URL=$CONVEX_URL -EXPO_PUBLIC_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Created packages/react-native-sdk/example/.env.local" - -# packages/convex/.env.local (for Convex URL reference) -cat > packages/convex/.env.local << EOF -CONVEX_URL=$CONVEX_URL -EOF -echo -e " ${GREEN}✓${NC} Created packages/convex/.env.local" - -# Success message -echo "" -echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" -echo -e "${GREEN}║ Setup Complete! ║${NC}" -echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" -echo "" -echo -e "${BLUE}Configuration:${NC}" -echo -e " Convex URL: $CONVEX_URL" -echo -e " Workspace ID: $WORKSPACE_ID" -echo -e " Admin Email: $ADMIN_EMAIL" -echo "" - -if [ "$SKIP_DEV_SERVER" = true ]; then - echo -e "${BLUE}To start development servers:${NC}" - echo -e " pnpm dev:web # Web dashboard at http://localhost:3000" - echo -e " pnpm dev:widget # Widget at http://localhost:5173" - echo "" -else - echo -e "${BLUE}Starting development servers...${NC}" - echo "" - echo -e " Web dashboard: ${GREEN}http://localhost:3000${NC}" - echo -e " Widget: ${GREEN}http://localhost:5173${NC}" - echo "" - echo -e "Press Ctrl+C to stop the servers." - echo "" - - # Start dev servers - pnpm dev:web & pnpm dev:widget & - wait -fi - -echo -e "${BLUE}To run the React Native SDK example:${NC}" -echo -e " cd packages/react-native-sdk/example" -echo -e " pnpm start" -echo "" -echo -e "${BLUE}Login credentials:${NC}" -echo -e " Email: $ADMIN_EMAIL" -echo -e " Password: (the one you provided)" -echo "" +exec node "$ROOT_DIR/scripts/local-convex-setup.js" "$@" diff --git a/scripts/tests/local-convex-setup.test.js b/scripts/tests/local-convex-setup.test.js new file mode 100644 index 0000000..df64c45 --- /dev/null +++ b/scripts/tests/local-convex-setup.test.js @@ -0,0 +1,630 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + SetupError, + generateJwtKeyPair, + mergeEnvFileContent, + parseEnvContent, + readEnvFile, + runSetup, + runUpdateEnv, +} = require("../local-convex-setup"); + +const GENERATED_JWT_PRIVATE_KEY = "generated-jwt-private-key"; +const GENERATED_JWKS = JSON.stringify({ + keys: [{ use: "sig", kty: "RSA", n: "generated-modulus", e: "AQAB" }], +}); + +async function createTempRepo() { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencom-local-setup-")); + await fs.mkdir(path.join(rootDir, "packages/convex"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "packages/react-native-sdk/example"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "apps/web"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "apps/widget"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "apps/mobile"), { recursive: true }); + await fs.mkdir(path.join(rootDir, "apps/landing"), { recursive: true }); + return rootDir; +} + +async function writeExecutable(filePath, contents) { + await fs.writeFile(filePath, contents, "utf8"); + await fs.chmod(filePath, 0o755); +} + +async function createFakeBin(tools) { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencom-local-setup-bin-")); + for (const [name, contents] of Object.entries(tools)) { + await writeExecutable(path.join(binDir, name), contents); + } + return binDir; +} + +function runWrapperWithFakePath(scriptName, fakeBinDir) { + const repoRoot = path.resolve(__dirname, "../.."); + return spawnSync("bash", [path.join(repoRoot, "scripts", scriptName), "--help"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH || ""}`, + }, + encoding: "utf8", + }); +} + +function createHarness({ + rootDir, + initialConvexEnv = "", + backendEnv = {}, + setupState = { hasUsers: false, hasWorkspaces: false }, + workspaces = [], + authError = "", + requireWorkspaceCreation = false, +}) { + const commands = []; + const envStore = { ...backendEnv }; + const tokenValue = "token-local-bootstrap"; + let activeWorkspaceId = workspaces[0]?._id || null; + let authAttempts = 0; + + async function ensureConvexEnvFile(contents) { + await fs.writeFile(path.join(rootDir, "packages/convex/.env.local"), contents, "utf8"); + } + + async function getFileContents(relativePath) { + return fs.readFile(path.join(rootDir, relativePath), "utf8"); + } + + const runtime = { + rootDir, + output: { + isTTY: false, + write() {}, + }, + ui: { + async ask() { + return ""; + }, + async askSecret() { + return ""; + }, + async confirm() { + return true; + }, + async select(_question, options, defaultIndex) { + return options[defaultIndex].value; + }, + async close() {}, + }, + log() {}, + warn() {}, + error() {}, + generateJwtKeyPair() { + return { + JWT_PRIVATE_KEY: GENERATED_JWT_PRIVATE_KEY, + JWKS: GENERATED_JWKS, + }; + }, + async exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + }, + async readFile(filePath, encoding = "utf8") { + return fs.readFile(filePath, encoding); + }, + async writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf8"); + }, + async runCommand(command, args) { + commands.push([command, ...args]); + const joined = `${command} ${args.join(" ")}`; + + if (joined === "pnpm install") { + return { stdout: "", stderr: "", code: 0 }; + } + + if ( + joined === "pnpm --filter @opencom/convex exec convex dev --once" || + joined === "pnpm --filter @opencom/convex exec convex dev --once --configure" + ) { + await ensureConvexEnvFile( + [ + initialConvexEnv, + 'CONVEX_DEPLOYMENT="dev:opencom-test"', + 'CONVEX_URL="https://opencom-test.convex.cloud"', + ] + .filter(Boolean) + .join("\n") + "\n" + ); + return { stdout: "", stderr: "", code: 0 }; + } + + if ( + command === "pnpm" && + args[0] === "--filter" && + args[1] === "@opencom/convex" && + args[2] === "exec" && + args[3] === "convex" && + args[4] === "env" && + args[5] === "get" + ) { + const key = args[6]; + if (envStore[key]) { + return { stdout: `${envStore[key]}\n`, stderr: "", code: 0 }; + } + const error = new Error(`missing env ${key}`); + error.stdout = ""; + error.stderr = `Environment variable ${key} is not set.`; + throw error; + } + + if ( + command === "pnpm" && + args[0] === "--filter" && + args[1] === "@opencom/convex" && + args[2] === "exec" && + args[3] === "convex" && + args[4] === "env" && + args[5] === "set" + ) { + if (args[6] === "--from-file") { + const parsedValues = parseEnvContent(await fs.readFile(args[7], "utf8")); + Object.assign(envStore, parsedValues); + return { stdout: "", stderr: "", code: 0 }; + } + envStore[args[6]] = args[7]; + return { stdout: "", stderr: "", code: 0 }; + } + + throw new Error(`Unexpected command: ${joined}`); + }, + async fetchImpl(_url, init) { + const request = JSON.parse(init.body); + + if (request.path === "setup:checkExistingSetup") { + return new Response(JSON.stringify(setupState), { status: 200 }); + } + + if (request.path === "auth:signIn") { + authAttempts += 1; + if (authError) { + return new Response(JSON.stringify({ status: "error", errorMessage: authError }), { + status: 200, + }); + } + + if (request.args.params.flow === "signUp" && workspaces.length === 0) { + workspaces.push({ + _id: "workspace_bootstrap", + name: request.args.params.workspaceName || "Bootstrap Workspace", + role: "admin", + }); + activeWorkspaceId = "workspace_bootstrap"; + } + + return new Response( + JSON.stringify({ + status: "success", + value: { + tokens: { + token: tokenValue, + refreshToken: "refresh-token", + }, + }, + }), + { status: 200 } + ); + } + + if (request.path === "auth:currentUser") { + if (requireWorkspaceCreation && !workspaces.length) { + return new Response( + JSON.stringify({ + user: { + _id: "user_1", + workspaceId: null, + }, + workspaces: [], + }), + { status: 200 } + ); + } + + return new Response( + JSON.stringify({ + user: { + _id: "user_1", + email: authAttempts > 0 ? "admin@example.com" : "unknown@example.com", + workspaceId: activeWorkspaceId, + }, + workspaces, + }), + { status: 200 } + ); + } + + if (request.path === "workspaces:create") { + const created = { + _id: "workspace_created", + name: request.args.name, + role: "owner", + }; + workspaces.push(created); + activeWorkspaceId = created._id; + return new Response(JSON.stringify(created._id), { status: 200 }); + } + + if (request.path === "auth:switchWorkspace") { + activeWorkspaceId = request.args.workspaceId; + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } + + throw new Error(`Unexpected fetch path: ${request.path}`); + }, + }; + + return { + commands, + envStore, + getFileContents, + runtime, + }; +} + +test("setup.sh rejects pnpm older than the pinned major before running setup", async () => { + const fakeBin = await createFakeBin({ + node: "#!/bin/sh\nif [ \"$1\" = \"-v\" ]; then echo \"v18.20.0\"; exit 0; fi\necho \"unexpected node execution\" >&2\nexit 42\n", + pnpm: "#!/bin/sh\necho \"8.15.9\"\n", + }); + + const result = runWrapperWithFakePath("setup.sh", fakeBin); + + assert.equal(result.status, 1); + assert.match(result.stderr, /PNPM 9\+ is required/); + assert.match(result.stderr, /8\.15\.9/); +}); + +test("update-env.sh rejects Node older than the runtime contract", async () => { + const fakeBin = await createFakeBin({ + node: "#!/bin/sh\nif [ \"$1\" = \"-v\" ]; then echo \"v16.20.2\"; exit 0; fi\necho \"unexpected node execution\" >&2\nexit 42\n", + }); + + const result = runWrapperWithFakePath("update-env.sh", fakeBin); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Node\.js 18\+ is required/); + assert.match(result.stderr, /v16\.20\.2/); +}); + +test("clean-environment setup configures deployment, auth env, and local files", async () => { + const rootDir = await createTempRepo(); + const harness = createHarness({ + rootDir, + backendEnv: {}, + setupState: { hasUsers: false, hasWorkspaces: false }, + workspaces: [], + }); + + await runSetup( + { + adminEmail: "admin@example.com", + adminPassword: "Opencom!123", + adminName: "Admin User", + workspaceName: "Fresh Workspace", + nonInteractive: true, + skipDev: true, + }, + harness.runtime + ); + + assert.equal(harness.envStore.JWT_PRIVATE_KEY, GENERATED_JWT_PRIVATE_KEY); + assert.equal(harness.envStore.JWKS, GENERATED_JWKS); + assert.equal(harness.envStore.SITE_URL, "http://localhost:3000"); + assert.ok( + harness.commands.some((command) => command.join(" ") === "pnpm install"), + "expected pnpm install to run" + ); + assert.ok( + harness.commands.some( + (command) => command.join(" ") === "pnpm --filter @opencom/convex exec convex dev --once" + ), + "expected convex dev --once to run" + ); + + const webEnv = await readEnvFile(path.join(rootDir, "apps/web/.env.local")); + const convexEnv = await readEnvFile(path.join(rootDir, "packages/convex/.env.local")); + + assert.equal(webEnv.NEXT_PUBLIC_CONVEX_URL, "https://opencom-test.convex.cloud"); + assert.equal(webEnv.NEXT_PUBLIC_TEST_WORKSPACE_ID, "workspace_bootstrap"); + assert.equal(convexEnv.CONVEX_DEPLOYMENT, "dev:opencom-test"); + assert.equal(convexEnv.CONVEX_URL, "https://opencom-test.convex.cloud"); + assert.equal(convexEnv.WORKSPACE_ID, "workspace_bootstrap"); + assert.equal(convexEnv.E2E_BACKEND_URL, "https://opencom-test.convex.cloud"); +}); + +test("rerun setup reuses existing deployment and preserves unrelated env entries", async () => { + const rootDir = await createTempRepo(); + await fs.writeFile( + path.join(rootDir, "packages/convex/.env.local"), + [ + "# Keep this comment", + 'CUSTOM_KEEP="yes"', + 'CONVEX_DEPLOYMENT="dev:existing"', + 'CONVEX_URL="https://existing.convex.cloud"', + ].join("\n") + "\n", + "utf8" + ); + await fs.writeFile( + path.join(rootDir, "apps/web/.env.local"), + ["# Existing web comment", 'MANUAL_FLAG="keep-me"', 'NEXT_PUBLIC_CONVEX_URL="stale"'].join( + "\n" + ) + "\n", + "utf8" + ); + + const harness = createHarness({ + rootDir, + backendEnv: { + JWT_PRIVATE_KEY: "already-configured-private-key", + JWKS: JSON.stringify({ keys: [{ use: "sig", kty: "RSA", n: "existing", e: "AQAB" }] }), + SITE_URL: "http://localhost:3000", + }, + setupState: { hasUsers: true, hasWorkspaces: true }, + workspaces: [ + { _id: "workspace_active", name: "Active Workspace", role: "admin" }, + { _id: "workspace_other", name: "Other Workspace", role: "agent" }, + ], + }); + + await runSetup( + { + adminEmail: "admin@example.com", + adminPassword: "Opencom!123", + nonInteractive: true, + skipDev: true, + }, + harness.runtime + ); + + assert.ok( + !harness.commands.some( + (command) => command.join(" ") === "pnpm --filter @opencom/convex exec convex dev --once" + ), + "expected existing deployment to be reused without reconfiguration" + ); + + const convexContent = await harness.getFileContents("packages/convex/.env.local"); + const webContent = await harness.getFileContents("apps/web/.env.local"); + const webEnv = parseEnvContent(webContent); + + assert.match(convexContent, /# Keep this comment/); + assert.match(convexContent, /CUSTOM_KEEP="yes"/); + assert.match(webContent, /# Existing web comment/); + assert.match(webContent, /MANUAL_FLAG="keep-me"/); + assert.equal(webEnv.NEXT_PUBLIC_TEST_WORKSPACE_ID, "workspace_active"); + assert.equal(webEnv.NEXT_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL, "https://existing.convex.cloud"); +}); + +test("partial Convex Auth JWT env regenerates both paired values", async () => { + const rootDir = await createTempRepo(); + await fs.writeFile( + path.join(rootDir, "packages/convex/.env.local"), + [ + 'CONVEX_DEPLOYMENT="dev:existing"', + 'CONVEX_URL="https://existing.convex.cloud"', + ].join("\n") + "\n", + "utf8" + ); + + const harness = createHarness({ + rootDir, + backendEnv: { + JWT_PRIVATE_KEY: "stale-unpaired-private-key", + SITE_URL: "http://localhost:3000", + }, + setupState: { hasUsers: true, hasWorkspaces: true }, + workspaces: [{ _id: "workspace_active", name: "Active Workspace", role: "admin" }], + }); + + await runSetup( + { + adminEmail: "admin@example.com", + adminPassword: "Opencom!123", + nonInteractive: true, + skipDev: true, + }, + harness.runtime + ); + + assert.equal(harness.envStore.JWT_PRIVATE_KEY, GENERATED_JWT_PRIVATE_KEY); + assert.equal(harness.envStore.JWKS, GENERATED_JWKS); + assert.ok( + harness.commands.some( + (command) => + command[0] === "pnpm" && + command.slice(1, 8).join(" ") === + "--filter @opencom/convex exec convex env set --from-file" && + command.includes("--force") + ), + "expected JWT_PRIVATE_KEY and JWKS to be reset together from a file" + ); +}); + +test("malformed Convex Auth JWKS regenerates both paired values", async () => { + const rootDir = await createTempRepo(); + await fs.writeFile( + path.join(rootDir, "packages/convex/.env.local"), + [ + 'CONVEX_DEPLOYMENT="dev:existing"', + 'CONVEX_URL="https://existing.convex.cloud"', + ].join("\n") + "\n", + "utf8" + ); + + const harness = createHarness({ + rootDir, + backendEnv: { + JWT_PRIVATE_KEY: "-----BEGIN PRIVATE KEY----- stale -----END PRIVATE KEY-----", + JWKS: "{\\\"keys\\\":[{\\\"use\\\":\\\"sig\\\"}]}", + SITE_URL: "http://localhost:3000", + }, + setupState: { hasUsers: true, hasWorkspaces: true }, + workspaces: [{ _id: "workspace_active", name: "Active Workspace", role: "admin" }], + }); + + await runSetup( + { + adminEmail: "admin@example.com", + adminPassword: "Opencom!123", + nonInteractive: true, + skipDev: true, + }, + harness.runtime + ); + + assert.equal(harness.envStore.JWT_PRIVATE_KEY, GENERATED_JWT_PRIVATE_KEY); + assert.equal(harness.envStore.JWKS, GENERATED_JWKS); +}); + +test("setup surfaces actionable errors when auth sign-in fails", async () => { + const rootDir = await createTempRepo(); + const harness = createHarness({ + rootDir, + initialConvexEnv: [ + 'CONVEX_DEPLOYMENT="dev:existing"', + 'CONVEX_URL="https://existing.convex.cloud"', + ].join("\n"), + backendEnv: { + JWT_PRIVATE_KEY: "already-configured-private-key", + JWKS: JSON.stringify({ keys: [{ use: "sig", kty: "RSA", n: "existing", e: "AQAB" }] }), + SITE_URL: "http://localhost:3000", + }, + setupState: { hasUsers: true, hasWorkspaces: true }, + workspaces: [{ _id: "workspace_active", name: "Active Workspace", role: "admin" }], + authError: "Invalid credentials", + }); + + await assert.rejects( + () => + runSetup( + { + adminEmail: "admin@example.com", + adminPassword: "wrong-password", + nonInteractive: true, + skipDev: true, + }, + harness.runtime + ), + (error) => { + assert.ok(error instanceof SetupError); + assert.match(error.summary, /Invalid credentials/i); + assert.match(error.why, /Convex Auth password sign-in\/sign-up path/i); + assert.ok(error.fix.some((step) => /existing admin account/i.test(step))); + return true; + } + ); +}); + +test("generateJwtKeyPair returns a Convex Auth compatible key pair shape", () => { + const pair = generateJwtKeyPair(); + assert.match(pair.JWT_PRIVATE_KEY, /^-----BEGIN PRIVATE KEY-----/); + assert.match(pair.JWT_PRIVATE_KEY, /-----END PRIVATE KEY-----$/); + + const jwks = JSON.parse(pair.JWKS); + assert.equal(Array.isArray(jwks.keys), true); + assert.equal(jwks.keys.length, 1); + assert.equal(jwks.keys[0].use, "sig"); + assert.equal(jwks.keys[0].kty, "RSA"); + assert.equal(jwks.keys[0].e, "AQAB"); + assert.equal(typeof jwks.keys[0].n, "string"); +}); + +test("update-env writes all local targets without deleting unrelated keys or comments", async () => { + const rootDir = await createTempRepo(); + await fs.writeFile( + path.join(rootDir, "apps/landing/.env.local"), + ["# Manual landing note", 'NEXT_PUBLIC_WIDGET_URL="https://cdn.example/widget.js"'].join("\n") + + "\n", + "utf8" + ); + + const runtime = { + rootDir, + output: { + isTTY: false, + write() {}, + }, + ui: { + async close() {}, + }, + log() {}, + warn() {}, + error() {}, + async exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + }, + async readFile(filePath, encoding = "utf8") { + return fs.readFile(filePath, encoding); + }, + async writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf8"); + }, + }; + + await runUpdateEnv( + { + convexUrl: "https://manual.convex.cloud", + workspaceId: "workspace_sync", + nonInteractive: true, + }, + runtime + ); + + const landingContent = await fs.readFile(path.join(rootDir, "apps/landing/.env.local"), "utf8"); + const landingEnv = parseEnvContent(landingContent); + const widgetEnv = await readEnvFile(path.join(rootDir, "apps/widget/.env.local")); + const convexEnv = await readEnvFile(path.join(rootDir, "packages/convex/.env.local")); + + assert.match(landingContent, /# Manual landing note/); + assert.match(landingContent, /NEXT_PUBLIC_WIDGET_URL="https:\/\/cdn.example\/widget.js"/); + assert.equal(landingEnv.NEXT_PUBLIC_CONVEX_URL, "https://manual.convex.cloud"); + assert.equal(landingEnv.NEXT_PUBLIC_WORKSPACE_ID, "workspace_sync"); + assert.equal(widgetEnv.VITE_WORKSPACE_ID, "workspace_sync"); + assert.equal(convexEnv.E2E_BACKEND_URL, "https://manual.convex.cloud"); + assert.equal(convexEnv.WORKSPACE_ID, "workspace_sync"); +}); + +test("mergeEnvFileContent updates managed keys in place and appends missing ones once", () => { + const merged = mergeEnvFileContent( + ["# Existing", 'KEEP_ME="1"', 'NEXT_PUBLIC_CONVEX_URL="old"'].join("\n") + "\n", + { + NEXT_PUBLIC_CONVEX_URL: "https://fresh.convex.cloud", + NEXT_PUBLIC_TEST_WORKSPACE_ID: "workspace_123", + }, + "Managed block" + ); + + const env = parseEnvContent(merged); + assert.match(merged, /# Existing/); + assert.match(merged, /KEEP_ME="1"/); + assert.equal(env.NEXT_PUBLIC_CONVEX_URL, "https://fresh.convex.cloud"); + assert.equal(env.NEXT_PUBLIC_TEST_WORKSPACE_ID, "workspace_123"); + assert.equal((merged.match(/Managed block/g) || []).length, 1); +}); diff --git a/scripts/update-env.sh b/scripts/update-env.sh index 35f859f..1943a8b 100755 --- a/scripts/update-env.sh +++ b/scripts/update-env.sh @@ -1,133 +1,20 @@ #!/bin/bash -set -e +set -euo pipefail -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" -echo -e "${BLUE}Opencom Environment Update Script${NC}" -echo "" - -# Parse arguments -CONVEX_URL="" -WORKSPACE_ID="" - -while [[ $# -gt 0 ]]; do - case $1 in - --url) - CONVEX_URL="$2" - shift 2 - ;; - --workspace) - WORKSPACE_ID="$2" - shift 2 - ;; - -h|--help) - echo "Usage: ./update-env.sh [options]" - echo "" - echo "Options:" - echo " --url URL Convex deployment URL" - echo " --workspace ID Workspace ID" - echo " -h, --help Show this help message" - echo "" - echo "If options are not provided, you will be prompted for values." - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${NC}" - exit 1 - ;; - esac -done - -# Prompt for values if not provided -if [ -z "$CONVEX_URL" ]; then - # Try to get current value - CURRENT_URL="" - if [ -f "$ROOT_DIR/apps/web/.env.local" ]; then - CURRENT_URL=$(grep "CONVEX_URL" "$ROOT_DIR/apps/web/.env.local" | cut -d'=' -f2 | head -1) - fi - - if [ -n "$CURRENT_URL" ]; then - read -p "Enter Convex URL [$CURRENT_URL]: " CONVEX_URL - CONVEX_URL=${CONVEX_URL:-$CURRENT_URL} - else - read -p "Enter Convex URL: " CONVEX_URL - fi -fi - -if [ -z "$WORKSPACE_ID" ]; then - # Try to get current value - CURRENT_WORKSPACE="" - if [ -f "$ROOT_DIR/apps/widget/.env.local" ]; then - CURRENT_WORKSPACE=$(grep "WORKSPACE_ID" "$ROOT_DIR/apps/widget/.env.local" | cut -d'=' -f2 | head -1) - fi - - if [ -n "$CURRENT_WORKSPACE" ]; then - read -p "Enter Workspace ID [$CURRENT_WORKSPACE]: " WORKSPACE_ID - WORKSPACE_ID=${WORKSPACE_ID:-$CURRENT_WORKSPACE} - else - read -p "Enter Workspace ID: " WORKSPACE_ID - fi +if ! command -v node >/dev/null 2>&1; then + echo "Error: node is required. Install Node.js 18+ and rerun ./scripts/update-env.sh." >&2 + exit 1 fi -if [ -z "$CONVEX_URL" ] || [ -z "$WORKSPACE_ID" ]; then - echo -e "${RED}Error: Both Convex URL and Workspace ID are required${NC}" +NODE_VERSION="$(node -v)" +NODE_MAJOR="$(printf '%s\n' "$NODE_VERSION" | sed 's/^v//' | cut -d. -f1)" +if ! [[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] || [ "${NODE_MAJOR}" -lt 18 ]; then + echo "Error: Node.js 18+ is required. Found ${NODE_VERSION:-unknown}. Install Node.js 18+ and rerun ./scripts/update-env.sh." >&2 exit 1 fi -echo "" -echo -e "${YELLOW}Updating environment files...${NC}" - -cd "$ROOT_DIR" - -# Update or create apps/web/.env.local -cat > apps/web/.env.local << EOF -NEXT_PUBLIC_CONVEX_URL=$CONVEX_URL -NEXT_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=$CONVEX_URL -EOF -echo -e " ${GREEN}✓${NC} Updated apps/web/.env.local" - -# Update or create apps/widget/.env.local -cat > apps/widget/.env.local << EOF -VITE_CONVEX_URL=$CONVEX_URL -VITE_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Updated apps/widget/.env.local" - -# Update or create apps/mobile/.env.local -cat > apps/mobile/.env.local << EOF -EXPO_PUBLIC_OPENCOM_DEFAULT_BACKEND_URL=$CONVEX_URL -EXPO_PUBLIC_CONVEX_URL=$CONVEX_URL -EXPO_PUBLIC_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Updated apps/mobile/.env.local" - -# Update or create packages/react-native-sdk/example/.env.local -cat > packages/react-native-sdk/example/.env.local << EOF -EXPO_PUBLIC_CONVEX_URL=$CONVEX_URL -EXPO_PUBLIC_WORKSPACE_ID=$WORKSPACE_ID -EOF -echo -e " ${GREEN}✓${NC} Updated packages/react-native-sdk/example/.env.local" - -# Update or create packages/convex/.env.local -cat > packages/convex/.env.local << EOF -CONVEX_URL=$CONVEX_URL -EOF -echo -e " ${GREEN}✓${NC} Updated packages/convex/.env.local" - -echo "" -echo -e "${GREEN}Environment files updated successfully!${NC}" -echo "" -echo -e "${BLUE}Configuration:${NC}" -echo -e " Convex URL: $CONVEX_URL" -echo -e " Workspace ID: $WORKSPACE_ID" -echo "" +exec node "$ROOT_DIR/scripts/update-local-env.js" "$@" diff --git a/scripts/update-local-env.js b/scripts/update-local-env.js new file mode 100644 index 0000000..8945553 --- /dev/null +++ b/scripts/update-local-env.js @@ -0,0 +1,7 @@ +"use strict"; + +const { runUpdateEnvCli } = require("./local-convex-setup"); + +runUpdateEnvCli(process.argv.slice(2)).then((code) => { + process.exitCode = code; +});