authorgravatar for 206951365+cursor[bot]@users.noreply.github.comcursor[bot] <206951365+cursor[bot]@users.noreply.github.com> 2026-06-19 11:21:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 11:21:39-07:00
loga4446e263d77bd5c32ce6bb4921d6464000765de
treee43bf38bf4a5de0b88162f68b559fb367f7daa93
parent494350cc0b8b85702d1207cd96912c8325588a63
signature Signed by PGP key B5690EEEBB952194

Improve idiomatic Effect usage in config and Tailscale paths (#3073)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 115 insertions(+), 39 deletions(-)

apps/server/src/vcs/VcsProjectConfig.test.ts+43
...@@ -67,4 +67,47 @@ describe("VcsProjectConfig", () => {...@@ -67,4 +67,47 @@ describe("VcsProjectConfig", () => {
67 }),67 }),
68 );68 );
69 });69 });
70
71 it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
72 it.effect("returns auto", () =>
73 Effect.gen(function* () {
74 const fileSystem = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 const root = yield* fileSystem.makeTempDirectoryScoped({
77 prefix: "t3-vcs-config-test-",
78 });
79 const configDir = path.join(root, ".t3code");
80 yield* fileSystem.makeDirectory(configDir, { recursive: true });
81 yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json");
82
83 const config = yield* VcsProjectConfig.VcsProjectConfig;
84 const kind = yield* config.resolveKind({ cwd: root });
85
86 assert.equal(kind, "auto");
87 }),
88 );
89 });
90
91 it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {
92 it.effect("returns auto", () =>
93 Effect.gen(function* () {
94 const fileSystem = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const root = yield* fileSystem.makeTempDirectoryScoped({
97 prefix: "t3-vcs-config-test-",
98 });
99 const configDir = path.join(root, ".t3code");
100 yield* fileSystem.makeDirectory(configDir, { recursive: true });
101 yield* fileSystem.writeFileString(
102 path.join(configDir, "vcs.json"),
103 `{"vcs":{"kind":"svn"}}`,
104 );
105
106 const config = yield* VcsProjectConfig.VcsProjectConfig;
107 const kind = yield* config.resolveKind({ cwd: root });
108
109 assert.equal(kind, "auto");
110 }),
111 );
112 });
70});113});
apps/server/src/vcs/VcsProjectConfig.ts+19-29
...@@ -2,10 +2,12 @@ import * as Context from "effect/Context";...@@ -2,10 +2,12 @@ import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
5import * as Path from "effect/Path";6import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
78
8import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";9import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";
10import { fromLenientJson } from "@t3tools/shared/schemaJson";
911
10const ProjectVcsConfig = Schema.Struct({12const ProjectVcsConfig = Schema.Struct({
11 vcs: Schema.optional(13 vcs: Schema.optional(
...@@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({...@@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({
15 ),17 ),
16 vcsKind: Schema.optional(VcsDriverKind),18 vcsKind: Schema.optional(VcsDriverKind),
17});19});
18const isProjectVcsConfig = Schema.is(ProjectVcsConfig);20const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
21const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
1922
20interface ProjectVcsConfigFile {23type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
21 readonly vcs?:
22 | {
23 readonly kind?: VcsDriverKindType | undefined;
24 }
25 | undefined;
26 readonly vcsKind?: VcsDriverKindType | undefined;
27}
2824
29export interface VcsProjectConfigResolveInput {25export interface VcsProjectConfigResolveInput {
30 readonly cwd: string;26 readonly cwd: string;
...@@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto...@@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
45 return config.vcs?.kind ?? config.vcsKind ?? "auto";41 return config.vcs?.kind ?? config.vcsKind ?? "auto";
46}42}
4743
48function parseConfig(raw: string): ProjectVcsConfigFile | null {44const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
49 try {45 decodeProjectVcsConfigJson(raw);
50 const parsed = JSON.parse(raw) as unknown;
51 return isProjectVcsConfig(parsed) ? parsed : null;
52 } catch {
53 return null;
54 }
55}
5646
57export const make = Effect.fn("makeVcsProjectConfig")(function* () {47export const make = Effect.fn("makeVcsProjectConfig")(function* () {
58 const fileSystem = yield* FileSystem.FileSystem;48 const fileSystem = yield* FileSystem.FileSystem;
...@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {...@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
63 while (true) {53 while (true) {
64 const candidate = path.join(current, ".t3code", "vcs.json");54 const candidate = path.join(current, ".t3code", "vcs.json");
65 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {55 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
66 return candidate;56 return Option.some(candidate);
67 }57 }
6858
69 const parent = path.dirname(current);59 const parent = path.dirname(current);
70 if (parent === current) {60 if (parent === current) {
71 return null;61 return Option.none();
72 }62 }
73 current = parent;63 current = parent;
74 }64 }
...@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {...@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
78 configPath: string,68 configPath: string,
79 ) {69 ) {
80 const raw = yield* fileSystem.readFileString(configPath).pipe(70 const raw = yield* fileSystem.readFileString(configPath).pipe(
71 Effect.map(Option.some),
81 Effect.catch((error) =>72 Effect.catch((error) =>
82 Effect.logWarning("failed to read VCS project config", {73 Effect.logWarning("failed to read VCS project config", {
83 configPath,74 configPath,
84 error,75 error,
85 }).pipe(Effect.as(null)),76 }).pipe(Effect.as(Option.none())),
86 ),77 ),
87 );78 );
88 if (raw === null) {79 if (Option.isNone(raw)) {
89 return "auto" as const;80 return "auto" as const;
90 }81 }
9182
92 const parsed = parseConfig(raw);83 const parsed = parseConfig(raw.value);
93 if (parsed === null) {84 if (Option.isNone(parsed)) {
94 yield* Effect.logWarning("invalid VCS project config", {85 yield* Effect.logWarning("invalid VCS project config", {
95 configPath,86 configPath,
96 });87 });
97 return "auto" as const;88 return "auto" as const;
98 }89 }
9990
100 return configuredKind(parsed);91 return configuredKind(parsed.value);
101 });92 });
10293
103 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(94 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
...@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {...@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
108 }99 }
109100
110 const configPath = yield* findConfigPath(input.cwd);101 const configPath = yield* findConfigPath(input.cwd);
111 if (configPath === null) {102 return yield* Option.match(configPath, {
112 return "auto";103 onNone: () => Effect.succeed("auto" as const),
113 }104 onSome: readConfiguredKind,
114105 });
115 return yield* readConfiguredKind(configPath);
116 });106 });
117107
118 return VcsProjectConfig.of({108 return VcsProjectConfig.of({
packages/tailscale/src/tailscale.test.ts+42
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";5import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";6import * as Stream from "effect/Stream";
7import * as TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
79
8import {10import {
...@@ -13,6 +15,7 @@ import {...@@ -13,6 +15,7 @@ import {
13 parseTailscaleMagicDnsName,15 parseTailscaleMagicDnsName,
14 parseTailscaleStatus,16 parseTailscaleStatus,
15 readTailscaleStatus,17 readTailscaleStatus,
18 TAILSCALE_STATUS_TIMEOUT,
16} from "./tailscale.ts";19} from "./tailscale.ts";
1720
18const encoder = new TextEncoder();21const encoder = new TextEncoder();
...@@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number })...@@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number })
35 });38 });
36}39}
3740
41function neverFinishingMockHandle() {
42 return ChildProcessSpawner.makeHandle({
43 pid: ChildProcessSpawner.ProcessId(1),
44 exitCode: Effect.never,
45 isRunning: Effect.succeed(true),
46 kill: () => Effect.void,
47 unref: Effect.succeed(Effect.void),
48 stdin: Sink.drain,
49 stdout: Stream.empty,
50 stderr: Stream.empty,
51 all: Stream.empty,
52 getInputFd: () => Sink.drain,
53 getOutputFd: () => Stream.empty,
54 });
55}
56
38function mockSpawnerLayer(57function mockSpawnerLayer(
39 handler: (58 handler: (
40 command: string,59 command: string,
...@@ -112,6 +131,29 @@ describe("tailscale", () => {...@@ -112,6 +131,29 @@ describe("tailscale", () => {
112 });131 });
113 });132 });
114133
134 it.effect("times out tailscale status through TestClock", () => {
135 const layer = Layer.merge(
136 TestClock.layer(),
137 Layer.succeed(
138 ChildProcessSpawner.ChildProcessSpawner,
139 ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle())),
140 ),
141 );
142
143 return Effect.gen(function* () {
144 const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped);
145 yield* Effect.yieldNow;
146 yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT);
147 const error = yield* Fiber.join(fiber);
148
149 if (error._tag !== "TailscaleCommandError") {
150 assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`);
151 }
152 assert.equal(error.message, "Tailscale status timed out.");
153 assert.equal(error.exitCode, null);
154 }).pipe(Effect.provide(layer));
155 });
156
115 it.effect("configures tailscale serve through the process spawner service", () => {157 it.effect("configures tailscale serve through the process spawner service", () => {
116 const layer = mockSpawnerLayer((command, args) => {158 const layer = mockSpawnerLayer((command, args) => {
117 assert.equal(command, "tailscale");159 assert.equal(command, "tailscale");
packages/tailscale/src/tailscale.ts+11-10
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Data from "effect/Data";2import * as Data from "effect/Data";
3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";5import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
...@@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";...@@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
910
10export const DEFAULT_TAILSCALE_SERVE_PORT = 443;11export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
11export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;12export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
12export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;13export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
13export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;14export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
1415
15// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so16// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so
16// it is always spawned directly rather than through cmd.exe shell mode.17// it is always spawned directly rather than through cmd.exe shell mode.
...@@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect<...@@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect<
180 return yield* parseTailscaleStatus(stdout);181 return yield* parseTailscaleStatus(stdout);
181}).pipe(182}).pipe(
182 Effect.scoped,183 Effect.scoped,
183 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),184 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
184 Effect.flatMap((result) =>185 Effect.flatMap((result) =>
185 Option.match(result, {186 Option.match(result, {
186 onNone: () =>187 onNone: () =>
...@@ -212,7 +213,7 @@ const runTailscaleCommand = (...@@ -212,7 +213,7 @@ const runTailscaleCommand = (
212 readonly runMessage: string;213 readonly runMessage: string;
213 readonly exitMessage: (exitCode: number) => string;214 readonly exitMessage: (exitCode: number) => string;
214 readonly timeoutMessage: string;215 readonly timeoutMessage: string;
215 readonly timeoutMs: number;216 readonly timeout: Duration.Input;
216 },217 },
217): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>218): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
218 Effect.gen(function* () {219 Effect.gen(function* () {
...@@ -246,7 +247,7 @@ const runTailscaleCommand = (...@@ -246,7 +247,7 @@ const runTailscaleCommand = (
246 }247 }
247 }).pipe(248 }).pipe(
248 Effect.scoped,249 Effect.scoped,
249 Effect.timeoutOption(input.timeoutMs),250 Effect.timeoutOption(input.timeout),
250 Effect.flatMap((result) =>251 Effect.flatMap((result) =>
251 Option.match(result, {252 Option.match(result, {
252 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),253 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
...@@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: {...@@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: {
268 runMessage: "Failed to run tailscale serve.",269 runMessage: "Failed to run tailscale serve.",
269 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,270 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
270 timeoutMessage: "Tailscale serve timed out.",271 timeoutMessage: "Tailscale serve timed out.",
271 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,272 timeout: TAILSCALE_SERVE_TIMEOUT,
272 });273 });
273};274};
274275
...@@ -284,13 +285,13 @@ export const disableTailscaleServe = (...@@ -284,13 +285,13 @@ export const disableTailscaleServe = (
284 runMessage: "Failed to run tailscale serve off.",285 runMessage: "Failed to run tailscale serve off.",
285 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,286 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
286 timeoutMessage: "Tailscale serve off timed out.",287 timeoutMessage: "Tailscale serve off timed out.",
287 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,288 timeout: TAILSCALE_SERVE_TIMEOUT,
288 });289 });
289 });290 });
290291
291export const probeTailscaleHttpsEndpoint = (input: {292export const probeTailscaleHttpsEndpoint = (input: {
292 readonly baseUrl: string;293 readonly baseUrl: string;
293 readonly timeoutMs?: number;294 readonly timeout?: Duration.Input;
294}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>295}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
295 Effect.gen(function* () {296 Effect.gen(function* () {
296 const client = yield* HttpClient.HttpClient;297 const client = yield* HttpClient.HttpClient;
...@@ -298,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: {...@@ -298,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: {
298 const url = new URL("/.well-known/t3/environment", input.baseUrl);299 const url = new URL("/.well-known/t3/environment", input.baseUrl);
299 const request = HttpClientRequest.get(url.toString());300 const request = HttpClientRequest.get(url.toString());
300 return yield* client.execute(request);301 return yield* client.execute(request);
301 }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));302 }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
302303
303 return Option.match(response, {304 return Option.match(response, {
304 onNone: () => false,305 onNone: () => false,