1import {
2 AuthRelayReadScope,
3 AuthRelayWriteScope,
4 type AuthEnvironmentScope,
5 type EnvironmentConnectReachability,
6 EnvironmentHttpApi,
7 formatConnectReachable,
8 type RelayClientInstallProgressEvent,
9 type RelayClientInstallProgressStage,
10} from "@t3tools/contracts";
11import { RelayOkResponse } from "@t3tools/contracts/relay";
12import * as RelayClient from "@t3tools/shared/relayClient";
13import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
14import * as Cause from "effect/Cause";
15import * as Clock from "effect/Clock";
16import * as Config from "effect/Config";
17import * as Console from "effect/Console";
18import * as SecureRandom from "effect/crypto/SecureRandom";
19import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";
22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as References from "effect/References";
27import * as Schema from "effect/Schema";
28import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
29import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
30import * as HttpApiClient from "effect/http-api/HttpApiClient";
31
32import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
33import * as ManagedLimits from "../policy/managedLimits.ts";
34import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
35import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
36import * as BootService from "../cloud/bootService.ts";
37import * as CliState from "../cloud/CliState.ts";
38import * as CliTokenManager from "../cloud/CliTokenManager.ts";
39import { formatReachabilityFailure } from "../cloud/ConnectReachability.ts";
40import { filterRelayResponse } from "../cloud/relayResponse.ts";
41import {
42 CLOUD_LINK_REVOKED,
43 CLOUD_LINKED_USER_ID,
44 isAgentActivityPublishingEnabledValue,
45 PUBLISH_AGENT_ACTIVITY_SECRET,
46 RELAY_URL_SECRET,
47} from "../cloud/config.ts";
48import { relayUrlConfig } from "../cloud/publicConfig.ts";
49import * as RelayTracing from "../cloud/relayTracing.ts";
50import * as ServerConfig from "../config.ts";
51import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
52import * as ExternalLauncher from "../process/externalLauncher.ts";
53import {
54 isProcessAlive,
55 readLiveServerRuntimeState,
56 readPersistedServerRuntimeState,
57} from "../serverRuntimeState.ts";
58import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
59import { resolveCliCommand } from "./invocation.ts";
60import * as CliService from "./service.ts";
61
62const jsonFlag = Flag.Boolean("json").pipe(
63 Flag.withDescription("Emit JSON instead of human-readable output."),
64 Flag.withDefault(false),
65);
66
67const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
68
69export const headlessFlag = Flag.Boolean("headless").pipe(
70 Flag.withDescription(
71 "Sign in with a URL and code on any device. Automatic without a terminal or over SSH.",
72 ),
73 Flag.withDefault(false),
74);
75
76/**
77 * Inside an SSH session there is no local browser to complete the loopback
78 * OAuth callback, so the device authorization grant is the only flow that
79 * can work.
80 */
81const headlessSessionConfig = Config.all({
82 sshConnection: Config.String("SSH_CONNECTION").pipe(Config.option),
83 sshTty: Config.String("SSH_TTY").pipe(Config.option),
84}).pipe(
85 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
86);
87
88const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) =>
89 Console.log(formatDeviceAuthorizationPrompt(prompt));
90
91function formatDeviceAuthorizationPrompt(
92 prompt: CliTokenManager.DeviceAuthorizationPrompt,
93): string {
94 const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn)));
95 return [
96 "Sign in to T3 Connect. Open on any device:",
97 ` ${prompt.verificationUriComplete ?? prompt.verificationUri}`,
98 `Code: ${prompt.userCode}`,
99 `Waiting for approval. Expires in ${minutes} min. Ctrl+C cancels.`,
100 ].join("\n");
101}
102
103const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
104 readonly headless: boolean;
105}) {
106 const tokens = yield* CliTokenManager.CloudCliTokenManager;
107 // Without a terminal, no key press can open a browser, so sign in with a code.
108 const useOutOfBandOAuth =
109 options.headless ||
110 (yield* headlessSessionConfig) ||
111 !(process.stdin.isTTY && process.stdout.isTTY);
112 if (!useOutOfBandOAuth) {
113 const authorization = yield* tokens.get;
114 if (authorization._tag === "Authorized") {
115 return authorization.token.identity ?? null;
116 }
117 }
118 const existing = yield* tokens.getExisting.pipe(
119 Effect.catchTags({
120 CloudCliCredentialRefreshError: () =>
121 Console.log("T3 Connect sign-in expired. Sign in again.").pipe(Effect.as(Option.none())),
122 }),
123 );
124 if (Option.isSome(existing)) {
125 return existing.value.identity ?? null;
126 }
127 const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin(
128 showDeviceAuthorizationPrompt,
129 ).pipe(
130 Effect.mapError((cause) =>
131 isCloudCliTokenManagerError(cause)
132 ? cause
133 : new CliTokenManager.CloudCliAuthorizationError({ cause }),
134 ),
135 );
136 yield* tokens.store(token);
137 return identity;
138});
139
140function bytesToString(value: Uint8Array): string {
141 return new TextDecoder().decode(value);
142}
143
144function stringToBytes(value: string): Uint8Array {
145 return new TextEncoder().encode(value);
146}
147
148interface CloudCliStatus {
149 readonly desired: boolean;
150 readonly authenticated: boolean;
151 readonly linked: boolean;
152 readonly revoked: boolean;
153 readonly cloudUserId: string | null;
154 readonly relayUrl: string | null;
155 readonly publishAgentActivity: boolean;
156 readonly relayClient: RelayClient.RelayClientStatus;
157 readonly linkMode: CliState.CliDesiredLinkMode;
158 /** `null` when no server answered the live check, or none ran because no tunnel is wanted. */
159 readonly reachability: EnvironmentConnectReachability | null;
160}
161
162function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> {
163 switch (executable.status) {
164 case "available": {
165 const source =
166 executable.source === "path"
167 ? "PATH"
168 : executable.source === "managed"
169 ? "managed install"
170 : "configured override";
171 return [
172 ` Relay client: available via ${source}`,
173 ` Path: ${executable.executablePath}`,
174 ` Version: ${executable.version}`,
175 ];
176 }
177 case "missing":
178 return [" Relay client: not installed"];
179 case "unsupported":
180 return [
181 ` Relay client: unsupported on ${executable.platform}-${executable.arch}`,
182 ` Managed version: ${executable.version}`,
183 ];
184 }
185}
186
187function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string {
188 if (options?.json) {
189 return JSON.stringify(status, null, 2);
190 }
191
192 const provisioned = status.revoked
193 ? "removed from T3 Connect"
194 : status.linked
195 ? "provisioned"
196 : status.desired && status.authenticated
197 ? "pending server startup"
198 : "not provisioned";
199 const nextStep = !status.authenticated
200 ? "Run `t3 connect link` to authorize and enable T3 Connect."
201 : status.revoked
202 ? "Run `t3 connect link` to link this environment again."
203 : !status.desired
204 ? "Run `t3 connect link` to enable T3 Connect."
205 : !status.linked
206 ? "Start T3 to provision the environment link and launch its managed tunnel."
207 : undefined;
208
209 return [
210 "T3 Connect",
211 ` Exposure: ${status.desired ? "enabled" : "disabled"}`,
212 ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
213 ` Environment link: ${provisioned}`,
214 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
215 ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
216 ...formatRelayClientStatus(status.relayClient),
217 ...(!status.desired || status.linkMode !== "managed"
218 ? []
219 : status.reachability === null
220 ? [
221 "",
222 "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.",
223 ]
224 : status.reachability.status === "reachable"
225 ? [
226 "",
227 `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`,
228 ]
229 : ["", formatReachabilityFailure(status.reachability)]),
230 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
231 ].join("\n");
232}
233
234const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
235
236const confirmRelayClientInstall = (version: string) =>
237 Prompt.run(
238 Prompt.Confirm({
239 message: `The T3 relay client is required for T3 Connect. Download and install version ${version}?`,
240 initial: false,
241 }),
242 );
243
244function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string {
245 switch (stage) {
246 case "checking":
247 return "Checking existing installation";
248 case "waiting_for_lock":
249 return "Waiting for installation lock";
250 case "downloading":
251 return "Downloading";
252 case "verifying":
253 return "Verifying download";
254 case "installing":
255 return "Installing";
256 case "validating":
257 return "Validating executable";
258 case "activating":
259 return "Activating installation";
260 }
261}
262
263const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) =>
264 event.type === "progress"
265 ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`)
266 : Effect.void;
267
268export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")(
269 function* <ConfirmError, ConfirmContext>(
270 relayClient: RelayClient.RelayClient["Service"],
271 confirmInstall: (version: string) => Effect.Effect<boolean, ConfirmError, ConfirmContext>,
272 reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
273 ) {
274 const executable = yield* relayClient.resolve;
275 if (executable.status === "available") {
276 return Option.some(executable);
277 }
278 if (executable.status === "unsupported") {
279 return Option.some(yield* relayClient.installWithProgress(reportProgress));
280 }
281 if (!(yield* confirmInstall(executable.version))) {
282 return Option.none();
283 }
284 return Option.some(yield* relayClient.installWithProgress(reportProgress));
285 },
286);
287
288const withCloudCliSessionToken = <A, E, R>(
289 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
290 scope: AuthEnvironmentScope,
291 run: (token: string) => Effect.Effect<A, E, R>,
292) =>
293 Effect.acquireUseRelease(
294 environmentAuth.issueSession({
295 scopes: [scope],
296 subject: "cloud-cli",
297 label: "t3 connect cli",
298 }),
299 (issued) => run(issued.token),
300 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
301 );
302
303type LiveCloudActionResult =
304 | { readonly status: "not-running" }
305 | { readonly status: "server-gone" }
306 | { readonly status: "succeeded" }
307 | { readonly status: "failed"; readonly cause: Cause.Cause<unknown> };
308
309const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
310 const config = yield* ServerConfig.ServerConfig;
311 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
312 if (Option.isNone(runtimeState)) {
313 return { status: "not-running" } satisfies LiveCloudActionResult;
314 }
315 // A killed server leaves its record behind, and another process may own its port by now.
316 if (!isProcessAlive(runtimeState.value.pid)) {
317 return { status: "server-gone" } satisfies LiveCloudActionResult;
318 }
319
320 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
321 const result = yield* Effect.exit(
322 withCloudCliSessionToken(environmentAuth, AuthRelayWriteScope, (token) =>
323 HttpApiClient.make(EnvironmentHttpApi, {
324 baseUrl: runtimeState.value.origin,
325 }).pipe(
326 Effect.flatMap((client) =>
327 client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }),
328 ),
329 Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT),
330 ),
331 ),
332 );
333 return Exit.isSuccess(result)
334 ? ({ status: "succeeded" } satisfies LiveCloudActionResult)
335 : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult);
336});
337
338// One check probes the endpoint, the tunnel edge, and Tailscale, each with its own timeout.
339const CLOUD_CLI_REACHABILITY_TIMEOUT = Duration.seconds(30);
340const CLOUD_CLI_REACHABILITY_WAIT = Duration.seconds(60);
341const CLOUD_CLI_REACHABILITY_RETRY = Duration.seconds(5);
342
343/** Asks the running server to check T3 Connect now. `null` when no server answers. */
344const checkLiveReachability = Effect.fn("cloud.cli.check_live_reachability")(function* () {
345 const config = yield* ServerConfig.ServerConfig;
346 const runtimeState = yield* readLiveServerRuntimeState(config.serverRuntimeStatePath);
347 if (Option.isNone(runtimeState)) return null;
348 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
349 return yield* withCloudCliSessionToken(environmentAuth, AuthRelayReadScope, (token) =>
350 HttpApiClient.make(EnvironmentHttpApi, { baseUrl: runtimeState.value.origin }).pipe(
351 Effect.flatMap((client) =>
352 client.connect.checkReachability({ headers: { authorization: `Bearer ${token}` } }),
353 ),
354 Effect.timeout(CLOUD_CLI_REACHABILITY_TIMEOUT),
355 ),
356 ).pipe(Effect.orElseSucceed(() => null));
357});
358
359function formatAlertBox(text: string): string {
360 const lines = text.split("\n");
361 const width = Math.max(...lines.map((line) => line.length));
362 const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
363 const red = (line: string) => (color ? `\x1b[1;31m${line}\x1b[0m` : line);
364 return [
365 `┌${"─".repeat(width + 2)}┐`,
366 ...lines.map((line) => `│ ${line.padEnd(width)} │`),
367 `└${"─".repeat(width + 2)}┘`,
368 ]
369 .map(red)
370 .join("\n");
371}
372
373/**
374 * Waits for the server that `t3 connect` just started to prove other devices
375 * can reach it, so a broken tunnel fails setup instead of looking green.
376 */
377export const waitForReachability = Effect.fn("cloud.cli.wait_for_reachability")(function* () {
378 yield* Console.log("Checking T3 Connect...");
379 const deadline =
380 (yield* Clock.currentTimeMillis) + Duration.toMillis(CLOUD_CLI_REACHABILITY_WAIT);
381 let last: EnvironmentConnectReachability | null = null;
382 while (true) {
383 last = (yield* checkLiveReachability()) ?? last;
384 if (last?.status === "reachable") {
385 yield* Console.log("✓ T3 Connect is reachable.");
386 return;
387 }
388 if ((yield* Clock.currentTimeMillis) >= deadline) break;
389 yield* Effect.sleep(CLOUD_CLI_REACHABILITY_RETRY);
390 }
391 yield* Console.error(
392 formatAlertBox(
393 last === null
394 ? "✕ Could not check T3 Connect\n The T3 server on this machine did not answer.\n Fix: run `t3 service status`, then `t3 connect status`."
395 : formatReachabilityFailure(last),
396 ),
397 );
398 yield* Effect.sync(() => {
399 process.exitCode = 1;
400 });
401});
402
403type RelayUnlinkResult =
404 | { readonly status: "not-authenticated" }
405 | { readonly status: "revoked" }
406 | { readonly status: "not-linked" };
407
408type CloudDisconnectOperation = "live-server-unlink" | "relay-environment-unlink";
409
410const logCloudDisconnectFailure = (
411 operation: CloudDisconnectOperation,
412 clearAuthorization: boolean,
413 cause: Cause.Cause<unknown>,
414) =>
415 Effect.logWarning("T3 Connect disconnect operation failed.").pipe(
416 Effect.annotateLogs({
417 operation,
418 clearAuthorization,
419 cause: Cause.pretty(cause),
420 }),
421 );
422
423const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () {
424 const tokens = yield* CliTokenManager.CloudCliTokenManager;
425 const token = yield* tokens.getExisting;
426 if (Option.isNone(token)) {
427 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
428 }
429
430 const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
431 const environmentId = yield* environment.getEnvironmentId;
432 const relayUrl = yield* relayUrlConfig;
433 const httpClient = yield* HttpClient.HttpClient;
434 const response = yield* HttpClientRequest.delete(
435 `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`,
436 ).pipe(
437 HttpClientRequest.bearerToken(token.value.accessToken),
438 httpClient.execute,
439 Effect.flatMap(filterRelayResponse),
440 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
441 withRelayClientTracing,
442 );
443 return response.ok
444 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
445 : ({ status: "not-linked" } satisfies RelayUnlinkResult);
446});
447
448/**
449 * Prints the local disconnect outcome, then whether the relay record was revoked.
450 * A missing CLI login or a failed relay DELETE leaves that record registered, so
451 * those outcomes warn to deregister it from the T3 Connect account page.
452 */
453const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")(
454 function* (input: {
455 readonly clearAuthorization: boolean;
456 readonly liveResult: LiveCloudActionResult;
457 readonly relayResult: Exit.Exit<RelayUnlinkResult, unknown>;
458 }) {
459 if (input.liveResult.status === "failed") {
460 yield* logCloudDisconnectFailure(
461 "live-server-unlink",
462 input.clearAuthorization,
463 input.liveResult.cause,
464 );
465 yield* Console.warn(
466 "T3 Connect is disabled, but the running server could not stop its tunnel.\nRestart that server to stop the connector.",
467 );
468 } else {
469 yield* Console.log("T3 Connect is disabled locally.");
470 // A killed server cannot stop the connector it started, and nothing adopts it on restart.
471 if (input.liveResult.status === "server-gone") {
472 yield* Console.warn(
473 "The server that ran T3 Connect is gone, but its cloudflared process may still be running.\nStop that process if it is.",
474 );
475 }
476 }
477
478 if (Exit.isFailure(input.relayResult)) {
479 yield* logCloudDisconnectFailure(
480 "relay-environment-unlink",
481 input.clearAuthorization,
482 input.relayResult.cause,
483 );
484 yield* Console.warn(
485 input.clearAuthorization
486 ? "Could not revoke the relay-side environment record before signing out.\nThe stored CLI authorization was still removed locally."
487 : "Could not revoke the relay-side environment record yet.\nRun `t3 connect unlink` again when the relay is reachable, or deregister it from the T3 Connect page of your T3 account.",
488 );
489 } else if (input.relayResult.value.status === "not-authenticated") {
490 yield* Console.warn(
491 "The relay-side environment record was not revoked.\nDeregister it from the T3 Connect page of your T3 account, or run `t3 connect login` and retry.",
492 );
493 } else if (input.relayResult.value.status === "revoked") {
494 yield* Console.log("Revoked the relay-side environment record.");
495 }
496 },
497);
498
499export const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
500 readonly clearAuthorization: boolean;
501}) {
502 yield* CliState.setCliDesiredCloudLink(false);
503 const liveResult = yield* runLiveCloudUnlink();
504 const relayResult = yield* Effect.exit(unlinkRelayEnvironment());
505 yield* CliState.clearPersistedCloudLink;
506
507 if (options.clearAuthorization) {
508 const tokens = yield* CliTokenManager.CloudCliTokenManager;
509 yield* tokens.clear;
510 }
511
512 yield* reportCloudDisconnectResults({
513 clearAuthorization: options.clearAuthorization,
514 liveResult,
515 relayResult,
516 });
517
518 if (options.clearAuthorization) {
519 yield* Console.log(
520 "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.",
521 );
522 }
523 return liveResult;
524});
525
526export const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
527 flags: { readonly baseDir: Option.Option<string> },
528 run: Effect.Effect<
529 A,
530 E,
531 | ServerSecretStore.ServerSecretStore
532 | CliTokenManager.CloudCliTokenManager
533 | RelayClient.RelayClient
534 | EnvironmentAuth.EnvironmentAuth
535 | BootService.BootService
536 | SecureRandom.SecureRandom
537 | FileSystem.FileSystem
538 | Path.Path
539 | HttpClient.HttpClient
540 | Prompt.Environment
541 | ServerConfig.ServerConfig
542 | ServerEnvironment.ServerEnvironmentIdentity
543 | ManagedPolicy.ManagedPolicy
544 >,
545 options?: {
546 readonly quietLogs?: boolean;
547 },
548) {
549 const logLevel = yield* GlobalFlag.LogLevel;
550 const config = yield* resolveCliAuthConfig(flags, logLevel, options);
551 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
552 const layerRuntime = Layer.mergeAll(
553 ServerSecretStore.layer,
554 CliTokenManager.layer.pipe(
555 Layer.provide(ServerSecretStore.layer),
556 Layer.provide(ExternalLauncher.layer),
557 ),
558 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
559 EnvironmentAuth.layerRuntime,
560 CliService.layer(config),
561 RelayTracing.layerHeadlessRelayClient.pipe(Layer.provide(ManagedPolicy.layer)),
562 ).pipe(
563 Layer.provideMerge(FetchHttpClient.layer),
564 Layer.provideMerge(ServerConfig.layer(config)),
565 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
566 );
567 return yield* run.pipe(Effect.provide(layerRuntime));
568});
569
570/** Status, unlink, and logout stay available so a machine can leave Connect the policy turned off. */
571export const requireConnectAllowed = Effect.gen(function* () {
572 const policy = yield* ManagedPolicy.ManagedPolicy;
573 if (!ManagedLimits.allowsConnect(policy)) {
574 return yield* ManagedLimits.refuseConnect(policy);
575 }
576});
577
578const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
579
580function formatRelayClientReady(version: string): string {
581 return `✓ Relay client ready · cloudflared ${version}`;
582}
583
584const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
585 readonly headless: boolean;
586 readonly publishOnly?: boolean;
587 /** For `t3 up`, where the user already chose T3 Connect: install it quietly, without asking. */
588 readonly installRelayClient?: boolean;
589}) {
590 const publishOnly = options.publishOnly ?? false;
591 if (!publishOnly) {
592 const relayClient = yield* RelayClient.RelayClient;
593 const installed = yield* acquireRelayClientForLink(
594 relayClient,
595 options.installRelayClient === true ? () => Effect.succeed(true) : confirmRelayClientInstall,
596 options.installRelayClient === true
597 ? (event) =>
598 event.type === "progress" && event.stage === "downloading"
599 ? Console.log("Installing the T3 Connect relay client...")
600 : Effect.void
601 : reportRelayClientInstallProgress,
602 );
603 if (Option.isNone(installed)) {
604 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
605 return null;
606 }
607 if (options.installRelayClient !== true) {
608 yield* Console.log(formatRelayClientReady(installed.value.version));
609 }
610 }
611
612 const identity = yield* authorizeCli(options);
613 yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
614 if (yield* CliState.dropRevokedCloudLink) {
615 yield* Console.log("Replacing the link T3 Connect revoked.");
616 }
617 if (publishOnly) {
618 const secrets = yield* ServerSecretStore.ServerSecretStore;
619 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
620 }
621 return { identity } as const;
622});
623export { linkEnvironmentForConnect };
624
625const connectLoginCommand = Command.make("login", {
626 ...projectLocationFlags,
627 headless: headlessFlag,
628}).pipe(
629 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
630 Command.withHandler((flags) =>
631 runCloudCommand(
632 flags,
633 Effect.gen(function* () {
634 yield* requireConnectAllowed;
635 yield* Console.log("T3 Connect\n");
636 const identity = yield* authorizeCli(flags);
637 yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
638 }),
639 ),
640 ),
641);
642
643const connectLinkCommand = Command.make("link", {
644 ...projectLocationFlags,
645 headless: headlessFlag,
646 publishOnly: Flag.Boolean("publish-only").pipe(
647 Flag.withDescription(
648 "Link to publish agent activity only, with no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
649 ),
650 Flag.withDefault(false),
651 ),
652}).pipe(
653 Command.withDescription("Authorize this environment for T3 Connect on next start."),
654 Command.withHandler((flags) =>
655 runCloudCommand(
656 flags,
657 Effect.gen(function* () {
658 yield* requireConnectAllowed;
659 yield* Console.log("T3 Connect\n");
660 const linked = yield* linkEnvironmentForConnect(flags);
661 if (linked) {
662 const serveCommand = yield* resolveCliCommand("serve");
663 yield* Console.log(
664 flags.publishOnly
665 ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
666 : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
667 );
668 }
669 }),
670 ),
671 ),
672);
673
674const connectStatusCommand = Command.make("status", {
675 ...projectLocationFlags,
676 json: jsonFlag,
677}).pipe(
678 Command.withDescription(
679 "Show T3 Connect setup and check that other devices can reach this machine.",
680 ),
681 Command.withHandler((flags) =>
682 runCloudCommand(
683 flags,
684 Effect.gen(function* () {
685 const secrets = yield* ServerSecretStore.ServerSecretStore;
686 const relayClient = yield* RelayClient.RelayClient;
687 const tokens = yield* CliTokenManager.CloudCliTokenManager;
688 const [
689 desired,
690 authenticated,
691 cloudUserId,
692 relayUrl,
693 publishAgentActivity,
694 revoked,
695 executable,
696 linkMode,
697 ] = yield* Effect.all(
698 [
699 CliState.readCliDesiredCloudLink,
700 tokens.hasCredential,
701 secrets.get(CLOUD_LINKED_USER_ID),
702 secrets.get(RELAY_URL_SECRET),
703 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
704 secrets.get(CLOUD_LINK_REVOKED),
705 relayClient.resolve,
706 CliState.readCliDesiredLinkMode,
707 ],
708 { concurrency: "unbounded" },
709 );
710 // A publish-only link has no tunnel for other devices to reach.
711 const reachability =
712 desired && linkMode === "managed" ? yield* checkLiveReachability() : null;
713 const status: CloudCliStatus = {
714 desired,
715 authenticated,
716 linked: Option.isSome(cloudUserId),
717 revoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
718 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
719 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
720 publishAgentActivity: isAgentActivityPublishingEnabledValue(
721 Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null,
722 ),
723 relayClient: executable,
724 linkMode,
725 reachability,
726 };
727 yield* Console.log(formatCloudStatus(status, { json: flags.json }));
728 }),
729 {
730 quietLogs: flags.json,
731 },
732 ),
733 ),
734);
735
736const connectPublishCommand = Command.make("publish", {
737 ...projectLocationFlags,
738 disable: Flag.Boolean("disable").pipe(
739 Flag.withDescription("Stop publishing agent activity to your mobile clients."),
740 Flag.withDefault(false),
741 ),
742}).pipe(
743 Command.withDescription(
744 "Toggle publishing agent activity (push notifications and Live Activities) to your mobile clients.",
745 ),
746 Command.withHandler((flags) =>
747 runCloudCommand(
748 flags,
749 Effect.gen(function* () {
750 const secrets = yield* ServerSecretStore.ServerSecretStore;
751 const tokens = yield* CliTokenManager.CloudCliTokenManager;
752 const enabled = !flags.disable;
753 if (enabled) yield* requireConnectAllowed;
754 yield* secrets.set(
755 PUBLISH_AGENT_ACTIVITY_SECRET,
756 stringToBytes(enabled ? "true" : "false"),
757 );
758 if (!enabled) {
759 // Cancel unprovisioned publish-only links too, or the next start will recreate them.
760 // Managed links also serve the tunnel and must survive.
761 const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
762 if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") {
763 yield* CliState.setCliDesiredCloudLink(false);
764 yield* Console.log("Cancelled the pending publish-only T3 Connect link.");
765 }
766 yield* Console.log("Publishing agent activity disabled.");
767 return;
768 }
769
770 yield* Console.log("Publishing agent activity enabled.");
771 const linked = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
772 if (linked) {
773 return;
774 }
775
776 if (!(yield* tokens.hasCredential)) {
777 yield* Console.log(
778 "Run `t3 connect login` first so this environment can be authorized to publish.",
779 );
780 return;
781 }
782 // A desired managed link already covers publishing and must not be downgraded.
783 if (yield* CliState.readCliDesiredCloudLink) {
784 yield* Console.log(
785 "A T3 Connect link is already pending. Start T3 to finish provisioning it; publishing starts once it links.",
786 );
787 return;
788 }
789 yield* CliState.setCliDesiredCloudLink(true, "publish_only");
790 yield* Console.log(
791 "Restart T3 to finish authorizing this environment to publish (no managed tunnel is created).",
792 );
793 }),
794 ),
795 ),
796);
797
798const connectUnlinkCommand = Command.make("unlink", {
799 ...projectLocationFlags,
800}).pipe(
801 Command.withDescription("Disable T3 Connect while retaining the stored authorization."),
802 Command.withHandler((flags) =>
803 runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })),
804 ),
805);
806
807const connectLogoutCommand = Command.make("logout", {
808 ...projectLocationFlags,
809}).pipe(
810 Command.withDescription("Disable T3 Connect and clear the stored CLI authorization."),
811 Command.withHandler((flags) =>
812 runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })),
813 ),
814);
815
816// `t3 connect` itself is `t3 up --connect`, in up.ts.
817export const connectSubcommands = [
818 connectLoginCommand,
819 connectLinkCommand,
820 connectPublishCommand,
821 connectStatusCommand,
822 connectUnlinkCommand,
823 connectLogoutCommand,
824] as const;