1import {
2 AuthRelayWriteScope,
3 EnvironmentHttpApi,
4 type RelayClientInstallProgressEvent,
5 type RelayClientInstallProgressStage,
6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as RelayClient from "@t3tools/shared/relayClient";
10import * as Cause from "effect/Cause";
11import * as Config from "effect/Config";
12import * as Console from "effect/Console";
13import * as Crypto from "effect/Crypto";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as References from "effect/References";
21import * as Schema from "effect/Schema";
22import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
23import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
24import * as HttpApiClient from "effect/http-api/HttpApiClient";
25
26import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as BootService from "../cloud/bootService.ts";
29import * as CliState from "../cloud/CliState.ts";
30import * as CliTokenManager from "../cloud/CliTokenManager.ts";
31import { filterRelayResponse } from "../cloud/relayResponse.ts";
32import {
33 CLOUD_LINK_REVOKED,
34 CLOUD_LINKED_USER_ID,
35 isAgentActivityPublishingEnabledValue,
36 PUBLISH_AGENT_ACTIVITY_SECRET,
37 RELAY_URL_SECRET,
38} from "../cloud/config.ts";
39import { relayUrlConfig } from "../cloud/publicConfig.ts";
40import * as ServerConfig from "../config.ts";
41import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
42import * as ExternalLauncher from "../process/externalLauncher.ts";
43import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
44import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
45import { resolveCliCommand } from "./invocation.ts";
46import { offerServiceDuringOnboarding, recoverServiceOnboardingOffer } from "./service.ts";
47import * as CliService from "./service.ts";
48
49const jsonFlag = Flag.Boolean("json").pipe(
50 Flag.withDescription("Emit JSON instead of human-readable output."),
51 Flag.withDefault(false),
52);
53
54const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
55
56const headlessFlag = Flag.Boolean("headless").pipe(
57 Flag.withDescription("Authorize without a local browser using the OAuth device flow."),
58 Flag.withDefault(false),
59);
60
61/**
62 * Inside an SSH session there is no local browser to complete the loopback
63 * OAuth callback, so the device authorization grant is the only flow that
64 * can work.
65 */
66export const headlessSessionConfig = Config.all({
67 sshConnection: Config.String("SSH_CONNECTION").pipe(Config.option),
68 sshTty: Config.String("SSH_TTY").pipe(Config.option),
69}).pipe(
70 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
71);
72
73const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) =>
74 Console.log(formatDeviceAuthorizationPrompt(prompt));
75
76function formatDeviceAuthorizationPrompt(
77 prompt: CliTokenManager.DeviceAuthorizationPrompt,
78): string {
79 const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn)));
80 return [
81 "Headless authorization",
82 "Open this URL on a device with a browser:",
83 ` ${prompt.verificationUriComplete ?? prompt.verificationUri}`,
84 "",
85 `Confirm this code when asked: ${prompt.userCode}`,
86 "",
87 `Waiting for approval (expires in ${minutes} min). Press Ctrl+C to cancel.`,
88 ].join("\n");
89}
90
91/** Returns the connected account identity, if the flow could determine one. */
92const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
93 readonly headless: boolean;
94}) {
95 const tokens = yield* CliTokenManager.CloudCliTokenManager;
96 const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig);
97 if (!useOutOfBandOAuth) {
98 const authorization = yield* tokens.get;
99 if (authorization._tag === "Authorized") {
100 return authorization.token.identity ?? null;
101 }
102 yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
103 }
104 // A stored credential whose refresh fails (revoked, expired grant) must
105 // fall through to a fresh device authorization, not dead-end the command.
106 const existing = yield* tokens.getExisting.pipe(
107 Effect.catchTags({
108 CloudCliCredentialRefreshError: () =>
109 Console.log(
110 "The stored T3 Connect credential could not be refreshed; signing in again.",
111 ).pipe(Effect.as(Option.none())),
112 }),
113 );
114 if (Option.isSome(existing)) {
115 return existing.value.identity ?? null;
116 }
117 const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin(
118 showDeviceAuthorizationPrompt,
119 ).pipe(
120 Effect.mapError((cause) =>
121 isCloudCliTokenManagerError(cause)
122 ? cause
123 : new CliTokenManager.CloudCliAuthorizationError({ cause }),
124 ),
125 );
126 yield* tokens.store(token);
127 return identity;
128});
129
130function bytesToString(value: Uint8Array): string {
131 return new TextDecoder().decode(value);
132}
133
134function stringToBytes(value: string): Uint8Array {
135 return new TextEncoder().encode(value);
136}
137
138interface CloudCliStatus {
139 readonly desired: boolean;
140 readonly authenticated: boolean;
141 readonly linked: boolean;
142 // The relay rejected the stored link, usually after a Deregister.
143 readonly revoked: boolean;
144 readonly cloudUserId: string | null;
145 readonly relayUrl: string | null;
146 readonly publishAgentActivity: boolean;
147 readonly relayClient: RelayClient.RelayClientStatus;
148}
149
150function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> {
151 switch (executable.status) {
152 case "available": {
153 const source =
154 executable.source === "path"
155 ? "PATH"
156 : executable.source === "managed"
157 ? "managed install"
158 : "configured override";
159 return [
160 ` Relay client: available via ${source}`,
161 ` Path: ${executable.executablePath}`,
162 ` Version: ${executable.version}`,
163 ];
164 }
165 case "missing":
166 return [" Relay client: not installed"];
167 case "unsupported":
168 return [
169 ` Relay client: unsupported on ${executable.platform}-${executable.arch}`,
170 ` Managed version: ${executable.version}`,
171 ];
172 }
173}
174
175function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string {
176 if (options?.json) {
177 return JSON.stringify(status, null, 2);
178 }
179
180 const provisioned = status.revoked
181 ? "removed from T3 Connect"
182 : status.linked
183 ? "provisioned"
184 : status.desired && status.authenticated
185 ? "pending server startup"
186 : "not provisioned";
187 const nextStep = !status.authenticated
188 ? "Run `t3 connect link` to authorize and enable T3 Connect."
189 : status.revoked
190 ? "Run `t3 connect link` to link this environment again."
191 : !status.desired
192 ? "Run `t3 connect link` to enable T3 Connect."
193 : !status.linked
194 ? "Start T3 to provision the environment link and launch its managed tunnel."
195 : undefined;
196
197 return [
198 "T3 Connect",
199 ` Exposure: ${status.desired ? "enabled" : "disabled"}`,
200 ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
201 ` Environment link: ${provisioned}`,
202 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
203 ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
204 ...formatRelayClientStatus(status.relayClient),
205 "",
206 "This is saved setup, not a live connection check. Check the background service with `t3 service status`.",
207 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
208 ].join("\n");
209}
210
211const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
212
213const confirmRelayClientInstall = (version: string) =>
214 Prompt.run(
215 Prompt.Confirm({
216 message: `The T3 relay client is required for T3 Connect. Download and install version ${version}?`,
217 initial: false,
218 }),
219 );
220
221function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string {
222 switch (stage) {
223 case "checking":
224 return "Checking existing installation";
225 case "waiting_for_lock":
226 return "Waiting for installation lock";
227 case "downloading":
228 return "Downloading";
229 case "verifying":
230 return "Verifying download";
231 case "installing":
232 return "Installing";
233 case "validating":
234 return "Validating executable";
235 case "activating":
236 return "Activating installation";
237 }
238}
239
240const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) =>
241 event.type === "progress"
242 ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`)
243 : Effect.void;
244
245export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")(
246 function* <ConfirmError, ConfirmContext>(
247 relayClient: RelayClient.RelayClient["Service"],
248 confirmInstall: (version: string) => Effect.Effect<boolean, ConfirmError, ConfirmContext>,
249 reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
250 ) {
251 const executable = yield* relayClient.resolve;
252 if (executable.status === "available") {
253 return Option.some(executable);
254 }
255 if (executable.status === "unsupported") {
256 return Option.some(yield* relayClient.installWithProgress(reportProgress));
257 }
258 if (!(yield* confirmInstall(executable.version))) {
259 return Option.none();
260 }
261 return Option.some(yield* relayClient.installWithProgress(reportProgress));
262 },
263);
264
265const withCloudCliSessionToken = <A, E, R>(
266 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
267 run: (token: string) => Effect.Effect<A, E, R>,
268) =>
269 Effect.acquireUseRelease(
270 environmentAuth.issueSession({
271 scopes: [AuthRelayWriteScope],
272 subject: "cloud-cli",
273 label: "t3 connect cli",
274 }),
275 (issued) => run(issued.token),
276 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
277 );
278
279type LiveCloudActionResult =
280 | { readonly status: "not-running" }
281 | { readonly status: "succeeded" }
282 | { readonly status: "failed"; readonly cause: Cause.Cause<unknown> };
283
284const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
285 const config = yield* ServerConfig.ServerConfig;
286 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
287 if (Option.isNone(runtimeState)) {
288 return { status: "not-running" } satisfies LiveCloudActionResult;
289 }
290
291 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
292 const result = yield* Effect.exit(
293 withCloudCliSessionToken(environmentAuth, (token) =>
294 HttpApiClient.make(EnvironmentHttpApi, {
295 baseUrl: runtimeState.value.origin,
296 }).pipe(
297 Effect.flatMap((client) =>
298 client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }),
299 ),
300 Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT),
301 ),
302 ),
303 );
304 return Exit.isSuccess(result)
305 ? ({ status: "succeeded" } satisfies LiveCloudActionResult)
306 : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult);
307});
308
309type RelayUnlinkResult =
310 | { readonly status: "not-authenticated" }
311 | { readonly status: "revoked" }
312 | { readonly status: "not-linked" };
313
314type CloudDisconnectOperation = "live-server-unlink" | "relay-environment-unlink";
315
316const logCloudDisconnectFailure = (
317 operation: CloudDisconnectOperation,
318 clearAuthorization: boolean,
319 cause: Cause.Cause<unknown>,
320) =>
321 Effect.logWarning("T3 Connect disconnect operation failed.").pipe(
322 Effect.annotateLogs({
323 operation,
324 clearAuthorization,
325 cause: Cause.pretty(cause),
326 }),
327 );
328
329const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () {
330 const tokens = yield* CliTokenManager.CloudCliTokenManager;
331 const token = yield* tokens.getExisting;
332 if (Option.isNone(token)) {
333 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
334 }
335
336 const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
337 const environmentId = yield* environment.getEnvironmentId;
338 const relayUrl = yield* relayUrlConfig;
339 const httpClient = yield* HttpClient.HttpClient;
340 const response = yield* HttpClientRequest.delete(
341 `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`,
342 ).pipe(
343 HttpClientRequest.bearerToken(token.value.accessToken),
344 httpClient.execute,
345 Effect.flatMap(filterRelayResponse),
346 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
347 );
348 return response.ok
349 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
350 : ({ status: "not-linked" } satisfies RelayUnlinkResult);
351});
352
353export const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")(
354 function* (input: {
355 readonly clearAuthorization: boolean;
356 readonly liveResult: LiveCloudActionResult;
357 readonly relayResult: Exit.Exit<RelayUnlinkResult, unknown>;
358 }) {
359 if (input.liveResult.status === "failed") {
360 yield* logCloudDisconnectFailure(
361 "live-server-unlink",
362 input.clearAuthorization,
363 input.liveResult.cause,
364 );
365 yield* Console.warn(
366 "T3 Connect is disabled, but the running server could not stop its tunnel.\nRestart that server to stop the connector.",
367 );
368 } else {
369 yield* Console.log("T3 Connect is disabled locally.");
370 }
371
372 if (Exit.isFailure(input.relayResult)) {
373 yield* logCloudDisconnectFailure(
374 "relay-environment-unlink",
375 input.clearAuthorization,
376 input.relayResult.cause,
377 );
378 yield* Console.warn(
379 input.clearAuthorization
380 ? "Could not revoke the relay-side environment record before signing out.\nThe stored CLI authorization was still removed locally."
381 : "Could not revoke the relay-side environment record yet.\nRun `t3 connect unlink` again when the relay is reachable.",
382 );
383 } else if (input.relayResult.value.status === "revoked") {
384 yield* Console.log("Revoked the relay-side environment record.");
385 }
386 },
387);
388
389const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
390 readonly clearAuthorization: boolean;
391}) {
392 yield* CliState.setCliDesiredCloudLink(false);
393 const liveResult = yield* runLiveCloudUnlink();
394 const relayResult = yield* Effect.exit(unlinkRelayEnvironment());
395 yield* CliState.clearPersistedCloudLink;
396
397 if (options.clearAuthorization) {
398 const tokens = yield* CliTokenManager.CloudCliTokenManager;
399 yield* tokens.clear;
400 }
401
402 yield* reportCloudDisconnectResults({
403 clearAuthorization: options.clearAuthorization,
404 liveResult,
405 relayResult,
406 });
407
408 if (options.clearAuthorization) {
409 yield* Console.log(
410 "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.",
411 );
412 }
413});
414
415const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
416 flags: { readonly baseDir: Option.Option<string> },
417 run: Effect.Effect<
418 A,
419 E,
420 | ServerSecretStore.ServerSecretStore
421 | CliTokenManager.CloudCliTokenManager
422 | RelayClient.RelayClient
423 | EnvironmentAuth.EnvironmentAuth
424 | BootService.BootService
425 | Crypto.Crypto
426 | FileSystem.FileSystem
427 | HttpClient.HttpClient
428 | Prompt.Environment
429 | ServerConfig.ServerConfig
430 | ServerEnvironment.ServerEnvironmentIdentity
431 >,
432 options?: {
433 readonly quietLogs?: boolean;
434 },
435) {
436 const logLevel = yield* GlobalFlag.LogLevel;
437 const config = yield* resolveCliAuthConfig(flags, logLevel);
438 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
439 const layerRuntime = Layer.mergeAll(
440 ServerSecretStore.layer,
441 CliTokenManager.layer.pipe(
442 Layer.provide(ServerSecretStore.layer),
443 Layer.provide(ExternalLauncher.layer),
444 ),
445 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
446 EnvironmentAuth.layerRuntime,
447 CliService.layer(config),
448 ).pipe(
449 Layer.provideMerge(FetchHttpClient.layer),
450 Layer.provideMerge(ServerConfig.layer(config)),
451 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
452 );
453 return yield* run.pipe(Effect.provide(layerRuntime));
454});
455
456const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
457
458function formatRelayClientReady(version: string): string {
459 return `✓ Relay client ready · cloudflared ${version}`;
460}
461
462const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
463 readonly headless: boolean;
464 readonly publishOnly?: boolean;
465}) {
466 const publishOnly = options.publishOnly ?? false;
467 if (!publishOnly) {
468 const relayClient = yield* RelayClient.RelayClient;
469 const installed = yield* acquireRelayClientForLink(
470 relayClient,
471 confirmRelayClientInstall,
472 reportRelayClientInstallProgress,
473 );
474 if (Option.isNone(installed)) {
475 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
476 return null;
477 }
478 yield* Console.log(formatRelayClientReady(installed.value.version));
479 }
480
481 const identity = yield* authorizeCli(options);
482 yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
483 if (yield* CliState.dropRevokedCloudLink) {
484 yield* Console.log(
485 "Removed the link T3 Connect no longer accepts. The next start links this environment again.",
486 );
487 }
488 if (publishOnly) {
489 const secrets = yield* ServerSecretStore.ServerSecretStore;
490 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
491 }
492 return { identity } as const;
493});
494
495const connectLoginCommand = Command.make("login", {
496 ...projectLocationFlags,
497 headless: headlessFlag,
498}).pipe(
499 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
500 Command.withHandler((flags) =>
501 runCloudCommand(
502 flags,
503 Effect.gen(function* () {
504 yield* Console.log("T3 Connect\n");
505 const identity = yield* authorizeCli(flags);
506 yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
507 }),
508 ),
509 ),
510);
511
512const connectLinkCommand = Command.make("link", {
513 ...projectLocationFlags,
514 headless: headlessFlag,
515 publishOnly: Flag.Boolean("publish-only").pipe(
516 Flag.withDescription(
517 "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
518 ),
519 Flag.withDefault(false),
520 ),
521}).pipe(
522 Command.withDescription("Authorize this environment for T3 Connect on next start."),
523 Command.withHandler((flags) =>
524 runCloudCommand(
525 flags,
526 Effect.gen(function* () {
527 yield* Console.log("T3 Connect\n");
528 const linked = yield* linkEnvironmentForConnect(flags);
529 if (linked) {
530 const serveCommand = yield* resolveCliCommand("serve");
531 yield* Console.log(
532 flags.publishOnly
533 ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
534 : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
535 );
536 }
537 }),
538 ),
539 ),
540);
541
542const connectStatusCommand = Command.make("status", {
543 ...projectLocationFlags,
544 json: jsonFlag,
545}).pipe(
546 Command.withDescription("Show persisted T3 Connect and relay client state."),
547 Command.withHandler((flags) =>
548 runCloudCommand(
549 flags,
550 Effect.gen(function* () {
551 const secrets = yield* ServerSecretStore.ServerSecretStore;
552 const relayClient = yield* RelayClient.RelayClient;
553 const tokens = yield* CliTokenManager.CloudCliTokenManager;
554 const [
555 desired,
556 authenticated,
557 cloudUserId,
558 relayUrl,
559 publishAgentActivity,
560 revoked,
561 executable,
562 ] = yield* Effect.all(
563 [
564 CliState.readCliDesiredCloudLink,
565 tokens.hasCredential,
566 secrets.get(CLOUD_LINKED_USER_ID),
567 secrets.get(RELAY_URL_SECRET),
568 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
569 secrets.get(CLOUD_LINK_REVOKED),
570 relayClient.resolve,
571 ],
572 { concurrency: "unbounded" },
573 );
574 const status: CloudCliStatus = {
575 desired,
576 authenticated,
577 linked: Option.isSome(cloudUserId),
578 revoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
579 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
580 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
581 publishAgentActivity: isAgentActivityPublishingEnabledValue(
582 Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null,
583 ),
584 relayClient: executable,
585 };
586 yield* Console.log(formatCloudStatus(status, { json: flags.json }));
587 }),
588 {
589 quietLogs: flags.json,
590 },
591 ),
592 ),
593);
594
595const connectPublishCommand = Command.make("publish", {
596 ...projectLocationFlags,
597 disable: Flag.Boolean("disable").pipe(
598 Flag.withDescription("Stop publishing agent activity to your mobile clients."),
599 Flag.withDefault(false),
600 ),
601}).pipe(
602 Command.withDescription(
603 "Toggle publishing agent activity (push notifications and Live Activities) to your mobile clients.",
604 ),
605 Command.withHandler((flags) =>
606 runCloudCommand(
607 flags,
608 Effect.gen(function* () {
609 const secrets = yield* ServerSecretStore.ServerSecretStore;
610 const tokens = yield* CliTokenManager.CloudCliTokenManager;
611 const enabled = !flags.disable;
612 yield* secrets.set(
613 PUBLISH_AGENT_ACTIVITY_SECRET,
614 stringToBytes(enabled ? "true" : "false"),
615 );
616 if (!enabled) {
617 // If enabling scheduled a publish-only link that hasn't been
618 // provisioned yet, disabling must cancel it too — otherwise the next
619 // start still links an environment whose only purpose was publishing.
620 // A pending managed link is left alone; it exists for the tunnel.
621 const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
622 if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") {
623 yield* CliState.setCliDesiredCloudLink(false);
624 yield* Console.log("Cancelled the pending publish-only T3 Connect link.");
625 }
626 yield* Console.log("Publishing agent activity disabled.");
627 return;
628 }
629
630 yield* Console.log("Publishing agent activity enabled.");
631 const linked = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
632 if (linked) {
633 return;
634 }
635
636 // Publishing needs the relay to know this environment belongs to you.
637 // Establish a tunnel-free publish-only link automatically so signing in
638 // is all it takes — the mobile client can still reach the environment
639 // out of band without T3 Connect.
640 if (!(yield* tokens.hasCredential)) {
641 yield* Console.log(
642 "Run `t3 connect login` first so this environment can be authorized to publish.",
643 );
644 return;
645 }
646 // A link may already be desired (e.g. `t3 connect link` before the
647 // server's first start). Never downgrade it: a desired managed link
648 // also covers publishing, so only request a publish-only link when no
649 // link is pending at all.
650 if (yield* CliState.readCliDesiredCloudLink) {
651 yield* Console.log(
652 "A T3 Connect link is already pending. Start T3 to finish provisioning it; publishing starts once it links.",
653 );
654 return;
655 }
656 yield* CliState.setCliDesiredCloudLink(true, "publish_only");
657 yield* Console.log(
658 "Restart T3 to finish authorizing this environment to publish (no managed tunnel is created).",
659 );
660 }),
661 ),
662 ),
663);
664
665const connectUnlinkCommand = Command.make("unlink", {
666 ...projectLocationFlags,
667}).pipe(
668 Command.withDescription("Disable T3 Connect while retaining the stored authorization."),
669 Command.withHandler((flags) =>
670 runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })),
671 ),
672);
673
674const connectLogoutCommand = Command.make("logout", {
675 ...projectLocationFlags,
676}).pipe(
677 Command.withDescription("Disable T3 Connect and clear the stored CLI authorization."),
678 Command.withHandler((flags) =>
679 runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })),
680 ),
681);
682
683export const connectCommand = Command.make("connect", {
684 ...projectLocationFlags,
685 headless: headlessFlag,
686}).pipe(
687 Command.withDescription("Set up T3 Connect for this machine."),
688 Command.withHandler((flags) =>
689 runCloudCommand(
690 flags,
691 Effect.gen(function* () {
692 yield* Console.log("T3 Connect\n");
693 const linked = yield* linkEnvironmentForConnect(flags);
694 if (!linked) {
695 return;
696 }
697 // Show which account was linked so an unexpected identity (an
698 // authorization code for a different account) is visible before the
699 // machine is brought online.
700 yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`);
701
702 // Authorization is stored. If service setup fails, preserve it and
703 // show how to run the server manually.
704 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
705 if (background) {
706 const platform = yield* HostProcess.Platform;
707 yield* Console.log(
708 platform === "darwin"
709 ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup."
710 : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.",
711 );
712 return;
713 }
714 const serveCommand = yield* resolveCliCommand("serve");
715 yield* Console.log(
716 `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
717 );
718 }),
719 ),
720 ),
721 Command.withSubcommands([
722 connectLoginCommand,
723 connectLinkCommand,
724 connectPublishCommand,
725 connectStatusCommand,
726 connectUnlinkCommand,
727 connectLogoutCommand,
728 ]),
729);