fuzzyworld

fuzzyworld-tools / commit

Add fuzzyworld.keep to protect server files from deploys; v1.0.4

7e5916224a0c88b35e4b557bf66aa014457c2951 · Pavel Safronov · 2026-10-26 19:46:59 -0700 · parents 1cc6342631be

Claude Code usage: this commit ≈0 out / ~13.7M cached · cost unknown (no per-model trailer) · session 1 tally 2.0K in / 1.1M out / 313.6M cached · usage by commit and session

Add fuzzyworld.keep to protect server files from deploys; v1.0.4

rsync --delete would remove files that are not in the build output.
Paths listed in "keep" are excluded, so a download built elsewhere
(quotable's APK, which needs a Gradle upgrade to build again) survives
redeploys.

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

Usage: 2.0K in / 1.1M out / 313.6M cached (session tally)
rtk:   2.4M saved, 55.8% over 5045 cmds (global/lifetime)
 README.md      |  5 +++--
 lib/deploy.mjs | 12 ++++++++++--
 package.json   |  2 +-
 3 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 11b5bcb..68ba297 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@ Add to the app repo's `package.json`:
   "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
+  "out": "dist",                     // default: dist, else public, else build
+  "keep": ["my-game.apk"]            // files on the server a deploy must not delete or replace (e.g. a download built elsewhere)
 },
 "devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#semver:^1.0.0" }
 ```
@@ -39,7 +40,7 @@ Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**:
 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
+5. `rsync --delete` to `apps.fuzzyworld.net/<app>/` (except `keep` paths), 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.
diff --git a/lib/deploy.mjs b/lib/deploy.mjs
index 677740e..67df114 100644
--- a/lib/deploy.mjs
+++ b/lib/deploy.mjs
@@ -14,7 +14,8 @@
 //     "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)
+//     "out": "dist",               // output directory (default: dist, else public, else build)
+//     "keep": ["app.apk"]          // files already on the server that a deploy must not delete or replace
 //   }
 import { spawnSync, execFileSync } from 'node:child_process';
 import fs from 'node:fs';
@@ -69,6 +70,11 @@ function readConfig(dir) {
   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}`);
+  for (const k of cfg.keep || []) {
+    if (typeof k !== 'string' || !k || k.startsWith('/') || k.split('/').some((p) => p === '..' || p === '')) {
+      die(`invalid fuzzyworld.keep entry ${JSON.stringify(k)} in ${file}`);
+    }
+  }
   return { pkg, cfg };
 }
 
@@ -225,7 +231,9 @@ export function deploy(argv) {
     }
     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}/`]);
+    // `keep` paths are excluded, so rsync --delete neither removes nor overwrites them.
+    const keep = (cfg.keep || []).flatMap((k) => ['--exclude', `/${k}`]);
+    run('rsync', ['-rlptz', '--delete', ...keep, `${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 {
diff --git a/package.json b/package.json
index 2cd7923..6c29f66 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "fuzzyworld-tools",
-  "version": "1.0.3",
+  "version": "1.0.4",
   "description": "Tooling for code.fuzzyworld.net and apps.fuzzyworld.net: fw command, deploy, vite plugin.",
   "type": "module",
   "bin": {