| author | |
| committer | |
| log | 23b55022175e69938514934f65c5a607d38f1e47 |
| tree | 95fa44a061e32d7caf27b54160e5d84b96970a2c |
| parent | a17cbc3b4022b824cda9b199e4c53a8c9a608923 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>29 files changed, 1417 insertions(+), 65 deletions(-)
.agents/skills/test-t3-app/SKILL.md+5-1| ... | ... | @@ -13,13 +13,17 @@ 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`. 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. | |
| 16 | 3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. 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 | 21 | The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. |
| 22 | 22 | |
| 23 | Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. | |
| 24 | ||
| 25 | Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. | |
| 26 | ||
| 23 | 27 | 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. |
| 24 | 28 | |
| 25 | 29 | ## Preserve the environment while iterating |
AGENTS.md+3| ... | ... | @@ -16,6 +16,9 @@ |
| 16 | 16 | ## Dev Servers |
| 17 | 17 | |
| 18 | 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 | - Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. | |
| 20 | - Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. | |
| 21 | - Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. | |
| 19 | 22 | |
| 20 | 23 | ## Package Roles |
| 21 | 24 |
apps/server/src/auth/EnvironmentAuth.test.ts+15-1| ... | ... | @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts"; |
| 8 | 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 9 | 9 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 10 | 10 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 11 | import { resolveSessionCookieName } from "./utils.ts"; | |
| 11 | 12 | |
| 12 | 13 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 13 | 14 | |
| 15 | /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ | |
| 16 | const TEST_SERVER_PORT = 13_773; | |
| 17 | ||
| 14 | 18 | const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) => |
| 15 | 19 | Layer.effect( |
| 16 | 20 | ServerConfig.ServerConfig, |
| ... | ... | @@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se |
| 19 | 23 | return { |
| 20 | 24 | ...config, |
| 21 | 25 | ...overrides, |
| 26 | // Last, so the port cannot be overridden out from under | |
| 27 | // makeCookieRequest — which builds the cookie name from this constant. | |
| 28 | // An override that changed it would leave the server reading | |
| 29 | // t3_session_<other> while every request still sent t3_session_13773, | |
| 30 | // and the tests would fail for a reason unrelated to what they assert. | |
| 31 | port: TEST_SERVER_PORT, | |
| 22 | 32 | } satisfies ServerConfig.ServerConfig["Service"]; |
| 23 | 33 | }), |
| 24 | 34 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); |
| ... | ... | @@ -35,7 +45,11 @@ const makeCookieRequest = ( |
| 35 | 45 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => |
| 36 | 46 | ({ |
| 37 | 47 | cookies: { |
| 38 | t3_session: sessionToken, | |
| 48 | // Derived, not hardcoded: the name is port-scoped so concurrent servers | |
| 49 | // on one hostname don't share a cookie. Mode and devUrl mirror | |
| 50 | // ServerConfig.layerTest, so this resolves to whatever the server reads. | |
| 51 | [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]: | |
| 52 | sessionToken, | |
| 39 | 53 | }, |
| 40 | 54 | headers: {}, |
| 41 | 55 | }) as unknown as Parameters< |
apps/server/src/auth/EnvironmentAuth.ts+5| ... | ... | @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service< |
| 438 | 438 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 439 | 439 | readonly subject?: string; |
| 440 | 440 | readonly proofKeyThumbprint?: string; |
| 441 | readonly purpose?: "startup"; | |
| 441 | 442 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; |
| 442 | 443 | readonly issuePairingCredential: ( |
| 443 | 444 | input?: AuthCreatePairingCredentialInput, |
| ... | ... | @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () { |
| 746 | 747 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 747 | 748 | readonly subject: string; |
| 748 | 749 | readonly label?: string; |
| 750 | readonly purpose?: "startup"; | |
| 749 | 751 | }) => |
| 750 | 752 | createPairingLink({ |
| 751 | 753 | scopes: input.scopes, |
| 752 | 754 | subject: input.subject, |
| 753 | 755 | ...(input.label ? { label: input.label } : {}), |
| 756 | ...(input.purpose ? { purpose: input.purpose } : {}), | |
| 754 | 757 | }).pipe( |
| 755 | 758 | Effect.map( |
| 756 | 759 | (issued) => |
| ... | ... | @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () { |
| 774 | 777 | ...(input?.ttl ? { ttl: input.ttl } : {}), |
| 775 | 778 | ...(input?.label ? { label: input.label } : {}), |
| 776 | 779 | ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), |
| 780 | ...(input?.purpose ? { purpose: input.purpose } : {}), | |
| 777 | 781 | }); |
| 778 | 782 | return { |
| 779 | 783 | id: issued.id, |
| ... | ... | @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () { |
| 872 | 876 | issuePairingCredentialForSubject({ |
| 873 | 877 | scopes: AuthAdministrativeScopes, |
| 874 | 878 | subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, |
| 879 | purpose: "startup", | |
| 875 | 880 | }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); |
| 876 | 881 | |
| 877 | 882 | const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+37| ... | ... | @@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 34 | 34 | |
| 35 | 35 | expect(descriptor.policy).toBe("desktop-managed-local"); |
| 36 | 36 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); |
| 37 | // Packaged desktop has no devUrl, but still needs the port scope: it | |
| 38 | // scans upward from 3773 for a free port and binds 127.0.0.1, so a second | |
| 39 | // instance shares this one's hostname on a different port. | |
| 37 | 40 | expect(descriptor.sessionCookieName).toBe("t3_session_3773"); |
| 38 | 41 | }).pipe( |
| 39 | 42 | Effect.provide( |
| ... | ... | @@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 45 | 48 | ), |
| 46 | 49 | ); |
| 47 | 50 | |
| 51 | it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () => | |
| 52 | Effect.gen(function* () { | |
| 53 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 54 | const descriptor = yield* policy.getDescriptor(); | |
| 55 | ||
| 56 | expect(descriptor.sessionCookieName).toBe("t3_session_3774"); | |
| 57 | }).pipe( | |
| 58 | Effect.provide( | |
| 59 | makeEnvironmentAuthPolicyLayer({ | |
| 60 | mode: "desktop", | |
| 61 | port: 3774, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ), | |
| 65 | ); | |
| 66 | ||
| 48 | 67 | it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => |
| 49 | 68 | Effect.gen(function* () { |
| 50 | 69 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| ... | ... | @@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 75 | 94 | makeEnvironmentAuthPolicyLayer({ |
| 76 | 95 | mode: "web", |
| 77 | 96 | host: "127.0.0.1", |
| 97 | port: 13773, | |
| 98 | }), | |
| 99 | ), | |
| 100 | ), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("scopes web session cookies by port only in development", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 106 | const descriptor = yield* policy.getDescriptor(); | |
| 107 | ||
| 108 | expect(descriptor.sessionCookieName).toBe("t3_session_13773"); | |
| 109 | }).pipe( | |
| 110 | Effect.provide( | |
| 111 | makeEnvironmentAuthPolicyLayer({ | |
| 112 | mode: "web", | |
| 113 | port: 13773, | |
| 114 | devUrl: new URL("http://127.0.0.1:5733"), | |
| 78 | 115 | }), |
| 79 | 116 | ), |
| 80 | 117 | ), |
apps/server/src/auth/EnvironmentAuthPolicy.ts+1| ... | ... | @@ -41,6 +41,7 @@ export const make = Effect.gen(function* () { |
| 41 | 41 | sessionCookieName: resolveSessionCookieName({ |
| 42 | 42 | mode: config.mode, |
| 43 | 43 | port: config.port, |
| 44 | devUrl: config.devUrl, | |
| 44 | 45 | }), |
| 45 | 46 | }; |
| 46 | 47 |
apps/server/src/auth/PairingGrantStore.ts+18-1| ... | ... | @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service< |
| 202 | 202 | readonly subject?: string; |
| 203 | 203 | readonly label?: string; |
| 204 | 204 | readonly proofKeyThumbprint?: string; |
| 205 | /** | |
| 206 | * "startup" marks the credential the server mints for itself at boot, | |
| 207 | * which gets the long dev TTL when a dev URL is configured. | |
| 208 | */ | |
| 209 | readonly purpose?: "startup"; | |
| 205 | 210 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; |
| 206 | 211 | readonly listActive: () => Effect.Effect< |
| 207 | 212 | ReadonlyArray<AuthPairingLink>, |
| ... | ... | @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); |
| 243 | 248 | // window can still recover by re-bootstrapping rather than locking |
| 244 | 249 | // the user out of the backend. |
| 245 | 250 | const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); |
| 251 | // A dev server's startup token is read off a log by whoever (or whatever) is | |
| 252 | // driving the session, often minutes later — after a `node --watch` restart, a | |
| 253 | // detour into another task, or a hand-off to the person actually doing the | |
| 254 | // testing. Five minutes turns that into a restart-the-server loop for no | |
| 255 | // security benefit: the token only unlocks a local dev backend, and its holder | |
| 256 | // could read the log anyway. Same reasoning (and duration) as the desktop | |
| 257 | // bootstrap grant above. Only applies when a dev URL is configured; user-issued | |
| 258 | // pairing links and real servers keep the 5-minute default. | |
| 259 | const DEV_STARTUP_TTL_HOURS = Duration.hours(24); | |
| 246 | 260 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; |
| 247 | 261 | const PAIRING_TOKEN_LENGTH = 12; |
| 248 | 262 | const PAIRING_TOKEN_REJECTION_LIMIT = |
| ... | ... | @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () { |
| 371 | 385 | ), |
| 372 | 386 | ); |
| 373 | 387 | const credential = yield* generatePairingToken; |
| 374 | const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; | |
| 388 | const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup"; | |
| 389 | const ttl = | |
| 390 | input?.ttl ?? | |
| 391 | (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); | |
| 375 | 392 | const now = yield* DateTime.now; |
| 376 | 393 | const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); |
| 377 | 394 | const issued: IssuedBootstrapCredential = { |
apps/server/src/auth/SessionStore.ts+1| ... | ... | @@ -470,6 +470,7 @@ export const make = Effect.gen(function* () { |
| 470 | 470 | const cookieName = resolveSessionCookieName({ |
| 471 | 471 | mode: serverConfig.mode, |
| 472 | 472 | port: serverConfig.port, |
| 473 | devUrl: serverConfig.devUrl, | |
| 473 | 474 | }); |
| 474 | 475 | |
| 475 | 476 | const emitUpsert = (clientSession: AuthClientSession) => |
apps/server/src/auth/utils.ts+19-5| ... | ... | @@ -10,15 +10,29 @@ import * as Result from "effect/Result"; |
| 10 | 10 | |
| 11 | 11 | const SESSION_COOKIE_NAME = "t3_session"; |
| 12 | 12 | |
| 13 | /** | |
| 14 | * Cookies are scoped by host but *not* by port, so any two servers that can be | |
| 15 | * live on one hostname at once need separate names — otherwise the second | |
| 16 | * clobbers the first's session and both sides see "Invalid session token | |
| 17 | * signature" until someone clears cookies by hand. | |
| 18 | * | |
| 19 | * Two populations qualify, for the same reason but from different causes: | |
| 20 | * | |
| 21 | * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. | |
| 22 | * - **Desktop**, which scans upward from 3773 for a free port and binds | |
| 23 | * 127.0.0.1, so a second instance lands on a different port and the same host. | |
| 24 | * | |
| 25 | * Hosted deployments keep the stable production name: their public port can | |
| 26 | * change between releases, and scoping it would log every user out. | |
| 27 | */ | |
| 13 | 28 | export function resolveSessionCookieName(input: { |
| 14 | 29 | readonly mode: "web" | "desktop"; |
| 15 | 30 | readonly port: number; |
| 31 | readonly devUrl: URL | undefined; | |
| 16 | 32 | }): string { |
| 17 | if (input.mode !== "desktop") { | |
| 18 | return SESSION_COOKIE_NAME; | |
| 19 | } | |
| 20 | ||
| 21 | return `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 33 | return input.devUrl === undefined && input.mode !== "desktop" | |
| 34 | ? SESSION_COOKIE_NAME | |
| 35 | : `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 22 | 36 | } |
| 23 | 37 | |
| 24 | 38 | export function base64UrlEncode(input: string | Uint8Array): string { |
apps/server/src/bin.test.ts+1| ... | ... | @@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 84 | 84 | ...derivedPaths, |
| 85 | 85 | staticDir: undefined, |
| 86 | 86 | devUrl: undefined, |
| 87 | devAllowedOrigins: [], | |
| 87 | 88 | noBrowser: true, |
| 88 | 89 | startupPresentation: "browser", |
| 89 | 90 | desktopBootstrapToken: undefined, |
apps/server/src/cli/config.test.ts+4| ... | ... | @@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 48 | 48 | otlpMetricsUrl: undefined, |
| 49 | 49 | otlpExportIntervalMs: 10_000, |
| 50 | 50 | otlpServiceName: "t3-server", |
| 51 | devAllowedOrigins: [], | |
| 51 | 52 | } as const; |
| 52 | 53 | |
| 53 | 54 | const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { |
| ... | ... | @@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 95 | 96 | T3CODE_HOST: "0.0.0.0", |
| 96 | 97 | T3CODE_HOME: baseDir, |
| 97 | 98 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 99 | T3CODE_DEV_ALLOWED_ORIGINS: | |
| 100 | "https://host.example.ts.net, https://phone.example.ts.net ", | |
| 98 | 101 | T3CODE_NO_BROWSER: "true", |
| 99 | 102 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 100 | 103 | T3CODE_LOG_WS_EVENTS: "true", |
| ... | ... | @@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 117 | 120 | host: "0.0.0.0", |
| 118 | 121 | staticDir: undefined, |
| 119 | 122 | devUrl: new URL("http://127.0.0.1:5173"), |
| 123 | devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"], | |
| 120 | 124 | noBrowser: true, |
| 121 | 125 | startupPresentation: "browser", |
| 122 | 126 | desktopBootstrapToken: undefined, |
apps/server/src/cli/config.ts+10| ... | ... | @@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({ |
| 106 | 106 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 107 | 107 | t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 108 | 108 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 109 | devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe( | |
| 110 | Config.withDefault(""), | |
| 111 | Config.map((value) => | |
| 112 | value | |
| 113 | .split(",") | |
| 114 | .map((entry) => entry.trim()) | |
| 115 | .filter((entry) => entry.length > 0), | |
| 116 | ), | |
| 117 | ), | |
| 109 | 118 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( |
| 110 | 119 | Config.option, |
| 111 | 120 | Config.map(Option.getOrUndefined), |
| ... | ... | @@ -363,6 +372,7 @@ export const resolveServerConfig = ( |
| 363 | 372 | host, |
| 364 | 373 | staticDir, |
| 365 | 374 | devUrl, |
| 375 | devAllowedOrigins: env.devAllowedOrigins, | |
| 366 | 376 | noBrowser, |
| 367 | 377 | startupPresentation, |
| 368 | 378 | desktopBootstrapToken, |
apps/server/src/config.ts+2| ... | ... | @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< |
| 72 | 72 | readonly baseDir: string; |
| 73 | 73 | readonly staticDir: string | undefined; |
| 74 | 74 | readonly devUrl: URL | undefined; |
| 75 | readonly devAllowedOrigins: ReadonlyArray<string>; | |
| 75 | 76 | readonly noBrowser: boolean; |
| 76 | 77 | readonly startupPresentation: StartupPresentation; |
| 77 | 78 | readonly desktopBootstrapToken: string | undefined; |
| ... | ... | @@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 187 | 188 | desktopBootstrapToken: undefined, |
| 188 | 189 | staticDir: undefined, |
| 189 | 190 | devUrl, |
| 191 | devAllowedOrigins: [], | |
| 190 | 192 | noBrowser: false, |
| 191 | 193 | startupPresentation: "browser", |
| 192 | 194 | }); |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | ... | @@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 43 | 43 | desktopBootstrapToken: undefined, |
| 44 | 44 | staticDir: undefined, |
| 45 | 45 | devUrl: undefined, |
| 46 | devAllowedOrigins: [], | |
| 46 | 47 | noBrowser: false, |
| 47 | 48 | startupPresentation: "browser", |
| 48 | 49 | } satisfies ServerConfig.ServerConfig["Service"]; |
apps/server/src/http.ts+14-1| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | AuthOrchestrationReadScope, |
| 5 | 5 | EnvironmentHttpApi, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; | |
| 7 | 8 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 8 | 9 | import * as Data from "effect/Data"; |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap( |
| 48 | 49 | const devOrigin = config.devUrl?.origin; |
| 49 | 50 | // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be |
| 50 | 51 | // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. |
| 52 | // | |
| 53 | // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second | |
| 54 | // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies | |
| 55 | // through Vite and is same-origin (no preflight at all), so this is a | |
| 56 | // safety net for the desktop renderer and any direct-to-backend caller. | |
| 51 | 57 | return HttpRouter.cors({ |
| 52 | 58 | ...(devOrigin |
| 53 | ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true } | |
| 59 | ? { | |
| 60 | allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], | |
| 61 | credentials: true, | |
| 62 | } | |
| 54 | 63 | : {}), |
| 55 | 64 | allowedMethods: browserApiCorsAllowedMethods, |
| 56 | 65 | allowedHeaders: browserApiCorsAllowedHeaders, |
| ... | ... | @@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add( |
| 216 | 225 | } |
| 217 | 226 | |
| 218 | 227 | const config = yield* ServerConfig.ServerConfig; |
| 228 | if (config.devUrl && isDevProxiedPath(url.value.pathname)) { | |
| 229 | return HttpServerResponse.text("Not Found", { status: 404 }); | |
| 230 | } | |
| 231 | ||
| 219 | 232 | if (config.devUrl && isLoopbackHostname(url.value.hostname)) { |
| 220 | 233 | return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { |
| 221 | 234 | status: 302, |
apps/server/src/server.test.ts+31| ... | ... | @@ -380,6 +380,7 @@ const buildAppUnderTest = (options?: { |
| 380 | 380 | ...derivedPaths, |
| 381 | 381 | staticDir: undefined, |
| 382 | 382 | devUrl, |
| 383 | devAllowedOrigins: [], | |
| 383 | 384 | noBrowser: true, |
| 384 | 385 | startupPresentation: "browser", |
| 385 | 386 | desktopBootstrapToken: defaultDesktopBootstrapToken, |
| ... | ... | @@ -1328,6 +1329,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1328 | 1329 | "bearer-access-token", |
| 1329 | 1330 | "dpop-access-token", |
| 1330 | 1331 | ]); |
| 1332 | // Desktop, so port-scoped: instances scan for a free port and share | |
| 1333 | // 127.0.0.1, and cookies are not scoped by port. | |
| 1331 | 1334 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 1332 | 1335 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1333 | 1336 | ); |
| ... | ... | @@ -3246,6 +3249,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3246 | 3249 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3247 | 3250 | ); |
| 3248 | 3251 | |
| 3252 | it.effect("allows configured development origins through ServerConfig", () => | |
| 3253 | Effect.gen(function* () { | |
| 3254 | const tailnetOrigin = "https://host.example.ts.net"; | |
| 3255 | yield* buildAppUnderTest({ | |
| 3256 | config: { | |
| 3257 | devUrl: new URL(crossOriginClientOrigin), | |
| 3258 | devAllowedOrigins: [tailnetOrigin], | |
| 3259 | }, | |
| 3260 | }); | |
| 3261 | ||
| 3262 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | |
| 3263 | const response = yield* fetchEffect(sessionUrl, { | |
| 3264 | method: "OPTIONS", | |
| 3265 | headers: { | |
| 3266 | origin: tailnetOrigin, | |
| 3267 | "access-control-request-method": "GET", | |
| 3268 | "access-control-request-headers": "content-type", | |
| 3269 | }, | |
| 3270 | }); | |
| 3271 | ||
| 3272 | assert.equal(response.status, 204); | |
| 3273 | assertBrowserApiCorsPreflightHeaders(response.headers, { | |
| 3274 | origin: tailnetOrigin, | |
| 3275 | credentials: true, | |
| 3276 | }); | |
| 3277 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 3278 | ); | |
| 3279 | ||
| 3249 | 3280 | for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) { |
| 3250 | 3281 | it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () => |
| 3251 | 3282 | Effect.gen(function* () { |
apps/web/vite.config.ts+74-25| ... | ... | @@ -7,14 +7,27 @@ import "vite-plus/test/config"; |
| 7 | 7 | import { defineConfig } from "vite-plus"; |
| 8 | 8 | import pkg from "./package.json" with { type: "json" }; |
| 9 | 9 | |
| 10 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; | |
| 11 | ||
| 10 | 12 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 11 | 13 | |
| 12 | 14 | const repoEnv = loadRepoEnv(); |
| 13 | 15 | Object.assign(process.env, repoEnv); |
| 14 | 16 | |
| 17 | // Single-origin dev is signalled positively, because it cannot be inferred | |
| 18 | // from the absence of VITE_HTTP_URL/VITE_WS_URL: the runner deletes those keys | |
| 19 | // but `loadRepoEnv` merges `.env`/`.env.local` *underneath* the process env, so | |
| 20 | // a developer with either URL in their `.env` gets it back here. Baking it then | |
| 21 | // pins the client to localhost and breaks every non-localhost origin — the | |
| 22 | // exact failure single-origin mode exists to prevent, and an invisible one | |
| 23 | // since the page still loads. | |
| 24 | const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; | |
| 25 | ||
| 15 | 26 | const port = Number(process.env.PORT ?? 5733); |
| 16 | const host = process.env.HOST?.trim() || "localhost"; | |
| 17 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | |
| 27 | const explicitHost = process.env.HOST?.trim(); | |
| 28 | const host = explicitHost || "localhost"; | |
| 29 | const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); | |
| 30 | const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); | |
| 18 | 31 | const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; |
| 19 | 32 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; |
| 20 | 33 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; |
| ... | ... | @@ -65,7 +78,20 @@ const unitTestProject = { |
| 65 | 78 | }, |
| 66 | 79 | } satisfies TestProjectInlineConfiguration; |
| 67 | 80 | |
| 68 | function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { | |
| 81 | function resolveDevProxyTarget( | |
| 82 | backendPort: string | undefined, | |
| 83 | wsUrl: string | undefined, | |
| 84 | ): string | undefined { | |
| 85 | // Browser dev is single-origin: the backend port is proxied through this | |
| 86 | // server so the app works from any origin (localhost, tailnet, LAN, phone). | |
| 87 | // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode. | |
| 88 | const port = Number(backendPort?.trim()); | |
| 89 | if (Number.isInteger(port) && port > 0) { | |
| 90 | return `http://localhost:${port}/`; | |
| 91 | } | |
| 92 | ||
| 93 | // dev:desktop still points the renderer straight at the backend, so fall | |
| 94 | // back to deriving the target from the explicit websocket URL. | |
| 69 | 95 | if (!wsUrl) { |
| 70 | 96 | return undefined; |
| 71 | 97 | } |
| ... | ... | @@ -86,7 +112,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { |
| 86 | 112 | } |
| 87 | 113 | } |
| 88 | 114 | |
| 89 | const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); | |
| 115 | const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); | |
| 116 | ||
| 117 | // Vite rejects requests whose Host header isn't localhost, which blocks sharing | |
| 118 | // a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: | |
| 119 | // the DNS is controlled by tailscale, so they can't be rebound by an attacker. | |
| 120 | // Anything else (ngrok, a LAN IP alias) goes through the env var. | |
| 121 | const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") | |
| 122 | .split(",") | |
| 123 | .map((entry) => entry.trim()) | |
| 124 | .filter((entry) => entry.length > 0); | |
| 125 | const allowedHosts = [".ts.net", ...configuredAllowedHosts]; | |
| 90 | 126 | |
| 91 | 127 | export default defineConfig(() => { |
| 92 | 128 | return { |
| ... | ... | @@ -119,6 +155,10 @@ export default defineConfig(() => { |
| 119 | 155 | define: { |
| 120 | 156 | // In dev mode, tell the web app where the WebSocket server lives |
| 121 | 157 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 158 | // Pinned explicitly rather than left to Vite's automatic VITE_ exposure: | |
| 159 | // under single-origin dev this must stay empty even when a `.env` | |
| 160 | // supplies it, so the client falls back to window.location.origin. | |
| 161 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), | |
| 122 | 162 | "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), |
| 123 | 163 | "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), |
| 124 | 164 | "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), |
| ... | ... | @@ -145,32 +185,41 @@ export default defineConfig(() => { |
| 145 | 185 | host, |
| 146 | 186 | port, |
| 147 | 187 | strictPort: true, |
| 188 | allowedHosts, | |
| 148 | 189 | ...(devProxyTarget |
| 149 | 190 | ? { |
| 150 | proxy: { | |
| 151 | "/.well-known": { | |
| 152 | target: devProxyTarget, | |
| 153 | changeOrigin: true, | |
| 154 | }, | |
| 155 | "/api": { | |
| 156 | target: devProxyTarget, | |
| 157 | changeOrigin: true, | |
| 158 | }, | |
| 159 | "/attachments": { | |
| 160 | target: devProxyTarget, | |
| 161 | changeOrigin: true, | |
| 162 | }, | |
| 191 | // One entry per shared prefix; the server's dev catch-all 404s the | |
| 192 | // same list, so the two sides cannot drift. `/ws` is the app's own | |
| 193 | // socket — Vite's HMR socket is matched separately and exactly | |
| 194 | // (path "/" plus a vite-hmr subprotocol), so the two upgrade | |
| 195 | // handlers don't collide. | |
| 196 | proxy: Object.fromEntries( | |
| 197 | DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ | |
| 198 | prefix, | |
| 199 | { | |
| 200 | target: devProxyTarget, | |
| 201 | changeOrigin: true, | |
| 202 | ...(prefix === "/ws" ? { ws: true } : {}), | |
| 203 | }, | |
| 204 | ]), | |
| 205 | ), | |
| 206 | } | |
| 207 | : {}), | |
| 208 | // Electron's BrowserWindow needs the HMR socket pinned to an explicit | |
| 209 | // host to connect reliably; dev:desktop is the only mode that sets HOST. | |
| 210 | // Everywhere else, leaving this unset lets the client derive it from the | |
| 211 | // page origin, which is what makes HMR work over Tailscale/LAN instead of | |
| 212 | // failing an attempt against the wrong machine's localhost first. | |
| 213 | // (Vite 8 logs connection state via console.debug — enable "Verbose".) | |
| 214 | ...(explicitHost | |
| 215 | ? { | |
| 216 | hmr: { | |
| 217 | protocol: "ws", | |
| 218 | host: explicitHost, | |
| 219 | clientPort: port, | |
| 163 | 220 | }, |
| 164 | 221 | } |
| 165 | 222 | : {}), |
| 166 | hmr: { | |
| 167 | // Explicit config so Vite's HMR WebSocket connects reliably | |
| 168 | // inside Electron's BrowserWindow. Vite 8 uses console.debug for | |
| 169 | // connection logs — enable "Verbose" in DevTools to see them. | |
| 170 | protocol: "ws", | |
| 171 | host, | |
| 172 | clientPort: port, | |
| 173 | }, | |
| 174 | 223 | }, |
| 175 | 224 | build: { |
| 176 | 225 | outDir: "dist", |
docs/reference/scripts.md+8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | # Scripts |
| 2 | 2 | |
| 3 | 3 | - `vp run dev` — Starts contracts, server, and web in watch mode. |
| 4 | - `vp run dev --share` — Also publishes the web port over HTTPS on this machine's tailnet. The startup pairing URL is built against the shared origin, and the mapping is removed on exit. | |
| 4 | 5 | - `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 | 6 | - `vp run dev:web` — Starts just the Vite dev server for the web app. |
| 6 | 7 | - 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. |
| ... | ... | @@ -24,6 +25,7 @@ |
| 24 | 25 | - Default build is unsigned/not notarized for local sharing. |
| 25 | 26 | - The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. |
| 26 | 27 | - Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). |
| 28 | ||
| 27 | 29 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. |
| 28 | 30 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. |
| 29 | 31 | - To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage` |
| ... | ... | @@ -37,6 +39,12 @@ |
| 37 | 39 | - Azure authentication env vars are also required (for example service principal with secret): |
| 38 | 40 | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. |
| 39 | 41 | |
| 42 | ## Browser development | |
| 43 | ||
| 44 | `dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the server, allowing the same bundle to work from localhost or a tailnet hostname. | |
| 45 | ||
| 46 | Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied, so treat the `[dev-runner]` output as authoritative. | |
| 47 | ||
| 40 | 48 | ## Running multiple dev instances |
| 41 | 49 | |
| 42 | 50 | Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. |
package.json+1| ... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 | "scripts": { |
| 6 | 6 | "prepare": "effect-tsgo patch && vp config --no-agent", |
| 7 | 7 | "dev": "node scripts/dev-runner.ts dev", |
| 8 | "dev:share": "node scripts/dev-runner.ts dev --share", | |
| 8 | 9 | "dev:server": "node scripts/dev-runner.ts dev:server", |
| 9 | 10 | "dev:web": "node scripts/dev-runner.ts dev:web", |
| 10 | 11 | "dev:marketing": "vp run --filter @t3tools/marketing dev", |
packages/shared/package.json+4| ... | ... | @@ -198,6 +198,10 @@ |
| 198 | 198 | "./devHome": { |
| 199 | 199 | "types": "./src/devHome.ts", |
| 200 | 200 | "import": "./src/devHome.ts" |
| 201 | }, | |
| 202 | "./devProxy": { | |
| 203 | "types": "./src/devProxy.ts", | |
| 204 | "import": "./src/devProxy.ts" | |
| 201 | 205 | } |
| 202 | 206 | }, |
| 203 | 207 | "scripts": { |
packages/shared/src/devProxy.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | /** | |
| 2 | * Backend paths the web dev server proxies in single-origin browser dev. | |
| 3 | * | |
| 4 | * Two consumers must agree on this list: the Vite proxy map | |
| 5 | * (apps/web/vite.config.ts) that forwards these to the backend, and the | |
| 6 | * server's dev catch-all (apps/server/src/http.ts) that 404s them instead of | |
| 7 | * redirecting back to Vite. Drift is silent and nasty in both directions — a | |
| 8 | * prefix only Vite knows gets answered with index.html; a prefix only the | |
| 9 | * server knows redirect-loops through the proxy. | |
| 10 | */ | |
| 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; | |
| 12 | ||
| 13 | export function isDevProxiedPath(pathname: string): boolean { | |
| 14 | return DEV_PROXIED_PATH_PREFIXES.some( | |
| 15 | (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), | |
| 16 | ); | |
| 17 | } |
packages/tailscale/src/tailscale.test.ts+62| ... | ... | @@ -25,6 +25,44 @@ import { |
| 25 | 25 | } from "./tailscale.ts"; |
| 26 | 26 | |
| 27 | 27 | const encoder = new TextEncoder(); |
| 28 | ||
| 29 | /** | |
| 30 | * Asserts nothing reachable from `error` contains `secret`. Recurses through | |
| 31 | * nested objects, arrays, and `cause` chains rather than checking only | |
| 32 | * top-level strings: a leak one level down (say, a wrapped cause carrying raw | |
| 33 | * stderr) is just as visible in a log, and a shallow check would pass it. | |
| 34 | * | |
| 35 | * Walks values instead of serializing so it holds for fields added later, and | |
| 36 | * tracks visited objects so a cyclic cause chain terminates. | |
| 37 | */ | |
| 38 | function assertCarriesNoSecret(error: object, secret: string): void { | |
| 39 | const seen = new WeakSet<object>(); | |
| 40 | ||
| 41 | const walk = (value: unknown, path: string): void => { | |
| 42 | if (typeof value === "string") { | |
| 43 | assert.notInclude(value, secret, `${path} leaked stderr`); | |
| 44 | return; | |
| 45 | } | |
| 46 | if (typeof value !== "object" || value === null || seen.has(value)) { | |
| 47 | return; | |
| 48 | } | |
| 49 | seen.add(value); | |
| 50 | ||
| 51 | if (Array.isArray(value)) { | |
| 52 | value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`)); | |
| 53 | return; | |
| 54 | } | |
| 55 | // `message` and `cause` are getters on Error subclasses, so they are not | |
| 56 | // own enumerable properties and Object.entries alone would skip them. | |
| 57 | walk((value as { message?: unknown }).message, `${path}.message`); | |
| 58 | walk((value as { cause?: unknown }).cause, `${path}.cause`); | |
| 59 | for (const [key, nested] of Object.entries(value)) { | |
| 60 | walk(nested, `${path}.${key}`); | |
| 61 | } | |
| 62 | }; | |
| 63 | ||
| 64 | walk(error, "error"); | |
| 65 | } | |
| 28 | 66 | const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; |
| 29 | 67 | const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; |
| 30 | 68 | |
| ... | ... | @@ -194,6 +232,26 @@ describe("tailscale", () => { |
| 194 | 232 | assert.notProperty(error, "stderr"); |
| 195 | 233 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); |
| 196 | 234 | assert.equal(error.message, "tailscale status exited with code 7."); |
| 235 | assert.equal(error.stderrDiagnostic, "not-logged-in"); | |
| 236 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); | |
| 237 | }); | |
| 238 | }); | |
| 239 | ||
| 240 | it.effect("classifies unrecognized stderr without quoting it", () => { | |
| 241 | const layer = mockSpawnerLayer(() => ({ | |
| 242 | code: 3, | |
| 243 | stderr: "something novel went wrong for node fluffy-badger tskey-auth-secret-token-value", | |
| 244 | })); | |
| 245 | ||
| 246 | return Effect.gen(function* () { | |
| 247 | const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); | |
| 248 | ||
| 249 | assert.instanceOf(error, TailscaleCommandExitError); | |
| 250 | // Unmatched stderr degrades to "unknown" rather than passing text | |
| 251 | // through — that fallback is what keeps novel output from leaking. | |
| 252 | assert.equal(error.stderrDiagnostic, "unknown"); | |
| 253 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); | |
| 254 | assertCarriesNoSecret(error, "fluffy-badger"); | |
| 197 | 255 | }); |
| 198 | 256 | }); |
| 199 | 257 | |
| ... | ... | @@ -253,6 +311,10 @@ describe("tailscale", () => { |
| 253 | 311 | assert.notProperty(error, "command"); |
| 254 | 312 | assert.notProperty(error, "stderr"); |
| 255 | 313 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); |
| 314 | // The diagnostic classifies the failure without quoting stderr, so the | |
| 315 | // key cannot reach a log through it either. | |
| 316 | assert.equal(error.stderrDiagnostic, "permission-denied"); | |
| 317 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); | |
| 256 | 318 | }); |
| 257 | 319 | }); |
| 258 | 320 |
packages/tailscale/src/tailscale.ts+43| ... | ... | @@ -23,6 +23,37 @@ const TailscaleCommandContext = { |
| 23 | 23 | argumentCount: Schema.Number, |
| 24 | 24 | }; |
| 25 | 25 | |
| 26 | /** | |
| 27 | * Failure kinds we can name without quoting the CLI. Anything unrecognized | |
| 28 | * becomes "unknown" rather than falling back to raw text — stderr can contain | |
| 29 | * auth keys (`tskey-…`) and node names, and these labels are logged. | |
| 30 | */ | |
| 31 | export const TailscaleStderrDiagnostic = Schema.Literals([ | |
| 32 | "no-existing-handler", | |
| 33 | "not-logged-in", | |
| 34 | "permission-denied", | |
| 35 | "unknown", | |
| 36 | ]); | |
| 37 | export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type; | |
| 38 | ||
| 39 | // Matched against stderr, most specific first. Patterns are deliberately short | |
| 40 | // and anchored on tailscale's own wording. | |
| 41 | const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< | |
| 42 | readonly [RegExp, Exclude<TailscaleStderrDiagnostic, "unknown">] | |
| 43 | > = [ | |
| 44 | [/handler does not exist/i, "no-existing-handler"], | |
| 45 | [/not logged in|logged out|needs? login/i, "not-logged-in"], | |
| 46 | [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"], | |
| 47 | ]; | |
| 48 | ||
| 49 | /** Classifies stderr into a safe label, dropping the text itself. */ | |
| 50 | export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { | |
| 51 | if (stderr.trim().length === 0) { | |
| 52 | return undefined; | |
| 53 | } | |
| 54 | return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown"; | |
| 55 | }; | |
| 56 | ||
| 26 | 57 | export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()( |
| 27 | 58 | "TailscaleCommandSpawnError", |
| 28 | 59 | { |
| ... | ... | @@ -54,6 +85,12 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass<Tailscale |
| 54 | 85 | exitCode: Schema.Number, |
| 55 | 86 | stdoutLength: Schema.optional(Schema.Number), |
| 56 | 87 | stderrLength: Schema.Number, |
| 88 | // A classified diagnostic, never raw CLI output. `tailscale` prints auth | |
| 89 | // keys and node identifiers into stderr, and this field is surfaced in | |
| 90 | // dev-runner logs — so it carries only a known-safe label from the closed | |
| 91 | // set below. Callers that need to recognize a specific failure (e.g. | |
| 92 | // `serve off` on a port with no mapping) match on the label. | |
| 93 | stderrDiagnostic: Schema.optional(TailscaleStderrDiagnostic), | |
| 57 | 94 | }, |
| 58 | 95 | ) { |
| 59 | 96 | override get message(): string { |
| ... | ... | @@ -212,6 +249,9 @@ export const readTailscaleStatus = Effect.gen(function* () { |
| 212 | 249 | exitCode, |
| 213 | 250 | stdoutLength: stdout.length, |
| 214 | 251 | stderrLength: stderr.length, |
| 252 | ...(stderrDiagnosticOf(stderr) !== undefined | |
| 253 | ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } | |
| 254 | : {}), | |
| 215 | 255 | }); |
| 216 | 256 | } |
| 217 | 257 | return yield* parseTailscaleStatus(stdout); |
| ... | ... | @@ -275,6 +315,9 @@ const runTailscaleCommand = ( |
| 275 | 315 | ...commandContext, |
| 276 | 316 | exitCode, |
| 277 | 317 | stderrLength: stderr.length, |
| 318 | ...(stderrDiagnosticOf(stderr) !== undefined | |
| 319 | ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } | |
| 320 | : {}), | |
| 278 | 321 | }); |
| 279 | 322 | } |
| 280 | 323 | }).pipe( |
pnpm-lock.yaml+3| ... | ... | @@ -905,6 +905,9 @@ importers: |
| 905 | 905 | '@t3tools/shared': |
| 906 | 906 | specifier: workspace:* |
| 907 | 907 | version: link:../packages/shared |
| 908 | '@t3tools/tailscale': | |
| 909 | specifier: workspace:* | |
| 910 | version: link:../packages/tailscale | |
| 908 | 911 | effect: |
| 909 | 912 | specifier: 4.0.0-beta.78 |
| 910 | 913 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
scripts/dev-runner.test.ts+387-1| ... | ... | @@ -22,6 +22,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | 22 | import { |
| 23 | 23 | checkPortAvailabilityOnHosts, |
| 24 | 24 | createDevRunnerEnv, |
| 25 | devPortProbeHosts, | |
| 25 | 26 | findFirstAvailableOffset, |
| 26 | 27 | getDevRunnerModeArgs, |
| 27 | 28 | resolveModePortOffsets, |
| ... | ... | @@ -66,6 +67,7 @@ const devServerInput = { |
| 66 | 67 | port: 13_773, |
| 67 | 68 | devUrl: undefined, |
| 68 | 69 | dryRun: false, |
| 70 | share: false, | |
| 69 | 71 | runArgs: ["--inspect", "secret-token-value"], |
| 70 | 72 | } as const; |
| 71 | 73 | |
| ... | ... | @@ -344,8 +346,175 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 344 | 346 | }); |
| 345 | 347 | |
| 346 | 348 | assert.equal(env.T3CODE_PORT, "13773"); |
| 349 | assert.equal(env.PORT, "5733"); | |
| 350 | }), | |
| 351 | ); | |
| 352 | ||
| 353 | // Browser dev is single-origin: Vite proxies the backend, and the client | |
| 354 | // resolves it from window.location.origin. Baking a localhost URL here is | |
| 355 | // what breaks sharing a dev server to another device. | |
| 356 | for (const mode of ["dev", "dev:web"] as const) { | |
| 357 | it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => | |
| 358 | Effect.gen(function* () { | |
| 359 | const env = yield* createDevRunnerEnv({ | |
| 360 | mode, | |
| 361 | baseEnv: { | |
| 362 | VITE_HTTP_URL: "http://localhost:1234", | |
| 363 | VITE_WS_URL: "ws://localhost:1234", | |
| 364 | }, | |
| 365 | serverOffset: 0, | |
| 366 | webOffset: 0, | |
| 367 | t3Home: undefined, | |
| 368 | browser: undefined, | |
| 369 | autoBootstrapProjectFromCwd: undefined, | |
| 370 | logWebSocketEvents: undefined, | |
| 371 | host: undefined, | |
| 372 | port: undefined, | |
| 373 | devUrl: undefined, | |
| 374 | }); | |
| 375 | ||
| 376 | assert.equal(env.VITE_HTTP_URL, undefined); | |
| 377 | assert.equal(env.VITE_WS_URL, undefined); | |
| 378 | assert.equal(env.T3CODE_PORT, "13773"); | |
| 379 | // Deleting the keys is not sufficient — vite.config.ts merges | |
| 380 | // `.env`/`.env.local` underneath this env and would revive them, so | |
| 381 | // the intent has to be stated positively. | |
| 382 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); | |
| 383 | }), | |
| 384 | ); | |
| 385 | } | |
| 386 | ||
| 387 | // Desktop pins the renderer at loopback deliberately; an ambient marker | |
| 388 | // must not make Vite discard those URLs. | |
| 389 | it.effect("clears the single-origin marker in dev:desktop mode", () => | |
| 390 | Effect.gen(function* () { | |
| 391 | const env = yield* createDevRunnerEnv({ | |
| 392 | mode: "dev:desktop", | |
| 393 | baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, | |
| 394 | serverOffset: 0, | |
| 395 | webOffset: 0, | |
| 396 | t3Home: undefined, | |
| 397 | browser: undefined, | |
| 398 | autoBootstrapProjectFromCwd: undefined, | |
| 399 | logWebSocketEvents: undefined, | |
| 400 | host: undefined, | |
| 401 | port: undefined, | |
| 402 | devUrl: undefined, | |
| 403 | }); | |
| 404 | ||
| 405 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); | |
| 406 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); | |
| 407 | }), | |
| 408 | ); | |
| 409 | ||
| 410 | it.effect("clears the single-origin marker in dev:server mode", () => | |
| 411 | Effect.gen(function* () { | |
| 412 | const env = yield* createDevRunnerEnv({ | |
| 413 | mode: "dev:server", | |
| 414 | baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, | |
| 415 | serverOffset: 0, | |
| 416 | webOffset: 0, | |
| 417 | t3Home: undefined, | |
| 418 | browser: undefined, | |
| 419 | autoBootstrapProjectFromCwd: undefined, | |
| 420 | logWebSocketEvents: undefined, | |
| 421 | host: undefined, | |
| 422 | port: undefined, | |
| 423 | devUrl: undefined, | |
| 424 | }); | |
| 425 | ||
| 426 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); | |
| 347 | 427 | assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); |
| 348 | assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); | |
| 428 | }), | |
| 429 | ); | |
| 430 | ||
| 431 | // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An | |
| 432 | // inherited one would survive into browser dev and point HMR at the wrong | |
| 433 | // interface — invisible over a shared origin, since the page still loads. | |
| 434 | for (const mode of ["dev", "dev:web"] as const) { | |
| 435 | it.effect(`drops an inherited HOST in ${mode} mode`, () => | |
| 436 | Effect.gen(function* () { | |
| 437 | const env = yield* createDevRunnerEnv({ | |
| 438 | mode, | |
| 439 | baseEnv: { HOST: "0.0.0.0" }, | |
| 440 | serverOffset: 0, | |
| 441 | webOffset: 0, | |
| 442 | t3Home: undefined, | |
| 443 | browser: undefined, | |
| 444 | autoBootstrapProjectFromCwd: undefined, | |
| 445 | logWebSocketEvents: undefined, | |
| 446 | host: undefined, | |
| 447 | port: undefined, | |
| 448 | devUrl: undefined, | |
| 449 | }); | |
| 450 | ||
| 451 | assert.equal(env.HOST, undefined); | |
| 452 | }), | |
| 453 | ); | |
| 454 | } | |
| 455 | ||
| 456 | // --host configures the *backend* (T3CODE_HOST). It must not become Vite's | |
| 457 | // bind address by way of an inherited HOST that happens to agree with it. | |
| 458 | it.effect("drops an inherited HOST even when --host is given", () => | |
| 459 | Effect.gen(function* () { | |
| 460 | const env = yield* createDevRunnerEnv({ | |
| 461 | mode: "dev", | |
| 462 | baseEnv: { HOST: "0.0.0.0" }, | |
| 463 | serverOffset: 0, | |
| 464 | webOffset: 0, | |
| 465 | t3Home: undefined, | |
| 466 | browser: undefined, | |
| 467 | autoBootstrapProjectFromCwd: undefined, | |
| 468 | logWebSocketEvents: undefined, | |
| 469 | host: "0.0.0.0", | |
| 470 | port: undefined, | |
| 471 | devUrl: undefined, | |
| 472 | }); | |
| 473 | ||
| 474 | assert.equal(env.HOST, undefined); | |
| 475 | assert.equal(env.T3CODE_HOST, "0.0.0.0"); | |
| 476 | }), | |
| 477 | ); | |
| 478 | ||
| 479 | // Desktop sets HOST itself, so the clearing must not reach it. | |
| 480 | it.effect("still pins HOST for dev:desktop", () => | |
| 481 | Effect.gen(function* () { | |
| 482 | const env = yield* createDevRunnerEnv({ | |
| 483 | mode: "dev:desktop", | |
| 484 | baseEnv: { HOST: "0.0.0.0" }, | |
| 485 | serverOffset: 0, | |
| 486 | webOffset: 0, | |
| 487 | t3Home: undefined, | |
| 488 | browser: undefined, | |
| 489 | autoBootstrapProjectFromCwd: undefined, | |
| 490 | logWebSocketEvents: undefined, | |
| 491 | host: undefined, | |
| 492 | port: undefined, | |
| 493 | devUrl: undefined, | |
| 494 | }); | |
| 495 | ||
| 496 | assert.equal(env.HOST, "127.0.0.1"); | |
| 497 | }), | |
| 498 | ); | |
| 499 | ||
| 500 | it.effect("keeps explicit backend URLs for the desktop renderer", () => | |
| 501 | Effect.gen(function* () { | |
| 502 | const env = yield* createDevRunnerEnv({ | |
| 503 | mode: "dev:desktop", | |
| 504 | baseEnv: {}, | |
| 505 | serverOffset: 0, | |
| 506 | webOffset: 0, | |
| 507 | t3Home: undefined, | |
| 508 | browser: undefined, | |
| 509 | autoBootstrapProjectFromCwd: undefined, | |
| 510 | logWebSocketEvents: undefined, | |
| 511 | host: undefined, | |
| 512 | port: undefined, | |
| 513 | devUrl: undefined, | |
| 514 | }); | |
| 515 | ||
| 516 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); | |
| 517 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:13773"); | |
| 349 | 518 | }), |
| 350 | 519 | ); |
| 351 | 520 | }); |
| ... | ... | @@ -444,6 +613,59 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 444 | 613 | ); |
| 445 | 614 | }); |
| 446 | 615 | |
| 616 | describe("devPortProbeHosts", () => { | |
| 617 | it.effect("probes loopback only when no bind host is configured", () => | |
| 618 | Effect.sync(() => { | |
| 619 | assert.deepStrictEqual(devPortProbeHosts(undefined), ["127.0.0.1", "::1"]); | |
| 620 | assert.deepStrictEqual(devPortProbeHosts(" "), ["127.0.0.1", "::1"]); | |
| 621 | }), | |
| 622 | ); | |
| 623 | ||
| 624 | // A port free on loopback can be taken on the interface the server will | |
| 625 | // actually bind, so --host/T3CODE_HOST has to be probed as well. | |
| 626 | it.effect("adds a non-loopback bind host to the probe list", () => | |
| 627 | Effect.sync(() => { | |
| 628 | assert.deepStrictEqual(devPortProbeHosts("0.0.0.0"), ["127.0.0.1", "::1", "0.0.0.0"]); | |
| 629 | assert.deepStrictEqual(devPortProbeHosts("192.168.1.10"), [ | |
| 630 | "127.0.0.1", | |
| 631 | "::1", | |
| 632 | "192.168.1.10", | |
| 633 | ]); | |
| 634 | }), | |
| 635 | ); | |
| 636 | ||
| 637 | it.effect("does not probe loopback twice when it is the configured host", () => | |
| 638 | Effect.sync(() => { | |
| 639 | assert.deepStrictEqual(devPortProbeHosts("127.0.0.1"), ["127.0.0.1", "::1"]); | |
| 640 | }), | |
| 641 | ); | |
| 642 | ||
| 643 | // Only the backend honours --host/T3CODE_HOST. Vite reads HOST (set for | |
| 644 | // desktop only), so judging the web port against the backend's interface | |
| 645 | // would reject ports for a server that never binds there. | |
| 646 | it.effect("passes the port role so only the server port sees the bind host", () => | |
| 647 | Effect.gen(function* () { | |
| 648 | const probed: Array<{ port: number; role: string | undefined }> = []; | |
| 649 | ||
| 650 | yield* resolveModePortOffsets({ | |
| 651 | mode: "dev", | |
| 652 | startOffset: 0, | |
| 653 | hasExplicitServerPort: false, | |
| 654 | hasExplicitDevUrl: false, | |
| 655 | checkPortAvailability: (port, role) => { | |
| 656 | probed.push({ port, role }); | |
| 657 | return Effect.succeed(true); | |
| 658 | }, | |
| 659 | }); | |
| 660 | ||
| 661 | assert.deepStrictEqual(probed, [ | |
| 662 | { port: 13_773, role: "server" }, | |
| 663 | { port: 5733, role: "web" }, | |
| 664 | ]); | |
| 665 | }), | |
| 666 | ); | |
| 667 | }); | |
| 668 | ||
| 447 | 669 | describe("resolveModePortOffsets", () => { |
| 448 | 670 | it.effect("uses a shared fallback offset for dev mode", () => |
| 449 | 671 | Effect.gen(function* () { |
| ... | ... | @@ -577,6 +799,170 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 577 | 799 | }); |
| 578 | 800 | }); |
| 579 | 801 | |
| 802 | // `tailscale serve` config outlives the process, so a dry run that shared | |
| 803 | // would replace and then tear down whatever mapping the port already had. | |
| 804 | // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) | |
| 805 | // lives in runDevRunnerWithInput; the env builder must not consult the | |
| 806 | // ambient variable on its own, or it would silently outrank the worktree | |
| 807 | // default and land dev state on the user's real database. | |
| 808 | it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => | |
| 809 | Effect.gen(function* () { | |
| 810 | const env = yield* createDevRunnerEnv({ | |
| 811 | mode: "dev", | |
| 812 | baseEnv: { T3CODE_HOME: "/home/user/.t3" }, | |
| 813 | serverOffset: 0, | |
| 814 | webOffset: 0, | |
| 815 | t3Home: undefined, | |
| 816 | browser: undefined, | |
| 817 | autoBootstrapProjectFromCwd: undefined, | |
| 818 | logWebSocketEvents: undefined, | |
| 819 | host: undefined, | |
| 820 | port: undefined, | |
| 821 | devUrl: undefined, | |
| 822 | }); | |
| 823 | ||
| 824 | assert.equal(env.T3CODE_HOME, undefined); | |
| 825 | }), | |
| 826 | ); | |
| 827 | ||
| 828 | // Sharing dev:desktop would publish a URL whose renderer dials the | |
| 829 | // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that | |
| 830 | // Electron loads from. It must decline, not half-work. | |
| 831 | it.effect("declines to share for dev:desktop and still starts the stack", () => { | |
| 832 | let spawnCount = 0; | |
| 833 | const spawnerLayer = Layer.succeed( | |
| 834 | ChildProcessSpawner.ChildProcessSpawner, | |
| 835 | ChildProcessSpawner.make(() => { | |
| 836 | spawnCount += 1; | |
| 837 | return Effect.succeed(mockProcess(0)); | |
| 838 | }), | |
| 839 | ); | |
| 840 | ||
| 841 | return Effect.gen(function* () { | |
| 842 | yield* runDevRunnerWithInput({ | |
| 843 | ...devServerInput, | |
| 844 | mode: "dev:desktop", | |
| 845 | port: undefined, | |
| 846 | share: true, | |
| 847 | }).pipe( | |
| 848 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | |
| 849 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 850 | ); | |
| 851 | ||
| 852 | assert.equal(spawnCount, 1); | |
| 853 | }); | |
| 854 | }); | |
| 855 | ||
| 856 | // Single-origin browser dev proxies the backend at localhost, so a backend | |
| 857 | // bound only to a specific interface breaks every proxied request in a way | |
| 858 | // that looks like a broken server. Reject the combination up front. | |
| 859 | it.effect("rejects a specific non-loopback --host for browser dev modes", () => { | |
| 860 | const spawnerLayer = Layer.succeed( | |
| 861 | ChildProcessSpawner.ChildProcessSpawner, | |
| 862 | ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))), | |
| 863 | ); | |
| 864 | ||
| 865 | return Effect.gen(function* () { | |
| 866 | const error = yield* runDevRunnerWithInput({ | |
| 867 | ...devServerInput, | |
| 868 | mode: "dev", | |
| 869 | port: undefined, | |
| 870 | host: "192.168.1.10", | |
| 871 | }).pipe( | |
| 872 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | |
| 873 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 874 | Effect.flip, | |
| 875 | ); | |
| 876 | ||
| 877 | if (error._tag !== "DevRunnerHostNotProxiableError") { | |
| 878 | assert.fail(`Unexpected error: ${error._tag}`); | |
| 879 | } | |
| 880 | assert.equal(error.mode, "dev"); | |
| 881 | assert.equal(error.host, "192.168.1.10"); | |
| 882 | assert.include(error.message, "0.0.0.0"); | |
| 883 | assert.include(error.message, "--share"); | |
| 884 | }); | |
| 885 | }); | |
| 886 | ||
| 887 | // Wildcards keep loopback answering, so the proxy target stays valid and | |
| 888 | // the combination must keep working — it is the documented way to serve a | |
| 889 | // LAN interface and the browser proxy at once. | |
| 890 | it.effect("still spawns the stack for a wildcard --host in dev mode", () => { | |
| 891 | let spawnCount = 0; | |
| 892 | const spawnerLayer = Layer.succeed( | |
| 893 | ChildProcessSpawner.ChildProcessSpawner, | |
| 894 | ChildProcessSpawner.make(() => { | |
| 895 | spawnCount += 1; | |
| 896 | return Effect.succeed(mockProcess(0)); | |
| 897 | }), | |
| 898 | ); | |
| 899 | ||
| 900 | return Effect.gen(function* () { | |
| 901 | yield* runDevRunnerWithInput({ | |
| 902 | ...devServerInput, | |
| 903 | mode: "dev", | |
| 904 | port: undefined, | |
| 905 | host: "0.0.0.0", | |
| 906 | }).pipe( | |
| 907 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | |
| 908 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 909 | ); | |
| 910 | ||
| 911 | assert.equal(spawnCount, 1); | |
| 912 | }); | |
| 913 | }); | |
| 914 | ||
| 915 | // dev:server does not proxy — the client talks to the backend directly — | |
| 916 | // so a specific interface bind stays legitimate there. | |
| 917 | it.effect("keeps a specific --host working for dev:server", () => { | |
| 918 | let spawnCount = 0; | |
| 919 | const spawnerLayer = Layer.succeed( | |
| 920 | ChildProcessSpawner.ChildProcessSpawner, | |
| 921 | ChildProcessSpawner.make(() => { | |
| 922 | spawnCount += 1; | |
| 923 | return Effect.succeed(mockProcess(0)); | |
| 924 | }), | |
| 925 | ); | |
| 926 | ||
| 927 | return Effect.gen(function* () { | |
| 928 | yield* runDevRunnerWithInput({ | |
| 929 | ...devServerInput, | |
| 930 | host: "192.168.1.10", | |
| 931 | }).pipe( | |
| 932 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | |
| 933 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 934 | ); | |
| 935 | ||
| 936 | assert.equal(spawnCount, 1); | |
| 937 | }); | |
| 938 | }); | |
| 939 | ||
| 940 | it.effect("spawns nothing when --dry-run is combined with --share", () => { | |
| 941 | let spawnCount = 0; | |
| 942 | const spawnerLayer = Layer.succeed( | |
| 943 | ChildProcessSpawner.ChildProcessSpawner, | |
| 944 | ChildProcessSpawner.make(() => { | |
| 945 | spawnCount += 1; | |
| 946 | return Effect.succeed(mockProcess(0)); | |
| 947 | }), | |
| 948 | ); | |
| 949 | ||
| 950 | return Effect.gen(function* () { | |
| 951 | yield* runDevRunnerWithInput({ | |
| 952 | ...devServerInput, | |
| 953 | mode: "dev", | |
| 954 | port: undefined, | |
| 955 | dryRun: true, | |
| 956 | share: true, | |
| 957 | }).pipe( | |
| 958 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), | |
| 959 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 960 | ); | |
| 961 | ||
| 962 | assert.equal(spawnCount, 0); | |
| 963 | }); | |
| 964 | }); | |
| 965 | ||
| 580 | 966 | it.effect("reports non-zero exits without manufacturing a cause", () => { |
| 581 | 967 | const spawnerLayer = Layer.succeed( |
| 582 | 968 | ChildProcessSpawner.ChildProcessSpawner, |
scripts/dev-runner.ts+255-30| ... | ... | @@ -5,7 +5,7 @@ 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 { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; | |
| 8 | import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; | |
| 9 | 9 | import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; |
| 10 | 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 11 | 11 | import * as Config from "effect/Config"; |
| ... | ... | @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; |
| 19 | 19 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 20 | 20 | import { ChildProcess } from "effect/unstable/process"; |
| 21 | 21 | |
| 22 | import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts"; | |
| 22 | 23 | import { loadRepoEnv } from "./lib/public-config.ts"; |
| 23 | 24 | |
| 24 | 25 | Object.assign(process.env, loadRepoEnv()); |
| ... | ... | @@ -28,7 +29,32 @@ const BASE_WEB_PORT = 5733; |
| 28 | 29 | const MAX_HASH_OFFSET = 3000; |
| 29 | 30 | const MAX_PORT = 65535; |
| 30 | 31 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; |
| 31 | const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; | |
| 32 | // Dev servers bind loopback, so loopback is the only interface whose | |
| 33 | // availability decides whether we can use a port. Probing wildcards too made | |
| 34 | // the runner walk away from a perfectly free port whenever something else held | |
| 35 | // the same number on another interface — `tailscale serve` does exactly that, | |
| 36 | // which silently moved the ports out from under a URL that had just been shared. | |
| 37 | const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; | |
| 38 | ||
| 39 | /** | |
| 40 | * Bind hosts on which a backend still answers `http://localhost:<port>`, which | |
| 41 | * is where single-origin browser dev proxies to. Loopback and the wildcards | |
| 42 | * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only | |
| 43 | * that address and the proxy target goes dark. | |
| 44 | */ | |
| 45 | export function isProxiableBindHost(host: string): boolean { | |
| 46 | const normalized = host.trim(); | |
| 47 | return ( | |
| 48 | normalized === "" || | |
| 49 | normalized === "localhost" || | |
| 50 | normalized === "127.0.0.1" || | |
| 51 | normalized === "::1" || | |
| 52 | normalized === "[::1]" || | |
| 53 | normalized === "0.0.0.0" || | |
| 54 | normalized === "::" || | |
| 55 | normalized === "[::]" | |
| 56 | ); | |
| 57 | } | |
| 32 | 58 | |
| 33 | 59 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 34 | 60 | path.join(NodeOS.homedir(), ".t3"), |
| ... | ... | @@ -49,7 +75,15 @@ const MODE_ARGS = { |
| 49 | 75 | } as const satisfies Record<string, ReadonlyArray<string>>; |
| 50 | 76 | |
| 51 | 77 | type DevMode = keyof typeof MODE_ARGS; |
| 52 | type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>; | |
| 78 | /** | |
| 79 | * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the | |
| 80 | * web port is always loopback. Passed explicitly rather than inferred from the | |
| 81 | * port number, which stops distinguishing them under a large port offset. | |
| 82 | */ | |
| 83 | type PortAvailabilityCheck<R = never> = ( | |
| 84 | port: number, | |
| 85 | role?: "server" | "web", | |
| 86 | ) => Effect.Effect<boolean, never, R>; | |
| 53 | 87 | |
| 54 | 88 | const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>; |
| 55 | 89 | |
| ... | ... | @@ -129,8 +163,21 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass<DevRunner |
| 129 | 163 | } |
| 130 | 164 | } |
| 131 | 165 | |
| 166 | export class DevRunnerHostNotProxiableError extends Schema.TaggedErrorClass<DevRunnerHostNotProxiableError>()( | |
| 167 | "DevRunnerHostNotProxiableError", | |
| 168 | { | |
| 169 | mode: Schema.Literals(["dev", "dev:web"]), | |
| 170 | host: Schema.String, | |
| 171 | }, | |
| 172 | ) { | |
| 173 | override get message(): string { | |
| 174 | return `--host ${this.host} cannot be combined with ${this.mode}: single-origin browser dev proxies the backend at localhost, and a backend bound only to ${this.host} leaves localhost unanswered, so every proxied request fails. Use a wildcard (0.0.0.0 or ::) to serve that interface and loopback together, or --share for remote access.`; | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 132 | 178 | export const DevRunnerError = Schema.Union([ |
| 133 | 179 | DevRunnerConfigurationError, |
| 180 | DevRunnerHostNotProxiableError, | |
| 134 | 181 | DevRunnerInvalidPortOffsetError, |
| 135 | 182 | DevRunnerPortExhaustedError, |
| 136 | 183 | DevRunnerProcessError, |
| ... | ... | @@ -167,6 +214,7 @@ const OffsetConfig = Config.all({ |
| 167 | 214 | export function resolveOffset(config: { |
| 168 | 215 | readonly portOffset: number | undefined; |
| 169 | 216 | readonly devInstance: string | undefined; |
| 217 | readonly worktreePath?: string | undefined; | |
| 170 | 218 | }): Effect.Effect< |
| 171 | 219 | { readonly offset: number; readonly source: string }, |
| 172 | 220 | DevRunnerInvalidPortOffsetError |
| ... | ... | @@ -188,19 +236,30 @@ export function resolveOffset(config: { |
| 188 | 236 | } |
| 189 | 237 | |
| 190 | 238 | const seed = config.devInstance?.trim(); |
| 191 | if (!seed) { | |
| 192 | return Effect.succeed({ offset: 0, source: "default ports" }); | |
| 239 | if (seed) { | |
| 240 | if (/^\d+$/.test(seed)) { | |
| 241 | return Effect.succeed({ | |
| 242 | offset: Number(seed), | |
| 243 | source: `numeric T3CODE_DEV_INSTANCE=${seed}`, | |
| 244 | }); | |
| 245 | } | |
| 246 | ||
| 247 | const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; | |
| 248 | return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); | |
| 193 | 249 | } |
| 194 | 250 | |
| 195 | if (/^\d+$/.test(seed)) { | |
| 196 | return Effect.succeed({ | |
| 197 | offset: Number(seed), | |
| 198 | source: `numeric T3CODE_DEV_INSTANCE=${seed}`, | |
| 199 | }); | |
| 251 | // Worktrees get ports derived from their path so each one is stable across | |
| 252 | // restarts and distinct from its siblings. Without this every worktree starts | |
| 253 | // at offset 0 and scan-collides onto whatever happens to be free that minute, | |
| 254 | // so ports move under you between runs — which breaks any URL you already | |
| 255 | // shared. The main checkout keeps the documented 5733/13773. | |
| 256 | const worktreePath = config.worktreePath?.trim(); | |
| 257 | if (worktreePath) { | |
| 258 | const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; | |
| 259 | return Effect.succeed({ offset, source: `worktree ${worktreePath}` }); | |
| 200 | 260 | } |
| 201 | 261 | |
| 202 | const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; | |
| 203 | return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); | |
| 262 | return Effect.succeed({ offset: 0, source: "default ports" }); | |
| 204 | 263 | } |
| 205 | 264 | |
| 206 | 265 | function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> { |
| ... | ... | @@ -246,8 +305,8 @@ export function createDevRunnerEnv({ |
| 246 | 305 | return Effect.gen(function* () { |
| 247 | 306 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 248 | 307 | const webPort = BASE_WEB_PORT + webOffset; |
| 249 | // Precedence is resolved by the caller. An unset t3Home here genuinely | |
| 250 | // means "use the default" rather than inheriting an ambient value. | |
| 308 | // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved | |
| 309 | // by the caller; an unset t3Home here genuinely means "use the default". | |
| 251 | 310 | const configuredBaseDir = t3Home?.trim() || undefined; |
| 252 | 311 | const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); |
| 253 | 312 | const isDesktopMode = mode === "dev:desktop"; |
| ... | ... | @@ -268,12 +327,41 @@ export function createDevRunnerEnv({ |
| 268 | 327 | |
| 269 | 328 | if (!isDesktopMode) { |
| 270 | 329 | output.T3CODE_PORT = String(serverPort); |
| 271 | output.VITE_HTTP_URL = `http://localhost:${serverPort}`; | |
| 272 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; | |
| 330 | // HOST is Vite's own bind address, and the desktop branch below is the | |
| 331 | // only place we set it. An inherited one (an exported HOST, a container, | |
| 332 | // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin | |
| 333 | // its HMR socket to that address — see the `explicitHost` gate in | |
| 334 | // apps/web/vite.config.ts. Over a shared origin that is invisible: the | |
| 335 | // page loads and only HMR quietly dials the wrong machine. | |
| 336 | delete output.HOST; | |
| 337 | if (mode === "dev" || mode === "dev:web") { | |
| 338 | // Browser dev is single-origin: everything (including /ws) is proxied | |
| 339 | // through Vite, so the client must resolve its backend from | |
| 340 | // window.location.origin rather than a baked-in localhost URL. See | |
| 341 | // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts | |
| 342 | // — it only defers to the origin when both of these are absent. Baking | |
| 343 | // localhost here is what breaks any non-localhost origin (tailnet, LAN, | |
| 344 | // phone): the remote browser dials its own machine. | |
| 345 | delete output.VITE_HTTP_URL; | |
| 346 | delete output.VITE_WS_URL; | |
| 347 | // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, | |
| 348 | // which merges `.env`/`.env.local` *under* this env, so a developer | |
| 349 | // with either URL in their `.env` would get it back and silently lose | |
| 350 | // single-origin mode. This states the intent positively so Vite can | |
| 351 | // ignore those values rather than infer from their absence. | |
| 352 | output.T3CODE_SINGLE_ORIGIN_DEV = "1"; | |
| 353 | } else { | |
| 354 | output.VITE_HTTP_URL = `http://localhost:${serverPort}`; | |
| 355 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; | |
| 356 | delete output.T3CODE_SINGLE_ORIGIN_DEV; | |
| 357 | } | |
| 273 | 358 | } else { |
| 274 | 359 | output.T3CODE_PORT = String(serverPort); |
| 275 | 360 | output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 276 | 361 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 362 | // Desktop pins the renderer to loopback on purpose; an ambient marker | |
| 363 | // must not make Vite drop those URLs. | |
| 364 | delete output.T3CODE_SINGLE_ORIGIN_DEV; | |
| 277 | 365 | delete output.T3CODE_MODE; |
| 278 | 366 | delete output.T3CODE_NO_BROWSER; |
| 279 | 367 | delete output.T3CODE_HOST; |
| ... | ... | @@ -344,13 +432,41 @@ export function checkPortAvailabilityOnHosts<R>( |
| 344 | 432 | }); |
| 345 | 433 | } |
| 346 | 434 | |
| 347 | const defaultCheckPortAvailability: PortAvailabilityCheck<NetService.NetService> = (port) => | |
| 348 | Effect.gen(function* () { | |
| 349 | const net = yield* NetService.NetService; | |
| 350 | return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => | |
| 351 | net.canListenOnHost(candidatePort, host), | |
| 352 | ); | |
| 353 | }); | |
| 435 | /** | |
| 436 | * Hosts to probe for a dev server bound to `configuredHost`. | |
| 437 | * | |
| 438 | * Loopback is always checked because the web server and the desktop renderer | |
| 439 | * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto | |
| 440 | * another interface, that interface decides whether the bind actually | |
| 441 | * succeeds — probing only loopback would hand back a port that is free here | |
| 442 | * and taken there, and the server would fail to start. | |
| 443 | * | |
| 444 | * `configuredHost` applies to the *backend* only. Vite takes its bind address | |
| 445 | * from `HOST`, which the runner sets for desktop alone, so the web port stays | |
| 446 | * on loopback and must not be judged against the backend's interface — | |
| 447 | * a port free on loopback but busy on that interface would otherwise be | |
| 448 | * rejected for a server that was never going to bind there. | |
| 449 | */ | |
| 450 | export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray<string> { | |
| 451 | const host = configuredHost?.trim(); | |
| 452 | if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) { | |
| 453 | return DEV_PORT_PROBE_HOSTS; | |
| 454 | } | |
| 455 | return [...DEV_PORT_PROBE_HOSTS, host]; | |
| 456 | } | |
| 457 | ||
| 458 | const makeDefaultCheckPortAvailability = | |
| 459 | (configuredHost: string | undefined): PortAvailabilityCheck<NetService.NetService> => | |
| 460 | (port, role) => | |
| 461 | Effect.gen(function* () { | |
| 462 | const net = yield* NetService.NetService; | |
| 463 | const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost); | |
| 464 | return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) => | |
| 465 | net.canListenOnHost(candidatePort, host), | |
| 466 | ); | |
| 467 | }); | |
| 468 | ||
| 469 | const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined); | |
| 354 | 470 | |
| 355 | 471 | interface FindFirstAvailableOffsetInput<R = NetService.NetService> { |
| 356 | 472 | readonly startOffset: number; |
| ... | ... | @@ -384,10 +500,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({ |
| 384 | 500 | |
| 385 | 501 | const checks: Array<Effect.Effect<boolean, never, R>> = []; |
| 386 | 502 | if (requireServerPort) { |
| 387 | checks.push(checkPort(serverPort)); | |
| 503 | checks.push(checkPort(serverPort, "server")); | |
| 388 | 504 | } |
| 389 | 505 | if (requireWebPort) { |
| 390 | checks.push(checkPort(webPort)); | |
| 506 | checks.push(checkPort(webPort, "web")); | |
| 391 | 507 | } |
| 392 | 508 | |
| 393 | 509 | if (checks.length === 0) { |
| ... | ... | @@ -483,6 +599,7 @@ interface DevRunnerCliInput { |
| 483 | 599 | readonly port: number | undefined; |
| 484 | 600 | readonly devUrl: URL | undefined; |
| 485 | 601 | readonly dryRun: boolean; |
| 602 | readonly share: boolean; | |
| 486 | 603 | readonly runArgs: ReadonlyArray<string>; |
| 487 | 604 | } |
| 488 | 605 | |
| ... | ... | @@ -498,19 +615,41 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 498 | 615 | ), |
| 499 | 616 | ); |
| 500 | 617 | |
| 501 | const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); | |
| 618 | // Single-origin browser dev proxies the backend at localhost. A wildcard | |
| 619 | // bind still answers there; a specific non-loopback interface does not, | |
| 620 | // which breaks every proxied request in a way that reads as "server is | |
| 621 | // broken" rather than "flag combination is unsupported". Reject it up | |
| 622 | // front instead. (dev:server and dev:desktop don't proxy — untouched.) | |
| 623 | if ( | |
| 624 | (input.mode === "dev" || input.mode === "dev:web") && | |
| 625 | input.host !== undefined && | |
| 626 | !isProxiableBindHost(input.host) | |
| 627 | ) { | |
| 628 | return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); | |
| 629 | } | |
| 630 | ||
| 631 | const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory); | |
| 632 | ||
| 633 | const { offset, source } = yield* resolveOffset({ | |
| 634 | portOffset, | |
| 635 | devInstance, | |
| 636 | worktreePath, | |
| 637 | }); | |
| 502 | 638 | |
| 503 | 639 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ |
| 504 | 640 | mode: input.mode, |
| 505 | 641 | startOffset: offset, |
| 506 | 642 | hasExplicitServerPort: input.port !== undefined, |
| 507 | 643 | hasExplicitDevUrl: input.devUrl !== undefined, |
| 644 | // A non-loopback bind host decides whether the backend can actually take | |
| 645 | // the port, so it has to be probed alongside loopback. | |
| 646 | checkPortAvailability: makeDefaultCheckPortAvailability(input.host), | |
| 508 | 647 | }); |
| 509 | 648 | |
| 510 | 649 | 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. | |
| 650 | // A dev server started inside a worktree defaults to that worktree's own | |
| 651 | // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must | |
| 652 | // outrank an ambient T3CODE_HOME. `--home-dir` still wins. | |
| 514 | 653 | const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory); |
| 515 | 654 | // Trim before choosing: `--home-dir ""` is not a selection, and treating it |
| 516 | 655 | // as one would skip the worktree default and land on the shared home — |
| ... | ... | @@ -543,10 +682,90 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 543 | 682 | `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, |
| 544 | 683 | ); |
| 545 | 684 | |
| 685 | // Before the share block: --dry-run only resolves and prints. Sharing would | |
| 686 | // replace, then tear down, whatever mapping the port already had — a | |
| 687 | // surprising side effect from a command documented as inert. | |
| 546 | 688 | if (input.dryRun) { |
| 547 | 689 | return; |
| 548 | 690 | } |
| 549 | 691 | |
| 692 | const sharedWebPort = BASE_WEB_PORT + webOffset; | |
| 693 | if (input.share) { | |
| 694 | if (input.mode === "dev:server") { | |
| 695 | yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); | |
| 696 | } else if (input.mode === "dev:desktop") { | |
| 697 | // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and | |
| 698 | // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI | |
| 699 | // and then watch it dial its own 127.0.0.1 for the backend. Worse, | |
| 700 | // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin | |
| 701 | // Electron itself loads the renderer from. Refuse rather than hand out | |
| 702 | // a URL that is broken in a way the user cannot see. | |
| 703 | yield* Effect.logWarning( | |
| 704 | "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", | |
| 705 | ); | |
| 706 | } else { | |
| 707 | // acquireRelease, not share-then-addFinalizer: the mapping outlives this | |
| 708 | // process (and reboots), so the cleanup has to be registered atomically | |
| 709 | // with creating it. An interrupt landing in between would otherwise | |
| 710 | // leave a mapping pointing at a port nothing is listening on. | |
| 711 | // | |
| 712 | // Deliberately no ownership tracking beyond that: if a second runner | |
| 713 | // takes this port during a fast restart, the first's exit can briefly | |
| 714 | // tear down the new mapping — visible (the URL stops working) and fixed | |
| 715 | // by re-running --share. A lease protocol closing that window existed | |
| 716 | // and was removed as more machinery than a dev convenience warrants. | |
| 717 | // | |
| 718 | // A tailnet that isn't up shouldn't stop the dev server from starting — | |
| 719 | // warn, and carry on serving locally. | |
| 720 | const shared = yield* Effect.acquireRelease( | |
| 721 | shareDevServer({ webPort: sharedWebPort }), | |
| 722 | () => | |
| 723 | // Serve config outlives this process, so a cleanup that did not | |
| 724 | // take leaves a tailnet URL pointing at a port nothing serves. | |
| 725 | unshareDevServer(sharedWebPort).pipe( | |
| 726 | Effect.flatMap((result) => | |
| 727 | result.cleared | |
| 728 | ? Effect.void | |
| 729 | : Effect.logWarning( | |
| 730 | `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ | |
| 731 | result.explanation ? `: ${result.explanation}` : "" | |
| 732 | }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, | |
| 733 | ), | |
| 734 | ), | |
| 735 | ), | |
| 736 | ).pipe( | |
| 737 | Effect.tapError((error: DevShareError) => | |
| 738 | Effect.logWarning( | |
| 739 | `[dev-runner] could not share on the tailnet: ${error.message}${ | |
| 740 | error.hint ? ` — ${error.hint}` : "" | |
| 741 | }`, | |
| 742 | ), | |
| 743 | ), | |
| 744 | Effect.option, | |
| 745 | Effect.map(Option.getOrUndefined), | |
| 746 | ); | |
| 747 | ||
| 748 | if (shared) { | |
| 749 | // The app is reached from the tailnet origin. Vite already allows | |
| 750 | // *.ts.net hosts; the backend needs the origin for credentialed | |
| 751 | // requests that bypass the proxy (desktop renderer, direct calls). | |
| 752 | env.T3CODE_DEV_ALLOWED_ORIGINS = [ | |
| 753 | env.T3CODE_DEV_ALLOWED_ORIGINS, | |
| 754 | new URL(shared.url).origin, | |
| 755 | ] | |
| 756 | .filter((entry) => entry && entry.length > 0) | |
| 757 | .join(","); | |
| 758 | // The server builds its pairing URL from this, so the URL printed at | |
| 759 | // startup is already the shareable one — no rewriting by hand. An | |
| 760 | // explicit --dev-url still wins. | |
| 761 | if (input.devUrl === undefined) { | |
| 762 | env.VITE_DEV_SERVER_URL = shared.url; | |
| 763 | } | |
| 764 | yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); | |
| 765 | } | |
| 766 | } | |
| 767 | } | |
| 768 | ||
| 550 | 769 | const spawnCommand = yield* resolveSpawnCommand( |
| 551 | 770 | "vp", |
| 552 | 771 | [...MODE_ARGS[input.mode], ...input.runArgs], |
| ... | ... | @@ -606,7 +825,7 @@ const devRunnerCli = Command.make("dev-runner", { |
| 606 | 825 | ), |
| 607 | 826 | t3Home: Flag.string("home-dir").pipe( |
| 608 | 827 | Flag.withDescription( |
| 609 | "Explicit T3 Code data directory; runtime state is stored under userdata. Inside a git worktree this defaults to that worktree's own .t3.", | |
| 828 | "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME). Inside a git worktree this defaults to that worktree's own .t3 so dev state stays off the shared home.", | |
| 610 | 829 | ), |
| 611 | 830 | Flag.optional, |
| 612 | 831 | Flag.map(Option.getOrUndefined), |
| ... | ... | @@ -646,6 +865,12 @@ const devRunnerCli = Command.make("dev-runner", { |
| 646 | 865 | Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), |
| 647 | 866 | Flag.withDefault(false), |
| 648 | 867 | ), |
| 868 | share: Flag.boolean("share").pipe( | |
| 869 | Flag.withDescription( | |
| 870 | "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.", | |
| 871 | ), | |
| 872 | Flag.withDefault(false), | |
| 873 | ), | |
| 649 | 874 | runArgs: Argument.string("run-arg").pipe( |
| 650 | 875 | Argument.withDescription("Additional Vite+ run args (pass after `--`)."), |
| 651 | 876 | Argument.variadic(), |
scripts/lib/dev-share.test.ts created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Sink from "effect/Sink"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 7 | ||
| 8 | import { | |
| 9 | type DevShareError, | |
| 10 | DevServeFailedError, | |
| 11 | shareDevServer, | |
| 12 | unshareDevServer, | |
| 13 | } from "./dev-share.ts"; | |
| 14 | ||
| 15 | const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); | |
| 16 | const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist"; | |
| 17 | ||
| 18 | interface CallResult { | |
| 19 | readonly exitCode: number; | |
| 20 | readonly stderr?: string; | |
| 21 | } | |
| 22 | ||
| 23 | const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); | |
| 24 | ||
| 25 | /** | |
| 26 | * Answers `tailscale status --json` with a valid tailnet name, and lets each | |
| 27 | * test set the outcome of the `off` (pre-clear) and `serve` calls separately — | |
| 28 | * they are the same subcommand and are told apart by the trailing `off`. | |
| 29 | */ | |
| 30 | const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => | |
| 31 | Layer.succeed( | |
| 32 | ChildProcessSpawner.ChildProcessSpawner, | |
| 33 | ChildProcessSpawner.make((command) => { | |
| 34 | const args = "args" in command ? (command.args as ReadonlyArray<string>) : []; | |
| 35 | const result: CallResult = args.includes("status") | |
| 36 | ? { exitCode: 0 } | |
| 37 | : args.includes("off") | |
| 38 | ? (input.off ?? { exitCode: 0 }) | |
| 39 | : (input.serve ?? { exitCode: 0 }); | |
| 40 | ||
| 41 | return Effect.succeed( | |
| 42 | ChildProcessSpawner.makeHandle({ | |
| 43 | pid: ChildProcessSpawner.ProcessId(1), | |
| 44 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), | |
| 45 | isRunning: Effect.succeed(false), | |
| 46 | kill: () => Effect.void, | |
| 47 | unref: Effect.succeed(Effect.void), | |
| 48 | stdin: Sink.drain, | |
| 49 | stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty, | |
| 50 | stderr: result.stderr ? encode(result.stderr) : Stream.empty, | |
| 51 | all: Stream.empty, | |
| 52 | getInputFd: () => Sink.drain, | |
| 53 | getOutputFd: () => Stream.empty, | |
| 54 | }), | |
| 55 | ); | |
| 56 | }), | |
| 57 | ); | |
| 58 | ||
| 59 | describe("unshareDevServer", () => { | |
| 60 | it.effect("treats a removed mapping as cleared", () => | |
| 61 | Effect.gen(function* () { | |
| 62 | const result = yield* unshareDevServer(5788).pipe( | |
| 63 | Effect.provide(spawnerLayer({ off: { exitCode: 0 } })), | |
| 64 | ); | |
| 65 | assert.isTrue(result.cleared); | |
| 66 | }), | |
| 67 | ); | |
| 68 | ||
| 69 | // `tailscale serve … off` exits 1 when the port had no mapping, which is the | |
| 70 | // normal first-share case — the port is clear, so this must not be an error. | |
| 71 | it.effect("treats a missing handler as cleared", () => | |
| 72 | Effect.gen(function* () { | |
| 73 | const result = yield* unshareDevServer(5788).pipe( | |
| 74 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), | |
| 75 | ); | |
| 76 | assert.isTrue(result.cleared); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("reports a genuine removal failure as not cleared", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const result = yield* unshareDevServer(5788).pipe( | |
| 83 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), | |
| 84 | ); | |
| 85 | assert.isFalse(result.cleared); | |
| 86 | assert.include(result.explanation, "permission denied"); | |
| 87 | // Structured, so a wrapping error can keep the real chain. | |
| 88 | assert.equal(result.cause?._tag, "TailscaleCommandExitError"); | |
| 89 | }), | |
| 90 | ); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("shareDevServer", () => { | |
| 94 | it.effect("returns the tailnet URL for the same port", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const shared = yield* shareDevServer({ webPort: 5788 }).pipe( | |
| 97 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), | |
| 98 | ); | |
| 99 | ||
| 100 | assert.equal(shared.host, "host.example.ts.net"); | |
| 101 | assert.equal(shared.url, "https://host.example.ts.net:5788/"); | |
| 102 | }), | |
| 103 | ); | |
| 104 | ||
| 105 | // The stale-mapping clear runs before serve, so a failure here leaves the | |
| 106 | // port serving nothing. Saying only "serve failed" would let an operator | |
| 107 | // assume their previous mapping survived. | |
| 108 | it.effect("reports that the prior mapping was cleared when serve fails", () => | |
| 109 | Effect.gen(function* () { | |
| 110 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( | |
| 111 | Effect.provide( | |
| 112 | spawnerLayer({ | |
| 113 | off: { exitCode: 0 }, | |
| 114 | serve: { exitCode: 1, stderr: "port already in use" }, | |
| 115 | }), | |
| 116 | ), | |
| 117 | Effect.flip, | |
| 118 | ); | |
| 119 | ||
| 120 | assert.instanceOf(error, DevServeFailedError); | |
| 121 | assert.equal(error.stage, "serve"); | |
| 122 | assert.equal(error.webPort, 5788); | |
| 123 | // The underlying failure is preserved rather than flattened to a string. | |
| 124 | assert.equal( | |
| 125 | (error.cause as { _tag?: string } | undefined)?._tag, | |
| 126 | "TailscaleCommandExitError", | |
| 127 | ); | |
| 128 | // Unclassifiable stderr is never quoted (it can carry auth keys), so the | |
| 129 | // message points at the command instead of echoing the CLI. | |
| 130 | assert.notInclude(error.message, "port already in use"); | |
| 131 | assert.include(error.message, "run the command by hand"); | |
| 132 | assert.include(error.message, "no longer served"); | |
| 133 | assert.include(error.message, "5788"); | |
| 134 | }), | |
| 135 | ); | |
| 136 | ||
| 137 | // A recognized failure gets our own wording for it — enough to act on | |
| 138 | // without passing CLI text through. | |
| 139 | it.effect("explains a recognized serve failure without quoting stderr", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( | |
| 142 | Effect.provide( | |
| 143 | spawnerLayer({ | |
| 144 | off: { exitCode: 0 }, | |
| 145 | serve: { | |
| 146 | exitCode: 1, | |
| 147 | stderr: "permission denied for tskey-auth-secret-token-value", | |
| 148 | }, | |
| 149 | }), | |
| 150 | ), | |
| 151 | Effect.flip, | |
| 152 | ); | |
| 153 | ||
| 154 | assert.instanceOf(error, DevServeFailedError); | |
| 155 | assert.equal(error.stage, "serve"); | |
| 156 | assert.include(error.message, "permission denied"); | |
| 157 | assert.include(error.message, "elevated privileges"); | |
| 158 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); | |
| 159 | }), | |
| 160 | ); | |
| 161 | ||
| 162 | // Serving over routes we could not remove yields a URL that loads but whose | |
| 163 | // /ws and /api quietly point at a dead backend. | |
| 164 | it.effect("refuses to serve when the existing mapping could not be cleared", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( | |
| 167 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), | |
| 168 | Effect.flip, | |
| 169 | ); | |
| 170 | ||
| 171 | assert.instanceOf(error, DevServeFailedError); | |
| 172 | // A distinct stage: the prior mapping survived, so nothing was replaced. | |
| 173 | assert.equal(error.stage, "clear-existing"); | |
| 174 | assert.include(error.message, "could not clear the existing mapping"); | |
| 175 | assert.include(error.message, "permission denied"); | |
| 176 | }), | |
| 177 | ); | |
| 178 | }); |
scripts/lib/dev-share.ts created+217| ... | ... | @@ -0,0 +1,217 @@ |
| 1 | /** | |
| 2 | * Shares a running dev server on the local tailnet via `tailscale serve`, so it | |
| 3 | * can be opened from a phone, another laptop, or by whoever is reviewing the | |
| 4 | * work. | |
| 5 | * | |
| 6 | * Thin wrapper over `@t3tools/tailscale` (the same client the server's own | |
| 7 | * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a | |
| 8 | * stale mapping left by a killed run, and refusing to serve over routes it | |
| 9 | * could not remove. | |
| 10 | * | |
| 11 | * Because browser dev is single-origin (Vite proxies the backend — see | |
| 12 | * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering | |
| 13 | * the web port is enough; the backend needs no mapping of its own. | |
| 14 | */ | |
| 15 | ||
| 16 | import { | |
| 17 | buildTailscaleHttpsBaseUrl, | |
| 18 | disableTailscaleServe, | |
| 19 | ensureTailscaleServe, | |
| 20 | readTailscaleStatus, | |
| 21 | type TailscaleCommandError, | |
| 22 | type TailscaleStderrDiagnostic, | |
| 23 | } from "@t3tools/tailscale"; | |
| 24 | import * as Effect from "effect/Effect"; | |
| 25 | import * as Schema from "effect/Schema"; | |
| 26 | import type { ChildProcessSpawner } from "effect/unstable/process"; | |
| 27 | ||
| 28 | /** | |
| 29 | * Human-readable gloss for each diagnostic. Deliberately our own words rather | |
| 30 | * than the CLI's: tailscale prints auth keys and node names into stderr, and | |
| 31 | * this string is logged. | |
| 32 | */ | |
| 33 | const DIAGNOSTIC_EXPLANATIONS: Record<TailscaleStderrDiagnostic, string | undefined> = { | |
| 34 | "no-existing-handler": "no mapping existed for that port", | |
| 35 | "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`", | |
| 36 | "permission-denied": "permission denied — `tailscale serve` may need elevated privileges", | |
| 37 | unknown: undefined, | |
| 38 | }; | |
| 39 | ||
| 40 | /** | |
| 41 | * Our own wording for why a tailscale command failed, derived from the | |
| 42 | * classified diagnostic. Never the CLI's text — see `stderrDiagnosticOf`. | |
| 43 | */ | |
| 44 | const explainCommandFailure = (error: TailscaleCommandError): string | undefined => | |
| 45 | error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined | |
| 46 | ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why") | |
| 47 | : undefined; | |
| 48 | ||
| 49 | /** | |
| 50 | * Three distinct failures, three classes: each has its own caller-visible | |
| 51 | * message and its own remedy, and `shareDevServer` chooses between them | |
| 52 | * structurally. A single error with a `reason` discriminator would encode that | |
| 53 | * distinction twice and put a lookup table in the `message` getter. | |
| 54 | * | |
| 55 | * Each wraps a real underlying failure and so keeps it as `cause`; the message | |
| 56 | * is derived only from the structural fields, never from `cause.message`. | |
| 57 | */ | |
| 58 | export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()( | |
| 59 | "TailscaleUnavailableError", | |
| 60 | { cause: Schema.Defect() }, | |
| 61 | ) { | |
| 62 | override get message(): string { | |
| 63 | return "could not talk to tailscale"; | |
| 64 | } | |
| 65 | ||
| 66 | get hint(): string { | |
| 67 | return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL."; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** No underlying failure: the status read succeeded and simply had no name. */ | |
| 72 | export class TailnetNameMissingError extends Schema.TaggedErrorClass<TailnetNameMissingError>()( | |
| 73 | "TailnetNameMissingError", | |
| 74 | {}, | |
| 75 | ) { | |
| 76 | override get message(): string { | |
| 77 | return "this machine has no tailnet DNS name"; | |
| 78 | } | |
| 79 | ||
| 80 | get hint(): string { | |
| 81 | return "Run `tailscale up` and make sure MagicDNS is enabled."; | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * `stage` is a genuine multi-value discriminator: both stages share the same | |
| 87 | * semantics (a `tailscale serve` invocation failed for this port) and differ | |
| 88 | * only in which one, which the message states plainly. | |
| 89 | */ | |
| 90 | export class DevServeFailedError extends Schema.TaggedErrorClass<DevServeFailedError>()( | |
| 91 | "DevServeFailedError", | |
| 92 | { | |
| 93 | stage: Schema.Literals(["clear-existing", "serve"]), | |
| 94 | webPort: Schema.Number, | |
| 95 | explanation: Schema.optional(Schema.String), | |
| 96 | cause: Schema.optional(Schema.Defect()), | |
| 97 | }, | |
| 98 | ) { | |
| 99 | override get message(): string { | |
| 100 | const port = String(this.webPort); | |
| 101 | const base = | |
| 102 | this.stage === "clear-existing" | |
| 103 | ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} off\` and retry` | |
| 104 | : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`; | |
| 105 | return this.explanation ? `${base}: ${this.explanation}` : base; | |
| 106 | } | |
| 107 | ||
| 108 | get hint(): undefined { | |
| 109 | return undefined; | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | export const DevShareError = Schema.Union([ | |
| 114 | TailscaleUnavailableError, | |
| 115 | TailnetNameMissingError, | |
| 116 | DevServeFailedError, | |
| 117 | ]); | |
| 118 | export type DevShareError = typeof DevShareError.Type; | |
| 119 | export const isDevShareError = Schema.is(DevShareError); | |
| 120 | ||
| 121 | /** | |
| 122 | * Removes any mapping for `webPort`, reporting whether the port is now clear. | |
| 123 | * | |
| 124 | * Runs uninterruptibly: this is called from a finalizer on the way out of an | |
| 125 | * interrupted program, and cancelling the cleanup subprocess would leave | |
| 126 | * exactly the stale mapping it exists to remove. | |
| 127 | */ | |
| 128 | export const unshareDevServer = ( | |
| 129 | webPort: number, | |
| 130 | ): Effect.Effect< | |
| 131 | { | |
| 132 | readonly cleared: boolean; | |
| 133 | readonly explanation?: string | undefined; | |
| 134 | // Kept structured so a caller wrapping this can preserve the real error | |
| 135 | // chain rather than a flattened string. | |
| 136 | readonly cause?: TailscaleCommandError | undefined; | |
| 137 | }, | |
| 138 | never, | |
| 139 | ChildProcessSpawner.ChildProcessSpawner | |
| 140 | > => | |
| 141 | disableTailscaleServe({ servePort: webPort }).pipe( | |
| 142 | Effect.as({ cleared: true } as const), | |
| 143 | Effect.catch((error: TailscaleCommandError) => | |
| 144 | Effect.succeed( | |
| 145 | // "Nothing was mapped" leaves the port clear either way. | |
| 146 | error._tag === "TailscaleCommandExitError" && | |
| 147 | error.stderrDiagnostic === "no-existing-handler" | |
| 148 | ? ({ cleared: true } as const) | |
| 149 | : ({ | |
| 150 | cleared: false, | |
| 151 | ...(explainCommandFailure(error) !== undefined | |
| 152 | ? { explanation: explainCommandFailure(error) } | |
| 153 | : {}), | |
| 154 | cause: error, | |
| 155 | } as const), | |
| 156 | ), | |
| 157 | ), | |
| 158 | Effect.uninterruptible, | |
| 159 | ); | |
| 160 | ||
| 161 | export interface DevShareResult { | |
| 162 | readonly url: string; | |
| 163 | readonly host: string; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Publishes `webPort` on the tailnet at the same port number and returns the | |
| 168 | * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. | |
| 169 | */ | |
| 170 | export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { | |
| 171 | readonly webPort: number; | |
| 172 | }) { | |
| 173 | const status = yield* readTailscaleStatus.pipe( | |
| 174 | Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })), | |
| 175 | ); | |
| 176 | if (status.magicDnsName === null) { | |
| 177 | return yield* new TailnetNameMissingError(); | |
| 178 | } | |
| 179 | ||
| 180 | // Clear any mapping left behind by a run that was killed before its finalizer | |
| 181 | // could fire. Serve config survives both the process and a reboot, and a | |
| 182 | // stale entry may carry path routes we no longer want — older versions mapped | |
| 183 | // /ws, /api and friends to a separate backend port, and serving "/" alone | |
| 184 | // would leave those pointing at a port nothing is listening on. | |
| 185 | const cleared = yield* unshareDevServer(input.webPort); | |
| 186 | if (!cleared.cleared) { | |
| 187 | // Serving over routes we failed to remove would hand out a URL that is | |
| 188 | // broken in a way the user cannot see: the page loads while /ws and /api | |
| 189 | // silently resolve to a dead backend. Better to refuse and say why. | |
| 190 | return yield* new DevServeFailedError({ | |
| 191 | stage: "clear-existing", | |
| 192 | webPort: input.webPort, | |
| 193 | ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}), | |
| 194 | ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}), | |
| 195 | }); | |
| 196 | } | |
| 197 | ||
| 198 | yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( | |
| 199 | Effect.mapError((error) => { | |
| 200 | const explanation = explainCommandFailure(error); | |
| 201 | return new DevServeFailedError({ | |
| 202 | stage: "serve", | |
| 203 | webPort: input.webPort, | |
| 204 | ...(explanation !== undefined ? { explanation } : {}), | |
| 205 | cause: error, | |
| 206 | }); | |
| 207 | }), | |
| 208 | ); | |
| 209 | ||
| 210 | return { | |
| 211 | url: buildTailscaleHttpsBaseUrl({ | |
| 212 | magicDnsName: status.magicDnsName, | |
| 213 | servePort: input.webPort, | |
| 214 | }), | |
| 215 | host: status.magicDnsName, | |
| 216 | } satisfies DevShareResult; | |
| 217 | }); |
scripts/package.json+1| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | "@effect/platform-node": "catalog:", |
| 11 | 11 | "@t3tools/contracts": "workspace:*", |
| 12 | 12 | "@t3tools/shared": "workspace:*", |
| 13 | "@t3tools/tailscale": "workspace:*", | |
| 13 | 14 | "effect": "catalog:", |
| 14 | 15 | "pngjs": "7.0.0", |
| 15 | 16 | "yaml": "catalog:" |