| author | |
| committer | |
| log | a4446e263d77bd5c32ce6bb4921d6464000765de |
| tree | e43bf38bf4a5de0b88162f68b559fb367f7daa93 |
| parent | 494350cc0b8b85702d1207cd96912c8325588a63 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | |
| 5 | 6 | import * as Path from "effect/Path"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | |
| 8 | 9 | import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; |
| 10 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 9 | 11 | |
| 10 | 12 | const ProjectVcsConfig = Schema.Struct({ |
| 11 | 13 | vcs: Schema.optional( |
| ... | ... | @@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({ |
| 15 | 17 | ), |
| 16 | 18 | vcsKind: Schema.optional(VcsDriverKind), |
| 17 | 19 | }); |
| 18 | const isProjectVcsConfig = Schema.is(ProjectVcsConfig); | |
| 20 | const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); | |
| 21 | const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); | |
| 19 | 22 | |
| 20 | interface ProjectVcsConfigFile { | |
| 21 | readonly vcs?: | |
| 22 | | { | |
| 23 | readonly kind?: VcsDriverKindType | undefined; | |
| 24 | } | |
| 25 | | undefined; | |
| 26 | readonly vcsKind?: VcsDriverKindType | undefined; | |
| 27 | } | |
| 23 | type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; | |
| 28 | 24 | |
| 29 | 25 | export interface VcsProjectConfigResolveInput { |
| 30 | 26 | readonly cwd: string; |
| ... | ... | @@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto |
| 45 | 41 | return config.vcs?.kind ?? config.vcsKind ?? "auto"; |
| 46 | 42 | } |
| 47 | 43 | |
| 48 | function 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 | } | |
| 44 | const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> => | |
| 45 | decodeProjectVcsConfigJson(raw); | |
| 56 | 46 | |
| 57 | 47 | export const make = Effect.fn("makeVcsProjectConfig")(function* () { |
| 58 | 48 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { |
| 63 | 53 | while (true) { |
| 64 | 54 | const candidate = path.join(current, ".t3code", "vcs.json"); |
| 65 | 55 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { |
| 66 | return candidate; | |
| 56 | return Option.some(candidate); | |
| 67 | 57 | } |
| 68 | 58 | |
| 69 | 59 | const parent = path.dirname(current); |
| 70 | 60 | if (parent === current) { |
| 71 | return null; | |
| 61 | return Option.none(); | |
| 72 | 62 | } |
| 73 | 63 | current = parent; |
| 74 | 64 | } |
| ... | ... | @@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { |
| 78 | 68 | configPath: string, |
| 79 | 69 | ) { |
| 80 | 70 | const raw = yield* fileSystem.readFileString(configPath).pipe( |
| 71 | Effect.map(Option.some), | |
| 81 | 72 | Effect.catch((error) => |
| 82 | 73 | Effect.logWarning("failed to read VCS project config", { |
| 83 | 74 | configPath, |
| 84 | 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 | 80 | return "auto" as const; |
| 90 | 81 | } |
| 91 | 82 | |
| 92 | const parsed = parseConfig(raw); | |
| 93 | if (parsed === null) { | |
| 83 | const parsed = parseConfig(raw.value); | |
| 84 | if (Option.isNone(parsed)) { | |
| 94 | 85 | yield* Effect.logWarning("invalid VCS project config", { |
| 95 | 86 | configPath, |
| 96 | 87 | }); |
| 97 | 88 | return "auto" as const; |
| 98 | 89 | } |
| 99 | 90 | |
| 100 | return configuredKind(parsed); | |
| 91 | return configuredKind(parsed.value); | |
| 101 | 92 | }); |
| 102 | 93 | |
| 103 | 94 | const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( |
| ... | ... | @@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { |
| 108 | 99 | } |
| 109 | 100 | |
| 110 | 101 | 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 | }); | |
| 116 | 106 | }); |
| 117 | 107 | |
| 118 | 108 | return VcsProjectConfig.of({ |
packages/tailscale/src/tailscale.test.ts+42| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; | |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 4 | 5 | import * as Sink from "effect/Sink"; |
| 5 | 6 | import * as Stream from "effect/Stream"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 6 | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 9 | |
| 8 | 10 | import { |
| ... | ... | @@ -13,6 +15,7 @@ import { |
| 13 | 15 | parseTailscaleMagicDnsName, |
| 14 | 16 | parseTailscaleStatus, |
| 15 | 17 | readTailscaleStatus, |
| 18 | TAILSCALE_STATUS_TIMEOUT, | |
| 16 | 19 | } from "./tailscale.ts"; |
| 17 | 20 | |
| 18 | 21 | const encoder = new TextEncoder(); |
| ... | ... | @@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) |
| 35 | 38 | }); |
| 36 | 39 | } |
| 37 | 40 | |
| 41 | function 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 | ||
| 38 | 57 | function mockSpawnerLayer( |
| 39 | 58 | handler: ( |
| 40 | 59 | command: string, |
| ... | ... | @@ -112,6 +131,29 @@ describe("tailscale", () => { |
| 112 | 131 | }); |
| 113 | 132 | }); |
| 114 | 133 | |
| 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 | 157 | it.effect("configures tailscale serve through the process spawner service", () => { |
| 116 | 158 | const layer = mockSpawnerLayer((command, args) => { |
| 117 | 159 | assert.equal(command, "tailscale"); |
packages/tailscale/src/tailscale.ts+11-10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 2 | 2 | import * as Data from "effect/Data"; |
| 3 | import * as Duration from "effect/Duration"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Option from "effect/Option"; |
| 5 | 6 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 8 | 9 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 9 | 10 | |
| 10 | 11 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 11 | export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; | |
| 12 | export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; | |
| 13 | export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; | |
| 12 | export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); | |
| 13 | export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); | |
| 14 | export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); | |
| 14 | 15 | |
| 15 | 16 | // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so |
| 16 | 17 | // it is always spawned directly rather than through cmd.exe shell mode. |
| ... | ... | @@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect< |
| 180 | 181 | return yield* parseTailscaleStatus(stdout); |
| 181 | 182 | }).pipe( |
| 182 | 183 | Effect.scoped, |
| 183 | Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), | |
| 184 | Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), | |
| 184 | 185 | Effect.flatMap((result) => |
| 185 | 186 | Option.match(result, { |
| 186 | 187 | onNone: () => |
| ... | ... | @@ -212,7 +213,7 @@ const runTailscaleCommand = ( |
| 212 | 213 | readonly runMessage: string; |
| 213 | 214 | readonly exitMessage: (exitCode: number) => string; |
| 214 | 215 | readonly timeoutMessage: string; |
| 215 | readonly timeoutMs: number; | |
| 216 | readonly timeout: Duration.Input; | |
| 216 | 217 | }, |
| 217 | 218 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 218 | 219 | Effect.gen(function* () { |
| ... | ... | @@ -246,7 +247,7 @@ const runTailscaleCommand = ( |
| 246 | 247 | } |
| 247 | 248 | }).pipe( |
| 248 | 249 | Effect.scoped, |
| 249 | Effect.timeoutOption(input.timeoutMs), | |
| 250 | Effect.timeoutOption(input.timeout), | |
| 250 | 251 | Effect.flatMap((result) => |
| 251 | 252 | Option.match(result, { |
| 252 | 253 | onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), |
| ... | ... | @@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: { |
| 268 | 269 | runMessage: "Failed to run tailscale serve.", |
| 269 | 270 | exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, |
| 270 | 271 | timeoutMessage: "Tailscale serve timed out.", |
| 271 | timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | |
| 272 | timeout: TAILSCALE_SERVE_TIMEOUT, | |
| 272 | 273 | }); |
| 273 | 274 | }; |
| 274 | 275 | |
| ... | ... | @@ -284,13 +285,13 @@ export const disableTailscaleServe = ( |
| 284 | 285 | runMessage: "Failed to run tailscale serve off.", |
| 285 | 286 | exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, |
| 286 | 287 | timeoutMessage: "Tailscale serve off timed out.", |
| 287 | timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | |
| 288 | timeout: TAILSCALE_SERVE_TIMEOUT, | |
| 288 | 289 | }); |
| 289 | 290 | }); |
| 290 | 291 | |
| 291 | 292 | export const probeTailscaleHttpsEndpoint = (input: { |
| 292 | 293 | readonly baseUrl: string; |
| 293 | readonly timeoutMs?: number; | |
| 294 | readonly timeout?: Duration.Input; | |
| 294 | 295 | }): Effect.Effect<boolean, never, HttpClient.HttpClient> => |
| 295 | 296 | Effect.gen(function* () { |
| 296 | 297 | const client = yield* HttpClient.HttpClient; |
| ... | ... | @@ -298,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: { |
| 298 | 299 | const url = new URL("/.well-known/t3/environment", input.baseUrl); |
| 299 | 300 | const request = HttpClientRequest.get(url.toString()); |
| 300 | 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)); | |
| 302 | 303 | |
| 303 | 304 | return Option.match(response, { |
| 304 | 305 | onNone: () => false, |