From c0c70fb9603e0ca16ce1f54fd7ac7277e6d12f51 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 23 Oct 2026 16:12:14 +0800 Subject: [PATCH 1/4] Open the local workspace without accounts and drop Docker deployment Local mode creates or reuses the single local owner and opens the dashboard directly; account pages and endpoints are disabled, and every request must come from this computer's own address and origin. The CLI now wraps the bundled local setup (doctor, init, start) instead of Docker Compose. --- .changeset/local-bundled-storage.md | 5 + .changeset/local-workspace-no-login.md | 7 + .dockerignore | 96 ---- AGENTS.md | 4 +- README.de.md | 21 +- README.ja.md | 21 +- README.ko.md | 21 +- README.md | 23 +- README.zh-CN.md | 23 +- apps/cli/README.md | 14 +- apps/cli/src/commands/compose.ts | 8 - apps/cli/src/commands/edit.ts | 37 -- apps/cli/src/commands/init.ts | 459 ------------------ apps/cli/src/commands/upgrade.ts | 221 --------- apps/cli/src/compose-pin.test.ts | 82 ---- apps/cli/src/compose-pin.ts | 38 -- apps/cli/src/compose.ts | 219 --------- apps/cli/src/config.ts | 91 ---- apps/cli/src/docker.ts | 137 ------ apps/cli/src/index.ts | 103 ++-- apps/cli/src/local.test.ts | 62 +++ apps/cli/src/migrations/index.ts | 2 +- apps/web/package.json | 2 +- apps/web/scripts/start.mjs | 7 + apps/web/src/components/app-sidebar.tsx | 23 +- .../web/src/lib/__tests__/entry-route.test.ts | 6 +- .../lib/__tests__/security-headers.test.ts | 17 + .../lib/auth/__tests__/local-request.test.ts | 120 +++++ apps/web/src/lib/auth/local-request.ts | 113 +++++ apps/web/src/lib/auth/server.ts | 30 +- apps/web/src/lib/breadcrumbs.ts | 3 + apps/web/src/lib/entry-route.ts | 3 +- apps/web/src/lib/i18n/zh.json | 7 +- apps/web/src/lib/security-headers.ts | 42 ++ apps/web/src/routes/_authed.tsx | 2 +- .../accept-invitation/$invitationId.tsx | 5 +- apps/web/src/routes/_authed/admin.tsx | 2 +- apps/web/src/routes/_authed/app/index.tsx | 54 ++- .../src/routes/_authed/app/org/$org/new.tsx | 6 +- .../_authed/app/org/$org/settings/index.tsx | 6 +- apps/web/src/routes/auth/forgot-password.tsx | 5 +- apps/web/src/routes/auth/login.tsx | 6 +- apps/web/src/routes/auth/register.tsx | 1 + apps/web/src/routes/auth/reset-password.tsx | 5 +- apps/web/src/server.ts | 72 ++- apps/web/src/server/config.ts | 7 +- apps/web/src/stories/app-sidebar.stories.tsx | 14 +- apps/web/vite.config.ts | 1 + apps/www/src/components/home/self-host.tsx | 2 +- apps/www/src/lib/faqs.ts | 2 +- assets/readme/workflow.de.svg | 75 +++ assets/readme/workflow.en.svg | 72 +++ assets/readme/workflow.ja.svg | 68 +++ assets/readme/workflow.ko.svg | 70 +++ assets/readme/workflow.zh-CN.svg | 68 +++ docker/Dockerfile | 113 ----- docker/entrypoint.sh | 5 - docs/beat-geo/getting-started.md | 10 +- docs/self-hosting.md | 10 +- packages/config/src/types.ts | 4 +- packages/deployment/src/deployment.ts | 2 +- .../docs/developer-guide/architecture.mdx | 5 +- .../content/docs/developer-guide/commands.mdx | 28 +- .../docs/developer-guide/configuration.mdx | 74 +-- .../developer-guide/development-setup.mdx | 87 +--- .../content/docs/developer-guide/releases.mdx | 63 +-- .../docs/developer-guide/telemetry.mdx | 86 +--- .../docs/content/docs/getting-started.mdx | 86 +--- packages/docs/content/docs/index.mdx | 4 +- .../docs/content/docs/user-guide/settings.mdx | 4 +- packages/lib/package.json | 4 +- packages/lib/src/auth/local-workspace.test.ts | 68 +++ packages/lib/src/auth/local-workspace.ts | 31 ++ packages/lib/src/auth/server.ts | 8 +- packages/lib/src/db/local-workspace.test.ts | 95 ++++ packages/lib/src/db/local-workspace.ts | 53 ++ scripts/local-doctor.mjs | 19 +- scripts/local-runtime.mjs | 10 +- scripts/local-runtime.test.mjs | 54 ++- scripts/setup-local.mjs | 20 +- scripts/start-local.mjs | 21 +- 81 files changed, 1372 insertions(+), 2202 deletions(-) create mode 100644 .changeset/local-bundled-storage.md create mode 100644 .changeset/local-workspace-no-login.md delete mode 100644 .dockerignore delete mode 100644 apps/cli/src/commands/compose.ts delete mode 100644 apps/cli/src/commands/edit.ts delete mode 100644 apps/cli/src/commands/init.ts delete mode 100644 apps/cli/src/commands/upgrade.ts delete mode 100644 apps/cli/src/compose-pin.test.ts delete mode 100644 apps/cli/src/compose-pin.ts delete mode 100644 apps/cli/src/compose.ts delete mode 100644 apps/cli/src/docker.ts create mode 100644 apps/cli/src/local.test.ts create mode 100644 apps/web/scripts/start.mjs create mode 100644 apps/web/src/lib/__tests__/security-headers.test.ts create mode 100644 apps/web/src/lib/auth/__tests__/local-request.test.ts create mode 100644 apps/web/src/lib/auth/local-request.ts create mode 100644 apps/web/src/lib/security-headers.ts create mode 100644 assets/readme/workflow.de.svg create mode 100644 assets/readme/workflow.en.svg create mode 100644 assets/readme/workflow.ja.svg create mode 100644 assets/readme/workflow.ko.svg create mode 100644 assets/readme/workflow.zh-CN.svg delete mode 100644 docker/Dockerfile delete mode 100644 docker/entrypoint.sh create mode 100644 packages/lib/src/auth/local-workspace.test.ts create mode 100644 packages/lib/src/auth/local-workspace.ts create mode 100644 packages/lib/src/db/local-workspace.test.ts create mode 100644 packages/lib/src/db/local-workspace.ts diff --git a/.changeset/local-bundled-storage.md b/.changeset/local-bundled-storage.md new file mode 100644 index 00000000..386f70b6 --- /dev/null +++ b/.changeset/local-bundled-storage.md @@ -0,0 +1,5 @@ +--- +"@beatapi/geo-cli": patch +--- + +Use the bundled local setup, startup and environment check instead of the container deployment wizard. diff --git a/.changeset/local-workspace-no-login.md b/.changeset/local-workspace-no-login.md new file mode 100644 index 00000000..e1d80868 --- /dev/null +++ b/.changeset/local-workspace-no-login.md @@ -0,0 +1,7 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +"@workspace/deployment": patch +--- + +Open the local workspace without registration or passwords, protect local browser requests, and remove account and organization controls from the local interface. diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 47e67c1c..00000000 --- a/.dockerignore +++ /dev/null @@ -1,96 +0,0 @@ -# Dependencies -node_modules -**/node_modules -.pnpm-store - -# Build outputs -.next -**/.next -.output -**/.output -out -**/out -dist -**/dist -build -**/build - -# Turborepo cache -.turbo -**/.turbo - -# Git -.git -**/.git -.gitignore -**/.gitignore - -# IDE -.idea -**/.idea -.vscode -**/.vscode -.cursor -**/.cursor -*.swp -**/*.swp -*.swo -**/*.swo - -# Environment files (use Docker env vars instead) -.env -**/.env -.env.* -**/.env.* -!.env.example -!**/.env.example - -# Test files -coverage -**/coverage -*.test.ts -**/*.test.ts -*.test.tsx -**/*.test.tsx -*.spec.ts -**/*.spec.ts -*.spec.tsx -**/*.spec.tsx -__tests__ -**/__tests__ - -# Documentation -docs -*.md -**/*.md -!README.md -!**/README.md - -# Docker (don't copy init scripts into build) -docker/init-scripts - -# Directories not needed in Docker builds -e2e -.beatgeo -**/.beatgeo -apps/cli - -# pnpm config (use-node-version breaks Alpine/MUSL Docker builds) -.npmrc - -# Misc -.DS_Store -**/.DS_Store -Thumbs.db -**/Thumbs.db -*.log -**/*.log -e2e/artifacts - -# Credentials and observations never belong in container build contexts. -.open-profound -.beat-geo -.elmo -**/.open-profound -**/.beat-geo -**/.elmo diff --git a/AGENTS.md b/AGENTS.md index 5ae61b0f..a48a8b86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ BeatGEO is an open-source AI visibility platform (Answer Engine Optimization): i - `apps/web` — product dashboard (TanStack Start + Vite, port 3000) - `apps/worker` — pg-boss background jobs (AI evaluations, citation tracking, reports) - `apps/www` — marketing site, docs, and blog (port 3001) -- `apps/cli` — `@beatapi/geo-cli`, the Docker Compose deployment CLI +- `apps/cli` — private source CLI for the bundled local setup, startup and environment check - `packages/lib` — shared logic and the Drizzle schema/migrations - `packages/ui` — shared shadcn-based UI components - `packages/docs` — user-facing docs content (MDX), rendered by `apps/www` @@ -54,7 +54,7 @@ Before handing work back or opening a PR, run `pnpm lint` and get it passing; CI ## Environment -`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for local mode: `DATABASE_URL`, `DEPLOYMENT_MODE=local`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL`/`VITE_APP_URL`, `DISABLE_TELEMETRY=1`, and `SCRAPE_TARGETS` plus the keys of the providers it names. Local web registration/setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require a research provider: `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`, or one direct LLM key (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `MISTRAL_API_KEY`) for onboarding and the Opportunities report. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. +`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for local mode: `DATABASE_URL`, `DEPLOYMENT_MODE=local`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL`/`VITE_APP_URL`, `DISABLE_TELEMETRY=1`, and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Hosted commercial modes are inherited code, not a launched service. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require a research provider: `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`, or one direct LLM key (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `MISTRAL_API_KEY`) for onboarding and the Opportunities report. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. ## Git workflow diff --git a/README.de.md b/README.de.md index 2330cdc3..9e424047 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,4 @@ -![BeatGEO — AI answers, citations and retests](assets/readme/cover.png) +[![BeatGEO — AI answers, citations and retests](assets/readme/cover.png)](https://beatapi.io/geo) # BeatGEO @@ -8,7 +8,7 @@ Ein quelloffener GEO-/AEO-Arbeitsbereich von [BeatAPI](https://beatapi.io). Verfolge Markennennungen und Quellen in echten KI-Antworten, leite Verbesserungen ab und wiederhole dieselben Fragen, um Veränderungen zu vergleichen. -[Schnellstart](#schnellstart) · [Ablauf](#ablauf) · [Funktionsstand](#funktionsstand) · [Roadmap](docs/roadmap.md) · [MIT-Lizenz](LICENSE.md) +[Website](https://beatapi.io/geo) · [Schnellstart](#schnellstart) · [Ablauf](#ablauf) · [Funktionsstand](#funktionsstand) · [Roadmap](docs/roadmap.md) · [MIT-Lizenz](LICENSE.md) > **Lokales MVP / Quellcode-Vorschau.** Die Kette ChatGPT-Erfassung → Datenbank → Worker wurde in Live-Smoke-Tests geprüft. Andere Engines, kostenpflichtige Recherche und unbeaufsichtigter Dauerbetrieb haben eigene Prüfgrenzen. [Prüfprotokoll auf Englisch](docs/beat-geo/validation.md) @@ -33,11 +33,11 @@ pnpm local:setup pnpm local:start ``` -Öffne **http://localhost:3140/setup** und registriere den lokalen Administrator: +Öffne **http://localhost:3140/** direkt im lokalen Arbeitsbereich. Für BeatGEO sind weder Registrierung noch Anmeldung oder Passwort nötig. BeatAPI und Chrome lassen sich unter `/setup` verbinden: -1. **Recherche verbinden.** BeatAPI-Key unter Connections prüfen, Recherchemodell auswählen und speichern. Alternativ diesen Schritt überspringen und Branchenvorlagen nutzen. +1. **Marke anlegen.** Website eingeben → Fragen erzeugen und bestätigen → erste Erfassung → Antworten und Quellen prüfen → Verbesserung speichern → dieselben Fragen erneut testen. 2. **Chrome verbinden.** Unter `chrome://extensions` den Entwicklermodus aktivieren und `.beat-geo/extension` entpackt laden. Verbundenes Profil auswählen und dort bei ChatGPT anmelden. Eine verbundene Erweiterung bestätigt keine Website-Anmeldung. -3. **Marke anlegen.** Website eingeben → Fragen erzeugen und bestätigen → erste Erfassung → Antworten und Quellen prüfen → Verbesserung speichern → dieselben Fragen erneut testen. +3. **Recherche verbinden.** BeatAPI-Key unter Connections prüfen, Recherchemodell auswählen und speichern. Alternativ diesen Schritt überspringen und Branchenvorlagen nutzen. Das Setup richtet eine eigene lokale PostgreSQL-Datenbank und die Erweiterung ein. Kein zweiter Repository-Klon, kein zusätzliches BeatBrowser-Konto und keine globale CLI-Installation nötig. Eine vorhandene BeatBrowser-Verbindung kann verwendet werden; pro Chrome-Profil nur eine Erweiterungskopie aktivieren. @@ -47,16 +47,7 @@ Das Setup richtet eine eigene lokale PostgreSQL-Datenbank und die Erweiterung ei ## Ablauf -```mermaid -flowchart LR - A[Website und Marke] --> B[Fragen erzeugen und bestätigen] - B --> C[KI-Antworten erfassen] - C --> D[Nennungen und Quellen prüfen] - D --> E[Maßnahme und Ausgangswerte speichern] - E --> F[Änderungen umsetzen] - F --> G[Dieselben Fragen wiederholen] - G --> D -``` +![Der Produktablauf](assets/readme/workflow.de.svg) BeatGEO verwaltet Produktdaten und Zeitplanung. **BeatBrowser** führt Aufgaben im angemeldeten Browser aus. **BeatAPI** kann Suche, Seitenabruf und Modellanalyse für Recherche und Optimierung bereitstellen. diff --git a/README.ja.md b/README.ja.md index 928b4f64..4f1ed5b0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,4 +1,4 @@ -![BeatGEO — AI answers, citations and retests](assets/readme/cover.png) +[![BeatGEO — AI answers, citations and retests](assets/readme/cover.png)](https://beatapi.io/geo) # BeatGEO @@ -8,7 +8,7 @@ [BeatAPI](https://beatapi.io) が開発するオープンソースの GEO / AEO ワークスペースです。実際の AI 回答からブランドへの言及と引用を追跡し、改善事項を記録。同じ質問で再測定して変化を比較します。 -[クイックスタート](#クイックスタート) · [ワークフロー](#ワークフロー) · [対応状況](#対応状況) · [ロードマップ](docs/roadmap.md) · [MIT ライセンス](LICENSE.md) +[公式サイト](https://beatapi.io/geo) · [クイックスタート](#クイックスタート) · [ワークフロー](#ワークフロー) · [対応状況](#対応状況) · [ロードマップ](docs/roadmap.md) · [MIT ライセンス](LICENSE.md) > **ローカル MVP / ソースプレビュー版。** ChatGPT の収集 → データベース → worker は実環境のスモークテストを通過しています。他のエンジン、有料リサーチ、長期間の無人運用は別途検証が必要です。[検証記録(英語)](docs/beat-geo/validation.md) @@ -33,11 +33,11 @@ pnpm local:setup pnpm local:start ``` -**http://localhost:3140/setup** を開き、ローカル管理者を登録します。 +**http://localhost:3140/** を開くと、ローカルワークスペースに直接入れます。BeatGEO の登録・ログイン・パスワード設定は不要です。BeatAPI と Chrome の接続は `/setup` で行えます。 -1. **リサーチを設定。** Connections で BeatAPI キーを検証し、リサーチモデルを選択して保存します。スキップして業種別の初期質問を使うこともできます。 +1. **ブランドを作成。** サイトを入力 → 質問を生成して確認 → 初回収集 → 回答と引用を確認 → 改善を記録 → 同じ質問で再測定します。 2. **Chrome を接続。** `chrome://extensions` でデベロッパーモードを有効にし、`.beat-geo/extension` を読み込みます。接続したプロファイルを選び、同じプロファイルで ChatGPT にログインしてください。拡張機能の接続とサイトへのログインは別です。 -3. **ブランドを作成。** サイトを入力 → 質問を生成して確認 → 初回収集 → 回答と引用を確認 → 改善を記録 → 同じ質問で再測定します。 +3. **リサーチを設定。** Connections で BeatAPI キーを検証し、リサーチモデルを選択して保存します。スキップして業種別の初期質問を使うこともできます。 セットアップが専用のローカル PostgreSQL と拡張機能を準備します。BeatBrowser を別にクローンしたり、別アカウントやグローバル CLI を用意したりする必要はありません。既存の接続も利用できますが、同じ Chrome プロファイルで拡張機能を二重に有効化しないでください。 @@ -47,16 +47,7 @@ pnpm local:start ## ワークフロー -```mermaid -flowchart LR - A[サイトとブランド] --> B[質問を生成して確認] - B --> C[AI 回答を収集] - C --> D[言及と引用を確認] - D --> E[改善と基準値を保存] - E --> F[変更を実施] - F --> G[同じ質問で再測定] - G --> D -``` +![プロダクトの流れ](assets/readme/workflow.ja.svg) BeatGEO が製品データとスケジュールを管理し、**BeatBrowser** がログイン済みブラウザで処理を実行します。**BeatAPI** はリサーチ向けの検索、ページ読み取り、モデル分析を提供できます。 diff --git a/README.ko.md b/README.ko.md index ca6e294e..4537136b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,4 +1,4 @@ -![BeatGEO — AI answers, citations and retests](assets/readme/cover.png) +[![BeatGEO — AI answers, citations and retests](assets/readme/cover.png)](https://beatapi.io/geo) # BeatGEO @@ -8,7 +8,7 @@ [BeatAPI](https://beatapi.io)가 만드는 오픈 소스 GEO / AEO 워크스페이스입니다. 실제 AI 답변에서 브랜드 언급과 인용을 추적하고, 개선 사항을 실행한 뒤 같은 질문으로 다시 측정해 변화를 비교합니다. -[빠른 시작](#빠른-시작) · [제품 흐름](#제품-흐름) · [지원 현황](#지원-현황) · [로드맵](docs/roadmap.md) · [MIT 라이선스](LICENSE.md) +[공식 웹사이트](https://beatapi.io/geo) · [빠른 시작](#빠른-시작) · [제품 흐름](#제품-흐름) · [지원 현황](#지원-현황) · [로드맵](docs/roadmap.md) · [MIT 라이선스](LICENSE.md) > **로컬 MVP / 소스 미리보기 버전.** ChatGPT 수집 → 데이터베이스 → worker 경로는 실제 스모크 테스트를 통과했습니다. 다른 엔진, 유료 리서치, 장기 무인 운영은 별도로 검증해야 합니다. [검증 기록 — 영어](docs/beat-geo/validation.md) @@ -33,11 +33,11 @@ pnpm local:setup pnpm local:start ``` -**http://localhost:3140/setup**을 열어 로컬 관리자를 등록합니다. +**http://localhost:3140/**을 열면 로컬 작업 공간으로 바로 들어갑니다. BeatGEO 회원가입, 로그인, 비밀번호 설정은 필요하지 않습니다. `/setup`에서 BeatAPI와 Chrome을 연결할 수 있습니다. -1. **리서치 연결.** Connections에서 BeatAPI 키를 검증하고 리서치 모델을 선택해 저장합니다. 건너뛰고 업종별 기본 질문을 사용해도 됩니다. +1. **브랜드 생성.** 웹사이트 입력 → 질문 생성 및 확인 → 첫 수집 → 답변과 인용 확인 → 개선 항목 저장 → 같은 질문으로 재측정합니다. 2. **Chrome 연결.** `chrome://extensions`에서 개발자 모드를 켜고 `.beat-geo/extension`을 압축 해제된 확장 프로그램으로 로드합니다. 연결된 프로필을 선택하고 같은 프로필에서 ChatGPT에 로그인하세요. 확장 프로그램 연결과 웹사이트 로그인은 별개입니다. -3. **브랜드 생성.** 웹사이트 입력 → 질문 생성 및 확인 → 첫 수집 → 답변과 인용 확인 → 개선 항목 저장 → 같은 질문으로 재측정합니다. +3. **리서치 연결.** Connections에서 BeatAPI 키를 검증하고 리서치 모델을 선택해 저장합니다. 건너뛰고 업종별 기본 질문을 사용해도 됩니다. 설치 과정에서 전용 로컬 PostgreSQL과 확장 프로그램을 준비합니다. 별도의 저장소 복제, BeatBrowser 계정, 전역 CLI 설치는 필요하지 않습니다. 기존 BeatBrowser 연결을 재사용할 수 있지만, Chrome 프로필 하나에는 확장 프로그램 한 개만 활성화하세요. @@ -47,16 +47,7 @@ pnpm local:start ## 제품 흐름 -```mermaid -flowchart LR - A[웹사이트와 브랜드] --> B[질문 생성 및 확인] - B --> C[AI 답변 수집] - C --> D[언급과 인용 확인] - D --> E[개선 항목과 기준값 저장] - E --> F[변경 사항 적용] - F --> G[같은 질문으로 재측정] - G --> D -``` +![제품 워크플로](assets/readme/workflow.ko.svg) BeatGEO는 제품 데이터와 일정을 관리합니다. **BeatBrowser**는 로그인된 브라우저에서 작업을 실행합니다. **BeatAPI**는 리서치와 개선을 위한 검색, 페이지 읽기, 모델 분석을 제공할 수 있습니다. diff --git a/README.md b/README.md index 84db1d5a..f2dc1505 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![BeatGEO — AI answers, citations and retests](assets/readme/cover.png) +[![BeatGEO — AI answers, citations and retests](assets/readme/cover.png)](https://beatapi.io/geo) # BeatGEO @@ -8,7 +8,7 @@ An open-source GEO / AEO workspace by [BeatAPI](https://beatapi.io). Track brand mentions and citations in real AI answers, turn gaps into actions, and rerun the same questions to compare results. -[Quick start](#quick-start) · [Product workflow](#the-product-workflow) · [Current support](#current-support) · [Roadmap](docs/roadmap.md) · [MIT license](LICENSE.md) +[Website](https://beatapi.io/geo) · [Quick start](#quick-start) · [Product workflow](#the-product-workflow) · [Current support](#current-support) · [Roadmap](docs/roadmap.md) · [MIT license](LICENSE.md) > **Local MVP / source preview.** The ChatGPT collection → database → worker path has passed live smoke tests. Other engines, paid research and unattended operation have separate acceptance boundaries. [Validation log](docs/beat-geo/validation.md) @@ -33,11 +33,11 @@ pnpm local:setup pnpm local:start ``` -Open **http://localhost:3140/setup** and register your local administrator: +Open **http://localhost:3140/** to enter your local workspace directly. There is no BeatGEO registration, login or password. Connections at `/setup` lets you add an optional BeatAPI key and connect Chrome: -1. **Connect research.** In Connections, enter a BeatAPI key, validate it, select a research model and save. You can skip research and use industry starter questions. +1. **Create a brand.** Enter the website, generate and confirm questions, then start the first collection. Read the answers and citations, save an improvement, and compare a retest. 2. **Connect Chrome.** Load `.beat-geo/extension` via `chrome://extensions` → Developer mode → Load unpacked. Select the connected profile and sign in to ChatGPT in that profile. An extension connection does not establish website login. -3. **Create a brand.** Enter the website, generate and confirm questions, then start the first collection. Read the answers and citations, save an improvement, and compare a retest. +3. **Connect research.** In Connections, enter a BeatAPI key, validate it, select a research model and save. You can skip research and use industry starter questions. The installer prepares a dedicated local PostgreSQL database and the included extension. No second repository clone, BeatBrowser account or global CLI installation is needed. Existing BeatBrowser connections can be reused; enable only one extension copy per profile. @@ -47,16 +47,7 @@ The installer prepares a dedicated local PostgreSQL database and the included ex ## The product workflow -```mermaid -flowchart LR - A[Website and brand] --> B[Generate and confirm questions] - B --> C[Collect AI answers] - C --> D[Inspect mentions and citations] - D --> E[Save action and baseline] - E --> F[Implement changes] - F --> G[Retest the same questions] - G --> D -``` +![The product workflow](assets/readme/workflow.en.svg) BeatGEO owns the product records and schedule. **BeatBrowser** handles the signed-in browser task. **BeatAPI** can supply search, page reading and model analysis for research and optimization. @@ -100,7 +91,7 @@ Browser collection, starter templates and basic statistics do not require paid m Forking BeatGEO keeps the **pinned BeatBrowser dependency**; it does not create a separate fork of BeatBrowser. Setup downloads that revision and prepares the extension. Developers changing BeatBrowser itself should fork it separately and explicitly update the dependency and lockfile. -Each local installation has its own account, database, keys and Chrome sessions. Do not commit `.env`, `.beat-geo/`, browser evidence or credentials. Local services bind to loopback; do not expose them through a public tunnel. [Privacy](docs/privacy.md) +Each local installation has its own private workspace, database, keys and Chrome sessions. Do not commit `.env`, `.beat-geo/`, browser evidence or credentials. Local services bind to loopback; do not expose them through a public tunnel. [Privacy](docs/privacy.md) ## Development and references diff --git a/README.zh-CN.md b/README.zh-CN.md index 7b73d414..0b4c545d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ -![BeatGEO — AI 回答、引用与持续复测](assets/readme/cover.png) +[![BeatGEO — AI 回答、引用与持续复测](assets/readme/cover.png)](https://beatapi.io/geo) # BeatGEO @@ -8,7 +8,7 @@ [BeatAPI](https://beatapi.io) 出品的开源 GEO / AEO 工作台。从真实 AI 回答中追踪品牌提及与引用,把发现的问题变成优化行动,再用同一组问题复测效果。 -[快速开始](#快速开始) · [产品闭环](#产品闭环) · [支持范围](#支持范围) · [路线图](docs/roadmap.md) · [MIT 许可](LICENSE.md) +[项目官网](https://beatapi.io/geo) · [快速开始](#快速开始) · [产品闭环](#产品闭环) · [支持范围](#支持范围) · [路线图](docs/roadmap.md) · [MIT 许可](LICENSE.md) > **本地 MVP / 源码预览版。** ChatGPT 采集 → 数据库 → worker 已通过真实冒烟测试。其他平台、付费研究与长期无人值守分别验收。[验收记录](docs/beat-geo/validation.md) @@ -33,11 +33,11 @@ pnpm local:setup pnpm local:start ``` -打开 **http://localhost:3140/setup**,注册本地管理员: +打开 **http://localhost:3140/**,直接进入本地工作台,无需注册、登录或设置密码。可在 `/setup` 连接 BeatAPI 和 Chrome: -1. **连接研究能力。** 在 Connections 填入 BeatAPI Key → 验证 → 选择研究模型 → 保存。也可跳过,使用行业模板题库。 +1. **创建品牌。** 填网站 → 生成并确认问题 → 首次采集 → 查看回答和引用 → 保存优化行动 → 同组复测。 2. **连接 Chrome。** 在 `chrome://extensions` 开启开发者模式,加载 `.beat-geo/extension`。选择连接的配置,并在同一配置登录 ChatGPT。扩展连接成功不代表网站已经登录。 -3. **创建品牌。** 填网站 → 生成并确认问题 → 首次采集 → 查看回答和引用 → 保存优化行动 → 同组复测。 +3. **连接研究能力。** 在 Connections 填入 BeatAPI Key → 验证 → 选择研究模型 → 保存。也可跳过,使用行业模板题库。 安装脚本会准备独立 PostgreSQL 和内置扩展,不必再克隆 BeatBrowser、注册另一套账号或安装全局 CLI。已有 BeatBrowser 可以复用,同一 Chrome 配置只启用一份扩展。 @@ -47,16 +47,7 @@ pnpm local:start ## 产品闭环 -```mermaid -flowchart LR - A[网站与品牌] --> B[生成并确认问题] - B --> C[采集 AI 回答] - C --> D[查看提及与引用] - D --> E[保存行动与基线] - E --> F[实施修改] - F --> G[同组问题复测] - G --> D -``` +![产品闭环](assets/readme/workflow.zh-CN.svg) BeatGEO 管理产品数据和调度,**BeatBrowser** 执行已登录浏览器任务,**BeatAPI** 为研究与优化提供搜索、网页阅读和模型分析。 @@ -100,7 +91,7 @@ README 提供英文、中文、日文、德文和韩文。**产品界面通过 E Fork BeatGEO 会保留 **BeatBrowser 的固定版本依赖**,不会额外创建一个 BeatBrowser fork。安装时下载该版本并准备扩展。只有需要改 BeatBrowser 源码的开发者才需要单独 fork,再更新依赖和锁文件。 -每位成员本机的账号、数据库、Key 和 Chrome 会话相互独立。不要提交 `.env`、`.beat-geo/`、浏览器证据或凭据。本地服务绑定 loopback,不要通过公网隧道暴露。[隐私说明](docs/privacy.md) +每台电脑的工作台、数据库、Key 和 Chrome 会话相互独立,不需要 BeatGEO 账号。不要提交 `.env`、`.beat-geo/`、浏览器证据或凭据。本地服务绑定 loopback,不要通过公网隧道暴露。[隐私说明](docs/privacy.md) ## 开发与参考 diff --git a/apps/cli/README.md b/apps/cli/README.md index 2cbb21ff..af8e5013 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,13 +1,15 @@ -# BeatGEO deployment CLI — source preview +# BeatGEO local CLI — source preview -This is internal deployment tooling for [BeatGEO](https://github.com/BeatAPI/BeatGEO). The npm package and container images are **not published**. Start with the root [quick start](../../README.md#quick-start) or [self-hosting guide](../../docs/self-hosting.md). +This private source CLI uses the same bundled-storage setup and startup scripts as the root quick start. No npm package or container images are published. -After installing the workspace dependencies with pnpm, inspect the CLI from the repository root: +From a source checkout with Node.js 24 and pnpm 12.4.1: ```sh -pnpm beatgeo --help +pnpm beatgeo doctor +pnpm beatgeo init +pnpm beatgeo start ``` -The source `init --dev` flow builds a Docker stack locally. It can initialize databases and start containers; use a dedicated development environment. Deployment upgrades require backups and review of migrations. This flow has not received release validation. +`init --resume` resumes an interrupted setup without replacing private configuration. `start` runs the web app, browser collector and worker until Ctrl+C. Local PostgreSQL is bundled and managed automatically. The `--dir` option selects another source checkout. -Telemetry is disabled by default. Events require an explicitly configured operator-owned `BEAT_GEO_POSTHOG_KEY`; `DISABLE_TELEMETRY=1` always disables them. There is no newsletter signup or upstream tracking destination. +Existing installations are never converted automatically. Back up their data and private configuration before following the [self-hosting guide](../../docs/self-hosting.md). diff --git a/apps/cli/src/commands/compose.ts b/apps/cli/src/commands/compose.ts deleted file mode 100644 index f7fcfdd9..00000000 --- a/apps/cli/src/commands/compose.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type DirOption, resolveConfigDir } from "../config.js"; -import { assertDockerRunning, runDockerCompose } from "../docker.js"; - -export async function runCompose(args: string[], options: DirOption): Promise { - const configDir = await resolveConfigDir(options.dir); - assertDockerRunning(); - await runDockerCompose(configDir, args); -} diff --git a/apps/cli/src/commands/edit.ts b/apps/cli/src/commands/edit.ts deleted file mode 100644 index 37ad1ba5..00000000 --- a/apps/cli/src/commands/edit.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; -import { type DirOption, fileExists, resolveConfigDir } from "../config.js"; -import { log } from "../util.js"; - -export async function runEdit(target: string, options: DirOption): Promise { - const configDir = await resolveConfigDir(options.dir); - - let filePath: string; - if (target === "env") { - filePath = path.join(configDir, ".env"); - } else if (target === "compose") { - filePath = path.join(configDir, "beatgeo.yaml"); - } else { - throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`); - } - - if (!(await fileExists(filePath))) { - throw new Error(`File not found: ${filePath}`); - } - - const editorEnv = process.env.VISUAL || process.env.EDITOR || "nano"; - const parts = editorEnv.split(/\s+/).filter(Boolean); - const cmd = parts[0] ?? "nano"; - const args = [...parts.slice(1), filePath]; - - await new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: "inherit" }); - child.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(`${cmd} exited with code ${code}`)); - }); - child.on("error", (err) => reject(err)); - }); - - log.info("Restart the stack with `beatgeo compose up -d` to apply changes."); -} diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts deleted file mode 100644 index 5e4de471..00000000 --- a/apps/cli/src/commands/init.ts +++ /dev/null @@ -1,459 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import * as p from "@clack/prompts"; -import { CLOUD_ENTRY_PRICE_USD } from "@workspace/config/plans"; -import { cloudPricingUrl } from "@workspace/config/referrals"; -import { formatScrapeTarget } from "@workspace/config/scrape-targets"; -import { parse as parseDotenv } from "dotenv"; -import pc from "picocolors"; -import { buildComposeYaml } from "../compose.js"; -import { CONFIG_HOME, type EnvMap, ensureDir, fileExists, type PostgresMode, writeConfigFiles } from "../config.js"; -import { assertDockerRunning, runDockerCompose, waitForHealthy } from "../docker.js"; -import { - collectCredentials, - collectProvider, - DEFAULT_SCRAPER_MODELS, - type DirectApiProvider, - finalizeScrapeTargets, - hasDirectApiConfigured, - PROVIDER_PROMPTS, - providerSpec, - type RecommendedScraper, -} from "../providers.js"; -import { trackCliEvent } from "../telemetry.js"; -import { assertNotCancelled, generateSecret, link, log, printBanner } from "../util.js"; - -export type InitOptions = { - dev?: boolean; - dir?: string; - dockerDir?: string; -}; - -const DEFAULT_APP_NAME = "BeatGEO"; -const DEFAULT_APP_ICON = "/icons/beatgeo-icon.svg"; -const DEFAULT_APP_PORT = 1515; -const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/beatgeo"; -const CLOUD_PRICING_URL = cloudPricingUrl("cli"); - -/** - * Guard an existing config from being replaced by accident. Returns the - * deployment id to carry over, so a re-run keeps reporting as the same install. - */ -async function confirmEnvOverwrite(configDir: string): Promise { - const existingEnvPath = path.join(configDir, ".env"); - if (!(await fileExists(existingEnvPath))) return undefined; - - const contents = await fs.readFile(existingEnvPath, "utf8"); - const isBeatGeoEnv = contents.startsWith("# Rendered by beatgeo") || contents.startsWith("# Generated by beatgeo"); - - p.log.warn( - isBeatGeoEnv - ? `An existing BeatGEO config was found at ${configDir}.` - : `A .env file already exists in ${configDir} and was NOT created by BeatGEO.`, - ); - const overwrite = await p.confirm({ - message: isBeatGeoEnv - ? "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced." - : "Overwrite the existing .env file? This cannot be undone.", - initialValue: false, - }); - assertNotCancelled(overwrite); - if (!overwrite) { - p.cancel( - isBeatGeoEnv - ? "Setup cancelled. Use `beatgeo edit env` to change individual values." - : "Setup cancelled. Choose a different directory with --dir.", - ); - process.exit(0); - } - - return isBeatGeoEnv ? parseDotenv(contents).DEPLOYMENT_ID : undefined; -} - -async function resolveDockerDirInteractive(cwd: string): Promise { - const inCwd = await fileExists(path.join(cwd, "Dockerfile")); - const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile")); - const defaultDir = inCwd ? "." : inDockerDir ? "docker" : "."; - - const dir = await p.text({ - message: "Path to docker directory (contains Dockerfile)", - defaultValue: defaultDir, - }); - assertNotCancelled(dir); - - const resolved = path.resolve(cwd, dir); - if (!(await fileExists(path.join(resolved, "Dockerfile")))) { - p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`); - process.exit(1); - } - - return resolved; -} - -async function resolveDockerLocation( - options: InitOptions, - cwd: string, -): Promise<{ dockerDir: string | undefined; repoRoot: string }> { - if (!options.dev) return { dockerDir: undefined, repoRoot: cwd }; - - if (!options.dockerDir) { - const dockerDir = await resolveDockerDirInteractive(cwd); - return { dockerDir, repoRoot: path.resolve(dockerDir, "..") }; - } - - const dockerDir = path.resolve(cwd, options.dockerDir); - if (!(await fileExists(path.join(dockerDir, "Dockerfile")))) { - p.log.error(`Dockerfile not found in ${dockerDir}`); - process.exit(1); - } - return { dockerDir, repoRoot: path.resolve(dockerDir, "..") }; -} - -function validatePort(value: string | undefined): string | undefined { - if (!value) return undefined; - const port = Number(value); - const valid = Number.isInteger(port) && port >= 1 && port <= 65535; - return valid ? undefined : "Must be an integer between 1 and 65535"; -} - -function toPublicUrl(value: string): URL | undefined { - const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`; - try { - return new URL(withScheme); - } catch { - return undefined; - } -} - -function validatePublicUrl(value: string | undefined): string | undefined { - if (!value) return "Required"; - const url = toPublicUrl(value.trim()); - if (!url?.hostname.includes(".")) return "Enter a domain, e.g. beatgeo.example.com"; - // Auth trusts only this origin, so BeatGEO can't be served from a subpath. - if (url.pathname !== "/") return "Must not include a path"; - return undefined; -} - -async function configureProvidersInteractive(env: EnvMap): Promise<"recommended" | "custom"> { - p.note( - [ - "BeatGEO needs two kinds of providers:", - "", - pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):", - ` • ${pc.cyan("Cloro")} — most reliable, every surface, ~$0.65/mo per prompt ($30/mo min)`, - ` • ${pc.cyan("BrightData")} — pay-as-you-go and cheaper, but slower, ~$0.45/mo per prompt`, - ` • ${pc.cyan("Oxylabs")} — cheapest per run, no Gemini/Copilot, $49/mo min`, - ` • ${pc.cyan("SearchApi")} — flat per-search, every surface, ~$1.20/mo per prompt ($40/mo min)`, - ` • ${pc.cyan("Olostep")} — premium, built for high volume, ~$2.25/mo per prompt`, - ` • ${pc.cyan("DataForSEO")} — pay-as-you-go, scrapers + direct APIs, ~$1.20/mo per prompt`, - "", - pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,", - " ad-hoc LLM calls). Required:", - ` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`, - ` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`, - "", - "Pricing assumes BeatGEO's default cadence (5 runs/day × 2 surfaces).", - ].join("\n"), - "AI providers", - ); - - const mode = await p.select({ - message: "Setup mode", - options: [ - { value: "recommended" as const, label: "Recommended — one scraper + one direct API" }, - { value: "custom" as const, label: "Custom — pick each provider individually" }, - ], - initialValue: "recommended" as const, - }); - assertNotCancelled(mode); - - if (mode === "recommended") { - await configureProvidersRecommended(env); - } else { - await configureProvidersCustom(env); - } - return mode; -} - -async function configureProvidersRecommended(env: EnvMap): Promise { - const targets: string[] = []; - - // ── Scraper ───────────────────────────────────────────────────────────── - const scraper = await p.select({ - message: "Scraper (tracks ChatGPT + Google AI Mode)", - options: [ - { value: "cloro" as const, label: "Cloro — most reliable, every surface (~$0.65/mo per prompt, $30/mo min)" }, - { value: "brightdata" as const, label: "BrightData — pay-as-you-go, cheaper but slower (~$0.45/mo per prompt)" }, - { value: "oxylabs" as const, label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)" }, - { - value: "searchapi" as const, - label: "SearchApi — flat per-search, every surface (~$1.20/mo per prompt, $40/mo min)", - }, - { value: "olostep" as const, label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)" }, - { - value: "dataforseo" as const, - label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)", - }, - ], - initialValue: "cloro" as const, - }); - assertNotCancelled(scraper); - await collectScraperKey(scraper, env); - for (const model of DEFAULT_SCRAPER_MODELS) { - targets.push(formatScrapeTarget({ model, provider: scraper, webSearch: true })); - } - - // ── Direct API ────────────────────────────────────────────────────────── - const direct = await p.select({ - message: "Direct LLM API (powers onboarding analysis + sentiment scoring)", - options: [ - { value: "beatapi" as const, label: "BeatAPI — research and analysis (recommended)" }, - { value: "openrouter" as const, label: "OpenRouter" }, - { value: "anthropic" as const, label: "Anthropic — direct Claude" }, - { value: "openai" as const, label: "OpenAI — direct GPT-* models" }, - { value: "mistral" as const, label: "Mistral — direct Mistral models" }, - ], - initialValue: "beatapi" as const, - }); - assertNotCancelled(direct); - await collectDirectApiQuick(direct, env); - - await finalizeScrapeTargets(env, targets, { skipEdit: true }); -} - -async function collectScraperKey(scraper: RecommendedScraper, env: EnvMap): Promise { - const spec = providerSpec(scraper); - const url = spec.signup?.url; - if (url) p.log.info(`Sign up: ${link(pc.cyan(url), url)}`); - await collectCredentials(spec, env); -} - -async function collectDirectApiQuick(kind: DirectApiProvider, env: EnvMap): Promise { - await collectCredentials(providerSpec(kind), env); -} - -async function configureProvidersCustom(env: EnvMap): Promise { - const targets: string[] = []; - - p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)")); - while (!hasDirectApiConfigured(env)) { - for (const spec of PROVIDER_PROMPTS.filter((candidate) => candidate.kind === "direct")) { - await collectProvider(spec, env, targets); - } - if (!hasDirectApiConfigured(env)) { - p.log.warn( - "Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.", - ); - } - } - - p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)")); - for (const spec of PROVIDER_PROMPTS.filter((candidate) => candidate.kind === "scraper")) { - await collectProvider(spec, env, targets); - } - - await finalizeScrapeTargets(env, targets); -} - -async function doStart(configDir: string): Promise { - assertDockerRunning(); - - log.step("Starting Docker Compose stack..."); - await runDockerCompose(configDir, ["up", "-d"]); - - const s = p.spinner(); - s.start("Waiting for services to become healthy..."); - const ok = await waitForHealthy(configDir, 180_000); - if (ok) { - s.stop("All services healthy!"); - } else { - s.stop("Health check timed out."); - p.log.warn("Some services did not report healthy status."); - } - - log.info("Examples:"); - console.log(` ${pc.bold("beatgeo compose logs -f")}`); - console.log(` ${pc.bold("beatgeo compose logs -f web")}`); - console.log(` ${pc.bold("beatgeo compose ps")}`); - console.log(` ${pc.bold("beatgeo compose down")}`); -} - -export async function runInit(options: InitOptions, version: string): Promise { - printBanner(); - p.intro(pc.bold("Setting up BeatGEO")); - - // Said before the wizard asks for anything, not after: someone who would - // rather buy the managed version should find that out before they have gone - // and opened scraper accounts. It also comes before the Docker check, so - // someone without Docker still sees the option that needs none. - p.note( - [ - "Self-hosting BeatGEO is free, and always will be. But it does mean", - "running Postgres and a worker, and opening your own scraper and", - "model provider accounts.", - "", - `BeatGEO Cloud is the same open-source product, hosted by us, from`, - `${pc.bold(`$${CLOUD_ENTRY_PRICE_USD}/month`)} — scraper and model access included, nothing to`, - "keep running.", - "", - ` ${link(pc.cyan(CLOUD_PRICING_URL), CLOUD_PRICING_URL)}`, - ].join("\n"), - "Prefer not to self-host?", - ); - - assertDockerRunning(); - - const cwd = process.cwd(); - - // ── Resolve config directory ───────────────────────────────────────── - const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME; - - const preservedDeploymentId = await confirmEnvOverwrite(configDir); - const { dockerDir, repoRoot } = await resolveDockerLocation(options, cwd); - - // ── Data stores ────────────────────────────────────────────────────── - const postgresMode = await p.select({ - message: "PostgreSQL connection", - options: [ - { - value: "docker" as const, - label: "Run Postgres in Docker", - }, - { - value: "external" as const, - label: "Use existing Postgres (provide DATABASE_URL)", - }, - ], - initialValue: "docker" as PostgresMode, - }); - assertNotCancelled(postgresMode); - - const env: EnvMap = {}; - env.DEPLOYMENT_MODE = "local"; - env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID(); - env.BETTER_AUTH_SECRET = generateSecret(); - // Standard base64 (not base64url): the app decodes this with Buffer.from(key, - // "base64") and requires exactly 32 bytes. Enables storing provider - // credentials encrypted in the database via the in-app Providers UI. - env.BEAT_GEO_ENCRYPTION_KEY = generateSecret(32, "base64"); - env.APP_NAME = DEFAULT_APP_NAME; - env.APP_ICON = DEFAULT_APP_ICON; - env.VITE_APP_NAME = DEFAULT_APP_NAME; - env.VITE_APP_ICON = DEFAULT_APP_ICON; - - if (postgresMode === "external") { - p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL"); - const url = await p.password({ - message: "DATABASE_URL", - validate: (v) => (!v ? "Required" : undefined), - }); - assertNotCancelled(url); - env.DATABASE_URL = url; - } else { - env.DATABASE_URL = LOCAL_DATABASE_URL; - } - - // ── AI providers ───────────────────────────────────────────────────── - const setupMode = await configureProvidersInteractive(env); - - // ── Telemetry ─────────────────────────────────────────────────────── - env.DISABLE_TELEMETRY = "1"; - - // ── Access ────────────────────────────────────────────────────────── - const access = await p.select({ - message: "How will you access BeatGEO?", - options: [ - { value: "local" as const, label: "On this machine (localhost)" }, - { value: "public" as const, label: "From a domain (e.g. behind a reverse proxy)" }, - ], - initialValue: "local" as const, - }); - assertNotCancelled(access); - - let publicUrl: URL | undefined; - if (access === "public") { - const domain = await p.text({ - message: "Domain", - placeholder: "beatgeo.example.com", - validate: validatePublicUrl, - }); - assertNotCancelled(domain); - publicUrl = toPublicUrl(domain.trim()); - } - - const portInput = await p.text({ - message: access === "public" ? "Port for your reverse proxy to forward to" : "Web app port", - placeholder: String(DEFAULT_APP_PORT), - defaultValue: String(DEFAULT_APP_PORT), - validate: validatePort, - }); - assertNotCancelled(portInput); - const port = Number(portInput); - - env.APP_URL = publicUrl?.origin ?? `http://localhost:${port}`; - env.VITE_APP_URL = env.APP_URL; - - // ── Write config ───────────────────────────────────────────────────── - const composeYaml = buildComposeYaml({ - dev: Boolean(options.dev), - postgresMode, - repoRoot, - dockerDir, - port, - version, - }); - - await ensureDir(configDir); - await writeConfigFiles(configDir, { - env, - composeYaml, - postgresMode, - dev: Boolean(options.dev), - version, - }); - - p.log.success(`Config written to ${configDir}`); - p.log.warn("Your generated .env file contains secrets — do not commit it to version control."); - - if (options.dev) { - p.log.info("Dev mode enabled. Run `beatgeo compose build` before starting."); - } - - const shouldStart = await p.confirm({ - message: "Start the stack now?", - initialValue: true, - }); - assertNotCancelled(shouldStart); - - if (shouldStart) { - await doStart(configDir); - } else { - p.log.info("You can start later with `beatgeo compose up -d`."); - } - - // CLI telemetry — silently dropped if the user opted out above. - await trackCliEvent(configDir, "cli_init", { - version, - os: process.platform, - arch: process.arch, - node_version: process.version, - postgres_mode: postgresMode, - dev_mode: Boolean(options.dev), - setup_mode: setupMode, - has_scraper: Boolean( - env.BRIGHTDATA_API_TOKEN || - env.SEARCHAPI_API_KEY || - env.OLOSTEP_API_KEY || - env.OXYLABS_USERNAME || - env.CLORO_API_KEY, - ), - has_direct_api: hasDirectApiConfigured(env), - }); - - p.log.message( - `If you find BeatGEO useful, star us on GitHub!\n ${link(pc.cyan("https://github.com/BeatAPI/BeatGEO"), "https://github.com/BeatAPI/BeatGEO")}`, - ); - - p.outro(pc.green("Setup complete!")); -} diff --git a/apps/cli/src/commands/upgrade.ts b/apps/cli/src/commands/upgrade.ts deleted file mode 100644 index 3241b3ff..00000000 --- a/apps/cli/src/commands/upgrade.ts +++ /dev/null @@ -1,221 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import * as p from "@clack/prompts"; -import { parse as parseDotenv } from "dotenv"; -import pc from "picocolors"; -import semver from "semver"; -import { refreshRenderedVersion, repinComposeImages } from "../compose.js"; -import { buildEnvFile, readRenderedVersion, resolveConfigDir } from "../config.js"; -import { - assertDockerRunning, - composeUsesBuild, - runDockerCompose, - stackHasRunningServices, - waitForHealthy, -} from "../docker.js"; -import { MIGRATIONS, type MigrationContext, planMigrations, runMigrations } from "../migrations/index.js"; -import { trackCliEvent } from "../telemetry.js"; -import { assertNotCancelled, log, printBanner } from "../util.js"; -import { fetchLatestCliVersion } from "../version.js"; - -export type UpgradeOptions = { - dir?: string; - yes?: boolean; -}; - -async function confirmStaleCli(cliVersion: string, assumeYes: boolean | undefined): Promise { - const latestCli = await fetchLatestCliVersion(); - if (!latestCli || !semver.valid(cliVersion) || !semver.lt(cliVersion, latestCli)) return; - - log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`); - log.info("Recommended: upgrade the CLI first, then rerun this command:"); - console.log(` ${pc.bold("npm install -g @beatapi/geo-cli@latest")}`); - const proceed = assumeYes - ? true - : await p.confirm({ - message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`, - initialValue: false, - }); - assertNotCancelled(proceed); - if (!proceed) { - p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `beatgeo upgrade`."); - process.exit(0); - } -} - -async function pullAtCurrentVersion(configDir: string, cliVersion: string, assumeYes: boolean | undefined) { - log.success(`Already at ${cliVersion}.`); - const pull = assumeYes - ? true - : await p.confirm({ message: "Pull images for this version anyway?", initialValue: false }); - assertNotCancelled(pull); - - if (pull) { - assertDockerRunning(); - const wasRunning = await stackHasRunningServices(configDir); - log.step("Pulling images..."); - await runDockerCompose(configDir, ["pull"]); - if (wasRunning) { - log.step("Restarting services..."); - await runDockerCompose(configDir, ["up", "-d"]); - } - } - p.outro(pc.green("Nothing to upgrade.")); -} - -/** - * With no detected version we can't tell which migrations apply, so we skip - * them and just re-pin + pull. (planMigrations would also return [] here since - * from === to, but it is special-cased for a clearer message.) - */ -function planUpgradeMigrations(detectedVersion: string | null, fromVersion: string, cliVersion: string) { - const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS); - if (detectedVersion === null) { - log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`); - } else { - log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`); - } - - if (plan.length === 0) { - log.step("No migrations to run (docker images will be re-pinned and pulled)."); - return plan; - } - log.step(`Migrations to apply: ${plan.length}`); - for (const migration of plan) { - console.log(` • ${pc.bold(`${migration.from} → ${migration.to}`)} ${migration.description}`); - } - return plan; -} - -async function applyUpgradeMigrations( - plan: Awaited>, - ctx: MigrationContext, - wasRunning: boolean, -): Promise { - try { - await runMigrations(plan, ctx); - } catch (error) { - log.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`); - log.info("Your config version was left unchanged. Fix the issue and rerun `beatgeo upgrade`."); - if (wasRunning) { - log.info("The stack was stopped for the upgrade. Restart with `beatgeo compose up -d` after fixing."); - } - process.exit(1); - } -} - -async function restartAfterUpgrade(configDir: string, wasRunning: boolean): Promise { - if (!wasRunning) { - log.info("Stack was stopped before upgrade — leaving it stopped. Start with `beatgeo compose up -d`."); - return; - } - log.step("Starting services..."); - await runDockerCompose(configDir, ["up", "-d"]); - const spinner = p.spinner(); - spinner.start("Waiting for services to become healthy..."); - if (await waitForHealthy(configDir, 180_000)) { - spinner.stop("All services healthy!"); - return; - } - spinner.stop("Health check timed out."); - log.warn("Some services did not report healthy status."); -} -export async function runUpgrade(options: UpgradeOptions, cliVersion: string): Promise { - printBanner(); - p.intro(pc.bold("Upgrading BeatGEO")); - - await confirmStaleCli(cliVersion, options.yes); - - // ── Resolve config + the version it was last rendered with ─────────── - const configDir = await resolveConfigDir(options.dir); - const composePath = path.join(configDir, "beatgeo.yaml"); - const detectedVersion = await readRenderedVersion(composePath); - const fromVersion = detectedVersion ?? cliVersion; - if (!semver.valid(fromVersion)) { - throw new Error(`Could not determine the installed version from ${composePath}.`); - } - - if (semver.gt(fromVersion, cliVersion)) { - log.warn(`Your deployment (${fromVersion}) is newer than this CLI (${cliVersion}).`); - log.info("Upgrade the CLI to match, then rerun:"); - console.log(` ${pc.bold("npm install -g @beatapi/geo-cli@latest")}`); - process.exit(1); - } - - // Only a *detected* matching version is "nothing to do". A legacy install - // with no version header (detectedVersion === null) still needs its image - // tags re-pinned, so it falls through to the upgrade path below. - if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) { - await pullAtCurrentVersion(configDir, cliVersion, options.yes); - return; - } - - const plan = planUpgradeMigrations(detectedVersion, fromVersion, cliVersion); - - const confirm = options.yes ? true : await p.confirm({ message: "Proceed with upgrade?", initialValue: true }); - assertNotCancelled(confirm); - if (!confirm) { - p.cancel("Upgrade cancelled."); - process.exit(0); - } - - // ── Stop the stack so migrations + image swap run on a quiet deployment ─ - assertDockerRunning(); - const wasRunning = await stackHasRunningServices(configDir); - if (wasRunning) { - log.step("Stopping services..."); - await runDockerCompose(configDir, ["down"]); - } - - // ── Run migrations ─────────────────────────────────────────────────── - await applyUpgradeMigrations(plan, buildMigrationContext(configDir, cliVersion), wasRunning); - - // ── Re-pin image tags + refresh the version recorded in the config ─── - const isDev = await composeUsesBuild(composePath); - await repinComposeImages(composePath, cliVersion); - await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion); - log.success(`Pinned config to ${cliVersion}.`); - - // ── Pull new images (dev builds from source, so nothing to pull) ───── - if (isDev) { - log.info("Dev mode detected — rebuild with `beatgeo compose build` to apply the new version."); - } else { - log.step("Pulling images..."); - await runDockerCompose(configDir, ["pull"]); - } - - // ── Restart only if the stack was running before the upgrade ───────── - await restartAfterUpgrade(configDir, wasRunning); - - await trackCliEvent(configDir, "cli_upgrade", { - from_version: fromVersion, - to_version: cliVersion, - migrations_run: plan.length, - was_running: wasRunning, - dev_mode: isDev, - }); - - p.outro(pc.green(`Upgraded to ${cliVersion}.`)); -} - -function buildMigrationContext(configDir: string, version: string): MigrationContext { - const envPath = path.join(configDir, ".env"); - return { - configDir, - log: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - step: (msg) => log.step(msg), - }, - readEnv: async () => { - try { - return parseDotenv(await fs.readFile(envPath, "utf8")); - } catch { - return {}; - } - }, - writeEnv: async (env) => { - await fs.writeFile(envPath, buildEnvFile(env, version), "utf8"); - }, - }; -} diff --git a/apps/cli/src/compose-pin.test.ts b/apps/cli/src/compose-pin.test.ts deleted file mode 100644 index 934c7492..00000000 --- a/apps/cli/src/compose-pin.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseRenderedVersion, refreshHeaderVersion, repinImages } from "./compose-pin.js"; - -const LEGACY_COMPOSE = `name: beatgeo - -services: - web: - image: beatapi/beat-geo-web:latest - worker: - image: beatapi/beat-geo-worker:latest - db-migrate: - image: beatapi/beat-geo-db-migrate:latest - postgres: - image: postgres:16-alpine -`; - -const HEADED_COMPOSE = `# Rendered by beatgeo 0.2.10 on 2026-01-01T00:00:00.000Z -# Run \`beatgeo upgrade\` after upgrading the CLI to refresh this file. -name: beatgeo - -services: - web: - image: beatapi/beat-geo-web:0.2.10 - postgres: - image: postgres:16-alpine -`; - -describe("parseRenderedVersion", () => { - it("reads the version from a rendered-by header", () => { - expect(parseRenderedVersion(HEADED_COMPOSE)).toBe("0.2.10"); - }); - - it("returns null when there is no header", () => { - expect(parseRenderedVersion(LEGACY_COMPOSE)).toBeNull(); - }); -}); - -describe("repinImages", () => { - it("re-pins every beatapi/beat-geo-* image to the target version", () => { - const out = repinImages(LEGACY_COMPOSE, "0.2.13"); - expect(out).toContain("beatapi/beat-geo-web:0.2.13"); - expect(out).toContain("beatapi/beat-geo-worker:0.2.13"); - expect(out).toContain("beatapi/beat-geo-db-migrate:0.2.13"); - expect(out).not.toContain(":latest"); - }); - - // A Postgres major bump needs a dump/restore, so an upgrade must never roll - // the tag underneath a running deployment. - it("leaves third-party images untouched", () => { - const out = repinImages(LEGACY_COMPOSE, "0.2.13"); - expect(out).toContain("postgres:16-alpine"); - }); -}); - -describe("refreshHeaderVersion", () => { - it("replaces an existing header in place without duplicating it", () => { - const out = refreshHeaderVersion(HEADED_COMPOSE, "0.2.13"); - expect(parseRenderedVersion(out)).toBe("0.2.13"); - expect(out.match(/# Rendered by beatgeo /g)).toHaveLength(1); - }); - - it("adds a header to a legacy file that has none", () => { - expect(parseRenderedVersion(LEGACY_COMPOSE)).toBeNull(); - const out = refreshHeaderVersion(LEGACY_COMPOSE, "0.2.13"); - expect(parseRenderedVersion(out)).toBe("0.2.13"); - // Original content is preserved below the new header. - expect(out).toContain("name: beatgeo"); - }); -}); - -// Regression: a legacy install (no header, :latest tags) must come out fully -// re-pinned AND with a detectable version header, so `beatgeo upgrade` doesn't -// treat it as "already current" and skip the re-pin on the next run. -describe("legacy install re-pin round-trip", () => { - it("pins images and records the version", () => { - const out = refreshHeaderVersion(repinImages(LEGACY_COMPOSE, "0.2.13"), "0.2.13"); - expect(out).not.toContain(":latest"); - expect(out).toContain("beatapi/beat-geo-web:0.2.13"); - expect(out).toContain("postgres:16-alpine"); - expect(parseRenderedVersion(out)).toBe("0.2.13"); - }); -}); diff --git a/apps/cli/src/compose-pin.ts b/apps/cli/src/compose-pin.ts deleted file mode 100644 index b1caf77d..00000000 --- a/apps/cli/src/compose-pin.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Pure helpers for the Docker image tags + version header that beatgeo writes into -// the generated compose / env files. Kept side-effect-free (no fs) so the -// upgrade re-pin path stays unit-testable; index.ts wraps these with file I/O. - -export function renderedByHeader(version: string): string { - return [ - `# Rendered by beatgeo ${version} on ${new Date().toISOString()}`, - "# Run `beatgeo upgrade` after upgrading the CLI to refresh this file.", - ].join("\n"); -} - -// Reads the version recorded in a `# Rendered by beatgeo on ...` header, -// or null when the file has no such header (e.g. a legacy, pre-header install). -export function parseRenderedVersion(contents: string): string | null { - const match = contents.match(/^# Rendered by beatgeo (\S+) on /m); - return match ? match[1] : null; -} - -// Refreshes the `# Rendered by beatgeo on ...` header, adding one at the -// top if the file doesn't have it yet (e.g. a legacy install rendered before -// the header existed) so future `beatgeo upgrade` runs can detect the version. -export function refreshHeaderVersion(contents: string, version: string): string { - if (!/^# Rendered by beatgeo \S+ on /m.test(contents)) { - return `${renderedByHeader(version)}\n${contents}`; - } - return contents.replace( - /^# Rendered by beatgeo \S+ on .*$/m, - `# Rendered by beatgeo ${version} on ${new Date().toISOString()}`, - ); -} - -// Re-pins `beatapi/beat-geo-*:` image tags to `version`, leaving third-party -// images untouched. Postgres especially: a major bump needs the cluster dumped -// and restored, so rolling the tag here would break a deployment rather than -// upgrade it. -export function repinImages(contents: string, version: string): string { - return contents.replace(/(image:\s*beatapi\/beat-geo-[a-z-]+):\S+/g, `$1:${version}`); -} diff --git a/apps/cli/src/compose.ts b/apps/cli/src/compose.ts deleted file mode 100644 index b3577165..00000000 --- a/apps/cli/src/compose.ts +++ /dev/null @@ -1,219 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { refreshHeaderVersion, renderedByHeader, repinImages } from "./compose-pin.js"; -import type { PostgresMode } from "./config.js"; -import { indentBlock } from "./util.js"; - -// Rewrites `beatapi/beat-geo-*:` image tags in place, preserving any manual -// edits the user made to the compose file, then refreshes the version header. -export async function repinComposeImages(composePath: string, version: string): Promise { - const contents = await fs.readFile(composePath, "utf8"); - await fs.writeFile(composePath, refreshHeaderVersion(repinImages(contents, version), version), "utf8"); -} - -export async function refreshRenderedVersion(filePath: string, version: string): Promise { - try { - const contents = await fs.readFile(filePath, "utf8"); - await fs.writeFile(filePath, refreshHeaderVersion(contents, version), "utf8"); - } catch { - // File is optional (e.g. .env may be absent in some setups). - } -} - -export function buildComposeYaml(options: { - dev: boolean; - postgresMode: PostgresMode; - repoRoot: string; - dockerDir?: string; - port: number; - version: string; -}): string { - const services: string[] = []; - const volumes = new Set(); - - const dependsOnWeb: string[] = []; - const dependsOnWorker: string[] = []; - - const dependencyConditions: Record = { - postgres: "service_healthy", - "db-migrate": "service_completed_successfully", - }; - - const dockerfilePath = options.dockerDir - ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile")) - : "docker/Dockerfile"; - - if (options.postgresMode === "docker") { - services.push(buildPostgresService()); - services.push( - buildDbMigrateService({ - dev: options.dev, - dockerfilePath, - repoRoot: options.repoRoot, - version: options.version, - }), - ); - dependsOnWeb.push("db-migrate"); - dependsOnWorker.push("db-migrate"); - volumes.add("postgres_data"); - } - - services.push( - buildWebService({ - dev: options.dev, - dependsOn: dependsOnWeb, - dependencyConditions, - repoRoot: options.repoRoot, - dockerfilePath, - port: options.port, - version: options.version, - }), - ); - services.push( - buildWorkerService({ - dev: options.dev, - dependsOn: dependsOnWorker, - dependencyConditions, - repoRoot: options.repoRoot, - dockerfilePath, - version: options.version, - }), - ); - - const lines = [renderedByHeader(options.version), "", "name: beatgeo", "", "services:"]; - lines.push(...services.map((service) => indentBlock(service, 2))); - - if (volumes.size > 0) { - lines.push("", "volumes:"); - for (const volume of volumes) { - lines.push(` ${volume}:`); - } - } - - return `${lines.join("\n")}\n`; -} - -function buildPostgresService(): string { - return [ - "postgres:", - " image: postgres:18-alpine", - " environment:", - " POSTGRES_USER: postgres", - " POSTGRES_PASSWORD: postgres", - " POSTGRES_DB: beatgeo", - " volumes:", - // PostgreSQL 18 puts PGDATA in a version-specific child directory and - // declares its volume at this parent. Mounting the child path can silently - // place the cluster in an anonymous volume that disappears on recreation. - " - postgres_data:/var/lib/postgresql", - " ports:", - ' - "5432:5432"', - " healthcheck:", - ' test: ["CMD-SHELL", "pg_isready -U postgres"]', - " interval: 5s", - " timeout: 5s", - " retries: 5", - " start_period: 30s", - ].join("\n"); -} - -function buildDbMigrateService(options: { - dev: boolean; - dockerfilePath: string; - repoRoot: string; - version: string; -}): string { - const lines = ["db-migrate:"]; - if (options.dev) { - lines.push( - " build:", - ` context: ${options.repoRoot}`, - ` dockerfile: ${options.dockerfilePath}`, - " target: migrate", - ); - } else { - lines.push(` image: beatapi/beat-geo-db-migrate:${options.version}`); - } - - lines.push( - " environment:", - " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/beatgeo", - " depends_on:", - " postgres:", - " condition: service_healthy", - ); - - return lines.join("\n"); -} - -function buildWebService(options: { - dev: boolean; - dependsOn: string[]; - dependencyConditions: Record; - repoRoot: string; - dockerfilePath: string; - port: number; - version: string; -}): string { - const lines = ["web:"]; - if (options.dev) { - lines.push( - " build:", - ` context: ${options.repoRoot}`, - ` dockerfile: ${options.dockerfilePath}`, - " target: web", - ); - } else { - lines.push(` image: beatapi/beat-geo-web:${options.version}`); - } - - lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`); - - if (options.dependsOn.length > 0) { - lines.push(" depends_on:"); - for (const service of options.dependsOn) { - const condition = options.dependencyConditions[service] ?? "service_started"; - lines.push(` ${service}:`, ` condition: ${condition}`); - } - } - - return lines.join("\n"); -} - -function buildWorkerService(options: { - dev: boolean; - dependsOn: string[]; - dependencyConditions: Record; - repoRoot: string; - dockerfilePath: string; - version: string; -}): string { - const lines = ["worker:"]; - if (options.dev) { - lines.push( - " build:", - ` context: ${options.repoRoot}`, - ` dockerfile: ${options.dockerfilePath}`, - " target: worker", - ); - } else { - lines.push(` image: beatapi/beat-geo-worker:${options.version}`); - } - - lines.push(" env_file:", " - path: .env", " required: true"); - - // On SIGTERM the worker gives pg-boss 30s to finish in-flight jobs, then - // flushes telemetry. Compose's 10s default would SIGKILL it partway through - // an evaluation, burning the provider call that job already paid for. - lines.push(" stop_grace_period: 35s"); - - if (options.dependsOn.length > 0) { - lines.push(" depends_on:"); - for (const service of options.dependsOn) { - const condition = options.dependencyConditions[service] ?? "service_started"; - lines.push(` ${service}:`, ` condition: ${condition}`); - } - } - - return lines.join("\n"); -} diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index f715d78d..7956dc9b 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,92 +1 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { parseRenderedVersion, renderedByHeader } from "./compose-pin.js"; - -export type PostgresMode = "docker" | "external"; - export type EnvMap = Record; - -export type DirOption = { - dir?: string; -}; - -export const CONFIG_HOME = path.join(os.homedir(), ".beatgeo"); - -export async function resolveConfigDir(explicitDir?: string): Promise { - const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME; - const composePath = path.join(resolved, "beatgeo.yaml"); - if (!(await fileExists(composePath))) { - if (explicitDir) { - throw new Error( - `Config directory does not contain beatgeo.yaml: ${resolved}\nRun \`beatgeo init --dir ${explicitDir}\` to create it.`, - ); - } - throw new Error(`No config found at ${resolved}. Run \`beatgeo init\` to create one, or specify --dir.`); - } - return resolved; -} - -export async function writeConfigFiles( - configDir: string, - initConfig: { - env: EnvMap; - composeYaml: string; - postgresMode: PostgresMode; - dev: boolean; - version: string; - }, -): Promise { - const envPath = path.join(configDir, ".env"); - const composePath = path.join(configDir, "beatgeo.yaml"); - - await ensureDir(configDir); - await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8"); - await fs.writeFile(composePath, initConfig.composeYaml, "utf8"); -} - -export function buildEnvFile(env: EnvMap, version: string): string { - const lines = [renderedByHeader(version), "# WARNING: contains secrets. Do not commit.", ""]; - - for (const [key, rawValue] of Object.entries(env)) { - if (rawValue === undefined) { - continue; - } - lines.push(`${key}=${formatEnvValue(rawValue)}`); - } - - return `${lines.join("\n")}\n`; -} - -function formatEnvValue(value: string): string { - if (value === "") { - return '""'; - } - if (/[\s#"']/u.test(value)) { - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; - } - return value; -} - -// Reads the version recorded in a `# Rendered by beatgeo on ...` header. -export async function readRenderedVersion(filePath: string): Promise { - try { - return parseRenderedVersion(await fs.readFile(filePath, "utf8")); - } catch { - return null; - } -} - -export async function fileExists(target: string): Promise { - try { - await fs.access(target); - return true; - } catch { - return false; - } -} - -export async function ensureDir(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }); -} diff --git a/apps/cli/src/docker.ts b/apps/cli/src/docker.ts deleted file mode 100644 index cbd9a744..00000000 --- a/apps/cli/src/docker.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { log, sleep } from "./util.js"; - -type ComposeService = { - Service: string; - State: string; - Health?: string; - ExitCode?: number; -}; - -async function getComposeServices(configDir: string): Promise { - const output = await runDockerComposeCapture(configDir, ["ps", "--format", "json"]); - if (!output.trim()) { - return []; - } - try { - const trimmed = output.trim(); - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed as ComposeService[]; - } - if (typeof parsed === "object" && parsed !== null) { - return [parsed as ComposeService]; - } - return []; - } catch { - try { - return output - .trim() - .split("\n") - .filter((line) => line.trim()) - .map((line) => JSON.parse(line) as ComposeService); - } catch { - log.warn("Unable to parse docker compose status."); - return []; - } - } -} - -function isServiceReady(service: ComposeService): boolean { - if (service.Health) { - return service.Health === "healthy"; - } - if (service.State?.startsWith("running")) { - return true; - } - return false; -} - -export async function waitForHealthy(configDir: string, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const services = await getComposeServices(configDir); - if (services.length > 0 && services.every(isServiceReady)) { - return true; - } - await sleep(3000); - } - return false; -} - -export async function stackHasRunningServices(configDir: string): Promise { - try { - const services = await getComposeServices(configDir); - return services.some((s) => s.State?.startsWith("running") ?? false); - } catch { - return false; - } -} - -export async function composeUsesBuild(composePath: string): Promise { - try { - const contents = await fs.readFile(composePath, "utf8"); - return /^\s*build:/m.test(contents); - } catch { - return false; - } -} - -export function runDockerCompose(configDir: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const composeFile = path.join(configDir, "beatgeo.yaml"); - const commandArgs = ["compose", "-f", composeFile, ...args]; - const child = spawn("docker", commandArgs, { - stdio: "inherit", - }); - child.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`docker compose exited with code ${code}`)); - } - }); - }); -} - -function runDockerComposeCapture(configDir: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const composeFile = path.join(configDir, "beatgeo.yaml"); - const commandArgs = ["compose", "-f", composeFile, ...args]; - const child = spawn("docker", commandArgs); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - child.on("close", (code) => { - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(stderr || `docker compose exited with code ${code}`)); - } - }); - }); -} - -export function assertDockerRunning(): void { - const result = spawnSync("docker", ["info"], { - stdio: "ignore", - }); - if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { - throw new Error( - "Docker does not appear to be installed. Install Docker Desktop or Docker Engine and try again: https://docs.docker.com/get-docker/", - ); - } - if (result.error) { - throw new Error(`Could not run Docker: ${result.error.message}`); - } - if (result.status !== 0) { - throw new Error("Docker does not appear to be running. Start Docker and try again."); - } -} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fb3c3160..9558a8e8 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,73 +1,66 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { Command } from "commander"; -import pc from "picocolors"; -import { runCompose } from "./commands/compose.js"; -import { runEdit } from "./commands/edit.js"; -import { type InitOptions, runInit } from "./commands/init.js"; -import { runUpgrade, type UpgradeOptions } from "./commands/upgrade.js"; -import type { DirOption } from "./config.js"; -import { printBanner } from "./util.js"; -import { getPackageVersion, maybeNotifyNewVersion } from "./version.js"; +import { getPackageVersion } from "./version.js"; + +function workspaceRoot(explicitDir?: string): string { + let candidate = explicitDir ? path.resolve(explicitDir) : path.dirname(fileURLToPath(import.meta.url)); + while (!existsSync(path.join(candidate, "scripts/setup-local.mjs"))) { + const parent = path.dirname(candidate); + if (explicitDir || parent === candidate) + throw new Error("Run this CLI from the BeatGEO source checkout, or pass --dir with its path."); + candidate = parent; + } + return candidate; +} + +function runLocal(action: string, options: { dir?: string }, args: string[] = []): void { + const root = workspaceRoot(options.dir); + const script = action === "setup" ? "setup-local.mjs" : action === "start" ? "start-local.mjs" : "local-doctor.mjs"; + const result = spawnSync(process.execPath, [path.join(root, "scripts", script), ...args], { + cwd: root, + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} async function main() { - const version = await getPackageVersion(); const program = new Command(); - program .name("beatgeo") - .version(version) - .option("--dir ", "Config directory") - .configureHelp({ showGlobalOptions: true }) - .action(() => { - printBanner(); - program.outputHelp(); - }); - + .version(await getPackageVersion()) + .description("Run the local BeatGEO workspace with bundled storage") + .option("--dir ", "BeatGEO source checkout") + .configureHelp({ showGlobalOptions: true }); program .command("init") - .description("set up local BeatGEO instance") - .option("--dev", "Use local build context (repo only)") - .option("--docker-dir ", "Path to Docker build context (dev mode)") - .action(async (_opts: object, cmd: Command) => { - await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version)); - }); - - program - .command("compose") - .description("run Docker Compose commands using your BeatGEO config") - .allowUnknownOption(true) - .argument("[args...]", "Arguments passed to Docker Compose") - .action(async (args: string[], _opts: object, cmd: Command) => { - await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals())); + .alias("setup") + .description("prepare local storage and the browser extension") + .option("--resume", "resume an interrupted local setup") + .action((_options: object, command: Command) => { + const options = command.optsWithGlobals<{ dir?: string; resume?: boolean }>(); + runLocal("setup", options, options.resume ? ["--resume"] : []); }); - program - .command("edit") - .description("change API keys, scrape targets, or the Docker Compose YAML") - .argument("", "which config file to edit") - .action(async (target: string, _opts: object, cmd: Command) => { - await runEdit(target, cmd.optsWithGlobals()); + .command("start") + .description("start the local web app, collector and worker") + .argument("[env-file]", "Local environment file") + .action((envFile: string | undefined, _options: object, command: Command) => { + runLocal("start", command.optsWithGlobals(), envFile ? [path.resolve(envFile)] : []); }); - program - .command("upgrade") - .description("upgrade your BeatGEO deployment to this CLI's version") - .option("--yes", "skip confirmation prompts") - .action(async (_opts: object, cmd: Command) => { - await runUpgrade(cmd.optsWithGlobals(), version); - }); - + .command("doctor") + .description("check local prerequisites without changing data") + .action((_options: object, command: Command) => runLocal("doctor", command.optsWithGlobals())); await program.parseAsync(process.argv); } -async function withVersionCheck(version: string, fn: () => Promise): Promise { - const notifyPromise = maybeNotifyNewVersion(version); - await fn(); - await notifyPromise.catch(() => undefined); -} - main().catch((error) => { - const msg = error instanceof Error ? error.message : String(error); - console.error(`\n${pc.red("Error:")} ${msg}`); - process.exit(1); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; }); diff --git a/apps/cli/src/local.test.ts b/apps/cli/src/local.test.ts new file mode 100644 index 00000000..2297b1eb --- /dev/null +++ b/apps/cli/src/local.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +const entry = fileURLToPath(new URL("./index.ts", import.meta.url)); +function cli(args: string[]) { + return spawnSync(process.execPath, ["--import", "tsx", entry, ...args], { + encoding: "utf8", + timeout: 15000, + }); +} + +test("the source CLI presents only bundled local operations", () => { + const result = cli(["--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("init|setup"); + expect(result.stdout).toContain("start"); + expect(result.stdout).toContain("doctor"); + expect(result.stdout).not.toMatch(/compose|docker|upgrade/i); +}); + +test("obsolete container commands fail before running any setup", () => { + for (const args of [ + ["compose", "up"], + ["init", "--docker-dir", "."], + ["init", "--dev"], + ]) { + const result = cli(args); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/unknown command|unknown option/); + } +}); + +test("local commands use the selected workspace and preserve resume arguments", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "beatgeo-cli-")); + try { + await mkdir(path.join(dir, "scripts")); + for (const name of ["setup-local", "start-local", "local-doctor"]) + await writeFile( + path.join(dir, "scripts", `${name}.mjs`), + `console.log(JSON.stringify({ action: ${JSON.stringify(name)}, cwd: process.cwd(), args: process.argv.slice(2) }));`, + ); + for (const [args, action, forwarded] of [ + [["init", "--resume"], "setup-local", ["--resume"]], + [["start"], "start-local", []], + [["doctor"], "local-doctor", []], + ] as const) { + const result = cli(["--dir", dir, ...args]); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + action, + cwd: await import("node:fs/promises").then((fs) => fs.realpath(dir)), + args: forwarded, + }); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/cli/src/migrations/index.ts b/apps/cli/src/migrations/index.ts index c220cd9d..11c2748a 100644 --- a/apps/cli/src/migrations/index.ts +++ b/apps/cli/src/migrations/index.ts @@ -5,7 +5,7 @@ export * from "./planner.js"; export * from "./types.js"; // Register migrations here when a release needs to change config/env on disk. -// Most releases need NO entry — docker images roll automatically on `elmo upgrade`. +// These inherited configuration migrations are retained for existing deployment data. // // Each entry runs once when a user upgrades through its `from` version. Keep the // `run` function pure over the passed-in context so it stays unit-testable. diff --git a/apps/web/package.json b/apps/web/package.json index 46fec075..6c0b4479 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite dev --port 3000", "build": "vite build && node ../../scripts/check-server-bundle.mjs .", - "start": "node .output/server/index.mjs", + "start": "node scripts/start.mjs", "preview": "vite preview", "test": "vitest run --project=unit", "test:integration": "vitest run --project=integration", diff --git a/apps/web/scripts/start.mjs b/apps/web/scripts/start.mjs new file mode 100644 index 00000000..8882bf06 --- /dev/null +++ b/apps/web/scripts/start.mjs @@ -0,0 +1,7 @@ +// The account-free local workspace is never a network-facing service. +if ((!process.env.DEPLOYMENT_MODE || process.env.DEPLOYMENT_MODE === "local") && process.env.READ_ONLY !== "true") { + process.env.NITRO_HOST = "127.0.0.1"; + process.env.HOST = "127.0.0.1"; + process.env.NITRO_PORT = new URL(process.env.APP_URL || "http://localhost:3140").port || "3140"; +} +await import("../.output/server/index.mjs"); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 56a176ce..6b6fb9bb 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -50,7 +50,16 @@ function organizationGroups( ): NavGroup[] { const params = orgLinkParams(organization); const items: NavItem[] = [ - { title: "Organization", link: { to: "/app/org/$org/settings", params }, icon: IconBriefcase, exact: true }, + ...(mode === "local" + ? [] + : [ + { + title: "Organization", + link: { to: "/app/org/$org/settings" as const, params }, + icon: IconBriefcase, + exact: true, + }, + ]), { title: "Brands", link: { to: "/app/org/$org/settings/brands", params }, icon: IconBuildings }, ]; @@ -75,7 +84,7 @@ function organizationGroups( } return [ - { label: "Organization Settings", items }, + { label: mode === "local" ? "Workspace" : "Organization Settings", items }, { label: "Docs", items: docs }, ]; } @@ -134,9 +143,13 @@ export function AppSidebar({ section, organization, brand }: AppSidebarProps) { // A gate page offers no destinations: every link would either 404 or bounce // the user straight back to the gate. - const adminItems = section === "account" ? [] : adminNavItems({ isAdmin, hasReportAccess, reportsEnabled }); + const adminItems = + mode === "local" || section === "account" ? [] : adminNavItems({ isAdmin, hasReportAccess, reportsEnabled }); const groups: NavGroup[] = [ + ...(mode === "local" + ? [{ label: "Workspace", items: [{ title: "Brands", link: { to: "/app" as const }, icon: IconBuildings }] }] + : []), ...(context.canManageConnections && mode === "local" ? [ { @@ -179,7 +192,9 @@ export function AppSidebar({ section, organization, brand }: AppSidebarProps) { - + {mode !== "local" && ( + + )} diff --git a/apps/web/src/lib/__tests__/entry-route.test.ts b/apps/web/src/lib/__tests__/entry-route.test.ts index 06953687..8eb947d2 100644 --- a/apps/web/src/lib/__tests__/entry-route.test.ts +++ b/apps/web/src/lib/__tests__/entry-route.test.ts @@ -7,9 +7,9 @@ describe("entryRouteForVisitor", () => { expect(entryRouteForVisitor({ mode: "cloud", canRegister: true, hasUsers: true })).toBe("/auth/register"); }); - it("opens an unbootstrapped local instance on sign-up and a bootstrapped one on sign-in", () => { - expect(entryRouteForVisitor({ mode: "local", canRegister: true, hasUsers: false })).toBe("/auth/register"); - expect(entryRouteForVisitor({ mode: "local", canRegister: false, hasUsers: true })).toBe("/auth/login"); + it("opens every local instance directly in the workspace", () => { + expect(entryRouteForVisitor({ mode: "local", canRegister: true, hasUsers: false })).toBe("/app"); + expect(entryRouteForVisitor({ mode: "local", canRegister: false, hasUsers: true })).toBe("/app"); }); it("opens demo on sign-in", () => { diff --git a/apps/web/src/lib/__tests__/security-headers.test.ts b/apps/web/src/lib/__tests__/security-headers.test.ts new file mode 100644 index 00000000..1ab8cc51 --- /dev/null +++ b/apps/web/src/lib/__tests__/security-headers.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest"; +import { addSecurityHeaders } from "../security-headers"; + +it("preserves an immutable redirect while adding browser security headers", () => { + const response = addSecurityHeaders(Response.redirect("http://localhost:3140/app", 302)); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("http://localhost:3140/app"); + expect(response.headers.get("x-frame-options")).toBe("DENY"); +}); +it("preserves session cookies and a streamed response body", async () => { + const input = new Response("workspace"); + input.headers.append("set-cookie", "session=one; HttpOnly; SameSite=Strict"); + input.headers.append("set-cookie", "cache=two; HttpOnly"); + const response = addSecurityHeaders(input); + expect(response.headers.getSetCookie()).toHaveLength(2); + expect(await response.text()).toBe("workspace"); +}); diff --git a/apps/web/src/lib/auth/__tests__/local-request.test.ts b/apps/web/src/lib/auth/__tests__/local-request.test.ts new file mode 100644 index 00000000..e20e4b0b --- /dev/null +++ b/apps/web/src/lib/auth/__tests__/local-request.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleLocalWorkspaceRequest, localRequestRefusal } from "../local-request"; + +const APP = "http://localhost:3140"; +function request(path = "/", headers: Record = {}, method = "GET") { + return new Request(APP + path, { method, headers }); +} +const navigation = { + accept: "text/html", + "sec-fetch-site": "none", + "sec-fetch-mode": "navigate", + "sec-fetch-dest": "document", +}; +function options() { + return { + appUrl: APP, + getSession: vi.fn(async (_headers: Headers): Promise => null), + bootstrap: vi.fn( + async () => new Response("{}", { headers: { "set-cookie": "session=fresh; HttpOnly; SameSite=Strict; Path=/" } }), + ), + next: vi.fn(async (_request: Request) => new Response("workspace")), + }; +} + +const untrustedHeaders: Record[] = [ + { origin: "https://attacker.example" }, + { origin: "null" }, + { "sec-fetch-site": "cross-site" }, + { "sec-fetch-site": "same-site" }, + { host: "attacker.example:3140" }, + { "x-forwarded-host": "localhost:3140" }, + { forwarded: "host=localhost:3140" }, + { "x-forwarded-for": "127.0.0.1" }, +]; + +describe("local workspace request protection", () => { + it.each(untrustedHeaders)("rejects an untrusted request even with a browser session: %j", async (headers) => { + const deps = options(); + deps.getSession.mockResolvedValue({ user: { id: "owner" } }); + const response = await handleLocalWorkspaceRequest(request("/", headers), deps); + expect(response.status).toBe(403); + expect(deps.bootstrap).not.toHaveBeenCalled(); + expect(deps.next).not.toHaveBeenCalled(); + }); + it.each(["http://attacker.example:3140", "http://0.0.0.0:3140", "http://localhost:3141", "https://localhost:3140"])( + "rejects a different authority: %s", + (url) => { + expect(localRequestRefusal(new Request(url), APP)?.status).toBe(403); + }, + ); + it("rejects a public APP_URL instead of enabling anonymous public access", () => { + expect(localRequestRefusal(request(), "https://beatgeo.example")?.status).toBe(403); + }); + it("opens a first direct browser visit and uses the real issued cookie for SSR", async () => { + const deps = options(); + const response = await handleLocalWorkspaceRequest(request("/app", navigation), deps); + expect(response.status).toBe(200); + expect(await response.text()).toBe("workspace"); + expect(response.headers.get("set-cookie")).toContain("HttpOnly"); + expect(deps.next.mock.calls[0][0].headers.get("cookie")).toBe("session=fresh"); + }); + it("reuses a valid browser session without issuing a new one", async () => { + const deps = options(); + deps.getSession.mockResolvedValue({ user: { id: "owner" } }); + expect( + (await handleLocalWorkspaceRequest(request("/app", { ...navigation, cookie: "session=existing" }), deps)).status, + ).toBe(200); + expect(deps.bootstrap).not.toHaveBeenCalled(); + }); + it.each([request(), request("/_server/action", { origin: APP }, "POST"), request("/api/private")])( + "does not bootstrap an API call or a request missing navigation metadata", + async (input) => { + const deps = options(); + expect((await handleLocalWorkspaceRequest(input, deps)).status).toBe(401); + expect(deps.bootstrap).not.toHaveBeenCalled(); + }, + ); + it("rejects cookie-only writes missing browser origin information", async () => { + const deps = options(); + deps.getSession.mockResolvedValue({ user: { id: "owner" } }); + expect((await handleLocalWorkspaceRequest(request("/_server/action", {}, "POST"), deps)).status).toBe(403); + }); + it("leaves MCP and REST authentication to their scoped bearer gates", async () => { + for (const path of ["/api/mcp", "/api/v1/brands"]) { + const deps = options(); + deps.next.mockResolvedValue(new Response("unauthorized", { status: 401 })); + expect((await handleLocalWorkspaceRequest(request(path, {}, "POST"), deps)).status).toBe(401); + expect(deps.bootstrap).not.toHaveBeenCalled(); + expect(deps.getSession).not.toHaveBeenCalled(); + } + }); + it.each([ + "/api/auth/sign-up/email", + "/api/auth/sign-in/email", + "/api/auth/change-password", + "/api/auth/organization/invite-member", + "/api/auth/local-workspace", + ])("disables direct account operations: %s", async (path) => { + const deps = options(); + expect((await handleLocalWorkspaceRequest(request(path, { origin: APP }, "POST"), deps)).status).toBe(404); + expect(deps.next).not.toHaveBeenCalled(); + }); + it.each([ + "/auth/login", + "/auth/register", + "/auth/forgot-password", + "/auth/reset-password", + "/accept-invitation/example", + ])("routes obsolete account pages back to the workspace: %s", async (path) => { + const response = await handleLocalWorkspaceRequest(request(path, navigation), options()); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`${APP}/app`); + }); + it("fails closed when the session cannot be established", async () => { + const deps = options(); + deps.bootstrap.mockResolvedValue(new Response("error", { status: 500 })); + expect((await handleLocalWorkspaceRequest(request("/app", navigation), deps)).status).toBe(503); + expect(deps.next).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/auth/local-request.ts b/apps/web/src/lib/auth/local-request.ts new file mode 100644 index 00000000..e6602fb1 --- /dev/null +++ b/apps/web/src/lib/auth/local-request.ts @@ -0,0 +1,113 @@ +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); +const ACCOUNT_PAGES = new Set([ + "/auth/login", + "/auth/register", + "/auth/forgot-password", + "/auth/reset-password", + "/auth/logout", +]); +const AUTH_READS = new Set(["/api/auth/get-session", "/api/auth/jwks"]); + +function refusal(message: string, status = 403): Response { + return Response.json({ error: message }, { status, headers: { "Cache-Control": "no-store" } }); +} + +export function localRequestRefusal(request: Request, appUrl: string): Response | null { + const configured = new URL(appUrl); + const url = new URL(request.url); + if (!LOOPBACK.has(configured.hostname) || !["http:", "https:"].includes(configured.protocol)) + return refusal("The local workspace must use a loopback APP_URL."); + if (!LOOPBACK.has(url.hostname) || url.port !== configured.port || url.protocol !== configured.protocol) + return refusal("Local workspace requests must use this computer's configured address."); + const host = request.headers.get("host"); + if (host && host !== url.host) return refusal("Invalid local host."); + if (["forwarded", "x-forwarded-host", "x-forwarded-for"].some((header) => request.headers.has(header))) + return refusal("The local workspace does not accept proxy requests."); + const origin = request.headers.get("origin"); + if (origin && origin !== url.origin) return refusal("Cross-origin access to the local workspace is disabled."); + const site = request.headers.get("sec-fetch-site"); + if (site && !["same-origin", "none"].includes(site)) + return refusal("Cross-site access to the local workspace is disabled."); + return null; +} + +function isBrowserNavigation(request: Request): boolean { + return ( + request.method === "GET" && + request.headers.get("accept")?.includes("text/html") === true && + request.headers.get("sec-fetch-mode") === "navigate" && + request.headers.get("sec-fetch-dest") === "document" && + ["none", "same-origin"].includes(request.headers.get("sec-fetch-site") ?? "") + ); +} + +interface LocalRequestOptions { + appUrl: string; + getSession: (headers: Headers) => Promise; + bootstrap: (headers: Headers) => Promise; + next: (request: Request) => Promise | Response; +} + +function localAccountResponse(request: Request, pathname: string): Response | null { + const sessionRead = request.method === "GET" && AUTH_READS.has(pathname); + if (pathname.startsWith("/api/auth/") && !sessionRead && !pathname.startsWith("/api/auth/oauth2/")) + return refusal("Account management is not available in the local workspace.", 404); + if (ACCOUNT_PAGES.has(pathname) || pathname.startsWith("/accept-invitation/") || pathname === "/choose-plan") + return Response.redirect(new URL("/app", request.url), 302); + return null; +} + +function usesOwnAuthentication(request: Request, pathname: string): boolean { + if (pathname === "/api/mcp" || ["/api/mcp/", "/api/v1/"].some((prefix) => pathname.startsWith(prefix))) return true; + if (request.method === "GET" && (pathname.startsWith("/.well-known/") || pathname === "/api/setup-status")) + return true; + return ( + pathname.startsWith("/api/auth/oauth2/") && !request.headers.has("origin") && !request.headers.has("sec-fetch-site") + ); +} + +function isUntrustedWrite(request: Request): boolean { + return ( + !["GET", "HEAD"].includes(request.method) && + request.headers.get("origin") !== new URL(request.url).origin && + request.headers.get("sec-fetch-site") !== "same-origin" + ); +} + +/** All local UI requests cross the same gate, including server functions and raw routes. */ +export async function handleLocalWorkspaceRequest(request: Request, options: LocalRequestOptions): Promise { + const blocked = localRequestRefusal(request, options.appUrl); + if (blocked) return blocked; + let pathname: string; + try { + pathname = decodeURIComponent(new URL(request.url).pathname).replace(/\/+$/, "") || "/"; + } catch { + return refusal("Invalid local path."); + } + const accountResponse = localAccountResponse(request, pathname); + if (accountResponse) return accountResponse; + if (usesOwnAuthentication(request, pathname)) return options.next(request); + if (isUntrustedWrite(request)) return refusal("Local writes require a same-origin browser request."); + if (request.headers.has("cookie") && (await options.getSession(request.headers))) return options.next(request); + if (!isBrowserNavigation(request) || pathname.startsWith("/api/") || pathname.startsWith("/_server")) + return refusal("Open the local workspace in your browser to continue.", 401); + const bootstrap = await options.bootstrap(request.headers); + if (!bootstrap.ok) + return refusal("The local workspace could not be opened. Check its database and configuration.", 503); + const cookies = bootstrap.headers.getSetCookie(); + if (!cookies.length) return refusal("The local workspace could not establish a browser session.", 503); + const headers = new Headers(request.headers); + headers.set( + "cookie", + [...cookies.map((cookie) => cookie.split(";", 1)[0]), headers.get("cookie")].filter(Boolean).join("; "), + ); + const response = await options.next(new Request(request, { headers })); + const responseHeaders = new Headers(response.headers); + for (const cookie of cookies) responseHeaders.append("set-cookie", cookie); + responseHeaders.set("cache-control", "no-store"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts index b0d65a85..546982ff 100644 --- a/apps/web/src/lib/auth/server.ts +++ b/apps/web/src/lib/auth/server.ts @@ -9,33 +9,15 @@ */ import { getCloudAuthOptions } from "@workspace/deployment/auth-hooks/cloud"; import { getWhitelabelAuthOptions } from "@workspace/deployment/auth-hooks/whitelabel"; +import { localWorkspacePlugin } from "@workspace/lib/auth/local-workspace"; import { type CreateAuthOptions, createAuth } from "@workspace/lib/auth/server"; -import { countUsers, provisionLocalOrg } from "@workspace/lib/db/provisioning"; +import { ensureLocalWorkspace } from "@workspace/lib/db/local-workspace"; -/** - * Local mode hooks: enforce "exactly one user, with an admin org created - * atomically on signup". The `before` hook rejects any signup once a user - * exists; the `after` hook creates the organization and membership. - * - * Also applies to direct POST /api/auth/sign-up/email calls — the hooks - * fire regardless of whether signup is triggered from our UI or a curl. - */ function getLocalAuthOptions(): CreateAuthOptions { return { - databaseHooks: { - user: { - create: { - before: async () => { - if ((await countUsers()) > 0) { - throw new Error("This instance is already bootstrapped. Sign in with the existing account instead."); - } - }, - after: async (user) => { - await provisionLocalOrg({ userId: user.id }); - }, - }, - }, - }, + emailAndPasswordEnabled: false, + disableSignUp: true, + extraPlugins: [localWorkspacePlugin(ensureLocalWorkspace)], }; } @@ -54,7 +36,7 @@ function getDeploymentAuthOptions(): CreateAuthOptions | undefined { return getCloudAuthOptions(); } default: - return getLocalAuthOptions(); + return process.env.READ_ONLY === "true" ? { disableSignUp: true } : getLocalAuthOptions(); } } diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts index 388a0fbe..5c4b9cef 100644 --- a/apps/web/src/lib/breadcrumbs.ts +++ b/apps/web/src/lib/breadcrumbs.ts @@ -1,4 +1,5 @@ import { useMatches } from "@tanstack/react-router"; +import { useDeploymentMode } from "@/hooks/use-deployment-features"; import { BRAND_ROUTE_ID, ORG_ROUTE_ID, routeSubjects } from "@/lib/route-subject"; declare module "@tanstack/react-router" { @@ -15,10 +16,12 @@ export interface Crumb { export function useBreadcrumbs(): Crumb[] { const matches = useMatches(); + const local = useDeploymentMode() === "local"; const subjects = routeSubjects(matches); return matches.flatMap((match): Crumb[] => { if (match.routeId === ORG_ROUTE_ID) { + if (local) return []; const label = subjects.organization?.name; return label ? [{ label, href: match.pathname, kind: "Organization" }] : []; } diff --git a/apps/web/src/lib/entry-route.ts b/apps/web/src/lib/entry-route.ts index 9f5ab677..4e151445 100644 --- a/apps/web/src/lib/entry-route.ts +++ b/apps/web/src/lib/entry-route.ts @@ -1,6 +1,6 @@ import type { DeploymentMode } from "@workspace/config/types"; -export type EntryRoute = "/auth/login" | "/auth/register"; +export type EntryRoute = "/auth/login" | "/auth/register" | "/app"; interface EntryConfig { mode: DeploymentMode; @@ -10,6 +10,7 @@ interface EntryConfig { export function entryRouteForVisitor(config: EntryConfig | undefined): EntryRoute | null { if (!config || config.mode === "whitelabel") return null; + if (config.mode === "local") return "/app"; if (config.canRegister && (config.mode === "cloud" || !config.hasUsers)) return "/auth/register"; return "/auth/login"; } diff --git a/apps/web/src/lib/i18n/zh.json b/apps/web/src/lib/i18n/zh.json index 1990b33e..aafdd4a6 100644 --- a/apps/web/src/lib/i18n/zh.json +++ b/apps/web/src/lib/i18n/zh.json @@ -1018,5 +1018,10 @@ "Couldn't load query fan-out right now. Reload the page to try again.": "衍生搜索加载失败,请刷新重试。", "No runs with web search enabled for the selected filters. Fan-out appears once your prompts have been run by an engine with web search.": "暂无联网搜索记录,完成支持网页搜索的采集后将显示在这里。", "No web queries in this period — the engines you track didn't expose any searches for these prompts and filters.": "当前时间范围内,采集平台没有返回可查看的搜索词。", - "Search responses...": "搜索回答…" + "Search responses...": "搜索回答…", + "Workspace": "工作台", + "Your websites": "你的网站", + "Open a website to view its questions, answers and progress.": "打开网站,查看问题、回答和监测进度。", + "Add a website": "添加网站", + "Add your website to start monitoring AI answers.": "添加网站,开始监测 AI 回答。" } diff --git a/apps/web/src/lib/security-headers.ts b/apps/web/src/lib/security-headers.ts new file mode 100644 index 00000000..24f8c4d0 --- /dev/null +++ b/apps/web/src/lib/security-headers.ts @@ -0,0 +1,42 @@ +import { AD_TAG_CSP } from "@workspace/ui/lib/ad-tags"; + +// HSTS asserts HTTPS-only for the host that served the response. Whitelabel +// deployments run on customer-controlled custom domains, where `includeSubDomains` +// would wrongly assert HTTPS across subdomains we don't own — so that directive +// is scoped to our own deployments. Browsers ignore HSTS received over plain +// HTTP, so it stays inert on localhost. +const strictTransportSecurity = + process.env.DEPLOYMENT_MODE === "whitelabel" ? "max-age=63072000" : "max-age=63072000; includeSubDomains"; + +const SECURITY_HEADERS: Record = { + "Content-Security-Policy": [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline' https://*.clarity.ms https://us.i.posthog.com https://*.crisp.chat ${AD_TAG_CSP.script}`, + `style-src 'self' 'unsafe-inline' https://*.crisp.chat ${AD_TAG_CSP.style}`, + "img-src 'self' data: https: https://c.bing.com", + `font-src 'self' data: https://*.crisp.chat ${AD_TAG_CSP.font}`, + "media-src 'self' https://*.crisp.chat", + // The chatbox runs its background work in a blob worker. + "worker-src 'self' blob: https://*.crisp.chat", + `frame-src 'self' https://*.crisp.chat ${AD_TAG_CSP.frame}`, + `connect-src 'self' https://us.i.posthog.com https://*.sentry.io https://*.clarity.ms https://c.bing.com https://*.crisp.chat wss://*.relay.crisp.chat wss://*.relay.rescue.crisp.chat ${AD_TAG_CSP.connect}`, + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + "Strict-Transport-Security": strictTransportSecurity, + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + // same-origin-allow-popups (not same-origin) keeps OAuth/SSO popups that rely on + // window.opener working while still isolating us from cross-origin openers. + "Cross-Origin-Opener-Policy": "same-origin-allow-popups", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", +}; + +export function addSecurityHeaders(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(SECURITY_HEADERS)) headers.set(key, value); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} diff --git a/apps/web/src/routes/_authed.tsx b/apps/web/src/routes/_authed.tsx index 915d3e5c..9b281790 100644 --- a/apps/web/src/routes/_authed.tsx +++ b/apps/web/src/routes/_authed.tsx @@ -47,7 +47,7 @@ function AuthedLayout() { useEffect(() => { const user = context.session?.user; - if (!user || identifiedRef.current === user.id) return; + if (context.clientConfig?.mode === "local" || !user || identifiedRef.current === user.id) return; identifiedRef.current = user.id; identifyUser(user.id, { diff --git a/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx b/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx index 8c5d2032..5828c7f8 100644 --- a/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx +++ b/apps/web/src/routes/_authed/accept-invitation/$invitationId.tsx @@ -6,7 +6,7 @@ * Better-auth requires the session email to match the invited email * (case-insensitively) and rejects expired or already-handled invitations. */ -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { Alert, AlertDescription } from "@workspace/ui/components/alert"; import { Button, buttonVariants } from "@workspace/ui/components/button"; import { useState } from "react"; @@ -18,6 +18,9 @@ import { useWriteErrorMessage } from "@/lib/write-errors"; import { acceptInvitationFn, getInvitationFn } from "@/server/team"; export const Route = createFileRoute("/_authed/accept-invitation/$invitationId")({ + beforeLoad: ({ context }) => { + if (context.clientConfig?.mode === "local") throw redirect({ to: "/app" }); + }, loader: async ({ params }) => { try { const invitation = await getInvitationFn({ data: { invitationId: params.invitationId } }); diff --git a/apps/web/src/routes/_authed/admin.tsx b/apps/web/src/routes/_authed/admin.tsx index a4021b78..2cd4fb12 100644 --- a/apps/web/src/routes/_authed/admin.tsx +++ b/apps/web/src/routes/_authed/admin.tsx @@ -8,7 +8,7 @@ import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"; export const Route = createFileRoute("/_authed/admin")({ staticData: { crumb: "Admin", shell: "admin" }, beforeLoad: ({ context }) => { - if (!context.isAdmin) throw notFound(); + if (context.clientConfig?.mode === "local" || !context.isAdmin) throw notFound(); }, component: () => , }); diff --git a/apps/web/src/routes/_authed/app/index.tsx b/apps/web/src/routes/_authed/app/index.tsx index cb9127d4..5b2d2c23 100644 --- a/apps/web/src/routes/_authed/app/index.tsx +++ b/apps/web/src/routes/_authed/app/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { Skeleton } from "@workspace/ui/components/skeleton"; import FullPageCard from "@/components/full-page-card"; import { OrganizationDirectory } from "@/components/organization-directory"; @@ -28,7 +28,10 @@ export const Route = createFileRoute("/_authed/app/")({ if (context.clientConfig?.mode === "whitelabel" && (await syncOrganizationMembershipsFn())) { await invalidateOrganizations(context.queryClient); } - return (await context.queryClient.ensureQueryData(organizationsQuery)).organizations; + const organizations = (await context.queryClient.ensureQueryData(organizationsQuery)).organizations; + if (context.clientConfig?.mode === "local" && organizations.length === 1 && organizations[0].brands.length === 0) + throw redirect({ to: "/app/org/$org/new", params: { org: organizations[0].slug } }); + return organizations; }, head: pageHead({ title: "Organizations and Brands", description: "Modify organizations or navigate to brands." }), component: OrganizationPickerPage, @@ -38,6 +41,7 @@ function OrganizationPickerPage() { const t = useT(); const organizations = Route.useLoaderData(); const context = Route.useRouteContext(); + if (context.clientConfig?.mode === "local") return ; if (organizations.length === 0) { return ( @@ -49,14 +53,46 @@ function OrganizationPickerPage() { return ( - {context.canManageConnections && context.clientConfig?.mode === "local" && ( -

- - {t("Connect BeatAPI and your browser")} - -

- )}
); } + +function LocalWorkspace({ organizations }: { organizations: OrganizationSummary[] }) { + const t = useT(); + return ( + +
+ {organizations.flatMap((organization) => + organization.brands.map((brand) => ( + +

{brand.name}

+

{brand.website}

+ + )), + )} + {organizations.map((organization) => ( + + {t("Add a website")} + + ))} + + {t("Connect BeatAPI and your browser")} + +
+
+ ); +} diff --git a/apps/web/src/routes/_authed/app/org/$org/new.tsx b/apps/web/src/routes/_authed/app/org/$org/new.tsx index c1a2b164..b8cc0af5 100644 --- a/apps/web/src/routes/_authed/app/org/$org/new.tsx +++ b/apps/web/src/routes/_authed/app/org/$org/new.tsx @@ -5,6 +5,7 @@ import { Label } from "@workspace/ui/components/label"; import { useState } from "react"; import FullPageCard from "@/components/full-page-card"; import { PlatformSelectionStep } from "@/components/platform-selection-step"; +import { useDeploymentMode } from "@/hooks/use-deployment-features"; import { useOrganizationsChanged } from "@/hooks/use-organizations"; import { useOrganizationParams } from "@/hooks/use-route-params"; import { validateWebsiteUrl } from "@/lib/brand-website"; @@ -34,6 +35,7 @@ export const Route = createFileRoute("/_authed/app/org/$org/new")({ function NewBrandPage() { const t = useT(); + const local = useDeploymentMode() === "local"; const { organizationId, organizationName, blocked } = Route.useLoaderData(); const organizationParams = useOrganizationParams(); const [step, setStep] = useState<"details" | "platforms">("details"); @@ -147,7 +149,9 @@ function NewBrandPage() { return (
diff --git a/apps/web/src/routes/_authed/app/org/$org/settings/index.tsx b/apps/web/src/routes/_authed/app/org/$org/settings/index.tsx index ed393c7e..e462b588 100644 --- a/apps/web/src/routes/_authed/app/org/$org/settings/index.tsx +++ b/apps/web/src/routes/_authed/app/org/$org/settings/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { normalizeSlug, ORG_URL_PREFIX } from "@workspace/lib/app-urls"; import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; @@ -12,6 +12,10 @@ import { useWriteErrorMessage } from "@/lib/write-errors"; import { updateOrganizationFn } from "@/server/organizations"; export const Route = createFileRoute("/_authed/app/org/$org/settings/")({ + beforeLoad: ({ context }) => { + if (context.clientConfig?.mode === "local") + throw redirect({ to: "/app/org/$org/settings/brands", params: { org: context.organization.slug } }); + }, head: pageHead({ title: "Organization", description: "The organization's name and URL." }), component: OrganizationSettingsPage, }); diff --git a/apps/web/src/routes/auth/forgot-password.tsx b/apps/web/src/routes/auth/forgot-password.tsx index 7f5db130..a409ece8 100644 --- a/apps/web/src/routes/auth/forgot-password.tsx +++ b/apps/web/src/routes/auth/forgot-password.tsx @@ -10,7 +10,7 @@ import { useT } from "@/lib/i18n"; * exists, to avoid account enumeration. */ -import { createFileRoute, Link, useRouteContext } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect, useRouteContext } from "@tanstack/react-router"; import type { ClientConfig } from "@workspace/config/types"; import { authClient } from "@workspace/lib/auth/client"; import { Button } from "@workspace/ui/components/button"; @@ -22,6 +22,9 @@ import { SalesFooterLinks, SalesPanel } from "@/components/auth/sales-panel"; import { buildTitle, getAppName } from "@/lib/route-head"; export const Route = createFileRoute("/auth/forgot-password")({ + beforeLoad: ({ context }) => { + if (context.clientConfig?.mode === "local") throw redirect({ to: "/app" }); + }, head: ({ match }) => { const appName = getAppName(match); return { diff --git a/apps/web/src/routes/auth/login.tsx b/apps/web/src/routes/auth/login.tsx index c70492d6..e30efd81 100644 --- a/apps/web/src/routes/auth/login.tsx +++ b/apps/web/src/routes/auth/login.tsx @@ -19,7 +19,6 @@ import { Input } from "@workspace/ui/components/input"; import { Label } from "@workspace/ui/components/label"; import { Separator } from "@workspace/ui/components/separator"; import { useEffect, useState } from "react"; -import { z } from "zod"; import { AuthSplitLayout } from "@/components/auth/auth-split-layout"; import { SalesFooterLinks, SalesPanel } from "@/components/auth/sales-panel"; import FullPageCard from "@/components/full-page-card"; @@ -52,9 +51,8 @@ interface LoginSearch { export const Route = createFileRoute("/auth/login")({ validateSearch: (search: Record): LoginSearch => search, - beforeLoad: ({ context, search }) => { - if (context.clientConfig?.mode === "local" && !context.clientConfig.hasUsers) - throw redirect({ to: "/auth/register", search: { returnTo: safeReturnTo(search.returnTo || "/setup") } }); + beforeLoad: ({ context }) => { + if (context.clientConfig?.mode === "local") throw redirect({ to: "/app" }); }, head: ({ match }) => { const appName = getAppName(match); diff --git a/apps/web/src/routes/auth/register.tsx b/apps/web/src/routes/auth/register.tsx index 626320f7..72fc8cb1 100644 --- a/apps/web/src/routes/auth/register.tsx +++ b/apps/web/src/routes/auth/register.tsx @@ -28,6 +28,7 @@ import { buildTitle, getAppName } from "@/lib/route-head"; export const Route = createFileRoute("/auth/register")({ beforeLoad: ({ context, search }) => { + if (context.clientConfig?.mode === "local") throw redirect({ to: "/app" }); if (!context.clientConfig?.canRegister) throw redirect({ to: "/auth/login", search: { returnTo: search.returnTo } }); }, diff --git a/apps/web/src/routes/auth/reset-password.tsx b/apps/web/src/routes/auth/reset-password.tsx index 5461ce0b..e466bbc2 100644 --- a/apps/web/src/routes/auth/reset-password.tsx +++ b/apps/web/src/routes/auth/reset-password.tsx @@ -6,7 +6,7 @@ import { useT } from "@/lib/i18n"; * ?error=INVALID_TOKEN on a bad one. */ -import { createFileRoute, Link, useNavigate, useRouteContext } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect, useNavigate, useRouteContext } from "@tanstack/react-router"; import type { ClientConfig } from "@workspace/config/types"; import { authClient } from "@workspace/lib/auth/client"; import { Alert, AlertDescription } from "@workspace/ui/components/alert"; @@ -20,6 +20,9 @@ import { SalesFooterLinks, SalesPanel } from "@/components/auth/sales-panel"; import { buildTitle, getAppName } from "@/lib/route-head"; export const Route = createFileRoute("/auth/reset-password")({ + beforeLoad: ({ context }) => { + if (context.clientConfig?.mode === "local") throw redirect({ to: "/app" }); + }, validateSearch: z.object({ token: z.string().optional(), error: z.string().optional(), diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index 7535c432..5fcef112 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -1,59 +1,41 @@ import "../instrument.server.mjs"; import { wrapFetchWithSentry } from "@sentry/tanstackstart-react"; import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; +import { getDeployment } from "@workspace/deployment"; +import { LOCAL_BOOTSTRAP_HEADER, LOCAL_BOOTSTRAP_PATH, localBootstrapProof } from "@workspace/lib/auth/local-workspace"; import { startCredentialRefresh } from "@workspace/lib/secrets"; -import { AD_TAG_CSP } from "@workspace/ui/lib/ad-tags"; +import { handleLocalWorkspaceRequest } from "@/lib/auth/local-request"; +import { auth } from "@/lib/auth/server"; +import { addSecurityHeaders } from "@/lib/security-headers"; -// Not awaited: the app has to serve sign-in and settings whether or not the +// Not awaited: the app has to serve the workspace and settings whether or not the // credential store is reachable. void startCredentialRefresh(); -// HSTS asserts HTTPS-only for the host that served the response. Whitelabel -// deployments run on customer-controlled custom domains, where `includeSubDomains` -// would wrongly assert HTTPS across subdomains we don't own — so that directive -// is scoped to our own deployments. Browsers ignore HSTS received over plain -// HTTP, so it stays inert on localhost. -const strictTransportSecurity = - process.env.DEPLOYMENT_MODE === "whitelabel" ? "max-age=63072000" : "max-age=63072000; includeSubDomains"; - -const SECURITY_HEADERS: Record = { - "Content-Security-Policy": [ - "default-src 'self'", - `script-src 'self' 'unsafe-inline' https://*.clarity.ms https://us.i.posthog.com https://*.crisp.chat ${AD_TAG_CSP.script}`, - `style-src 'self' 'unsafe-inline' https://*.crisp.chat ${AD_TAG_CSP.style}`, - "img-src 'self' data: https: https://c.bing.com", - `font-src 'self' data: https://*.crisp.chat ${AD_TAG_CSP.font}`, - "media-src 'self' https://*.crisp.chat", - // The chatbox runs its background work in a blob worker. - "worker-src 'self' blob: https://*.crisp.chat", - `frame-src 'self' https://*.crisp.chat ${AD_TAG_CSP.frame}`, - `connect-src 'self' https://us.i.posthog.com https://*.sentry.io https://*.clarity.ms https://c.bing.com https://*.crisp.chat wss://*.relay.crisp.chat wss://*.relay.rescue.crisp.chat ${AD_TAG_CSP.connect}`, - "object-src 'none'", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "), - "Strict-Transport-Security": strictTransportSecurity, - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin-when-cross-origin", - // same-origin-allow-popups (not same-origin) keeps OAuth/SSO popups that rely on - // window.opener working while still isolating us from cross-origin openers. - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", -}; - -function addSecurityHeaders(response: Response): Response { - for (const [key, value] of Object.entries(SECURITY_HEADERS)) { - response.headers.set(key, value); - } - return response; -} - export default createServerEntry( wrapFetchWithSentry({ async fetch(request: Request) { - const response = await handler.fetch(request); + const response = + getDeployment().mode === "local" + ? await handleLocalWorkspaceRequest(request, { + appUrl: process.env.APP_URL || "http://localhost:3140", + getSession: (headers) => auth.api.getSession({ headers }), + bootstrap: async () => { + const context = await auth.$context; + return auth.handler( + new Request(new URL(`/api/auth${LOCAL_BOOTSTRAP_PATH}`, context.baseURL), { + method: "POST", + headers: { + [LOCAL_BOOTSTRAP_HEADER]: localBootstrapProof(context.secret), + "Content-Type": "application/json", + }, + body: "{}", + }), + ); + }, + next: (localRequest) => handler.fetch(localRequest), + }) + : await handler.fetch(request); return addSecurityHeaders(response); }, }), diff --git a/apps/web/src/server/config.ts b/apps/web/src/server/config.ts index a217fa53..5d971a61 100644 --- a/apps/web/src/server/config.ts +++ b/apps/web/src/server/config.ts @@ -41,10 +41,7 @@ export const getClientConfig = createServerFn({ method: "GET" }).handler(async ( const userCount = await countUsers(); const hasUsers = userCount > 0; - // Cloud is public self-serve, so registration is always open. Otherwise it's - // only reachable in local mode before the first user signs up — once the - // instance is bootstrapped, both the UI and API reject signups. - const canRegister = deployment.features.selfServeSignup || (deployment.mode === "local" && !hasUsers); + const canRegister = deployment.features.selfServeSignup; return { mode: deployment.mode, @@ -65,7 +62,7 @@ export const getClientConfig = createServerFn({ method: "GET" }).handler(async ( export const getEnvValidationStateFn = createServerFn({ method: "GET" }).handler(async () => { await storedCredentialsLoaded(); const envState = getEnvValidationState(withStoredCredentials()); - // Local operators must be able to sign in before adding their research key. + // Opening a local workspace never requires a research key. const missing = envState.missing.filter((item) => !(envState.mode === "local" && item.id === "RESEARCH_PROVIDER")); return { mode: envState.mode, diff --git a/apps/web/src/stories/app-sidebar.stories.tsx b/apps/web/src/stories/app-sidebar.stories.tsx index 9fa42dce..a0955108 100644 --- a/apps/web/src/stories/app-sidebar.stories.tsx +++ b/apps/web/src/stories/app-sidebar.stories.tsx @@ -202,7 +202,7 @@ export default { title: "App Sidebar", } satisfies Meta; -/** Local (self-hosted) — all nav visible, admin access, self-registered user */ +/** Local workspace exposes no account or organization controls. */ export const Local: StoryObj = { render: () => { const brand = configureMocks( @@ -213,18 +213,18 @@ export const Local: StoryObj = { ); return ( - + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.queryByText("Workflows")).toBeNull(); - - await userEvent.click(await canvas.findByRole("button", { name: "Account and organizations" })); - await expect(await screen.findByText("Workflows")).toBeInTheDocument(); - await expect(await screen.findByText("Tools")).toBeInTheDocument(); + await expect(canvas.getByText("Workspace")).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Account and organizations" })).toBeNull(); + await expect(canvas.queryByRole("button", { name: "Account" })).toBeNull(); + for (const label of ["Log out", "Team", "Organization", "Organization Settings", "Workflows", "Tools"]) + await expect(canvas.queryByText(label)).toBeNull(); }, }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 65ae28d6..1a58ec3d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -12,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; const tslibEsm = fileURLToPath(import.meta.resolve("tslib/tslib.es6.mjs")); export default defineConfig({ + server: { host: "127.0.0.1" }, build: { sourcemap: "hidden", }, diff --git a/apps/www/src/components/home/self-host.tsx b/apps/www/src/components/home/self-host.tsx index 28e7846c..0db1585e 100644 --- a/apps/www/src/components/home/self-host.tsx +++ b/apps/www/src/components/home/self-host.tsx @@ -5,7 +5,7 @@ import { SELF_HOST_LINK } from "@/lib/self-host-link"; import { SectionHeading } from "./ui"; const POINTS = [ - { title: "Open Source", body: "MIT-licensed and runs on Docker." }, + { title: "Open Source", body: "MIT-licensed with bundled local storage." }, { title: "Own Your Data", body: "Everything is stored in Postgres." }, { title: "Track Anything", body: "BYOK to track any AI model." }, ]; diff --git a/apps/www/src/lib/faqs.ts b/apps/www/src/lib/faqs.ts index 6bd13053..bf62a651 100644 --- a/apps/www/src/lib/faqs.ts +++ b/apps/www/src/lib/faqs.ts @@ -71,7 +71,7 @@ export const PRICING_FAQS: FaqItem[] = [ { question: "What do I need to run BeatGEO myself?", answer: - "BeatGEO runs as a Docker Compose stack managed by a CLI. Install it, run the interactive init, and it's up in a couple of commands. You can use the bundled database or connect BeatGEO to your own PostgreSQL database.", + "Run the source quick start with Node.js 24 and pnpm 12.4.1. BeatGEO bundles and manages PostgreSQL, so there is no container runtime or separate database to configure. Run pnpm local:setup, then pnpm local:start.", }, { question: "Do I need a credit card to get started?", diff --git a/assets/readme/workflow.de.svg b/assets/readme/workflow.de.svg new file mode 100644 index 00000000..46b1c325 --- /dev/null +++ b/assets/readme/workflow.de.svg @@ -0,0 +1,75 @@ + + +Der Produktablauf +flowchart LR + A[Website und Marke] --> B[Fragen erzeugen und bestätigen] + B --> C[KI-Antworten erfassen] + C --> D[Nennungen und Quellen prüfen] + D --> E[Maßnahme und Ausgangswerte speichern] + E --> F[Änderungen umsetzen] + F --> G[Dieselben Fragen wiederholen] + G --> D + +flowchart LR + A[Website und Marke] --> B[Fragen erzeugen und bestätigen] + B --> C[KI-Antworten erfassen] + C --> D[Nennungen und Quellen prüfen] + D --> E[Maßnahme und Ausgangswerte speichern] + E --> F[Änderungen umsetzen] + F --> G[Dieselben Fragen wiederholen] + G --> D + + + + + + + + + + + + +01 + +Website und Marke + + +02 + +Fragen erzeugen +und bestätigen + + +03 + +KI-Antworten +erfassen + + +04 + +Nennungen und +Quellen prüfen + + +05 + +Maßnahme und +Ausgangswerte +speichern + + +06 + +Änderungen +umsetzen + + +07 + +Dieselben Fragen +wiederholen + + + diff --git a/assets/readme/workflow.en.svg b/assets/readme/workflow.en.svg new file mode 100644 index 00000000..5b55cbd0 --- /dev/null +++ b/assets/readme/workflow.en.svg @@ -0,0 +1,72 @@ + + +The product workflow +flowchart LR + A[Website and brand] --> B[Generate and confirm questions] + B --> C[Collect AI answers] + C --> D[Inspect mentions and citations] + D --> E[Save action and baseline] + E --> F[Implement changes] + F --> G[Retest the same questions] + G --> D + +flowchart LR + A[Website and brand] --> B[Generate and confirm questions] + B --> C[Collect AI answers] + C --> D[Inspect mentions and citations] + D --> E[Save action and baseline] + E --> F[Implement changes] + F --> G[Retest the same questions] + G --> D + + + + + + + + + + + + +01 + +Website and brand + + +02 + +Generate and +confirm questions + + +03 + +Collect AI answers + + +04 + +Inspect mentions +and citations + + +05 + +Save action and +baseline + + +06 + +Implement changes + + +07 + +Retest the same +questions + + + diff --git a/assets/readme/workflow.ja.svg b/assets/readme/workflow.ja.svg new file mode 100644 index 00000000..8f999363 --- /dev/null +++ b/assets/readme/workflow.ja.svg @@ -0,0 +1,68 @@ + + +プロダクトの流れ +flowchart LR + A[サイトとブランド] --> B[質問を生成して確認] + B --> C[AI 回答を収集] + C --> D[言及と引用を確認] + D --> E[改善と基準値を保存] + E --> F[変更を実施] + F --> G[同じ質問で再測定] + G --> D + +flowchart LR + A[サイトとブランド] --> B[質問を生成して確認] + B --> C[AI 回答を収集] + C --> D[言及と引用を確認] + D --> E[改善と基準値を保存] + E --> F[変更を実施] + F --> G[同じ質問で再測定] + G --> D + + + + + + + + + + + + +01 + +サイトとブランド + + +02 + +質問を生成して確認 + + +03 + +AI 回答を収集 + + +04 + +言及と引用を確認 + + +05 + +改善と基準値を保存 + + +06 + +変更を実施 + + +07 + +同じ質問で再測定 + + + diff --git a/assets/readme/workflow.ko.svg b/assets/readme/workflow.ko.svg new file mode 100644 index 00000000..ac88dccc --- /dev/null +++ b/assets/readme/workflow.ko.svg @@ -0,0 +1,70 @@ + + +제품 워크플로 +flowchart LR + A[웹사이트와 브랜드] --> B[질문 생성 및 확인] + B --> C[AI 답변 수집] + C --> D[언급과 인용 확인] + D --> E[개선 항목과 기준값 저장] + E --> F[변경 사항 적용] + F --> G[같은 질문으로 재측정] + G --> D + +flowchart LR + A[웹사이트와 브랜드] --> B[질문 생성 및 확인] + B --> C[AI 답변 수집] + C --> D[언급과 인용 확인] + D --> E[개선 항목과 기준값 저장] + E --> F[변경 사항 적용] + F --> G[같은 질문으로 재측정] + G --> D + + + + + + + + + + + + +01 + +웹사이트와 브랜드 + + +02 + +질문 생성 및 확인 + + +03 + +AI 답변 수집 + + +04 + +언급과 인용 확인 + + +05 + +개선 항목과 기준값 +저장 + + +06 + +변경 사항 적용 + + +07 + +같은 질문으로 +재측정 + + + diff --git a/assets/readme/workflow.zh-CN.svg b/assets/readme/workflow.zh-CN.svg new file mode 100644 index 00000000..6d41198e --- /dev/null +++ b/assets/readme/workflow.zh-CN.svg @@ -0,0 +1,68 @@ + + +产品闭环 +flowchart LR + A[网站与品牌] --> B[生成并确认问题] + B --> C[采集 AI 回答] + C --> D[查看提及与引用] + D --> E[保存行动与基线] + E --> F[实施修改] + F --> G[同组问题复测] + G --> D + +flowchart LR + A[网站与品牌] --> B[生成并确认问题] + B --> C[采集 AI 回答] + C --> D[查看提及与引用] + D --> E[保存行动与基线] + E --> F[实施修改] + F --> G[同组问题复测] + G --> D + + + + + + + + + + + + +01 + +网站与品牌 + + +02 + +生成并确认问题 + + +03 + +采集 AI 回答 + + +04 + +查看提及与引用 + + +05 + +保存行动与基线 + + +06 + +实施修改 + + +07 + +同组问题复测 + + + diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 1d14f0e3..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,113 +0,0 @@ -# BeatGEO -# Supports: web app (Nitro server) and worker - -# Base stage with Node.js -FROM node:24-alpine AS base -RUN corepack enable pnpm -RUN apk add --no-cache curl - -# Dependencies stage -FROM base AS deps -WORKDIR /app - -# Copy package files -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/web/package.json ./apps/web/ -COPY apps/worker/package.json ./apps/worker/ -COPY packages/ui/package.json ./packages/ui/ -COPY packages/config/package.json ./packages/config/ -COPY packages/lib/package.json ./packages/lib/ -COPY packages/deployment/package.json ./packages/deployment/ -COPY packages/og/package.json ./packages/og/ -COPY packages/api-spec/package.json ./packages/api-spec/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Builder stage -FROM base AS builder -WORKDIR /app - -# Copy dependencies -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules -COPY --from=deps /app/apps/worker/node_modules ./apps/worker/node_modules -COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules -COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules -COPY --from=deps /app/packages/lib/node_modules ./packages/lib/node_modules -COPY --from=deps /app/packages/deployment/node_modules ./packages/deployment/node_modules -COPY --from=deps /app/packages/og/node_modules ./packages/og/node_modules - -# Copy source code -COPY . . - -# The web bundle and the two pruned deployments below read the same tree and -# write nothing the others read, so they are separate stages: BuildKit runs -# them concurrently instead of spending the bundle's minute before starting on -# the deploys. - -# Web application bundle -FROM builder AS web-build - -RUN pnpm --filter @workspace/web build - -# Pruned standalone deployments (only each package's own deps) -# --legacy: allows deploy without inject-workspace-packages (which breaks Alpine Docker builds) -FROM builder AS worker-deploy -RUN pnpm --filter @workspace/worker deploy --legacy /deploy/worker - -FROM builder AS lib-deploy -RUN pnpm --filter @workspace/lib deploy --legacy /deploy/lib - -# Runner stage for web app (Nitro) -FROM base AS web -WORKDIR /app - -ENV NODE_ENV=production - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 beatgeo - -# Copy entrypoint script -COPY docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# Copy Nitro output - this is the entire server bundle -COPY --from=web-build --chown=beatgeo:nodejs /app/apps/web/.output ./.output - -USER beatgeo - -EXPOSE 3000 -ENV PORT=3000 -ENV HOST="0.0.0.0" - -ENTRYPOINT ["/entrypoint.sh"] -CMD ["node", ".output/server/index.mjs"] - -# Migration stage — runs drizzle-kit migrate then exits -FROM base AS migrate -WORKDIR /app - -COPY --from=lib-deploy /deploy/lib ./ - -CMD ["npx", "drizzle-kit", "migrate"] - -# Worker stage -FROM base AS worker -WORKDIR /app - -ENV NODE_ENV=production - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 worker - -COPY docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -COPY --from=worker-deploy --chown=worker:nodejs /deploy/worker ./ - -USER worker - -ENTRYPOINT ["/entrypoint.sh"] -CMD ["npx", "tsx", "src/index.ts"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index b41247b9..00000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -# Database migrations are handled by the db-migrate init container. -# Execute the main command -exec "$@" diff --git a/docs/beat-geo/getting-started.md b/docs/beat-geo/getting-started.md index 2d5d340b..8b38bd08 100644 --- a/docs/beat-geo/getting-started.md +++ b/docs/beat-geo/getting-started.md @@ -7,7 +7,7 @@ flowchart TD A[准备 Git、Node 24、pnpm、Chrome] --> B[环境检查 local:doctor] B --> C[local:setup 自动建独立数据库并准备扩展] C --> D[local:start 启动网页、采集器和调度进程] - D --> E[注册本地 BeatGEO 管理员] + D --> E[直接进入本地工作台并填写网站] E --> F[保存 BeatAPI Key 和研究模型] F --> G[加载内置 BeatBrowser、选择 Chrome 配置] G --> H[在该配置登录 ChatGPT] @@ -31,7 +31,7 @@ flowchart TD | BeatAPI Key、余额、可用研究模型 | 网站研究、生成问题、分析机会 | 在 Connections 验证并保存;模板路线可跳过 | | ChatGPT 网站账号 | 默认网页采集目标 | 在选定 Chrome 配置登录;与 BeatAPI Key 无关 | -默认使用随依赖安装的 PostgreSQL,由 BeatGEO 启停。用户不需要 Docker、数据库命令或数据库密码。可选 `pnpm local:setup --docker`,已有专用 PostgreSQL 也可按[手动指南](../self-hosting.md)配置;已有数据不会自动转换或覆盖。当前不支持 SQLite。 +默认使用随依赖安装的 PostgreSQL,由 BeatGEO 启停。用户不需要 Docker、数据库命令或数据库密码。本地安装和启动只使用内置数据库,不再提供 Docker 选项。已有专用 PostgreSQL 可按[手动指南](../self-hosting.md)配置;已有数据不会自动转换或覆盖。当前不支持 SQLite。 ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ pnpm local:setup pnpm local:start ``` -环境检查只读取工具版本。安装脚本会安装锁定依赖、准备本地 PostgreSQL、生成随机凭据、初始化表并准备扩展。已有配置不会被覆盖。默认打开 http://localhost:3140/setup,若端口被占用会自动选择其他端口,以终端输出为准;首次注册的是本地 BeatGEO 管理员,不会注册或绑定一个新的 BeatAPI 账号。 +环境检查只读取工具版本。安装脚本会安装锁定依赖、准备本地 PostgreSQL、生成随机凭据、初始化表并准备扩展。已有配置不会被覆盖。默认打开 http://localhost:3140/,若端口被占用会自动选择其他端口,以终端输出为准;本地工作台自动建立私有身份,无需注册、登录或设置密码;不会注册新的 BeatAPI 账号。 ## 页面引导 @@ -83,6 +83,10 @@ flowchart LR 这里没有向 BeatAPI 主站数据库“POST 数据库”的操作:网页请求本地 BeatGEO 服务,由本地服务读写自己的 PostgreSQL。BeatAPI 网关只处理其能力调用和计费。 +## 本地访问保护 + +本地工作台只接受本机 loopback 地址,自动建立私有浏览器会话。跨来源请求、代理地址及直接注册/改密/邀请接口均被拒绝。已有单用户数据库沿用原身份和数据;多用户数据库不会被自动接管。不要通过公网隧道或局域网公开本地服务。 + ## 当前还需要完善的产品体验 - 一键桌面安装与自动启动,让非开发者不必管理终端;当前设置页已显示 worker 心跳。 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index ed9a0fb7..c4efd08a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -2,9 +2,11 @@ For the complete first-use journey, see [Getting started](beat-geo/getting-started.md). Run `node scripts/local-doctor.mjs` to check prerequisites without changing data. -For a new local installation, use `pnpm local:setup` and `pnpm local:start`, then open http://localhost:3140/setup. This source installer requires Git, Node.js 24, pnpm 12.4.1 and Chrome; it bundles and manages its own PostgreSQL (no Docker required) and the pinned BeatBrowser extension. The advanced manual path below is for an existing dedicated PostgreSQL installation. SQLite is not supported. Build from source; no BeatGEO npm CLI, SDK or container images are published in this MVP. +For a new local installation, use `pnpm local:setup` and `pnpm local:start`, then open http://localhost:3140/. This source installer requires Git, Node.js 24, pnpm 12.4.1 and Chrome; it bundles and manages its own PostgreSQL (no Docker required) and the pinned BeatBrowser extension. The advanced manual path below is for an existing dedicated PostgreSQL installation. SQLite is not supported. Build from source; no BeatGEO npm CLI, SDK or container images are published in this MVP. -Default local storage lives in `.beat-geo/postgres`. Stop BeatGEO before backing up this directory together with `.beat-geo/database.json` and both private `.env` files. Existing Docker/manual installations keep their original data and configuration. For a new optional Docker install, run `pnpm local:setup --docker`. The native path has been exercised on macOS arm64; other upstream-supported platforms still require release validation. +Default local storage lives in `.beat-geo/postgres`. Stop BeatGEO before backing up this directory together with `.beat-geo/database.json` and both private `.env` files. Older Docker/manual installations keep their original data and configuration; the local installer does not convert them. Back up and explicitly migrate existing data before switching to bundled storage. The local installer and launcher support bundled storage only. The native path has been exercised on macOS arm64; other upstream-supported platforms still require release validation. + +Local mode has no registration, login, passwords, team invitations or organization switching. It establishes a private browser session automatically and accepts only loopback requests. Existing multi-user databases are never claimed automatically. Hosted commercial editions are not currently offered. ## Configure one local instance @@ -24,7 +26,7 @@ RUNS_PER_PROMPT=1 BROWSER_RUNNER_URL=http://127.0.0.1:4318 BROWSER_RUNNER_TOKEN=REPLACE_WITH_RANDOM_RUNNER_SECRET -# Optional: configure these in Connections after signing in instead. +# Optional: configure these in Connections after opening the local workspace instead. # BEATAPI_API_KEY=YOUR_BEATAPI_KEY # BEATAPI_RESEARCH_MODEL=MODEL_ID_AVAILABLE_TO_YOUR_KEY ``` @@ -51,7 +53,7 @@ pnpm --filter @workspace/worker dev For source BeatBrowser, also set `BEAT_BROWSER_COMMAND=node` and `BEAT_BROWSER_ARGS_JSON` to a JSON array containing the absolute path to `beat-browser/src/cli.js`. Set `BEAT_BROWSER_PROFILE_ID` if multiple Chrome profiles are connected. Share the same `BROWSER_RUNNER_TOKEN` between collector and worker; it is distinct from a model API key. -Open **http://localhost:3000**, register the local administrator, add a brand/website, use Connections to configure optional research credentials, then generate and review website-based questions (or an industry starter set without a key) and start collection. In Opportunities, save a recommendation with a completed baseline, record the change you made, and choose Retest. The queue processes the same questions and shows matched comparisons; changing a question or model configuration creates a different cohort. Timeouts and failures remain visible and do not count as zero exposure. +Open **http://localhost:3000**, enter the workspace directly and add a brand/website, use Connections to configure optional research credentials, then generate and review website-based questions (or an industry starter set without a key) and start collection. In Opportunities, save a recommendation with a completed baseline, record the change you made, and choose Retest. The queue processes the same questions and shows matched comparisons; changing a question or model configuration creates a different cohort. Timeouts and failures remain visible and do not count as zero exposure. The browser bridge is serial, synchronous and loopback-only: run **one worker per collector**, with `RUNS_PER_PROMPT=1`. Add `gemini:beat-browser` only after validating that platform with your account; its background completion remains less reliable. A cloud worker cannot reach a user's localhost. Device leasing and offline recovery remain roadmap work. A sleeping laptop cannot provide continuous monitoring. diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index ff719cd2..bdcfcafd 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -12,7 +12,7 @@ export interface FeaturesConfig { canEditOrganizations: boolean; /** * Whether public self-serve registration is available. True only in cloud - * mode. Local allows a single bootstrap signup (see ClientConfig.canRegister); + * mode. Local establishes its private workspace identity automatically; * demo/whitelabel never expose signup. */ selfServeSignup: boolean; @@ -81,7 +81,7 @@ export interface ClientConfig { defaultDelayHours: number; /** * Whether /auth/register should be reachable. True in cloud mode (self-serve - * signup) and in local mode before the first user is bootstrapped. + * signup). Local workspaces never expose registration. * Demo/whitelabel always false. */ canRegister: boolean; diff --git a/packages/deployment/src/deployment.ts b/packages/deployment/src/deployment.ts index c54b7178..cf891e80 100644 --- a/packages/deployment/src/deployment.ts +++ b/packages/deployment/src/deployment.ts @@ -19,7 +19,7 @@ export const FEATURES_BY_MODE: Record = { showOptimizeButton: false, canCreateBrands: true, platformPicksEditable: true, - canEditOrganizations: true, + canEditOrganizations: false, canCreateOrganizations: false, selfServeSignup: false, billing: false, diff --git a/packages/docs/content/docs/developer-guide/architecture.mdx b/packages/docs/content/docs/developer-guide/architecture.mdx index b648faee..5d17ce2d 100644 --- a/packages/docs/content/docs/developer-guide/architecture.mdx +++ b/packages/docs/content/docs/developer-guide/architecture.mdx @@ -12,7 +12,7 @@ BeatGEO is a pnpm monorepo managed by Turborepo. The codebase is split into **ap ├── apps/ │ ├── web/ # Main dashboard (TanStack Start) │ ├── worker/ # Background job runner (pg-boss) -│ ├── cli/ # @beatapi/geo-cli npm package +│ ├── cli/ # Private local source CLI │ └── www/ # Marketing site (this site) │ ├── packages/ @@ -26,7 +26,6 @@ BeatGEO is a pnpm monorepo managed by Turborepo. The codebase is split into **ap │ ├── og/ # Shared OG image generation │ └── api-spec/ # OpenAPI specification │ -├── docker/ # Dockerfile (multi-stage) ├── e2e/ # End-to-end tests ├── biome.json # Linting and formatting ├── turbo.json # Task pipeline configuration @@ -58,7 +57,7 @@ What matters for development: - The **Web App** serves the dashboard UI and the REST API (`/api/v1`). - The **Worker** processes background jobs — querying AI models, running evaluations. - Both import shared code from `packages/lib` (database schema, AI provider config) and `packages/config` (deployment types). -- The **CLI** generates Docker Compose configurations and manages the deployment lifecycle. +- The **CLI** runs the same bundled-storage setup, startup and environment check as the root local scripts. ## Deployment Modes diff --git a/packages/docs/content/docs/developer-guide/commands.mdx b/packages/docs/content/docs/developer-guide/commands.mdx index cde21772..77943b19 100644 --- a/packages/docs/content/docs/developer-guide/commands.mdx +++ b/packages/docs/content/docs/developer-guide/commands.mdx @@ -166,26 +166,18 @@ pnpm cli:link pnpm cli:unlink ``` -## Docker - -```bash -# Build Docker images locally -beatgeo compose build - -# Build without cache -beatgeo compose build --no-cache - -# Build only web or worker -beatgeo compose build web -beatgeo compose build worker - -# Run other docker compose commands -beatgeo compose up -d -beatgeo compose ps -beatgeo compose logs -f web -beatgeo compose down +## Local Workspace + +```sh +pnpm local:doctor +pnpm local:setup +pnpm local:start +# Resume an interrupted automatic setup without replacing configuration +pnpm local:setup --resume ``` +The source CLI offers the same operations with `pnpm beatgeo doctor`, `pnpm beatgeo init` and `pnpm beatgeo start`. Stop the running launcher with Ctrl+C. Local PostgreSQL is bundled and managed automatically. + ## Versioning ```bash diff --git a/packages/docs/content/docs/developer-guide/configuration.mdx b/packages/docs/content/docs/developer-guide/configuration.mdx index 5e105dec..fc832b88 100644 --- a/packages/docs/content/docs/developer-guide/configuration.mdx +++ b/packages/docs/content/docs/developer-guide/configuration.mdx @@ -3,19 +3,15 @@ title: Configuration description: Environment variables, deployment modes, and advanced configuration options. --- -## Deployment Modes +## Local Workspace -BeatGEO supports several deployment modes, controlled by the `DEPLOYMENT_MODE` environment variable: +The current open-source edition runs locally, with `DEPLOYMENT_MODE=local`. It opens without registration, login, passwords, team invitations or organization switching. Hosted commercial editions are not currently offered. Inherited deployment-mode code does not represent a launched service. -| Mode | Description | -|------|-------------| -| `local` | Single-org, email/password auth. Best for individual use. **(default)** | -| `demo` | Read-only mode for showcasing BeatGEO without allowing changes. | -| `cloud` | Managed hosting. [Sign up for BeatGEO Cloud](https://github.com/BeatAPI/BeatGEO). | +The local service binds only to loopback and uses a private browser session with same-origin request protection. Do not expose it through a public tunnel or a reverse proxy. The existing owner is retained for a single-user database; multi-user databases are not automatically converted. ## Environment Variables -When you run `beatgeo init`, the CLI generates a `.env` file in your config directory. You can edit this file directly to change settings. +`pnpm local:setup` generates matching private configuration in root `.env` and `apps/web/.env`. Keep both files synchronized. Local setup and startup use bundled PostgreSQL; no database-mode selection is needed. ### Core Settings @@ -79,64 +75,12 @@ Brand onboarding and the Opportunities report also need one direct LLM API key ## Updating Configuration -To change environment variables after initial setup: +Stop the local launcher with Ctrl+C, update both private `.env` files, then run `pnpm local:start`. Do not regenerate secrets to reopen an existing workspace. -```bash -# Edit the .env file -nano ~/.beatgeo/.env +## Local Storage and Backups -# Restart services to pick up changes -beatgeo compose down -beatgeo compose up -d -``` +The local installer uses bundled PostgreSQL 17 with `BEAT_GEO_DATABASE_MODE=embedded`. Data is stored in `.beat-geo/postgres` and its ownership record is `.beat-geo/database.json`. Stop BeatGEO before backing up both paths together with root `.env` and `apps/web/.env`. Preserve the encryption key to keep stored credentials readable. -## Serving BeatGEO from a Domain +## Existing Installations -`beatgeo init` asks how you'll access BeatGEO. Choose **From a domain** and enter it (for example `beatgeo.example.com`, which becomes `https://beatgeo.example.com`), then point your reverse proxy at the port you pick. Sign-in is only accepted from the configured URL. - -To change it later, run `beatgeo edit env`, set both `APP_URL` and `VITE_APP_URL` to the new URL (for example `https://beatgeo.example.com`), then restart with `beatgeo compose up -d`. BeatGEO must be served from the root of its domain, not a subpath. - -## Using an External Database - -By default, `beatgeo init` provisions a PostgreSQL container. To use an existing database instead, choose "Use existing Postgres" during `beatgeo init` and provide your connection string when prompted. - -Your database must be PostgreSQL 15 or later. - -## Upgrading Postgres - -`beatgeo init` provisions Postgres 18. `beatgeo upgrade` rolls the BeatGEO images but never touches the Postgres container, so a deployment created on an earlier major keeps running on it — supported, and nothing you have to act on. Postgres 16 gets fixes until November 2028. - -If you do want to move, Postgres cannot read a data directory written by a different major version, so it takes a dump and restore. - - - Do not just change the image tag. From version 18 on, the official Postgres image stores its data in a version-specific subdirectory (`/var/lib/postgresql/18/docker`) and declares its volume one level up, at `/var/lib/postgresql`. An 18 image with the old `/var/lib/postgresql/data` mount does not fail — it writes a brand new, empty cluster to an anonymous volume that is discarded the next time the container is recreated. Your original data survives in `beatgeo_postgres_data`, but BeatGEO comes up empty and anything written in the meantime is lost. - - -Take a dump while the old container is still running: - -```bash -beatgeo compose up -d postgres -docker compose -f ~/.beatgeo/beatgeo.yaml exec -T postgres pg_dump -U postgres -d beatgeo > beatgeo-backup.sql -beatgeo compose down -``` - -Check that `beatgeo-backup.sql` is non-empty and ends with `-- PostgreSQL database dump complete` before continuing. Then edit the Postgres service with `beatgeo edit compose`, changing **both** the image and the mount path: - -```yaml -postgres: - image: postgres:18-alpine - volumes: - - postgres_data:/var/lib/postgresql -``` - -Now restore. Postgres 18 finds no cluster at its new path, initializes an empty one alongside the old files, and the dump goes into that: - -```bash -beatgeo compose up -d postgres -docker compose -f ~/.beatgeo/beatgeo.yaml exec -T postgres psql -U postgres -d beatgeo < beatgeo-backup.sql -beatgeo compose up -d -``` - -Bringing up `postgres` on its own first matters: it keeps `db-migrate` from initializing an empty schema ahead of the restore. - -Nothing is destroyed along the way: the old cluster stays in the volume, unused, so you can roll back by putting the original image and `/var/lib/postgresql/data` mount back. Keep `beatgeo-backup.sql` either way. +The local installer and launcher do not start containers or take over external databases. Existing data and configuration are left unchanged. Back up and explicitly migrate existing data before changing storage modes. Advanced manual database and domain configuration is described in the [self-hosting guide](https://github.com/BeatAPI/BeatGEO/blob/main/docs/self-hosting.md). diff --git a/packages/docs/content/docs/developer-guide/development-setup.mdx b/packages/docs/content/docs/developer-guide/development-setup.mdx index 91038ed0..8ebec741 100644 --- a/packages/docs/content/docs/developer-guide/development-setup.mdx +++ b/packages/docs/content/docs/developer-guide/development-setup.mdx @@ -1,93 +1,32 @@ --- title: Development Setup -description: Set up a local development environment for contributing to BeatGEO. +description: Set up a local development workspace for BeatGEO. --- ## Prerequisites -- **Node.js** 24.x -- **pnpm** 10.x+ (required — npm and yarn are not supported) -- **PostgreSQL** 15+ (or use Docker) -- **Docker** (for running the full stack locally) +Use Git, Node.js 24, pnpm 12.4.1 and Chrome. The source installer bundles PostgreSQL and the browser extension. -## Clone and Install - -```bash +```sh git clone https://github.com/BeatAPI/BeatGEO.git -cd beatgeo -pnpm install -``` - -## Configure Environment - -Copy the example environment file and fill in your values: - -```bash -cp .env.example .env -``` - -At minimum, you need: - -| Variable | Description | -|----------|-------------| -| `DATABASE_URL` | PostgreSQL connection string | -| `BETTER_AUTH_SECRET` | Any random string for session encryption | -| `BEAT_GEO_ENCRYPTION_KEY` | Key for provider credentials stored in the database — `openssl rand -base64 32` | - -Add AI provider API keys if you want to test visibility tracking: - -| Variable | Description | -|----------|-------------| -| `OPENAI_API_KEY` | For ChatGPT tracking | -| `ANTHROPIC_API_KEY` | For Claude tracking | - -## Database Setup - -Run Drizzle migrations to create the schema: - -```bash -pnpm --filter @workspace/lib exec drizzle-kit migrate -``` - -## Start Development Servers - -Run all development servers in parallel: - -```bash -pnpm dev -``` - -This starts: -- **Web app** at `http://localhost:3000` -- **Worker** process for background jobs - -Or run only the web app: - -```bash -pnpm --filter @workspace/web dev +cd BeatGEO +pnpm local:doctor +pnpm local:setup +pnpm local:start ``` -## Development with Docker +Open http://localhost:3140/. Local mode opens the workspace without registration or login. The launcher runs the web app, browser collector, worker and dedicated database together. Ctrl+C stops them. Vite reloads source changes during development. -For a setup closer to production, use the CLI in dev mode: +## Configuration -```bash -# Build from local source instead of pulling images -beatgeo init --dev +Setup creates root `.env` and `apps/web/.env` with matching local credentials. Keep both files private and synchronized. Use Connections in the app for optional research credentials. Browser collection uses the Chrome account selected through BeatBrowser; direct API calls and synthetic fixtures are separate from real browser evidence. -# Rebuild after code changes -beatgeo compose build -beatgeo compose up -d -``` - -The Docker build uses a multi-stage `docker/Dockerfile` with separate targets for `web` and `worker`. +An interrupted setup can be resumed with `pnpm local:setup --resume`. Existing configuration is never overwritten or converted automatically. Back up local storage and credentials before changing database settings. ## Component Stories -BeatGEO uses Storybook for component development in isolation: - -```bash +```sh pnpm --filter @workspace/web storybook ``` -Stories live in `apps/web/src/stories/` and cover the app sidebar, prompt charts, and other components across deployment modes. +Stories live in `apps/web/src/stories/`. For checks and isolated regression, see [Commands Reference](/docs/developer-guide/commands). diff --git a/packages/docs/content/docs/developer-guide/releases.mdx b/packages/docs/content/docs/developer-guide/releases.mdx index c9cb4970..d4459f58 100644 --- a/packages/docs/content/docs/developer-guide/releases.mdx +++ b/packages/docs/content/docs/developer-guide/releases.mdx @@ -1,65 +1,12 @@ --- title: Versioning & Releases -description: How BeatGEO handles versioning, changesets, and the release process. +description: Source validation and release boundaries for BeatGEO. --- -## Changesets +BeatGEO uses Changesets with fixed workspace versioning. Add a changeset for a user-facing package change; documentation-only changes and internal refactors do not require one. Maintainers apply version changes after review. -BeatGEO uses [Changesets](https://github.com/changesets/changesets) for version management. All packages in the monorepo use fixed versioning — they stay on the same version number. +The supported installation path is the source quick start with bundled local storage. The source CLI is private. No BeatGEO npm CLI or container images are published, and inherited upstream publishing workflows are disabled. -### Adding a Changeset +Before a source release, run lint, workspace type checks and tests, browser contracts, and the documented isolated database regression. Validate the local setup and browser extension on each claimed platform. Unit tests and synthetic provider responses do not prove real account collection or paid-provider behavior. -Every PR that changes user-facing behavior should include a changeset: - -```bash -pnpm changeset -``` - -This interactive command asks: -1. Which packages are affected -2. Whether the change is a major, minor, or patch -3. A short description of what changed - -A markdown file is created in the `.changeset/` directory. Commit this file with your PR. - -### What Needs a Changeset - -- Bug fixes → `patch` -- New features → `minor` -- Breaking changes → `major` -- Documentation-only changes → usually don't need a changeset -- Internal refactors that don't change behavior → usually don't need a changeset - -## Release Process (Maintainers) - -When ready to release a new version: - -1. **Apply changesets and bump versions** locally: - ```bash - pnpm version-packages - ``` - This consumes all pending changeset files, updates `package.json` versions across the monorepo, and generates `CHANGELOG.md` entries. - -2. **Review and commit** the version changes: - ```bash - git add . - git commit -m "chore: release v$(node -p "require('./apps/cli/package.json').version")" - git push - ``` - -3. **Trigger the release workflow** from GitHub Actions: - - Go to **Actions → Release → Run workflow** - - This creates a GitHub release and publishes to Docker Hub and npm - -## What Gets Published - -| Artifact | Registry | Description | -|----------|----------|-------------| -| `@beatapi/geo-cli` | npm | The BeatGEO CLI package | -| `beatapi/beat-geo-web` | Docker Hub | Web app Docker image | -| `beatapi/beat-geo-worker` | Docker Hub | Worker Docker image | -| `beatapi/beat-geo-db-migrate` | Docker Hub | One-shot drizzle migrations image | - -## Versioning Strategy - -All packages share the same version number. When any package changes, all versions bump together. This keeps things simple and avoids compatibility issues between packages. +Back up existing local databases and private configuration before applying schema changes. Changing the PostgreSQL major version requires an explicit data migration; do not replace a database directory with files from another major version. diff --git a/packages/docs/content/docs/developer-guide/telemetry.mdx b/packages/docs/content/docs/developer-guide/telemetry.mdx index 7008f7d7..7ee1af98 100644 --- a/packages/docs/content/docs/developer-guide/telemetry.mdx +++ b/packages/docs/content/docs/developer-guide/telemetry.mdx @@ -1,88 +1,10 @@ --- title: Telemetry -description: What BeatGEO's CLI and local deployment collect, how it is identified, and how to disable it. +description: Local telemetry defaults and privacy controls. --- -BeatGEO collects a small amount of usage data to help us understand which -features get used and prioritize work. Telemetry comes from two places, -and the same opt-out covers both: +The local installer sets `DISABLE_TELEMETRY=1` in both private environment files. The source CLI runs local scripts without a newsletter signup or telemetry setup wizard. -1. **The CLI** — events fired by `beatgeo` commands you run on your machine. -2. **Your local deployment** — events fired by the web app and worker - containers when you run `beatgeo compose up -d`. +Keep `DISABLE_TELEMETRY=1` in root `.env` and `apps/web/.env`. Stop the launcher with Ctrl+C and restart with `pnpm local:start` after changing environment settings. -## What is collected - -All events go to PostHog. Across both sources, an event includes some -combination of: - -- **Deployment ID** — a random UUID generated by `beatgeo init` and written - to your deployment's `.env` as `DEPLOYMENT_ID`. It identifies both your - CLI events and your local deployment's web/worker events. Re-running - `beatgeo init` reuses the existing ID, so the identity is stable. It is - not derived from your machine, email, username, or any configuration - value. -- **Version, OS, architecture, Node version**, and **deployment mode** - (`local`, `whitelabel`, etc.) -- **Command/event name** and a small set of non-secret options (e.g. - whether Postgres runs in Docker, whether `--dev` was passed, which - providers are configured) -- **Feature counts** from the web app — e.g. number of prompts edited, - brands created, reports run. Counts only, never the names or text. -- **IP address** — PostHog records the request IP on each event and uses - it for approximate geolocation. - -If you opt in to product updates during `beatgeo init` and provide an email -address, we record a `newsletter_signup` event with the email attached as -a person property. This is the only path that ever sends an email unless -you opt into telemetry. The signup runs even if you have disabled -telemetry, because typing your email is an explicit action with clear -intent; the event still goes to the same PostHog project. - -- If telemetry is **enabled**, the event is keyed off your deployment ID, - so the email links to the rest of your CLI events. -- If telemetry is **disabled**, the event is keyed off the email itself - and is never linked back to your deployment ID. - -## What is not collected - -- API keys, `.env` contents, or any secrets -- Brand names, prompt text, or scraped responses -- File paths, repo paths, or hostnames -- Logs or stack traces - -## How to disable it - -The CLI asks during `beatgeo init` whether to share telemetry. The choice -covers both the CLI and your local deployment. To change your mind -later, edit `.env` and add or remove `DISABLE_TELEMETRY=1`: - -```sh -beatgeo edit env # opens .env in $VISUAL / $EDITOR (fallback: nano) -beatgeo compose up -d # restart so the web app and worker pick it up -``` - -Setting `DISABLE_TELEMETRY=1` in any environment also disables telemetry -there unconditionally, which is handy in CI or for one-off runs without -touching the file. - -When telemetry is disabled, no events are sent. The newsletter -signup path described above is the one exception: if you choose to enter -your email during `beatgeo init`, that single event is sent regardless of -your telemetry setting, since you are explicitly asking to receive -updates. - -## Why we collect this - -We use this data to answer questions like: - -- how many BeatGEO instances are running in the wild, and on what versions -- how many total prompts are being tracked across all instances -- which model providers are most popular, and which ones produce the most - errors -- how often `beatgeo init` succeeds end-to-end, and where it drops off -- which Postgres mode (Docker vs. external) is more common -- whether new features actually get used after we ship them - -If you have feedback on what we collect, open an issue at -[github.com/BeatAPI/BeatGEO](https://github.com/BeatAPI/BeatGEO). +Raw browser evidence, local database files and credentials stay in ignored workspace directories. Do not commit them. For the local data boundaries, see the [privacy guide](https://github.com/BeatAPI/BeatGEO/blob/main/docs/privacy.md). diff --git a/packages/docs/content/docs/getting-started.mdx b/packages/docs/content/docs/getting-started.mdx index 5348166a..3690ed86 100644 --- a/packages/docs/content/docs/getting-started.mdx +++ b/packages/docs/content/docs/getting-started.mdx @@ -1,89 +1,35 @@ --- title: Quick Start -description: Get BeatGEO running on your infrastructure in under 5 minutes using the CLI. +description: Start the local BeatGEO workspace with bundled storage. --- ## Prerequisites -- **Docker** — Docker Engine 24+ with Docker Compose v2 -- **Node.js** — 18.x or later (for the CLI) -- At least one AI provider API key (OpenAI, Anthropic, or OpenRouter) -- **(Recommended)** A scraping provider to track the real ChatGPT and Google AI Mode experiences — see [Providers](/docs/user-guide/providers) +Prepare Git, Node.js 24, pnpm 12.4.1 and Chrome. PostgreSQL is bundled and managed by BeatGEO. No container runtime, separate database installation or paid API key is needed to open the workspace. -## Install the CLI +## Install and Start -```bash -npm install -g @beatapi/geo-cli +```sh +git clone https://github.com/BeatAPI/BeatGEO.git +cd BeatGEO +pnpm local:setup +pnpm local:start ``` -## Initialize Your Instance +Open **http://localhost:3140/** to enter the workspace directly, without registration, login or a password. Add a website and use Connections (`/setup`) to load the bundled BeatBrowser extension into Chrome. Use the Chrome profile signed in to ChatGPT for real browser collection. -Run the interactive setup wizard: +`local:setup` installs the locked dependencies, generates private credentials, initializes dedicated local storage and prepares the browser extension. It will not overwrite existing configuration. If an automatic setup was interrupted, run `pnpm local:setup --resume`. -```bash -beatgeo init -``` - -The wizard walks you through: - -1. **Config directory** — Where to store your `beatgeo.yaml` and `.env` files (default: `./beatgeo`) -2. **PostgreSQL** — Run Postgres in Docker (recommended) or provide an existing `DATABASE_URL` -3. **AI Providers** — Configure scrapers ([Cloro](https://cloro.dev?fpr=beatgeo&fp_sid=docs), [BrightData](https://get.brightdata.com/beatgeo?sid=docs), [Oxylabs](https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32), [SearchApi](https://www.searchapi.io/?via=beatgeo), [Olostep](https://olostep.com/?ref=beatgeo), [DataForSEO](https://try.dataforseo.com/beatgeo)) and/or direct model APIs (OpenAI, Anthropic, Mistral, OpenRouter); see [Providers](/docs/user-guide/providers) for details -4. **Access** — Use BeatGEO on this machine (localhost), or from a domain behind a reverse proxy (see [Serving BeatGEO from a Domain](/docs/developer-guide/configuration#serving-beatgeo-from-a-domain)) -5. **Start** — Optionally start the stack immediately - -Once complete, BeatGEO generates two files in your config directory: - -| File | Purpose | -|------|---------| -| `beatgeo.yaml` | Docker Compose configuration for all services | -| `.env` | Environment variables, secrets, and API keys | - -## Start BeatGEO - -If you didn't start during init, bring everything up with: - -```bash -beatgeo compose up -d -``` - -This pulls the Docker images and starts all services. Once they report healthy, open **http://localhost:1515** in your browser. +Run `pnpm local:doctor` to check prerequisites without changing data. The private source CLI provides the same operations through `pnpm beatgeo doctor`, `pnpm beatgeo init` and `pnpm beatgeo start`; no globally published CLI is required. ## System Architecture -BeatGEO system architecture: User connects to Web; Web and Worker share a Postgres database; Web calls LLM APIs; Worker calls both LLM APIs and an LLM Scraper. - -| Service | Role | -|---------|------| -| **Web** | Dashboard UI and REST API | -| **Worker** | Background scheduler that drives prompt runs | -| **Postgres** | Shared database and job queue | -| **LLM APIs** | Direct API calls (OpenAI, Anthropic, OpenRouter) | -| **LLM Scraper** | Scrapers for ChatGPT, Google AI Mode, and other live AI surfaces ([Cloro](https://cloro.dev?fpr=beatgeo&fp_sid=docs), [BrightData](https://get.brightdata.com/beatgeo?sid=docs), [Oxylabs](https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32), [SearchApi](https://www.searchapi.io/?via=beatgeo), [Olostep](https://olostep.com/?ref=beatgeo), [DataForSEO](https://try.dataforseo.com/beatgeo)) | -| **BeatGEO CLI** | Generates `beatgeo.yaml` + `.env` and runs `docker compose` | - -## CLI Commands Reference +The web app, collector and worker run locally. PostgreSQL stores accounts, questions, results and scheduled jobs in `.beat-geo/postgres`. BeatBrowser uses your logged-in Chrome profile to collect answers and citations. Optional research providers support website research and Opportunities analysis. -Once BeatGEO is running, these commands help you manage it: +Keep the start terminal, Chrome and the computer running for scheduled monitoring. Ctrl+C stops the local processes and saves storage. Restart with `pnpm local:start`; do not run setup again to reopen an existing workspace. -```bash -# View live logs -beatgeo compose logs -f - -# View logs for a specific service -beatgeo compose logs -f web - -# Check service status -beatgeo compose ps - -# Stop all services -beatgeo compose down -``` +## Back Up Your Workspace -## What's Next +Stop BeatGEO before backing up `.beat-geo/postgres`, `.beat-geo/database.json`, root `.env` and `apps/web/.env` together. Keep these files private. Existing databases from older installations are never converted automatically. -With BeatGEO running, head to the [User Guide](/docs/user-guide) to create your first brand, configure prompts, and start tracking AI visibility. +For advanced configuration and existing installations, see the [self-hosting guide](https://github.com/BeatAPI/BeatGEO/blob/main/docs/self-hosting.md). diff --git a/packages/docs/content/docs/index.mdx b/packages/docs/content/docs/index.mdx index 4f37d220..3b152471 100644 --- a/packages/docs/content/docs/index.mdx +++ b/packages/docs/content/docs/index.mdx @@ -20,13 +20,13 @@ Unlike proprietary alternatives, BeatGEO is fully self-hosted — you own your d ## How It Works -BeatGEO runs as a Docker Compose stack on your infrastructure. Once running, it periodically queries AI models with your configured prompts and tracks how they mention your brand, competitors, and cited sources over time. +BeatGEO runs locally with its own bundled PostgreSQL, web app, browser collector and worker. Once running, it periodically queries AI models with your configured prompts and tracks how they mention your brand, competitors, and cited sources over time. ## Next Steps -Install the CLI and get BeatGEO running in minutes. +Follow the source quick start to set up and open the local workspace. Walk through the complete setup — from brand creation to visibility tracking. diff --git a/packages/docs/content/docs/user-guide/settings.mdx b/packages/docs/content/docs/user-guide/settings.mdx index c505fe39..0f6f9fd6 100644 --- a/packages/docs/content/docs/user-guide/settings.mdx +++ b/packages/docs/content/docs/user-guide/settings.mdx @@ -60,8 +60,8 @@ Some settings (like API keys) are configured via environment variables rather th nano ./beatgeo/.env # Restart to pick up changes -beatgeo compose down -beatgeo compose up -d +# Stop the running launcher with Ctrl+C, then restart: +pnpm local:start ``` See the [Configuration](/docs/developer-guide/configuration) page for a complete reference of environment variables. diff --git a/packages/lib/package.json b/packages/lib/package.json index a8d997ab..4c1a6863 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -46,7 +46,9 @@ "./question-bank": "./src/onboarding/question-bank.ts", "./runtime-health": "./src/runtime-health.ts", "./site-audit": "./src/site-audit.ts", - "./observed-metrics": "./src/observed-metrics.ts" + "./observed-metrics": "./src/observed-metrics.ts", + "./auth/local-workspace": "./src/auth/local-workspace.ts", + "./db/local-workspace": "./src/db/local-workspace.ts" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.71", diff --git a/packages/lib/src/auth/local-workspace.test.ts b/packages/lib/src/auth/local-workspace.test.ts new file mode 100644 index 00000000..e9b46221 --- /dev/null +++ b/packages/lib/src/auth/local-workspace.test.ts @@ -0,0 +1,68 @@ +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { describe, expect, it, vi } from "vitest"; +import { LOCAL_BOOTSTRAP_HEADER, localBootstrapProof, localWorkspacePlugin } from "./local-workspace"; + +const secret = "local-test-secret-that-is-at-least-thirty-two-characters"; +function setup() { + const owner = { + id: "local-owner", + name: "Local workspace", + email: "workspace@beatgeo.invalid", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + const storage: Record[]> = { + user: [owner], + session: [], + account: [], + verification: [], + }; + const ensureOwner = vi.fn(async () => owner); + const auth = betterAuth({ + baseURL: "http://localhost:3140", + secret, + database: memoryAdapter(storage), + plugins: [localWorkspacePlugin(ensureOwner)], + rateLimit: { enabled: false }, + }); + return { auth, storage, ensureOwner }; +} + +describe("invisible local browser sessions", () => { + it("issues a real reusable auth session without a password or account record", async () => { + const { auth, storage, ensureOwner } = setup(); + const response = await auth.handler( + new Request("http://localhost:3140/api/auth/local-workspace", { + method: "POST", + headers: { [LOCAL_BOOTSTRAP_HEADER]: localBootstrapProof(secret) }, + }), + ); + expect(response.status).toBe(200); + expect(ensureOwner).toHaveBeenCalledOnce(); + const cookies = response.headers.getSetCookie(); + expect(cookies.join(";")).toContain("HttpOnly"); + expect(cookies.join(";")).toContain("SameSite=Strict"); + const headers = new Headers({ cookie: cookies.map((cookie) => cookie.split(";", 1)[0]).join("; ") }); + const session = await auth.api.getSession({ headers }); + expect(session?.user.id).toBe("local-owner"); + expect(storage.account).toHaveLength(0); + expect((await auth.api.getSession({ headers }))?.session.id).toBe(session?.session.id); + }); + it.each(["", "invalid", "a".repeat(64), "é".repeat(64)])( + "refuses issuance without the private in-process proof", + async (proof) => { + const { auth, storage, ensureOwner } = setup(); + const response = await auth.handler( + new Request("http://localhost:3140/api/auth/local-workspace", { + method: "POST", + headers: { [LOCAL_BOOTSTRAP_HEADER]: proof }, + }), + ); + expect(response.status).toBe(404); + expect(ensureOwner).not.toHaveBeenCalled(); + expect(storage.session).toHaveLength(0); + }, + ); +}); diff --git a/packages/lib/src/auth/local-workspace.ts b/packages/lib/src/auth/local-workspace.ts new file mode 100644 index 00000000..f0a842e1 --- /dev/null +++ b/packages/lib/src/auth/local-workspace.ts @@ -0,0 +1,31 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { BetterAuthPlugin, User } from "better-auth"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { setSessionCookie } from "better-auth/cookies"; + +export const LOCAL_BOOTSTRAP_PATH = "/local-workspace"; +export const LOCAL_BOOTSTRAP_HEADER = "x-beatgeo-local-bootstrap"; + +export function localBootstrapProof(secret: string): string { + return createHmac("sha256", secret).update("beatgeo-local-workspace-bootstrap").digest("hex"); +} + +/** Only the in-process HTTP entry can issue a local browser session. */ +export function localWorkspacePlugin(ensureOwner: () => Promise) { + return { + id: "local-workspace", + endpoints: { + bootstrapLocalWorkspace: createAuthEndpoint(LOCAL_BOOTSTRAP_PATH, { method: "POST" }, async (ctx) => { + const proof = ctx.headers?.get(LOCAL_BOOTSTRAP_HEADER) ?? ""; + const expected = localBootstrapProof(ctx.context.secret); + if (!/^[0-9a-f]{64}$/.test(proof) || !timingSafeEqual(Buffer.from(proof), Buffer.from(expected))) + throw new APIError("NOT_FOUND"); + const owner = await ensureOwner(); + const session = await ctx.context.internalAdapter.createSession(owner.id); + if (!session) throw new APIError("INTERNAL_SERVER_ERROR"); + await setSessionCookie(ctx, { user: owner, session }, false, { sameSite: "strict", httpOnly: true }); + return ctx.json({ ready: true }); + }), + }, + } satisfies BetterAuthPlugin; +} diff --git a/packages/lib/src/auth/server.ts b/packages/lib/src/auth/server.ts index 13b0fbda..3004c3a7 100644 --- a/packages/lib/src/auth/server.ts +++ b/packages/lib/src/auth/server.ts @@ -86,14 +86,18 @@ function mcpResourceDefault(resource: string) { } satisfies BetterAuthPlugin; } +function developmentOrigin(): string | undefined { + if (process.env.DEPLOYMENT_MODE === "local" || process.env.NODE_ENV === "production") return undefined; + return `http://localhost:${process.env.PORT ?? "3000"}`; +} + export function createAuth(options?: CreateAuthOptions) { const appUrl = process.env.APP_URL || process.env.VITE_APP_URL; if (!appUrl) { throw new Error("APP_URL or VITE_APP_URL must be set for Better Auth"); } - const localOrigin = - process.env.NODE_ENV !== "production" ? `http://localhost:${process.env.PORT ?? "3000"}` : undefined; + const localOrigin = developmentOrigin(); const baseURL = localOrigin ?? appUrl; const origin = baseURL.replace(/\/$/, ""); diff --git a/packages/lib/src/db/local-workspace.test.ts b/packages/lib/src/db/local-workspace.test.ts new file mode 100644 index 00000000..ef3e2e8b --- /dev/null +++ b/packages/lib/src/db/local-workspace.test.ts @@ -0,0 +1,95 @@ +import { getTableName } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ rows: {} as Record[]> })); +vi.mock("./db", () => ({ + db: { + transaction: async (run: (tx: unknown) => Promise) => { + const before = structuredClone(state.rows); + const tx = { + execute: async () => {}, + select: () => ({ + from: (table: Parameters[0]) => { + const rows = state.rows[getTableName(table)] ?? []; + return { + limit: async (count: number) => rows.slice(0, count), + where: () => ({ limit: async (count: number) => rows.slice(0, count) }), + }; + }, + }), + insert: (table: Parameters[0]) => ({ + values: (value: Record) => { + state.rows[getTableName(table)] ??= []; + const rows = state.rows[getTableName(table)]; + if (getTableName(table) === "member") { + rows.push(value); + return Promise.resolve(); + } + return { + returning: async () => { + rows.push(value); + return [value]; + }, + onConflictDoNothing: async () => { + if (!rows.some((row) => row.id === value.id)) rows.push(value); + }, + }; + }, + }), + }; + try { + return await run(tx); + } catch (error) { + state.rows = before; + throw error; + } + }, + }, +})); + +import { ensureLocalWorkspace } from "./local-workspace"; + +beforeEach(() => { + state.rows = { user: [], organization: [], member: [], brands: [] }; +}); +describe("private local workspace ownership", () => { + it("creates one private owner and reuses it on later opens without a password", async () => { + const first = await ensureLocalWorkspace(); + const again = await ensureLocalWorkspace(); + expect(first.id).toBe(again.id); + expect(state.rows.user).toHaveLength(1); + expect(state.rows.organization).toHaveLength(1); + expect(state.rows.member).toHaveLength(1); + expect(state.rows.user[0]).not.toHaveProperty("password"); + expect(state.rows.account).toBeUndefined(); + }); + it("preserves the existing owner, organization URL and brands", async () => { + state.rows = { + user: [{ id: "original-owner", name: "Original", email: "original@example.invalid" }], + organization: [{ id: "default", slug: "original-workspace", name: "Original workspace" }], + member: [{ id: "original-membership", organizationId: "default", userId: "original-owner", role: "admin" }], + brands: [{ id: "brand-1", organizationId: "default" }], + }; + const before = structuredClone(state.rows); + expect((await ensureLocalWorkspace()).id).toBe("original-owner"); + expect(state.rows).toEqual(before); + }); + it("refuses to claim a multi-user database", async () => { + state.rows.user = [{ id: "one" }, { id: "two" }]; + const before = structuredClone(state.rows); + await expect(ensureLocalWorkspace()).rejects.toThrow(/multiple users/); + expect(state.rows).toEqual(before); + }); + it("does not bypass a disabled existing owner", async () => { + state.rows.user = [{ id: "disabled", banned: true }]; + await expect(ensureLocalWorkspace()).rejects.toThrow(/disabled/); + }); + it("does not promote an existing non-owner member", async () => { + state.rows.user = [{ id: "member" }]; + state.rows.organization = [{ id: "default" }]; + state.rows.member = [{ userId: "member", organizationId: "default", role: "member" }]; + const before = structuredClone(state.rows); + await expect(ensureLocalWorkspace()).rejects.toThrow(/not an owner/); + expect(state.rows).toEqual(before); + }); +}); diff --git a/packages/lib/src/db/local-workspace.ts b/packages/lib/src/db/local-workspace.ts new file mode 100644 index 00000000..456fb1f8 --- /dev/null +++ b/packages/lib/src/db/local-workspace.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { db } from "./db"; +import { member, organization, user } from "./schema"; + +/** Preserve the sole existing owner; never claim a multi-user database. */ +export async function ensureLocalWorkspace() { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('beatgeo-local-workspace'))`); + const users = await tx.select().from(user).limit(2); + if (users.length > 1) + throw new Error( + "This database contains multiple users. Open it with its original configuration; no ownership was changed.", + ); + let owner = users[0]; + if (owner?.banned) throw new Error("The existing workspace owner is disabled. No ownership was changed."); + if (!owner) { + [owner] = await tx + .insert(user) + .values({ + id: randomUUID(), + name: "Local workspace", + email: "workspace@beatgeo.invalid", + emailVerified: true, + role: "user", + }) + .returning(); + } + const organizations = await tx.select().from(organization).limit(2); + if (organizations.length > 1 || (organizations[0] && organizations[0].id !== "default")) + throw new Error("This database is not a single local workspace. No ownership was changed."); + await tx + .insert(organization) + .values({ id: "default", name: "Local workspace", slug: "default", createdAt: new Date() }) + .onConflictDoNothing({ target: organization.id }); + const [membership] = await tx + .select() + .from(member) + .where(and(eq(member.userId, owner.id), eq(member.organizationId, "default"))) + .limit(1); + if (membership && !["admin", "owner"].includes(membership.role)) + throw new Error("The existing workspace membership is not an owner. No permissions were changed."); + if (!membership) + await tx.insert(member).values({ + id: randomUUID(), + userId: owner.id, + organizationId: "default", + role: "admin", + createdAt: new Date(), + }); + return owner; + }); +} diff --git a/scripts/local-doctor.mjs b/scripts/local-doctor.mjs index 2b28a45c..8552541f 100644 --- a/scripts/local-doctor.mjs +++ b/scripts/local-doctor.mjs @@ -5,36 +5,31 @@ import { pathToFileURL } from "node:url"; const REQUIREMENTS = [ ["pnpm", ["--version"], "pnpm 12.4.1", "Install pnpm 12.4.1.", (value) => value.trim() === "12.4.1"], ["git", ["--version"], "Git", "Install Git; the pinned BeatBrowser dependency uses it."], - [ - "docker", ["info", "--format", "{{.ServerVersion}}"], "Docker engine", - "Install and start Docker Desktop or a Docker engine.", - ], - ["docker", ["compose", "version", "--short"], "Docker Compose", "Install Docker Compose v2 or update Docker Desktop."], ]; -export function inspectLocalRequirements({ nodeVersion = process.versions.node, run = spawnSync, databaseMode = "embedded" } = {}) { +export function inspectLocalRequirements({ nodeVersion = process.versions.node, run = spawnSync } = {}) { const checks = [{ name: "Node.js 24", ok: nodeVersion.split(".")[0] === "24", fix: "Install and select Node.js 24." }]; for (const [command, args, name, fix, accepts] of REQUIREMENTS) { - if (command === "docker" && databaseMode !== "docker") continue; const result = run(command, args, { encoding: "utf8", timeout: 5000, stdio: "pipe" }); checks.push({ name, ok: !result.error && result.status === 0 && (!accepts || accepts(result.stdout)), fix }); } return checks; } -export function requireLocalEnvironment(databaseMode = "embedded") { - const checks = inspectLocalRequirements({ databaseMode }); +export function requireLocalEnvironment() { + const checks = inspectLocalRequirements(); const failed = checks.filter((check) => !check.ok); if (failed.length) throw new Error(failed.map((check) => `${check.name}: ${check.fix}`).join("\n")); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { if (existsSync(".env")) process.loadEnvFile(".env"); - const databaseMode = process.env.BEAT_GEO_DATABASE_MODE || (existsSync(".beat-geo/compose.yaml") ? "docker" : "embedded"); - const checks = inspectLocalRequirements({ databaseMode }); + const checks = inspectLocalRequirements(); + if (process.env.BEAT_GEO_DATABASE_MODE && process.env.BEAT_GEO_DATABASE_MODE !== "embedded") + checks.push({ name: "Bundled local storage", ok: false, fix: "Existing database and configuration were left unchanged. See docs/self-hosting.md before moving data from an older installation." }); for (const check of checks) console.log(`${check.ok ? "OK" : "NEEDS ACTION"} · ${check.name}${check.ok ? "" : ` — ${check.fix}`}`); - console.log(`Storage mode: ${databaseMode}. Embedded mode needs no Docker or separate database installation.`); + console.log("Local storage is bundled and managed by BeatGEO. No separate database installation is needed."); console.log("Chrome: install it on this computer, then load the bundled BeatBrowser extension in Setup."); console.log("This check does not create a database, install extensions or call paid APIs."); process.exitCode = checks.every((check) => check.ok) ? 0 : 1; diff --git a/scripts/local-runtime.mjs b/scripts/local-runtime.mjs index de44f18b..78156de6 100644 --- a/scripts/local-runtime.mjs +++ b/scripts/local-runtime.mjs @@ -2,12 +2,12 @@ import { randomBytes } from "node:crypto"; import { cp, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; -export function localEnvironment({ databasePort = 55440, appPort = 3140, browserPort = 4319, databaseMode = "embedded" } = {}) { +export function localEnvironment({ databasePort = 55440, appPort = 3140, browserPort = 4319 } = {}) { const password = randomBytes(24).toString("hex"); return { DATABASE_URL: `postgres://beatgeo:${password}@127.0.0.1:${databasePort}/beatgeo`, DEPLOYMENT_MODE: "local", - BEAT_GEO_DATABASE_MODE: databaseMode, + BEAT_GEO_DATABASE_MODE: "embedded", BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), BEAT_GEO_ENCRYPTION_KEY: randomBytes(32).toString("base64"), DISABLE_TELEMETRY: "1", @@ -21,9 +21,9 @@ export function localEnvironment({ databasePort = 55440, appPort = 3140, browser }; } -export function localCompose(env) { - const url = new URL(env.DATABASE_URL); - return `services:\n database:\n image: postgres:17\n environment:\n POSTGRES_USER: beatgeo\n POSTGRES_PASSWORD: ${url.password}\n POSTGRES_DB: beatgeo\n ports:\n - "127.0.0.1:${url.port}:5432"\n volumes:\n - beatgeo-data:/var/lib/postgresql/data\n healthcheck:\n test: ["CMD-SHELL", "pg_isready -U beatgeo -d beatgeo"]\n interval: 3s\n timeout: 3s\n retries: 20\nvolumes:\n beatgeo-data:\n`; +export function requireBundledStorage(env) { + if (env.BEAT_GEO_DATABASE_MODE !== "embedded") + throw new Error("Local setup and startup require bundled storage. Your existing database and configuration have not been changed. See docs/self-hosting.md before moving data from an older installation."); } export async function prepareExtension(root) { diff --git a/scripts/local-runtime.test.mjs b/scripts/local-runtime.test.mjs index f1e9dfbd..3c8d98f3 100644 --- a/scripts/local-runtime.test.mjs +++ b/scripts/local-runtime.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { localEnvironment, localCompose } from "./local-runtime.mjs"; +import { localEnvironment, requireBundledStorage } from "./local-runtime.mjs"; test("new installs have isolated credentials and need no paid key or fake collector", () => { const first = localEnvironment(), second = localEnvironment(); @@ -14,26 +14,26 @@ test("new installs have isolated credentials and need no paid key or fake collec assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.port, "55440"); }); -test("database binds only to loopback and uses an independent named volume", () => { - const compose = localCompose(localEnvironment()); - assert.match(compose, /127\.0\.0\.1:55440:5432/); - assert.match(compose, /beatgeo-data:/); - assert.doesNotMatch(compose, /external: true/); +test("older database configurations are rejected without being changed", () => { + for (const mode of [undefined, "docker", "external"]) { + const env = { ...localEnvironment(), BEAT_GEO_DATABASE_MODE: mode }; + const before = { ...env }; + assert.throws(() => requireBundledStorage(env), /existing database and configuration have not been changed/); + assert.deepEqual(env, before); + } + assert.doesNotThrow(() => requireBundledStorage(localEnvironment())); }); -test("installation preflight distinguishes missing tools, wrong versions and a stopped Docker engine", async () => { +test("installation preflight reports unsupported Node, pnpm and missing Git", async () => { const { inspectLocalRequirements } = await import("./local-doctor.mjs"); const checks = inspectLocalRequirements({ nodeVersion: "22.0.0", - databaseMode: "docker", - run: (command, args) => { - if (command === "pnpm") return { status: 0, stdout: "11.0.0" }; - if (command === "git") return { status: null, error: new Error("not found") }; - return { status: args[0] === "info" ? 1 : 0, stdout: "" }; - }, + run: (command) => command === "pnpm" + ? { status: 0, stdout: "11.0.0" } + : { status: null, error: new Error("not found") }, }); assert.deepEqual(checks.filter((check) => !check.ok).map((check) => check.name), [ - "Node.js 24", "pnpm 12.4.1", "Git", "Docker engine", + "Node.js 24", "pnpm 12.4.1", "Git", ]); assert(checks.every((check) => check.fix.length > 0)); }); @@ -57,3 +57,29 @@ test("default install does not require Docker or a database command", async () = assert.deepEqual(commands, ["pnpm", "git"]); assert.equal(localEnvironment().BEAT_GEO_DATABASE_MODE, "embedded"); }); + + +test("an old Compose file cannot make the environment check require Docker", async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const path = await import("node:path"); + const { spawnSync } = await import("node:child_process"); + const { fileURLToPath } = await import("node:url"); + const dir = await mkdtemp(path.join(tmpdir(), "beatgeo-doctor-")); + try { + await mkdir(path.join(dir, ".beat-geo")); + await writeFile(path.join(dir, ".beat-geo/compose.yaml"), "legacy config: leave unchanged"); + const bin = path.join(dir, "bin"); + await mkdir(bin); + for (const [name, version] of [["pnpm", "12.4.1"], ["git", "git version 2"]]) + await writeFile(path.join(bin, name), `#!/bin/sh\nprintf '${version}\\n'\n`, { mode: 0o755 }); + const env = { ...process.env, PATH: bin }; + delete env.BEAT_GEO_DATABASE_MODE; + const result = spawnSync(process.execPath, [fileURLToPath(new URL("./local-doctor.mjs", import.meta.url))], { + cwd: dir, env, encoding: "utf8", timeout: 10000, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /Local storage is bundled/); + assert.doesNotMatch(result.stdout, /Docker engine|Docker Compose/); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/setup-local.mjs b/scripts/setup-local.mjs index f9de804d..4f08ca3b 100644 --- a/scripts/setup-local.mjs +++ b/scripts/setup-local.mjs @@ -5,7 +5,7 @@ import { availablePort, startEmbeddedDatabase } from "./local-database.mjs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { requireLocalEnvironment } from "./local-doctor.mjs"; -import { localCompose, localEnvironment, prepareExtension } from "./local-runtime.mjs"; +import { localEnvironment, prepareExtension, requireBundledStorage } from "./local-runtime.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); function run(command, args, cwd = root, environment = process.env) { @@ -15,6 +15,8 @@ function run(command, args, cwd = root, environment = process.env) { let database; try { + if (process.argv.slice(2).some((arg) => arg !== "--resume")) + throw new Error("Use pnpm local:setup or pnpm local:setup --resume. Local storage is bundled; no database option is needed."); if (Number(process.versions.node.split(".")[0]) !== 24) throw new Error("Use Node.js 24 and pnpm 12.4.1."); const local = path.join(root, ".beat-geo"); const resume = process.argv.includes("--resume"); @@ -29,24 +31,23 @@ try { const webText = await readFile(path.join(root, "apps/web/.env"), "utf8"); const { parseEnv } = await import("node:util"); env = parseEnv(rootText); + requireBundledStorage(env); const url = new URL(env.DATABASE_URL); if ( rootText !== webText || env.DEPLOYMENT_MODE !== "local" || url.hostname !== "127.0.0.1" || !url.port || - url.pathname !== "/beatgeo" || - (env.BEAT_GEO_DATABASE_MODE !== "embedded" && - (await readFile(path.join(local, "compose.yaml"), "utf8")) !== localCompose(env)) + url.pathname !== "/beatgeo" ) throw new Error( "This is not an unchanged automatic local setup. Follow the self-hosting guide; no database was modified.", ); } else { const [databasePort, appPort, browserPort] = await Promise.all([55440, 3140, 4319].map(availablePort)); - env = localEnvironment({ databasePort, appPort, browserPort, databaseMode: process.argv.includes("--docker") ? "docker" : "embedded" }); + env = localEnvironment({ databasePort, appPort, browserPort }); } - requireLocalEnvironment(env.BEAT_GEO_DATABASE_MODE || "docker"); + requireLocalEnvironment(); run("pnpm", ["install", "--frozen-lockfile"]); if (!resume) { const text = @@ -54,13 +55,10 @@ try { .map(([key, value]) => `${key}=${value}`) .join("\n") + "\n"; await mkdir(local, { recursive: true, mode: 0o700 }); - if (env.BEAT_GEO_DATABASE_MODE === "docker") - await writeFile(path.join(local, "compose.yaml"), localCompose(env), { mode: 0o600, flag: "wx" }); await writeFile(path.join(root, ".env"), text, { mode: 0o600, flag: "wx" }); await writeFile(path.join(root, "apps/web/.env"), text, { mode: 0o600, flag: "wx" }); } - if (env.BEAT_GEO_DATABASE_MODE === "embedded") database = await startEmbeddedDatabase(root, env, { initialize: true }); - else run("docker", ["compose", "-p", "beatgeo-local", "-f", path.join(local, "compose.yaml"), "up", "-d", "--wait"]); + database = await startEmbeddedDatabase(root, env, { initialize: true }); run( process.execPath, ["--env-file=../../.env", "node_modules/drizzle-kit/bin.cjs", "migrate"], @@ -69,7 +67,7 @@ try { ); const extension = await prepareExtension(root); console.log( - `BeatGEO is ready. Included BeatBrowser ${extension.version}.\nRun pnpm local:start, then open ${env.APP_URL}/setup\nLoad the Chrome extension from ${extension.destination}`, + `BeatGEO is ready. Included BeatBrowser ${extension.version}.\nRun pnpm local:start, then open ${env.APP_URL}/\nLoad the Chrome extension from ${extension.destination}`, ); } catch (error) { console.error(error.message); diff --git a/scripts/start-local.mjs b/scripts/start-local.mjs index 47336b4f..242eea3c 100644 --- a/scripts/start-local.mjs +++ b/scripts/start-local.mjs @@ -5,13 +5,14 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { startEmbeddedDatabase } from "./local-database.mjs"; -import { prepareExtension } from "./local-runtime.mjs"; +import { prepareExtension, requireBundledStorage } from "./local-runtime.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const envPath = process.argv[2] || path.join(root, ".env"); if (!existsSync(envPath)) throw new Error("Run pnpm local:setup first, or supply your BeatGEO environment file."); Object.assign(process.env, parseEnv(readFileSync(envPath, "utf8"))); if (process.env.DEPLOYMENT_MODE !== "local") throw new Error("This launcher only starts a local BeatGEO instance."); +requireBundledStorage(process.env); const app = new URL(process.env.APP_URL || "http://localhost:3140"); if (!["localhost", "127.0.0.1"].includes(app.hostname)) throw new Error("Use a loopback APP_URL for the local launcher."); @@ -49,17 +50,11 @@ process.on("SIGINT", () => { void stop(); }); process.on("SIGTERM", () => { void stop(); }); try { await prepareExtension(root); - if (process.env.BEAT_GEO_DATABASE_MODE === "embedded") { - database = await startEmbeddedDatabase(root, process.env); - // Migrations are applied before either application process starts. - execFileSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "migrate"], { - cwd: path.join(root, "packages/lib"), stdio: "inherit", env: process.env, - }); - } else { - const compose = path.join(root, ".beat-geo/compose.yaml"); - if (!process.argv[2] && existsSync(compose)) - execFileSync("docker", ["compose", "-p", "beatgeo-local", "-f", compose, "up", "-d", "--wait"], { stdio: "inherit" }); - } + database = await startEmbeddedDatabase(root, process.env); + // Migrations are applied before either application process starts. + execFileSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "migrate"], { + cwd: path.join(root, "packages/lib"), stdio: "inherit", env: process.env, + }); start(process.execPath, ["apps/browser-lab/server.mjs"]); // The shared bridge may serve other agents, so it is not stopped with BeatGEO. try { @@ -69,7 +64,7 @@ try { } catch { console.log("Chrome is not connected yet. Complete the extension step in Setup."); } start("pnpm", ["--filter", "@workspace/web", "exec", "vite", "--host", "127.0.0.1", "--port", app.port || "3140", "--strictPort"]); start("pnpm", ["--filter", "@workspace/worker", "start"]); - console.log(`BeatGEO setup: ${app.origin}/setup\nKeep this terminal running for scheduled monitoring. Ctrl+C stops these services and saves local data.`); + console.log(`BeatGEO workspace: ${app.origin}/\nKeep this terminal running for scheduled monitoring. Ctrl+C stops these services and saves local data.`); } catch (error) { console.error(error instanceof Error ? error.message : "BeatGEO could not start."); await stop(1); From 10430a7da512666af552499ec627a9a4ef37ba78 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 23 Oct 2026 16:17:28 +0800 Subject: [PATCH 2/4] Keep both sidebar changes and one local dev-server config after the merge The local Workspace and Learn groups move into a helper so the sidebar stays within the complexity limit, and the dev server keeps its loopback host and dashboard warmup in a single server block. --- apps/web/src/components/app-sidebar.tsx | 75 ++++++++++++++++--------- apps/web/vite.config.ts | 2 +- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index be30af5e..4dee7537 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -144,6 +144,44 @@ function brandGroups(organization: OrganizationSummary, brand: BrandWithPrompts) return groups; } +/** The local workspace's own groups wrap the section's groups: one Workspace entry first, Learn last. */ +function sidebarGroups({ + section, + organization, + brand, + features, + mode, + adminItems, + canManageConnections, +}: AppSidebarProps & { + features?: FeaturesConfig; + mode?: DeploymentMode; + adminItems: NavItem[]; + canManageConnections: boolean; +}): NavGroup[] { + const local = mode === "local"; + const groups: NavGroup[] = []; + if (local) + groups.push({ label: "Workspace", items: [{ title: "Brands", link: { to: "/app" }, icon: IconBuildings }] }); + if (local && canManageConnections) + groups.push({ label: "Setup", items: [{ title: "Connections", link: { to: "/setup" }, icon: IconPlugConnected }] }); + if (section === "brand" && organization && brand) groups.push(...brandGroups(organization, brand)); + if (section === "organization" && organization) groups.push(...organizationGroups(organization, features, mode)); + if (section === "admin" && adminItems.length > 0) groups.push({ label: "Admin", items: adminItems }); + if (local && section === "brand" && organization) + groups.push({ + label: "Learn", + items: [ + { + title: "Real workflow case", + link: { to: "/app/org/$org/settings/cases", params: orgLinkParams(organization) }, + icon: IconSchool, + }, + ], + }); + return groups; +} + export function AppSidebar({ section, organization, brand }: AppSidebarProps) { const { setOpenMobile } = useSidebar(); const { isAdmin, hasReportAccess } = useViewer(); @@ -158,34 +196,15 @@ export function AppSidebar({ section, organization, brand }: AppSidebarProps) { const adminItems = mode === "local" || section === "account" ? [] : adminNavItems({ isAdmin, hasReportAccess, reportsEnabled }); - const groups: NavGroup[] = [ - ...(mode === "local" - ? [{ label: "Workspace", items: [{ title: "Brands", link: { to: "/app" as const }, icon: IconBuildings }] }] - : []), - ...(context.canManageConnections && mode === "local" - ? [ - { - label: "Setup", - items: [{ title: "Connections", link: { to: "/setup" as const }, icon: IconPlugConnected }], - }, - ] - : []), - ...(section === "brand" && organization && brand ? brandGroups(organization, brand) : []), - ...(section === "organization" && organization ? organizationGroups(organization, features, mode) : []), - ...(section === "admin" && adminItems.length > 0 ? [{ label: "Admin", items: adminItems }] : []), - ]; - if (mode === "local" && section === "brand" && organization) { - groups.push({ - label: "Learn", - items: [ - { - title: "Real workflow case", - link: { to: "/app/org/$org/settings/cases", params: orgLinkParams(organization) }, - icon: IconSchool, - }, - ], - }); - } + const groups = sidebarGroups({ + section, + organization, + brand, + features, + mode, + adminItems, + canManageConnections: Boolean(context.canManageConnections), + }); const brandmark = ( <> diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 88230831..dbbf82e1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -12,7 +12,6 @@ import pkg from "./package.json" with { type: "json" }; const tslibEsm = fileURLToPath(import.meta.resolve("tslib/tslib.es6.mjs")); export default defineConfig({ - server: { host: "127.0.0.1" }, build: { sourcemap: "hidden", }, @@ -20,6 +19,7 @@ export default defineConfig({ __APP_VERSION__: JSON.stringify(pkg.version), }, server: { + host: "127.0.0.1", // The local edition runs this dev server, so without warmup the first visit // to each dashboard page waits seconds for on-demand compilation. warmup: { From 6c5fdc61ffed7050b31452813a1005339d394da0 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 23 Oct 2026 16:40:51 +0800 Subject: [PATCH 3/4] Open the workspace on a first visit through the server runtime The runtime's own Request cannot be copied with the Request constructor, so the first browser visit crashed with a 500 before any session cookie existed. The forwarded request is now rebuilt from its URL, method and headers. --- .../lib/auth/__tests__/local-request.test.ts | 22 +++++++++++++++++++ apps/web/src/lib/auth/local-request.ts | 6 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/auth/__tests__/local-request.test.ts b/apps/web/src/lib/auth/__tests__/local-request.test.ts index e20e4b0b..211302be 100644 --- a/apps/web/src/lib/auth/__tests__/local-request.test.ts +++ b/apps/web/src/lib/auth/__tests__/local-request.test.ts @@ -118,3 +118,25 @@ describe("local workspace request protection", () => { expect(deps.next).not.toHaveBeenCalled(); }); }); + +describe("first visit through the server runtime", () => { + // The server hands over its own Request subclass, which the platform Request + // constructor cannot copy: a first visit crashed with a 500 before any cookie existed. + function runtimeRequest(path: string, headers: Record): Request { + const plain = request(path, headers); + const runtime = Object.create(Request.prototype) as Request; + for (const key of ["url", "method", "headers", "signal"] as const) + Object.defineProperty(runtime, key, { get: () => plain[key] }); + return runtime; + } + + it("opens the workspace and sets the session cookie", async () => { + const deps = options(); + const response = await handleLocalWorkspaceRequest(runtimeRequest("/app", navigation), deps); + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).toContain("session=fresh"); + const forwarded = deps.next.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get("cookie")).toContain("session=fresh"); + expect(new URL(forwarded.url).pathname).toBe("/app"); + }); +}); diff --git a/apps/web/src/lib/auth/local-request.ts b/apps/web/src/lib/auth/local-request.ts index e6602fb1..28c5908e 100644 --- a/apps/web/src/lib/auth/local-request.ts +++ b/apps/web/src/lib/auth/local-request.ts @@ -101,7 +101,11 @@ export async function handleLocalWorkspaceRequest(request: Request, options: Loc "cookie", [...cookies.map((cookie) => cookie.split(";", 1)[0]), headers.get("cookie")].filter(Boolean).join("; "), ); - const response = await options.next(new Request(request, { headers })); + // Rebuilt from its parts: the server runtime's own Request cannot be passed to the Request + // constructor, and this path only serves body-less GET navigations. + const response = await options.next( + new Request(request.url, { method: request.method, headers, signal: request.signal }), + ); const responseHeaders = new Headers(response.headers); for (const cookie of cookies) responseHeaders.append("set-cookie", cookie); responseHeaders.set("cache-control", "no-store"); From 01ed3cd01e11b196ac6fa447f85dbc60e58b6aa6 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 23 Oct 2026 16:40:51 +0800 Subject: [PATCH 4/4] Stop warming dashboard modules on dev-server start On a fresh checkout the warmup held Vite's first dependency bundle open, so the page never hydrated and forms did nothing. --- apps/web/vite.config.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dbbf82e1..b4833502 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,11 +20,6 @@ export default defineConfig({ }, server: { host: "127.0.0.1", - // The local edition runs this dev server, so without warmup the first visit - // to each dashboard page waits seconds for on-demand compilation. - warmup: { - clientFiles: ["./src/routes/_authed/**/*.tsx", "./src/components/**/*.tsx", "!./src/**/*.stories.tsx"], - }, }, resolve: { tsconfigPaths: true,