| author | |
| committer | |
| log | a17cbc3b4022b824cda9b199e4c53a8c9a608923 |
| tree | 0b829c4933bebeb9f0f6d80bd034a4fda9b9a533 |
| parent | 5719e8ac4020dda0e375ef61d044b61f55a0df8a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>9 files changed, 367 insertions(+), 9 deletions(-)
.agents/skills/test-t3-app/SKILL.md+4-2| ... | @@ -13,11 +13,13 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi | ... | @@ -13,11 +13,13 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi |
| 13 | 2. Choose a base directory that belongs only to the current worktree or test: | 13 | 2. Choose a base directory that belongs only to the current worktree or test: |
| 14 | - Use the repository's ignored `.t3` directory for reusable worktree-local state. | 14 | - Use the repository's ignored `.t3` directory for reusable worktree-local state. |
| 15 | - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. | 15 | - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. |
| 16 | 3. Start the full web stack with `vp run dev --home-dir <base-dir>`. | 16 | 3. Start the full web stack with `vp run dev`. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory. |
| 17 | 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. | 17 | 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. |
| 18 | 18 | ||
| 19 | Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. | 19 | Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. |
| 20 | 20 | ||
| 21 | The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. | ||
| 22 | |||
| 21 | The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. | 23 | The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. |
| 22 | 24 | ||
| 23 | ## Preserve the environment while iterating | 25 | ## Preserve the environment while iterating |
| ... | @@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \ | ... | @@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \ |
| 55 | 57 | ||
| 56 | Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. | 58 | Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. |
| 57 | 59 | ||
| 58 | Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. | 60 | Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `<worktree>/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. |
| 59 | 61 | ||
| 60 | ## Inspect or seed SQLite state | 62 | ## Inspect or seed SQLite state |
| 61 | 63 |
AGENTS.md+4| ... | @@ -13,6 +13,10 @@ | ... | @@ -13,6 +13,10 @@ |
| 13 | - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. | 13 | - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. |
| 14 | - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. | 14 | - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. |
| 15 | 15 | ||
| 16 | ## Dev Servers | ||
| 17 | |||
| 18 | - In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. | ||
| 19 | |||
| 16 | ## Package Roles | 20 | ## Package Roles |
| 17 | 21 | ||
| 18 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. | 22 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. |
docs/reference/scripts.md+2-1| ... | @@ -3,7 +3,8 @@ | ... | @@ -3,7 +3,8 @@ |
| 3 | - `vp run dev` — Starts contracts, server, and web in watch mode. | 3 | - `vp run dev` — Starts contracts, server, and web in watch mode. |
| 4 | - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. | 4 | - `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. |
| 5 | - `vp run dev:web` — Starts just the Vite dev server for the web app. | 5 | - `vp run dev:web` — Starts just the Vite dev server for the web app. |
| 6 | - Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data. | 6 | - Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even when `T3CODE_HOME` is set, storing state in `<worktree>/.t3/userdata`. Pass `--home-dir <path>` to choose another isolated directory explicitly. Submodules are not worktrees and keep the normal precedence. |
| 7 | - From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data. | ||
| 7 | - Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. | 8 | - Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. |
| 8 | - Pass dev-runner flags directly after the root task name, for example: | 9 | - Pass dev-runner flags directly after the root task name, for example: |
| 9 | `vp run dev --home-dir /tmp/t3code-dev` | 10 | `vp run dev --home-dir /tmp/t3code-dev` |
packages/shared/package.json+4| ... | @@ -194,6 +194,10 @@ | ... | @@ -194,6 +194,10 @@ |
| 194 | "./httpReadiness": { | 194 | "./httpReadiness": { |
| 195 | "types": "./src/httpReadiness.ts", | 195 | "types": "./src/httpReadiness.ts", |
| 196 | "import": "./src/httpReadiness.ts" | 196 | "import": "./src/httpReadiness.ts" |
| 197 | }, | ||
| 198 | "./devHome": { | ||
| 199 | "types": "./src/devHome.ts", | ||
| 200 | "import": "./src/devHome.ts" | ||
| 197 | } | 201 | } |
| 198 | }, | 202 | }, |
| 199 | "scripts": { | 203 | "scripts": { |
packages/shared/src/devHome.test.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk. | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, describe, it } from "@effect/vitest"; | ||
| 4 | import * as NodeFS from "node:fs"; | ||
| 5 | import * as NodeOS from "node:os"; | ||
| 6 | import * as NodePath from "node:path"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import { resolveGitWorktreePath, resolveWorktreeT3Home } from "./devHome.ts"; | ||
| 10 | |||
| 11 | const makeRepo = ( | ||
| 12 | kind: | ||
| 13 | | "worktree" | ||
| 14 | | "checkout" | ||
| 15 | | "bare" | ||
| 16 | | "submodule" | ||
| 17 | | "unreadable-git-file" | ||
| 18 | | "bare-repo-worktree" | ||
| 19 | | "custom-common-dir-worktree", | ||
| 20 | ) => | ||
| 21 | Effect.acquireRelease( | ||
| 22 | Effect.sync(() => { | ||
| 23 | const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devhome-")); | ||
| 24 | if (kind === "worktree") { | ||
| 25 | NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /elsewhere/.git/worktrees/x\n"); | ||
| 26 | } else if (kind === "bare-repo-worktree") { | ||
| 27 | // `git worktree add` from a bare repo: the common dir is `<name>.git`. | ||
| 28 | NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/myrepo.git/worktrees/x\n"); | ||
| 29 | } else if (kind === "custom-common-dir-worktree") { | ||
| 30 | // $GIT_COMMON_DIR need not be named `.git` at all. | ||
| 31 | NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/store/worktrees/x\n"); | ||
| 32 | } else if (kind === "submodule") { | ||
| 33 | NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: ../.git/modules/sub\n"); | ||
| 34 | } else if (kind === "unreadable-git-file") { | ||
| 35 | NodeFS.writeFileSync(NodePath.join(root, ".git"), "not a gitdir pointer\n"); | ||
| 36 | } else if (kind === "checkout") { | ||
| 37 | NodeFS.mkdirSync(NodePath.join(root, ".git")); | ||
| 38 | } | ||
| 39 | const nested = NodePath.join(root, "apps", "web", "src"); | ||
| 40 | NodeFS.mkdirSync(nested, { recursive: true }); | ||
| 41 | return { root, nested }; | ||
| 42 | }), | ||
| 43 | ({ root }) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })), | ||
| 44 | ); | ||
| 45 | |||
| 46 | describe("resolveGitWorktreePath", () => { | ||
| 47 | it.effect("finds a worktree root from a nested directory", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const { root, nested } = yield* makeRepo("worktree"); | ||
| 50 | assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root)); | ||
| 51 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 52 | ); | ||
| 53 | |||
| 54 | it.effect("reports a main checkout as not a linked worktree", () => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const { nested } = yield* makeRepo("checkout"); | ||
| 57 | assert.equal(yield* resolveGitWorktreePath(nested), undefined); | ||
| 58 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 59 | ); | ||
| 60 | |||
| 61 | it.effect("reports a directory outside a repository", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const { nested } = yield* makeRepo("bare"); | ||
| 64 | assert.equal(yield* resolveGitWorktreePath(nested), undefined); | ||
| 65 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 66 | ); | ||
| 67 | |||
| 68 | it.effect("reports a submodule as not a linked worktree", () => | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const { nested } = yield* makeRepo("submodule"); | ||
| 71 | assert.equal(yield* resolveGitWorktreePath(nested), undefined); | ||
| 72 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 73 | ); | ||
| 74 | |||
| 75 | it.effect("reports a .git file without a usable gitdir pointer", () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const { nested } = yield* makeRepo("unreadable-git-file"); | ||
| 78 | assert.equal(yield* resolveGitWorktreePath(nested), undefined); | ||
| 79 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 80 | ); | ||
| 81 | |||
| 82 | it.effect("finds a worktree of a bare repository", () => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const { root, nested } = yield* makeRepo("bare-repo-worktree"); | ||
| 85 | assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root)); | ||
| 86 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 87 | ); | ||
| 88 | |||
| 89 | it.effect("finds a worktree whose common dir is not named .git", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const { root, nested } = yield* makeRepo("custom-common-dir-worktree"); | ||
| 92 | assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root)); | ||
| 93 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 94 | ); | ||
| 95 | }); | ||
| 96 | |||
| 97 | describe("resolveWorktreeT3Home", () => { | ||
| 98 | it.effect("answers with .t3 before the dev runner creates it", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const { root, nested } = yield* makeRepo("worktree"); | ||
| 101 | const home = yield* resolveWorktreeT3Home(nested); | ||
| 102 | assert.equal(home, NodePath.join(NodePath.resolve(root), ".t3")); | ||
| 103 | assert.isFalse(NodeFS.existsSync(home ?? "")); | ||
| 104 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 105 | ); | ||
| 106 | }); | ||
packages/shared/src/devHome.ts created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | /** | ||
| 2 | * Where development state lives, and how to keep it away from the shared | ||
| 3 | * `~/.t3` that a user's installed T3 Code runs against. | ||
| 4 | * | ||
| 5 | * A linked git worktree gets its own (gitignored) `.t3`: feature work in a | ||
| 6 | * throwaway branch must not share a database with the real app, and an ambient | ||
| 7 | * `T3CODE_HOME` counts as an explicit base dir — flipping the state directory | ||
| 8 | * from `<base>/dev` to `<base>/userdata`, the live production database. | ||
| 9 | */ | ||
| 10 | |||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Path from "effect/Path"; | ||
| 15 | |||
| 16 | /** | ||
| 17 | * A `.git` file points at the real git directory. A linked worktree's lives at | ||
| 18 | * `<common-dir>/worktrees/<name>`; a submodule's at | ||
| 19 | * `<super-git-dir>/modules/<name>`. Both are files, so the pointer — not the | ||
| 20 | * file-vs-directory distinction alone — is what identifies a worktree. | ||
| 21 | * | ||
| 22 | * The common dir is not necessarily named `.git`: a worktree of a bare repo | ||
| 23 | * points at `<repo>.git/worktrees/<name>`, and `$GIT_COMMON_DIR` can be | ||
| 24 | * anything. So match on the `worktrees/<name>` tail, which git always uses, | ||
| 25 | * rather than on the name of the directory containing it. | ||
| 26 | */ | ||
| 27 | const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boolean => { | ||
| 28 | const gitdir = gitFileContents | ||
| 29 | .split(/\r?\n/) | ||
| 30 | .map((line) => line.trim()) | ||
| 31 | .find((line) => line.startsWith("gitdir:")) | ||
| 32 | ?.slice("gitdir:".length) | ||
| 33 | .trim(); | ||
| 34 | if (gitdir === undefined || gitdir.length === 0) { | ||
| 35 | return false; | ||
| 36 | } | ||
| 37 | // Compare as path segments so a directory merely named `…worktrees…` cannot | ||
| 38 | // match as a substring. Trailing separators normalize away first. | ||
| 39 | const segments = path | ||
| 40 | .normalize(gitdir.replaceAll("\\", "/")) | ||
| 41 | .split(/[/\\]/) | ||
| 42 | .filter((segment) => segment.length > 0); | ||
| 43 | // `<common-dir>/worktrees/<name>`: `worktrees` is the penultimate segment, | ||
| 44 | // and something must precede it. This excludes `<git-dir>/modules/<name>`. | ||
| 45 | return segments.length >= 3 && segments.at(-2) === "worktrees"; | ||
| 46 | }; | ||
| 47 | |||
| 48 | /** | ||
| 49 | * The path of the linked git worktree containing `cwd`, or undefined when | ||
| 50 | * `cwd` is not inside one. Git marks a linked worktree by making `.git` a file | ||
| 51 | * whose `gitdir:` points into the repository's `.git/worktrees`. | ||
| 52 | * | ||
| 53 | * Walks up to the repository root, so running from a subdirectory resolves the | ||
| 54 | * same worktree as running from the top. | ||
| 55 | */ | ||
| 56 | export const resolveGitWorktreePath = ( | ||
| 57 | cwd: string, | ||
| 58 | ): Effect.Effect<string | undefined, never, FileSystem.FileSystem | Path.Path> => | ||
| 59 | Effect.gen(function* () { | ||
| 60 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 61 | const path = yield* Path.Path; | ||
| 62 | |||
| 63 | let directory = path.resolve(cwd); | ||
| 64 | for (;;) { | ||
| 65 | const gitPath = path.join(directory, ".git"); | ||
| 66 | const info = yield* fileSystem.stat(gitPath).pipe(Effect.option); | ||
| 67 | if (Option.isSome(info)) { | ||
| 68 | // A directory means the main checkout. Stop either way: nesting one | ||
| 69 | // repository inside another does not make the outer one this root. | ||
| 70 | if (info.value.type !== "File") { | ||
| 71 | return undefined; | ||
| 72 | } | ||
| 73 | // A submodule also has a `.git` file, but it is not a worktree of this | ||
| 74 | // repository and gets no worktree-local home. | ||
| 75 | const contents = yield* fileSystem | ||
| 76 | .readFileString(gitPath) | ||
| 77 | .pipe(Effect.orElseSucceed(() => "")); | ||
| 78 | return pointsAtLinkedWorktree(contents, path) ? directory : undefined; | ||
| 79 | } | ||
| 80 | const parent = path.dirname(directory); | ||
| 81 | if (parent === directory) { | ||
| 82 | return undefined; | ||
| 83 | } | ||
| 84 | directory = parent; | ||
| 85 | } | ||
| 86 | }); | ||
| 87 | |||
| 88 | /** | ||
| 89 | * The worktree-local data directory for `cwd`, or undefined outside a linked | ||
| 90 | * worktree. Deliberately does not require the directory to exist yet: falling | ||
| 91 | * back because it is missing would send callers at the shared home. | ||
| 92 | */ | ||
| 93 | export const resolveWorktreeT3Home = ( | ||
| 94 | cwd: string, | ||
| 95 | ): Effect.Effect<string | undefined, never, FileSystem.FileSystem | Path.Path> => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const worktreePath = yield* resolveGitWorktreePath(cwd); | ||
| 98 | if (worktreePath === undefined) { | ||
| 99 | return undefined; | ||
| 100 | } | ||
| 101 | const path = yield* Path.Path; | ||
| 102 | return path.join(worktreePath, ".t3"); | ||
| 103 | }); | ||
packages/shared/src/hostProcess.ts+7| ... | @@ -30,6 +30,13 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>( | ... | @@ -30,6 +30,13 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>( |
| 30 | }, | 30 | }, |
| 31 | ); | 31 | ); |
| 32 | 32 | ||
| 33 | export const HostProcessWorkingDirectory = Context.Reference<string>( | ||
| 34 | "@t3tools/shared/hostProcess/HostProcessWorkingDirectory", | ||
| 35 | { | ||
| 36 | defaultValue: () => process.cwd(), | ||
| 37 | }, | ||
| 38 | ); | ||
| 39 | |||
| 33 | export const HostProcessExecutablePath = Context.Reference<string>( | 40 | export const HostProcessExecutablePath = Context.Reference<string>( |
| 34 | "@t3tools/shared/hostProcess/HostProcessExecutablePath", | 41 | "@t3tools/shared/hostProcess/HostProcessExecutablePath", |
| 35 | { | 42 | { |
scripts/dev-runner.test.ts+117-1| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk. | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 2 | import * as NetService from "@t3tools/shared/Net"; | 6 | import * as NetService from "@t3tools/shared/Net"; |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 7 | import { |
| 8 | HostProcessEnvironment, | ||
| 9 | HostProcessPlatform, | ||
| 10 | HostProcessWorkingDirectory, | ||
| 11 | } from "@t3tools/shared/hostProcess"; | ||
| 4 | import { assert, describe, it } from "@effect/vitest"; | 12 | import { assert, describe, it } from "@effect/vitest"; |
| 5 | import * as ConfigProvider from "effect/ConfigProvider"; | 13 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 6 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| ... | @@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 629 | assert.notInclude(error.message, "secret-token-value"); | 637 | assert.notInclude(error.message, "secret-token-value"); |
| 630 | }); | 638 | }); |
| 631 | }); | 639 | }); |
| 640 | |||
| 641 | describe("t3 home precedence", () => { | ||
| 642 | const makeWorktree = Effect.acquireRelease( | ||
| 643 | Effect.sync(() => { | ||
| 644 | const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devrunner-")); | ||
| 645 | NodeFS.writeFileSync( | ||
| 646 | NodePath.join(root, ".git"), | ||
| 647 | "gitdir: /elsewhere/.git/worktrees/x\n", | ||
| 648 | ); | ||
| 649 | return root; | ||
| 650 | }), | ||
| 651 | (root) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })), | ||
| 652 | ); | ||
| 653 | |||
| 654 | const spawnedHome = (input: { | ||
| 655 | readonly t3Home: string | undefined; | ||
| 656 | readonly cwd: string; | ||
| 657 | readonly ambientHome: string | undefined; | ||
| 658 | }) => | ||
| 659 | Effect.gen(function* () { | ||
| 660 | let captured: Record<string, string | undefined> | undefined; | ||
| 661 | const spawnerLayer = Layer.succeed( | ||
| 662 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 663 | ChildProcessSpawner.make((command) => { | ||
| 664 | captured = ( | ||
| 665 | command as { | ||
| 666 | readonly options?: { readonly env?: Record<string, string | undefined> }; | ||
| 667 | } | ||
| 668 | ).options?.env; | ||
| 669 | return Effect.succeed(mockProcess(0)); | ||
| 670 | }), | ||
| 671 | ); | ||
| 672 | |||
| 673 | yield* runDevRunnerWithInput({ ...devServerInput, t3Home: input.t3Home }).pipe( | ||
| 674 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | ||
| 675 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 676 | Effect.provideService(HostProcessWorkingDirectory, input.cwd), | ||
| 677 | Effect.provideService( | ||
| 678 | HostProcessEnvironment, | ||
| 679 | input.ambientHome === undefined ? {} : { T3CODE_HOME: input.ambientHome }, | ||
| 680 | ), | ||
| 681 | ); | ||
| 682 | |||
| 683 | return captured?.T3CODE_HOME; | ||
| 684 | }); | ||
| 685 | |||
| 686 | it.effect("prefers an explicit --home-dir over the worktree default", () => | ||
| 687 | Effect.gen(function* () { | ||
| 688 | const path = yield* Path.Path; | ||
| 689 | const root = yield* makeWorktree; | ||
| 690 | const home = yield* spawnedHome({ | ||
| 691 | t3Home: "/tmp/explicit-home", | ||
| 692 | cwd: root, | ||
| 693 | ambientHome: "/home/user/.t3", | ||
| 694 | }); | ||
| 695 | assert.equal(home, path.resolve("/tmp/explicit-home")); | ||
| 696 | }).pipe(Effect.scoped), | ||
| 697 | ); | ||
| 698 | |||
| 699 | it.effect("treats a blank --home-dir as unset rather than as a selection", () => | ||
| 700 | Effect.gen(function* () { | ||
| 701 | const path = yield* Path.Path; | ||
| 702 | const root = yield* makeWorktree; | ||
| 703 | const home = yield* spawnedHome({ | ||
| 704 | t3Home: " ", | ||
| 705 | cwd: root, | ||
| 706 | ambientHome: "/home/user/.t3", | ||
| 707 | }); | ||
| 708 | assert.equal(home, path.join(path.resolve(root), ".t3")); | ||
| 709 | }).pipe(Effect.scoped), | ||
| 710 | ); | ||
| 711 | |||
| 712 | it.effect("prefers the worktree .t3 over an ambient T3CODE_HOME", () => | ||
| 713 | Effect.gen(function* () { | ||
| 714 | const path = yield* Path.Path; | ||
| 715 | const root = yield* makeWorktree; | ||
| 716 | const home = yield* spawnedHome({ | ||
| 717 | t3Home: undefined, | ||
| 718 | cwd: root, | ||
| 719 | ambientHome: "/home/user/.t3", | ||
| 720 | }); | ||
| 721 | assert.equal(home, path.join(path.resolve(root), ".t3")); | ||
| 722 | }).pipe(Effect.scoped), | ||
| 723 | ); | ||
| 724 | |||
| 725 | it.effect("falls back to an ambient T3CODE_HOME outside a worktree", () => | ||
| 726 | Effect.gen(function* () { | ||
| 727 | const path = yield* Path.Path; | ||
| 728 | const home = yield* spawnedHome({ | ||
| 729 | t3Home: undefined, | ||
| 730 | cwd: NodeOS.tmpdir(), | ||
| 731 | ambientHome: "/home/user/.t3", | ||
| 732 | }); | ||
| 733 | assert.equal(home, path.resolve("/home/user/.t3")); | ||
| 734 | }), | ||
| 735 | ); | ||
| 736 | |||
| 737 | it.effect("leaves the home implicit with no worktree and no ambient value", () => | ||
| 738 | Effect.gen(function* () { | ||
| 739 | const home = yield* spawnedHome({ | ||
| 740 | t3Home: undefined, | ||
| 741 | cwd: NodeOS.tmpdir(), | ||
| 742 | ambientHome: undefined, | ||
| 743 | }); | ||
| 744 | assert.equal(home, undefined); | ||
| 745 | }), | ||
| 746 | ); | ||
| 747 | }); | ||
| 632 | }); | 748 | }); |
| 633 | }); | 749 | }); |
scripts/dev-runner.ts+20-5| ... | @@ -5,7 +5,8 @@ import * as NodeOS from "node:os"; | ... | @@ -5,7 +5,8 @@ import * as NodeOS from "node:os"; |
| 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as NetService from "@t3tools/shared/Net"; | 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 8 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 9 | import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; | ||
| 9 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 10 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| ... | @@ -245,7 +246,9 @@ export function createDevRunnerEnv({ | ... | @@ -245,7 +246,9 @@ export function createDevRunnerEnv({ |
| 245 | return Effect.gen(function* () { | 246 | return Effect.gen(function* () { |
| 246 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; | 247 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 247 | const webPort = BASE_WEB_PORT + webOffset; | 248 | const webPort = BASE_WEB_PORT + webOffset; |
| 248 | const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; | 249 | // Precedence is resolved by the caller. An unset t3Home here genuinely |
| 250 | // means "use the default" rather than inheriting an ambient value. | ||
| 251 | const configuredBaseDir = t3Home?.trim() || undefined; | ||
| 249 | const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); | 252 | const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); |
| 250 | const isDesktopMode = mode === "dev:desktop"; | 253 | const isDesktopMode = mode === "dev:desktop"; |
| 251 | 254 | ||
| ... | @@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 505 | }); | 508 | }); |
| 506 | 509 | ||
| 507 | const hostEnvironment = yield* HostProcessEnvironment; | 510 | const hostEnvironment = yield* HostProcessEnvironment; |
| 511 | // A worktree defaults to its own gitignored `.t3`. This deliberately | ||
| 512 | // outranks ambient T3CODE_HOME, which otherwise selects the installed | ||
| 513 | // app's live userdata database. An explicit --home-dir still wins. | ||
| 514 | const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory); | ||
| 515 | // Trim before choosing: `--home-dir ""` is not a selection, and treating it | ||
| 516 | // as one would skip the worktree default and land on the shared home — | ||
| 517 | // exactly the outcome this precedence exists to prevent. | ||
| 518 | const resolvedT3Home = | ||
| 519 | (input.t3Home?.trim() || undefined) ?? | ||
| 520 | worktreeHome ?? | ||
| 521 | (hostEnvironment.T3CODE_HOME?.trim() || undefined); | ||
| 508 | const env = yield* createDevRunnerEnv({ | 522 | const env = yield* createDevRunnerEnv({ |
| 509 | mode: input.mode, | 523 | mode: input.mode, |
| 510 | baseEnv: hostEnvironment, | 524 | baseEnv: hostEnvironment, |
| 511 | serverOffset, | 525 | serverOffset, |
| 512 | webOffset, | 526 | webOffset, |
| 513 | t3Home: input.t3Home, | 527 | t3Home: resolvedT3Home, |
| 514 | browser: input.browser, | 528 | browser: input.browser, |
| 515 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, | 529 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, |
| 516 | logWebSocketEvents: input.logWebSocketEvents, | 530 | logWebSocketEvents: input.logWebSocketEvents, |
| ... | @@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", { | ... | @@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", { |
| 592 | ), | 606 | ), |
| 593 | t3Home: Flag.string("home-dir").pipe( | 607 | t3Home: Flag.string("home-dir").pipe( |
| 594 | Flag.withDescription( | 608 | Flag.withDescription( |
| 595 | "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", | 609 | "Explicit T3 Code data directory; runtime state is stored under userdata. Inside a git worktree this defaults to that worktree's own .t3.", |
| 596 | ), | 610 | ), |
| 597 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), | 611 | Flag.optional, |
| 612 | Flag.map(Option.getOrUndefined), | ||
| 598 | ), | 613 | ), |
| 599 | browser: Flag.boolean("browser").pipe( | 614 | browser: Flag.boolean("browser").pipe( |
| 600 | Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), | 615 | Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), |