Infra: split dev and production (releases, services, deploy script)

- Production now runs from ~/freezmo-prod/current (release symlink) as a
  standalone server on :3207; releases are exported by scripts/deploy.sh,
  built and e2e-verified on a temp port, switched atomically, health-
  checked via the new /api/healthz (commit + build time) and rolled back
  automatically on failure. Last 5 releases kept; scripts/rollback.sh for
  manual rollback. Shared env + data live in ~/freezmo-prod/shared/ and
  are symlinked into every release (existing sync/push data migrated and
  verified identical).
- Dev runs from ~/freezmo via freezmo-dev.service: next dev on :3217
  (LAN only), its own build folder (.next-dev via NEXT_DIST_DIR), its own
  data (~/freezmo/.freezmo-dev), DEV badge, dev favicon/theme colour,
  "Freezmo Dev" PWA identity and never the production service worker.
- /api/healthz + the Settings Details version row (short commit + build
  time). output: standalone in next.config (next start still works).
- AGENTS.md documents the workflow rules: only dev updates during a task,
  temporary dirs/ports for test builds, and deploy-at-the-end as the only
  production path. README gains an Environments and deploy section.
- Fix found by the rehearsal: lib/dev-mode.ts must not be a client module
  (a server component importing a constant from a "use client" module
  gets a truthy proxy, which baked dev metadata into production builds).
- SW -> freezmo-v34.
This commit is contained in:
Martin Dvořák 2026-10-11 19:25:29 +00:00
commit 3f52ab47c2
22 changed files with 665 additions and 35 deletions

56
AGENTS.md Normal file
View file

@ -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://<lan-ip>: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).

View file

@ -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/<timestamp>-<sha>` |
| 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://<lan-ip>:3217`**, production =
`http://<lan-ip>: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 <commit> # 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

37
app/api/healthz/route.ts Normal file
View file

@ -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" } },
);
}

View file

@ -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,7 +70,9 @@ 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: [
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)

View file

@ -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",

View file

@ -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;
/>
<span className="flex min-w-0 items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
{IS_DEV_INSTANCE ? (
<span className="rounded-md border border-amber-400/60 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-300">
DEV
</span>
) : null}
{!compact ? (
<span className="hidden truncate text-[13px] text-[var(--text-subtle)] sm:inline">
{t("header.tagline")}

View file

@ -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 = () => {

View file

@ -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<HTMLInputElement>(null);
const settings = useSyncExternalStore(
settingsStore.subscribe,
@ -513,6 +515,17 @@ export function SettingsDialog({
mono
badge={modelBadge(health?.text)}
/>
{version ? (
<DetailRow
label={t("settings.version")}
value={
version.builtAt
? `${version.short} · ${new Date(version.builtAt).toLocaleString()}`
: version.short
}
mono
/>
) : null}
</dl>
{health?.mode === "direct-cloud" && health.needsKey ? (
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
@ -782,6 +795,12 @@ export function SettingsDialog({
) : null}
{health ? <HealthPanel health={health} /> : null}
{version ? (
<p className="mt-2 text-[11px] text-[var(--text-subtle)]">
{t("settings.version")}: <span className="font-mono">{version.short}</span>
{version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}
</p>
) : null}
{/* keeps real content out of the 36px fade strip at the bottom */}
<div className="h-9" aria-hidden />

View file

@ -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 ? (
<>
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
{IS_DEV_INSTANCE ? (
<span className="rounded-md border border-amber-400/60 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-300">
DEV
</span>
) : null}
</>
) : null}
</Link>
{!collapsed ? (

View file

@ -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

View file

@ -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/<name>,current -> releases/<name>,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

View file

@ -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/**",
],
},
];

7
lib/dev-mode.ts Normal file
View file

@ -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";

View file

@ -987,6 +987,7 @@ const en: Record<string, string> = {
"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<string, string> = {
"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.",

35
lib/use-version.ts Normal file
View file

@ -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<RunningVersion | null>(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;
}

View file

@ -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",

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Freezmo Dev">
<rect width="64" height="64" rx="14" fill="#1c1917"/>
<path d="M32 9 53 21v22L32 55 11 43V21Z" fill="none" stroke="#f59e0b" stroke-width="5" stroke-linejoin="round"/>
<path d="M11 21l21 12 21-12M32 33v22" fill="none" stroke="#f59e0b" stroke-width="4" stroke-linejoin="round"/>
<path d="M22 40h6l2.4-6 3.2 10 2.2-5h6" fill="none" stroke="#fbbf24" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Freezmo Dev">
<rect width="512" height="512" rx="112" fill="#1c1917"/>
<path d="M256 72 424 168v176L256 440 88 344V168Z" fill="none" stroke="#f59e0b" stroke-width="40" stroke-linejoin="round"/>
<path d="M88 168l168 96 168-96M256 264v176" fill="none" stroke="#f59e0b" stroke-width="32" stroke-linejoin="round"/>
<text x="256" y="316" text-anchor="middle" font-family="system-ui, sans-serif" font-size="96" font-weight="700" fill="#fbbf24">DEV</text>
</svg>

After

Width:  |  Height:  |  Size: 549 B

View file

@ -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`;

216
scripts/deploy.sh Executable file
View file

@ -0,0 +1,216 @@
#!/usr/bin/env bash
#
# Deploy Freezmo to production.
#
# scripts/deploy.sh # deploy the latest origin/main
# scripts/deploy.sh <commit|tag> # 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/<timestamp>-<sha>
# 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" <<JSON
{
"commit": "$TARGET",
"short": "$SHORT",
"builtAt": "$BUILT_AT",
"release": "$RELEASE"
}
JSON
ln -sfn "$PROD/shared/env.local" "$DIR/server/.env.local"
ln -sfn "$PROD/shared/freezmo" "$DIR/server/.freezmo"
# ------------------------------------------------------------------ #
# 3. Verify the new release on a temporary port #
# ------------------------------------------------------------------ #
if [[ "$RUN_TESTS" == "1" ]]; then
say "starting the new release on :$TEST_PORT for the e2e run…"
# The standalone server does not read .env files itself, so hand it the
# shared production env exactly like the systemd unit does.
set -a
# shellcheck disable=SC1090
source "$PROD/shared/env.local"
set +a
( cd "$DIR/server" && PORT="$TEST_PORT" HOSTNAME=127.0.0.1 node server.js >"$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"

59
scripts/rollback.sh Executable file
View file

@ -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"

View file

@ -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,