From 7c331eeddeaf8a5859dea19f994d490966eb0287 Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 18:56:27 -0500 Subject: [PATCH 1/4] fix(scripts): run worktree setup without POSIX shell t3.json worktree setup used ln -sf and shell variable expansion, which fails under Windows PowerShell when creating worktrees. Replace it with a small Node script that installs deps, links or copies .env files, and warms the web dep cache. --- scripts/setup-worktree.ts | 50 +++++++++++++++++++++++++++++++++++++++ t3.json | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 scripts/setup-worktree.ts diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts new file mode 100644 index 000000000000..a35a27e9a8a8 --- /dev/null +++ b/scripts/setup-worktree.ts @@ -0,0 +1,50 @@ +// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +/** + * Worktree setup for t3.json, portable across POSIX and Windows shells. The + * previous inline command chained `ln -sf` and `$VAR` expansion, which fails + * under PowerShell/cmd. Installs dependencies, links the project root's `.env` + * files into the worktree, then warms the web dependency cache. + * + * On Windows, symlink creation needs Developer Mode or elevation; when it is + * denied we fall back to copying the file (a copy won't track later edits to + * the root `.env`, so re-run this script after changing it). + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFs from "node:fs"; +import * as NodePath from "node:path"; + +const projectRoot = process.env.T3CODE_PROJECT_ROOT; +const worktree = process.env.T3CODE_WORKTREE_PATH ?? process.cwd(); + +function run(command: string, args: readonly string[]): void { + // shell: true so Windows resolves launcher shims like vp.cmd + const result = NodeChildProcess.spawnSync(command, [...args], { + stdio: "inherit", + shell: true, + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function linkEnvFile(relativePath: string): void { + if (!projectRoot) return; + const source = NodePath.join(projectRoot, relativePath); + const destination = NodePath.join(worktree, relativePath); + // Running in the project root itself would link the file to itself. + if (NodePath.resolve(source) === NodePath.resolve(destination)) return; + NodeFs.rmSync(destination, { force: true }); + try { + NodeFs.symlinkSync(source, destination, "file"); + } catch { + if (NodeFs.existsSync(source)) { + NodeFs.copyFileSync(source, destination); + console.log(`[setup-worktree] copied ${relativePath} (symlink unavailable)`); + } + } +} + +run("vp", ["i"]); +linkEnvFile(".env"); +linkEnvFile(NodePath.join("infra", "relay", ".env")); +run("node", [NodePath.join("apps", "web", "scripts", "warm-dep-cache.ts")]); diff --git a/t3.json b/t3.json index 007e8f961948..d2c55e050776 100644 --- a/t3.json +++ b/t3.json @@ -4,7 +4,7 @@ "scripts": [ { "name": "Setup Worktree", - "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts", + "command": "node scripts/setup-worktree.ts", "icon": "configure", "runOnWorktreeCreate": true } From 33c0326b036b2e0275d5904e9f5e95d608fb61cd Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 21:40:47 -0500 Subject: [PATCH 2/4] fix(scripts): harden worktree setup env linking and tests Pin cwd for installs, only replace env files when the project root has a source, create parent dirs, and cover link/copy/skip paths so Windows setup does not wipe local .env files. --- scripts/setup-worktree.test.ts | 94 ++++++++++++++++++++++ scripts/setup-worktree.ts | 142 ++++++++++++++++++++++++++------- 2 files changed, 208 insertions(+), 28 deletions(-) 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 000000000000..d748edf7920a --- /dev/null +++ b/scripts/setup-worktree.test.ts @@ -0,0 +1,94 @@ +// @effect-diagnostics nodeBuiltinImport:off +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"; + +import { + linkOrCopyEnvFile, + linkProjectEnvFiles, + resolveWorktreePaths, +} from "./setup-worktree.ts"; + +const makeTempDir = (prefix: string): string => + NodeFs.mkdtempSync(NodePath.join(NodeOs.tmpdir(), prefix)); + +describe("setup-worktree", () => { + it("resolves worktree from env, falling back to cwd", () => { + expect( + resolveWorktreePaths({ + T3CODE_PROJECT_ROOT: "C:\\repo", + T3CODE_WORKTREE_PATH: "C:\\repo-wt", + }), + ).toEqual({ + projectRoot: "C:\\repo", + worktree: "C:\\repo-wt", + }); + + const resolved = resolveWorktreePaths({ T3CODE_PROJECT_ROOT: "/repo" }); + expect(resolved.projectRoot).toBe("/repo"); + expect(resolved.worktree).toBe(process.cwd()); + }); + + it("links or copies env files into the worktree", () => { + const root = makeTempDir("setup-worktree-root-"); + const worktree = makeTempDir("setup-worktree-wt-"); + try { + NodeFs.mkdirSync(NodePath.join(root, "infra", "relay"), { recursive: true }); + NodeFs.writeFileSync(NodePath.join(root, ".env"), "ROOT=1\n", "utf8"); + NodeFs.writeFileSync(NodePath.join(root, "infra", "relay", ".env"), "RELAY=1\n", "utf8"); + + const results = linkProjectEnvFiles({ projectRoot: root, worktree }); + expect(results).toHaveLength(2); + for (const entry of results) { + expect(["linked", "copied"]).toContain(entry.result); + } + // Either linked or copied is fine; content must match. + expect(NodeFs.readFileSync(NodePath.join(worktree, ".env"), "utf8")).toBe("ROOT=1\n"); + expect(NodeFs.readFileSync(NodePath.join(worktree, "infra", "relay", ".env"), "utf8")).toBe( + "RELAY=1\n", + ); + } finally { + NodeFs.rmSync(root, { recursive: true, force: true }); + NodeFs.rmSync(worktree, { recursive: true, force: true }); + } + }); + + it("does not delete a worktree env file when the source is missing", () => { + const root = makeTempDir("setup-worktree-missing-root-"); + const worktree = makeTempDir("setup-worktree-missing-wt-"); + try { + const destination = NodePath.join(worktree, ".env"); + NodeFs.writeFileSync(destination, "LOCAL=1\n", "utf8"); + + expect( + linkOrCopyEnvFile({ + projectRoot: root, + worktree, + relativePath: ".env", + }), + ).toBe("skipped-missing-source"); + expect(NodeFs.readFileSync(destination, "utf8")).toBe("LOCAL=1\n"); + } finally { + NodeFs.rmSync(root, { recursive: true, force: true }); + NodeFs.rmSync(worktree, { recursive: true, force: true }); + } + }); + + it("skips when project root and worktree are the same path", () => { + const root = makeTempDir("setup-worktree-same-"); + try { + NodeFs.writeFileSync(NodePath.join(root, ".env"), "SAME=1\n", "utf8"); + expect( + linkOrCopyEnvFile({ + projectRoot: root, + worktree: root, + relativePath: ".env", + }), + ).toBe("skipped-same-path"); + } finally { + NodeFs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index a35a27e9a8a8..c7be4c236651 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -1,50 +1,136 @@ // @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. /** - * Worktree setup for t3.json, portable across POSIX and Windows shells. The - * previous inline command chained `ln -sf` and `$VAR` expansion, which fails - * under PowerShell/cmd. Installs dependencies, links the project root's `.env` - * files into the worktree, then warms the web dependency cache. + * Worktree setup for t3.json, portable across POSIX and Windows shells. + * + * The previous inline command chained `ln -sf` and `$VAR` expansion, which + * fails under PowerShell/cmd when T3 types the setup script into a terminal + * (see ProjectSetupScriptRunner). This Node entrypoint: + * 1. installs deps (`vp i`) + * 2. links the project root's `.env` files into the worktree + * 3. warms the web dependency cache * * On Windows, symlink creation needs Developer Mode or elevation; when it is - * denied we fall back to copying the file (a copy won't track later edits to - * the root `.env`, so re-run this script after changing it). + * denied we fall back to copying (a copy won't track later edits to the root + * `.env`, so re-run this script after changing it). */ import * as NodeChildProcess from "node:child_process"; import * as NodeFs from "node:fs"; import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; + +export type EnvLinkResult = + | "linked" + | "copied" + | "skipped-same-path" + | "skipped-missing-source"; + +export const ENV_LINK_RELATIVE_PATHS = [".env", NodePath.join("infra", "relay", ".env")] as const; + +export function resolveWorktreePaths(env: NodeJS.ProcessEnv = NodeProcess.env): { + readonly projectRoot: string | undefined; + readonly worktree: string; +} { + return { + projectRoot: env.T3CODE_PROJECT_ROOT || undefined, + worktree: env.T3CODE_WORKTREE_PATH || NodeProcess.cwd(), + }; +} + +/** + * Link `projectRoot/relativePath` into the worktree. Never deletes the + * destination when the source is missing (avoids wiping a local file if the + * root has no `.env` yet). + */ +export function linkOrCopyEnvFile(input: { + readonly projectRoot: string; + readonly worktree: string; + readonly relativePath: string; +}): EnvLinkResult { + const source = NodePath.join(input.projectRoot, input.relativePath); + const destination = NodePath.join(input.worktree, input.relativePath); + if (NodePath.resolve(source) === NodePath.resolve(destination)) { + return "skipped-same-path"; + } + if (!NodeFs.existsSync(source)) { + return "skipped-missing-source"; + } + + NodeFs.mkdirSync(NodePath.dirname(destination), { recursive: true }); + NodeFs.rmSync(destination, { force: true }); + try { + NodeFs.symlinkSync(source, destination, "file"); + return "linked"; + } catch { + NodeFs.copyFileSync(source, destination); + console.log(`[setup-worktree] copied ${input.relativePath} (symlink unavailable)`); + return "copied"; + } +} -const projectRoot = process.env.T3CODE_PROJECT_ROOT; -const worktree = process.env.T3CODE_WORKTREE_PATH ?? process.cwd(); +export function linkProjectEnvFiles(input: { + readonly projectRoot: string; + readonly worktree: string; + readonly relativePaths?: readonly string[]; +}): ReadonlyArray<{ readonly relativePath: string; readonly result: EnvLinkResult }> { + const relativePaths = input.relativePaths ?? ENV_LINK_RELATIVE_PATHS; + return relativePaths.map((relativePath) => ({ + relativePath, + result: linkOrCopyEnvFile({ + projectRoot: input.projectRoot, + worktree: input.worktree, + relativePath, + }), + })); +} -function run(command: string, args: readonly string[]): void { - // shell: true so Windows resolves launcher shims like vp.cmd +function run(command: string, args: readonly string[], cwd: string): void { + // shell: true so Windows resolves launcher shims (vp.cmd) the same way a + // typed terminal command would. Always pin cwd to the worktree — do not + // rely on the process already sitting there. const result = NodeChildProcess.spawnSync(command, [...args], { stdio: "inherit", shell: true, + cwd, + env: NodeProcess.env, }); + if (result.error) { + console.error(`[setup-worktree] failed to spawn ${command}:`, result.error.message); + NodeProcess.exit(1); + } if (result.status !== 0) { - process.exit(result.status ?? 1); + NodeProcess.exit(result.status ?? 1); } } -function linkEnvFile(relativePath: string): void { - if (!projectRoot) return; - const source = NodePath.join(projectRoot, relativePath); - const destination = NodePath.join(worktree, relativePath); - // Running in the project root itself would link the file to itself. - if (NodePath.resolve(source) === NodePath.resolve(destination)) return; - NodeFs.rmSync(destination, { force: true }); - try { - NodeFs.symlinkSync(source, destination, "file"); - } catch { - if (NodeFs.existsSync(source)) { - NodeFs.copyFileSync(source, destination); - console.log(`[setup-worktree] copied ${relativePath} (symlink unavailable)`); +export function runSetupWorktree(env: NodeJS.ProcessEnv = NodeProcess.env): void { + const { projectRoot, worktree } = resolveWorktreePaths(env); + if (!NodeFs.existsSync(worktree)) { + console.error(`[setup-worktree] worktree path does not exist: ${worktree}`); + NodeProcess.exit(1); + } + + NodeProcess.chdir(worktree); + + run("vp", ["i"], worktree); + + if (projectRoot) { + for (const { relativePath, result } of linkProjectEnvFiles({ projectRoot, worktree })) { + if (result === "linked" || result === "copied") { + console.log(`[setup-worktree] ${result} ${relativePath}`); + } } + } else { + console.warn("[setup-worktree] T3CODE_PROJECT_ROOT unset; skipping .env link"); } + + run("node", [NodePath.join("apps", "web", "scripts", "warm-dep-cache.ts")], worktree); } -run("vp", ["i"]); -linkEnvFile(".env"); -linkEnvFile(NodePath.join("infra", "relay", ".env")); -run("node", [NodePath.join("apps", "web", "scripts", "warm-dep-cache.ts")]); +const isExecutedDirectly = + typeof NodeProcess.argv[1] === "string" && + NodeURL.pathToFileURL(NodePath.resolve(NodeProcess.argv[1])).href === import.meta.url; + +if (isExecutedDirectly) { + runSetupWorktree(); +} From 1cb172ad242365b6f94a460573403b6705fc407b Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 22:08:57 -0500 Subject: [PATCH 3/4] fix(scripts): stage env link/copy before replacing worktree file Review: rmSync before symlink/copy could delete a locally edited worktree .env if the replacement then failed. Stage to a temp path and only rename over the destination after the operation succeeds; restore from backup on swap failure. --- scripts/setup-worktree.test.ts | 23 ++++++++++++ scripts/setup-worktree.ts | 69 +++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/scripts/setup-worktree.test.ts b/scripts/setup-worktree.test.ts index d748edf7920a..fa1d1dbe6572 100644 --- a/scripts/setup-worktree.test.ts +++ b/scripts/setup-worktree.test.ts @@ -91,4 +91,27 @@ describe("setup-worktree", () => { NodeFs.rmSync(root, { recursive: true, force: true }); } }); + + it("keeps the worktree env file when staging the replacement fails", () => { + const root = makeTempDir("setup-worktree-fail-root-"); + const worktree = makeTempDir("setup-worktree-fail-wt-"); + try { + // Source path exists but is a directory so symlink("file")/copyFile both fail. + NodeFs.mkdirSync(NodePath.join(root, ".env")); + const destination = NodePath.join(worktree, ".env"); + NodeFs.writeFileSync(destination, "LOCAL=1\n", "utf8"); + + expect(() => + linkOrCopyEnvFile({ + projectRoot: root, + worktree, + relativePath: ".env", + }), + ).toThrow(); + expect(NodeFs.readFileSync(destination, "utf8")).toBe("LOCAL=1\n"); + } finally { + NodeFs.rmSync(root, { recursive: true, force: true }); + NodeFs.rmSync(worktree, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index c7be4c236651..c8bd53809b9d 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -40,7 +40,8 @@ export function resolveWorktreePaths(env: NodeJS.ProcessEnv = NodeProcess.env): /** * Link `projectRoot/relativePath` into the worktree. Never deletes the * destination when the source is missing (avoids wiping a local file if the - * root has no `.env` yet). + * root has no `.env` yet). Staging + rename keeps a locally edited worktree + * file if symlink/copy fails mid-flight. */ export function linkOrCopyEnvFile(input: { readonly projectRoot: string; @@ -56,15 +57,65 @@ export function linkOrCopyEnvFile(input: { return "skipped-missing-source"; } - NodeFs.mkdirSync(NodePath.dirname(destination), { recursive: true }); - NodeFs.rmSync(destination, { force: true }); + const destinationDir = NodePath.dirname(destination); + NodeFs.mkdirSync(destinationDir, { recursive: true }); + + // Same directory as destination so rename is same-volume (atomic on POSIX; + // on Windows we still only remove the old file after staging succeeds). + const staging = NodePath.join( + destinationDir, + `.${NodePath.basename(destination)}.${NodeProcess.pid}.${Date.now()}.tmp`, + ); + const backup = `${staging}.bak`; + + let result: "linked" | "copied"; try { - NodeFs.symlinkSync(source, destination, "file"); - return "linked"; - } catch { - NodeFs.copyFileSync(source, destination); - console.log(`[setup-worktree] copied ${input.relativePath} (symlink unavailable)`); - return "copied"; + try { + NodeFs.symlinkSync(source, staging, "file"); + result = "linked"; + } catch { + NodeFs.copyFileSync(source, staging); + result = "copied"; + console.log(`[setup-worktree] copied ${input.relativePath} (symlink unavailable)`); + } + + let hadDestination = false; + try { + NodeFs.renameSync(destination, backup); + hadDestination = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + try { + NodeFs.renameSync(staging, destination); + } catch (error) { + if (hadDestination) { + try { + NodeFs.renameSync(backup, destination); + } catch { + // Leave backup on disk for manual recovery. + } + } + throw error; + } + + if (hadDestination) { + NodeFs.rmSync(backup, { force: true }); + } + return result; + } catch (error) { + NodeFs.rmSync(staging, { force: true }); + if (NodeFs.existsSync(backup) && !NodeFs.existsSync(destination)) { + try { + NodeFs.renameSync(backup, destination); + } catch { + // Leave backup on disk for manual recovery. + } + } + throw error; } } From 60918659d093c34beff103bfa0fe1fea2764aaf2 Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 22:35:00 -0500 Subject: [PATCH 4/4] fix(scripts): realpath same-path guard and skip non-file env sources Review: resolve alone missed worktree junctions/symlinks to the project root, and a directory source could symlink-succeed on POSIX. Use realpath for identity, skip non-files without touching the destination, and cover both cases in tests. --- scripts/setup-worktree.test.ts | 40 +++++++++++++++++++++++++++++----- scripts/setup-worktree.ts | 21 ++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/setup-worktree.test.ts b/scripts/setup-worktree.test.ts index fa1d1dbe6572..88f158d67fba 100644 --- a/scripts/setup-worktree.test.ts +++ b/scripts/setup-worktree.test.ts @@ -92,26 +92,54 @@ describe("setup-worktree", () => { } }); - it("keeps the worktree env file when staging the replacement fails", () => { - const root = makeTempDir("setup-worktree-fail-root-"); - const worktree = makeTempDir("setup-worktree-fail-wt-"); + it("skips when worktree is a symlink/junction to the project root", () => { + const root = makeTempDir("setup-worktree-alias-root-"); + const parent = makeTempDir("setup-worktree-alias-parent-"); + const alias = NodePath.join(parent, "wt-alias"); + try { + NodeFs.writeFileSync(NodePath.join(root, ".env"), "ROOT=1\n", "utf8"); + try { + NodeFs.symlinkSync(root, alias, "junction"); + } catch { + // Junction/symlink may be denied; skip this platform-specific case. + return; + } + expect( + linkOrCopyEnvFile({ + projectRoot: root, + worktree: alias, + relativePath: ".env", + }), + ).toBe("skipped-same-path"); + // Must not have replaced root/.env with a self-link/copy mess. + expect(NodeFs.readFileSync(NodePath.join(root, ".env"), "utf8")).toBe("ROOT=1\n"); + } finally { + NodeFs.rmSync(alias, { force: true }); + NodeFs.rmSync(root, { recursive: true, force: true }); + NodeFs.rmSync(parent, { recursive: true, force: true }); + } + }); + + it("skips non-file sources without touching the worktree env", () => { + const root = makeTempDir("setup-worktree-dir-root-"); + const worktree = makeTempDir("setup-worktree-dir-wt-"); try { - // Source path exists but is a directory so symlink("file")/copyFile both fail. NodeFs.mkdirSync(NodePath.join(root, ".env")); const destination = NodePath.join(worktree, ".env"); NodeFs.writeFileSync(destination, "LOCAL=1\n", "utf8"); - expect(() => + expect( linkOrCopyEnvFile({ projectRoot: root, worktree, relativePath: ".env", }), - ).toThrow(); + ).toBe("skipped-not-a-file"); expect(NodeFs.readFileSync(destination, "utf8")).toBe("LOCAL=1\n"); } finally { NodeFs.rmSync(root, { recursive: true, force: true }); NodeFs.rmSync(worktree, { recursive: true, force: true }); } }); + }); diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index c8bd53809b9d..52a4f6aab4fd 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -23,7 +23,8 @@ export type EnvLinkResult = | "linked" | "copied" | "skipped-same-path" - | "skipped-missing-source"; + | "skipped-missing-source" + | "skipped-not-a-file"; export const ENV_LINK_RELATIVE_PATHS = [".env", NodePath.join("infra", "relay", ".env")] as const; @@ -37,6 +38,15 @@ export function resolveWorktreePaths(env: NodeJS.ProcessEnv = NodeProcess.env): }; } +/** Resolve through symlinks/junctions when the path exists; else lexical resolve. */ +export function resolvePathIdentity(path: string): string { + try { + return NodeFs.realpathSync(path); + } catch { + return NodePath.resolve(path); + } +} + /** * Link `projectRoot/relativePath` into the worktree. Never deletes the * destination when the source is missing (avoids wiping a local file if the @@ -50,12 +60,19 @@ export function linkOrCopyEnvFile(input: { }): EnvLinkResult { const source = NodePath.join(input.projectRoot, input.relativePath); const destination = NodePath.join(input.worktree, input.relativePath); - if (NodePath.resolve(source) === NodePath.resolve(destination)) { + // realpath so a worktree that is a symlink/junction to the project root is + // treated as the same path (resolve alone does not follow links). + if (resolvePathIdentity(source) === resolvePathIdentity(destination)) { return "skipped-same-path"; } if (!NodeFs.existsSync(source)) { return "skipped-missing-source"; } + // Directories (or other non-files) must not be linked/copied over a .env — + // on POSIX, symlinkSync to a directory would otherwise "succeed". + if (!NodeFs.statSync(source).isFile()) { + return "skipped-not-a-file"; + } const destinationDir = NodePath.dirname(destination); NodeFs.mkdirSync(destinationDir, { recursive: true });