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", () => {
6767 }),
6868 );
6969 });
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 });
70113});
apps/server/src/vcs/VcsProjectConfig.ts+19-29
......@@ -2,10 +2,12 @@ import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
56import * as Path from "effect/Path";
67import * as Schema from "effect/Schema";
78
89import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";
10import { fromLenientJson } from "@t3tools/shared/schemaJson";
911
1012const ProjectVcsConfig = Schema.Struct({
1113 vcs: Schema.optional(
......@@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({
1517 ),
1618 vcsKind: Schema.optional(VcsDriverKind),
1719});
18const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
20const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
21const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
1922
20interface ProjectVcsConfigFile {
21 readonly vcs?:
22 | {
23 readonly kind?: VcsDriverKindType | undefined;
24 }
25 | undefined;
26 readonly vcsKind?: VcsDriverKindType | undefined;
27}
23type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
2824
2925export interface VcsProjectConfigResolveInput {
3026 readonly cwd: string;
......@@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
4541 return config.vcs?.kind ?? config.vcsKind ?? "auto";
4642}
4743
48function parseConfig(raw: string): ProjectVcsConfigFile | null {
49 try {
50 const parsed = JSON.parse(raw) as unknown;
51 return isProjectVcsConfig(parsed) ? parsed : null;
52 } catch {
53 return null;
54 }
55}
44const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
45 decodeProjectVcsConfigJson(raw);
5646
5747export const make = Effect.fn("makeVcsProjectConfig")(function* () {
5848 const fileSystem = yield* FileSystem.FileSystem;
......@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
6353 while (true) {
6454 const candidate = path.join(current, ".t3code", "vcs.json");
6555 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
66 return candidate;
56 return Option.some(candidate);
6757 }
6858
6959 const parent = path.dirname(current);
7060 if (parent === current) {
71 return null;
61 return Option.none();
7262 }
7363 current = parent;
7464 }
......@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
7868 configPath: string,
7969 ) {
8070 const raw = yield* fileSystem.readFileString(configPath).pipe(
71 Effect.map(Option.some),
8172 Effect.catch((error) =>
8273 Effect.logWarning("failed to read VCS project config", {
8374 configPath,
8475 error,
85 }).pipe(Effect.as(null)),
76 }).pipe(Effect.as(Option.none())),
8677 ),
8778 );
88 if (raw === null) {
79 if (Option.isNone(raw)) {
8980 return "auto" as const;
9081 }
9182
92 const parsed = parseConfig(raw);
93 if (parsed === null) {
83 const parsed = parseConfig(raw.value);
84 if (Option.isNone(parsed)) {
9485 yield* Effect.logWarning("invalid VCS project config", {
9586 configPath,
9687 });
9788 return "auto" as const;
9889 }
9990
100 return configuredKind(parsed);
91 return configuredKind(parsed.value);
10192 });
10293
10394 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
......@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
10899 }
109100
110101 const configPath = yield* findConfigPath(input.cwd);
111 if (configPath === null) {
112 return "auto";
113 }
114
115 return yield* readConfiguredKind(configPath);
102 return yield* Option.match(configPath, {
103 onNone: () => Effect.succeed("auto" as const),
104 onSome: readConfiguredKind,
105 });
116106 });
117107
118108 return VcsProjectConfig.of({
packages/tailscale/src/tailscale.test.ts+42
......@@ -1,8 +1,10 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
34import * as Layer from "effect/Layer";
45import * as Sink from "effect/Sink";
56import * as Stream from "effect/Stream";
7import * as TestClock from "effect/testing/TestClock";
68import { ChildProcessSpawner } from "effect/unstable/process";
79
810import {
......@@ -13,6 +15,7 @@ import {
1315 parseTailscaleMagicDnsName,
1416 parseTailscaleStatus,
1517 readTailscaleStatus,
18 TAILSCALE_STATUS_TIMEOUT,
1619} from "./tailscale.ts";
1720
1821const encoder = new TextEncoder();
......@@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number })
3538 });
3639}
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
3857function mockSpawnerLayer(
3958 handler: (
4059 command: string,
......@@ -112,6 +131,29 @@ describe("tailscale", () => {
112131 });
113132 });
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
115157 it.effect("configures tailscale serve through the process spawner service", () => {
116158 const layer = mockSpawnerLayer((command, args) => {
117159 assert.equal(command, "tailscale");
packages/tailscale/src/tailscale.ts+11-10
......@@ -1,5 +1,6 @@
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
22import * as Data from "effect/Data";
3import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
45import * as Option from "effect/Option";
56import * as Schema from "effect/Schema";
......@@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
89import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
910
1011export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
11export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
12export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
13export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
12export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
13export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
14export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
1415
1516// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so
1617// it is always spawned directly rather than through cmd.exe shell mode.
......@@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect<
180181 return yield* parseTailscaleStatus(stdout);
181182}).pipe(
182183 Effect.scoped,
183 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),
184 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
184185 Effect.flatMap((result) =>
185186 Option.match(result, {
186187 onNone: () =>
......@@ -212,7 +213,7 @@ const runTailscaleCommand = (
212213 readonly runMessage: string;
213214 readonly exitMessage: (exitCode: number) => string;
214215 readonly timeoutMessage: string;
215 readonly timeoutMs: number;
216 readonly timeout: Duration.Input;
216217 },
217218): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
218219 Effect.gen(function* () {
......@@ -246,7 +247,7 @@ const runTailscaleCommand = (
246247 }
247248 }).pipe(
248249 Effect.scoped,
249 Effect.timeoutOption(input.timeoutMs),
250 Effect.timeoutOption(input.timeout),
250251 Effect.flatMap((result) =>
251252 Option.match(result, {
252253 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
......@@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: {
268269 runMessage: "Failed to run tailscale serve.",
269270 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
270271 timeoutMessage: "Tailscale serve timed out.",
271 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
272 timeout: TAILSCALE_SERVE_TIMEOUT,
272273 });
273274};
274275
......@@ -284,13 +285,13 @@ export const disableTailscaleServe = (
284285 runMessage: "Failed to run tailscale serve off.",
285286 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
286287 timeoutMessage: "Tailscale serve off timed out.",
287 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
288 timeout: TAILSCALE_SERVE_TIMEOUT,
288289 });
289290 });
290291
291292export const probeTailscaleHttpsEndpoint = (input: {
292293 readonly baseUrl: string;
293 readonly timeoutMs?: number;
294 readonly timeout?: Duration.Input;
294295}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
295296 Effect.gen(function* () {
296297 const client = yield* HttpClient.HttpClient;
......@@ -298,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: {
298299 const url = new URL("/.well-known/t3/environment", input.baseUrl);
299300 const request = HttpClientRequest.get(url.toString());
300301 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
303304 return Option.match(response, {
304305 onNone: () => false,