diff --git a/docs/operations/development.md b/docs/operations/development.md index ea7f10a6ba2dd..9b5346e4b0476 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -167,7 +167,10 @@ 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. 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.test.ts b/scripts/setup-worktree.test.ts new file mode 100644 index 0000000000000..f82ccd635f50a --- /dev/null +++ b/scripts/setup-worktree.test.ts @@ -0,0 +1,86 @@ +// @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 { 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(bin, "vp.cmd"), "@exit /b 0\r\n"); + NodeFS.writeFileSync(NodePath.join(worktree, "node_modules", ".modules.yaml"), "{}"); + + const setup = () => { + const run = NodeChildProcess.spawnSync( + process.execPath, + ["--import", `data:text/javascript,${encodeURIComponent(DENY_SYMLINKS)}`, script], + { + cwd: worktree, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + T3CODE_PROJECT_ROOT: main, + }, + }, + ); + expect(run.status, run.stderr).toBe(0); + }; + const envFiles = () => + [".env", NodePath.join("infra", "relay", ".env")].map((file) => { + const target = NodePath.join(worktree, file); + return { + symlink: NodeFS.lstatSync(target).isSymbolicLink(), + text: NodeFS.readFileSync(target, "utf8"), + }; + }); + + setup(); + expect(NodeFS.existsSync(NodePath.join(worktree, ".t3", "setup-done"))).toBe(true); + expect(envFiles()).toEqual([ + { 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"); + setup(); + expect(envFiles()).toEqual([ + { symlink: false, text: "ROOT=1\n" }, + { symlink: false, text: "LOCAL=1\n" }, + ]); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 100a7af168105..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 }); @@ -112,5 +113,13 @@ 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) { + if (!(error instanceof Error && "code" in error && error.code === "EPERM")) throw error; + NodeFS.copyFileSync(source, target, NodeFS.constants.COPYFILE_EXCL); + process.stderr.write( + `Copied ${file}: symlinks are not allowed here. Delete it and rerun setup to pick up later changes to ${source}.\n`, + ); + } }