From f97db28b757f5df65fa98ca0c0986726f73f59ab Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 10:37:12 -0600 Subject: [PATCH 1/4] fix(dev): copy env files when worktree setup cannot symlink On Windows without Developer Mode or admin rights, symlinkSync throws EPERM, so Setup Worktree ended in setup-failed after a good install. Copy the env file on EPERM instead and warn that the copy will not follow later changes. Real files are still never replaced. Co-Authored-By: Claude Opus 5.5 --- docs/operations/development.md | 4 +++- scripts/setup-worktree.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index ea7f10a6ba2dd..0d453671aa23d 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -167,7 +167,9 @@ Put that value in the main checkout's gitignored `.env`: T3CODE_DEV_AUTH_TOKEN= ``` -The `t3.json` Setup Worktree action links that file to each worktree's `.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process +The `t3.json` Setup Worktree action links that file to each worktree's `.env`. Where symlinks are +not allowed, such as Windows without Developer Mode, it copies the file instead, and that copy does +not pick up later changes. The dev runner reads repository env files at startup. `.env.local` and inherited process environment values override `.env`, so no per-worktree export is needed after setup. For a manual worktree or launcher without that link, export the same fixed value instead: diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 100a7af168105..cd10ae4d11dd6 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -112,5 +112,14 @@ for (const file of ENV_FILES) { const existing = NodeFS.lstatSync(target, { throwIfNoEntry: false }); if (existing && !existing.isSymbolicLink()) continue; if (existing) NodeFS.rmSync(target); - NodeFS.symlinkSync(source, target); + try { + NodeFS.symlinkSync(source, target); + } catch (error) { + // Windows denies symlinks without Developer Mode or admin rights. + if (!(error instanceof Error && "code" in error && error.code === "EPERM")) throw error; + NodeFS.copyFileSync(source, target); + process.stderr.write( + `Copied ${file} because symlinks are not allowed here. The copy will not follow later changes to ${source}.\n`, + ); + } } From 8a3f48d8d36ab4074edfcd6b0ed2682e959590c3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 10:48:59 -0600 Subject: [PATCH 2/4] fix(dev): never overwrite a file with the env copy, say how to refresh it Co-Authored-By: Claude Opus 5.5 --- docs/operations/development.md | 5 +++-- scripts/setup-worktree.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 0d453671aa23d..9b5346e4b0476 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -168,8 +168,9 @@ T3CODE_DEV_AUTH_TOKEN= ``` The `t3.json` Setup Worktree action links that file to each worktree's `.env`. Where symlinks are -not allowed, such as Windows without Developer Mode, it copies the file instead, and that copy does -not pick up later changes. The dev runner reads repository env files at startup. `.env.local` and inherited process +not allowed, such as Windows without Developer Mode, it copies the file instead. A copy does not pick +up later changes; delete it and rerun setup to refresh it. +The dev runner reads repository env files at startup. `.env.local` and inherited process environment values override `.env`, so no per-worktree export is needed after setup. For a manual worktree or launcher without that link, export the same fixed value instead: diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index cd10ae4d11dd6..cea1c4c97ec1b 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -99,7 +99,8 @@ if ( ); } -// Worktrees link main-checkout env files; replace only symlinks so real files always survive. +// Worktrees link main-checkout env files, or copy them where Windows denies symlinks. +// Only symlinks are replaced, so real files always survive. for (const file of ENV_FILES) { const source = NodePath.join(projectRoot, file); const sourceStat = NodeFS.lstatSync(source, { throwIfNoEntry: false }); @@ -115,11 +116,10 @@ for (const file of ENV_FILES) { try { NodeFS.symlinkSync(source, target); } catch (error) { - // Windows denies symlinks without Developer Mode or admin rights. if (!(error instanceof Error && "code" in error && error.code === "EPERM")) throw error; - NodeFS.copyFileSync(source, target); + NodeFS.copyFileSync(source, target, NodeFS.constants.COPYFILE_EXCL); process.stderr.write( - `Copied ${file} because symlinks are not allowed here. The copy will not follow later changes to ${source}.\n`, + `Copied ${file}: symlinks are not allowed here. Delete it and rerun setup to pick up later changes to ${source}.\n`, ); } } From cf3d3814156acb69172b415c6b3387039df12880 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 11:00:00 -0600 Subject: [PATCH 3/4] test(dev): cover worktree setup when symlinks are denied Co-Authored-By: Claude Opus 5.5 --- scripts/setup-worktree.test.ts | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 scripts/setup-worktree.test.ts diff --git a/scripts/setup-worktree.test.ts b/scripts/setup-worktree.test.ts new file mode 100644 index 0000000000000..898329ad58029 --- /dev/null +++ b/scripts/setup-worktree.test.ts @@ -0,0 +1,85 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the real setup script against a fixture checkout. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +// Windows without Developer Mode or admin rights rejects every symlink this way. +const DENY_SYMLINKS = `import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +fs.symlinkSync = () => { + throw Object.assign(new Error("EPERM: operation not permitted, symlink"), { code: "EPERM" }); +}; +syncBuiltinESMExports(); +`; + +describe("setup-worktree", () => { + it("copies env files when symlinks are denied and keeps the copies on later runs", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-setup-worktree-")); + try { + const main = NodePath.join(root, "main"); + const worktree = NodePath.join(root, "worktree"); + const bin = NodePath.join(root, "bin"); + for (const dir of [ + NodePath.join(main, "infra", "relay"), + NodePath.join(worktree, "infra", "relay"), + NodePath.join(worktree, "node_modules"), + NodePath.join(worktree, "scripts"), + bin, + ]) { + NodeFS.mkdirSync(dir, { recursive: true }); + } + NodeFS.writeFileSync(NodePath.join(main, ".env"), "ROOT=1\n"); + NodeFS.writeFileSync(NodePath.join(main, "infra", "relay", ".env"), "RELAY=1\n"); + const script = NodePath.join(worktree, "scripts", "setup-worktree.ts"); + NodeFS.copyFileSync(NodePath.join(import.meta.dirname, "setup-worktree.ts"), script); + NodeFS.writeFileSync(NodePath.join(worktree, "scripts", "package.json"), '{"type":"module"}'); + // A no-op install keeps the run on the env-file step. + NodeFS.writeFileSync(NodePath.join(bin, "vp"), "#!/bin/sh\n", { mode: 0o755 }); + NodeFS.writeFileSync(NodePath.join(worktree, "node_modules", ".modules.yaml"), "{}"); + const denySymlinks = NodePath.join(root, "deny-symlinks.mjs"); + NodeFS.writeFileSync(denySymlinks, DENY_SYMLINKS); + + const setup = () => + NodeChildProcess.spawnSync( + process.execPath, + ["--import", NodeURL.pathToFileURL(denySymlinks).href, script], + { + cwd: worktree, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + T3CODE_PROJECT_ROOT: main, + }, + }, + ); + const envFiles = () => + [".env", NodePath.join("infra", "relay", ".env")].map((file) => { + const target = NodePath.join(worktree, file); + return [NodeFS.lstatSync(target).isSymbolicLink(), NodeFS.readFileSync(target, "utf8")]; + }); + + const first = setup(); + expect(first.status, first.stderr).toBe(0); + expect(NodeFS.existsSync(NodePath.join(worktree, ".t3", "setup-done"))).toBe(true); + expect(envFiles()).toEqual([ + [false, "ROOT=1\n"], + [false, "RELAY=1\n"], + ]); + + NodeFS.writeFileSync(NodePath.join(main, ".env"), "ROOT=2\n"); + NodeFS.writeFileSync(NodePath.join(worktree, "infra", "relay", ".env"), "LOCAL=1\n"); + const second = setup(); + expect(second.status, second.stderr).toBe(0); + expect(envFiles()).toEqual([ + [false, "ROOT=1\n"], + [false, "LOCAL=1\n"], + ]); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); +}); From 08602037dbb8be99060b415b019bd37cb2cd39fa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 11:04:16 -0600 Subject: [PATCH 4/4] test(dev): simplify the denied-symlink setup fixture Co-Authored-By: Claude Opus 5.5 --- scripts/setup-worktree.test.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/scripts/setup-worktree.test.ts b/scripts/setup-worktree.test.ts index 898329ad58029..f82ccd635f50a 100644 --- a/scripts/setup-worktree.test.ts +++ b/scripts/setup-worktree.test.ts @@ -3,7 +3,6 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; import { describe, expect, it } from "vite-plus/test"; // Windows without Developer Mode or admin rights rejects every symlink this way. @@ -38,14 +37,13 @@ describe("setup-worktree", () => { NodeFS.writeFileSync(NodePath.join(worktree, "scripts", "package.json"), '{"type":"module"}'); // A no-op install keeps the run on the env-file step. NodeFS.writeFileSync(NodePath.join(bin, "vp"), "#!/bin/sh\n", { mode: 0o755 }); + NodeFS.writeFileSync(NodePath.join(bin, "vp.cmd"), "@exit /b 0\r\n"); NodeFS.writeFileSync(NodePath.join(worktree, "node_modules", ".modules.yaml"), "{}"); - const denySymlinks = NodePath.join(root, "deny-symlinks.mjs"); - NodeFS.writeFileSync(denySymlinks, DENY_SYMLINKS); - const setup = () => - NodeChildProcess.spawnSync( + const setup = () => { + const run = NodeChildProcess.spawnSync( process.execPath, - ["--import", NodeURL.pathToFileURL(denySymlinks).href, script], + ["--import", `data:text/javascript,${encodeURIComponent(DENY_SYMLINKS)}`, script], { cwd: worktree, encoding: "utf8", @@ -56,27 +54,30 @@ describe("setup-worktree", () => { }, }, ); + expect(run.status, run.stderr).toBe(0); + }; const envFiles = () => [".env", NodePath.join("infra", "relay", ".env")].map((file) => { const target = NodePath.join(worktree, file); - return [NodeFS.lstatSync(target).isSymbolicLink(), NodeFS.readFileSync(target, "utf8")]; + return { + symlink: NodeFS.lstatSync(target).isSymbolicLink(), + text: NodeFS.readFileSync(target, "utf8"), + }; }); - const first = setup(); - expect(first.status, first.stderr).toBe(0); + setup(); expect(NodeFS.existsSync(NodePath.join(worktree, ".t3", "setup-done"))).toBe(true); expect(envFiles()).toEqual([ - [false, "ROOT=1\n"], - [false, "RELAY=1\n"], + { symlink: false, text: "ROOT=1\n" }, + { symlink: false, text: "RELAY=1\n" }, ]); NodeFS.writeFileSync(NodePath.join(main, ".env"), "ROOT=2\n"); NodeFS.writeFileSync(NodePath.join(worktree, "infra", "relay", ".env"), "LOCAL=1\n"); - const second = setup(); - expect(second.status, second.stderr).toBe(0); + setup(); expect(envFiles()).toEqual([ - [false, "ROOT=1\n"], - [false, "LOCAL=1\n"], + { symlink: false, text: "ROOT=1\n" }, + { symlink: false, text: "LOCAL=1\n" }, ]); } finally { NodeFS.rmSync(root, { recursive: true, force: true });