diff --git a/.env.example b/.env.example index 2bfe58cc4..47f43aa8d 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,14 @@ MULTICA_CODEX_MODEL= MULTICA_CODEX_WORKDIR= MULTICA_CODEX_TIMEOUT=20m +# Self-host image channel +# Default stable release channel. Pin to an exact release like v0.2.4 if you +# want to stay on a specific version. If the selected tag has not been +# published to GHCR yet, use make selfhost-build / the build override instead. +MULTICA_IMAGE_TAG=latest +MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend +MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web + # Email (Resend) # For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and # master code 888888 works (only when APP_ENV != "production"; see above). @@ -44,10 +52,12 @@ RESEND_API_KEY= RESEND_FROM_EMAIL=noreply@multica.ai # Google OAuth +# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so +# changing it only requires restarting the backend / compose stack. No web +# rebuild is needed. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback -NEXT_PUBLIC_GOOGLE_CLIENT_ID= # S3 / CloudFront S3_BUCKET= @@ -90,10 +100,9 @@ NEXT_PUBLIC_WS_URL= # ==================== Self-hosting: Control Signups (fixes #930) ==================== # Set to "false" to completely disable new user signups (recommended for private instances) ALLOW_SIGNUP=true -# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting. -# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle, -# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend). -NEXT_PUBLIC_ALLOW_SIGNUP=true +# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this +# only requires restarting the backend / compose stack — not rebuilding web. +# It is not hot-reloaded. # Optional: Only allow emails from these domains (comma-separated) ALLOWED_EMAIL_DOMAINS= @@ -109,4 +118,3 @@ POSTHOG_API_KEY= POSTHOG_HOST=https://us.i.posthog.com # Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out). ANALYTICS_DISABLED= - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf99f77e..7b30cd6df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,10 +10,14 @@ on: permissions: contents: write + packages: write jobs: - release: + verify: runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.release_meta.outputs.tag_name }} + is_stable: ${{ steps.release_meta.outputs.is_stable }} steps: - name: Checkout uses: actions/checkout@v4 @@ -21,6 +25,8 @@ jobs: fetch-depth: 0 - name: Validate tag name + id: release_meta + shell: bash run: | tag="${GITHUB_REF_NAME}" echo "Triggered by tag: $tag" @@ -32,6 +38,12 @@ jobs: echo "::error::Refusing to release from dirty tag '$tag'." exit 1 fi + echo "tag_name=$tag" >> "$GITHUB_OUTPUT" + if [[ "$tag" == *-* ]]; then + echo "is_stable=false" >> "$GITHUB_OUTPUT" + else + echo "is_stable=true" >> "$GITHUB_OUTPUT" + fi - name: Setup Go uses: actions/setup-go@v5 @@ -42,6 +54,21 @@ jobs: - name: Run tests run: cd server && go test ./... + release: + needs: verify + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -51,6 +78,91 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + docker-images: + needs: verify + runs-on: ubuntu-latest + concurrency: + group: release-docker-images-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute backend image tags + id: meta_backend + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/multica-backend + flavor: | + latest=false + tags: | + type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }} + type=raw,value=${{ needs.verify.outputs.tag_name }} + type=sha,prefix=sha- + labels: | + org.opencontainers.image.title=Multica Backend + org.opencontainers.image.description=Multica self-hosted backend + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + pull: true + push: true + platforms: linux/amd64,linux/arm64 + labels: ${{ steps.meta_backend.outputs.labels }} + tags: ${{ steps.meta_backend.outputs.tags }} + cache-from: type=gha,scope=release-backend + cache-to: type=gha,mode=max,scope=release-backend + build-args: | + VERSION=${{ needs.verify.outputs.tag_name }} + COMMIT=${{ github.sha }} + + - name: Compute web image tags + id: meta_web + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/multica-web + flavor: | + latest=false + tags: | + type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }} + type=raw,value=${{ needs.verify.outputs.tag_name }} + type=sha,prefix=sha- + labels: | + org.opencontainers.image.title=Multica Web + org.opencontainers.image.description=Multica self-hosted web frontend + + - name: Build and push web image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.web + pull: true + push: true + platforms: linux/amd64,linux/arm64 + labels: ${{ steps.meta_web.outputs.labels }} + tags: ${{ steps.meta_web.outputs.tags }} + cache-from: type=gha,scope=release-web + cache-to: type=gha,mode=max,scope=release-web + build-args: | + REMOTE_API_URL=http://backend:8080 + NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }} + # Build the Desktop installers for Linux and Windows and upload them to # the GitHub Release that the `release` job above just published. macOS # Desktop continues to ship via the manual `release-desktop` skill so it diff --git a/Dockerfile.web b/Dockerfile.web index d1f507c5b..afae4f91b 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -36,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline # Set build-time env: tells Next.js rewrites to proxy API calls to the backend service ARG REMOTE_API_URL=http://backend:8080 -ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID ARG NEXT_PUBLIC_WS_URL +ARG NEXT_PUBLIC_APP_VERSION=dev ENV REMOTE_API_URL=$REMOTE_API_URL -ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ENV STANDALONE=true # Build the web app (standalone output for minimal runtime) diff --git a/Makefile b/Makefile index c843b6a97..40fb1ab5f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-stop +.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop MAIN_ENV_FILE ?= .env WORKTREE_ENV_FILE ?= .env.worktree @@ -52,7 +52,7 @@ makehelp: help ## Alias for `make help` # ---------- Self-hosting (Docker Compose) ---------- ##@ Self-hosting -selfhost: ## Create .env if needed, then build and start the local self-hosted stack +selfhost: ## Create .env if needed, then pull and start the official self-hosted images @if [ ! -f .env ]; then \ echo "==> Creating .env from .env.example..."; \ cp .env.example .env; \ @@ -64,8 +64,58 @@ selfhost: ## Create .env if needed, then build and start the local self-hosted s fi; \ echo "==> Generated random JWT_SECRET"; \ fi + @echo "==> Pulling official Multica images..." + @if ! docker compose -f docker-compose.selfhost.yml pull; then \ + echo ""; \ + echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \ + echo "If this is before the first GHCR release, build from the current checkout:"; \ + echo " make selfhost-build"; \ + exit 1; \ + fi @echo "==> Starting Multica via Docker Compose..." - docker compose -f docker-compose.selfhost.yml up -d --build + docker compose -f docker-compose.selfhost.yml up -d + @echo "==> Waiting for backend to be ready..." + @for i in $$(seq 1 30); do \ + if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + break; \ + fi; \ + sleep 2; \ + done + @if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + echo ""; \ + echo "✓ Multica is running!"; \ + echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \ + echo " Backend: http://localhost:$${PORT:-8080}"; \ + echo ""; \ + echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \ + echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \ + echo ""; \ + echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \ + echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \ + echo ""; \ + echo "Next — install the CLI and connect your machine:"; \ + echo " brew install multica-ai/tap/multica"; \ + echo " multica setup self-host"; \ + else \ + echo ""; \ + echo "Services are still starting. Check logs:"; \ + echo " docker compose -f docker-compose.selfhost.yml logs"; \ + fi + +selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack + @if [ ! -f .env ]; then \ + echo "==> Creating .env from .env.example..."; \ + cp .env.example .env; \ + JWT=$$(openssl rand -hex 32); \ + if [ "$$(uname)" = "Darwin" ]; then \ + sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \ + else \ + sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \ + fi; \ + echo "==> Generated random JWT_SECRET"; \ + fi + @echo "==> Building Multica from the current checkout..." + docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build @echo "==> Waiting for backend to be ready..." @for i in $$(seq 1 30); do \ if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ @@ -82,6 +132,9 @@ selfhost: ## Create .env if needed, then build and start the local self-hosted s echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \ echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \ echo ""; \ + echo "Built images locally via docker-compose.selfhost.build.yml."; \ + echo "Local tags: multica-backend:dev and multica-web:dev."; \ + echo ""; \ echo "Next — install the CLI and connect your machine:"; \ echo " brew install multica-ai/tap/multica"; \ echo " multica setup self-host"; \ diff --git a/README.md b/README.md index 9ad2b10f2..b6b5c639d 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon > multica setup self-host > ``` > -> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details. +> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details. +> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout. --- diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index f6b5f7fdf..653bcd0a2 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins multica setup self-host ``` -This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost. +This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details. @@ -54,6 +54,10 @@ make selfhost `make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose. +By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`. +If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`. +`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images. + Once ready: - **Frontend:** http://localhost:3000 @@ -69,6 +73,8 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults - **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address. - **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed. + > **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`. ### Step 3 — Install CLI & Start Daemon @@ -156,14 +162,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae > Your local Docker services are unaffected. Stop them separately if you no longer need them. -## Rebuilding After Updates +## Upgrading ```bash -git pull -make selfhost +docker compose -f docker-compose.selfhost.yml pull +docker compose -f docker-compose.selfhost.yml up -d ``` -Migrations run automatically on backend startup. +Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup. +If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`. --- @@ -186,6 +193,7 @@ JWT_SECRET=$(openssl rand -hex 32) Then start everything: ```bash +docker compose -f docker-compose.selfhost.yml pull docker compose -f docker-compose.selfhost.yml up -d ``` diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index b4b4bf4eb..ef86e18e7 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -42,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | | `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) | +Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed. + +### Signup Controls (Optional) + +| Variable | Description | +|----------|-------------| +| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance | +| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains | +| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses | + +Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed. + ### File Storage (Optional) For file uploads and attachments, configure S3 and CloudFront: @@ -234,7 +246,7 @@ When using separate domains for frontend and backend, set these environment vari FRONTEND_ORIGIN=https://app.example.com CORS_ALLOWED_ORIGINS=https://app.example.com -# Frontend (set before building the frontend image) +# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml) REMOTE_API_URL=https://api.example.com NEXT_PUBLIC_API_URL=https://api.example.com NEXT_PUBLIC_WS_URL=wss://api.example.com/ws @@ -250,15 +262,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000 CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000 ``` -Then rebuild: +Then restart the stack: ```bash -docker compose -f docker-compose.selfhost.yml up -d --build +docker compose -f docker-compose.selfhost.yml up -d ``` The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration. -> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image. +> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`. ## Health Check @@ -274,8 +286,9 @@ Use this for load balancer health checks or monitoring. ## Upgrading ```bash -git pull -docker compose -f docker-compose.selfhost.yml up -d --build +docker compose -f docker-compose.selfhost.yml pull +docker compose -f docker-compose.selfhost.yml up -d ``` -Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect. +Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect. +If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`. diff --git a/apps/docs/content/docs/getting-started/self-hosting.mdx b/apps/docs/content/docs/getting-started/self-hosting.mdx index 227571c81..c6de2db3d 100644 --- a/apps/docs/content/docs/getting-started/self-hosting.mdx +++ b/apps/docs/content/docs/getting-started/self-hosting.mdx @@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins multica setup self-host ``` -This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details. +This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details. If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`. @@ -53,13 +53,17 @@ make selfhost `make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose. +By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`. +If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`. +`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images. + Once ready: - **Frontend:** http://localhost:3000 - **Backend API:** http://localhost:8080 -If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`. +If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`. ### Step 2 — Log In @@ -70,6 +74,8 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod - **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address. - **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed. + **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`. @@ -151,14 +157,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae Your local Docker services are unaffected. Stop them separately if you no longer need them. -## Rebuilding After Updates +## Upgrading ```bash -git pull -make selfhost +docker compose -f docker-compose.selfhost.yml pull +docker compose -f docker-compose.selfhost.yml up -d ``` -Migrations run automatically on backend startup. +Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup. +If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`. --- @@ -191,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | | `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) | +Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed. + +### Signup Controls (Optional) + +| Variable | Description | +|----------|-------------| +| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance | +| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains | +| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses | + +Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed. + ### File Storage (Optional) For file uploads and attachments, configure S3 and CloudFront: diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index ceebf15bf..05608b218 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth"; +import { useConfigStore } from "@multica/core/config"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { paths, @@ -24,11 +25,10 @@ import { Loader2 } from "lucide-react"; import { setLoggedInCookie } from "@/features/auth/auth-cookie"; import { LoginPage, validateCliCallback } from "@multica/views/auth"; -const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; - function LoginPageContent() { const router = useRouter(); const qc = useQueryClient(); + const googleClientId = useConfigStore((state) => state.googleClientId); const user = useAuthStore((s) => s.user); const isLoading = useAuthStore((s) => s.isLoading); const searchParams = useSearchParams(); diff --git a/apps/web/features/landing/i18n/context.tsx b/apps/web/features/landing/i18n/context.tsx index ac6754925..eb5e90669 100644 --- a/apps/web/features/landing/i18n/context.tsx +++ b/apps/web/features/landing/i18n/context.tsx @@ -1,11 +1,15 @@ "use client"; -import { createContext, useContext, useState, useCallback } from "react"; -import { en } from "./en"; -import { zh } from "./zh"; +import { createContext, useContext, useState, useCallback, useMemo } from "react"; +import { useConfigStore } from "@multica/core/config"; +import { createEnDict } from "./en"; +import { createZhDict } from "./zh"; import type { LandingDict, Locale } from "./types"; -const dictionaries: Record = { en, zh }; +const dictionaryFactories: Record LandingDict> = { + en: createEnDict, + zh: createZhDict, +}; const COOKIE_NAME = "multica-locale"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year @@ -26,6 +30,11 @@ export function LocaleProvider({ initialLocale?: Locale; }) { const [locale, setLocaleState] = useState(initialLocale); + const allowSignup = useConfigStore((state) => state.allowSignup); + const t = useMemo( + () => dictionaryFactories[locale](allowSignup), + [allowSignup, locale], + ); const setLocale = useCallback((l: Locale) => { setLocaleState(l); @@ -34,7 +43,7 @@ export function LocaleProvider({ return ( {children} diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index ac5661d24..878516c40 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -1,9 +1,8 @@ import { githubUrl } from "../components/shared"; import type { LandingDict } from "./types"; -export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false"; - -export const en: LandingDict = { +export function createEnDict(allowSignup: boolean): LandingDict { + return { header: { github: "GitHub", login: "Log in", @@ -122,8 +121,8 @@ export const en: LandingDict = { headlineFaded: "in the next hour.", steps: [ { - title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace", - description: ALLOW_SIGNUP + title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace", + description: allowSignup ? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms." : "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.", }, @@ -726,4 +725,5 @@ export const en: LandingDict = { }, ], }, -}; + }; +} diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 4e2d4be10..c1c25065d 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -1,9 +1,8 @@ import { githubUrl } from "../components/shared"; import type { LandingDict } from "./types"; -export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false"; - -export const zh: LandingDict = { +export function createZhDict(allowSignup: boolean): LandingDict { + return { header: { github: "GitHub", login: "\u767b\u5f55", @@ -122,8 +121,8 @@ export const zh: LandingDict = { headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002", steps: [ { - title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间", - description: ALLOW_SIGNUP + title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间", + description: allowSignup ? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。" : "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。", }, @@ -726,4 +725,5 @@ export const zh: LandingDict = { }, ], }, -}; + }; +} diff --git a/docker-compose.selfhost.build.yml b/docker-compose.selfhost.build.yml new file mode 100644 index 000000000..a80dffdf7 --- /dev/null +++ b/docker-compose.selfhost.build.yml @@ -0,0 +1,19 @@ +# Development override: build the backend/web images from the current checkout +# instead of pulling the official GHCR images. + +services: + backend: + image: multica-backend:dev + build: + context: . + dockerfile: Dockerfile + + frontend: + image: multica-web:dev + build: + context: . + dockerfile: Dockerfile.web + args: + REMOTE_API_URL: http://backend:8080 + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-} + NEXT_PUBLIC_APP_VERSION: dev diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 428c28fc7..bdbd01afa 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -29,9 +29,7 @@ services: retries: 5 backend: - build: - context: . - dockerfile: Dockerfile + image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest} depends_on: postgres: condition: service_healthy @@ -61,13 +59,7 @@ services: restart: unless-stopped frontend: - build: - context: . - dockerfile: Dockerfile.web - args: - REMOTE_API_URL: http://backend:8080 - NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-} - NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-} + image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest} depends_on: - backend ports: diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 689b6ebe1..ec1e6e63b 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -702,6 +702,8 @@ export class ApiClient { // App Config async getConfig(): Promise<{ cdn_domain: string; + allow_signup: boolean; + google_client_id?: string; posthog_key?: string; posthog_host?: string; }> { diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts index 18817e778..2afbcb744 100644 --- a/packages/core/config/index.ts +++ b/packages/core/config/index.ts @@ -3,12 +3,19 @@ import { useStore } from "zustand"; interface ConfigState { cdnDomain: string; + allowSignup: boolean; + googleClientId: string; setCdnDomain: (domain: string) => void; + setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void; } export const configStore = createStore((set) => ({ cdnDomain: "", + allowSignup: true, + googleClientId: "", setCdnDomain: (domain) => set({ cdnDomain: domain }), + setAuthConfig: ({ allowSignup, googleClientId = "" }) => + set({ allowSignup, googleClientId }), })); export function useConfigStore(): ConfigState; diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx index abcd2b0b2..ab9f37788 100644 --- a/packages/core/platform/auth-initializer.tsx +++ b/packages/core/platform/auth-initializer.tsx @@ -47,6 +47,10 @@ export function AuthInitializer({ .getConfig() .then((cfg) => { if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain); + configStore.getState().setAuthConfig({ + allowSignup: cfg.allow_signup, + googleClientId: cfg.google_client_id, + }); if (cfg.posthog_key) { initAnalytics({ key: cfg.posthog_key, host: cfg.posthog_host || "" }); } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e3ea2044b..f5e5439f6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -39,6 +39,55 @@ function Get-LatestVersion { } } +function Get-SelfHostRef { + if ($env:MULTICA_SELFHOST_REF) { + return $env:MULTICA_SELFHOST_REF + } + + $latest = Get-LatestVersion + if ($latest) { + return $latest + } + + return "main" +} + +function Checkout-ServerRef { + param([string]$Ref) + + if ($Ref -eq "main") { + git fetch origin main --depth 1 2>$null + git checkout --force main 2>$null + git reset --hard origin/main 2>$null + return + } + + git fetch origin --tags --force 2>$null + $tagRef = "refs/tags/$Ref" + git show-ref --verify --quiet $tagRef 2>$null + if ($LASTEXITCODE -eq 0) { + git checkout --force $Ref 2>$null + return + } + + git fetch origin $Ref --depth 1 2>$null + git checkout --force $Ref 2>$null +} + +function Pull-OfficialSelfHostImages { + docker compose -f docker-compose.selfhost.yml pull + if ($LASTEXITCODE -eq 0) { + return + } + + Write-Host "" + Write-Warn "Official images for the selected self-host channel are not published yet." + Write-Host "This can happen before the first GHCR release is available." + Write-Host "From $InstallDir, build from source instead:" + Write-Host " docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build" + exit 1 +} + # --------------------------------------------------------------------------- # CLI Installation # --------------------------------------------------------------------------- @@ -202,14 +251,12 @@ After installing Docker, re-run this script with `$env:MULTICA_MODE="local"`. # --------------------------------------------------------------------------- function Install-Server { Write-Info "Setting up Multica server..." + $serverRef = Get-SelfHostRef + Write-Info "Using self-host assets from $serverRef..." if (Test-Path (Join-Path $InstallDir ".git")) { Write-Info "Updating existing installation at $InstallDir..." Write-Warn "Any local changes in $InstallDir will be overwritten." - Push-Location $InstallDir - git fetch origin main --depth 1 2>$null - git reset --hard origin/main 2>$null - Pop-Location } else { Write-Info "Cloning Multica repository..." if (-not (Test-CommandExists "git")) { @@ -226,9 +273,9 @@ function Install-Server { git clone --depth 1 $RepoUrl $InstallDir } - Write-Ok "Repository ready at $InstallDir" - Push-Location $InstallDir + Checkout-ServerRef $serverRef + Write-Ok "Repository ready at $InstallDir ($serverRef)" if (-not (Test-Path ".env")) { Write-Info "Creating .env with random JWT_SECRET..." @@ -240,8 +287,10 @@ function Install-Server { Write-Ok "Using existing .env" } + Write-Info "Pulling official Multica images..." + Pull-OfficialSelfHostImages Write-Info "Starting Multica services (this may take a few minutes on first run)..." - docker compose -f docker-compose.selfhost.yml up -d --build + docker compose -f docker-compose.selfhost.yml up -d Write-Info "Waiting for backend to be ready..." $ready = $false diff --git a/scripts/install.sh b/scripts/install.sh index 0c4a45e3d..4f1c085d4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -140,6 +140,55 @@ get_latest_version() { curl -sI "$REPO_WEB_URL/releases/latest" 2>/dev/null | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n' || true } +get_selfhost_ref() { + if [ -n "${MULTICA_SELFHOST_REF:-}" ]; then + printf '%s' "$MULTICA_SELFHOST_REF" + return + fi + + local latest + latest=$(get_latest_version) + if [ -n "$latest" ]; then + printf '%s' "$latest" + return + fi + + printf '%s' "main" +} + +checkout_server_ref() { + local ref="$1" + + if [ "$ref" = "main" ]; then + git fetch origin main --depth 1 2>/dev/null || true + git checkout --force main 2>/dev/null || true + git reset --hard origin/main 2>/dev/null || true + return + fi + + git fetch origin --tags --force 2>/dev/null || true + if git rev-parse --verify --quiet "refs/tags/$ref" >/dev/null; then + git checkout --force "$ref" 2>/dev/null || git checkout --force "tags/$ref" 2>/dev/null || true + return + fi + + git fetch origin "$ref" --depth 1 2>/dev/null || true + git checkout --force "$ref" 2>/dev/null || true +} + +pull_official_selfhost_images() { + if docker compose -f docker-compose.selfhost.yml pull; then + return + fi + + echo "" + warn "Official images for the selected self-host channel are not published yet." + echo "This can happen before the first GHCR release is available." + echo "From $INSTALL_DIR, build from source instead:" + echo " docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build" + exit 1 +} + upgrade_cli_brew() { info "Upgrading Multica CLI via Homebrew..." brew update 2>/dev/null || true @@ -221,12 +270,13 @@ After installing Docker, re-run this script with --with-server." # --------------------------------------------------------------------------- setup_server() { info "Setting up Multica server..." + local server_ref + server_ref=$(get_selfhost_ref) + info "Using self-host assets from ${server_ref}..." if [ -d "$INSTALL_DIR/.git" ]; then info "Updating existing installation at $INSTALL_DIR..." cd "$INSTALL_DIR" - git fetch origin main --depth 1 2>/dev/null || true - git reset --hard origin/main 2>/dev/null || true else info "Cloning Multica repository..." if ! command_exists git; then @@ -242,7 +292,9 @@ setup_server() { cd "$INSTALL_DIR" fi - ok "Repository ready at $INSTALL_DIR" + checkout_server_ref "$server_ref" + + ok "Repository ready at $INSTALL_DIR ($server_ref)" # Generate .env if needed if [ ! -f .env ]; then @@ -261,8 +313,10 @@ setup_server() { fi # Start Docker Compose + info "Pulling official Multica images..." + pull_official_selfhost_images info "Starting Multica services (this may take a few minutes on first run)..." - docker compose -f docker-compose.selfhost.yml up -d --build + docker compose -f docker-compose.selfhost.yml up -d # Wait for health check info "Waiting for backend to be ready..." diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index 3815e7db8..9971164b7 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -7,6 +7,11 @@ import ( type AppConfig struct { CdnDomain string `json:"cdn_domain"` + // Public auth config consumed by the web app at runtime so self-hosted + // deployments do not need to rebuild the frontend image when operators + // toggle signup or wire Google OAuth. + AllowSignup bool `json:"allow_signup"` + GoogleClientID string `json:"google_client_id,omitempty"` // PostHog public config for the frontend. The key is the same Project // API Key the backend uses; returning it here (instead of baking it @@ -18,7 +23,10 @@ type AppConfig struct { } func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { - config := AppConfig{} + config := AppConfig{ + AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false", + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + } if h.Storage != nil { config.CdnDomain = h.Storage.CdnDomain() } diff --git a/server/internal/handler/config_test.go b/server/internal/handler/config_test.go new file mode 100644 index 000000000..df4f2f852 --- /dev/null +++ b/server/internal/handler/config_test.go @@ -0,0 +1,48 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) { + origStorage := testHandler.Storage + testHandler.Storage = &mockStorage{} + defer func() { testHandler.Storage = origStorage }() + + t.Setenv("ALLOW_SIGNUP", "false") + t.Setenv("GOOGLE_CLIENT_ID", "google-client-id") + t.Setenv("POSTHOG_API_KEY", "phc_test") + t.Setenv("POSTHOG_HOST", "https://eu.i.posthog.com") + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + w := httptest.NewRecorder() + + testHandler.GetConfig(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var cfg AppConfig + if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + + if cfg.CdnDomain != "cdn.example.com" { + t.Fatalf("cdn_domain: want cdn.example.com, got %q", cfg.CdnDomain) + } + if cfg.AllowSignup { + t.Fatalf("allow_signup: want false, got true") + } + if cfg.GoogleClientID != "google-client-id" { + t.Fatalf("google_client_id: want google-client-id, got %q", cfg.GoogleClientID) + } + if cfg.PosthogKey != "phc_test" { + t.Fatalf("posthog_key: want phc_test, got %q", cfg.PosthogKey) + } + if cfg.PosthogHost != "https://eu.i.posthog.com" { + t.Fatalf("posthog_host: want https://eu.i.posthog.com, got %q", cfg.PosthogHost) + } +}