diff --git a/.changeset/dev-isolated-instance.md b/.changeset/dev-isolated-instance.md new file mode 100644 index 0000000000..d6b5a7e358 --- /dev/null +++ b/.changeset/dev-isolated-instance.md @@ -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=`, `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 `/.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//{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. diff --git a/docs/contributing.md b/docs/contributing.md index 8adbbae21f..da0434a1da 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 +`/.fusion` — including `.fusion/tasks//`, 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//` — 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 diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index bdd6bb1812..6a2090227f 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -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(); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index bb9f3ebfd3..15c8c3662d 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -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 `/.fusion`, including `.fusion/tasks//` + — 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=` 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=."); + 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 diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 6025c0de9e..9c1a617abd 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -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;