| 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 | 13 | 2. Choose a base directory that belongs only to the current worktree or test: |
| 14 | 14 | - Use the repository's ignored `.t3` directory for reusable worktree-local state. |
| 15 | 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 | 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 | 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 | 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 | 25 | ## Preserve the environment while iterating |
| ... | ... | @@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \ |
| 55 | 57 | |
| 56 | 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 | 62 | ## Inspect or seed SQLite state |
| 61 | 63 |
AGENTS.md+4| ... | ... | @@ -13,6 +13,10 @@ |
| 13 | 13 | - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. |
| 14 | 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 | 20 | ## Package Roles |
| 17 | 21 | |
| 18 | 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 | 3 | - `vp run dev` — Starts contracts, server, and web in watch mode. |
| 4 | 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 | 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 | 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 | 9 | - Pass dev-runner flags directly after the root task name, for example: |
| 9 | 10 | `vp run dev --home-dir /tmp/t3code-dev` |
packages/shared/package.json+4| ... | ... | @@ -194,6 +194,10 @@ |
| 194 | 194 | "./httpReadiness": { |
| 195 | 195 | "types": "./src/httpReadiness.ts", |
| 196 | 196 | "import": "./src/httpReadiness.ts" |
| 197 | }, | |
| 198 | "./devHome": { | |
| 199 | "types": "./src/devHome.ts", | |
| 200 | "import": "./src/devHome.ts" | |
| 197 | 201 | } |
| 198 | 202 | }, |
| 199 | 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 | 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 | 40 | export const HostProcessExecutablePath = Context.Reference<string>( |
| 34 | 41 | "@t3tools/shared/hostProcess/HostProcessExecutablePath", |
| 35 | 42 | { |
scripts/dev-runner.test.ts+117-1| ... | ... | @@ -1,6 +1,14 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk. | |
| 1 | 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 | 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 | 12 | import { assert, describe, it } from "@effect/vitest"; |
| 5 | 13 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 6 | 14 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 629 | 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 | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 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 | 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 10 | 11 | import * as Config from "effect/Config"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -245,7 +246,9 @@ export function createDevRunnerEnv({ |
| 245 | 246 | return Effect.gen(function* () { |
| 246 | 247 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 247 | 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 | 252 | const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); |
| 250 | 253 | const isDesktopMode = mode === "dev:desktop"; |
| 251 | 254 | |
| ... | ... | @@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 505 | 508 | }); |
| 506 | 509 | |
| 507 | 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 | 522 | const env = yield* createDevRunnerEnv({ |
| 509 | 523 | mode: input.mode, |
| 510 | 524 | baseEnv: hostEnvironment, |
| 511 | 525 | serverOffset, |
| 512 | 526 | webOffset, |
| 513 | t3Home: input.t3Home, | |
| 527 | t3Home: resolvedT3Home, | |
| 514 | 528 | browser: input.browser, |
| 515 | 529 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, |
| 516 | 530 | logWebSocketEvents: input.logWebSocketEvents, |
| ... | ... | @@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", { |
| 592 | 606 | ), |
| 593 | 607 | t3Home: Flag.string("home-dir").pipe( |
| 594 | 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 | 614 | browser: Flag.boolean("browser").pipe( |
| 600 | 615 | Flag.withDescription("Open a browser automatically (disabled by default for web dev)."), |