feat: pnpm dev --isolated runs against its own database and project

Working on Fusion from inside a machine that already runs one, a plain
`pnpm dev` shares that instance's LIVE database. Everything durable hangs off
$HOME/.fusion — settings, credentials, central DB, the embedded Postgres data
dir — and a process pointed at a data dir whose postmaster is already running
attaches to it instead of starting its own.

--isolated spawns the dev child with HOME pointed at a sandbox, so it gets its
own settings, credentials and Postgres cluster on its own port. It also moves
the child's cwd, which is the half that is easy to miss: `fn dashboard`
derives its project from the working directory and has no project flag, so
isolating HOME alone leaves both instances on `<repo>/.fusion` — including
`.fusion/tasks/<id>/`, which the orphaned-task-dir sweep re-imports, so a
fresh dev database would adopt the real instance's tasks.

The sandbox defaults to ~/.fusion-dev/<checkout-name>/{home,project}: outside
the work tree so it neither shows up in git status nor dies on a clean
checkout, and keyed by checkout so two clones cannot collide. The project dir
is git init-ed on first use because Fusion projects are git work trees.
Changing cwd is safe because PRELOAD/LOADER/ENTRY are already absolute.

Verified in a container beside a running Fusion: the isolated instance
reported zero projects while the real one reported two, on separate Postgres
clusters (ports 42617 and 38311).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-19 21:14:31 -07:00
parent 7ae238cc67
commit 338dc173ff
5 changed files with 167 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add `pnpm dev --isolated` to run the dev server against its own database and project directory.
category: feature
dev: Inside a machine already running Fusion, a plain `pnpm dev` shares the live database: everything durable hangs off `$HOME/.fusion` and `embedded-lifecycle` attaches to an existing postmaster when the data dir already has one. `--isolated` (also `--isolated=<dir>`, `FUSION_DEV_ISOLATED=1`) spawns the dev child with `HOME` pointed at a sandbox, giving it its own settings, credentials, central DB and Postgres cluster on its own port. It also sets the child's `cwd`, because `fn dashboard` derives its project from the working directory and has no project flag — without that, both instances share `<repo>/.fusion/tasks/`, which the orphaned-task-dir sweep re-imports, so a fresh dev database adopts the real instance's tasks. The sandbox defaults to `~/.fusion-dev/<checkout-name>/{home,project}` — outside the work tree and keyed by checkout — and the project dir is `git init`-ed on first use. Safe because `PRELOAD`/`LOADER`/`ENTRY` are already absolute paths.

View File

@@ -121,6 +121,30 @@ Tunnelling any other port has no Fusion auth to lend it, and says so:
└ anyone with this URL can reach that port — Fusion adds no auth to it
```
### Running against an isolated database (`--isolated`)
Working on Fusion from inside a machine that already runs one — a container, a shared box — a plain
`pnpm dev` **shares that instance's live database**. Everything durable hangs off `$HOME/.fusion`
(settings, credentials, central DB, the embedded Postgres data dir), and a second process pointed at
a data dir whose postmaster is already running attaches to it rather than starting its own.
```bash
pnpm dev --isolated --tunnel # own database, own project dir, own tunnel
pnpm dev --isolated=/tmp/sandbox # put the sandbox somewhere specific
FUSION_DEV_ISOLATED=1 pnpm dev # same, from the environment
```
`--isolated` gives the dev server its own `HOME` (so its own `.fusion`, credentials and Postgres
cluster, on its own port) **and** its own project directory. Both matter: `fn dashboard` derives its
project from the working directory, so isolating `HOME` alone would leave both instances sharing
`<repo>/.fusion` — including `.fusion/tasks/<id>/`, which self-healing's orphaned-task-dir sweep
re-imports, so a fresh dev database would adopt the real instance's tasks.
The sandbox defaults to `~/.fusion-dev/<checkout-name>/` — outside the work tree, so it neither shows
up in `git status` nor dies on a clean checkout, and keyed by checkout so two clones do not collide.
Its project directory is `git init`-ed on first use, because Fusion projects are git work trees. The
dev database persists across restarts; delete the directory to start fresh.
Requires `cloudflared` on PATH (the Docker image ships it). Quick tunnels need no account, domain, or
payment card **because a dev server is HTTP** — the TCP endpoints that something like SSH would need
require a card (ngrok) or a domain plus Zero Trust (Cloudflare), which is why this flag exists only

View File

@@ -9,6 +9,7 @@ import {
readDevServerListening,
readDevServerListeningPort,
resolveDevTunnelPort,
resolveIsolatedDevPaths,
resolvePrebuildMode,
} from "../../../../scripts/dev-with-memory-lib.mjs";
import {
@@ -65,6 +66,8 @@ describe("dev-with-memory prebuild options", () => {
watchSourceFromFlag: false,
tunnel: false,
tunnelPort: undefined,
isolated: false,
isolatedDir: undefined,
});
});
@@ -77,6 +80,8 @@ describe("dev-with-memory prebuild options", () => {
watchSourceFromFlag: true,
tunnel: false,
tunnelPort: undefined,
isolated: false,
isolatedDir: undefined,
});
});
@@ -373,6 +378,47 @@ describe("development source restart watcher", () => {
expect(resolveDevTunnelPort(5173, { PORT: "8080" })).toBe(5173);
});
/*
FNXC:DevIsolation 2026-08-20-04:10:
A plain `pnpm dev` inside a machine already running Fusion SHARES its database: everything
durable hangs off $HOME/.fusion, and a process pointed at a data dir whose postmaster is already
up attaches to it rather than starting its own. Isolating HOME alone still leaves both instances
on one project directory, where the orphaned-task-dir sweep would have the dev instance adopt
the real one's tasks — so the flag moves the working directory too.
*/
it("parses --isolated with and without an explicit directory", () => {
expect(parseDevWrapperArgs(["--isolated"], {})).toMatchObject({ isolated: true, isolatedDir: undefined });
expect(parseDevWrapperArgs(["--isolated=/tmp/sandbox"], {})).toMatchObject({ isolated: true, isolatedDir: "/tmp/sandbox" });
expect(parseDevWrapperArgs(["dashboard"], {})).toMatchObject({ isolated: false });
expect(parseDevWrapperArgs(["dashboard"], { FUSION_DEV_ISOLATED: "1" })).toMatchObject({ isolated: true });
expect(() => parseDevWrapperArgs(["--isolated="], {})).toThrow(/Missing directory/);
});
it("does not swallow a following argument as the isolation directory", () => {
// `--isolated dashboard` means "isolate, and run the dashboard".
expect(parseDevWrapperArgs(["--isolated", "dashboard"], {})).toMatchObject({ isolated: true, isolatedDir: undefined, args: ["dashboard"] });
});
it("keeps the sandbox out of the repo and separates database from project", () => {
const paths = resolveIsolatedDevPaths({ repoRoot: "/Users/dev/Projects/kb", home: "/Users/dev" });
// Outside the work tree: a project dir inside it shows up in git status and dies on a clean checkout.
expect(paths.base).toBe("/Users/dev/.fusion-dev/kb");
expect(paths.home).toBe("/Users/dev/.fusion-dev/kb/home");
expect(paths.project).toBe("/Users/dev/.fusion-dev/kb/project");
expect(paths.project.startsWith("/Users/dev/Projects/kb")).toBe(false);
});
it("keys the sandbox by checkout so two clones do not share one database", () => {
const a = resolveIsolatedDevPaths({ repoRoot: "/w/kb", home: "/h" });
const b = resolveIsolatedDevPaths({ repoRoot: "/w/kb-feature", home: "/h" });
expect(a.home).not.toBe(b.home);
});
it("honours an explicit sandbox directory", () => {
expect(resolveIsolatedDevPaths({ repoRoot: "/w/kb", explicitDir: "/tmp/sandbox" }))
.toEqual({ base: "/tmp/sandbox", home: "/tmp/sandbox/home", project: "/tmp/sandbox/project" });
});
it("recognises the cloudflare quick-tunnel hostname in agent output", () => {
expect(extractQuickTunnelUrl("INF | https://neat-fox-tree.trycloudflare.com |")).toBe("https://neat-fox-tree.trycloudflare.com");
expect(extractQuickTunnelUrl("INF Registered tunnel connection")).toBeNull();

View File

@@ -133,6 +133,8 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
`--tunnel=PORT` targets a port other than the dashboard's (e.g. a Vite server on 5173).
*/
let tunnel = env.FUSION_DEV_TUNNEL === "1";
let isolated = env.FUSION_DEV_ISOLATED === "1";
let isolatedDir = env.FUSION_DEV_ISOLATED_DIR || undefined;
let tunnelPort = env.FUSION_DEV_TUNNEL_PORT ? Number(env.FUSION_DEV_TUNNEL_PORT) : undefined;
for (let i = 0; i < rawArgs.length; i += 1) {
@@ -168,6 +170,35 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
continue;
}
/*
FNXC:DevIsolation 2026-08-20-04:10:
`--isolated` runs the dev server against its OWN database and its OWN project directory, for
working on Fusion from inside a machine that is already running one. Everything durable hangs
off $HOME/.fusion — global settings, credentials, the central DB, the embedded Postgres data dir
— and a second process pointed at a data dir whose postmaster is already running simply ATTACHES
to it, so a plain `pnpm dev` inside a Fusion container silently shares the live database.
Isolating HOME alone is not enough: `fn dashboard` derives its project from the working
directory, so both instances would still share `<repo>/.fusion`, including `.fusion/tasks/<id>/`
— and self-healing's orphaned-task-dir sweep re-imports task directories that have no row,
meaning a fresh dev database would adopt the real instance's tasks. The flag therefore moves the
working directory too.
`--isolated=<dir>` puts the sandbox somewhere specific; otherwise it is a stable per-repo path so
the dev database survives restarts.
*/
if (arg === "--isolated") {
isolated = true;
continue;
}
if (arg.startsWith("--isolated=")) {
isolated = true;
isolatedDir = arg.slice("--isolated=".length);
if (!isolatedDir) throw new Error("Missing directory for --isolated=<dir>.");
continue;
}
if (arg === "--tunnel") {
tunnel = true;
const next = rawArgs[i + 1];
@@ -201,9 +232,33 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
watchSourceFromFlag,
tunnel,
tunnelPort,
isolated,
isolatedDir,
};
}
/**
* Where an isolated dev instance keeps its state.
*
* FNXC:DevIsolation 2026-08-20-04:10:
* `home` becomes the child's HOME, giving it its own `.fusion` — settings, credentials, central DB,
* and an embedded Postgres cluster on its own port (a fresh data dir binds a free port; an existing
* one would have been attached to instead). `project` becomes the child's working directory, so the
* dev instance cannot reach the real instance's `.fusion/tasks/` and adopt its tasks.
*
* The default lives under the REAL home rather than inside the repo: a project directory inside a
* git work tree shows up in status and risks being committed, and the dev database should not be
* wiped by a clean checkout. It is keyed by repo directory name so several checkouts do not collide.
*/
export function resolveIsolatedDevPaths({ repoRoot, home, explicitDir } = {}) {
if (!repoRoot) throw new Error("resolveIsolatedDevPaths requires repoRoot");
if (!home && !explicitDir) throw new Error("resolveIsolatedDevPaths requires home or explicitDir");
const repoName = repoRoot.split(/[\\/]+/).filter(Boolean).pop() || "fusion";
const base = explicitDir ?? `${home}/.fusion-dev/${repoName}`;
return { base, home: `${base}/home`, project: `${base}/project` };
}
/*
FNXC:DevTunnel 2026-08-19-02:05:
Mirrors DEV_SERVER_LISTENING_MESSAGE in packages/cli/src/commands/dev-source-restart.ts. The literal

View File

@@ -16,8 +16,12 @@ import {
parseDevWrapperArgs,
readDevServerListening,
resolveDevTunnelPort,
resolveIsolatedDevPaths,
resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs";
import { existsSync as fsExistsSync, mkdirSync as fsMkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join as pathJoin, resolve as pathResolve } from "node:path";
import { createDevSourceWatcher } from "./lib/dev-source-watch.mjs";
import { resolveDevTunnelAuth, startDevTunnel } from "./lib/dev-tunnel.mjs";
@@ -34,7 +38,7 @@ try {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag, tunnel, tunnelPort } = parsedArgs;
const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag, tunnel, tunnelPort, isolated, isolatedDir } = parsedArgs;
let { watchSource } = parsedArgs;
// NODE_OPTIONS is shared with every spawned node process (build + run +
@@ -47,6 +51,31 @@ process.env.NODE_OPTIONS = nodeOptions;
// builds default to 127.0.0.1; this override only applies when starting
// the dashboard via `pnpm dev dashboard` and only if no --host was passed.
const forwardedArgs = buildForwardedDevArgs(args);
/*
FNXC:DevIsolation 2026-08-20-04:10:
Prepare the isolated sandbox up front so the child can be spawned straight into it. The project
directory is `git init`-ed when empty because Fusion projects are git work trees — worktrees,
branches and merges all assume one — and an isolated instance that cannot resolve a repository is
not usable for the UI work this flag exists to support.
*/
let isolatedPaths;
if (isolated) {
const realHome = process.env.HOME || process.env.USERPROFILE;
isolatedPaths = resolveIsolatedDevPaths({
repoRoot: process.cwd(),
home: realHome,
explicitDir: isolatedDir ? pathResolve(isolatedDir) : undefined,
});
fsMkdirSync(isolatedPaths.home, { recursive: true });
fsMkdirSync(isolatedPaths.project, { recursive: true });
if (!fsExistsSync(pathJoin(isolatedPaths.project, ".git"))) {
// Short, deterministic git plumbing — the engine-wide execSync ban targets user-configured
// commands, not this.
spawnSync("git", ["init", "-q"], { cwd: isolatedPaths.project, stdio: "ignore" });
}
console.log(`[fusion:dev] isolated instance — database ${isolatedPaths.home}/.fusion, project ${isolatedPaths.project}`);
}
if (watchSource && forwardedArgs[0] !== "dashboard") {
if (watchSourceFromFlag) {
console.error("[fusion:dev] --watch is supported for the dashboard engine process only");
@@ -200,11 +229,16 @@ function runApp(extraArgs) {
// FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the
// flag so the child can tell a real supervising parent from an inherited
// copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts).
// FNXC:DevIsolation 2026-08-20-04:10: HOME moves the whole durable state (settings, credentials,
// central DB, embedded Postgres cluster); cwd moves the project, so the two instances cannot
// share `.fusion/tasks/`. Absolute PRELOAD/LOADER/ENTRY paths make the cwd change safe.
...(isolatedPaths ? { cwd: isolatedPaths.project } : {}),
env: {
...process.env,
FUSION_RESTART_SUPERVISED: "1",
FUSION_SUPERVISOR_PID: String(process.pid),
...(watchSource ? { FUSION_DEV_WATCH: "1" } : {}),
...(isolatedPaths ? { HOME: isolatedPaths.home, USERPROFILE: isolatedPaths.home } : {}),
},
});
appChild = tsx;