fuzzyworld

fuzzyworld-tools / commit

Add fw deploy, vite plugin and apps.fuzzyworld.net index

f9ca0752208e0eda12fb21a62b22c6653beb19a8 · Pavel Safronov · 2026-10-26 17:20:32 -0700 · parents 623239b70769

Claude Code usage: this commit ~130.7K out / ~32.3M cached · cost unknown (no per-model trailer) · session 1 tally 1.1K in / 646.4K out / 116.0M cached · usage by commit and session

Add fw deploy, vite plugin and apps.fuzzyworld.net index

fw deploy builds a fresh clone of the pushed branch, runs the repo's
checks, verifies that URLs, manifest and service worker stay inside
/<app>/, then uploads to apps.fuzzyworld.net/<app>/. The vite plugin
sets base from package.json. The apps index is generated from deployed
apps; engye redirects to its Cloudflare Pages domain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Usage: 1.1K in / 646.4K out / 116.0M cached (session tally)
rtk:   2.3M saved, 55.6% over 4950 cmds (global/lifetime)
 README.md                   |  50 ++++++++-
 bin/fw                      |  12 ++-
 lib/deploy.mjs              | 242 ++++++++++++++++++++++++++++++++++++++++++++
 package.json                |  22 ++++
 server/fw                   |  17 +++-
 server/gen.py               |  53 +++++++++-
 server/install.sh           |   4 +
 server/site-apps/extra.json |   8 ++
 server/site-apps/htaccess   |  34 +++++++
 vite/index.js               |  39 +++++++
 10 files changed, 468 insertions(+), 13 deletions(-)

diff --git a/README.md b/README.md
index 7c3fc55..3fc975e 100644
--- a/README.md
+++ b/README.md
@@ -3,16 +3,56 @@
 Tooling for Pavel's self-hosted code and apps on DreamHost:
 
 - **code.fuzzyworld.net**: static browse pages and read-only `git clone` for public repos
-- **apps.fuzzyworld.net**: deployed web apps (`fw deploy`, Phase 4 of the migration; not built yet)
+- **apps.fuzzyworld.net**: deployed web apps, one per path (`/bubbles/`, `/slide/`, ...), published with `fw deploy`
 
 There is no app server. Pages are generated when you push; Apache only serves files.
 
+## Making a repo deploy as an app
+
+Add to the app repo's `package.json`:
+
+```jsonc
+"scripts": { "deploy": "fw deploy" },
+"fuzzyworld": {
+  "app": "my-game",                  // URL: apps.fuzzyworld.net/my-game/   (required)
+  "title": "My Game",                // apps index
+  "description": "One line.",        // apps index
+  "repo": "my-game",                 // DreamHost repo name, if different from app
+  "prebuild": ["pnpm gen-assets"],   // commands before checks and build
+  "checks": ["typecheck", "test"],   // scripts that must pass (default: whichever of typecheck/lint/test exist)
+  "build": "make public",            // default: `<pm> run build`
+  "out": "dist"                      // default: dist, else public, else build
+},
+"devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#v1" }
+```
+
+For vite, add the plugin; it sets `base` to `/<app>/` for builds (dev server unchanged):
+
+```ts
+import fuzzyworld from 'fuzzyworld-tools/vite';
+export default defineConfig({ plugins: [fuzzyworld()] });
+```
+
+Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**:
+
+1. clones branch `main` of `dh:git/<repo>.git` into a temp dir. It builds what is pushed, never your working copy (`--ref <branch>` for another branch, `--here` to build the current directory as is)
+2. installs with the lockfile's package manager (`pnpm install --frozen-lockfile`, `npm ci`, `yarn install --frozen-lockfile`)
+3. runs `prebuild`, then the `checks`, then the build. Any failure stops the deploy.
+4. checks the output: every same-site URL in `index.html`, the web manifest's `id`/`scope`/`start_url`, and every `serviceWorker.register(...)` script and scope must stay inside `/<app>/`. That keeps apps on the shared origin from capturing each other, and makes each one install separately on Android. Any problem stops the deploy.
+5. `rsync --delete` to `apps.fuzzyworld.net/<app>/`, writes `.fuzzyworld.json` (commit, time; not web-readable), and refreshes the apps index and the repo's code page
+6. prints the URL
+
+`--dry-run` does everything except upload.
+
+Apps hosted elsewhere are listed on the index from `server/site-apps/extra.json` (engye: Cloudflare Pages; `apps.fuzzyworld.net/engye/...` redirects there).
+
 ## Commands
 
-On the laptop, `bin/fw` forwards repo commands to DreamHost (`ssh dh ~/bin/fw ...`):
+On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to DreamHost (`ssh dh ~/bin/fw ...`):
 
 | Command | What it does |
 |---|---|
+| `fw deploy [--ref B] [--here] [--dry-run]` | build and publish this repo's app (above) |
 | `fw list` | every repo, its visibility and last commit |
 | `fw new <name> [--public] [--description TEXT]` | create `~/git/<name>.git` on DreamHost; **private unless `--public`** |
 | `fw public <name>` | publish the source on code.fuzzyworld.net right away |
@@ -38,6 +78,7 @@ Other settings in the repo config: `fuzzyworld.description`, plus `fuzzyworld.gi
 | `~/git/<name>.git` | canonical bare repos (push here; not web-visible) |
 | `~/code.fuzzyworld.net/<name>/` | generated pages for public repos |
 | `~/code.fuzzyworld.net/<name>.git` | public clone copy (dumb HTTP), refreshed on push |
+| `~/apps.fuzzyworld.net/<app>/` | deployed apps (`fw deploy`); `index.html` at the root is generated |
 | `~/fw/tools/server/` | this repo's `server/` directory, installed by `server/install.sh` |
 | `~/bin/fw` | symlink to `~/fw/tools/server/fw` |
 | `~/fw/state/` | per-repo page manifests and locks |
@@ -70,6 +111,7 @@ The site never executes anything from a repo:
 - raw copies are written only for image extensions; `.htaccess` also denies `.php`, `.shtml`, `.cgi`, `.pl`, `.py` and `.sh` and disables SSI/CGI (DreamHost runs `.php` files and SSI otherwise; tested 2026-10-26)
 - `Content-Security-Policy: script-src 'none'` on every response (this also covers SVG images)
 - inside public clone copies, `config`, `description`, `hooks/` and `logs/` return 404
+- apps.fuzzyworld.net gets the same no-execution rules. HTML, service workers and manifests are sent with `no-cache` (a deploy is seen immediately); `/<app>/assets/` (hashed by vite) is cached for a year.
 
 ## Install / update
 
@@ -78,4 +120,6 @@ server/install.sh            # copy server/ to dh, install site files and hooks
 server/install.sh --publish  # ...and republish every repo
 ```
 
-Requirements on DreamHost: git, Python 3.12, `flock`, `setsid`.
+Requirements on DreamHost: git, Python 3.12, `flock`, `setsid`. On the laptop: Node ≥ 18, git, rsync, ssh with the `dh` alias.
+
+Using `fw` outside an app repo (e.g. on a second laptop): `npm install -g git+https://code.fuzzyworld.net/fuzzyworld-tools.git#v1`.
diff --git a/bin/fw b/bin/fw
index ff837b2..6ec609d 100755
--- a/bin/fw
+++ b/bin/fw
@@ -1,17 +1,19 @@
 #!/bin/bash
-# fw (laptop side): runs repo commands on DreamHost over ssh.
+# fw (laptop side).
+#   fw deploy [--ref <branch>] [--here] [--dry-run]   build and publish this repo's app (see lib/deploy.mjs)
 #   fw list | new <name> [--public] [--description TEXT] | public <name> | private <name>
-#   fw describe <name> TEXT | publish <name> | publish-all
-# `fw deploy` and app scaffolding in `fw new` arrive in Phase 4.
+#   fw describe <name> TEXT | publish <name> | publish-all      (run on DreamHost over ssh)
 set -euo pipefail
 HOST=${FW_HOST:-dh}
+# Follow the symlink npm creates in node_modules/.bin to find this package.
+HERE=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)
 
 case "${1:-help}" in
+  deploy)
+    shift; exec node "$HERE/lib/deploy.mjs" "$@" ;;
   list|new|public|private|describe|publish|publish-all|help|-h|--help)
     # printf %q keeps arguments (e.g. a description with spaces) intact through ssh.
     exec ssh "$HOST" "~/bin/fw $(printf '%q ' "$@")" ;;
-  deploy)
-    echo "fw deploy: not available yet (Phase 4)" >&2; exit 2 ;;
   *)
     echo "fw: unknown command '$1' (try: fw help)" >&2; exit 2 ;;
 esac
diff --git a/lib/deploy.mjs b/lib/deploy.mjs
new file mode 100644
index 0000000..677740e
--- /dev/null
+++ b/lib/deploy.mjs
@@ -0,0 +1,242 @@
+// fw deploy: build an app from its canonical repo and publish it to apps.fuzzyworld.net/<app>/.
+//
+//   fw deploy                 build branch `main` of dh:git/<repo>.git (a fresh clone)
+//   fw deploy --ref <branch>  build another branch or tag
+//   fw deploy --here          build the current directory as it is (uncommitted work included)
+//   fw deploy --dry-run       build and check, but don't upload
+//
+// Configuration comes from package.json of the *built* source:
+//   "fuzzyworld": {
+//     "app": "my-game",            // URL path: apps.fuzzyworld.net/my-game/   (required)
+//     "title": "My Game",          // shown on the apps index
+//     "description": "...",        // shown on the apps index
+//     "repo": "my-game",           // repo name on DreamHost, if different from app
+//     "prebuild": ["pnpm gen-assets"],   // shell commands before checks and build
+//     "checks": ["typecheck", "test"],   // package scripts that must pass (default: those of typecheck/lint/test that exist)
+//     "build": "make public",      // build command (default: `<pm> run build`)
+//     "out": "dist"                // output directory (default: dist, else public, else build)
+//   }
+import { spawnSync, execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const HOST = process.env.FW_HOST || 'dh';
+const APPS_URL = 'https://apps.fuzzyworld.net';
+const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
+
+class DeployError extends Error {}
+
+// Throws instead of exiting, so the temp clone is always removed (see `finally` in deploy()).
+function die(msg) {
+  throw new DeployError(msg);
+}
+
+function step(msg) {
+  console.log(`\n▶ ${msg}`);
+}
+
+function run(cmd, args, opts = {}) {
+  const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
+  if (r.status !== 0) die(`failed: ${cmd} ${args.join(' ')}`);
+}
+
+function shell(command, opts) {
+  run('/bin/sh', ['-c', command], opts);
+}
+
+function git(args, cwd) {
+  return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
+}
+
+function parseArgs(argv) {
+  const o = { ref: 'main', here: false, dryRun: false };
+  for (let i = 0; i < argv.length; i++) {
+    const a = argv[i];
+    if (a === '--here') o.here = true;
+    else if (a === '--dry-run') o.dryRun = true;
+    else if (a === '--ref') o.ref = argv[++i] || die('--ref needs a value');
+    else die(`unknown option ${a}`);
+  }
+  return o;
+}
+
+function readConfig(dir) {
+  const file = path.join(dir, 'package.json');
+  if (!fs.existsSync(file)) die(`no package.json in ${dir}`);
+  const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
+  const cfg = pkg.fuzzyworld;
+  if (!cfg) die(`no "fuzzyworld" section in ${file}`);
+  if (!cfg.app || !NAME_RE.test(cfg.app)) die(`invalid or missing fuzzyworld.app in ${file}`);
+  if (cfg.repo && !NAME_RE.test(cfg.repo)) die(`invalid fuzzyworld.repo in ${file}`);
+  return { pkg, cfg };
+}
+
+function packageManager(dir) {
+  if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return { pm: 'pnpm', install: ['install', '--frozen-lockfile'] };
+  if (fs.existsSync(path.join(dir, 'yarn.lock'))) return { pm: 'yarn', install: ['install', '--frozen-lockfile'] };
+  if (fs.existsSync(path.join(dir, 'package-lock.json'))) return { pm: 'npm', install: ['ci'] };
+  return { pm: 'npm', install: ['install'] };
+}
+
+// ---------------------------------------------------------------- output checks
+
+function walk(dir, out = []) {
+  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+    const p = path.join(dir, e.name);
+    if (e.isDirectory()) walk(p, out);
+    else out.push(p);
+  }
+  return out;
+}
+
+// Resolve a URL as the browser would from the app's index page.
+function resolveUrl(ref, app, from = `/${app}/`) {
+  return new URL(ref, `${APPS_URL}${from}`);
+}
+
+function checkOutput(out, app) {
+  const problems = [];
+  const warnings = [];
+  const base = `/${app}/`;
+  const index = path.join(out, 'index.html');
+  if (!fs.existsSync(index)) die(`build output has no index.html: ${out}`);
+  const html = fs.readFileSync(index, 'utf8');
+
+  // Every same-site URL in index.html must stay inside /<app>/.
+  for (const m of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
+    const u = resolveUrl(m[1], app);
+    if (u.origin === APPS_URL && !u.pathname.startsWith(base)) {
+      problems.push(`index.html references ${m[1]} (resolves outside ${base})`);
+    }
+  }
+
+  // Web manifest: id, scope and start_url must be under /<app>/, so each app installs separately.
+  const mf = html.match(/<link[^>]+rel=["']manifest["'][^>]*>/i);
+  if (mf) {
+    const href = mf[0].match(/href=["']([^"']+)["']/)?.[1];
+    const mUrl = href && resolveUrl(href, app);
+    const file = mUrl && path.join(out, decodeURIComponent(mUrl.pathname.slice(base.length)));
+    if (!file || !fs.existsSync(file)) {
+      problems.push(`manifest ${href} not found in build output`);
+    } else {
+      const m = JSON.parse(fs.readFileSync(file, 'utf8'));
+      const at = mUrl.pathname;
+      for (const key of ['scope', 'start_url', 'id']) {
+        if (m[key] === undefined) continue;
+        const p = resolveUrl(m[key], app, at).pathname;
+        if (!p.startsWith(base)) problems.push(`manifest ${key} "${m[key]}" resolves to ${p}, outside ${base}`);
+      }
+      if (m.id === undefined) warnings.push(`manifest has no "id"; browsers fall back to start_url. Set "id": "${base}" to be explicit.`);
+    }
+  }
+
+  // Service workers: a registration scope (or script) outside /<app>/ would capture other apps.
+  for (const f of walk(out).filter((f) => /\.(m?js|html)$/.test(f))) {
+    const text = fs.readFileSync(f, 'utf8');
+    for (const m of text.matchAll(/serviceWorker\.register\(\s*(["'`])([^"'`]+)\1([^)]*)\)/g)) {
+      const script = resolveUrl(m[2], app).pathname;
+      if (!script.startsWith(base)) problems.push(`${path.relative(out, f)} registers service worker ${m[2]} outside ${base}`);
+      const scope = m[3].match(/scope\s*:\s*(["'`])([^"'`]+)\1/)?.[2];
+      if (scope && !resolveUrl(scope, app).pathname.startsWith(base)) {
+        problems.push(`${path.relative(out, f)} registers a service worker with scope "${scope}" outside ${base}`);
+      }
+    }
+    if (text.includes('gitlab.io')) warnings.push(`${path.relative(out, f)} mentions gitlab.io`);
+  }
+  return { problems, warnings };
+}
+
+// ---------------------------------------------------------------- main
+
+export function deploy(argv) {
+  const o = parseArgs(argv);
+  const cwd = process.cwd();
+  let top;
+  try {
+    top = git(['rev-parse', '--show-toplevel'], cwd);
+  } catch {
+    top = cwd;
+  }
+  let { cfg } = readConfig(top);
+  const repo = cfg.repo || cfg.app;
+
+  let src = top;
+  let tmp = null;
+  if (o.here) {
+    const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: top, encoding: 'utf8' }).stdout.trim();
+    if (dirty) console.warn('fw deploy: --here with uncommitted changes; the deployed app will not match any commit');
+  } else {
+    // Build what is pushed, never the working copy: a fresh shallow clone of the canonical repo.
+    tmp = fs.mkdtempSync(path.join(os.tmpdir(), `fw-deploy-${cfg.app}-`));
+    src = path.join(tmp, 'src');
+    step(`cloning ${HOST}:git/${repo}.git (${o.ref})`);
+    run('git', ['clone', '--quiet', '--depth', '1', '--branch', o.ref, `${HOST}:git/${repo}.git`, src]);
+    ({ cfg } = readConfig(src)); // the committed config is what gets deployed
+  }
+  const app = cfg.app;
+  const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: src, encoding: 'utf8' }).stdout.trim() || 'unknown';
+  const { pkg } = readConfig(src);
+  const env = { ...process.env, FW_APP: app, FW_BASE: `/${app}/` };
+
+  try {
+    const { pm, install } = packageManager(src);
+    step(`${pm} ${install.join(' ')}`);
+    run(pm, install, { cwd: src, env });
+
+    for (const c of cfg.prebuild || []) {
+      step(c);
+      shell(c, { cwd: src, env });
+    }
+
+    const scripts = pkg.scripts || {};
+    const checks = cfg.checks ?? ['typecheck', 'lint', 'test'].filter((s) => scripts[s] && !/no test specified/.test(scripts[s]));
+    for (const c of checks) {
+      if (!scripts[c]) die(`check "${c}" is not a script in package.json`);
+      step(`check: ${pm} run ${c}`);
+      run(pm, ['run', c], { cwd: src, env });
+    }
+
+    step(`build: ${cfg.build || `${pm} run build`}`);
+    if (cfg.build) shell(cfg.build, { cwd: src, env });
+    else run(pm, ['run', 'build'], { cwd: src, env });
+
+    const outName = cfg.out || ['dist', 'public', 'build'].find((d) => fs.existsSync(path.join(src, d, 'index.html')));
+    if (!outName) die('could not find the build output (set fuzzyworld.out)');
+    const out = path.join(src, outName);
+
+    step(`checking ${outName}/`);
+    const { problems, warnings } = checkOutput(out, app);
+    for (const w of warnings) console.warn(`  warning: ${w}`);
+    if (problems.length) {
+      for (const p of problems) console.error(`  ✗ ${p}`);
+      die(`${problems.length} problem(s) in the build output; nothing was uploaded`);
+    }
+    console.log('  ✓ all URLs, manifest and service worker stay inside', `/${app}/`);
+
+    fs.writeFileSync(path.join(out, '.fuzzyworld.json'), JSON.stringify({
+      app, repo, title: cfg.title || app, description: cfg.description || '',
+      commit, ref: o.here ? '(working copy)' : o.ref, deployed: new Date().toISOString(),
+    }, null, 2));
+
+    if (o.dryRun) {
+      console.log(`\n(dry run) would upload ${out}/ to ${HOST}:apps.fuzzyworld.net/${app}/`);
+      return;
+    }
+    step(`uploading to apps.fuzzyworld.net/${app}/`);
+    run('ssh', [HOST, `mkdir -p apps.fuzzyworld.net/${app}`]);
+    run('rsync', ['-rlptz', '--delete', `${out}/`, `${HOST}:apps.fuzzyworld.net/${app}/`]);
+    run('ssh', [HOST, `~/bin/fw app-deployed ${app} ${repo}`]);
+    console.log(`\n✅ ${APPS_URL}/${app}/  (commit ${commit.slice(0, 12)})`);
+  } finally {
+    if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
+  }
+}
+
+try {
+  deploy(process.argv.slice(2));
+} catch (e) {
+  if (!(e instanceof DeployError)) throw e;
+  console.error(`\nfw deploy: ${e.message}`);
+  process.exit(1);
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..31beb98
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "fuzzyworld-tools",
+  "version": "1.0.0",
+  "description": "Tooling for code.fuzzyworld.net and apps.fuzzyworld.net: fw command, deploy, vite plugin.",
+  "type": "module",
+  "bin": {
+    "fw": "bin/fw"
+  },
+  "exports": {
+    "./vite": "./vite/index.js",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "bin/",
+    "lib/",
+    "vite/"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "license": "MIT"
+}
diff --git a/server/fw b/server/fw
index 1ed6227..c2ca242 100755
--- a/server/fw
+++ b/server/fw
@@ -10,6 +10,8 @@
 #   fw publish <name>               refresh public copy + pages (the push hook runs this)
 #   fw publish-all                  publish every public repo, then the index
 #   fw install-hooks                point every repo at the shared hooks
+#   fw apps-index                   rebuild the apps.fuzzyworld.net index
+#   fw app-deployed <app> <repo>    after `fw deploy`: apps index, plus the repo's code page
 #
 # Visibility is `git config fuzzyworld.visibility` inside each repo. Anything other
 # than exactly "public" is treated as private.
@@ -150,8 +152,21 @@ cmd_install_hooks() {
   echo "hooks installed on $(ls -d "$GIT_ROOT"/*.git | wc -l) repos"
 }
 
+cmd_app_deployed() {
+  local app=${1:-} name=${2:-}
+  valid_name "$app"
+  "${GEN[@]}" apps
+  # The repo's summary page links to its app; refresh it if the source is public.
+  if [ -n "$name" ] && [ -d "$(repo_dir "$name")" ] && [ "$(visibility "$name")" = public ]; then
+    with_lock "$name" publish_one "$name" >/dev/null
+    "${GEN[@]}" index >/dev/null
+  fi
+}
+
 mkdir -p "$STATE" "$LOGS"
 case "${1:-}" in
+  apps-index) "${GEN[@]}" apps ;;
+  app-deployed) cmd_app_deployed "${2:-}" "${3:-}" ;;
   list) cmd_list ;;
   new) shift; cmd_new "$@" ;;
   public|private) cmd_set_visibility "$1" "${2:-}" ;;
@@ -159,6 +174,6 @@ case "${1:-}" in
   publish) cmd_publish "${2:-}" ;;
   publish-all) cmd_publish_all ;;
   install-hooks) cmd_install_hooks ;;
-  ""|-h|--help|help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;;
+  ""|-h|--help|help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' ;;
   *) die "unknown command: $1 (try: fw help)" ;;
 esac
diff --git a/server/gen.py b/server/gen.py
index 7e06d66..69027bd 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -3,6 +3,7 @@
 
     gen.py repo <name>    build or update the pages of one public repo
     gen.py index          rebuild the site index (public repos only)
+    gen.py apps           rebuild the apps.fuzzyworld.net index from deployed apps
 
 Only the git CLI and the standard library are required. Python-Markdown is used
 for README/.md rendering when importable (from ~/fw/pylib); otherwise those files
@@ -107,7 +108,13 @@ def url(*parts):
     return "/" + "/".join(urllib.parse.quote(s) for s in path.split("/"))
 
 
-def page(title, body, crumbs=None, repo=None):
+def page(title, body, crumbs=None, repo=None, site="code"):
+    if site == "apps":
+        host, footer = "apps.fuzzyworld.net", (f'<a href="/">All apps</a> · <a href="{BASE_URL}/">Code</a> · '
+                                                f'<a href="{BASE_URL}/why/">Why this site exists</a>')
+    else:
+        host, footer = "code.fuzzyworld.net", (f'<a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · '
+                                                f'<a href="/why/">Why this site exists</a>')
     nav = ""
     if repo:
         nav = (f'<nav class="repo-nav"><a href="{url(repo)}/">Summary</a>'
@@ -122,17 +129,17 @@ def page(title, body, crumbs=None, repo=None):
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{esc(title)} · code.fuzzyworld.net</title>
+<title>{esc(title)} · {host}</title>
 <link rel="stylesheet" href="/style.css">
 </head>
 <body>
-<header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header>
+<header class="site"><a class="home" href="/">{host}</a></header>
 <main>
 {nav}
 {crumb_html}
 {body}
 </main>
-<footer class="site"><a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · <a href="/why/">Why this site exists</a></footer>
+<footer class="site">{footer}</footer>
 </body>
 </html>
 """
@@ -504,11 +511,49 @@ def build_index():
     print(f"index: {len(rows)} public repos")
 
 
+def build_apps_index():
+    """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json."""
+    apps = []
+    for entry in sorted(os.listdir(APPS)):
+        meta_path = os.path.join(APPS, entry, ".fuzzyworld.json")
+        if not os.path.isfile(meta_path):
+            continue
+        try:
+            with open(meta_path) as f:
+                meta = json.load(f)
+        except ValueError:
+            continue
+        apps.append({"href": f"/{entry}/", "title": meta.get("title") or entry,
+                     "description": meta.get("description", ""), "repo": meta.get("repo", entry)})
+    # Apps hosted elsewhere (e.g. engye on Cloudflare Pages), listed in server/site-apps/extra.json.
+    extra = os.path.join(os.path.dirname(os.path.abspath(__file__)), "site-apps", "extra.json")
+    if os.path.isfile(extra):
+        with open(extra) as f:
+            apps += json.load(f)
+    apps.sort(key=lambda a: a["title"].lower())
+    items = []
+    for a in apps:
+        repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git")
+        src = (f' · <a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>'
+               if a.get("repo") and os.path.isdir(repo) and is_public(repo) else "")
+        desc = f'<p class="desc">{esc(a["description"])}</p>' if a.get("description") else ""
+        items.append(f'<li><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a>'
+                     f'<span class="meta">{src}</span>{desc}</li>')
+    body = (f"<h1>Pavel's apps</h1>"
+            f'<p>Small web apps and games. No accounts, no sign-in. Source code is at '
+            f'<a href="{BASE_URL}/">code.fuzzyworld.net</a>.</p>'
+            f'<ul class="repos">{"".join(items)}</ul>')
+    write(os.path.join(APPS, "index.html"), page("Apps", body, site="apps"))
+    print(f"apps index: {len(apps)} apps")
+
+
 def main(argv):
     if len(argv) == 3 and argv[1] == "repo":
         build_repo(argv[2])
     elif len(argv) == 2 and argv[1] == "index":
         build_index()
+    elif len(argv) == 2 and argv[1] == "apps":
+        build_apps_index()
     else:
         raise SystemExit(__doc__)
 
diff --git a/server/install.sh b/server/install.sh
index 833cbdc..5956cbf 100755
--- a/server/install.sh
+++ b/server/install.sh
@@ -17,7 +17,11 @@ site=~/code.fuzzyworld.net
 cp ~/fw/tools/server/site/htaccess "$site/.htaccess"
 cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt ~/fw/tools/server/site/404.html "$site/"
 mkdir -p "$site/why" && cp ~/fw/tools/server/site/why/index.html "$site/why/"
+apps=~/apps.fuzzyworld.net
+cp ~/fw/tools/server/site-apps/htaccess "$apps/.htaccess"
+cp ~/fw/tools/server/site/style.css "$apps/"
 ~/bin/fw install-hooks
+~/bin/fw apps-index
 EOF
 if [ "${1:-}" = --publish ]; then ssh "$HOST" '~/bin/fw publish-all'; fi
 echo "installed on $HOST"
diff --git a/server/site-apps/extra.json b/server/site-apps/extra.json
new file mode 100644
index 0000000..5047f39
--- /dev/null
+++ b/server/site-apps/extra.json
@@ -0,0 +1,8 @@
+[
+  {
+    "href": "https://engye.fuzzyworld.net/",
+    "title": "engye",
+    "description": "Share files by QR code, directly between devices. No accounts, no cloud.",
+    "repo": "engye"
+  }
+]
diff --git a/server/site-apps/htaccess b/server/site-apps/htaccess
new file mode 100644
index 0000000..9b84e43
--- /dev/null
+++ b/server/site-apps/htaccess
@@ -0,0 +1,34 @@
+# apps.fuzzyworld.net. Installed as .htaccess by fuzzyworld-tools (server/install.sh).
+# Every app lives in /<app>/ and is a static build uploaded by `fw deploy`.
+Options -Indexes -Includes -ExecCGI
+DirectoryIndex index.html
+
+RewriteEngine On
+# www.<site> -> <site>, keeping path and query.
+RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
+RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
+
+# engye is hosted on Cloudflare Pages (it needs a Pages Function); keep old paths working.
+RewriteRule ^engye(?:/(.*))?$ https://engye.fuzzyworld.net/$1 [R=301,L]
+
+# Apps are static; nothing here is executed on the server.
+RemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar .shtml .cgi .pl .py
+RemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
+<FilesMatch "\.(php\d?|phtml|phar|shtml|cgi|pl|py|sh)$">
+  Require all denied
+</FilesMatch>
+<Files ".fuzzyworld.json">
+  Require all denied
+</Files>
+
+<IfModule mod_headers.c>
+  Header always set X-Content-Type-Options nosniff
+  Header always set Referrer-Policy strict-origin-when-cross-origin
+  # Entry points must revalidate so a deploy is picked up; hashed build assets never change.
+  <FilesMatch "(\.html|sw\.js|service-worker\.js|\.webmanifest|manifest\.json)$">
+    Header set Cache-Control "no-cache"
+  </FilesMatch>
+  <If "%{REQUEST_URI} =~ m#^/[^/]+/assets/#">
+    Header set Cache-Control "public, max-age=31536000, immutable"
+  </If>
+</IfModule>
diff --git a/vite/index.js b/vite/index.js
new file mode 100644
index 0000000..15e57cc
--- /dev/null
+++ b/vite/index.js
@@ -0,0 +1,39 @@
+// Vite plugin for apps on apps.fuzzyworld.net.
+//
+//   import fuzzyworld from 'fuzzyworld-tools/vite';
+//   export default defineConfig({ plugins: [fuzzyworld()] });
+//
+// Sets `base` to `/<app>/`, where <app> is `fuzzyworld.app` in package.json, so every
+// asset URL stays inside the app's own path on the shared origin. `fw deploy` checks
+// the built output (manifest, service worker scope) before anything is uploaded.
+import fs from 'node:fs';
+import path from 'node:path';
+
+export function readAppConfig(root = process.cwd()) {
+  const pkgPath = path.join(root, 'package.json');
+  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+  const cfg = pkg.fuzzyworld;
+  if (!cfg || !cfg.app) {
+    throw new Error(`fuzzyworld: add "fuzzyworld": { "app": "<name>" } to ${pkgPath}`);
+  }
+  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(cfg.app)) {
+    throw new Error(`fuzzyworld: invalid app name "${cfg.app}"`);
+  }
+  return cfg;
+}
+
+export default function fuzzyworld() {
+  return {
+    name: 'fuzzyworld',
+    config(config, { command }) {
+      const root = config.root ? path.resolve(config.root) : process.cwd();
+      const { app } = readAppConfig(root);
+      const base = `/${app}/`;
+      // `vite dev` keeps the developer's own base; builds always use the app path.
+      if (command === 'build' && config.base && config.base !== base && config.base !== './') {
+        console.warn(`fuzzyworld: overriding base "${config.base}" with "${base}"`);
+      }
+      return command === 'build' ? { base } : {};
+    },
+  };
+}