Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +# Optional root-level overrides for the checked-in public client configuration. | |
| 2 | +# Server-side secrets must never be committed here. | |
| 3 | +# | |
| 4 | +# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... | |
| 5 | +# T3_RELAY_URL=https://relay.example.com | |
| @@ -26,6 +26,10 @@ permissions: · 25 unmodified lines | ||
| 26 | 26 | contents: read |
| 27 | 27 | id-token: none |
| 28 | 28 | |
| 29 | +env: | |
| 30 | + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} | |
| 31 | + T3_RELAY_URL: ${{ vars.T3_RELAY_URL }} | |
| 32 | + | |
| 29 | 33 | jobs: |
| 30 | 34 | check_changes: |
| 31 | 35 | name: Check for changes since last nightly |
| @@ -648,6 +652,8 @@ jobs: · 616 unmodified lines | ||
| 648 | 652 | --token "$VERCEL_TOKEN" \ |
| 649 | 653 | "${vercel_scope_args[@]}" \ |
| 650 | 654 | --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ |
| 655 | + --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ | |
| 656 | + --build-env "T3_RELAY_URL=${T3_RELAY_URL:-}" \ | |
| 651 | 657 | --build-env "VITE_HOSTED_APP_URL=$router_url" \ |
| 652 | 658 | --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" |
| 653 | 659 | )" |
| @@ -62,6 +62,11 @@ mise install · 61 unmodified lines | ||
| 62 | 62 | vp install |
| 63 | 63 | ``` |
| 64 | 64 | |
| 65 | +T3 Cloud public client configuration has checked-in development defaults, so a fresh clone works | |
| 66 | +without creating app-local `.env` files. To point web, desktop, and mobile at another Clerk/relay | |
| 67 | +deployment, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the | |
| 68 | +canonical overrides there. | |
| 69 | + | |
| 65 | 70 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. |
| 66 | 71 | |
| 67 | 72 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | setCloudAuthToken, |
| 10 | 10 | } from "./methods/cloudAuth.ts"; |
| 11 | 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 12 | +import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts"; | |
| 12 | 13 | import { |
| 13 | 14 | getSavedEnvironmentRegistry, |
| 14 | 15 | getSavedEnvironmentSecret, |
| @@ -87,6 +88,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 72 unmodified lines | ||
| 87 | 88 | yield* ipc.handle(setCloudAuthToken); |
| 88 | 89 | yield* ipc.handle(clearCloudAuthToken); |
| 89 | 90 | yield* ipc.handle(fetchCloudAuth); |
| 91 | + yield* ipc.handle(getCloudflaredStatus); | |
| 92 | + yield* ipc.handle(installCloudflared); | |
| 90 | 93 | |
| 91 | 94 | yield* ipc.handle(getUpdateState); |
| 92 | 95 | yield* ipc.handle(setUpdateChannel); |
| @@ -8,6 +8,8 @@ export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; · 7 unmodified lines | ||
| 8 | 8 | export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; |
| 9 | 9 | export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; |
| 10 | 10 | export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; |
| 11 | +export const GET_CLOUDFLARED_STATUS_CHANNEL = "desktop:get-cloudflared-status"; | |
| 12 | +export const INSTALL_CLOUDFLARED_CHANNEL = "desktop:install-cloudflared"; | |
| 11 | 13 | export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; |
| 12 | 14 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 13 | 15 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| @@ -0,0 +1,55 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 5 | + | |
| 6 | +import { fetchCloudAuth } from "./cloudAuth.ts"; | |
| 7 | + | |
| 8 | +function makeHttpClientLayer( | |
| 9 | + handler: ( | |
| 10 | + request: HttpClientRequest.HttpClientRequest, | |
| 11 | + ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>, | |
| 12 | +) { | |
| 13 | + return Layer.succeed( | |
| 14 | + HttpClient.HttpClient, | |
| 15 | + HttpClient.make((request) => handler(request)), | |
| 16 | + ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +describe("Desktop cloud auth IPC", () => { | |
| 20 | + it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { | |
| 21 | + const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; | |
| 22 | + let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; | |
| 23 | + const layer = makeHttpClientLayer((request) => | |
| 24 | + Effect.sync(() => { | |
| 25 | + forwardedRequest = request; | |
| 26 | + return HttpClientResponse.fromWeb( | |
| 27 | + request, | |
| 28 | + Response.json({ response: { object: "sign_in_attempt" } }), | |
| 29 | + ); | |
| 30 | + }), | |
| 31 | + ); | |
| 32 | + | |
| 33 | + return Effect.gen(function* () { | |
| 34 | + yield* fetchCloudAuth.handler({ | |
| 35 | + url: "https://example.clerk.accounts.dev/v1/client/sign_ins", | |
| 36 | + method: "POST", | |
| 37 | + headers: { | |
| 38 | + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", | |
| 39 | + "x-mobile": "1", | |
| 40 | + }, | |
| 41 | + body, | |
| 42 | + }); | |
| 43 | + | |
| 44 | + assert(forwardedRequest !== null); | |
| 45 | + assert.equal( | |
| 46 | + forwardedRequest.headers["content-type"], | |
| 47 | + "application/x-www-form-urlencoded;charset=UTF-8", | |
| 48 | + ); | |
| 49 | + assert.equal(forwardedRequest.body._tag, "Uint8Array"); | |
| 50 | + if (forwardedRequest.body._tag === "Uint8Array") { | |
| 51 | + assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body); | |
| 52 | + } | |
| 53 | + }).pipe(Effect.provide(layer)); | |
| 54 | + }); | |
| 55 | +}); | |
| @@ -6,7 +6,7 @@ import * as Data from "effect/Data"; · 5 unmodified lines | ||
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | −import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 9 | +import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 10 | 10 | |
| 11 | 11 | import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; |
| 12 | 12 | import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; |
| @@ -91,11 +91,20 @@ export const fetchCloudAuth = makeIpcMethod({ · 78 unmodified lines | ||
| 91 | 91 | }), |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | − const request = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(url, { | |
| 95 | − headers: input.headers, | |
| 96 | − }).pipe( | |
| 97 | − input.body === undefined ? (request) => request : HttpClientRequest.bodyText(input.body), | |
| 94 | + const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")( | |
| 95 | + url, | |
| 96 | + { | |
| 97 | + headers: input.headers, | |
| 98 | + }, | |
| 98 | 99 | ); |
| 100 | + const request = | |
| 101 | + input.body === undefined | |
| 102 | + ? requestWithoutBody | |
| 103 | + : HttpClientRequest.bodyText( | |
| 104 | + requestWithoutBody, | |
| 105 | + input.body, | |
| 106 | + Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")), | |
| 107 | + ); | |
| 99 | 108 | |
| 100 | 109 | const response = yield* HttpClient.execute(request).pipe( |
| 101 | 110 | Effect.mapError( |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | + | |
| 6 | +import { getCloudflaredStatus, installCloudflared } from "./cloudflared.ts"; | |
| 7 | + | |
| 8 | +const available = { | |
| 9 | + status: "available", | |
| 10 | + executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", | |
| 11 | + source: "managed", | |
| 12 | + version: Cloudflared.CLOUDFLARED_VERSION, | |
| 13 | +} as const; | |
| 14 | + | |
| 15 | +describe("Desktop cloudflared IPC", () => { | |
| 16 | + it.effect("reads status and delegates installation to the shared manager", () => | |
| 17 | + Effect.gen(function* () { | |
| 18 | + const installed: Array<boolean> = []; | |
| 19 | + const layer = Layer.succeed( | |
| 20 | + Cloudflared.CloudflaredExecutable, | |
| 21 | + Cloudflared.CloudflaredExecutable.of({ | |
| 22 | + resolve: Effect.succeed({ | |
| 23 | + status: "missing", | |
| 24 | + version: Cloudflared.CLOUDFLARED_VERSION, | |
| 25 | + }), | |
| 26 | + install: Effect.sync(() => { | |
| 27 | + installed.push(true); | |
| 28 | + return available; | |
| 29 | + }), | |
| 30 | + }), | |
| 31 | + ); | |
| 32 | + | |
| 33 | + expect(yield* getCloudflaredStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({ | |
| 34 | + status: "missing", | |
| 35 | + version: Cloudflared.CLOUDFLARED_VERSION, | |
| 36 | + }); | |
| 37 | + expect(yield* installCloudflared.handler(undefined).pipe(Effect.provide(layer))).toEqual( | |
| 38 | + available, | |
| 39 | + ); | |
| 40 | + expect(installed).toEqual([true]); | |
| 41 | + }), | |
| 42 | + ); | |
| 43 | +}); | |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import { DesktopCloudflaredStatusSchema } from "@t3tools/contracts"; | |
| 2 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Schema from "effect/Schema"; | |
| 5 | + | |
| 6 | +import * as IpcChannels from "../channels.ts"; | |
| 7 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 8 | + | |
| 9 | +export const getCloudflaredStatus = makeIpcMethod({ | |
| 10 | + channel: IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL, | |
| 11 | + payload: Schema.Undefined, | |
| 12 | + result: DesktopCloudflaredStatusSchema, | |
| 13 | + handler: Effect.fn("desktop.ipc.cloudflared.getStatus")(function* () { | |
| 14 | + const cloudflared = yield* Cloudflared.CloudflaredExecutable; | |
| 15 | + return yield* cloudflared.resolve; | |
| 16 | + }), | |
| 17 | +}); | |
| 18 | + | |
| 19 | +export const installCloudflared = makeIpcMethod({ | |
| 20 | + channel: IpcChannels.INSTALL_CLOUDFLARED_CHANNEL, | |
| 21 | + payload: Schema.Undefined, | |
| 22 | + result: DesktopCloudflaredStatusSchema, | |
| 23 | + handler: Effect.fn("desktop.ipc.cloudflared.install")(function* () { | |
| 24 | + const cloudflared = yield* Cloudflared.CloudflaredExecutable; | |
| 25 | + return yield* cloudflared.install; | |
| 26 | + }), | |
| 27 | +}); | |
| @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; · 8 unmodified lines | ||
| 9 | 9 | import * as Electron from "electron"; |
| 10 | 10 | |
| 11 | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 12 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 12 | 13 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 13 | 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 14 | 15 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| @@ -94,6 +95,17 @@ const desktopSshEnvironmentLayer = Layer.unwrap( · 79 unmodified lines | ||
| 94 | 95 | }), |
| 95 | 96 | ); |
| 96 | 97 | |
| 98 | +const desktopCloudflaredLayer = Layer.unwrap( | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 101 | + return Cloudflared.layer({ | |
| 102 | + baseDir: environment.baseDir, | |
| 103 | + platform: environment.platform, | |
| 104 | + arch: environment.processArch, | |
| 105 | + }); | |
| 106 | + }), | |
| 107 | +); | |
| 108 | + | |
| 97 | 109 | const electronLayer = Layer.mergeAll( |
| 98 | 110 | ElectronApp.layer, |
| 99 | 111 | ElectronDialog.layer, |
| @@ -141,7 +153,11 @@ const desktopApplicationLayer = Layer.mergeAll( · 41 unmodified lines | ||
| 141 | 153 | DesktopCloudAuth.layer, |
| 142 | 154 | DesktopShellEnvironment.layer, |
| 143 | 155 | desktopSshLayer, |
| 144 | −).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); | |
| 156 | +).pipe( | |
| 157 | + Layer.provideMerge(DesktopUpdates.layer), | |
| 158 | + Layer.provideMerge(desktopCloudflaredLayer), | |
| 159 | + Layer.provideMerge(desktopBackendLayer), | |
| 160 | +); | |
| 145 | 161 | |
| 146 | 162 | const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( |
| 147 | 163 | Layer.flatMap(() => |
| @@ -102,6 +102,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 101 unmodified lines | ||
| 102 | 102 | ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), |
| 103 | 103 | clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), |
| 104 | 104 | fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), |
| 105 | + getCloudflaredStatus: () => ipcRenderer.invoke(IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL), | |
| 106 | + installCloudflared: () => ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARED_CHANNEL), | |
| 105 | 107 | onCloudAuthCallback: (listener) => { |
| 106 | 108 | const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { |
| 107 | 109 | if (typeof rawUrl !== "string") return; |
| @@ -16,6 +16,10 @@ This app has three variants: · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | Run commands from `apps/mobile`. |
| 18 | 18 | |
| 19 | +Public T3 Cloud development defaults are shared with web and desktop. Optional overrides belong in | |
| 20 | +the repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See | |
| 21 | +[`../../.env.example`](../../.env.example). | |
| 22 | + | |
| 19 | 23 | ## Development |
| 20 | 24 | |
| 21 | 25 | Start Metro for the dev client: |
| @@ -63,6 +67,9 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. · 41 unmodified lines | ||
| 63 | 67 | |
| 64 | 68 | CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. |
| 65 | 69 | |
| 70 | +For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` | |
| 71 | +as EAS environment variables. Expo config maps the canonical values into the mobile build. | |
| 72 | + | |
| 66 | 73 | Create a PR preview dev-client build manually: |
| 67 | 74 | |
| 68 | 75 | ```bash |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import type { ExpoConfig } from "expo/config"; |
| 2 | 2 | |
| 3 | +import { loadRepoEnv } from "../../scripts/lib/public-config"; | |
| 4 | + | |
| 3 | 5 | type AppVariant = "development" | "preview" | "production"; |
| 4 | 6 | |
| 5 | −const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT); | |
| 7 | +const repoEnv = loadRepoEnv(); | |
| 8 | +Object.assign(process.env, repoEnv); | |
| 9 | + | |
| 10 | +const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); | |
| 6 | 11 | |
| 7 | 12 | const VARIANT_CONFIG: Record< |
| 8 | 13 | AppVariant, |
| @@ -150,10 +155,10 @@ const config: ExpoConfig = { · 141 unmodified lines | ||
| 150 | 155 | extra: { |
| 151 | 156 | appVariant: APP_VARIANT, |
| 152 | 157 | relay: { |
| 153 | − url: process.env.T3_RELAY_URL ?? null, | |
| 158 | + url: repoEnv.T3_RELAY_URL ?? null, | |
| 154 | 159 | }, |
| 155 | 160 | clerk: { |
| 156 | − publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, | |
| 161 | + publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, | |
| 157 | 162 | }, |
| 158 | 163 | eas: { |
| 159 | 164 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", |
| @@ -57,7 +57,7 @@ export default function SettingsWaitlistRouteScreen() { · 56 unmodified lines | ||
| 57 | 57 | textAlign: "center", |
| 58 | 58 | }} |
| 59 | 59 | > |
| 60 | − Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. | |
| 60 | + Add T3CODE_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. | |
| 61 | 61 | </Text> |
| 62 | 62 | </View> |
| 63 | 63 | )} |
| @@ -1,14 +1,36 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { vi } from "vitest"; | |
| 2 | 3 | import * as Deferred from "effect/Deferred"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 5 | +import * as Fiber from "effect/Fiber"; | |
| 4 | 6 | import * as Layer from "effect/Layer"; |
| 5 | 7 | import * as PlatformError from "effect/PlatformError"; |
| 6 | 8 | import * as Sink from "effect/Sink"; |
| 7 | 9 | import * as Stream from "effect/Stream"; |
| 8 | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 9 | 12 | |
| 10 | 13 | import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; |
| 11 | 14 | |
| 15 | +const cloudflaredAvailableLayer = Layer.succeed( | |
| 16 | + Cloudflared.CloudflaredExecutable, | |
| 17 | + Cloudflared.CloudflaredExecutable.of({ | |
| 18 | + resolve: Effect.succeed({ | |
| 19 | + status: "available", | |
| 20 | + executablePath: "cloudflared", | |
| 21 | + source: "path", | |
| 22 | + version: Cloudflared.CLOUDFLARED_VERSION, | |
| 23 | + }), | |
| 24 | + install: Effect.die("unused"), | |
| 25 | + }), | |
| 26 | +); | |
| 27 | + | |
| 28 | +const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) => | |
| 29 | + Layer.mergeAll( | |
| 30 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 31 | + cloudflaredAvailableLayer, | |
| 32 | + ); | |
| 33 | + | |
| 12 | 34 | function makeHandle(input: { |
| 13 | 35 | readonly pid: number; |
| 14 | 36 | readonly onKill: () => void; |
| @@ -58,7 +80,7 @@ describe("CloudManagedEndpointRuntime", () => { · 43 unmodified lines | ||
| 58 | 80 | }), |
| 59 | 81 | ); |
| 60 | 82 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( |
| 61 | − Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 83 | + Effect.provide(runtimeDependencies(spawner)), | |
| 62 | 84 | ); |
| 63 | 85 | |
| 64 | 86 | yield* runtime.applyConfig({ |
| @@ -88,6 +110,7 @@ describe("CloudManagedEndpointRuntime", () => { · 23 unmodified lines | ||
| 88 | 110 | ]); |
| 89 | 111 | expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); |
| 90 | 112 | expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); |
| 113 | + expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); | |
| 91 | 114 | expect(killed).toEqual([100, 101]); |
| 92 | 115 | expect(stopped).toEqual({ status: "disabled" }); |
| 93 | 116 | }), |
| @@ -109,7 +132,7 @@ describe("CloudManagedEndpointRuntime", () => { · 15 unmodified lines | ||
| 109 | 132 | }), |
| 110 | 133 | ); |
| 111 | 134 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( |
| 112 | − Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 135 | + Effect.provide(runtimeDependencies(spawner)), | |
| 113 | 136 | ); |
| 114 | 137 | |
| 115 | 138 | const started = yield* runtime.applyConfig({ |
| @@ -148,7 +171,7 @@ describe("CloudManagedEndpointRuntime", () => { · 32 unmodified lines | ||
| 148 | 171 | }), |
| 149 | 172 | ); |
| 150 | 173 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( |
| 151 | − Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 174 | + Effect.provide(runtimeDependencies(spawner)), | |
| 152 | 175 | ); |
| 153 | 176 | const config = { |
| 154 | 177 | providerKind: "cloudflare_tunnel" as const, |
| @@ -195,7 +218,7 @@ describe("CloudManagedEndpointRuntime", () => { · 40 unmodified lines | ||
| 195 | 218 | }), |
| 196 | 219 | ); |
| 197 | 220 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( |
| 198 | − Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 221 | + Effect.provide(runtimeDependencies(spawner)), | |
| 199 | 222 | ); |
| 200 | 223 | |
| 201 | 224 | const started = yield* runtime.applyConfig({ |
| @@ -208,7 +231,59 @@ describe("CloudManagedEndpointRuntime", () => { · 6 unmodified lines | ||
| 208 | 231 | |
| 209 | 232 | expect(started).toMatchObject({ status: "running", pid: 400 }); |
| 210 | 233 | expect(spawned).toEqual([400, 401]); |
| 211 | − expect(killed).toEqual([]); | |
| 234 | + expect(killed).toEqual([400]); | |
| 235 | + }), | |
| 236 | + ); | |
| 237 | + | |
| 238 | + it.effect("serializes concurrent connector config changes", () => | |
| 239 | + Effect.gen(function* () { | |
| 240 | + const spawned: Array<number> = []; | |
| 241 | + const killed: Array<number> = []; | |
| 242 | + const firstSpawnEntered = yield* Deferred.make<void>(); | |
| 243 | + const releaseFirstSpawn = yield* Deferred.make<void>(); | |
| 244 | + const spawner = ChildProcessSpawner.make(() => | |
| 245 | + Effect.gen(function* () { | |
| 246 | + const pid = 500 + spawned.length; | |
| 247 | + spawned.push(pid); | |
| 248 | + if (pid === 500) { | |
| 249 | + yield* Deferred.succeed(firstSpawnEntered, undefined); | |
| 250 | + yield* Deferred.await(releaseFirstSpawn); | |
| 251 | + } | |
| 252 | + const handle = makeHandle({ | |
| 253 | + pid, | |
| 254 | + onKill: () => { | |
| 255 | + killed.push(pid); | |
| 256 | + }, | |
| 257 | + }); | |
| 258 | + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | |
| 259 | + return handle; | |
| 260 | + }), | |
| 261 | + ); | |
| 262 | + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 263 | + Effect.provide(runtimeDependencies(spawner)), | |
| 264 | + ); | |
| 265 | + | |
| 266 | + const first = yield* runtime | |
| 267 | + .applyConfig({ | |
| 268 | + providerKind: "cloudflare_tunnel", | |
| 269 | + connectorToken: "token-1", | |
| 270 | + }) | |
| 271 | + .pipe(Effect.forkChild); | |
| 272 | + yield* Deferred.await(firstSpawnEntered); | |
| 273 | + const second = yield* runtime | |
| 274 | + .applyConfig({ | |
| 275 | + providerKind: "cloudflare_tunnel", | |
| 276 | + connectorToken: "token-2", | |
| 277 | + }) | |
| 278 | + .pipe(Effect.forkChild); | |
| 279 | + yield* Deferred.succeed(releaseFirstSpawn, undefined); | |
| 280 | + | |
| 281 | + yield* Fiber.join(first); | |
| 282 | + const status = yield* Fiber.join(second); | |
| 283 | + | |
| 284 | + expect(status).toMatchObject({ status: "running", pid: 501 }); | |
| 285 | + expect(spawned).toEqual([500, 501]); | |
| 286 | + expect(killed).toEqual([500]); | |
| 212 | 287 | }), |
| 213 | 288 | ); |
| 214 | 289 | |
| @@ -225,7 +300,7 @@ describe("CloudManagedEndpointRuntime", () => { · 10 unmodified lines | ||
| 225 | 300 | ), |
| 226 | 301 | ); |
| 227 | 302 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( |
| 228 | − Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 303 | + Effect.provide(runtimeDependencies(spawner)), | |
| 229 | 304 | ); |
| 230 | 305 | |
| 231 | 306 | const status = yield* runtime.applyConfig({ |
| @@ -241,4 +316,40 @@ describe("CloudManagedEndpointRuntime", () => { · 9 unmodified lines | ||
| 241 | 316 | }); |
| 242 | 317 | }), |
| 243 | 318 | ); |
| 319 | + | |
| 320 | + it.effect("reports a missing cloudflared executable without spawning", () => | |
| 321 | + Effect.gen(function* () { | |
| 322 | + const spawn = vi.fn(); | |
| 323 | + const spawner = ChildProcessSpawner.make(spawn); | |
| 324 | + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 325 | + Effect.provide( | |
| 326 | + Layer.mergeAll( | |
| 327 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 328 | + Layer.succeed( | |
| 329 | + Cloudflared.CloudflaredExecutable, | |
| 330 | + Cloudflared.CloudflaredExecutable.of({ | |
| 331 | + resolve: Effect.succeed({ | |
| 332 | + status: "missing", | |
| 333 | + version: Cloudflared.CLOUDFLARED_VERSION, | |
| 334 | + }), | |
| 335 | + install: Effect.die("unused"), | |
| 336 | + }), | |
| 337 | + ), | |
| 338 | + ), | |
| 339 | + ), | |
| 340 | + ); | |
| 341 | + | |
| 342 | + const status = yield* runtime.applyConfig({ | |
| 343 | + providerKind: "cloudflare_tunnel", | |
| 344 | + connectorToken: "token", | |
| 345 | + }); | |
| 346 | + | |
| 347 | + expect(status).toEqual({ | |
| 348 | + status: "failed", | |
| 349 | + providerKind: "cloudflare_tunnel", | |
| 350 | + reason: "cloudflared is not installed.", | |
| 351 | + }); | |
| 352 | + expect(spawn).not.toHaveBeenCalled(); | |
| 353 | + }), | |
| 354 | + ); | |
| 244 | 355 | }); |
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; |
| 2 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 2 | 3 | import * as Context from "effect/Context"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Exit from "effect/Exit"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Ref from "effect/Ref"; |
| 9 | +import * as Result from "effect/Result"; | |
| 10 | +import * as Semaphore from "effect/Semaphore"; | |
| 8 | 11 | import * as Scope from "effect/Scope"; |
| 9 | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 13 | |
| @@ -88,8 +91,11 @@ const stopConnector = (connector: ActiveConnector | null) => · 77 unmodified lines | ||
| 88 | 91 | |
| 89 | 92 | export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { |
| 90 | 93 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 94 | + const cloudflared = yield* Cloudflared.CloudflaredExecutable; | |
| 91 | 95 | const activeRef = yield* Ref.make<ActiveConnector | null>(null); |
| 92 | − let applyConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; | |
| 96 | + const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null); | |
| 97 | + const reconcileSemaphore = yield* Semaphore.make(1); | |
| 98 | + let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; | |
| 93 | 99 | |
| 94 | 100 | const stopActive = Effect.gen(function* () { |
| 95 | 101 | const active = yield* Ref.getAndSet(activeRef, null); |
| @@ -98,26 +104,46 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 2 unmodified lines | ||
| 98 | 104 | |
| 99 | 105 | const superviseConnector = (connector: ActiveConnector) => |
| 100 | 106 | Effect.gen(function* () { |
| 101 | − const exitCode = yield* connector.child.exitCode; | |
| 102 | − const active = yield* Ref.get(activeRef); | |
| 103 | − if (active?.child.pid !== connector.child.pid || active.configKey !== connector.configKey) { | |
| 104 | − return; | |
| 105 | − } | |
| 106 | − yield* Ref.set(activeRef, null); | |
| 107 | − yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", { | |
| 108 | − pid: Number(connector.child.pid), | |
| 109 | − exitCode: Number(exitCode), | |
| 110 | − tunnelId: connector.config.tunnelId, | |
| 111 | − tunnelName: connector.config.tunnelName, | |
| 112 | − }); | |
| 113 | − yield* applyConfig(connector.config); | |
| 107 | + const result = yield* Effect.result(connector.child.exitCode); | |
| 108 | + yield* reconcileSemaphore.withPermits(1)( | |
| 109 | + Effect.gen(function* () { | |
| 110 | + const active = yield* Ref.get(activeRef); | |
| 111 | + if ( | |
| 112 | + active?.child.pid !== connector.child.pid || | |
| 113 | + active.configKey !== connector.configKey | |
| 114 | + ) { | |
| 115 | + return; | |
| 116 | + } | |
| 117 | + yield* Ref.set(activeRef, null); | |
| 118 | + yield* stopConnector(connector); | |
| 119 | + | |
| 120 | + const desiredConfig = yield* Ref.get(desiredConfigRef); | |
| 121 | + if ( | |
| 122 | + !desiredConfig || | |
| 123 | + desiredConfig.providerKind !== "cloudflare_tunnel" || | |
| 124 | + runtimeConfigKey(desiredConfig) !== connector.configKey | |
| 125 | + ) { | |
| 126 | + return; | |
| 127 | + } | |
| 128 | + | |
| 129 | + yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", { | |
| 130 | + pid: Number(connector.child.pid), | |
| 131 | + ...(Result.isSuccess(result) | |
| 132 | + ? { exitCode: Number(result.success) } | |
| 133 | + : { cause: result.failure }), | |
| 134 | + tunnelId: connector.config.tunnelId, | |
| 135 | + tunnelName: connector.config.tunnelName, | |
| 136 | + }); | |
| 137 | + yield* reconcileConfig(desiredConfig); | |
| 138 | + }), | |
| 139 | + ); | |
| 114 | 140 | }).pipe( |
| 115 | − Effect.catch((cause) => | |
| 141 | + Effect.catchCause((cause) => | |
| 116 | 142 | Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }), |
| 117 | 143 | ), |
| 118 | 144 | ); |
| 119 | 145 | |
| 120 | − applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(function* (config) { | |
| 146 | + reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { | |
| 121 | 147 | if (!config || config.providerKind !== "cloudflare_tunnel") { |
| 122 | 148 | yield* stopActive; |
| 123 | 149 | return config |
| @@ -144,14 +170,33 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 20 unmodified lines | ||
| 144 | 170 | |
| 145 | 171 | yield* stopActive; |
| 146 | 172 | |
| 173 | + const executable = yield* cloudflared.resolve; | |
| 174 | + if (executable.status !== "available") { | |
| 175 | + return { | |
| 176 | + status: "failed", | |
| 177 | + providerKind: "cloudflare_tunnel", | |
| 178 | + reason: | |
| 179 | + executable.status === "unsupported" | |
| 180 | + ? `Managed cloudflared is unsupported on ${executable.platform}-${executable.arch}.` | |
| 181 | + : "cloudflared is not installed.", | |
| 182 | + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | |
| 183 | + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | |
| 184 | + } satisfies CloudManagedEndpointRuntimeStatus; | |
| 185 | + } | |
| 186 | + | |
| 147 | 187 | const connectorScope = yield* Scope.make("sequential"); |
| 148 | 188 | const child = yield* spawner |
| 149 | 189 | .spawn( |
| 150 | − ChildProcess.make("cloudflared", ["tunnel", "run", "--token", config.connectorToken], { | |
| 151 | − shell: process.platform === "win32", | |
| 152 | − stderr: "ignore", | |
| 153 | − stdout: "ignore", | |
| 154 | − }), | |
| 190 | + ChildProcess.make( | |
| 191 | + executable.executablePath, | |
| 192 | + ["tunnel", "run", "--token", config.connectorToken], | |
| 193 | + { | |
| 194 | + detached: false, | |
| 195 | + shell: process.platform === "win32", | |
| 196 | + stderr: "ignore", | |
| 197 | + stdout: "ignore", | |
| 198 | + }, | |
| 199 | + ), | |
| 155 | 200 | ) |
| 156 | 201 | .pipe( |
| 157 | 202 | Effect.provideService(Scope.Scope, connectorScope), |
| @@ -210,6 +255,13 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 52 unmodified lines | ||
| 210 | 255 | } satisfies CloudManagedEndpointRuntimeStatus; |
| 211 | 256 | }); |
| 212 | 257 | |
| 258 | + const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( | |
| 259 | + (config: RelayManagedEndpointRuntimeConfig | null) => | |
| 260 | + reconcileSemaphore.withPermits(1)( | |
| 261 | + Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))), | |
| 262 | + ), | |
| 263 | + ); | |
| 264 | + | |
| 213 | 265 | return CloudManagedEndpointRuntime.of({ |
| 214 | 266 | applyConfig, |
| 215 | 267 | }); |
| @@ -81,6 +81,7 @@ import { · 80 unmodified lines | ||
| 81 | 81 | } from "./serverRuntimeState.ts"; |
| 82 | 82 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 83 | 83 | import * as NetService from "@t3tools/shared/Net"; |
| 84 | +import * as Cloudflared from "@t3tools/shared/cloudflared"; | |
| 84 | 85 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 85 | 86 | |
| 86 | 87 | const PtyAdapterLive = Layer.unwrap( |
| @@ -95,6 +96,13 @@ const PtyAdapterLive = Layer.unwrap( · 8 unmodified lines | ||
| 95 | 96 | }), |
| 96 | 97 | ); |
| 97 | 98 | |
| 99 | +const CloudflaredExecutableLive = Layer.unwrap( | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const config = yield* ServerConfig; | |
| 102 | + return Cloudflared.layer({ baseDir: config.baseDir }); | |
| 103 | + }), | |
| 104 | +); | |
| 105 | + | |
| 98 | 106 | const HttpServerLive = Layer.unwrap( |
| 99 | 107 | Effect.gen(function* () { |
| 100 | 108 | const config = yield* ServerConfig; |
| @@ -277,7 +285,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 176 unmodified lines | ||
| 277 | 285 | Layer.provideMerge(AuthLayerLive), |
| 278 | 286 | Layer.provideMerge(ServerSecretStore.layer), |
| 279 | 287 | Layer.provideMerge( |
| 280 | − CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 288 | + CloudManagedEndpointRuntime.layer.pipe( | |
| 289 | + Layer.provide(ServerSecretStore.layer), | |
| 290 | + Layer.provide(CloudflaredExecutableLive), | |
| 291 | + ), | |
| 281 | 292 | ), |
| 282 | 293 | ); |
| 283 | 294 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { RelayWebClientId } from "@t3tools/contracts/relay"; |
| 3 | −import { beforeEach, vi } from "vitest"; | |
| 3 | +import { afterEach, beforeEach, vi } from "vitest"; | |
| 4 | +import type { DesktopBridge } from "@t3tools/contracts"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| @@ -15,6 +16,7 @@ import { · 8 unmodified lines | ||
| 15 | 16 | import type { SavedEnvironmentRecord } from "../environments/runtime"; |
| 16 | 17 | import { |
| 17 | 18 | connectManagedCloudEnvironment, |
| 19 | + ensureDesktopCloudflaredAvailable, | |
| 18 | 20 | linkEnvironmentToCloud, |
| 19 | 21 | linkPrimaryEnvironmentToCloud, |
| 20 | 22 | listManagedCloudEnvironments, |
| @@ -96,6 +98,13 @@ function requestBodyText(body: BodyInit | null | undefined): string { · 75 unmodified lines | ||
| 96 | 98 | } |
| 97 | 99 | |
| 98 | 100 | describe("web cloud link environment client", () => { |
| 101 | + afterEach(() => { | |
| 102 | + if ("window" in globalThis) { | |
| 103 | + Reflect.deleteProperty(window, "desktopBridge"); | |
| 104 | + } | |
| 105 | + vi.unstubAllGlobals(); | |
| 106 | + }); | |
| 107 | + | |
| 99 | 108 | beforeEach(() => { |
| 100 | 109 | vi.restoreAllMocks(); |
| 101 | 110 | createProofMock.mockClear(); |
| @@ -115,6 +124,48 @@ describe("web cloud link environment client", () => { · 13 unmodified lines | ||
| 115 | 124 | expect(normalizeRelayBaseUrl(" ")).toBeNull(); |
| 116 | 125 | }); |
| 117 | 126 | |
| 127 | + it("installs cloudflared after desktop confirmation", async () => { | |
| 128 | + vi.stubGlobal("window", {}); | |
| 129 | + const confirm = vi.fn().mockResolvedValue(true); | |
| 130 | + const installCloudflared = vi.fn().mockResolvedValue({ | |
| 131 | + status: "available", | |
| 132 | + executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", | |
| 133 | + source: "managed", | |
| 134 | + version: "2026.5.2", | |
| 135 | + }); | |
| 136 | + window.desktopBridge = { | |
| 137 | + confirm, | |
| 138 | + getCloudflaredStatus: vi.fn().mockResolvedValue({ | |
| 139 | + status: "missing", | |
| 140 | + version: "2026.5.2", | |
| 141 | + }), | |
| 142 | + installCloudflared, | |
| 143 | + } as unknown as DesktopBridge; | |
| 144 | + | |
| 145 | + await Effect.runPromise(ensureDesktopCloudflaredAvailable()); | |
| 146 | + | |
| 147 | + expect(confirm).toHaveBeenCalledOnce(); | |
| 148 | + expect(installCloudflared).toHaveBeenCalledOnce(); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it("does not install cloudflared when desktop confirmation is declined", async () => { | |
| 152 | + vi.stubGlobal("window", {}); | |
| 153 | + const installCloudflared = vi.fn(); | |
| 154 | + window.desktopBridge = { | |
| 155 | + confirm: vi.fn().mockResolvedValue(false), | |
| 156 | + getCloudflaredStatus: vi.fn().mockResolvedValue({ | |
| 157 | + status: "missing", | |
| 158 | + version: "2026.5.2", | |
| 159 | + }), | |
| 160 | + installCloudflared, | |
| 161 | + } as unknown as DesktopBridge; | |
| 162 | + | |
| 163 | + await expect(Effect.runPromise(ensureDesktopCloudflaredAvailable())).rejects.toMatchObject({ | |
| 164 | + message: "Cloudflare Tunnel installation was cancelled.", | |
| 165 | + }); | |
| 166 | + expect(installCloudflared).not.toHaveBeenCalled(); | |
| 167 | + }); | |
| 168 | + | |
| 118 | 169 | it.effect("lists relay-managed environments for hosted and served web clients", () => |
| 119 | 170 | Effect.gen(function* () { |
| 120 | 171 | const fetchMock = vi.fn().mockResolvedValueOnce( |
| @@ -55,6 +55,59 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen · 54 unmodified lines | ||
| 55 | 55 | readonly cause?: unknown; |
| 56 | 56 | }> {} |
| 57 | 57 | |
| 58 | +const desktopCloudflaredBridgeError = (cause: unknown) => | |
| 59 | + new CloudEnvironmentLinkError({ | |
| 60 | + message: "Could not prepare Cloudflare Tunnel.", | |
| 61 | + cause, | |
| 62 | + }); | |
| 63 | + | |
| 64 | +export function ensureDesktopCloudflaredAvailable(): Effect.Effect< | |
| 65 | + void, | |
| 66 | + CloudEnvironmentLinkError | |
| 67 | +> { | |
| 68 | + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge; | |
| 69 | + if (!bridge) return Effect.void; | |
| 70 | + | |
| 71 | + return Effect.gen(function* () { | |
| 72 | + const status = yield* Effect.tryPromise({ | |
| 73 | + try: () => bridge.getCloudflaredStatus(), | |
| 74 | + catch: desktopCloudflaredBridgeError, | |
| 75 | + }); | |
| 76 | + if (status.status === "available") return; | |
| 77 | + if (status.status === "unsupported") { | |
| 78 | + return yield* new CloudEnvironmentLinkError({ | |
| 79 | + message: `T3 Code cannot install cloudflared automatically on ${status.platform}-${status.arch}.`, | |
| 80 | + }); | |
| 81 | + } | |
| 82 | + | |
| 83 | + const confirmed = yield* Effect.tryPromise({ | |
| 84 | + try: () => | |
| 85 | + bridge.confirm( | |
| 86 | + "T3 Code needs Cloudflare Tunnel to make this environment available through T3 Cloud. Download and install cloudflared now?", | |
| 87 | + ), | |
| 88 | + catch: desktopCloudflaredBridgeError, | |
| 89 | + }); | |
| 90 | + if (!confirmed) { | |
| 91 | + return yield* new CloudEnvironmentLinkError({ | |
| 92 | + message: "Cloudflare Tunnel installation was cancelled.", | |
| 93 | + }); | |
| 94 | + } | |
| 95 | + | |
| 96 | + const installed = yield* Effect.tryPromise({ | |
| 97 | + try: () => bridge.installCloudflared(), | |
| 98 | + catch: desktopCloudflaredBridgeError, | |
| 99 | + }); | |
| 100 | + if (installed.status !== "available") { | |
| 101 | + return yield* new CloudEnvironmentLinkError({ | |
| 102 | + message: | |
| 103 | + installed.status === "unsupported" | |
| 104 | + ? `T3 Code cannot install cloudflared automatically on ${installed.platform}-${installed.arch}.` | |
| 105 | + : "cloudflared is still unavailable after installation.", | |
| 106 | + }); | |
| 107 | + } | |
| 108 | + }); | |
| 109 | +} | |
| 110 | + | |
| 58 | 111 | const isRelayProtectedError = Schema.is(RelayProtectedError); |
| 59 | 112 | const isEnvironmentCloudApiError = Schema.is( |
| 60 | 113 | Schema.Union([ |
| @@ -231,7 +284,7 @@ export function listManagedCloudEnvironments(input: { · 170 unmodified lines | ||
| 231 | 284 | const configuredRelayUrl = relayUrl(); |
| 232 | 285 | if (!configuredRelayUrl) { |
| 233 | 286 | return yield* new CloudEnvironmentLinkError({ |
| 234 | − message: "VITE_T3_RELAY_URL is not configured.", | |
| 287 | + message: "T3_RELAY_URL is not configured.", | |
| 235 | 288 | }); |
| 236 | 289 | } |
| 237 | 290 | const relayClient = yield* ManagedRelayClient; |
| @@ -261,7 +314,7 @@ export function listCloudDevices(input: { · 23 unmodified lines | ||
| 261 | 314 | return Effect.gen(function* () { |
| 262 | 315 | if (!relayUrl()) { |
| 263 | 316 | return yield* new CloudEnvironmentLinkError({ |
| 264 | − message: "VITE_T3_RELAY_URL is not configured.", | |
| 317 | + message: "T3_RELAY_URL is not configured.", | |
| 265 | 318 | }); |
| 266 | 319 | } |
| 267 | 320 | const relayClient = yield* ManagedRelayClient; |
| @@ -290,7 +343,7 @@ export function connectManagedCloudEnvironment(input: { · 22 unmodified lines | ||
| 290 | 343 | const configuredRelayUrl = relayUrl(); |
| 291 | 344 | if (!configuredRelayUrl) { |
| 292 | 345 | return yield* new CloudEnvironmentLinkError({ |
| 293 | − message: "VITE_T3_RELAY_URL is not configured.", | |
| 346 | + message: "T3_RELAY_URL is not configured.", | |
| 294 | 347 | }); |
| 295 | 348 | } |
| 296 | 349 | const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); |
| @@ -465,7 +518,7 @@ export function linkEnvironmentToCloud(input: { · 168 unmodified lines | ||
| 465 | 518 | const configuredRelayUrl = relayUrl(); |
| 466 | 519 | if (!configuredRelayUrl) { |
| 467 | 520 | return yield* new CloudEnvironmentLinkError({ |
| 468 | − message: "VITE_T3_RELAY_URL is not configured.", | |
| 521 | + message: "T3_RELAY_URL is not configured.", | |
| 469 | 522 | }); |
| 470 | 523 | } |
| 471 | 524 | const relayClient = yield* ManagedRelayClient; |
| @@ -560,7 +613,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 88 unmodified lines | ||
| 560 | 613 | const configuredRelayUrl = relayUrl(); |
| 561 | 614 | if (!configuredRelayUrl) { |
| 562 | 615 | return yield* new CloudEnvironmentLinkError({ |
| 563 | − message: "VITE_T3_RELAY_URL is not configured.", | |
| 616 | + message: "T3_RELAY_URL is not configured.", | |
| 564 | 617 | }); |
| 565 | 618 | } |
| 566 | 619 | const relayClient = yield* ManagedRelayClient; |
| @@ -570,6 +623,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 3 unmodified lines | ||
| 570 | 623 | message: "Local environment is not ready yet.", |
| 571 | 624 | }); |
| 572 | 625 | } |
| 626 | + yield* ensureDesktopCloudflaredAvailable(); | |
| 573 | 627 | |
| 574 | 628 | const challenge = yield* relayClient |
| 575 | 629 | .createEnvironmentLinkChallenge({ |
| @@ -64,7 +64,7 @@ export function CloudSettingsPanel() { · 63 unmodified lines | ||
| 64 | 64 | <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}> |
| 65 | 65 | <SettingsRow |
| 66 | 66 | title="Cloud account" |
| 67 | − description="Set VITE_CLERK_PUBLISHABLE_KEY to enable optional cloud features." | |
| 67 | + description="Set T3CODE_CLERK_PUBLISHABLE_KEY to enable optional cloud features." | |
| 68 | 68 | /> |
| 69 | 69 | </SettingsSection> |
| 70 | 70 | </SettingsPageContainer> |
| @@ -470,6 +470,18 @@ const createDesktopBridgeStub = (overrides?: { · 469 unmodified lines | ||
| 470 | 470 | headers: {}, |
| 471 | 471 | body: "", |
| 472 | 472 | }), |
| 473 | + getCloudflaredStatus: vi.fn().mockResolvedValue({ | |
| 474 | + status: "available", | |
| 475 | + executablePath: "/usr/local/bin/cloudflared", | |
| 476 | + source: "path", | |
| 477 | + version: "2026.5.2", | |
| 478 | + }), | |
| 479 | + installCloudflared: vi.fn().mockResolvedValue({ | |
| 480 | + status: "available", | |
| 481 | + executablePath: "/usr/local/bin/cloudflared", | |
| 482 | + source: "path", | |
| 483 | + version: "2026.5.2", | |
| 484 | + }), | |
| 473 | 485 | onCloudAuthCallback: () => () => {}, |
| 474 | 486 | onMenuAction: () => () => {}, |
| 475 | 487 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| @@ -245,6 +245,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 244 unmodified lines | ||
| 245 | 245 | headers: {}, |
| 246 | 246 | body: "", |
| 247 | 247 | }), |
| 248 | + getCloudflaredStatus: async () => ({ | |
| 249 | + status: "available", | |
| 250 | + executablePath: "/usr/local/bin/cloudflared", | |
| 251 | + source: "path", | |
| 252 | + version: "2026.5.2", | |
| 253 | + }), | |
| 254 | + installCloudflared: async () => ({ | |
| 255 | + status: "available", | |
| 256 | + executablePath: "/usr/local/bin/cloudflared", | |
| 257 | + source: "path", | |
| 258 | + version: "2026.5.2", | |
| 259 | + }), | |
| 248 | 260 | onCloudAuthCallback: () => () => undefined, |
| 249 | 261 | onMenuAction: () => () => undefined, |
| 250 | 262 | getUpdateState: async () => { |
| @@ -26,5 +26,5 @@ · 25 unmodified lines | ||
| 26 | 26 | } |
| 27 | 27 | ] |
| 28 | 28 | }, |
| 29 | − "include": ["src", "vite.config.ts", "vercel.ts", "test"] | |
| 29 | + "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"] | |
| 30 | 30 | } |
| @@ -8,9 +8,16 @@ import "vite-plus/test/config"; · 7 unmodified lines | ||
| 8 | 8 | import { defineConfig } from "vite-plus"; |
| 9 | 9 | import pkg from "./package.json" with { type: "json" }; |
| 10 | 10 | |
| 11 | +import { loadRepoEnv } from "../../scripts/lib/public-config"; | |
| 12 | + | |
| 13 | +const repoEnv = loadRepoEnv(); | |
| 14 | +Object.assign(process.env, repoEnv); | |
| 15 | + | |
| 11 | 16 | const port = Number(process.env.PORT ?? 5733); |
| 12 | 17 | const host = process.env.HOST?.trim() || "localhost"; |
| 13 | 18 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 19 | +const configuredRelayUrl = repoEnv.VITE_T3_RELAY_URL?.trim() || ""; | |
| 20 | +const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; | |
| 14 | 21 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 15 | 22 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 16 | 23 | const configuredHostedAppUrl = (() => { |
| @@ -123,6 +130,8 @@ export default defineConfig(() => { · 106 unmodified lines | ||
| 123 | 130 | define: { |
| 124 | 131 | // In dev mode, tell the web app where the WebSocket server lives |
| 125 | 132 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 133 | + "import.meta.env.VITE_T3_RELAY_URL": JSON.stringify(configuredRelayUrl), | |
| 134 | + "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), | |
| 126 | 135 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), |
| 127 | 136 | "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), |
| 128 | 137 | "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), |
| @@ -6,15 +6,41 @@ audience. · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | ## Application Keys |
| 8 | 8 | |
| 9 | −Use keys from the same Clerk instance in each location: | |
| 9 | +Web, desktop, and mobile use checked-in public development defaults from | |
| 10 | +`packages/shared/src/relayAuth.ts`, so a fresh clone can use T3 Cloud without creating local | |
| 11 | +environment files. These values are safe to embed in client builds: the Clerk publishable key and | |
| 12 | +relay URL are public identifiers, not secrets. | |
| 10 | 13 | |
| 11 | −| Consumer | Configuration | Value | | |
| 12 | −| ------------------------ | --------------------------------------- | ----------------------------------------------------- | | |
| 13 | −| Web and desktop renderer | `apps/web/.env` | `VITE_CLERK_PUBLISHABLE_KEY=<publishable key>` | | |
| 14 | −| Mobile build | `apps/mobile/.env` or build environment | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=<publishable key>` | | |
| 15 | −| Relay deployment | Alchemy secret | `CLERK_SECRET_KEY=<secret key>` | | |
| 14 | +To point all clients at another Clerk/relay deployment, add a repository-root `.env` or | |
| 15 | +`.env.local` file: | |
| 16 | 16 | |
| 17 | −Never put `CLERK_SECRET_KEY` in a client application environment. | |
| 17 | +```dotenv | |
| 18 | +T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key> | |
| 19 | +T3_RELAY_URL=https://relay.example.com | |
| 20 | +``` | |
| 21 | + | |
| 22 | +The shared client loader projects these canonical values into the framework-specific | |
| 23 | +`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3_RELAY_URL`, and | |
| 24 | +`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` aliases. Existing aliases remain accepted as overrides for | |
| 25 | +compatibility, but new client configuration should use the canonical names. | |
| 26 | + | |
| 27 | +Configuration precedence is: | |
| 28 | + | |
| 29 | +1. Process or CI environment variables. | |
| 30 | +2. Repository-root `.env.local`. | |
| 31 | +3. Repository-root `.env`. | |
| 32 | +4. Checked-in public development defaults. | |
| 33 | + | |
| 34 | +Release builds read `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` from GitHub Actions repository | |
| 35 | +variables. EAS preview and production builds should define the same client-facing values in their | |
| 36 | +EAS environment. | |
| 37 | + | |
| 38 | +For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay | |
| 39 | +deployment reads `T3_RELAY_DOMAIN` and `T3_RELAY_ZONE_NAME` through Effect `Config`, with the | |
| 40 | +checked-in shared values as defaults. `bun --cwd infra/relay run deploy` invokes Alchemy from the | |
| 41 | +relay directory, so Alchemy loads `infra/relay/.env`. The relay still requires `CLERK_SECRET_KEY` as | |
| 42 | +an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application environment or commit it to | |
| 43 | +the repository. | |
| 18 | 44 | |
| 19 | 45 | ## JWT Template |
| 20 | 46 | |
| @@ -25,8 +51,9 @@ In **Clerk Dashboard > JWT templates**, create a template with: · 4 unmodified lines | ||
| 25 | 51 | | Name | `t3-relay` | |
| 26 | 52 | | Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` | |
| 27 | 53 | |
| 28 | −The `aud` value must be the deployed relay public URL, with no trailing slash, and must match | |
| 29 | −`VITE_T3_RELAY_URL` and `T3_RELAY_URL`. If the relay domain changes, update all three values. | |
| 54 | +The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the | |
| 55 | +client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If | |
| 56 | +the relay domain changes, update both values and the JWT template. | |
| 30 | 57 | |
| 31 | 58 | ## Desktop OAuth Redirect Allowlist |
| 32 | 59 | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +# Relay deployment overrides. The public relay URL is derived as | |
| 2 | +# https://<T3_RELAY_DOMAIN>. | |
| 3 | +T3_RELAY_DOMAIN=relay.example.com | |
| 4 | +T3_RELAY_ZONE_NAME=example.com | |
| 5 | + | |
| 6 | +# Relay runtime secrets and APNs configuration. | |
| 7 | +CLERK_SECRET_KEY=sk_test_... | |
| 8 | +APNS_ENVIRONMENT=sandbox | |
| 9 | +APNS_TEAM_ID=... | |
| 10 | +APNS_KEY_ID=... | |
| 11 | +APNS_BUNDLE_ID=... | |
| 12 | +APNS_PRIVATE_KEY=... | |
| @@ -80,12 +80,13 @@ dependencies represented at their boundary rather than mocking internal behavior · 79 unmodified lines | ||
| 80 | 80 | The relay deploys through Alchemy: |
| 81 | 81 | |
| 82 | 82 | ```sh |
| 83 | −cd infra/relay | |
| 84 | −bun run deploy | |
| 83 | +bun --cwd infra/relay run deploy | |
| 85 | 84 | ``` |
| 86 | 85 | |
| 87 | 86 | The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database |
| 88 | −connectivity, and relay tracing resources. Runtime secrets include Clerk and APNs credentials. | |
| 87 | +connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to | |
| 88 | +`infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that | |
| 89 | +file from the relay directory. Runtime secrets include Clerk and APNs credentials. | |
| 89 | 90 | |
| 90 | 91 | See: |
| 91 | 92 | |
| @@ -25,7 +25,7 @@ export default Alchemy.Stack( · 24 unmodified lines | ||
| 25 | 25 | Effect.gen(function* () { |
| 26 | 26 | const db = yield* PlanetscaleDatabase; |
| 27 | 27 | const hyperdrive = yield* RelayHyperdrive; |
| 28 | − const zone = yield* ManagedEndpointZone; | |
| 28 | + const zone = yield* ManagedEndpointZone.pipe(Effect.orDie); | |
| 29 | 29 | const api = yield* Api; |
| 30 | 30 | |
| 31 | 31 | return { |
| @@ -29,7 +29,7 @@ import { · 28 unmodified lines | ||
| 29 | 29 | tokenApi, |
| 30 | 30 | withoutCapturedParentSpan, |
| 31 | 31 | } from "./http/Api.ts"; |
| 32 | −import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.ts"; | |
| 32 | +import { ManagedEndpointZone, RelayDeploymentConfig } from "./zone.ts"; | |
| 33 | 33 | import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; |
| 34 | 34 | import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; |
| 35 | 35 | import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; |
| @@ -87,18 +87,22 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS · 51 unmodified lines | ||
| 87 | 87 | |
| 88 | 88 | export default class Api extends Cloudflare.Worker<Api>()( |
| 89 | 89 | "Api", |
| 90 | − { | |
| 91 | − main: import.meta.filename, | |
| 92 | − compatibility: { | |
| 93 | − date: "2026-05-22", | |
| 94 | − flags: ["nodejs_compat"], | |
| 95 | − }, | |
| 96 | − domain: RELAY_PUBLIC_DOMAIN, | |
| 97 | − }, | |
| 90 | + RelayDeploymentConfig.pipe( | |
| 91 | + Config.map(({ relayPublicDomain }) => ({ | |
| 92 | + main: import.meta.filename, | |
| 93 | + compatibility: { | |
| 94 | + date: "2026-05-22", | |
| 95 | + flags: ["nodejs_compat"], | |
| 96 | + }, | |
| 97 | + domain: relayPublicDomain, | |
| 98 | + })), | |
| 99 | + Effect.orDie, | |
| 100 | + ), | |
| 98 | 101 | Effect.gen(function* () { |
| 99 | 102 | // |
| 100 | 103 | // 1. Provision Infrastructure for the Worker to use |
| 101 | 104 | // |
| 105 | + const { relayPublicOrigin } = yield* RelayDeploymentConfig; | |
| 102 | 106 | const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; |
| 103 | 107 | const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; |
| 104 | 108 | const cloudMintKeyPair = yield* CloudMintKeyPair; |
| @@ -142,7 +146,7 @@ export default class Api extends Cloudflare.Worker<Api>()( · 37 unmodified lines | ||
| 142 | 146 | |
| 143 | 147 | const loadSettings = Effect.gen(function* () { |
| 144 | 148 | return RelayConfiguration.RelayConfiguration.of({ |
| 145 | − relayIssuer: RELAY_PUBLIC_ORIGIN, | |
| 149 | + relayIssuer: relayPublicOrigin, | |
| 146 | 150 | apns: { |
| 147 | 151 | environment, |
| 148 | 152 | teamId: apnsTeamId, |
| @@ -1,9 +1,26 @@ | ||
| 1 | 1 | import { adopt } from "alchemy/AdoptPolicy"; |
| 2 | 2 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 3 | +import * as Config from "effect/Config"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 3 | 5 | |
| 4 | −export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; | |
| 5 | −export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; | |
| 6 | +import { DEFAULT_T3_RELAY_DOMAIN, DEFAULT_T3_RELAY_ZONE_NAME } from "@t3tools/shared/relayAuth"; | |
| 6 | 7 | |
| 7 | −export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { | |
| 8 | − name: "ineededadomain.com", | |
| 9 | −}).pipe(adopt(true)); | |
| 8 | +export const RelayDeploymentConfig = Config.all({ | |
| 9 | + relayPublicDomain: Config.string("T3_RELAY_DOMAIN").pipe( | |
| 10 | + Config.withDefault(DEFAULT_T3_RELAY_DOMAIN), | |
| 11 | + ), | |
| 12 | + managedEndpointZoneName: Config.string("T3_RELAY_ZONE_NAME").pipe( | |
| 13 | + Config.withDefault(DEFAULT_T3_RELAY_ZONE_NAME), | |
| 14 | + ), | |
| 15 | +}).pipe( | |
| 16 | + Config.map((config) => ({ | |
| 17 | + relayPublicDomain: config.relayPublicDomain, | |
| 18 | + relayPublicOrigin: `https://${config.relayPublicDomain}`, | |
| 19 | + managedEndpointZoneName: config.managedEndpointZoneName, | |
| 20 | + })), | |
| 21 | +); | |
| 22 | + | |
| 23 | +export const ManagedEndpointZone = RelayDeploymentConfig.pipe( | |
| 24 | + Config.map(({ managedEndpointZoneName }) => managedEndpointZoneName), | |
| 25 | + Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))), | |
| 26 | +); | |
| @@ -394,6 +394,26 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ · 393 unmodified lines | ||
| 394 | 394 | }); |
| 395 | 395 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; |
| 396 | 396 | |
| 397 | +export const DesktopCloudflaredStatusSchema = Schema.Union([ | |
| 398 | + Schema.Struct({ | |
| 399 | + status: Schema.Literal("available"), | |
| 400 | + executablePath: Schema.String, | |
| 401 | + source: Schema.Literals(["override", "managed", "path"]), | |
| 402 | + version: Schema.String, | |
| 403 | + }), | |
| 404 | + Schema.Struct({ | |
| 405 | + status: Schema.Literal("missing"), | |
| 406 | + version: Schema.String, | |
| 407 | + }), | |
| 408 | + Schema.Struct({ | |
| 409 | + status: Schema.Literal("unsupported"), | |
| 410 | + platform: Schema.String, | |
| 411 | + arch: Schema.String, | |
| 412 | + version: Schema.String, | |
| 413 | + }), | |
| 414 | +]); | |
| 415 | +export type DesktopCloudflaredStatus = typeof DesktopCloudflaredStatusSchema.Type; | |
| 416 | + | |
| 397 | 417 | export interface DesktopBridge { |
| 398 | 418 | getAppBranding: () => DesktopAppBranding | null; |
| 399 | 419 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| @@ -444,6 +464,8 @@ export interface DesktopBridge { · 44 unmodified lines | ||
| 444 | 464 | setCloudAuthToken: (token: string) => Promise<boolean>; |
| 445 | 465 | clearCloudAuthToken: () => Promise<void>; |
| 446 | 466 | fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise<DesktopCloudAuthFetchResult>; |
| 467 | + getCloudflaredStatus: () => Promise<DesktopCloudflaredStatus>; | |
| 468 | + installCloudflared: () => Promise<DesktopCloudflaredStatus>; | |
| 447 | 469 | onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; |
| 448 | 470 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 449 | 471 | getUpdateState: () => Promise<DesktopUpdateState>; |
| @@ -138,6 +138,10 @@ · 137 unmodified lines | ||
| 138 | 138 | "./terminalLabels": { |
| 139 | 139 | "types": "./src/terminalLabels.ts", |
| 140 | 140 | "import": "./src/terminalLabels.ts" |
| 141 | + }, | |
| 142 | + "./cloudflared": { | |
| 143 | + "types": "./src/cloudflared.ts", | |
| 144 | + "import": "./src/cloudflared.ts" | |
| 141 | 145 | } |
| 142 | 146 | }, |
| 143 | 147 | "scripts": { |
| @@ -0,0 +1,257 @@ | ||
| 1 | +import { sha256 } from "@noble/hashes/sha2"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { describe, expect, it } from "@effect/vitest"; | |
| 4 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Encoding from "effect/Encoding"; | |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Sink from "effect/Sink"; | |
| 10 | +import * as Stream from "effect/Stream"; | |
| 11 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 12 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + CloudflaredInstallError, | |
| 16 | + CLOUDFLARED_VERSION, | |
| 17 | + makeCloudflaredExecutable, | |
| 18 | + resolveManagedCloudflaredPath, | |
| 19 | +} from "./cloudflared.ts"; | |
| 20 | + | |
| 21 | +const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); | |
| 22 | + | |
| 23 | +function makeHandle(exitCode = 0) { | |
| 24 | + return ChildProcessSpawner.makeHandle({ | |
| 25 | + pid: ChildProcessSpawner.ProcessId(100), | |
| 26 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | |
| 27 | + isRunning: Effect.succeed(false), | |
| 28 | + kill: () => Effect.void, | |
| 29 | + unref: Effect.succeed(Effect.void), | |
| 30 | + stdin: Sink.drain, | |
| 31 | + stdout: Stream.empty, | |
| 32 | + stderr: Stream.empty, | |
| 33 | + all: Stream.empty, | |
| 34 | + getInputFd: () => Sink.drain, | |
| 35 | + getOutputFd: () => Stream.empty, | |
| 36 | + }); | |
| 37 | +} | |
| 38 | + | |
| 39 | +const makeHttpClientLayer = (bytes: Uint8Array) => | |
| 40 | + Layer.succeed( | |
| 41 | + HttpClient.HttpClient, | |
| 42 | + HttpClient.make((request) => | |
| 43 | + Effect.succeed( | |
| 44 | + HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer)), | |
| 45 | + ), | |
| 46 | + ), | |
| 47 | + ); | |
| 48 | + | |
| 49 | +const makeSpawnerLayer = (commands: Array<string>) => | |
| 50 | + Layer.succeed( | |
| 51 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 52 | + ChildProcessSpawner.make((command) => | |
| 53 | + Effect.sync(() => { | |
| 54 | + commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); | |
| 55 | + return makeHandle(); | |
| 56 | + }), | |
| 57 | + ), | |
| 58 | + ); | |
| 59 | + | |
| 60 | +describe("CloudflaredExecutable", () => { | |
| 61 | + it.effect("resolves explicit overrides before managed and PATH executables", () => | |
| 62 | + Effect.gen(function* () { | |
| 63 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 64 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 65 | + prefix: "t3-cloudflared-test-", | |
| 66 | + }); | |
| 67 | + const overridePath = `${baseDir}/override-cloudflared`; | |
| 68 | + yield* fileSystem.writeFileString(overridePath, "override"); | |
| 69 | + yield* fileSystem.chmod(overridePath, 0o755); | |
| 70 | + const manager = yield* makeCloudflaredExecutable({ | |
| 71 | + baseDir, | |
| 72 | + platform: "linux", | |
| 73 | + arch: "x64", | |
| 74 | + configProvider: () => | |
| 75 | + ConfigProvider.fromEnv({ | |
| 76 | + env: { | |
| 77 | + PATH: "", | |
| 78 | + T3CODE_CLOUDFLARED_PATH: overridePath, | |
| 79 | + }, | |
| 80 | + }), | |
| 81 | + }); | |
| 82 | + | |
| 83 | + expect(yield* manager.resolve).toEqual({ | |
| 84 | + status: "available", | |
| 85 | + executablePath: overridePath, | |
| 86 | + source: "override", | |
| 87 | + version: CLOUDFLARED_VERSION, | |
| 88 | + }); | |
| 89 | + }).pipe( | |
| 90 | + Effect.scoped, | |
| 91 | + Effect.provide( | |
| 92 | + Layer.mergeAll( | |
| 93 | + NodeServices.layer, | |
| 94 | + makeHttpClientLayer(new Uint8Array()), | |
| 95 | + makeSpawnerLayer([]), | |
| 96 | + ), | |
| 97 | + ), | |
| 98 | + ), | |
| 99 | + ); | |
| 100 | + | |
| 101 | + it.effect("downloads, verifies, validates, and atomically installs the managed executable", () => | |
| 102 | + Effect.gen(function* () { | |
| 103 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 104 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 105 | + prefix: "t3-cloudflared-test-", | |
| 106 | + }); | |
| 107 | + const bytes = new TextEncoder().encode("test-cloudflared-binary"); | |
| 108 | + const manager = yield* makeCloudflaredExecutable({ | |
| 109 | + baseDir, | |
| 110 | + platform: "linux", | |
| 111 | + arch: "x64", | |
| 112 | + releaseAsset: { | |
| 113 | + url: "https://example.test/cloudflared", | |
| 114 | + sha256: Encoding.encodeHex(sha256(bytes)), | |
| 115 | + archive: "binary", | |
| 116 | + }, | |
| 117 | + configProvider: emptyConfigProvider, | |
| 118 | + }); | |
| 119 | + | |
| 120 | + const installed = yield* manager.install; | |
| 121 | + const managedPath = resolveManagedCloudflaredPath({ | |
| 122 | + baseDir, | |
| 123 | + platform: "linux", | |
| 124 | + arch: "x64", | |
| 125 | + }); | |
| 126 | + expect(installed).toEqual({ | |
| 127 | + status: "available", | |
| 128 | + executablePath: managedPath, | |
| 129 | + source: "managed", | |
| 130 | + version: CLOUDFLARED_VERSION, | |
| 131 | + }); | |
| 132 | + expect(new TextDecoder().decode(yield* fileSystem.readFile(managedPath))).toBe( | |
| 133 | + "test-cloudflared-binary", | |
| 134 | + ); | |
| 135 | + expect(yield* manager.resolve).toEqual(installed); | |
| 136 | + }).pipe( | |
| 137 | + Effect.scoped, | |
| 138 | + Effect.provide( | |
| 139 | + Layer.mergeAll( | |
| 140 | + NodeServices.layer, | |
| 141 | + makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), | |
| 142 | + makeSpawnerLayer([]), | |
| 143 | + ), | |
| 144 | + ), | |
| 145 | + ), | |
| 146 | + ); | |
| 147 | + | |
| 148 | + it.effect("rejects downloads whose checksum does not match the pinned manifest", () => | |
| 149 | + Effect.gen(function* () { | |
| 150 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 151 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 152 | + prefix: "t3-cloudflared-test-", | |
| 153 | + }); | |
| 154 | + const manager = yield* makeCloudflaredExecutable({ | |
| 155 | + baseDir, | |
| 156 | + platform: "linux", | |
| 157 | + arch: "x64", | |
| 158 | + releaseAsset: { | |
| 159 | + url: "https://example.test/cloudflared", | |
| 160 | + sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), | |
| 161 | + archive: "binary", | |
| 162 | + }, | |
| 163 | + configProvider: emptyConfigProvider, | |
| 164 | + }); | |
| 165 | + | |
| 166 | + const error = yield* manager.install.pipe(Effect.flip); | |
| 167 | + expect(error).toBeInstanceOf(CloudflaredInstallError); | |
| 168 | + expect(error.reason).toBe("invalid_checksum"); | |
| 169 | + }).pipe( | |
| 170 | + Effect.scoped, | |
| 171 | + Effect.provide( | |
| 172 | + Layer.mergeAll( | |
| 173 | + NodeServices.layer, | |
| 174 | + makeHttpClientLayer(new TextEncoder().encode("tampered")), | |
| 175 | + makeSpawnerLayer([]), | |
| 176 | + ), | |
| 177 | + ), | |
| 178 | + ), | |
| 179 | + ); | |
| 180 | + | |
| 181 | + it.effect("serializes concurrent installs within one runtime", () => { | |
| 182 | + const commands: Array<string> = []; | |
| 183 | + const bytes = new TextEncoder().encode("test-cloudflared-binary"); | |
| 184 | + return Effect.gen(function* () { | |
| 185 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 186 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 187 | + prefix: "t3-cloudflared-test-", | |
| 188 | + }); | |
| 189 | + const manager = yield* makeCloudflaredExecutable({ | |
| 190 | + baseDir, | |
| 191 | + platform: "linux", | |
| 192 | + arch: "x64", | |
| 193 | + releaseAsset: { | |
| 194 | + url: "https://example.test/cloudflared", | |
| 195 | + sha256: Encoding.encodeHex(sha256(bytes)), | |
| 196 | + archive: "binary", | |
| 197 | + }, | |
| 198 | + configProvider: emptyConfigProvider, | |
| 199 | + }); | |
| 200 | + | |
| 201 | + const [first, second] = yield* Effect.all([manager.install, manager.install], { | |
| 202 | + concurrency: "unbounded", | |
| 203 | + }); | |
| 204 | + expect(second).toEqual(first); | |
| 205 | + expect(commands).toHaveLength(1); | |
| 206 | + }).pipe( | |
| 207 | + Effect.scoped, | |
| 208 | + Effect.provide( | |
| 209 | + Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), | |
| 210 | + ), | |
| 211 | + ); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + it.effect("observes PATH changes after the manager has been constructed", () => | |
| 215 | + Effect.gen(function* () { | |
| 216 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 217 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 218 | + prefix: "t3-cloudflared-test-", | |
| 219 | + }); | |
| 220 | + const binDir = `${baseDir}/bin`; | |
| 221 | + const executablePath = `${binDir}/cloudflared`; | |
| 222 | + let path = ""; | |
| 223 | + const manager = yield* makeCloudflaredExecutable({ | |
| 224 | + baseDir, | |
| 225 | + platform: "linux", | |
| 226 | + arch: "x64", | |
| 227 | + configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), | |
| 228 | + }); | |
| 229 | + | |
| 230 | + expect(yield* manager.resolve).toEqual({ | |
| 231 | + status: "missing", | |
| 232 | + version: CLOUDFLARED_VERSION, | |
| 233 | + }); | |
| 234 | + | |
| 235 | + yield* fileSystem.makeDirectory(binDir); | |
| 236 | + yield* fileSystem.writeFileString(executablePath, "cloudflared"); | |
| 237 | + yield* fileSystem.chmod(executablePath, 0o755); | |
| 238 | + path = binDir; | |
| 239 | + | |
| 240 | + expect(yield* manager.resolve).toEqual({ | |
| 241 | + status: "available", | |
| 242 | + executablePath, | |
| 243 | + source: "path", | |
| 244 | + version: CLOUDFLARED_VERSION, | |
| 245 | + }); | |
| 246 | + }).pipe( | |
| 247 | + Effect.scoped, | |
| 248 | + Effect.provide( | |
| 249 | + Layer.mergeAll( | |
| 250 | + NodeServices.layer, | |
| 251 | + makeHttpClientLayer(new Uint8Array()), | |
| 252 | + makeSpawnerLayer([]), | |
| 253 | + ), | |
| 254 | + ), | |
| 255 | + ), | |
| 256 | + ); | |
| 257 | +}); | |
| @@ -0,0 +1,477 @@ | ||
| 1 | +import * as Clock from "effect/Clock"; | |
| 2 | +import * as Config from "effect/Config"; | |
| 3 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 4 | +import * as Context from "effect/Context"; | |
| 5 | +import * as Crypto from "effect/Crypto"; | |
| 6 | +import * as Data from "effect/Data"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Encoding from "effect/Encoding"; | |
| 9 | +import * as FileSystem from "effect/FileSystem"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Path from "effect/Path"; | |
| 13 | +import * as PlatformError from "effect/PlatformError"; | |
| 14 | +import * as Semaphore from "effect/Semaphore"; | |
| 15 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 16 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 17 | + | |
| 18 | +export const CLOUDFLARED_VERSION = "2026.5.2"; | |
| 19 | +export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; | |
| 20 | + | |
| 21 | +export type CloudflaredExecutableSource = "override" | "managed" | "path"; | |
| 22 | + | |
| 23 | +export type CloudflaredExecutableStatus = | |
| 24 | + | { | |
| 25 | + readonly status: "available"; | |
| 26 | + readonly executablePath: string; | |
| 27 | + readonly source: CloudflaredExecutableSource; | |
| 28 | + readonly version: string; | |
| 29 | + } | |
| 30 | + | { | |
| 31 | + readonly status: "missing"; | |
| 32 | + readonly version: string; | |
| 33 | + } | |
| 34 | + | { | |
| 35 | + readonly status: "unsupported"; | |
| 36 | + readonly platform: NodeJS.Platform; | |
| 37 | + readonly arch: string; | |
| 38 | + readonly version: string; | |
| 39 | + }; | |
| 40 | + | |
| 41 | +export type AvailableCloudflaredExecutable = Extract< | |
| 42 | + CloudflaredExecutableStatus, | |
| 43 | + { readonly status: "available" } | |
| 44 | +>; | |
| 45 | + | |
| 46 | +export class CloudflaredInstallError extends Data.TaggedError("CloudflaredInstallError")<{ | |
| 47 | + readonly reason: | |
| 48 | + | "download_failed" | |
| 49 | + | "invalid_checksum" | |
| 50 | + | "install_locked" | |
| 51 | + | "override_missing" | |
| 52 | + | "unsupported_platform" | |
| 53 | + | "validation_failed" | |
| 54 | + | "write_failed"; | |
| 55 | + readonly message: string; | |
| 56 | + readonly cause?: unknown; | |
| 57 | +}> {} | |
| 58 | + | |
| 59 | +class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{ | |
| 60 | + readonly command: string; | |
| 61 | + readonly exitCode: number; | |
| 62 | +}> {} | |
| 63 | + | |
| 64 | +export interface CloudflaredReleaseAsset { | |
| 65 | + readonly url: string; | |
| 66 | + readonly sha256: string; | |
| 67 | + readonly archive: "binary" | "tgz"; | |
| 68 | +} | |
| 69 | + | |
| 70 | +const CLOUDFLARED_RELEASE_ASSETS: Readonly< | |
| 71 | + Partial<Record<`${NodeJS.Platform}-${string}`, CloudflaredReleaseAsset>> | |
| 72 | +> = { | |
| 73 | + "darwin-arm64": { | |
| 74 | + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-arm64.tgz", | |
| 75 | + sha256: "ba94054c9fd4297645093d59d51442e5e546d07bb0516120e694a13d5b216d38", | |
| 76 | + archive: "tgz", | |
| 77 | + }, | |
| 78 | + "darwin-x64": { | |
| 79 | + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-amd64.tgz", | |
| 80 | + sha256: "7240f709506bc2c1eb9da4d89cf2555499c60280ecb854b7d80e8f17d4b7903d", | |
| 81 | + archive: "tgz", | |
| 82 | + }, | |
| 83 | + "linux-arm64": { | |
| 84 | + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-arm64", | |
| 85 | + sha256: "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815", | |
| 86 | + archive: "binary", | |
| 87 | + }, | |
| 88 | + "linux-x64": { | |
| 89 | + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-amd64", | |
| 90 | + sha256: "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886", | |
| 91 | + archive: "binary", | |
| 92 | + }, | |
| 93 | + "win32-x64": { | |
| 94 | + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", | |
| 95 | + sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", | |
| 96 | + archive: "binary", | |
| 97 | + }, | |
| 98 | +}; | |
| 99 | + | |
| 100 | +const INSTALL_LOCK_RETRY_COUNT = 100; | |
| 101 | +const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | |
| 102 | +const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | |
| 103 | + | |
| 104 | +const trimmedString = (name: string) => | |
| 105 | + Config.string(name).pipe( | |
| 106 | + Config.option, | |
| 107 | + Config.map( | |
| 108 | + Option.flatMap((value) => { | |
| 109 | + const trimmed = value.trim(); | |
| 110 | + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); | |
| 111 | + }), | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | + | |
| 115 | +const CloudflaredConfig = Config.all({ | |
| 116 | + executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME), | |
| 117 | + path: trimmedString("PATH"), | |
| 118 | +}); | |
| 119 | + | |
| 120 | +export interface CloudflaredExecutableOptions { | |
| 121 | + readonly baseDir: string; | |
| 122 | + readonly platform?: NodeJS.Platform; | |
| 123 | + readonly arch?: string; | |
| 124 | + readonly releaseAsset?: CloudflaredReleaseAsset; | |
| 125 | + readonly configProvider?: () => ConfigProvider.ConfigProvider; | |
| 126 | +} | |
| 127 | + | |
| 128 | +export interface CloudflaredExecutableShape { | |
| 129 | + readonly resolve: Effect.Effect<CloudflaredExecutableStatus>; | |
| 130 | + readonly install: Effect.Effect<AvailableCloudflaredExecutable, CloudflaredInstallError>; | |
| 131 | +} | |
| 132 | + | |
| 133 | +export class CloudflaredExecutable extends Context.Service< | |
| 134 | + CloudflaredExecutable, | |
| 135 | + CloudflaredExecutableShape | |
| 136 | +>()("@t3tools/shared/cloudflared/CloudflaredExecutable") {} | |
| 137 | + | |
| 138 | +function executableFileName(platform: NodeJS.Platform): string { | |
| 139 | + return platform === "win32" ? "cloudflared.exe" : "cloudflared"; | |
| 140 | +} | |
| 141 | + | |
| 142 | +export function resolveManagedCloudflaredPath(input: { | |
| 143 | + readonly baseDir: string; | |
| 144 | + readonly platform: NodeJS.Platform; | |
| 145 | + readonly arch: string; | |
| 146 | +}): string { | |
| 147 | + const separator = input.platform === "win32" ? "\\" : "/"; | |
| 148 | + return [ | |
| 149 | + input.baseDir.replace(/[\\/]+$/u, ""), | |
| 150 | + "tools", | |
| 151 | + "cloudflared", | |
| 152 | + CLOUDFLARED_VERSION, | |
| 153 | + `${input.platform}-${input.arch}`, | |
| 154 | + executableFileName(input.platform), | |
| 155 | + ].join(separator); | |
| 156 | +} | |
| 157 | + | |
| 158 | +function resolveReleaseAsset( | |
| 159 | + platform: NodeJS.Platform, | |
| 160 | + arch: string, | |
| 161 | +): CloudflaredReleaseAsset | null { | |
| 162 | + return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; | |
| 163 | +} | |
| 164 | + | |
| 165 | +function isAlreadyExists(error: PlatformError.PlatformError): boolean { | |
| 166 | + return error.reason._tag === "AlreadyExists"; | |
| 167 | +} | |
| 168 | + | |
| 169 | +const wrapInstallFailure = | |
| 170 | + ( | |
| 171 | + reason: CloudflaredInstallError["reason"], | |
| 172 | + message: string, | |
| 173 | + ): (<E, R>( | |
| 174 | + effect: Effect.Effect<void, E, R>, | |
| 175 | + ) => Effect.Effect<void, CloudflaredInstallError, R>) => | |
| 176 | + (effect) => | |
| 177 | + effect.pipe( | |
| 178 | + Effect.mapError( | |
| 179 | + (cause) => | |
| 180 | + new CloudflaredInstallError({ | |
| 181 | + reason, | |
| 182 | + message, | |
| 183 | + cause, | |
| 184 | + }), | |
| 185 | + ), | |
| 186 | + ); | |
| 187 | + | |
| 188 | +export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* ( | |
| 189 | + options: CloudflaredExecutableOptions, | |
| 190 | +): Effect.fn.Return< | |
| 191 | + CloudflaredExecutableShape, | |
| 192 | + never, | |
| 193 | + | ChildProcessSpawner.ChildProcessSpawner | |
| 194 | + | Crypto.Crypto | |
| 195 | + | FileSystem.FileSystem | |
| 196 | + | HttpClient.HttpClient | |
| 197 | + | Path.Path | |
| 198 | +> { | |
| 199 | + const crypto = yield* Crypto.Crypto; | |
| 200 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 201 | + const httpClient = yield* HttpClient.HttpClient; | |
| 202 | + const path = yield* Path.Path; | |
| 203 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 204 | + const installSemaphore = yield* Semaphore.make(1); | |
| 205 | + const platform = options.platform ?? process.platform; | |
| 206 | + const arch = options.arch ?? process.arch; | |
| 207 | + const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); | |
| 208 | + const loadCloudflaredConfig = Effect.suspend(() => | |
| 209 | + CloudflaredConfig.pipe( | |
| 210 | + Effect.provideService( | |
| 211 | + ConfigProvider.ConfigProvider, | |
| 212 | + options.configProvider?.() ?? ConfigProvider.fromEnv(), | |
| 213 | + ), | |
| 214 | + ), | |
| 215 | + ).pipe(Effect.orDie); | |
| 216 | + const managedPath = path.join( | |
| 217 | + options.baseDir, | |
| 218 | + "tools", | |
| 219 | + "cloudflared", | |
| 220 | + CLOUDFLARED_VERSION, | |
| 221 | + `${platform}-${arch}`, | |
| 222 | + executableFileName(platform), | |
| 223 | + ); | |
| 224 | + | |
| 225 | + const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( | |
| 226 | + executablePath: string, | |
| 227 | + ) { | |
| 228 | + const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); | |
| 229 | + if (Option.isNone(info) || info.value.type !== "File") return false; | |
| 230 | + return platform === "win32" || (info.value.mode & 0o111) !== 0; | |
| 231 | + }); | |
| 232 | + | |
| 233 | + const resolvePathExecutable = Effect.gen(function* () { | |
| 234 | + const config = yield* loadCloudflaredConfig; | |
| 235 | + const pathValue = Option.getOrUndefined(config.path); | |
| 236 | + if (!pathValue) return null; | |
| 237 | + const delimiter = platform === "win32" ? ";" : ":"; | |
| 238 | + for (const directory of pathValue.split(delimiter)) { | |
| 239 | + const trimmed = directory.trim().replace(/^"|"$/gu, ""); | |
| 240 | + if (trimmed.length === 0) continue; | |
| 241 | + const candidate = path.join(trimmed, executableFileName(platform)); | |
| 242 | + if (yield* isExecutableFile(candidate)) return candidate; | |
| 243 | + } | |
| 244 | + return null; | |
| 245 | + }); | |
| 246 | + | |
| 247 | + const resolve: CloudflaredExecutableShape["resolve"] = Effect.gen(function* () { | |
| 248 | + const config = yield* loadCloudflaredConfig; | |
| 249 | + if (Option.isSome(config.executableOverride)) { | |
| 250 | + return (yield* isExecutableFile(config.executableOverride.value)) | |
| 251 | + ? { | |
| 252 | + status: "available", | |
| 253 | + executablePath: config.executableOverride.value, | |
| 254 | + source: "override", | |
| 255 | + version: CLOUDFLARED_VERSION, | |
| 256 | + } | |
| 257 | + : { status: "missing", version: CLOUDFLARED_VERSION }; | |
| 258 | + } | |
| 259 | + if (yield* isExecutableFile(managedPath)) { | |
| 260 | + return { | |
| 261 | + status: "available", | |
| 262 | + executablePath: managedPath, | |
| 263 | + source: "managed", | |
| 264 | + version: CLOUDFLARED_VERSION, | |
| 265 | + }; | |
| 266 | + } | |
| 267 | + const pathExecutable = yield* resolvePathExecutable; | |
| 268 | + if (pathExecutable) { | |
| 269 | + return { | |
| 270 | + status: "available", | |
| 271 | + executablePath: pathExecutable, | |
| 272 | + source: "path", | |
| 273 | + version: CLOUDFLARED_VERSION, | |
| 274 | + }; | |
| 275 | + } | |
| 276 | + return releaseAsset | |
| 277 | + ? { status: "missing", version: CLOUDFLARED_VERSION } | |
| 278 | + : { | |
| 279 | + status: "unsupported", | |
| 280 | + platform, | |
| 281 | + arch, | |
| 282 | + version: CLOUDFLARED_VERSION, | |
| 283 | + }; | |
| 284 | + }); | |
| 285 | + | |
| 286 | + const runCommand = Effect.fn("cloudflared.runCommand")(function* ( | |
| 287 | + command: string, | |
| 288 | + args: ReadonlyArray<string>, | |
| 289 | + ) { | |
| 290 | + const child = yield* spawner.spawn( | |
| 291 | + ChildProcess.make(command, args, { | |
| 292 | + shell: platform === "win32", | |
| 293 | + stdout: "ignore", | |
| 294 | + stderr: "ignore", | |
| 295 | + }), | |
| 296 | + ); | |
| 297 | + const exitCode = Number(yield* child.exitCode); | |
| 298 | + if (exitCode !== 0) { | |
| 299 | + return yield* new CloudflaredCommandError({ command, exitCode }); | |
| 300 | + } | |
| 301 | + }); | |
| 302 | + | |
| 303 | + const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* ( | |
| 304 | + asset: CloudflaredReleaseAsset, | |
| 305 | + ) { | |
| 306 | + const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( | |
| 307 | + Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 308 | + Effect.mapError( | |
| 309 | + (cause) => | |
| 310 | + new CloudflaredInstallError({ | |
| 311 | + reason: "download_failed", | |
| 312 | + message: "Could not download cloudflared.", | |
| 313 | + cause, | |
| 314 | + }), | |
| 315 | + ), | |
| 316 | + ); | |
| 317 | + const bytes = new Uint8Array( | |
| 318 | + yield* response.arrayBuffer.pipe( | |
| 319 | + Effect.mapError( | |
| 320 | + (cause) => | |
| 321 | + new CloudflaredInstallError({ | |
| 322 | + reason: "download_failed", | |
| 323 | + message: "Could not read the downloaded cloudflared binary.", | |
| 324 | + cause, | |
| 325 | + }), | |
| 326 | + ), | |
| 327 | + ), | |
| 328 | + ); | |
| 329 | + const checksum = yield* crypto.digest("SHA-256", bytes).pipe( | |
| 330 | + Effect.mapError( | |
| 331 | + (cause) => | |
| 332 | + new CloudflaredInstallError({ | |
| 333 | + reason: "validation_failed", | |
| 334 | + message: "Could not verify the downloaded cloudflared checksum.", | |
| 335 | + cause, | |
| 336 | + }), | |
| 337 | + ), | |
| 338 | + ); | |
| 339 | + if (Encoding.encodeHex(checksum) !== asset.sha256) { | |
| 340 | + return yield* new CloudflaredInstallError({ | |
| 341 | + reason: "invalid_checksum", | |
| 342 | + message: "Downloaded cloudflared checksum did not match the pinned release.", | |
| 343 | + }); | |
| 344 | + } | |
| 345 | + return bytes; | |
| 346 | + }); | |
| 347 | + | |
| 348 | + const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( | |
| 349 | + lockPath: string, | |
| 350 | + ) { | |
| 351 | + for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | |
| 352 | + const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | |
| 353 | + Effect.as(true), | |
| 354 | + Effect.catch((error) => | |
| 355 | + isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | |
| 356 | + ), | |
| 357 | + ); | |
| 358 | + if (acquired) return; | |
| 359 | + | |
| 360 | + const now = yield* Clock.currentTimeMillis; | |
| 361 | + const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | |
| 362 | + const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | |
| 363 | + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | |
| 364 | + yield* fileSystem.remove(lockPath, { force: true }); | |
| 365 | + continue; | |
| 366 | + } | |
| 367 | + yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | |
| 368 | + } | |
| 369 | + return yield* new CloudflaredInstallError({ | |
| 370 | + reason: "install_locked", | |
| 371 | + message: "Another cloudflared installation is still in progress.", | |
| 372 | + }); | |
| 373 | + }); | |
| 374 | + | |
| 375 | + const installUnlocked: CloudflaredExecutableShape["install"] = Effect.gen(function* () { | |
| 376 | + const existing = yield* resolve; | |
| 377 | + if (existing.status === "available") return existing; | |
| 378 | + const config = yield* loadCloudflaredConfig; | |
| 379 | + if (Option.isSome(config.executableOverride)) { | |
| 380 | + return yield* new CloudflaredInstallError({ | |
| 381 | + reason: "override_missing", | |
| 382 | + message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, | |
| 383 | + }); | |
| 384 | + } | |
| 385 | + if (!releaseAsset) { | |
| 386 | + return yield* new CloudflaredInstallError({ | |
| 387 | + reason: "unsupported_platform", | |
| 388 | + message: `T3 Code does not provide a managed cloudflared binary for ${platform}-${arch}.`, | |
| 389 | + }); | |
| 390 | + } | |
| 391 | + | |
| 392 | + const managedDirectory = path.dirname(managedPath); | |
| 393 | + const lockPath = `${managedPath}.lock`; | |
| 394 | + yield* fileSystem | |
| 395 | + .makeDirectory(managedDirectory, { recursive: true }) | |
| 396 | + .pipe(wrapInstallFailure("write_failed", "Could not create the cloudflared tool directory.")); | |
| 397 | + yield* acquireInstallLock(lockPath).pipe( | |
| 398 | + Effect.catchTag("PlatformError", (cause) => | |
| 399 | + Effect.fail( | |
| 400 | + new CloudflaredInstallError({ | |
| 401 | + reason: "write_failed", | |
| 402 | + message: "Could not acquire the cloudflared installation lock.", | |
| 403 | + cause, | |
| 404 | + }), | |
| 405 | + ), | |
| 406 | + ), | |
| 407 | + ); | |
| 408 | + return yield* Effect.gen(function* () { | |
| 409 | + const afterLock = yield* resolve; | |
| 410 | + if (afterLock.status === "available") return afterLock; | |
| 411 | + | |
| 412 | + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 413 | + directory: managedDirectory, | |
| 414 | + prefix: ".install-", | |
| 415 | + }); | |
| 416 | + const archivePath = path.join( | |
| 417 | + tempDirectory, | |
| 418 | + releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), | |
| 419 | + ); | |
| 420 | + yield* fileSystem | |
| 421 | + .writeFile(archivePath, yield* downloadAsset(releaseAsset)) | |
| 422 | + .pipe(wrapInstallFailure("write_failed", "Could not write the cloudflared download.")); | |
| 423 | + | |
| 424 | + const executablePath = path.join(tempDirectory, executableFileName(platform)); | |
| 425 | + if (releaseAsset.archive === "tgz") { | |
| 426 | + yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( | |
| 427 | + wrapInstallFailure("write_failed", "Could not extract cloudflared."), | |
| 428 | + ); | |
| 429 | + } | |
| 430 | + if (platform !== "win32") { | |
| 431 | + yield* fileSystem | |
| 432 | + .chmod(executablePath, 0o755) | |
| 433 | + .pipe(wrapInstallFailure("write_failed", "Could not make cloudflared executable.")); | |
| 434 | + } | |
| 435 | + yield* runCommand(executablePath, ["--version"]).pipe( | |
| 436 | + wrapInstallFailure("validation_failed", "The downloaded cloudflared binary did not run."), | |
| 437 | + ); | |
| 438 | + | |
| 439 | + const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; | |
| 440 | + yield* fileSystem | |
| 441 | + .rename(executablePath, stagedPath) | |
| 442 | + .pipe(wrapInstallFailure("write_failed", "Could not stage cloudflared.")); | |
| 443 | + yield* fileSystem | |
| 444 | + .rename(stagedPath, managedPath) | |
| 445 | + .pipe( | |
| 446 | + wrapInstallFailure("write_failed", "Could not activate cloudflared."), | |
| 447 | + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), | |
| 448 | + ); | |
| 449 | + return { | |
| 450 | + status: "available", | |
| 451 | + executablePath: managedPath, | |
| 452 | + source: "managed", | |
| 453 | + version: CLOUDFLARED_VERSION, | |
| 454 | + } satisfies AvailableCloudflaredExecutable; | |
| 455 | + }).pipe( | |
| 456 | + Effect.scoped, | |
| 457 | + Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), | |
| 458 | + Effect.catch((cause) => | |
| 459 | + cause instanceof CloudflaredInstallError | |
| 460 | + ? Effect.fail(cause) | |
| 461 | + : Effect.fail( | |
| 462 | + new CloudflaredInstallError({ | |
| 463 | + reason: "write_failed", | |
| 464 | + message: "Could not install cloudflared.", | |
| 465 | + cause, | |
| 466 | + }), | |
| 467 | + ), | |
| 468 | + ), | |
| 469 | + ); | |
| 470 | + }); | |
| 471 | + const install = installSemaphore.withPermit(installUnlocked); | |
| 472 | + | |
| 473 | + return CloudflaredExecutable.of({ resolve, install }); | |
| 474 | +}); | |
| 475 | + | |
| 476 | +export const layer = (options: CloudflaredExecutableOptions) => | |
| 477 | + Layer.effect(CloudflaredExecutable, makeCloudflaredExecutable(options)); | |
| @@ -1,3 +1,12 @@ | ||
| 1 | +export const DEFAULT_T3_RELAY_DOMAIN = "t3code-relay.ineededadomain.com"; | |
| 2 | + | |
| 3 | +export const DEFAULT_T3_RELAY_URL = `https://${DEFAULT_T3_RELAY_DOMAIN}`; | |
| 4 | + | |
| 5 | +export const DEFAULT_T3_RELAY_ZONE_NAME = "ineededadomain.com"; | |
| 6 | + | |
| 7 | +export const DEFAULT_T3_CLERK_PUBLISHABLE_KEY = | |
| 8 | + "pk_test_YXdhaXRlZC1tb25rZmlzaC01OC5jbGVyay5hY2NvdW50cy5kZXYk"; | |
| 9 | + | |
| 1 | 10 | export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; |
| 2 | 11 | |
| 3 | 12 | export const RELAY_CLERK_TOKEN_OPTIONS = { |
| @@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; · 16 unmodified lines | ||
| 17 | 17 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 18 | 18 | import { ChildProcess } from "effect/unstable/process"; |
| 19 | 19 | |
| 20 | +import { loadRepoEnv } from "./lib/public-config.ts"; | |
| 21 | + | |
| 22 | +Object.assign(process.env, loadRepoEnv()); | |
| 23 | + | |
| 20 | 24 | const BASE_SERVER_PORT = 13773; |
| 21 | 25 | const BASE_WEB_PORT = 5733; |
| 22 | 26 | const MAX_HASH_OFFSET = 3000; |
| @@ -0,0 +1,74 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. | |
| 2 | +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 5 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | + | |
| 7 | +import { DEFAULT_PUBLIC_CONFIG, loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; | |
| 8 | + | |
| 9 | +const temporaryDirectories: string[] = []; | |
| 10 | + | |
| 11 | +afterEach(() => { | |
| 12 | + for (const directory of temporaryDirectories.splice(0)) { | |
| 13 | + rmSync(directory, { recursive: true, force: true }); | |
| 14 | + } | |
| 15 | +}); | |
| 16 | + | |
| 17 | +describe("loadRepoEnv", () => { | |
| 18 | + it("projects checked-in public defaults into Vite and Expo aliases", () => { | |
| 19 | + const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() }); | |
| 20 | + | |
| 21 | + expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); | |
| 22 | + expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); | |
| 23 | + expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); | |
| 24 | + expect(env.T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); | |
| 25 | + expect(env.VITE_T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("applies process, root local, root, and checked-in precedence in that order", () => { | |
| 29 | + const repoRoot = makeTemporaryDirectory(); | |
| 30 | + writeFileSync( | |
| 31 | + join(repoRoot, ".env"), | |
| 32 | + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3_RELAY_URL=https://root.example.test\n", | |
| 33 | + ); | |
| 34 | + writeFileSync( | |
| 35 | + join(repoRoot, ".env.local"), | |
| 36 | + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3_RELAY_URL=https://local.example.test\n", | |
| 37 | + ); | |
| 38 | + | |
| 39 | + expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3_RELAY_URL).toBe("https://local.example.test"); | |
| 40 | + expect( | |
| 41 | + loadRepoEnv({ | |
| 42 | + baseEnv: { | |
| 43 | + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", | |
| 44 | + T3_RELAY_URL: "https://ci.example.test", | |
| 45 | + }, | |
| 46 | + repoRoot, | |
| 47 | + }), | |
| 48 | + ).toMatchObject({ | |
| 49 | + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", | |
| 50 | + VITE_CLERK_PUBLISHABLE_KEY: "pk_ci", | |
| 51 | + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci", | |
| 52 | + T3_RELAY_URL: "https://ci.example.test", | |
| 53 | + VITE_T3_RELAY_URL: "https://ci.example.test", | |
| 54 | + }); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("accepts legacy framework aliases as root overrides", () => { | |
| 58 | + expect( | |
| 59 | + resolvePublicConfig({ | |
| 60 | + VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", | |
| 61 | + VITE_T3_RELAY_URL: "https://legacy.example.test", | |
| 62 | + }), | |
| 63 | + ).toEqual({ | |
| 64 | + clerkPublishableKey: "pk_legacy", | |
| 65 | + relayUrl: "https://legacy.example.test", | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +function makeTemporaryDirectory() { | |
| 71 | + const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); | |
| 72 | + temporaryDirectories.push(directory); | |
| 73 | + return directory; | |
| 74 | +} | |
| @@ -0,0 +1,78 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. | |
| 2 | +import * as NodeFS from "node:fs"; | |
| 3 | +import * as NodePath from "node:path"; | |
| 4 | +import * as NodeURL from "node:url"; | |
| 5 | +import * as NodeUtil from "node:util"; | |
| 6 | + | |
| 7 | +import { DEFAULT_T3_CLERK_PUBLISHABLE_KEY, DEFAULT_T3_RELAY_URL } from "@t3tools/shared/relayAuth"; | |
| 8 | + | |
| 9 | +export interface T3CodePublicConfig { | |
| 10 | + readonly clerkPublishableKey: string; | |
| 11 | + readonly relayUrl: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +type Environment = Readonly<Record<string, string | undefined>>; | |
| 15 | + | |
| 16 | +const REPO_ROOT = NodePath.dirname( | |
| 17 | + NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), | |
| 18 | +); | |
| 19 | + | |
| 20 | +// These values are intentionally public. Client builds embed them so a fresh clone can use | |
| 21 | +// T3 Cloud without local setup. Use root .env files or CI environment variables to override them. | |
| 22 | +export const DEFAULT_PUBLIC_CONFIG: T3CodePublicConfig = { | |
| 23 | + clerkPublishableKey: DEFAULT_T3_CLERK_PUBLISHABLE_KEY, | |
| 24 | + relayUrl: DEFAULT_T3_RELAY_URL, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export function loadRepoEnv({ | |
| 28 | + baseEnv = process.env, | |
| 29 | + repoRoot = REPO_ROOT, | |
| 30 | +}: { | |
| 31 | + readonly baseEnv?: Environment; | |
| 32 | + readonly repoRoot?: string; | |
| 33 | +} = {}): Record<string, string | undefined> { | |
| 34 | + const rootEnv = readEnvFile(NodePath.join(repoRoot, ".env")); | |
| 35 | + const localEnv = readEnvFile(NodePath.join(repoRoot, ".env.local")); | |
| 36 | + const config = resolvePublicConfig(baseEnv, localEnv, rootEnv); | |
| 37 | + | |
| 38 | + return { | |
| 39 | + ...rootEnv, | |
| 40 | + ...localEnv, | |
| 41 | + ...baseEnv, | |
| 42 | + T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | |
| 43 | + T3_RELAY_URL: config.relayUrl, | |
| 44 | + VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | |
| 45 | + VITE_T3_RELAY_URL: config.relayUrl, | |
| 46 | + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | |
| 47 | + }; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function resolvePublicConfig(...sources: readonly Environment[]): T3CodePublicConfig { | |
| 51 | + return { | |
| 52 | + clerkPublishableKey: | |
| 53 | + firstNonEmpty( | |
| 54 | + sources, | |
| 55 | + "T3CODE_CLERK_PUBLISHABLE_KEY", | |
| 56 | + "VITE_CLERK_PUBLISHABLE_KEY", | |
| 57 | + "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", | |
| 58 | + ) ?? DEFAULT_PUBLIC_CONFIG.clerkPublishableKey, | |
| 59 | + relayUrl: | |
| 60 | + firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL") ?? DEFAULT_PUBLIC_CONFIG.relayUrl, | |
| 61 | + }; | |
| 62 | +} | |
| 63 | + | |
| 64 | +function firstNonEmpty(sources: readonly Environment[], ...names: readonly string[]) { | |
| 65 | + for (const source of sources) { | |
| 66 | + for (const name of names) { | |
| 67 | + const value = source[name]?.trim(); | |
| 68 | + if (value) { | |
| 69 | + return value; | |
| 70 | + } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + return undefined; | |
| 74 | +} | |
| 75 | + | |
| 76 | +function readEnvFile(path: string): Record<string, string | undefined> { | |
| 77 | + return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {}; | |
| 78 | +} | |