diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9b176f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Used for `docker build -f deploy/Dockerfile .` (context is repo root). +.git +.github +node_modules +dist +dist-ssr +src-tauri +test-results +playwright-report +e2e +doc +.claude +*.md +!README.md diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..cba88d7 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +# Used by `vite build` — public site URL (DNS + TLS terminate at the edge). +VITE_APP_ORIGIN=https://pengine.net diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml new file mode 100644 index 0000000..73c91c0 --- /dev/null +++ b/.github/workflows/web-deploy.yml @@ -0,0 +1,138 @@ +name: Deploy web app + +# Build the pengine web image, publish to GHCR, and roll it out on the deploy +# host via docker compose under ~/pengine. This workflow only updates the app +# container (static bundle on :80 → 127.0.0.1:1420). Reverse-proxy / nginx / +# TLS for the public site is configured elsewhere (another repository). +# +# Triggers: +# - Push a tag matching v* → builds and deploys that tag +# - Manual dispatch → pick a tag to redeploy +# +# Required repo secrets: +# DEPLOY_HOST — hostname or IP of the deploy target +# DEPLOY_USER — SSH user on the deploy target +# DEPLOY_SSH_KEY — private SSH key (PEM) authorized on the target +# Optional: +# DEPLOY_HOST_KNOWN_HOSTS — verified known_hosts line(s); if unset, CI uses ssh-keyscan + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag to deploy (e.g. v1.0.1)" + required: true + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/pengine-web + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image_ref: ${{ steps.ref.outputs.image_ref }} + steps: + - uses: actions/checkout@v4 + with: + # Tag push: github.ref; manual: workflow input (otherwise default branch would build) + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve version + id: ver + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + echo "tag=$TAG" >>"$GITHUB_OUTPUT" + echo "version=$VERSION" >>"$GITHUB_OUTPUT" + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64 + push: true + provenance: false + sbom: false + build-args: | + VITE_APP_ORIGIN=https://pengine.net + tags: | + ${{ env.IMAGE }}:${{ steps.ver.outputs.version }} + ${{ env.IMAGE }}:latest + + - name: Export image ref + id: ref + run: echo "image_ref=${{ env.IMAGE }}@${{ steps.build.outputs.digest }}" >>"$GITHUB_OUTPUT" + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Start SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Trust host key + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_HOST_KNOWN_HOSTS: ${{ secrets.DEPLOY_HOST_KNOWN_HOSTS }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + if [ -n "${DEPLOY_HOST_KNOWN_HOSTS:-}" ]; then + printf '%s\n' "$DEPLOY_HOST_KNOWN_HOSTS" >>~/.ssh/known_hosts + else + ssh-keyscan -H "$DEPLOY_HOST" >>~/.ssh/known_hosts 2>/dev/null \ + || { echo "ssh-keyscan failed; set optional DEPLOY_HOST_KNOWN_HOSTS to pin the host key." >&2; exit 1; } + fi + chmod 644 ~/.ssh/known_hosts + + - name: Copy docker-compose.yml to host + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" 'mkdir -p ~/pengine' + scp deploy/docker-compose.yml "$DEPLOY_USER@$DEPLOY_HOST:~/pengine/docker-compose.yml" + + - name: Pull and restart + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + GHCR_USER: ${{ github.repository_owner }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_REF: ${{ needs.build.outputs.image_ref }} + run: | + ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + GHCR_USER="$GHCR_USER" \ + GHCR_TOKEN="$GHCR_TOKEN" \ + PENGINE_WEB_IMAGE="$IMAGE_REF" \ + 'bash -s' <<'REMOTE' + set -euo pipefail + cd ~/pengine + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + export PENGINE_WEB_IMAGE + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f + REMOTE diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..148cef6 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,46 @@ +# pengine-web: **website only** — same static bundle the Tauri desktop shell loads from +# `dist/`, without building or running the native app. +# +# Mirrors `src-tauri/tauri.conf.json`: +# - `build.beforeBuildCommand` → `bun run build` (→ `build:web`) +# - `build.frontendDist` → `../dist` (this image serves that folder on :80) +# +# Intentionally omitted: `tauri build`, Cargo, `src-tauri/` (see /.dockerignore). +# +# Build: docker build -f deploy/Dockerfile . +# Context: repository root (see /.dockerignore). + +FROM oven/bun:1 AS build +WORKDIR /app + +ENV HUSKY=0 +ENV NODE_ENV=production + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --ignore-scripts + +COPY . . + +ARG VITE_APP_ORIGIN=https://pengine.net +ENV VITE_APP_ORIGIN=${VITE_APP_ORIGIN} + +# Web UI only — matches Tauri’s packaged frontend; does not invoke the Tauri CLI. +RUN bun run build:web + +FROM ghcr.io/static-web-server/static-web-server:2-alpine +USER root +RUN apk add --no-cache wget +USER sws + +COPY --chown=sws:sws --from=build /app/dist /home/sws/public + +ENV SERVER_HOST=0.0.0.0 +ENV SERVER_PORT=80 +ENV SERVER_ROOT=/home/sws/public +ENV SERVER_FALLBACK_PAGE=/home/sws/public/index.html +ENV SERVER_LOG_LEVEL=error + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..f8ba644 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,20 @@ +# Runs the pengine web image on the deploy host (plain HTTP on :80 in-container, +# published to 127.0.0.1:1420). Reverse-proxy / TLS is not part of this repo — +# configure that in your ops repo (e.g. nginx → http://127.0.0.1:1420). +# +# PENGINE_WEB_IMAGE is set by the GitHub Actions deploy step to the image +# reference being rolled out (e.g. ghcr.io/pengine-ai/pengine-web:v1.0.1). +# Docker Compose substitutes it from the environment at `up` time. + +services: + web: + image: ${PENGINE_WEB_IMAGE:?PENGINE_WEB_IMAGE is required} + container_name: pengine + restart: unless-stopped + ports: + - "127.0.0.1:1420:80" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + interval: 30s + timeout: 3s + retries: 3 diff --git a/doc/README.md b/doc/README.md index d30edd8..191ee18 100644 --- a/doc/README.md +++ b/doc/README.md @@ -55,6 +55,7 @@ Product overview: [../README.md](../README.md). | [guides/skills.md](guides/skills.md) | Skills vs MCP, Dashboard behavior, `SKILL.md` / `mandatory.md`, context cap, troubleshooting | | [guides/custom-mcp-tools.md](guides/custom-mcp-tools.md) | Concepts, dashboard vs API, `mcp.json` paths, stdio fields, Docker/custom tools, pitfalls | | [guides/releasing.md](guides/releasing.md) | Tag-driven release pipeline, code signing/notarization explained, how to obtain Apple + Windows secrets | +| [guides/deploying-web.md](guides/deploying-web.md) | Web-app deploy pipeline: GHCR image + SSH docker-compose rollout to the host | ### Tool Engine (maintainers) diff --git a/doc/guides/deploying-web.md b/doc/guides/deploying-web.md new file mode 100644 index 0000000..3ac6d2f --- /dev/null +++ b/doc/guides/deploying-web.md @@ -0,0 +1,87 @@ +# Deploying the web app + +The public production URL for the web UI is **`https://pengine.net`** (DNS A/AAAA → your host; TLS at the reverse proxy). The Vite production build embeds this via [`VITE_APP_ORIGIN`](../../.env.production) for client-side metadata. + +The pengine web bundle is deployed to a remote host via the +[`Deploy web app`](../../.github/workflows/web-deploy.yml) GitHub Actions +workflow. Each run: + +1. Builds [`deploy/Dockerfile`](../../deploy/Dockerfile) from the **repo root** + (see [`/.dockerignore`](../../.dockerignore)): `bun install` → [`bun run build:web`](../../package.json) + (same as Tauri [`build.beforeBuildCommand`](../../src-tauri/tauri.conf.json) / [`build.frontendDist`](../../src-tauri/tauri.conf.json); **no** `tauri build` or Rust) → [static-web-server](https://github.com/static-web-server/static-web-server) + with **`SERVER_FALLBACK_PAGE`** so React Router paths resolve. CI passes + `VITE_APP_ORIGIN=https://pengine.net` as a **build-arg** (overridable locally). +2. Pushes the image to GHCR as `ghcr.io//pengine-web:` and + `:latest`. +3. SSHes into the deploy host, copies + [`deploy/docker-compose.yml`](../../deploy/docker-compose.yml) to `~/pengine`, + logs in to GHCR, then `docker compose pull && docker compose up -d`. + +The container publishes on `127.0.0.1:1420`. **TLS and reverse-proxy (e.g. nginx) +for the public site are not defined in this repository** — maintain that in +your ops / infrastructure repo and point it at `http://127.0.0.1:1420` (or +adjust the published port in `docker-compose.yml` to match your layout). + +## Triggers + +- **Tag push** — pushing a tag matching `v*` (e.g. `v1.0.1`) deploys that tag. + The same tag also fires [`App Release`](../../.github/workflows/app-release.yml); + the two run in parallel. +- **Manual dispatch** — from the Actions tab, pick any existing tag to + redeploy it. + +## Required secrets + +Add under *Settings → Secrets and variables → Actions*: + +| Secret | Value | +| --- | --- | +| `DEPLOY_HOST` | Hostname or IP of the deploy target | +| `DEPLOY_USER` | SSH user on the target (must be in the `docker` group) | +| `DEPLOY_SSH_KEY` | Private SSH key (PEM, including `-----BEGIN`/`-----END` lines), with its public half in the target user's `~/.ssh/authorized_keys` | + +Optional: + +| Secret | Value | +| --- | --- | +| `DEPLOY_HOST_KNOWN_HOSTS` | One or more `known_hosts` lines for `DEPLOY_HOST` (paste output of a **verified** `ssh-keyscan`). If omitted, the workflow runs `ssh-keyscan` at deploy time instead. | + +GHCR auth on the host uses the per-run `GITHUB_TOKEN` — no extra secret +needed, but the host must be able to reach `ghcr.io` on 443. + +## One-time host bootstrap + +On the deploy host, as `DEPLOY_USER`: + +```bash +# Docker + compose plugin (Debian/Ubuntu shown; adapt for your distro). +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker "$USER" +# Log out and back in so the group takes effect. + +# Directory the workflow drops docker-compose.yml into. +mkdir -p ~/pengine +``` + +Configure your external reverse-proxy (from your other repository) to forward +HTTPS traffic to `http://127.0.0.1:1420` — that is the contract between host +ingress and this deployment. + +## Verifying a deploy + +After the workflow succeeds: + +```bash +ssh "$DEPLOY_USER@$DEPLOY_HOST" 'docker ps --filter name=pengine' +curl -fsSL https://pengine.net/ | head +``` + +To roll back, manually dispatch the workflow with the previous tag. + +## Local image build + +```bash +docker build -f deploy/Dockerfile --build-arg VITE_APP_ORIGIN=https://pengine.net -t pengine-web:local . +docker run --rm -p 8080:80 pengine-web:local +# Open http://127.0.0.1:8080 +``` diff --git a/package-lock.json b/package-lock.json index c1f8f1c..e6eb23a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pengine", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pengine", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/package.json b/package.json index b6170b6..cfcf53c 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "pengine", "private": true, - "version": "1.0.0", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build:web": "tsc && vite build", + "build": "bun run build:web", "preview": "vite preview", "tauri": "tauri", "test:e2e": "playwright test", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 185d470..0c3beed 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3170,7 +3170,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pengine" -version = "1.0.0" +version = "1.0.2" dependencies = [ "axum", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02140c0..2378848 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pengine" -version = "1.0.0" +version = "1.0.2" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 199812f..74e6a95 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "pengine", - "version": "1.0.0", + "version": "1.0.2", "identifier": "com.maximedogawa.pengine", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.tsx b/src/App.tsx index 33d0af5..86a1d42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,26 @@ -import { useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { getPengineHealth } from "./modules/bot/api"; import { useAppSessionStore } from "./modules/bot/store/appSessionStore"; -import { DashboardPage } from "./pages/DashboardPage"; -import { LandingPage } from "./pages/LandingPage"; -import { SetupPage } from "./pages/SetupPage"; + +const LandingPage = lazy(() => + import("./pages/LandingPage").then((m) => ({ default: m.LandingPage })), +); +const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage }))); +const DashboardPage = lazy(() => + import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })), +); + +function RoutePageFallback() { + return ( +
+

Loading…

+
+ ); +} /** One-shot: sync dashboard route with persisted session or running local app. */ function StartupDashboardRedirect() { @@ -76,12 +92,14 @@ function App() { return (
- - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + +
); } diff --git a/src/main.tsx b/src/main.tsx index cc62ca2..603f642 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import "./shared/appMeta"; import { initTauriRegistryBridge } from "./shared/mcpEvents"; import "./index.css"; diff --git a/src/shared/appMeta.ts b/src/shared/appMeta.ts new file mode 100644 index 0000000..91dbd7f --- /dev/null +++ b/src/shared/appMeta.ts @@ -0,0 +1,10 @@ +const rawOrigin = import.meta.env.VITE_APP_ORIGIN; + +if (import.meta.env.PROD && (rawOrigin === undefined || rawOrigin === "")) { + const msg = + "[pengine] VITE_APP_ORIGIN is missing — set it for production (e.g. .env.production or Docker build-arg)."; + throw new Error(msg); +} + +/** Public origin for the deployed web app (production: https://pengine.net). */ +export const APP_ORIGIN: string = rawOrigin ?? "https://pengine.net"; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..5252099 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + /** Production site URL (see `.env.production`). */ + readonly VITE_APP_ORIGIN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index f2791c5..f28f0f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,12 +6,30 @@ import { createPengineViteLogger } from "./vite/pengine-logger"; const host = process.env.TAURI_DEV_HOST; +/** Split heavy `node_modules` so no single chunk exceeds the default 500 kB warning. */ +function manualChunks(id: string): string | undefined { + if (!id.includes("node_modules")) return; + if (/\/(?:react\/|react-dom\/|scheduler\/)/.test(id)) return "react-vendor"; + if (id.includes("react-router")) return "router"; + if (id.includes("@radix-ui") || id.includes("@dnd-kit")) return "ui-vendor"; + if (id.includes("@tauri-apps")) return "tauri"; + if (id.includes("qrcode.react")) return "qrcode"; + return undefined; +} + export default defineConfig(async () => { const clearScreen = false; return { customLogger: createPengineViteLogger("info", { allowClearScreen: clearScreen }), plugins: [tailwindcss(), react()], clearScreen, + build: { + rollupOptions: { + output: { + manualChunks, + }, + }, + }, server: { port: 1420, strictPort: true, @@ -27,5 +45,10 @@ export default defineConfig(async () => { ignored: ["**/src-tauri/**"], }, }, + // Production build preview — not Vite’s default 4173 (avoids clashes e.g. with other Vite apps / tooling); adjacent to dev :1420. + preview: { + port: 1422, + strictPort: true, + }, }; });