diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f68a00d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — working rules for this repo + +Freezmo has two environments. Every session — human or agent — follows these +rules. They are not optional: production is the app the user actually uses. + +## Environments + +| | Dev (this working tree) | Production | +| --- | --- | --- | +| Path | `~/freezmo` (where you edit) | `~/freezmo-prod/current` (release symlink) | +| Service | `freezmo-dev.service` | `freezmo.service` | +| Port | **3217** (`http://:3217`, LAN only) | **3207** + the public URL | +| Build folder | `.next-dev` (hot reload) | `.next/standalone` inside the release | +| Data | `~/freezmo/.freezmo-dev/` | `~/freezmo-prod/shared/freezmo/` | +| Env | `~/freezmo/.env.local` | `~/freezmo-prod/shared/env.local` | +| Identity | DEV badge, dev favicon/theme, "Freezmo Dev" PWA | normal | + +Dev runs `next dev` with hot reload; **all live, real-time changes go there**. +Production only ever runs a pushed, verified commit deployed by +`scripts/deploy.sh` (releases under `~/freezmo-prod/releases/`, last 5 kept, +`scripts/rollback.sh` to go back). + +## Rules + +1. **During a task, only the dev instance updates.** Never restart or rebuild + production mid-task; production changes only through `scripts/deploy.sh` at + the very end. +2. **Never build into a running instance's folder.** The dev service owns + `.next-dev`; `npm run build` (used by the test suites' spawned instances) + owns `.next`; a release build owns its own release directory. Any build or + test that needs a production build uses a temporary directory and port. +3. **At the end of a task**: run `npm run lint`, `npm run test:e2e` and + `npm run build`, bump the service-worker cache (`public/sw.js`), update the + README, commit, push to `main`, then run `scripts/deploy.sh`. Production + only ever runs a pushed, verified commit. +4. **`scripts/deploy.sh` is the only deploy path.** It refuses a dirty tree, + refuses commits that aren't on `origin/main`, runs lint + build + the full + e2e suite against the new release on a temporary port, switches the + `current` symlink atomically, health-checks `/api/healthz` for the new + commit and rolls back automatically on failure. +5. **The Ollama API key never leaves the browser.** It must never appear in + exports, sync payloads, share links, logs, URLs, `.env` files committed to + the repo, or release directories. Server data (sync blobs, push + subscriptions) lives only under `shared/`. +6. **Both languages, always.** Every user-facing string goes into + `lib/i18n-dict.ts` in **en and cs** (the i18n test enforces key parity). +7. **Phones are first-class**: ≤639px layouts, 44px touch targets, 16px + inputs, safe-area padding; verify at 360px and 390px as well as desktop. +8. **Versioned storage, never lose data.** Deck/homework/chat schemas are + validated with zod on read; backups and sync share the same payload schema. +9. **The test suites live in `tests/`** (`npm run test:e2e`, filterable: + `npm run test:e2e -- chat`). Add or extend a suite for every feature; a + green suite set is part of "done". Diagnostics that are not gates: + `tests/bubble-perf.mjs`, `tests/model-compare.mjs`. +10. **Keep the service worker honest**: bump `CACHE_VERSION` in `public/sw.js` + for every user-visible deploy (the deploy itself does not do this). diff --git a/README.md b/README.md index c6d6ff6..5c914a7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ no database, no data leaving your machine except the requests you send to Ollama - [Checking the connection](#checking-the-connection) - [Running on your network / from your phone](#running-on-your-network--from-your-phone) - [Keep it running as a service](#keep-it-running-as-a-service-systemd-user-service) +- [Environments and deploy](#environments-and-deploy) - [Folders](#folders) - [Install as an app (PWA)](#install-as-an-app-pwa) - [Security notes](#security-notes) @@ -494,37 +495,90 @@ Notes for phones: - Clipboard paste works best on desktop; on mobile use the camera or photo picker. - Add the page to your home screen for a full-screen, app-like experience. -### Keep it running as a service (systemd user service) +### Keep it running as a service (systemd user services) -`deploy/freezmo.service` runs the production build under **systemd (user)**, so Freezmo comes back by -itself after a reboot and restarts if it ever crashes: +Two units keep Freezmo alive — **production** (`deploy/freezmo.service`, the +release on :3207) and **dev** (`deploy/freezmo-dev.service`, hot reload on +:3217). Both come back after a reboot and restart if they crash: ```bash -npm run build # the service serves the built app mkdir -p ~/.config/systemd/user -cp deploy/freezmo.service ~/.config/systemd/user/ +cp deploy/freezmo.service deploy/freezmo-dev.service ~/.config/systemd/user/ systemctl --user daemon-reload -systemctl --user enable --now freezmo.service +systemctl --user enable --now freezmo.service freezmo-dev.service loginctl enable-linger "$USER" # start at boot without logging in ``` | Command | What it does | | --- | --- | -| `systemctl --user status freezmo` | is it up? | -| `journalctl --user -u freezmo -f` | live logs | -| `systemctl --user restart freezmo` | after a rebuild | -| `systemctl --user stop freezmo` | stop it | +| `systemctl --user status freezmo freezmo-dev` | are they up? | +| `journalctl --user -u freezmo-dev -f` | live dev logs (hot reload included) | +| `journalctl --user -u freezmo -f` | live production logs | +| `systemctl --user restart freezmo-dev` | after changing the dev unit | -It binds `0.0.0.0:3207` exactly like `npm start` and reads `.env.local` from the project directory. -The unit refuses to start without `.next/BUILD_ID`, so run `npm run build` before enabling it — and -again after pulling changes. +Production is **never** rebuilt or restarted by hand — `scripts/deploy.sh` +does that, and only with a pushed, verified commit. The dev instance reads +`~/freezmo/.env.local`, builds into `.next-dev` and keeps its data in +`.freezmo-dev/`, so nothing it does can touch production. See +[Environments and deploy](#environments-and-deploy) and +[AGENTS.md](AGENTS.md) for the rules. -Because the build and the running server share `.next/`, stop the service before a manual rebuild: +--- + +## Environments and deploy + +Freezmo runs as two separate instances so that live work never touches what +you actually use: + +| | **Dev** | **Production** | +| --- | --- | --- | +| Path | `~/freezmo` (the working tree) | `~/freezmo-prod/current` → `releases/-` | +| Service | `freezmo-dev.service` | `freezmo.service` | +| Port | **3217** — LAN only, hot reload | **3207** — the public URL | +| Build | `.next-dev` (dev server) | standalone server inside the release | +| Data | `~/freezmo/.freezmo-dev/` | `~/freezmo-prod/shared/freezmo/` (sync, push) | +| Env | `~/freezmo/.env.local` | `~/freezmo-prod/shared/env.local` | +| Look | DEV badge, dev favicon/theme, installs as **Freezmo Dev** | normal | + +On your phone: **dev = `http://:3217`**, production = +`http://:3207` (and the public URL). The dev instance is bound to the +LAN only — nothing routes port 3217 through the public proxy — so source maps +and the HMR socket never leave the network. It uses a different origin, which +means separate storage and a separate service worker: the production worker is +never registered there and both PWAs can be installed side by side. + +`NEXT_DIST_DIR` keeps the build folders apart (dev uses `.next-dev`, a plain +`npm run build` keeps producing `.next` for the test suites), so no build can +clobber a running server. + +### Deploying ```bash -systemctl --user stop freezmo && npm run build && systemctl --user start freezmo +scripts/deploy.sh # deploy the latest origin/main +scripts/deploy.sh # a specific pushed commit +scripts/deploy.sh --skip-tests # fast path (lint + build only) +scripts/rollback.sh # back to the previous release ``` +`deploy.sh` refuses a dirty working tree and commits that aren't on +`origin/main`, exports the commit into a fresh release directory, runs +`npm ci`, lint and the production build there, starts the new release on a +temporary port and runs the **full browser suite against it** (the running +production is untouched), then links the shared env + data, writes +`release.json`, flips the `current` symlink atomically, restarts the service +and polls `/api/healthz` until the new commit answers. Any failure after the +switch rolls back to the previous release automatically. The last 5 releases +are kept. The running version (short commit + build time) is shown in +Settings → Details. + +Data that must survive deploys lives in `~/freezmo-prod/shared/`: +`env.local`, and `freezmo/{sync,push}` (symlinked into every release as +`.env.local` and `.freezmo`). + +The day-to-day rules — only dev updates during a task, production changes only +through the deploy script at the end — are written down in +[AGENTS.md](AGENTS.md) so every future session follows them. + --- ## Folders diff --git a/app/api/healthz/route.ts b/app/api/healthz/route.ts new file mode 100644 index 0000000..d52d2cb --- /dev/null +++ b/app/api/healthz/route.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Deploy health check: reports which release is running. The deploy script + * writes `release.json` next to the server (in the standalone root) and polls + * this route after switching `current`; a mismatch or an unreachable server + * triggers the automatic rollback. + * + * In a dev checkout there is no release.json — the route answers with the + * commit baked into the environment, or "dev". + */ +export async function GET() { + let release: { commit?: string; short?: string; builtAt?: string; release?: string } | null = null; + try { + release = JSON.parse(readFileSync(path.join(process.cwd(), "release.json"), "utf8")); + } catch { + release = null; + } + + const commit = release?.commit ?? process.env.FREEZMO_COMMIT ?? "dev"; + return Response.json( + { + ok: true, + commit, + short: release?.short ?? commit.slice(0, 7), + builtAt: release?.builtAt ?? null, + release: release?.release ?? null, + dev: commit === "dev", + uptimeSeconds: Math.round(process.uptime()), + }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index fec4a7f..1343f4e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,9 @@ import { InstallSheet } from "@/components/InstallSheet"; import { OfflineBanner } from "@/components/OfflineBanner"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { Toaster } from "@/components/Toaster"; +import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; + +const devInstanceJs = IS_DEV_INSTANCE ? "true" : "false"; import "./globals.css"; export const metadata: Metadata = { @@ -33,7 +36,12 @@ export const metadata: Metadata = { { url: "/icons/splash-1668x2388.png", media: "(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" }, ], }, - icons: { + icons: IS_DEV_INSTANCE + ? { + // Dev wears its own favicon so the two instances are never confused. + icon: [{ url: "/icons/dev-favicon.svg", type: "image/svg+xml" }], + } + : { icon: [ // ?v=2 busts browser favicon caches after the icon redesign. { url: "/icons/favicon-32.png?v=2", sizes: "32x32", type: "image/png" }, @@ -62,10 +70,12 @@ export const viewport: Viewport = { // font-size rule in globals.css stops iOS Safari's focus auto-zoom, which // ignores the viewport meta anyway. viewportFit: "cover", - themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f4f7fd" }, - { media: "(prefers-color-scheme: dark)", color: "#0e1424" }, - ], + themeColor: IS_DEV_INSTANCE + ? [{ media: "(prefers-color-scheme: light)", color: "#b45309" }, { media: "(prefers-color-scheme: dark)", color: "#b45309" }] + : [ + { media: "(prefers-color-scheme: light)", color: "#f4f7fd" }, + { media: "(prefers-color-scheme: dark)", color: "#0e1424" }, + ], }; /** @@ -73,7 +83,7 @@ export const viewport: Viewport = { * `theme-color` meta in step with the *app* theme (the SSR metas follow the * OS preference, which is wrong once the user has picked a theme here). */ -const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',d?'#0e1424':'#f4f7fd');}catch(e){}})();`; +const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(d?'#0e1424':'#f4f7fd'));}catch(e){}})();`; /** * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) diff --git a/app/manifest.ts b/app/manifest.ts index c03ba08..205c105 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -4,7 +4,35 @@ import type { MetadataRoute } from "next"; * Web app manifest — what makes Freezmo installable on iOS/Android * (iOS reads the apple-* meta tags in app/layout.tsx as well). */ +const devInstance = process.env.NEXT_PUBLIC_FREEZMO_DEV === "1"; + export default function manifest(): MetadataRoute.Manifest { + if (devInstance) { + return { + // A distinct identity so the dev PWA installs next to the real one. + id: "/dev", + name: "Freezmo Dev — notes to study feed", + short_name: "Freezmo Dev", + description: "Development instance of Freezmo (hot reload). Not the production app.", + start_url: "/", + scope: "/", + display: "standalone", + display_override: ["standalone", "minimal-ui"], + orientation: "portrait", + background_color: "#1c1917", + theme_color: "#b45309", + categories: ["education", "productivity"], + icons: [ + { src: "/icons/dev-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + ], + shortcuts: [ + { name: "New deck", short_name: "New", url: "/create" }, + { name: "Library", short_name: "Library", url: "/" }, + ], + }; + } + return { id: "/", name: "Freezmo — notes to study feed", diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 74f9417..957092b 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { SettingsDialog } from "@/components/SettingsDialog"; import { TabBar } from "@/components/TabBar"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; import { useI18n } from "@/lib/i18n"; import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events"; import { cn } from "@/lib/utils"; @@ -61,6 +62,11 @@ export function AppHeader({ className, compact = false }: { className?: string; /> Freezmo + {IS_DEV_INSTANCE ? ( + + DEV + + ) : null} {!compact ? ( {t("header.tagline")} diff --git a/components/ServiceWorkerRegistrar.tsx b/components/ServiceWorkerRegistrar.tsx index 1d039d2..37a6c63 100644 --- a/components/ServiceWorkerRegistrar.tsx +++ b/components/ServiceWorkerRegistrar.tsx @@ -2,6 +2,8 @@ import { useEffect } from "react"; +import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; + /** * Registers the service worker in production builds only. * @@ -11,6 +13,8 @@ import { useEffect } from "react"; export function ServiceWorkerRegistrar() { useEffect(() => { if (process.env.NODE_ENV !== "production") return; + // A dev instance never registers the production worker, whatever the build. + if (IS_DEV_INSTANCE) return; if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; const register = () => { diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index d014aec..70a1b03 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion, useDragControls, useReducedMotion } from "fram import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { LOCALES, useI18n, type Locale } from "@/lib/i18n"; +import { useVersion } from "@/lib/use-version"; import { haptic } from "@/lib/haptics"; import { modelErrorCopy } from "@/lib/model-errors"; import { springSheet } from "@/lib/motion"; @@ -53,6 +54,7 @@ export function SettingsDialog({ const { health, loading, error, refresh, apiKey, checkedAt } = useHealth(); const installAvailable = useInstallAvailable(); const { locale, setLocale, t, plural } = useI18n(); + const version = useVersion(); const keyInputRef = useRef(null); const settings = useSyncExternalStore( settingsStore.subscribe, @@ -513,6 +515,17 @@ export function SettingsDialog({ mono badge={modelBadge(health?.text)} /> + {version ? ( + + ) : null} {health?.mode === "direct-cloud" && health.needsKey ? (

@@ -782,6 +795,12 @@ export function SettingsDialog({ ) : null} {health ? : null} + {version ? ( +

+ {t("settings.version")}: {version.short} + {version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""} +

+ ) : null} {/* keeps real content out of the 36px fade strip at the bottom */}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 78424e9..ee89d08 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -20,6 +20,7 @@ import { import { useTheme } from "@/components/ThemeToggle"; import { createFolder, folderStore } from "@/lib/folders"; import { haptic } from "@/lib/haptics"; +import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; import { useI18n } from "@/lib/i18n"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { UNORGANIZED_ID } from "@/lib/schemas"; @@ -102,7 +103,14 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { className="size-7 shrink-0 rounded-lg shadow-[0_1px_3px_rgb(14_21_40/0.35)]" /> {!collapsed ? ( - Freezmo + <> + Freezmo + {IS_DEV_INSTANCE ? ( + + DEV + + ) : null} + ) : null} {!collapsed ? ( diff --git a/deploy/freezmo-dev.service b/deploy/freezmo-dev.service new file mode 100644 index 0000000..bc05891 --- /dev/null +++ b/deploy/freezmo-dev.service @@ -0,0 +1,40 @@ +[Unit] +# Freezmo — dev instance (hot reload, LAN only). +# +# This is the working tree (~/freezmo). Everything live happens here: edits are +# visible within seconds, its build folder is .next-dev (a production build can +# never clobber it) and its data lives in ~/freezmo/.freezmo-dev, so test +# shares, sync blobs and rate limits never touch production data. +# +# It binds the LAN so the phone can reach it, but nothing routes this port +# through the public proxy — production stays the only public instance. +# It shows a DEV badge, uses a dev favicon/theme colour, installs as +# "Freezmo Dev" (separate origin, separate storage) and never registers the +# production service worker. +# +# Install (per user, no root needed): +# cp ~/freezmo/deploy/freezmo-dev.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now freezmo-dev.service +# Logs: journalctl --user -u freezmo-dev -f +Description=Freezmo dev instance (hot reload, LAN only) +Documentation=file://%h/freezmo/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/freezmo +Environment=NEXT_TELEMETRY_DISABLED=1 +Environment=NEXT_DIST_DIR=.next-dev +Environment=NEXT_PUBLIC_FREEZMO_DEV=1 +Environment=SYNC_DIR=%h/freezmo/.freezmo-dev/sync +Environment=PUSH_DIR=%h/freezmo/.freezmo-dev/push +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/bin/node %h/freezmo/node_modules/next/dist/bin/next dev -H 0.0.0.0 -p 3217 +Restart=always +RestartSec=2 +TimeoutStartSec=0 + +[Install] +WantedBy=default.target diff --git a/deploy/freezmo.service b/deploy/freezmo.service index 87f115e..32b7f16 100644 --- a/deploy/freezmo.service +++ b/deploy/freezmo.service @@ -1,13 +1,21 @@ [Unit] -# Freezmo — self-hosted notes-to-study-feed app. +# Freezmo — production release (self-hosted study app). +# +# Runs the "current" release symlink: scripts/deploy.sh atomically flips it and +# restarts this unit, so production only ever runs a pushed, verified commit. +# Layout: ~/freezmo-prod/{releases/,current -> releases/,shared} +# current/server/server.js standalone Next server (built by the deploy) +# current/server/.env.local -> shared/env.local +# current/server/.freezmo -> shared/freezmo (sync/, push/) +# # Install (per user, no root needed): # mkdir -p ~/.config/systemd/user -# cp deploy/freezmo.service ~/.config/systemd/user/ +# cp ~/freezmo/deploy/freezmo.service ~/.config/systemd/user/ # systemctl --user daemon-reload # systemctl --user enable --now freezmo.service # loginctl enable-linger "$USER" # start at boot without logging in # Logs: journalctl --user -u freezmo -f -Description=Freezmo (self-hosted study app) +Description=Freezmo (self-hosted study app, production) Documentation=file://%h/freezmo/README.md After=network-online.target Wants=network-online.target @@ -16,15 +24,19 @@ After=ollama.service [Service] Type=simple -WorkingDirectory=%h/freezmo +# The standalone server keeps its own cwd; the release carries shared env/data +# as symlinks, and EnvironmentFile is the belt to that pair of braces. +WorkingDirectory=%h/freezmo-prod/current/server Environment=NODE_ENV=production Environment=NEXT_TELEMETRY_DISABLED=1 +Environment=PORT=3207 +Environment=HOSTNAME=0.0.0.0 Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -# Refuse to start without a production build — clearer than next crashing in a -# restart loop. Run `npm run build` in %h/freezmo first. -AssertPathExists=%h/freezmo/.next/BUILD_ID -# Next.js loads .env.local (OLLAMA_*) from the working directory itself. -ExecStart=/bin/node %h/freezmo/node_modules/next/dist/bin/next start -H 0.0.0.0 -p 3207 +EnvironmentFile=-%h/freezmo-prod/shared/env.local +# Refuse to start without a release — clearer than next crashing in a restart +# loop. Run scripts/deploy.sh (from %h/freezmo) first. +AssertPathExists=%h/freezmo-prod/current/server/server.js +ExecStart=/bin/node %h/freezmo-prod/current/server/server.js Restart=always RestartSec=3 TimeoutStartSec=0 diff --git a/eslint.config.mjs b/eslint.config.mjs index 43d942e..b045cfd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,11 +15,15 @@ const eslintConfig = [ ignores: [ "node_modules/**", ".next/**", + // The dev service's build folder (NEXT_DIST_DIR). + ".next-dev/**", "out/**", "build/**", "next-env.d.ts", // Generated at build/dev time by scripts/copy-pdf-worker.mjs. "public/pdf.worker.min.mjs", + // Test scratch: rehearsal standalone copies carry a full node_modules. + "tests/.tmp/**", ], }, ]; diff --git a/lib/dev-mode.ts b/lib/dev-mode.ts new file mode 100644 index 0000000..f677a11 --- /dev/null +++ b/lib/dev-mode.ts @@ -0,0 +1,7 @@ +/** + * True when this build is the development instance (`freezmo-dev.service` + * sets NEXT_PUBLIC_FREEZMO_DEV=1). Baked in at build/start time, so the dev + * server shows its badge, favicon, theme colour and PWA identity while a + * production build is completely unaffected. + */ +export const IS_DEV_INSTANCE = process.env.NEXT_PUBLIC_FREEZMO_DEV === "1"; diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 80df8a8..261aae4 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -987,6 +987,7 @@ const en: Record = { "chat.savedToNotes": "Saved to {deck}'s notes", "chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)", "chat.savedFromChat": "Saved from chat", + "settings.version": "Version", "settings.chatNotes": "Keep notes text for chat", "settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.", "server.chatModelNotAllowed": "That chat model is not on the server's allowlist.", @@ -1919,6 +1920,7 @@ const cs: Record = { "chat.savedToNotes": "Uloženo do poznámek sady {deck}", "chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)", "chat.savedFromChat": "Uloženo z chatu", + "settings.version": "Verze", "settings.chatNotes": "Uchovávat text poznámek pro chat", "settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.", "server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.", diff --git a/lib/use-version.ts b/lib/use-version.ts new file mode 100644 index 0000000..9b1f753 --- /dev/null +++ b/lib/use-version.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export type RunningVersion = { + commit: string; + short: string; + builtAt: string | null; + release: string | null; + dev: boolean; +}; + +/** Which release is running (from /api/healthz), for the Settings details. */ +export function useVersion(): RunningVersion | null { + const [version, setVersion] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const response = await fetch("/api/healthz", { cache: "no-store" }); + if (!response.ok) return; + const body = (await response.json()) as RunningVersion; + if (!cancelled) setVersion(body); + } catch { + /* offline or old server — the row stays hidden */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return version; +} diff --git a/next.config.ts b/next.config.ts index d0b931b..0d9cebb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,12 +2,27 @@ import type { NextConfig } from "next"; /** * Freezmo is meant to be opened from a phone on the same network, so the dev - * server (which binds 0.0.0.0:3207) should accept dev-asset requests coming - * from private hostnames. Production builds are not affected by this option. + * server should accept dev-asset requests coming from private hostnames. + * Production builds are not affected by this option. * * Add your own hostname here if Next still warns about a cross-origin request. */ const nextConfig: NextConfig = { + /** + * Dev and production never share a build folder: the dev service runs with + * NEXT_DIST_DIR=.next-dev (see deploy/freezmo-dev.service), while a manual + * `npm run build` keeps producing .next for the test suites. A production + * build can therefore never clobber the running dev server, and the other + * way around. + */ + distDir: process.env.NEXT_DIST_DIR || ".next", + /** + * Releases ship as self-contained standalone output: scripts/deploy.sh + * copies public/ and .next/static next to `.next/standalone/server.js` and + * runs that. `next start` keeps working too, so the existing workflows and + * the e2e suites are unaffected. + */ + output: "standalone", allowedDevOrigins: [ "127.0.0.1", "localhost", diff --git a/public/icons/dev-favicon.svg b/public/icons/dev-favicon.svg new file mode 100644 index 0000000..ee138dc --- /dev/null +++ b/public/icons/dev-favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons/dev-icon.svg b/public/icons/dev-icon.svg new file mode 100644 index 0000000..2573974 --- /dev/null +++ b/public/icons/dev-icon.svg @@ -0,0 +1,6 @@ + + + + + DEV + diff --git a/public/sw.js b/public/sw.js index abcabbf..18e4b37 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v33"; +const CACHE_VERSION = "freezmo-v34"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..b19b56e --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# +# Deploy Freezmo to production. +# +# scripts/deploy.sh # deploy the latest origin/main +# scripts/deploy.sh # deploy a specific pushed commit +# scripts/deploy.sh --skip-tests # skip the pre-switch e2e run (fast path) +# +# What it does, in order: +# 1. refuses to run unless the working tree is clean and the target commit is +# already pushed to origin/main (production only runs pushed, verified code) +# 2. exports the target commit into ~/freezmo-prod/releases/- +# 3. npm ci, lint, production build (standalone output) inside that release +# 4. (default) starts the new release on a temporary port and runs the full +# browser suite against it — the running production is never touched +# 5. links shared env + data, writes release.json, flips `current` atomically, +# restarts the service and polls /api/healthz for the new commit +# 6. on any failure after the switch it rolls back to the previous release +# 7. keeps the last 5 releases +# +# Overridable: FREEZMO_REPO, FREEZMO_PROD, FREEZMO_SERVICE, FREEZMO_PORT, +# FREEZMO_KEEP, FREEZMO_TEST_PORT, FREEZMO_HEALTH_TRIES (0 forces a rollback — +# handy for testing the failure path). + +set -euo pipefail + +REPO="${FREEZMO_REPO:-$HOME/freezmo}" +PROD="${FREEZMO_PROD:-$HOME/freezmo-prod}" +SERVICE="${FREEZMO_SERVICE:-freezmo}" +PORT="${FREEZMO_PORT:-3207}" +KEEP="${FREEZMO_KEEP:-5}" +TEST_PORT="${FREEZMO_TEST_PORT:-3299}" +HEALTH_TRIES="${FREEZMO_HEALTH_TRIES:-45}" +RUN_TESTS=1 +TARGET_ARG="" + +for arg in "$@"; do + case "$arg" in + --skip-tests) RUN_TESTS=0 ;; + --with-tests) RUN_TESTS=1 ;; + -h|--help) sed -n '2,24p' "$0"; exit 0 ;; + *) TARGET_ARG="$arg" ;; + esac +done + +say() { printf '\033[1m[deploy]\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; } + +# ------------------------------------------------------------------ # +# 1. Preconditions # +# ------------------------------------------------------------------ # + +[[ -d "$REPO/.git" ]] || fail "no git repository at $REPO" +cd "$REPO" + +if [[ -n "$(git status --porcelain)" ]]; then + fail "working tree is not clean — commit or stash first (production only runs committed code)" +fi + +say "fetching origin/main…" +git fetch origin main --quiet + +TARGET_REF="${TARGET_ARG:-main}" +TARGET="$(git rev-parse --verify "$TARGET_REF^{commit}")" || fail "unknown commit: $TARGET_REF" + +if ! git merge-base --is-ancestor "$TARGET" origin/main; then + fail "commit $TARGET is not on origin/main — push it first" +fi + +SHORT="$(git rev-parse --short "$TARGET")" +RELEASE="$(date +%Y%m%d-%H%M%S)-$SHORT" +DIR="$PROD/releases/$RELEASE" + +say "deploying $SHORT as $RELEASE" +mkdir -p "$PROD/releases" "$PROD/shared/freezmo/sync" "$PROD/shared/freezmo/push" + +# ------------------------------------------------------------------ # +# 2. Export + build the release # +# ------------------------------------------------------------------ # + +mkdir -p "$DIR/src" +git archive "$TARGET" | tar -x -C "$DIR/src" +say "release exported to $DIR/src" + +cd "$DIR/src" +say "npm ci…" +npm ci --no-audit --no-fund >/dev/null + +say "lint…" +npm run lint + +say "production build…" +npm run build + +BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Assemble the standalone runtime: server.js + pruned node_modules, with +# public/ and .next/static copied next to it (Next does not copy those). +say "assembling standalone runtime…" +mkdir -p "$DIR/server/.next" +cp -r "$DIR/src/.next/standalone/." "$DIR/server/" +cp -r "$DIR/src/.next/static" "$DIR/server/.next/static" +cp -r "$DIR/src/public" "$DIR/server/public" + +cat > "$DIR/server/release.json" <"$DIR/test-server.log" 2>&1 ) & + TEST_PID=$! + cleanup_test() { kill "$TEST_PID" >/dev/null 2>&1 || true; wait "$TEST_PID" 2>/dev/null || true; } + trap cleanup_test EXIT + + ready=0 + for _ in $(seq 1 60); do + if curl -fsS --max-time 2 "http://127.0.0.1:$TEST_PORT/api/healthz" >/dev/null 2>&1; then ready=1; break; fi + sleep 1 + done + [[ "$ready" == "1" ]] || { cat "$DIR/test-server.log" >&2; fail "the new release did not start on :$TEST_PORT"; } + + say "running the browser suites against the new release…" + # Spawned test instances run from the release checkout (hermetic), while the + # suites themselves talk to the release over HTTP. + ( cd "$REPO" && FREEZMO_URL="http://127.0.0.1:$TEST_PORT" FREEZMO_SPAWN_CWD="$DIR/src" npm run test:e2e ) + + cleanup_test + trap - EXIT + say "suites passed" +else + say "skipping the e2e run (--skip-tests)" +fi + +# ------------------------------------------------------------------ # +# 4. Switch, restart, health-check (roll back on failure) # +# ------------------------------------------------------------------ # + +PREVIOUS="$(readlink -f "$PROD/current" 2>/dev/null || true)" + +health_ok() { + local expected="$1" body commit + for ((i = 0; i < HEALTH_TRIES; i++)); do + body="$(curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/healthz" 2>/dev/null || true)" + if [[ -n "$body" ]]; then + commit="$(printf '%s' "$body" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4 || true)" + # Compare short prefixes: the target may be a full sha while the release + # directory (and rollbacks) only carry the short one. + if [[ -n "$commit" && "${commit:0:7}" == "${expected:0:7}" ]]; then return 0; fi + fi + sleep 1 + done + return 1 +} + +rollback() { + if [[ -n "$PREVIOUS" && -d "$PREVIOUS" ]]; then + say "rolling back to $(basename "$PREVIOUS")…" + ln -sfn "$PREVIOUS" "$PROD/current.tmp" && mv -T "$PROD/current.tmp" "$PROD/current" + systemctl --user restart "$SERVICE" || true + if health_ok "$(basename "$PREVIOUS" | sed 's/.*-//')"; then + say "rollback healthy — production is back on $(basename "$PREVIOUS")" + else + say "rollback restarted, but the health check is still failing — check journalctl --user -u $SERVICE" + fi + else + say "no previous release to roll back to" + fi + fail "deploy failed — $RELEASE is not live" +} + +say "switching current -> $RELEASE…" +ln -sfn "$DIR" "$PROD/current.tmp" +mv -T "$PROD/current.tmp" "$PROD/current" + +say "restarting $SERVICE…" +systemctl --user restart "$SERVICE" + +if ! health_ok "$TARGET"; then + rollback +fi +say "health check passed: $SHORT is live on :$PORT" + +# ------------------------------------------------------------------ # +# 5. Prune old releases (keep the last $KEEP, never the live one) # +# ------------------------------------------------------------------ # + +LIVE="$(readlink -f "$PROD/current")" +mapfile -t OLD < <(ls -1dt "$PROD"/releases/*/ 2>/dev/null | tail -n +$((KEEP + 1))) +for old_dir in "${OLD[@]:-}"; do + [[ -n "$old_dir" ]] || continue + [[ "$(readlink -f "$old_dir")" == "$LIVE" ]] && continue + say "pruning $(basename "$old_dir")" + rm -rf "$old_dir" +done + +say "done — $RELEASE is live (commit $SHORT, built $BUILT_AT)" +say "production: http://127.0.0.1:$PORT · logs: journalctl --user -u $SERVICE -f" diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..8f927f5 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Roll production back to the previous release (or to a named one). +# +# scripts/rollback.sh # previous release +# scripts/rollback.sh 20261011-193000-5ca0de9 +# +# It flips the `current` symlink, restarts the service and health-checks the +# result, so a manual rollback is the same atomic operation the deploy script +# performs on failure. + +set -euo pipefail + +PROD="${FREEZMO_PROD:-$HOME/freezmo-prod}" +SERVICE="${FREEZMO_SERVICE:-freezmo}" +PORT="${FREEZMO_PORT:-3207}" +HEALTH_TRIES="${FREEZMO_HEALTH_TRIES:-45}" + +say() { printf '\033[1m[rollback]\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m[rollback]\033[0m %s\n' "$*" >&2; exit 1; } + +LIVE="$(readlink -f "$PROD/current" 2>/dev/null || true)" +[[ -n "$LIVE" ]] || fail "no current release at $PROD/current" + +if [[ -n "${1:-}" ]]; then + TARGET="$(readlink -f "$PROD/releases/$1" 2>/dev/null || true)" + [[ -n "$TARGET" && -d "$TARGET" ]] || fail "release not found: $1" +else + TARGET="" + for dir in "$PROD"/releases/*/; do + resolved="$(readlink -f "$dir")" + if [[ "$resolved" != "$LIVE" ]]; then + TARGET="$resolved" + break + fi + done + [[ -n "$TARGET" ]] || fail "no previous release to roll back to" +fi + +say "current: $(basename "$LIVE")" +say "switching to: $(basename "$TARGET")" +ln -sfn "$TARGET" "$PROD/current.tmp" +mv -T "$PROD/current.tmp" "$PROD/current" + +systemctl --user restart "$SERVICE" + +EXPECTED="$(grep -o '"commit":"[^"]*"' "$TARGET/server/release.json" 2>/dev/null | cut -d'"' -f4 || true)" +[[ -n "$EXPECTED" ]] || EXPECTED="$(basename "$TARGET" | sed 's/.*-//')" +for ((i = 0; i < HEALTH_TRIES; i++)); do + body="$(curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/healthz" 2>/dev/null || true)" + commit="$(printf '%s' "$body" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4 || true)" + if [[ -n "$commit" && "${commit:0:7}" == "${EXPECTED:0:7}" ]]; then + say "healthy — production is on $(basename "$TARGET")" + exit 0 + fi + sleep 1 +done + +fail "health check did not confirm $(basename "$TARGET") — check journalctl --user -u $SERVICE" diff --git a/tests/lib.mjs b/tests/lib.mjs index 2c03daa..c35ed79 100644 --- a/tests/lib.mjs +++ b/tests/lib.mjs @@ -17,6 +17,12 @@ import path from "node:path"; export const BASE = process.env.FREEZMO_URL || "http://127.0.0.1:3207"; export const REPO = path.resolve(new URL("..", import.meta.url).pathname); +/** + * Where suites that spawn their own `next start` instances run from. Defaults + * to the repo the tests live in; scripts/deploy.sh points it at the release + * checkout so the pre-switch e2e run is hermetic. + */ +export const SPAWN_CWD = process.env.FREEZMO_SPAWN_CWD || REPO; /* ------------------------------------------------------------------ */ /* Browser */ @@ -276,7 +282,7 @@ export async function waitForHttp(url, attempts = 60) { */ export function spawnApp(port, env = {}) { const server = spawn("npx", ["next", "start", "-p", String(port), "-H", "127.0.0.1"], { - cwd: REPO, + cwd: SPAWN_CWD, env: { ...process.env, ...env }, stdio: "ignore", detached: true,