Skip to content

fix(editor): use a literal dev port so bun dev works on Windows - #551

Closed
evolv3ai wants to merge 3 commits into
pascalorg:mainfrom
evolv3ai:fix/windows-dev-port-expansion
Closed

fix(editor): use a literal dev port so bun dev works on Windows#551
evolv3ai wants to merge 3 commits into
pascalorg:mainfrom
evolv3ai:fix/windows-dev-port-expansion

Conversation

@evolv3ai

@evolv3ai evolv3ai commented Jul 27, 2026

Copy link
Copy Markdown

Problem

bun dev fails immediately on Windows. The editor dev task dies before Next.js starts:

editor:dev: $ dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}
editor:dev: error: option '-p, --port <port>' argument '${PORT:-3002}' is invalid. '${PORT:-3002}' is not a non-negative number.
editor:dev: error: script "dev" exited with code 1

Cause

On Windows, bun run executes package scripts with Bun's own built-in shell rather than handing them to a POSIX shell. That shell does not implement default-value parameter expansion, so ${PORT:-3002} in apps/editor/package.json reaches Next.js verbatim and is rejected.

Setting PORT in the environment first does not work around it — the literal is never expanded either way. There is no shell-level workaround from the caller's side; the script itself has to avoid the syntax.

Fix

Use the documented default port directly:

-"dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}"
+"dev": "dotenv -e ../../.env.local -- next dev --port 3002"

Verified on Windows 11 / Bun 1.3.6 / Node 24.18.0 — bun dev now boots the whole workspace and the editor serves HTTP 200 on http://localhost:3002.

Tradeoff, and an alternative if you'd prefer it

This drops the PORT override that SETUP.md documents. I kept the change to one line because that's the smallest thing that unblocks Windows, but I'm happy to switch to a small cross-platform launcher instead, which would preserve PORT on every platform:

// apps/editor/scripts/dev.mjs
import { spawn } from "node:child_process";
const port = process.env.PORT ?? "3002";
spawn("next", ["dev", "--port", port], { stdio: "inherit", shell: true });

Just say which you'd rather have and I'll update the PR.

Two unrelated things I noticed while debugging

Not touched in this PR, but worth flagging:

  1. The root dev script is also a no-op on Windows. It begins with set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose. Bun's shell has no set builtin, so it prints bun: command not found: set twice and silently skips loading .env. Turbo still runs, so it's non-fatal, but any variables in a root .env are never loaded on Windows.

  2. .env.example and SETUP.md disagree on the default port. .env.example says # Dev server port (default: 3000) / # PORT=3000, while SETUP.md and the dev script both use 3002.


Note

Medium Risk
The content-clear guard changes persistence semantics for scene PUTs (intentional clears need an explicit flag); routing and dev script changes are lower risk.

Overview
Scene save protection: PUT /api/scenes/[id] now blocks writes that would remove every authored node (walls, slabs, zones, etc.) while leaving only the default site/building/level scaffold or an empty graph—matching the race where debounced autosave runs before the initial load. Those saves return 409 with content_clear_rejected unless the body includes allowContentClear: true. Logic lives in new scene-content-guard helpers with unit tests.

Routing: Next.js rewrites /editor/:id/scene/:id so MCP/store editorUrl links stop 404ing without changing the browser path (client code still expects /editor/...).

Dev: apps/editor dev script uses a fixed --port 3002 (no ${PORT:-3002}, which breaks under Bun on Windows) and adds --hostname 0.0.0.0.

Reviewed by Cursor Bugbot for commit d90667b. Bugbot is set up for automated code reviews on this repo. Configure here.

evolv3ai and others added 3 commits July 27, 2026 12:30
On Windows, `bun run` executes package scripts with Bun's own built-in
shell rather than a POSIX shell. That shell does not implement
default-value parameter expansion, so `${PORT:-3002}` is passed through
to Next.js verbatim and the dev server exits immediately:

    error: option '-p, --port <port>' argument '${PORT:-3002}' is invalid.
           '${PORT:-3002}' is not a non-negative number.

Setting PORT in the environment does not help — the literal is never
expanded either way.

Replace the expansion with the documented default port (3002).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scene store hands out `/editor/<id>` as a project's canonical URL
(`editorUrlForScene` in packages/mcp/src/storage/sqlite-scene-store.ts)
and `/api/scenes` reports it verbatim, but this app only routes
`/scene/[id]`. Every MCP-reported `editorUrl` therefore 404s.

Rewrite `/editor/:id` to `/scene/:id` rather than redirect: client code
parses the project id back out of the browser path (scan upload in
packages/editor/src/components/ui/action-menu/view-toggles.tsx), so the
`/editor/` prefix has to survive the hop. `/scene/<id>` keeps working
for the app's own links, and the MCP-side tests asserting `/editor/<id>`
stay valid, so no test or storage changes are needed.

Also pass `--hostname 0.0.0.0` explicitly to `next dev`. Next already
binds every interface by default, so this pins that behaviour rather
than changing it, and surfaces the Network URL in dev output. Reaching
the dev server from another device still requires an inbound firewall
rule for port 3002.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The editor autosaves on a debounce. When that timer fires before the
initial scene load resolves, the client PUTs the empty graph it started
from -- or the bare site/building/level scaffold it falls back to. The
stored version is still the one the client read, so `expectedVersion`
matches, optimistic concurrency waves the write through, and the stored
scene is destroyed. Observed against a 49-node scene: six consecutive
writes took it to 0, 3, 0, 3, 0, then 4 nodes.

`PUT /api/scenes/[id]` now rejects a write that would remove the last
authored node from a scene that had one, with 409 and the current ETag,
so the existing client conflict path resyncs instead of clobbering.
Callers that mean it pass `allowContentClear: true`.

The check counts authored nodes rather than raw node count: site,
building, and level are the scaffold a fresh session starts from, so a
scaffold-only write is a wipe too and a naive emptiness test would have
let three of the six through.

This addresses the server side only. The client-side race in
components/scene-loader.tsx -- autosave arming before the initial graph
lands -- is the underlying cause and is still open; the guard is what
prevents it reaching the store.

Note `expectedVersion: expectedVersion ?? existing.version` on the same
handler: a caller that omits `If-Match` gets the current version as its
own precondition, which always matches. The guard covers that path too,
but the fallback remains a weak point worth revisiting.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d90667b. Configure here.

version: existing.version,
},
{ status: 409, headers: { ETag: `"${existing.version}"` } },
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear rejection shown as conflict

Medium Severity

The new content_clear_rejected error returns HTTP 409, a status code the editor client already uses exclusively for version conflicts. This causes the client to display a misleading "another session saved first" message, obscuring the specific guidance for a blocked content clear.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d90667b. Configure here.

version: existing.version,
},
{ status: 409, headers: { ETag: `"${existing.version}"` } },
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejected clear still enables wipe

High Severity

The new content-clear guard only blocks a PUT whose incoming graph has zero authored nodes. On content_clear_rejected, scene-loader returns without throwing, so autosave marks the run saved and leaves the emptied in-memory graph in place. Any later edit that adds even one content node makes wouldClearSceneContent false, so the next PUT can still replace the full stored scene with that near-empty graph.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d90667b. Configure here.

@Aymericr

Aymericr commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Thanks — there are three real findings in here, and I verified all three. But they're three separate changes, and one of them is fixed at the wrong layer, so I'd like to split this rather than merge it as-is.

Verified true:

  1. The Windows port bug. Bun Shell genuinely does not expand ${PORT:-3002} — I tested it directly, $\echo ${"${PORT:-3002}"}`returns the literal string. On Windows, Bun runs scripts through its own shell rather than cmd/sh, sonext dev --port ${PORT:-3002}` receives a literal and fails. Real bug, and it makes the repo un-runnable on Windows out of the box.
  2. The MCP editorUrl 404. packages/mcp/src/storage/sqlite-scene-store.ts:151 returns /editor/<id>, and this app only has app/scene/[id]. Confirmed.
  3. The data-loss bug is real — a client that autosaves before its load resolves sends the bare scaffold with a matching version, and expectedVersion waves it through.

But the guard is at the wrong layer, and I found the actual root cause.

packages/editor/src/hooks/use-auto-save.ts already has this guard — isSuspiciousNodeDrop. The reason it didn't fire is a stale counter: lastNodeCount is initialized at hook mount, before the scene loads, so it's the empty count. The isLoadingSceneRef branch of the subscription (line 136) refreshes lastNodesSnapshot, lastCollectionsRef, lastMaterialsRef and lastInstalledPluginsRef — but not lastNodeCount. So for the first save of every session the guard compares against a stale zero, previousNodeCount > 4 is false, and it passes.

That matters for where the fix goes. Fixing the hook protects the hosted app and every consumer of @pascal-app/editor; a guard in apps/editor's route protects only the OSS standalone app. And your route-level guard is bypassable one edit later: apps/editor/components/scene-loader.tsx:131 treats any 409 as a version conflict and returns without throwing, so autosave marks the run saved and keeps the emptied graph in memory. Add one node and wouldClearSceneContent is false, the version still matches, and the next PUT replaces the scene with a 1-node graph. The 409 reuse also means the user sees "Conflict — reload to continue" (save-button.tsx:91) for what isn't a conflict — and Reload would resurrect content they may have deliberately deleted.

What I'd like instead — three PRs:

A. The port fix (please keep this one, it's the one your title describes). Rather than hardcoding, dotenv-cli can supply the default without any shell expansion. I verified the precedence chain:

"dev": "dotenv -e ../../.env.local -e ../../.env -- next dev"

Next's CLI already reads PORT from the environment (.env('PORT') on the -p option in next/dist/bin/next), so with .env carrying PORT=3002 you get: shell PORT wins → else .env.local → else .env's 3002. Works identically on Windows because nothing is expanded by a shell. That keeps the documented PORT override in SETUP.md:27 and .env.example working — dropping it silently invalidates both, and .env.example is separately wrong about the default (it says 3000, it's 3002).

Also please drop --hostname 0.0.0.0. The comment says it pins existing behavior, but it narrows it: Next deliberately passes no default host (see the comment at next-dev.js:194), Node then binds :: dual-stack, and forcing 0.0.0.0 makes http://[::1]:3002 unreachable.

B. The MCP URL mismatch — worth its own PR, and I'd rather fix it in sqlite-scene-store.ts than paper over it with a rewrite. The rewrite's stated rationale doesn't hold here anyway: view-toggles.tsx:136 does parse the id out of /editor/, but that path is dead in this repo (no registerUploadHandler call site).

C. The autosave guard — in use-auto-save.ts, refreshing lastNodeCount in the loading branch alongside the other refs. If you'd like to take that one, it's the highest-value of the three. A server-side backstop on top is defensible, but it needs its own status code rather than 409, and both clients need to handle it.

One process note: the title and body still describe a one-line dev-port fix, but the head is 5 files and +153/-1 across three commits with changed persistence semantics. That's the kind of drift that makes a PR hard to review — please update the body when scope grows. Also, the new test file lives in apps/editor/lib/, whose test script is bun test lib, so it does run in CI now (#548 landed the root test task) — worth knowing it wasn't running when you wrote it.

Happy to take A on its own immediately if you push just that.

Aymericr added a commit that referenced this pull request Aug 4, 2026
…#578)

`useAutoSave` refuses to persist a graph that drops from populated to a
bare scaffold, on the assumption that it is an accidental full deletion.
The baseline it compares against was seeded once when the hook mounted —
which happens before the scene has loaded, so it sat at the scaffold
count for the whole session and the guard could never fire. The one write
it exists to stop is the one it let through: an autosave racing the
initial load overwrites the stored scene with the scaffold.

The loading branch of the store subscription already refreshed the
snapshot, collections, materials and plugin refs; it just never refreshed
the count. Rather than add a fourth assignment to a branch whose contract
was implicit, the baseline now lives in `createStoredNodeCountTracker`,
which distinguishes the two things that were being conflated: a graph
read from storage becomes the new baseline, an edited graph does not.
That also removes the duplicated guard between `executeSave` and
`flushOnExit`, and makes the invariant testable without React — the same
approach `floorplan-camera-sync.ts` takes for its closure state.

Surfaced by @evolv3ai in #551, which fixed the symptom with a
`nodeCount === 0` check in the standalone app's save route. This fixes it
in the shared hook instead, so the hosted editor and npm consumers are
covered too, and a blocked write can't be laundered through the 409
conflict path that `scene-loader.tsx` treats as success.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Windows problem is real and worth fixing — thank you for finding it. ${PORT:-3002} is POSIX parameter expansion, apps/editor/package.json:7 is the only place in the repo that uses it, and under cmd.exe/PowerShell it reaches Next as a literal string. So the diagnosis in your title is correct.

I can't merge this as it stands, for two reasons — one about the fix itself, one about scope.

1. The fix trades one bug for another. Next's CLI already binds PORT to --port:

.option('--port <port>', …).argParser(parseValidPositiveInteger).default(3000).env('PORT')

Commander's .env() is only consulted when the flag is absent, so hardcoding --port 3002 makes PORT=4000 bun dev silently ignore PORT — which is how our own community app selects its port. The one-line fix that works on both platforms is to drop the flag and let Next read the env var:

-"dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}",
+"dev": "dotenv -e ../../.env.local -- next dev",

That needs PORT=3002 in .env.local/.env.example to preserve today's default, since Next's own default is 3000. Happy with any variant that keeps PORT working — cross-env would also do it.

Also worth knowing: this doesn't yet make root bun dev work on Windows. The root script is set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose (package.json:6), which is POSIX-only too. Your change fixes bun dev inside apps/editor. Fixing the root script is a genuinely useful follow-up if you want it — the .env loading there could move to dotenv -e ./.env and become portable.

2. The PR does four things and the title names one. The diff is 153 lines across 5 files:

  • apps/editor/package.json — the port fix (1 line), plus an undisclosed --hostname 0.0.0.0
  • apps/editor/lib/scene-content-guard.ts + test — a new 42-line scene-persistence guard
  • apps/editor/app/api/scenes/[id]/route.ts — 31 lines wiring that guard into the save path
  • apps/editor/next.config.ts — an /editor/<id>/scene/<id> rewrite

--hostname 0.0.0.0 binds the dev server to every interface, which on a laptop on untrusted Wi-Fi exposes it to the local network. That may well be what you want in your setup, but it's a security-relevant default that needs to be its own change with its own rationale, not a rider on a port fix.

The /editor rewrite also collides with #570, which adds an /editor redirect to the same next.config.ts. Two PRs solving the same routing mismatch in one file will conflict, and I'd rather settle which behaviour is right — you rewrite (preserving the /editor/ prefix because client code parses the id out of it), #570 redirects. Your rationale for rewriting is the more careful one, so this shouldn't be lost; it just needs to be decided in one place.

And the persistence guard is the piece I most want reviewed on its own merits: it changes what the save endpoint accepts, which is the path where a bug means data loss. It deserves a description, and reviewers looking at it deliberately — it will get neither while it's inside a PR titled "use a literal dev port".

What I'd suggest, and I'll review each quickly:

  1. This PR → the one-line port fix only (using the PORT-preserving form above). I'll merge it as soon as it's trimmed.
  2. A second PR--hostname 0.0.0.0, with the use case. Likely answer is to make it opt-in via HOSTNAME rather than the default.
  3. A third PR → the scene-content guard, with the failure it prevents.
  4. The /editor routing → comment on #570 with your rewrite-vs-redirect argument and we'll land whichever is right there.

If splitting is more work than you want, tell me and I'll cherry-pick the port line myself with credit to you — but I'd genuinely rather have items 2 and 3 as reviewable changes than lose them.

@Aymericr

Aymericr commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Two updates on my review above, one of them a correction to me.

Finding B was my mistake — there is no 404. I said editorUrl returning /editor/<id> was broken because this app only serves /scene/[id]. I missed that apps/editor/next.config.ts already redirects for exactly this reason:

// MCP / package metadata returns `/editor/<id>` (hosted route). This open-source
// app serves saved scenes at `/scene/<id>` — redirect so links and bookmarks work.
async redirects() {
  return [{ source: '/editor/:id', destination: '/scene/:id', permanent: false }]
}

And /editor/<projectId> is the correct route on the hosted side (apps/community/app/editor/[projectId]), which is what storage/types.ts documents the field as. So the current value is right for both consumers and the rewrite in your branch isn't needed. Sorry for sending you after that one — I went to fix it myself, got as far as a branch, and found the redirect.

Finding C is now fixed on main, in the hook. #578 landed createStoredNodeCountTracker, which makes the baseline refresh explicit at the point where a graph arrives from storage:

/** A graph read from storage — it defines what "populated" means from here. */
trackLoadedGraph(nodeCount: number) {
  count = nodeCount
},

The loading branch of the subscription now calls it alongside the other refs, so the guard is armed from the first save of the session instead of comparing against a stale zero. That's in packages/editor, so it covers the hosted app and every consumer of the package — not just the standalone shell. Please drop your route-level guard and the apps/editor/lib test when you rebase; the 409 reuse I flagged goes away with it.

That leaves A, the fix your title actually describes. It's still open and I'd still merge it on its own. To restate concretely:

"dev": "dotenv -e ../../.env.local -e ../../.env -- next dev"

with PORT=3002 in .env, and no --hostname 0.0.0.0. That keeps the documented PORT override working, has no shell expansion to break on Windows, and leaves dual-stack binding alone.

Push just that and I'll merge it — no need to touch the other two.

Aymericr added a commit that referenced this pull request Aug 4, 2026
…ride (#587)

Two package scripts use POSIX shell syntax that Bun's own shell does not
implement, and Bun uses that shell for `bun run` on Windows:

- `apps/editor`'s dev script passes `--port ${PORT:-3002}`, which arrives at
  Next verbatim: `option '-p, --port <port>' argument '${PORT:-3002}' is
  invalid`. Setting PORT does not help — the literal is never expanded.
- the root dev script starts `set -a && . ./.env`, and `set` is not a Bun
  shell builtin, so it prints `bun: command not found: set` and silently
  skips loading `.env` entirely.

Reproduced both on macOS with `bun run --shell=bun`, which selects the same
shell Windows gets:

    $ echo port=${PORT:-3002}
    port=${PORT:-3002}          # even with PORT=9999 in the environment
    $ set -a && echo set-worked
    bun: command not found: set

Hardcoding the port would fix Windows but drop the PORT override that
SETUP.md and .env.example both document. Instead, load a committed
`.env.defaults` last and let `next dev` read PORT from the environment
(the CLI already declares `.env('PORT')` on `-p, --port`). Nothing is
shell-expanded, so it behaves the same on every platform, and the
precedence stays shell PORT > .env.local > .env.defaults — verified at
3002 by default and 4321 with an override, under both shells.

`.env.defaults` is needed because `.env` and `.env.local` are gitignored,
so a checked-in default has nowhere else to live.

Also corrects `.env.example`, which advertised a 3000 default the repo has
not used since the port moved to 3002.

Reported by @evolv3ai in #551, including the Windows console output and the
`set` finding.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Aymericr

Aymericr commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

All three findings are now resolved, so I'm closing this — but the Windows bug you found is fixed on main, and it's fixed because you reported it.

A (the Windows dev-server break) — fixed in #587, credited to you. I reproduced both of your findings on macOS using bun run --shell=bun, which selects the same shell Windows gets:

$ echo port=${PORT:-3002}
port=${PORT:-3002}          # unexpanded even with PORT=9999 set
$ set -a && echo set-worked
bun: command not found: set

Exactly your diagnosis, including the root-script set issue you flagged as an aside. That second one is the more insidious of the two — turbo still runs, so on Windows the root .env has silently never loaded and nothing said so. Both scripts now go through dotenv-cli with a committed .env.defaults loaded last, which keeps the PORT override working (verified 3002 by default, 4321 with an override, under both shells) instead of hardcoding it away. Also fixed the .env.example 3000-vs-3002 mismatch you noticed.

One correction to my earlier review while I'm here: I claimed Bun doesn't expand ${PORT:-3002} at all. That was wrong — I'd tested Bun's $ template tag, which escapes interpolated values by design, rather than bun run script execution. Under bun run with the system shell it expands fine, which is why this only ever broke on Windows. Your original diagnosis was more precise than mine.

B (the MCP editorUrl) was my error — no bug there. apps/editor/next.config.ts already redirects /editor/:id/scene/:id, added for exactly this reason, and /editor/<projectId> is correct for the hosted app. Details in my comment above.

C (the autosave data loss) — fixed in #578, at the hook layer in packages/editor rather than the route, so it covers the hosted app and every consumer of the package. The root cause was the stale-baseline problem I described: createStoredNodeCountTracker now refreshes explicitly when a graph arrives from storage, so the guard is armed from the first save of the session.

Closing since there's nothing left in this branch to merge. Thanks for the report — a bug that makes the repo un-runnable on a whole platform is high-value, and the console output plus the set observation is what made it quick to confirm. Please do send more.

@Aymericr Aymericr closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants