fuzzyworld

fuzzyworld-tools / commit

fw deploy --as: a second copy of an app under another path

bc98a4857fcda1801fd593d5d67d11b6d615b67b · Pavel Safronov · 2026-10-31 16:43:00 -0700 · parents 0e603cf7bed2

Claude Code usage: this commit 174.9K out / 32.2M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 8 tally 512 in / 174.9K out / 32.2M cached · usage by commit and session

fw deploy --as: a second copy of an app under another path

`fw deploy --as explodorama-next` builds the same repo and publishes it at
apps.fuzzyworld.net/<name>/ (FW_APP, read by the vite plugin, sets the base);
the repo it clones stays the app's own. 1.3.0.

FW-Tokens-In: 512
FW-Tokens-Out: 174931
FW-Tokens-Cached: 32239171
FW-Session: fd332071-d2da-4a3e-a9ee-59cdeec5b405
FW-Rtk-Saved: 2709680
FW-Rtk-Cmds: 7545

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md      |  4 ++--
 lib/deploy.mjs | 10 +++++++---
 package.json   |  2 +-
 vite/index.js  |  3 ++-
 4 files changed, 12 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 1fa6e37..5928e03 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Add to the app repo's `package.json`:
 "devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#semver:^1.0.0" }
 ```
 
-For vite, add the plugin; it sets `base` to `/<app>/` for builds (dev server unchanged):
+For vite, add the plugin; it sets `base` to `/<app>/` for builds (dev server unchanged; `FW_APP`, set by `fw deploy --as`, overrides the app name):
 
 ```ts
 import fuzzyworld from 'fuzzyworld-tools/vite';
@@ -69,7 +69,7 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea
 
 | Command | What it does |
 |---|---|
-| `fw deploy [--ref B] [--here] [--dry-run]` | build and publish this repo's app (above) |
+| `fw deploy [--ref B] [--as NAME] [--here] [--dry-run]` | build and publish this repo's app (above). `--as NAME` publishes it at `apps.fuzzyworld.net/NAME/` instead, a second copy of the same repo (e.g. `explodorama-next` built from `main` while `/explodorama/` is deployed from a tag with `--ref`); the build gets `FW_APP=NAME`, which the vite plugin uses for `base`, and the index title gets the suffix |
 | `fw repoint [DIR] [--https] [--dry-run]` | for each repo in DIR whose `origin` is on gitlab.com: rename it `gitlab`, add `origin` → `dh:git/<repo>.git` (or read-only `https://code.fuzzyworld.net/<repo>.git` with `--https`, public repos only), fetch, and retarget branch tracking. Changes only remotes; checks that uncommitted work, HEAD and stashes are unchanged |
 | `fw usage [--all\|--now]` | Claude Code token use per session from `~/.claude/projects` (runs locally): messages, current context size, cache-read, output (cache-read and output include subagents). `--now` prints one line for the newest session, to decide when to put it down |
 | `fw list` | every repo, its visibility and last commit |
diff --git a/lib/deploy.mjs b/lib/deploy.mjs
index 67df114..f9e455f 100644
--- a/lib/deploy.mjs
+++ b/lib/deploy.mjs
@@ -2,6 +2,7 @@
 //
 //   fw deploy                 build branch `main` of dh:git/<repo>.git (a fresh clone)
 //   fw deploy --ref <branch>  build another branch or tag
+//   fw deploy --as <app>      publish under another path (another copy of the same repo, e.g. a `-next` build of main)
 //   fw deploy --here          build the current directory as it is (uncommitted work included)
 //   fw deploy --dry-run       build and check, but don't upload
 //
@@ -51,14 +52,16 @@ function git(args, cwd) {
 }
 
 function parseArgs(argv) {
-  const o = { ref: 'main', here: false, dryRun: false };
+  const o = { ref: 'main', here: false, dryRun: false, as: '' };
   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 === '--as') o.as = argv[++i] || die('--as needs a value');
     else if (a === '--ref') o.ref = argv[++i] || die('--ref needs a value');
     else die(`unknown option ${a}`);
   }
+  if (o.as && !NAME_RE.test(o.as)) die(`invalid --as name ${JSON.stringify(o.as)}`);
   return o;
 }
 
@@ -180,7 +183,8 @@ export function deploy(argv) {
     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;
+  // `--as`: the same repo under another path; the repo it is cloned from stays cfg.app's (or cfg.repo).
+  const app = o.as || 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}/` };
@@ -221,7 +225,7 @@ export function deploy(argv) {
     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 || '',
+      app, repo, title: (cfg.title || cfg.app) + (o.as ? ` (${o.as.startsWith(cfg.app + '-') ? o.as.slice(cfg.app.length + 1) : o.as})` : ''), description: cfg.description || '',
       commit, ref: o.here ? '(working copy)' : o.ref, deployed: new Date().toISOString(),
     }, null, 2));
 
diff --git a/package.json b/package.json
index 29fffb4..3abe84e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "fuzzyworld-tools",
-  "version": "1.2.0",
+  "version": "1.3.0",
   "description": "Tooling for code.fuzzyworld.net and apps.fuzzyworld.net: fw command, deploy, vite plugin.",
   "type": "module",
   "scripts": {
diff --git a/vite/index.js b/vite/index.js
index a286937..1191368 100644
--- a/vite/index.js
+++ b/vite/index.js
@@ -39,7 +39,8 @@ export default function fuzzyworld() {
     name: 'fuzzyworld',
     config(config, { command }) {
       const root = config.root ? path.resolve(config.root) : process.cwd();
-      const { app } = readAppConfig(root);
+      // `fw deploy --as <name>` (lib/deploy.mjs) builds the same app under another path.
+      const app = process.env.FW_APP || readAppConfig(root).app;
       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 !== './') {