1#!/usr/bin/env node
2
3import * as NodeOS from "node:os";
4
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NetService from "@t3tools/shared/Net";
8import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import * as Config from "effect/Config";
12import * as Effect from "effect/Effect";
13import * as Hash from "effect/Hash";
14import * as Layer from "effect/Layer";
15import * as Logger from "effect/Logger";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import { Argument, Command, Flag } from "effect/cli";
20import { ChildProcess } from "effect/process";
21
22import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts";
23import { loadRepoEnv } from "./lib/public-config.ts";
24
25Object.assign(process.env, loadRepoEnv());
26
27const BASE_SERVER_PORT = 13773;
28const BASE_WEB_PORT = 5733;
29const MAX_HASH_OFFSET = 3000;
30const MAX_PORT = 65535;
31const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
32// HTTP(S) requests to these ports are blocked by the Fetch standard before a
33// browser reaches the network. Keep the complete list here so explicit or
34// future wider offsets cannot produce a URL that curl accepts but browsers
35// reject. https://fetch.spec.whatwg.org/#port-blocking
36const FETCH_BAD_PORTS = new Set([
37 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
38 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
39 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
40 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
41 6669, 6679, 6697, 10080,
42]);
43// Dev servers bind loopback, so loopback is the only interface whose
44// availability decides whether we can use a port. Probing wildcards too made
45// the runner walk away from a perfectly free port whenever something else held
46// the same number on another interface — `tailscale serve` does exactly that,
47// which silently moved the ports out from under a URL that had just been shared.
48const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const;
49
50/**
51 * Bind hosts on which a backend still answers `http://localhost:<port>`, which
52 * is where single-origin browser dev proxies to. Loopback and the wildcards
53 * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only
54 * that address and the proxy target goes dark.
55 */
56export function isProxiableBindHost(host: string): boolean {
57 const normalized = host.trim();
58 return (
59 normalized === "" ||
60 normalized === "localhost" ||
61 normalized === "127.0.0.1" ||
62 normalized === "::1" ||
63 normalized === "[::1]" ||
64 normalized === "0.0.0.0" ||
65 normalized === "::" ||
66 normalized === "[::]"
67 );
68}
69
70export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
71 path.join(NodeOS.homedir(), ".t3"),
72);
73
74const MODE_ARGS = {
75 dev: [
76 "run",
77 "--filter=@t3tools/contracts",
78 "--filter=@t3tools/web",
79 "--filter=t3",
80 "--parallel",
81 "dev",
82 ],
83 "dev:server": ["run", "--filter=t3", "dev"],
84 "dev:web": ["run", "--filter=@t3tools/web", "dev"],
85 "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"],
86} as const satisfies Record<string, ReadonlyArray<string>>;
87
88type DevMode = keyof typeof MODE_ARGS;
89/**
90 * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the
91 * web port is always loopback. Passed explicitly rather than inferred from the
92 * port number, which stops distinguishing them under a large port offset.
93 */
94type PortAvailabilityCheck<R = never> = (
95 port: number,
96 role?: "server" | "web",
97) => Effect.Effect<boolean, never, R>;
98
99const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
100
101export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> {
102 return MODE_ARGS[mode];
103}
104
105export function isBrowserAllowedPort(port: number): boolean {
106 return !FETCH_BAD_PORTS.has(port);
107}
108
109export class DevRunnerConfigurationError extends Schema.TaggedError<DevRunnerConfigurationError>()(
110 "DevRunnerConfigurationError",
111 {
112 configKeys: Schema.Array(Schema.String),
113 cause: Schema.Defect(),
114 },
115) {
116 override get message(): string {
117 return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`;
118 }
119}
120
121export class DevRunnerInvalidPortOffsetError extends Schema.TaggedError<DevRunnerInvalidPortOffsetError>()(
122 "DevRunnerInvalidPortOffsetError",
123 {
124 configKey: Schema.Literal("T3CODE_PORT_OFFSET"),
125 portOffset: Schema.Number,
126 minimum: Schema.Number,
127 },
128) {
129 override get message(): string {
130 return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`;
131 }
132}
133
134export class DevRunnerPortExhaustedError extends Schema.TaggedError<DevRunnerPortExhaustedError>()(
135 "DevRunnerPortExhaustedError",
136 {
137 startOffset: Schema.Number,
138 requireServerPort: Schema.Boolean,
139 requireWebPort: Schema.Boolean,
140 baseServerPort: Schema.Number,
141 baseWebPort: Schema.Number,
142 maximumPort: Schema.Number,
143 },
144) {
145 override get message(): string {
146 return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`;
147 }
148}
149
150export class DevRunnerProcessError extends Schema.TaggedError<DevRunnerProcessError>()(
151 "DevRunnerProcessError",
152 {
153 operation: Schema.Literals(["spawn", "wait-for-exit"]),
154 mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
155 executable: Schema.Literal("vp"),
156 argumentCount: Schema.Number,
157 shell: Schema.Boolean,
158 cause: Schema.Defect(),
159 },
160) {
161 override get message(): string {
162 return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`;
163 }
164}
165
166export class DevRunnerProcessExitError extends Schema.TaggedError<DevRunnerProcessExitError>()(
167 "DevRunnerProcessExitError",
168 {
169 mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
170 executable: Schema.Literal("vp"),
171 argumentCount: Schema.Number,
172 shell: Schema.Boolean,
173 exitCode: Schema.Number,
174 },
175) {
176 override get message(): string {
177 return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`;
178 }
179}
180
181export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunnerHostNotProxiableError>()(
182 "DevRunnerHostNotProxiableError",
183 {
184 mode: Schema.Literals(["dev", "dev:web"]),
185 host: Schema.String,
186 },
187) {
188 override get message(): string {
189 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.`;
190 }
191}
192
193const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
194 Config.String(name).pipe(
195 Config.option,
196 Config.map((value) => Option.getOrUndefined(value)),
197 );
198const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> =>
199 Config.Boolean(name).pipe(
200 Config.option,
201 Config.map((value) => Option.getOrUndefined(value)),
202 );
203const optionalPortConfig = (name: string): Config.Config<number | undefined> =>
204 Config.Port(name).pipe(
205 Config.option,
206 Config.map((value) => Option.getOrUndefined(value)),
207 );
208const optionalIntegerConfig = (name: string): Config.Config<number | undefined> =>
209 Config.Int(name).pipe(
210 Config.option,
211 Config.map((value) => Option.getOrUndefined(value)),
212 );
213const OffsetConfig = Config.all({
214 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
215 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
216});
217
218export function resolveOffset(config: {
219 readonly portOffset: number | undefined;
220 readonly devInstance: string | undefined;
221 readonly worktreePath?: string | undefined;
222}): Effect.Effect<
223 { readonly offset: number; readonly source: string },
224 DevRunnerInvalidPortOffsetError
225> {
226 if (config.portOffset !== undefined) {
227 if (config.portOffset < 0) {
228 return Effect.fail(
229 new DevRunnerInvalidPortOffsetError({
230 configKey: "T3CODE_PORT_OFFSET",
231 portOffset: config.portOffset,
232 minimum: 0,
233 }),
234 );
235 }
236 return Effect.succeed({
237 offset: config.portOffset,
238 source: `T3CODE_PORT_OFFSET=${config.portOffset}`,
239 });
240 }
241
242 const seed = config.devInstance?.trim();
243 if (seed) {
244 if (/^\d+$/.test(seed)) {
245 return Effect.succeed({
246 offset: Number(seed),
247 source: `numeric T3CODE_DEV_INSTANCE=${seed}`,
248 });
249 }
250
251 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
252 return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
253 }
254
255 // Worktrees get ports derived from their path so each one is stable across
256 // restarts and distinct from its siblings. Without this every worktree starts
257 // at offset 0 and scan-collides onto whatever happens to be free that minute,
258 // so ports move under you between runs — which breaks any URL you already
259 // shared. The main checkout keeps the documented 5733/13773.
260 const worktreePath = config.worktreePath?.trim();
261 if (worktreePath) {
262 const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1;
263 return Effect.succeed({ offset, source: `worktree ${worktreePath}` });
264 }
265
266 return Effect.succeed({ offset: 0, source: "default ports" });
267}
268
269function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
270 return Effect.gen(function* () {
271 const path = yield* Path.Path;
272 const configured = baseDir?.trim();
273
274 if (configured) {
275 return path.resolve(configured);
276 }
277
278 return yield* DEFAULT_T3_HOME;
279 });
280}
281
282interface CreateDevRunnerEnvInput {
283 readonly mode: DevMode;
284 readonly baseEnv: NodeJS.ProcessEnv;
285 readonly serverOffset: number;
286 readonly webOffset: number;
287 readonly t3Home: string | undefined;
288 readonly browser: boolean | undefined;
289 readonly autoBootstrapProjectFromCwd: boolean | undefined;
290 readonly logWebSocketEvents: boolean | undefined;
291 readonly host: string | undefined;
292 readonly port: number | undefined;
293 readonly devUrl: URL | undefined;
294}
295
296export function createDevRunnerEnv({
297 mode,
298 baseEnv,
299 serverOffset,
300 webOffset,
301 t3Home,
302 browser,
303 autoBootstrapProjectFromCwd,
304 logWebSocketEvents,
305 host,
306 port,
307 devUrl,
308}: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, never, Path.Path> {
309 return Effect.gen(function* () {
310 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
311 const webPort = BASE_WEB_PORT + webOffset;
312 // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved
313 // by the caller; an unset t3Home here genuinely means "use the default".
314 const configuredBaseDir = t3Home?.trim() || undefined;
315 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
316 const isDesktopMode = mode === "dev:desktop";
317
318 const output: NodeJS.ProcessEnv = {
319 ...baseEnv,
320 PORT: String(webPort),
321 VITE_DEV_SERVER_URL:
322 devUrl?.toString() ??
323 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,
324 };
325
326 if (configuredBaseDir !== undefined) {
327 output.T3CODE_HOME = resolvedBaseDir;
328 } else {
329 delete output.T3CODE_HOME;
330 }
331
332 // A dev-runner server is never launcher-managed. When the shell that runs
333 // this script was itself spawned by the machine's managed t3 service (an
334 // agent working inside T3 Code), these leak through and the child server
335 // fails startup with "The service launcher started a different t3 version"
336 // (serviceLauncherClient.ts resolveStartup).
337 delete output.T3_SERVICE_LAUNCHER_CONTEXT;
338 delete output.T3_BOOT_SERVICE_UNIT;
339
340 if (!isDesktopMode) {
341 output.T3CODE_PORT = String(serverPort);
342 // HOST is Vite's own bind address, and the desktop branch below is the
343 // only place we set it. An inherited one (an exported HOST, a container,
344 // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin
345 // its HMR socket to that address — see the `explicitHost` gate in
346 // apps/web/vite.config.ts. Over a shared origin that is invisible: the
347 // page loads and only HMR quietly dials the wrong machine.
348 delete output.HOST;
349 if (mode === "dev" || mode === "dev:web") {
350 // Browser dev is single-origin: everything (including /ws) is proxied
351 // through Vite, so the client must resolve its backend from
352 // window.location.origin rather than a baked-in localhost URL. See
353 // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts
354 // — it only defers to the origin when both of these are absent. Baking
355 // localhost here is what breaks any non-localhost origin (tailnet, LAN,
356 // phone): the remote browser dials its own machine.
357 delete output.VITE_HTTP_URL;
358 delete output.VITE_WS_URL;
359 // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv,
360 // which merges `.env`/`.env.local` *under* this env, so a developer
361 // with either URL in their `.env` would get it back and silently lose
362 // single-origin mode. This states the intent positively so Vite can
363 // ignore those values rather than infer from their absence.
364 output.T3CODE_SINGLE_ORIGIN_DEV = "1";
365 } else {
366 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
367 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
368 delete output.T3CODE_SINGLE_ORIGIN_DEV;
369 }
370 } else {
371 output.T3CODE_PORT = String(serverPort);
372 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
373 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
374 // Desktop pins the renderer to loopback on purpose; an ambient marker
375 // must not make Vite drop those URLs.
376 delete output.T3CODE_SINGLE_ORIGIN_DEV;
377 delete output.T3CODE_MODE;
378 delete output.T3CODE_NO_BROWSER;
379 delete output.T3CODE_HOST;
380 delete output.T3CODE_DEV_AUTH_TOKEN;
381 }
382
383 if (!isDesktopMode && host !== undefined) {
384 output.T3CODE_HOST = host;
385 }
386
387 if (!isDesktopMode) {
388 output.T3CODE_NO_BROWSER = browser === true ? "0" : "1";
389 }
390
391 if (autoBootstrapProjectFromCwd !== undefined) {
392 output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0";
393 } else {
394 delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD;
395 }
396
397 if (logWebSocketEvents !== undefined) {
398 output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0";
399 } else {
400 delete output.T3CODE_LOG_WS_EVENTS;
401 }
402
403 if (mode === "dev") {
404 output.T3CODE_MODE = "web";
405 delete output.T3CODE_DESKTOP_WS_URL;
406 }
407
408 if (mode === "dev:server" || mode === "dev:web") {
409 output.T3CODE_MODE = "web";
410 delete output.T3CODE_DESKTOP_WS_URL;
411 }
412
413 if (isDesktopMode) {
414 output.HOST = DESKTOP_DEV_LOOPBACK_HOST;
415 delete output.T3CODE_DESKTOP_WS_URL;
416 }
417
418 return output;
419 });
420}
421
422function portPairForOffset(offset: number): {
423 readonly serverPort: number;
424 readonly webPort: number;
425} {
426 return {
427 serverPort: BASE_SERVER_PORT + offset,
428 webPort: BASE_WEB_PORT + offset,
429 };
430}
431
432export function checkPortAvailabilityOnHosts<R>(
433 port: number,
434 hosts: ReadonlyArray<string>,
435 canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>,
436): Effect.Effect<boolean, never, R> {
437 return Effect.gen(function* () {
438 for (const host of hosts) {
439 if (!(yield* canListenOnHost(port, host))) {
440 return false;
441 }
442 }
443
444 return true;
445 });
446}
447
448/**
449 * Hosts to probe for a dev server bound to `configuredHost`.
450 *
451 * Loopback is always checked because the web server and the desktop renderer
452 * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto
453 * another interface, that interface decides whether the bind actually
454 * succeeds — probing only loopback would hand back a port that is free here
455 * and taken there, and the server would fail to start.
456 *
457 * `configuredHost` applies to the *backend* only. Vite takes its bind address
458 * from `HOST`, which the runner sets for desktop alone, so the web port stays
459 * on loopback and must not be judged against the backend's interface —
460 * a port free on loopback but busy on that interface would otherwise be
461 * rejected for a server that was never going to bind there.
462 */
463export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray<string> {
464 const host = configuredHost?.trim();
465 if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) {
466 return DEV_PORT_PROBE_HOSTS;
467 }
468 return [...DEV_PORT_PROBE_HOSTS, host];
469}
470
471const makeDefaultCheckPortAvailability =
472 (configuredHost: string | undefined): PortAvailabilityCheck<NetService.NetService> =>
473 (port, role) =>
474 Effect.gen(function* () {
475 const net = yield* NetService.NetService;
476 const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost);
477 return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) =>
478 net.canListenOnHost(candidatePort, host),
479 );
480 });
481
482const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined);
483
484interface FindFirstAvailableOffsetInput<R = NetService.NetService> {
485 readonly startOffset: number;
486 readonly requireServerPort: boolean;
487 readonly requireWebPort: boolean;
488 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
489}
490
491export function findFirstAvailableOffset<R = NetService.NetService>({
492 startOffset,
493 requireServerPort,
494 requireWebPort,
495 checkPortAvailability,
496}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerPortExhaustedError, R> {
497 return Effect.gen(function* () {
498 const checkPort = (checkPortAvailability ??
499 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
500
501 for (let candidate = startOffset; ; candidate += 1) {
502 const { serverPort, webPort } = portPairForOffset(candidate);
503 const serverPortOutOfRange = serverPort > MAX_PORT;
504 const webPortOutOfRange = webPort > MAX_PORT;
505
506 if (
507 (requireServerPort && serverPortOutOfRange) ||
508 (requireWebPort && webPortOutOfRange) ||
509 (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange))
510 ) {
511 break;
512 }
513
514 if (requireWebPort && !isBrowserAllowedPort(webPort)) {
515 continue;
516 }
517
518 const checks: Array<Effect.Effect<boolean, never, R>> = [];
519 if (requireServerPort) {
520 checks.push(checkPort(serverPort, "server"));
521 }
522 if (requireWebPort) {
523 checks.push(checkPort(webPort, "web"));
524 }
525
526 if (checks.length === 0) {
527 return candidate;
528 }
529
530 const availability = yield* Effect.all(checks);
531 if (availability.every(Boolean)) {
532 return candidate;
533 }
534 }
535
536 return yield* new DevRunnerPortExhaustedError({
537 startOffset,
538 requireServerPort,
539 requireWebPort,
540 baseServerPort: BASE_SERVER_PORT,
541 baseWebPort: BASE_WEB_PORT,
542 maximumPort: MAX_PORT,
543 });
544 });
545}
546
547interface ResolveModePortOffsetsInput<R = NetService.NetService> {
548 readonly mode: DevMode;
549 readonly startOffset: number;
550 readonly hasExplicitServerPort: boolean;
551 readonly hasExplicitDevUrl: boolean;
552 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
553}
554
555export function resolveModePortOffsets<R = NetService.NetService>({
556 mode,
557 startOffset,
558 hasExplicitServerPort,
559 hasExplicitDevUrl,
560 checkPortAvailability,
561}: ResolveModePortOffsetsInput<R>): Effect.Effect<
562 { readonly serverOffset: number; readonly webOffset: number },
563 DevRunnerPortExhaustedError,
564 R
565> {
566 return Effect.gen(function* () {
567 const checkPort = (checkPortAvailability ??
568 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
569
570 if (mode === "dev:web") {
571 if (hasExplicitDevUrl) {
572 return { serverOffset: startOffset, webOffset: startOffset };
573 }
574
575 const webOffset = yield* findFirstAvailableOffset({
576 startOffset,
577 requireServerPort: false,
578 requireWebPort: true,
579 checkPortAvailability: checkPort,
580 });
581 return { serverOffset: startOffset, webOffset };
582 }
583
584 if (mode === "dev:server") {
585 if (hasExplicitServerPort) {
586 return { serverOffset: startOffset, webOffset: startOffset };
587 }
588
589 const serverOffset = yield* findFirstAvailableOffset({
590 startOffset,
591 requireServerPort: true,
592 requireWebPort: false,
593 checkPortAvailability: checkPort,
594 });
595 return { serverOffset, webOffset: serverOffset };
596 }
597
598 const sharedOffset = yield* findFirstAvailableOffset({
599 startOffset,
600 requireServerPort: !hasExplicitServerPort,
601 requireWebPort: !hasExplicitDevUrl,
602 checkPortAvailability: checkPort,
603 });
604
605 return { serverOffset: sharedOffset, webOffset: sharedOffset };
606 });
607}
608
609interface DevRunnerCliInput {
610 readonly mode: DevMode;
611 readonly t3Home: string | undefined;
612 readonly browser: boolean | undefined;
613 readonly autoBootstrapProjectFromCwd: boolean | undefined;
614 readonly logWebSocketEvents: boolean | undefined;
615 readonly host: string | undefined;
616 readonly port: number | undefined;
617 readonly devUrl: URL | undefined;
618 readonly dryRun: boolean;
619 readonly share: boolean;
620 readonly runArgs: ReadonlyArray<string>;
621}
622
623export function runDevRunnerWithInput(input: DevRunnerCliInput) {
624 return Effect.gen(function* () {
625 const { portOffset, devInstance } = yield* OffsetConfig.pipe(
626 Effect.mapError(
627 (cause) =>
628 new DevRunnerConfigurationError({
629 configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"],
630 cause,
631 }),
632 ),
633 );
634
635 // Single-origin browser dev proxies the backend at localhost. A wildcard
636 // bind still answers there; a specific non-loopback interface does not,
637 // which breaks every proxied request in a way that reads as "server is
638 // broken" rather than "flag combination is unsupported". Reject it up
639 // front instead. (dev:server and dev:desktop don't proxy — untouched.)
640 if (
641 (input.mode === "dev" || input.mode === "dev:web") &&
642 input.host !== undefined &&
643 !isProxiableBindHost(input.host)
644 ) {
645 return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host });
646 }
647
648 const worktreePath = yield* resolveGitWorktreePath(yield* HostProcess.WorkingDirectory);
649
650 const { offset, source } = yield* resolveOffset({
651 portOffset,
652 devInstance,
653 worktreePath,
654 });
655
656 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
657 mode: input.mode,
658 startOffset: offset,
659 hasExplicitServerPort: input.port !== undefined,
660 hasExplicitDevUrl: input.devUrl !== undefined,
661 // A non-loopback bind host decides whether the backend can actually take
662 // the port, so it has to be probed alongside loopback.
663 checkPortAvailability: makeDefaultCheckPortAvailability(input.host),
664 });
665
666 const hostEnvironment = yield* HostProcess.Environment;
667 // A dev server started inside a worktree defaults to that worktree's own
668 // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must
669 // outrank an ambient T3CODE_HOME. `--home-dir` still wins.
670 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory);
671 // Trim before choosing: `--home-dir ""` is not a selection, and treating it
672 // as one would skip the worktree default and land on the shared home —
673 // exactly the outcome this precedence exists to prevent.
674 const resolvedT3Home =
675 (input.t3Home?.trim() || undefined) ??
676 worktreeHome ??
677 (hostEnvironment.T3CODE_HOME?.trim() || undefined);
678 const env = yield* createDevRunnerEnv({
679 mode: input.mode,
680 baseEnv: hostEnvironment,
681 serverOffset,
682 webOffset,
683 t3Home: resolvedT3Home,
684 browser: input.browser,
685 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
686 logWebSocketEvents: input.logWebSocketEvents,
687 host: input.host,
688 port: input.port,
689 devUrl: input.devUrl,
690 });
691
692 const selectionSuffix =
693 serverOffset !== offset || webOffset !== offset
694 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
695 : "";
696 const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME);
697
698 yield* Effect.logInfo(
699 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
700 );
701
702 // Before the share block: --dry-run only resolves and prints. Sharing would
703 // replace, then tear down, whatever mapping the port already had — a
704 // surprising side effect from a command documented as inert.
705 if (input.dryRun) {
706 return;
707 }
708
709 const sharedWebPort = BASE_WEB_PORT + webOffset;
710 if (input.share) {
711 if (input.mode === "dev:server") {
712 yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server).");
713 } else if (input.mode === "dev:desktop") {
714 // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and
715 // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI
716 // and then watch it dial its own 127.0.0.1 for the backend. Worse,
717 // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin
718 // Electron itself loads the renderer from. Refuse rather than hand out
719 // a URL that is broken in a way the user cannot see.
720 yield* Effect.logWarning(
721 "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.",
722 );
723 } else {
724 // acquireRelease, not share-then-addFinalizer: the mapping outlives this
725 // process (and reboots), so the cleanup has to be registered atomically
726 // with creating it. An interrupt landing in between would otherwise
727 // leave a mapping pointing at a port nothing is listening on.
728 //
729 // Deliberately no ownership tracking beyond that: if a second runner
730 // takes this port during a fast restart, the first's exit can briefly
731 // tear down the new mapping — visible (the URL stops working) and fixed
732 // by re-running --share. A lease protocol closing that window existed
733 // and was removed as more machinery than a dev convenience warrants.
734 //
735 // A tailnet that isn't up shouldn't stop the dev server from starting —
736 // warn, and carry on serving locally.
737 const shared = yield* Effect.acquireRelease(
738 shareDevServer({ webPort: sharedWebPort }),
739 () =>
740 // Serve config outlives this process, so a cleanup that did not
741 // take leaves a tailnet URL pointing at a port nothing serves.
742 unshareDevServer(sharedWebPort).pipe(
743 Effect.flatMap((result) =>
744 result.cleared
745 ? Effect.void
746 : Effect.logWarning(
747 `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${
748 result.explanation ? `: ${result.explanation}` : ""
749 }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`,
750 ),
751 ),
752 ),
753 ).pipe(
754 Effect.tapError((error: DevShareError) =>
755 Effect.logWarning(
756 `[dev-runner] could not share on the tailnet: ${error.message}${
757 error.hint ? ` — ${error.hint}` : ""
758 }`,
759 ),
760 ),
761 Effect.option,
762 Effect.map(Option.getOrUndefined),
763 );
764
765 if (shared) {
766 // The app is reached from the tailnet origin. Vite already allows
767 // *.ts.net hosts; the backend needs the origin for credentialed
768 // requests that bypass the proxy (desktop renderer, direct calls).
769 env.T3CODE_DEV_ALLOWED_ORIGINS = [
770 env.T3CODE_DEV_ALLOWED_ORIGINS,
771 new URL(shared.url).origin,
772 ]
773 .filter((entry) => entry && entry.length > 0)
774 .join(",");
775 // The server builds its pairing URL from this, so the URL printed at
776 // startup is already the shareable one — no rewriting by hand. An
777 // explicit --dev-url still wins.
778 if (input.devUrl === undefined) {
779 env.VITE_DEV_SERVER_URL = shared.url;
780 }
781 // A shared origin serves a remote browser, where unbundled dev's
782 // per-module requests each pay a tailnet round trip — a cold module
783 // graph takes minutes to first paint. Bundled dev collapses that to
784 // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0
785 // still opts a --share run back out.
786 if (env.T3CODE_BUNDLED_DEV === undefined) {
787 env.T3CODE_BUNDLED_DEV = "1";
788 }
789 yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`);
790 }
791 }
792 }
793
794 const args = [...MODE_ARGS[input.mode], ...input.runArgs];
795 const spawnCommand = yield* resolveSpawnCommand("vp", args, { env });
796 const processContext = {
797 mode: input.mode,
798 executable: "vp" as const,
799 argumentCount: args.length,
800 shell: spawnCommand.shell,
801 } as const;
802 const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, {
803 stdin: "inherit",
804 stdout: "inherit",
805 stderr: "inherit",
806 env,
807 extendEnv: false,
808 shell: spawnCommand.shell,
809 // Keep Vite+ in the same process group so terminal signals (Ctrl+C)
810 // reach it directly. Effect defaults to detached: true on non-Windows,
811 // which would put the runner in a new group and require manual forwarding.
812 detached: false,
813 forceKillAfter: "1500 millis",
814 }).pipe(
815 Effect.mapError(
816 (cause) =>
817 new DevRunnerProcessError({
818 ...processContext,
819 operation: "spawn",
820 cause,
821 }),
822 ),
823 );
824
825 const exitCode = yield* child.exitCode.pipe(
826 Effect.mapError(
827 (cause) =>
828 new DevRunnerProcessError({
829 ...processContext,
830 operation: "wait-for-exit",
831 cause,
832 }),
833 ),
834 );
835 if (exitCode !== 0) {
836 return yield* new DevRunnerProcessExitError({
837 ...processContext,
838 exitCode,
839 });
840 }
841 });
842}
843
844const devRunnerCli = Command.make("dev-runner", {
845 mode: Argument.Literals("mode", DEV_RUNNER_MODES).pipe(
846 Argument.withDescription("Development mode to run."),
847 ),
848 t3Home: Flag.String("home-dir").pipe(
849 Flag.withDescription(
850 "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.",
851 ),
852 Flag.optional,
853 Flag.map(Option.getOrUndefined),
854 ),
855 browser: Flag.Boolean("browser").pipe(
856 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),
857 Flag.withDefault(false),
858 ),
859 autoBootstrapProjectFromCwd: Flag.Boolean("auto-bootstrap-project-from-cwd").pipe(
860 Flag.withDescription(
861 "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).",
862 ),
863 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")),
864 ),
865 logWebSocketEvents: Flag.Boolean("log-websocket-events").pipe(
866 Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."),
867 Flag.withAlias("log-ws-events"),
868 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")),
869 ),
870 host: Flag.String("host").pipe(
871 Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."),
872 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")),
873 ),
874 port: Flag.Int("port").pipe(
875 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
876 Flag.withDescription("Server port override (forwards to T3CODE_PORT)."),
877 Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")),
878 ),
879 devUrl: Flag.String("dev-url").pipe(
880 Flag.withSchema(Schema.URLFromString),
881 Flag.withDescription(
882 "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.",
883 ),
884 Flag.optional,
885 Flag.map(Option.getOrUndefined),
886 ),
887 dryRun: Flag.Boolean("dry-run").pipe(
888 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),
889 Flag.withDefault(false),
890 ),
891 share: Flag.Boolean("share").pipe(
892 Flag.withDescription(
893 "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.",
894 ),
895 Flag.withDefault(false),
896 ),
897 runArgs: Argument.String("run-arg").pipe(
898 Argument.withDescription("Additional Vite+ run args (pass after `--`)."),
899 Argument.variadic(),
900 ),
901}).pipe(
902 Command.withDescription("Run monorepo development modes with deterministic port/env wiring."),
903 Command.withHandler((input) => runDevRunnerWithInput(input)),
904);
905
906const cliRuntimeLayer = Layer.mergeAll(
907 Logger.layer([Logger.consolePretty()]),
908 NodeServices.layer,
909 NetService.layer,
910);
911
912if (import.meta.main) {
913 Command.run(devRunnerCli, { version: "0.0.0" }).pipe(
914 Effect.scoped,
915 Effect.provide(cliRuntimeLayer),
916 NodeRuntime.runMain,
917 );
918}