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:
parent
5ca0de920c
commit
3f52ab47c2
22 changed files with 665 additions and 35 deletions
56
AGENTS.md
Normal file
56
AGENTS.md
Normal 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).
|
||||
84
README.md
84
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/<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
37
app/api/healthz/route.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<>
|
||||
<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 ? (
|
||||
|
|
|
|||
40
deploy/freezmo-dev.service
Normal file
40
deploy/freezmo-dev.service
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
7
lib/dev-mode.ts
Normal 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";
|
||||
|
|
@ -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
35
lib/use-version.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
6
public/icons/dev-favicon.svg
Normal file
6
public/icons/dev-favicon.svg
Normal 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 |
6
public/icons/dev-icon.svg
Normal file
6
public/icons/dev-icon.svg
Normal 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 |
|
|
@ -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
216
scripts/deploy.sh
Executable 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
59
scripts/rollback.sh
Executable 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue