History

feat(cloud): manage cloudflared installation and relay defaults

Julius Marminge committed 5 months ago

Commit 91e8bff1 parent b9c613a38 files · +1557 −74Browse files

Unified · Split

38 files
  1. .env.example +5 −0
  2. .github/workflows/release.yml +6 −0
  3. README.md +5 −0
  4. apps/desktop/src/ipc/DesktopIpcHandlers.ts +3 −0
  5. apps/desktop/src/ipc/channels.ts +2 −0
  6. apps/desktop/src/ipc/methods/cloudAuth.test.ts +55 −0
  7. apps/desktop/src/ipc/methods/cloudAuth.ts +14 −5
  8. apps/desktop/src/ipc/methods/cloudflared.test.ts +43 −0
  9. apps/desktop/src/ipc/methods/cloudflared.ts +27 −0
  10. apps/desktop/src/main.ts +17 −1
  11. apps/desktop/src/preload.ts +2 −0
  12. apps/mobile/README.md +7 −0
  13. apps/mobile/app.config.ts +8 −3
  14. apps/mobile/src/app/settings/waitlist.tsx +1 −1
  15. apps/server/src/cloud/ManagedEndpointRuntime.test.ts +117 −6
  16. apps/server/src/cloud/ManagedEndpointRuntime.ts +73 −21
  17. apps/server/src/server.ts +12 −1
  18. apps/web/src/cloud/linkEnvironment.test.ts +52 −1
  19. apps/web/src/cloud/linkEnvironment.ts +59 −5
  20. apps/web/src/components/settings/CloudSettings.tsx +1 −1
  21. apps/web/src/components/settings/SettingsPanels.browser.tsx +12 −0
  22. apps/web/src/localApi.test.ts +12 −0
  23. apps/web/tsconfig.json +1 −1
  24. apps/web/vite.config.ts +9 −0
  25. docs/t3-cloud-clerk.md +36 −9
  26. infra/relay/.env.example +12 −0
  27. infra/relay/README.md +4 −3
  28. infra/relay/alchemy.run.ts +1 −1
  29. infra/relay/src/worker.ts +14 −10
  30. infra/relay/src/zone.ts +22 −5
  31. packages/contracts/src/ipc.ts +22 −0
  32. packages/shared/package.json +4 −0
  33. packages/shared/src/cloudflared.test.ts +257 −0
  34. packages/shared/src/cloudflared.ts +477 −0
  35. packages/shared/src/relayAuth.ts +9 −0
  36. scripts/dev-runner.ts +4 −0
  37. scripts/lib/public-config.test.ts +74 −0
  38. scripts/lib/public-config.ts +78 −0
.env.example+5−0
@@ -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
.github/workflows/release.yml+6−0
@@ -26,6 +26,10 @@ permissions: · 25 unmodified lines
2626 contents: read
2727 id-token: none
2828
29+env:
30+ T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }}
31+ T3_RELAY_URL: ${{ vars.T3_RELAY_URL }}
32+
2933 jobs:
3034 check_changes:
3135 name: Check for changes since last nightly
@@ -648,6 +652,8 @@ jobs: · 616 unmodified lines
648652 --token "$VERCEL_TOKEN" \
649653 "${vercel_scope_args[@]}" \
650654 --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:-}" \
651657 --build-env "VITE_HOSTED_APP_URL=$router_url" \
652658 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
653659 )"
README.md+5−0
@@ -62,6 +62,11 @@ mise install · 61 unmodified lines
6262 vp install
6363 ```
6464
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+
6570 Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
6671
6772 Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
apps/desktop/src/ipc/DesktopIpcHandlers.ts+3−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 setCloudAuthToken,
1010 } from "./methods/cloudAuth.ts";
1111 import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
12+import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts";
1213 import {
1314 getSavedEnvironmentRegistry,
1415 getSavedEnvironmentSecret,
@@ -87,6 +88,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 72 unmodified lines
8788 yield* ipc.handle(setCloudAuthToken);
8889 yield* ipc.handle(clearCloudAuthToken);
8990 yield* ipc.handle(fetchCloudAuth);
91+ yield* ipc.handle(getCloudflaredStatus);
92+ yield* ipc.handle(installCloudflared);
9093
9194 yield* ipc.handle(getUpdateState);
9295 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+2−0
@@ -8,6 +8,8 @@ export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; · 7 unmodified lines
88 export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";
99 export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
1010 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";
1113 export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
1214 export const MENU_ACTION_CHANNEL = "desktop:menu-action";
1315 export const UPDATE_STATE_CHANNEL = "desktop:update-state";
apps/desktop/src/ipc/methods/cloudAuth.test.ts+55−0
@@ -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+});
apps/desktop/src/ipc/methods/cloudAuth.ts+14−5
@@ -6,7 +6,7 @@ import * as Data from "effect/Data"; · 5 unmodified lines
66 import * as Effect from "effect/Effect";
77 import * as Option from "effect/Option";
88 import * as Schema from "effect/Schema";
9−import { HttpClient, HttpClientRequest } from "effect/unstable/http";
9+import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http";
1010
1111 import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
1212 import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
@@ -91,11 +91,20 @@ export const fetchCloudAuth = makeIpcMethod({ · 78 unmodified lines
9191 }),
9292 });
9393
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+ },
9899 );
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+ );
99108
100109 const response = yield* HttpClient.execute(request).pipe(
101110 Effect.mapError(
apps/desktop/src/ipc/methods/cloudflared.test.ts+43−0
@@ -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+});
apps/desktop/src/ipc/methods/cloudflared.ts+27−0
@@ -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+});
apps/desktop/src/main.ts+17−1
@@ -9,6 +9,7 @@ import * as Option from "effect/Option"; · 8 unmodified lines
99 import * as Electron from "electron";
1010
1111 import * as NetService from "@t3tools/shared/Net";
12+import * as Cloudflared from "@t3tools/shared/cloudflared";
1213 import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
1314 import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
1415 import serverPackageJson from "../../server/package.json" with { type: "json" };
@@ -94,6 +95,17 @@ const desktopSshEnvironmentLayer = Layer.unwrap( · 79 unmodified lines
9495 }),
9596 );
9697
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+
97109 const electronLayer = Layer.mergeAll(
98110 ElectronApp.layer,
99111 ElectronDialog.layer,
@@ -141,7 +153,11 @@ const desktopApplicationLayer = Layer.mergeAll( · 41 unmodified lines
141153 DesktopCloudAuth.layer,
142154 DesktopShellEnvironment.layer,
143155 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+);
145161
146162 const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe(
147163 Layer.flatMap(() =>
apps/desktop/src/preload.ts+2−0
@@ -102,6 +102,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 101 unmodified lines
102102 ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token),
103103 clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL),
104104 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),
105107 onCloudAuthCallback: (listener) => {
106108 const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => {
107109 if (typeof rawUrl !== "string") return;
apps/mobile/README.md+7−0
@@ -16,6 +16,10 @@ This app has three variants: · 15 unmodified lines
1616
1717 Run commands from `apps/mobile`.
1818
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+
1923 ## Development
2024
2125 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
6367
6468 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.
6569
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+
6673 Create a PR preview dev-client build manually:
6774
6875 ```bash
apps/mobile/app.config.ts+8−3
@@ -1,8 +1,13 @@
11 import type { ExpoConfig } from "expo/config";
22
3+import { loadRepoEnv } from "../../scripts/lib/public-config";
4+
35 type AppVariant = "development" | "preview" | "production";
46
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);
611
712 const VARIANT_CONFIG: Record<
813 AppVariant,
@@ -150,10 +155,10 @@ const config: ExpoConfig = { · 141 unmodified lines
150155 extra: {
151156 appVariant: APP_VARIANT,
152157 relay: {
153− url: process.env.T3_RELAY_URL ?? null,
158+ url: repoEnv.T3_RELAY_URL ?? null,
154159 },
155160 clerk: {
156− publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
161+ publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
157162 },
158163 eas: {
159164 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
apps/mobile/src/app/settings/waitlist.tsx+1−1
@@ -57,7 +57,7 @@ export default function SettingsWaitlistRouteScreen() { · 56 unmodified lines
5757 textAlign: "center",
5858 }}
5959 >
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.
6161 </Text>
6262 </View>
6363 )}
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+117−6
@@ -1,14 +1,36 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { vi } from "vitest";
23 import * as Deferred from "effect/Deferred";
34 import * as Effect from "effect/Effect";
5+import * as Fiber from "effect/Fiber";
46 import * as Layer from "effect/Layer";
57 import * as PlatformError from "effect/PlatformError";
68 import * as Sink from "effect/Sink";
79 import * as Stream from "effect/Stream";
810 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11+import * as Cloudflared from "@t3tools/shared/cloudflared";
912
1013 import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";
1114
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+
1234 function makeHandle(input: {
1335 readonly pid: number;
1436 readonly onKill: () => void;
@@ -58,7 +80,7 @@ describe("CloudManagedEndpointRuntime", () => { · 43 unmodified lines
5880 }),
5981 );
6082 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
61− Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
83+ Effect.provide(runtimeDependencies(spawner)),
6284 );
6385
6486 yield* runtime.applyConfig({
@@ -88,6 +110,7 @@ describe("CloudManagedEndpointRuntime", () => { · 23 unmodified lines
88110 ]);
89111 expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]);
90112 expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]);
113+ expect(spawned.map((command) => command.options.detached)).toEqual([false, false]);
91114 expect(killed).toEqual([100, 101]);
92115 expect(stopped).toEqual({ status: "disabled" });
93116 }),
@@ -109,7 +132,7 @@ describe("CloudManagedEndpointRuntime", () => { · 15 unmodified lines
109132 }),
110133 );
111134 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
112− Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
135+ Effect.provide(runtimeDependencies(spawner)),
113136 );
114137
115138 const started = yield* runtime.applyConfig({
@@ -148,7 +171,7 @@ describe("CloudManagedEndpointRuntime", () => { · 32 unmodified lines
148171 }),
149172 );
150173 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
151− Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
174+ Effect.provide(runtimeDependencies(spawner)),
152175 );
153176 const config = {
154177 providerKind: "cloudflare_tunnel" as const,
@@ -195,7 +218,7 @@ describe("CloudManagedEndpointRuntime", () => { · 40 unmodified lines
195218 }),
196219 );
197220 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
198− Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
221+ Effect.provide(runtimeDependencies(spawner)),
199222 );
200223
201224 const started = yield* runtime.applyConfig({
@@ -208,7 +231,59 @@ describe("CloudManagedEndpointRuntime", () => { · 6 unmodified lines
208231
209232 expect(started).toMatchObject({ status: "running", pid: 400 });
210233 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]);
212287 }),
213288 );
214289
@@ -225,7 +300,7 @@ describe("CloudManagedEndpointRuntime", () => { · 10 unmodified lines
225300 ),
226301 );
227302 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
228− Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
303+ Effect.provide(runtimeDependencies(spawner)),
229304 );
230305
231306 const status = yield* runtime.applyConfig({
@@ -241,4 +316,40 @@ describe("CloudManagedEndpointRuntime", () => { · 9 unmodified lines
241316 });
242317 }),
243318 );
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+ );
244355 });
apps/server/src/cloud/ManagedEndpointRuntime.ts+73−21
@@ -1,10 +1,13 @@
11 import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2+import * as Cloudflared from "@t3tools/shared/cloudflared";
23 import * as Context from "effect/Context";
34 import * as Effect from "effect/Effect";
45 import * as Exit from "effect/Exit";
56 import * as Layer from "effect/Layer";
67 import * as Option from "effect/Option";
78 import * as Ref from "effect/Ref";
9+import * as Result from "effect/Result";
10+import * as Semaphore from "effect/Semaphore";
811 import * as Scope from "effect/Scope";
912 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1013
@@ -88,8 +91,11 @@ const stopConnector = (connector: ActiveConnector | null) => · 77 unmodified lines
8891
8992 export const makeCloudManagedEndpointRuntime = Effect.gen(function* () {
9093 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
94+ const cloudflared = yield* Cloudflared.CloudflaredExecutable;
9195 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"];
9399
94100 const stopActive = Effect.gen(function* () {
95101 const active = yield* Ref.getAndSet(activeRef, null);
@@ -98,26 +104,46 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 2 unmodified lines
98104
99105 const superviseConnector = (connector: ActiveConnector) =>
100106 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+ );
114140 }).pipe(
115− Effect.catch((cause) =>
141+ Effect.catchCause((cause) =>
116142 Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }),
117143 ),
118144 );
119145
120− applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(function* (config) {
146+ reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) {
121147 if (!config || config.providerKind !== "cloudflare_tunnel") {
122148 yield* stopActive;
123149 return config
@@ -144,14 +170,33 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 20 unmodified lines
144170
145171 yield* stopActive;
146172
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+
147187 const connectorScope = yield* Scope.make("sequential");
148188 const child = yield* spawner
149189 .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+ ),
155200 )
156201 .pipe(
157202 Effect.provideService(Scope.Scope, connectorScope),
@@ -210,6 +255,13 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 52 unmodified lines
210255 } satisfies CloudManagedEndpointRuntimeStatus;
211256 });
212257
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+
213265 return CloudManagedEndpointRuntime.of({
214266 applyConfig,
215267 });
apps/server/src/server.ts+12−1
@@ -81,6 +81,7 @@ import { · 80 unmodified lines
8181 } from "./serverRuntimeState.ts";
8282 import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
8383 import * as NetService from "@t3tools/shared/Net";
84+import * as Cloudflared from "@t3tools/shared/cloudflared";
8485 import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
8586
8687 const PtyAdapterLive = Layer.unwrap(
@@ -95,6 +96,13 @@ const PtyAdapterLive = Layer.unwrap( · 8 unmodified lines
9596 }),
9697 );
9798
99+const CloudflaredExecutableLive = Layer.unwrap(
100+ Effect.gen(function* () {
101+ const config = yield* ServerConfig;
102+ return Cloudflared.layer({ baseDir: config.baseDir });
103+ }),
104+);
105+
98106 const HttpServerLive = Layer.unwrap(
99107 Effect.gen(function* () {
100108 const config = yield* ServerConfig;
@@ -277,7 +285,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 176 unmodified lines
277285 Layer.provideMerge(AuthLayerLive),
278286 Layer.provideMerge(ServerSecretStore.layer),
279287 Layer.provideMerge(
280− CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)),
288+ CloudManagedEndpointRuntime.layer.pipe(
289+ Layer.provide(ServerSecretStore.layer),
290+ Layer.provide(CloudflaredExecutableLive),
291+ ),
281292 ),
282293 );
283294
apps/web/src/cloud/linkEnvironment.test.ts+52−1
@@ -1,6 +1,7 @@
11 import { EnvironmentId } from "@t3tools/contracts";
22 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";
45 import { describe, expect, it } from "@effect/vitest";
56 import * as Effect from "effect/Effect";
67 import * as Layer from "effect/Layer";
@@ -15,6 +16,7 @@ import { · 8 unmodified lines
1516 import type { SavedEnvironmentRecord } from "../environments/runtime";
1617 import {
1718 connectManagedCloudEnvironment,
19+ ensureDesktopCloudflaredAvailable,
1820 linkEnvironmentToCloud,
1921 linkPrimaryEnvironmentToCloud,
2022 listManagedCloudEnvironments,
@@ -96,6 +98,13 @@ function requestBodyText(body: BodyInit | null | undefined): string { · 75 unmodified lines
9698 }
9799
98100 describe("web cloud link environment client", () => {
101+ afterEach(() => {
102+ if ("window" in globalThis) {
103+ Reflect.deleteProperty(window, "desktopBridge");
104+ }
105+ vi.unstubAllGlobals();
106+ });
107+
99108 beforeEach(() => {
100109 vi.restoreAllMocks();
101110 createProofMock.mockClear();
@@ -115,6 +124,48 @@ describe("web cloud link environment client", () => { · 13 unmodified lines
115124 expect(normalizeRelayBaseUrl(" ")).toBeNull();
116125 });
117126
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+
118169 it.effect("lists relay-managed environments for hosted and served web clients", () =>
119170 Effect.gen(function* () {
120171 const fetchMock = vi.fn().mockResolvedValueOnce(
apps/web/src/cloud/linkEnvironment.ts+59−5
@@ -55,6 +55,59 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen · 54 unmodified lines
5555 readonly cause?: unknown;
5656 }> {}
5757
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+
58111 const isRelayProtectedError = Schema.is(RelayProtectedError);
59112 const isEnvironmentCloudApiError = Schema.is(
60113 Schema.Union([
@@ -231,7 +284,7 @@ export function listManagedCloudEnvironments(input: { · 170 unmodified lines
231284 const configuredRelayUrl = relayUrl();
232285 if (!configuredRelayUrl) {
233286 return yield* new CloudEnvironmentLinkError({
234− message: "VITE_T3_RELAY_URL is not configured.",
287+ message: "T3_RELAY_URL is not configured.",
235288 });
236289 }
237290 const relayClient = yield* ManagedRelayClient;
@@ -261,7 +314,7 @@ export function listCloudDevices(input: { · 23 unmodified lines
261314 return Effect.gen(function* () {
262315 if (!relayUrl()) {
263316 return yield* new CloudEnvironmentLinkError({
264− message: "VITE_T3_RELAY_URL is not configured.",
317+ message: "T3_RELAY_URL is not configured.",
265318 });
266319 }
267320 const relayClient = yield* ManagedRelayClient;
@@ -290,7 +343,7 @@ export function connectManagedCloudEnvironment(input: { · 22 unmodified lines
290343 const configuredRelayUrl = relayUrl();
291344 if (!configuredRelayUrl) {
292345 return yield* new CloudEnvironmentLinkError({
293− message: "VITE_T3_RELAY_URL is not configured.",
346+ message: "T3_RELAY_URL is not configured.",
294347 });
295348 }
296349 const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl);
@@ -465,7 +518,7 @@ export function linkEnvironmentToCloud(input: { · 168 unmodified lines
465518 const configuredRelayUrl = relayUrl();
466519 if (!configuredRelayUrl) {
467520 return yield* new CloudEnvironmentLinkError({
468− message: "VITE_T3_RELAY_URL is not configured.",
521+ message: "T3_RELAY_URL is not configured.",
469522 });
470523 }
471524 const relayClient = yield* ManagedRelayClient;
@@ -560,7 +613,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 88 unmodified lines
560613 const configuredRelayUrl = relayUrl();
561614 if (!configuredRelayUrl) {
562615 return yield* new CloudEnvironmentLinkError({
563− message: "VITE_T3_RELAY_URL is not configured.",
616+ message: "T3_RELAY_URL is not configured.",
564617 });
565618 }
566619 const relayClient = yield* ManagedRelayClient;
@@ -570,6 +623,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 3 unmodified lines
570623 message: "Local environment is not ready yet.",
571624 });
572625 }
626+ yield* ensureDesktopCloudflaredAvailable();
573627
574628 const challenge = yield* relayClient
575629 .createEnvironmentLinkChallenge({
apps/web/src/components/settings/CloudSettings.tsx+1−1
@@ -64,7 +64,7 @@ export function CloudSettingsPanel() { · 63 unmodified lines
6464 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>
6565 <SettingsRow
6666 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."
6868 />
6969 </SettingsSection>
7070 </SettingsPageContainer>
apps/web/src/components/settings/SettingsPanels.browser.tsx+12−0
@@ -470,6 +470,18 @@ const createDesktopBridgeStub = (overrides?: { · 469 unmodified lines
470470 headers: {},
471471 body: "",
472472 }),
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+ }),
473485 onCloudAuthCallback: () => () => {},
474486 onMenuAction: () => () => {},
475487 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
apps/web/src/localApi.test.ts+12−0
@@ -245,6 +245,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 244 unmodified lines
245245 headers: {},
246246 body: "",
247247 }),
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+ }),
248260 onCloudAuthCallback: () => () => undefined,
249261 onMenuAction: () => () => undefined,
250262 getUpdateState: async () => {
apps/web/tsconfig.json+1−1
@@ -26,5 +26,5 @@ · 25 unmodified lines
2626 }
2727 ]
2828 },
29− "include": ["src", "vite.config.ts", "vercel.ts", "test"]
29+ "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"]
3030 }
apps/web/vite.config.ts+9−0
@@ -8,9 +8,16 @@ import "vite-plus/test/config"; · 7 unmodified lines
88 import { defineConfig } from "vite-plus";
99 import pkg from "./package.json" with { type: "json" };
1010
11+import { loadRepoEnv } from "../../scripts/lib/public-config";
12+
13+const repoEnv = loadRepoEnv();
14+Object.assign(process.env, repoEnv);
15+
1116 const port = Number(process.env.PORT ?? 5733);
1217 const host = process.env.HOST?.trim() || "localhost";
1318 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() || "";
1421 const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
1522 const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
1623 const configuredHostedAppUrl = (() => {
@@ -123,6 +130,8 @@ export default defineConfig(() => { · 106 unmodified lines
123130 define: {
124131 // In dev mode, tell the web app where the WebSocket server lives
125132 "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),
126135 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
127136 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
128137 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
docs/t3-cloud-clerk.md+36−9
@@ -6,15 +6,41 @@ audience. · 5 unmodified lines
66
77 ## Application Keys
88
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.
1013
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:
1616
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.
1844
1945 ## JWT Template
2046
@@ -25,8 +51,9 @@ In **Clerk Dashboard > JWT templates**, create a template with: · 4 unmodified lines
2551 | Name | `t3-relay` |
2652 | Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` |
2753
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.
3057
3158 ## Desktop OAuth Redirect Allowlist
3259
infra/relay/.env.example+12−0
@@ -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=...
infra/relay/README.md+4−3
@@ -80,12 +80,13 @@ dependencies represented at their boundary rather than mocking internal behavior · 79 unmodified lines
8080 The relay deploys through Alchemy:
8181
8282 ```sh
83−cd infra/relay
84−bun run deploy
83+bun --cwd infra/relay run deploy
8584 ```
8685
8786 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.
8990
9091 See:
9192
infra/relay/alchemy.run.ts+1−1
@@ -25,7 +25,7 @@ export default Alchemy.Stack( · 24 unmodified lines
2525 Effect.gen(function* () {
2626 const db = yield* PlanetscaleDatabase;
2727 const hyperdrive = yield* RelayHyperdrive;
28− const zone = yield* ManagedEndpointZone;
28+ const zone = yield* ManagedEndpointZone.pipe(Effect.orDie);
2929 const api = yield* Api;
3030
3131 return {
infra/relay/src/worker.ts+14−10
@@ -29,7 +29,7 @@ import { · 28 unmodified lines
2929 tokenApi,
3030 withoutCapturedParentSpan,
3131 } from "./http/Api.ts";
32−import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.ts";
32+import { ManagedEndpointZone, RelayDeploymentConfig } from "./zone.ts";
3333 import { makeRelayTraceLayer, RelayObservability } from "./observability.ts";
3434 import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts";
3535 import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts";
@@ -87,18 +87,22 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS · 51 unmodified lines
8787
8888 export default class Api extends Cloudflare.Worker<Api>()(
8989 "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+ ),
98101 Effect.gen(function* () {
99102 //
100103 // 1. Provision Infrastructure for the Worker to use
101104 //
105+ const { relayPublicOrigin } = yield* RelayDeploymentConfig;
102106 const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue;
103107 const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue;
104108 const cloudMintKeyPair = yield* CloudMintKeyPair;
@@ -142,7 +146,7 @@ export default class Api extends Cloudflare.Worker<Api>()( · 37 unmodified lines
142146
143147 const loadSettings = Effect.gen(function* () {
144148 return RelayConfiguration.RelayConfiguration.of({
145− relayIssuer: RELAY_PUBLIC_ORIGIN,
149+ relayIssuer: relayPublicOrigin,
146150 apns: {
147151 environment,
148152 teamId: apnsTeamId,
infra/relay/src/zone.ts+22−5
@@ -1,9 +1,26 @@
11 import { adopt } from "alchemy/AdoptPolicy";
22 import * as Cloudflare from "alchemy/Cloudflare";
3+import * as Config from "effect/Config";
4+import * as Effect from "effect/Effect";
35
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";
67
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+);
packages/contracts/src/ipc.ts+22−0
@@ -394,6 +394,26 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ · 393 unmodified lines
394394 });
395395 export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;
396396
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+
397417 export interface DesktopBridge {
398418 getAppBranding: () => DesktopAppBranding | null;
399419 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
@@ -444,6 +464,8 @@ export interface DesktopBridge { · 44 unmodified lines
444464 setCloudAuthToken: (token: string) => Promise<boolean>;
445465 clearCloudAuthToken: () => Promise<void>;
446466 fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise<DesktopCloudAuthFetchResult>;
467+ getCloudflaredStatus: () => Promise<DesktopCloudflaredStatus>;
468+ installCloudflared: () => Promise<DesktopCloudflaredStatus>;
447469 onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void;
448470 onMenuAction: (listener: (action: string) => void) => () => void;
449471 getUpdateState: () => Promise<DesktopUpdateState>;
packages/shared/package.json+4−0
@@ -138,6 +138,10 @@ · 137 unmodified lines
138138 "./terminalLabels": {
139139 "types": "./src/terminalLabels.ts",
140140 "import": "./src/terminalLabels.ts"
141+ },
142+ "./cloudflared": {
143+ "types": "./src/cloudflared.ts",
144+ "import": "./src/cloudflared.ts"
141145 }
142146 },
143147 "scripts": {
packages/shared/src/cloudflared.test.ts+257−0
@@ -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+});
packages/shared/src/cloudflared.ts+477−0
@@ -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));
packages/shared/src/relayAuth.ts+9−0
@@ -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+
110 export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay";
211
312 export const RELAY_CLERK_TOKEN_OPTIONS = {
scripts/dev-runner.ts+4−0
@@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; · 16 unmodified lines
1717 import { Argument, Command, Flag } from "effect/unstable/cli";
1818 import { ChildProcess } from "effect/unstable/process";
1919
20+import { loadRepoEnv } from "./lib/public-config.ts";
21+
22+Object.assign(process.env, loadRepoEnv());
23+
2024 const BASE_SERVER_PORT = 13773;
2125 const BASE_WEB_PORT = 5733;
2226 const MAX_HASH_OFFSET = 3000;
scripts/lib/public-config.test.ts+74−0
@@ -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+}
scripts/lib/public-config.ts+78−0
@@ -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+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t