Showing the changes to apps/server/src/cli (2 of 35 files). Show all
Unified · Split
| @@ -1,19 +1,45 @@ | ||
| 1 | 1 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 4 | 5 | import * as Console from "effect/Console"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Exit from "effect/Exit"; |
| 7 | 8 | import * as Logger from "effect/Logger"; |
| 8 | 9 | import * as Option from "effect/Option"; |
| 9 | 10 | import * as References from "effect/References"; |
| 11 | +import * as Terminal from "effect/Terminal"; | |
| 10 | 12 | |
| 11 | 13 | import { |
| 12 | 14 | acquireRelayClientForLink, |
| 15 | + formatHeadlessAuthorizationPrompt, | |
| 16 | + formatRelayClientReady, | |
| 17 | + headlessSessionConfig, | |
| 13 | 18 | isPublishAgentActivityEnabledValue, |
| 19 | + recoverBootServiceOffer, | |
| 14 | 20 | reportCloudDisconnectResults, |
| 15 | 21 | } from "./connect.ts"; |
| 16 | 22 | |
| 23 | +it("explains how to complete headless authorization", () => { | |
| 24 | + assert.equal( | |
| 25 | + formatHeadlessAuthorizationPrompt("https://example.test/connect"), | |
| 26 | + [ | |
| 27 | + "Headless authorization", | |
| 28 | + "Open this URL on a device with a browser:", | |
| 29 | + " https://example.test/connect", | |
| 30 | + "", | |
| 31 | + "After signing in, return here and enter the code shown in your browser.", | |
| 32 | + ].join("\n"), | |
| 33 | + ); | |
| 34 | +}); | |
| 35 | + | |
| 36 | +it("formats relay readiness without printing its installation path", () => { | |
| 37 | + assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); | |
| 38 | +}); | |
| 39 | + | |
| 40 | +const readHeadlessSessionConfig = (env: Record<string, string>) => | |
| 41 | + headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); | |
| 42 | + | |
| 17 | 43 | const managedExecutable = { |
| 18 | 44 | status: "available", |
| 19 | 45 | executablePath: "/tmp/cloudflared", |
| @@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line | ||
| 21 | 47 | version: RelayClient.CLOUDFLARED_VERSION, |
| 22 | 48 | } as const; |
| 23 | 49 | |
| 50 | +it.effect("detects headless operation from individual SSH config values", () => | |
| 51 | + Effect.gen(function* () { | |
| 52 | + assert.isFalse(yield* readHeadlessSessionConfig({})); | |
| 53 | + assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" })); | |
| 54 | + assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" })); | |
| 55 | + assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" })); | |
| 56 | + }), | |
| 57 | +); | |
| 58 | + | |
| 59 | +it.effect("treats cancelling optional background setup as a successful skip", () => | |
| 60 | + Effect.gen(function* () { | |
| 61 | + const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); | |
| 62 | + assert.isFalse(result); | |
| 63 | + }), | |
| 64 | +); | |
| 65 | + | |
| 24 | 66 | it.effect("does not install the relay client when the user declines the managed download", () => |
| 25 | 67 | Effect.gen(function* () { |
| 26 | 68 | let installCalls = 0; |
| @@ -6,9 +6,12 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | 8 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 9 | +import * as Terminal from "effect/Terminal"; | |
| 9 | 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 10 | 11 | import * as Cause from "effect/Cause"; |
| 12 | +import * as Config from "effect/Config"; | |
| 11 | 13 | import * as Console from "effect/Console"; |
| 14 | +import * as Crypto from "effect/Crypto"; | |
| 12 | 15 | import * as Duration from "effect/Duration"; |
| 13 | 16 | import * as Effect from "effect/Effect"; |
| 14 | 17 | import * as Exit from "effect/Exit"; |
| @@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line | ||
| 16 | 19 | import * as Layer from "effect/Layer"; |
| 17 | 20 | import * as Option from "effect/Option"; |
| 18 | 21 | import * as References from "effect/References"; |
| 22 | +import * as Schema from "effect/Schema"; | |
| 19 | 23 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 20 | 24 | import { |
| 21 | 25 | FetchHttpClient, |
| @@ -25,8 +29,10 @@ import { · 3 unmodified lines | ||
| 25 | 29 | } from "effect/unstable/http"; |
| 26 | 30 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 27 | 31 | |
| 32 | +import packageJson from "../../package.json" with { type: "json" }; | |
| 28 | 33 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 29 | 34 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 35 | +import * as BootService from "../cloud/bootService.ts"; | |
| 30 | 36 | import * as CliState from "../cloud/CliState.ts"; |
| 31 | 37 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 32 | 38 | import { |
| @@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines | ||
| 38 | 44 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; |
| 39 | 45 | import * as ServerConfig from "../config.ts"; |
| 40 | 46 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 47 | +import * as ExternalLauncher from "../process/externalLauncher.ts"; | |
| 48 | +import * as ProcessRunner from "../processRunner.ts"; | |
| 41 | 49 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 42 | 50 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 43 | 51 | |
| @@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines | ||
| 46 | 54 | Flag.withDefault(false), |
| 47 | 55 | ); |
| 48 | 56 | |
| 57 | +const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); | |
| 58 | + | |
| 59 | +const headlessFlag = Flag.boolean("headless").pipe( | |
| 60 | + Flag.withDescription("Authorize without a local browser using out-of-band OAuth."), | |
| 61 | + Flag.withDefault(false), | |
| 62 | +); | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * Inside an SSH session there is no local browser to complete the loopback | |
| 66 | + * OAuth callback, so out-of-band OAuth is the only flow that can work. | |
| 67 | + */ | |
| 68 | +export const headlessSessionConfig = Config.all({ | |
| 69 | + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), | |
| 70 | + sshTty: Config.string("SSH_TTY").pipe(Config.option), | |
| 71 | +}).pipe( | |
| 72 | + Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)), | |
| 73 | +); | |
| 74 | + | |
| 75 | +const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")( | |
| 76 | + function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) { | |
| 77 | + yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl)); | |
| 78 | + return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate })); | |
| 79 | + }, | |
| 80 | +); | |
| 81 | + | |
| 82 | +export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { | |
| 83 | + return [ | |
| 84 | + "Headless authorization", | |
| 85 | + "Open this URL on a device with a browser:", | |
| 86 | + ` ${authorizeUrl}`, | |
| 87 | + "", | |
| 88 | + "After signing in, return here and enter the code shown in your browser.", | |
| 89 | + ].join("\n"); | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** Returns the connected account identity, if the flow could determine one. */ | |
| 93 | +const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { | |
| 94 | + readonly headless: boolean; | |
| 95 | +}) { | |
| 96 | + const tokens = yield* CliTokenManager.CloudCliTokenManager; | |
| 97 | + const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig); | |
| 98 | + if (!useOutOfBandOAuth) { | |
| 99 | + const authorization = yield* tokens.get; | |
| 100 | + if (authorization._tag === "Authorized") { | |
| 101 | + return authorization.token.identity ?? null; | |
| 102 | + } | |
| 103 | + yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below."); | |
| 104 | + } | |
| 105 | + // A stored credential whose refresh fails (revoked, expired grant) must | |
| 106 | + // fall through to a fresh out-of-band authorization, not dead-end the command. | |
| 107 | + const existing = yield* tokens.getExisting.pipe( | |
| 108 | + Effect.catchTag("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.outOfBandOAuthLogin( | |
| 118 | + promptForOutOfBandOAuthCode, | |
| 119 | + ).pipe( | |
| 120 | + Effect.mapError((cause) => | |
| 121 | + // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI | |
| 122 | + // cancels quietly instead of dumping an authorization error. | |
| 123 | + Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause) | |
| 124 | + ? cause | |
| 125 | + : new CliTokenManager.CloudCliAuthorizationError({ cause }), | |
| 126 | + ), | |
| 127 | + ); | |
| 128 | + yield* tokens.store(token); | |
| 129 | + return identity; | |
| 130 | +}); | |
| 131 | + | |
| 49 | 132 | function bytesToString(value: Uint8Array): string { |
| 50 | 133 | return new TextDecoder().decode(value); |
| 51 | 134 | } |
| @@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 261 unmodified lines | ||
| 313 | 396 | if (options.clearAuthorization) { |
| 314 | 397 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 315 | 398 | yield* tokens.clear; |
| 399 | + | |
| 400 | + // uninstall itself no-ops when nothing is installed (and on non-Linux), | |
| 401 | + // so no status pre-check that could mask a real removal failure. | |
| 402 | + const bootService = yield* BootService.BootService; | |
| 403 | + yield* bootService.uninstall.pipe( | |
| 404 | + Effect.tap((removed) => | |
| 405 | + removed ? Console.log("Removed the T3 Code background service.") : Effect.void, | |
| 406 | + ), | |
| 407 | + Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), | |
| 408 | + Effect.catch((error) => | |
| 409 | + Console.warn(`Could not remove the background service: ${error.message}`).pipe( | |
| 410 | + Effect.as(false), | |
| 411 | + ), | |
| 412 | + ), | |
| 413 | + ); | |
| 316 | 414 | } |
| 317 | 415 | |
| 318 | 416 | yield* reportCloudDisconnectResults({ |
| @@ -326,7 +424,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 7 unmodified lines | ||
| 326 | 424 | } |
| 327 | 425 | }); |
| 328 | 426 | |
| 329 | −const runCloudCommand = <A, E>( | |
| 427 | +const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>( | |
| 330 | 428 | flags: { readonly baseDir: Option.Option<string> }, |
| 331 | 429 | run: Effect.Effect< |
| 332 | 430 | A, |
| @@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines | ||
| 335 | 433 | | CliTokenManager.CloudCliTokenManager |
| 336 | 434 | | RelayClient.RelayClient |
| 337 | 435 | | EnvironmentAuth.EnvironmentAuth |
| 436 | + | BootService.BootService | |
| 437 | + | Crypto.Crypto | |
| 338 | 438 | | FileSystem.FileSystem |
| 339 | 439 | | HttpClient.HttpClient |
| 340 | 440 | | Prompt.Environment |
| @@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines | ||
| 344 | 444 | options?: { |
| 345 | 445 | readonly quietLogs?: boolean; |
| 346 | 446 | }, |
| 347 | −) => | |
| 348 | − Effect.gen(function* () { | |
| 349 | − const logLevel = yield* GlobalFlag.LogLevel; | |
| 350 | − const config = yield* resolveCliAuthConfig(flags, logLevel); | |
| 351 | − const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | |
| 352 | − const runtimeLayer = Layer.mergeAll( | |
| 353 | − ServerSecretStore.layer, | |
| 354 | − CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 355 | − RelayClient.layerCloudflared({ baseDir: config.baseDir }), | |
| 356 | − EnvironmentAuth.runtimeLayer, | |
| 357 | − ServerEnvironment.layer, | |
| 358 | − headlessRelayClientTracingLayer, | |
| 359 | − ).pipe( | |
| 360 | − Layer.provideMerge(FetchHttpClient.layer), | |
| 361 | − Layer.provideMerge(ServerConfig.layer(config)), | |
| 362 | − Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | |
| 447 | +) { | |
| 448 | + const logLevel = yield* GlobalFlag.LogLevel; | |
| 449 | + const config = yield* resolveCliAuthConfig(flags, logLevel); | |
| 450 | + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | |
| 451 | + const runtimeLayer = Layer.mergeAll( | |
| 452 | + ServerSecretStore.layer, | |
| 453 | + CliTokenManager.layer.pipe( | |
| 454 | + Layer.provide(ServerSecretStore.layer), | |
| 455 | + Layer.provide(ExternalLauncher.layer), | |
| 456 | + ), | |
| 457 | + RelayClient.layerCloudflared({ baseDir: config.baseDir }), | |
| 458 | + EnvironmentAuth.runtimeLayer, | |
| 459 | + ServerEnvironment.layer, | |
| 460 | + BootService.layer({ | |
| 461 | + baseDir: config.baseDir, | |
| 462 | + logsDir: config.logsDir, | |
| 463 | + cliVersion: packageJson.version, | |
| 464 | + }).pipe(Layer.provide(ProcessRunner.layer)), | |
| 465 | + headlessRelayClientTracingLayer, | |
| 466 | + ).pipe( | |
| 467 | + Layer.provideMerge(FetchHttpClient.layer), | |
| 468 | + Layer.provideMerge(ServerConfig.layer(config)), | |
| 469 | + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | |
| 470 | + ); | |
| 471 | + return yield* run.pipe(Effect.provide(runtimeLayer)); | |
| 472 | +}); | |
| 473 | + | |
| 474 | +const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : ""); | |
| 475 | + | |
| 476 | +export function formatRelayClientReady(version: string): string { | |
| 477 | + return `✓ Relay client ready · cloudflared ${version}`; | |
| 478 | +} | |
| 479 | + | |
| 480 | +const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: { | |
| 481 | + readonly headless: boolean; | |
| 482 | + readonly publishOnly?: boolean; | |
| 483 | +}) { | |
| 484 | + const publishOnly = options.publishOnly ?? false; | |
| 485 | + if (!publishOnly) { | |
| 486 | + const relayClient = yield* RelayClient.RelayClient; | |
| 487 | + const installed = yield* acquireRelayClientForLink( | |
| 488 | + relayClient, | |
| 489 | + confirmRelayClientInstall, | |
| 490 | + reportRelayClientInstallProgress, | |
| 363 | 491 | ); |
| 364 | − return yield* run.pipe(Effect.provide(runtimeLayer)); | |
| 365 | − }); | |
| 492 | + if (Option.isNone(installed)) { | |
| 493 | + yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); | |
| 494 | + return null; | |
| 495 | + } | |
| 496 | + yield* Console.log(formatRelayClientReady(installed.value.version)); | |
| 497 | + } | |
| 498 | + | |
| 499 | + const identity = yield* authorizeCli(options); | |
| 500 | + yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); | |
| 501 | + if (publishOnly) { | |
| 502 | + const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 503 | + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); | |
| 504 | + } | |
| 505 | + return { identity } as const; | |
| 506 | +}); | |
| 366 | 507 | |
| 367 | 508 | const connectLoginCommand = Command.make("login", { |
| 368 | 509 | ...projectLocationFlags, |
| 510 | + headless: headlessFlag, | |
| 369 | 511 | }).pipe( |
| 370 | 512 | Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), |
| 371 | 513 | Command.withHandler((flags) => |
| 372 | 514 | runCloudCommand( |
| 373 | 515 | flags, |
| 374 | 516 | Effect.gen(function* () { |
| 375 | − const tokens = yield* CliTokenManager.CloudCliTokenManager; | |
| 376 | − yield* tokens.get; | |
| 377 | − yield* Console.log("Signed in to T3 Connect."); | |
| 517 | + yield* Console.log("T3 Connect\n"); | |
| 518 | + const identity = yield* authorizeCli(flags); | |
| 519 | + yield* Console.log(`✓ Signed in${connectedAs(identity)}`); | |
| 378 | 520 | }), |
| 379 | 521 | ), |
| 380 | 522 | ), |
| @@ -382,6 +524,7 @@ const connectLoginCommand = Command.make("login", { · 1 unmodified line | ||
| 382 | 524 | |
| 383 | 525 | const connectLinkCommand = Command.make("link", { |
| 384 | 526 | ...projectLocationFlags, |
| 527 | + headless: headlessFlag, | |
| 385 | 528 | publishOnly: Flag.boolean("publish-only").pipe( |
| 386 | 529 | Flag.withDescription( |
| 387 | 530 | "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).", |
| @@ -394,41 +537,15 @@ const connectLinkCommand = Command.make("link", { · 6 unmodified lines | ||
| 394 | 537 | runCloudCommand( |
| 395 | 538 | flags, |
| 396 | 539 | Effect.gen(function* () { |
| 397 | − // A publish-only link needs no Cloudflare tunnel, so skip installing the | |
| 398 | − // relay client entirely. | |
| 399 | − if (!flags.publishOnly) { | |
| 400 | − const relayClient = yield* RelayClient.RelayClient; | |
| 401 | − const installed = yield* acquireRelayClientForLink( | |
| 402 | − relayClient, | |
| 403 | − confirmRelayClientInstall, | |
| 404 | − reportRelayClientInstallProgress, | |
| 405 | − ); | |
| 406 | − if (Option.isNone(installed)) { | |
| 407 | − yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); | |
| 408 | − return; | |
| 409 | − } | |
| 540 | + yield* Console.log("T3 Connect\n"); | |
| 541 | + const linked = yield* linkEnvironmentForConnect(flags); | |
| 542 | + if (linked) { | |
| 410 | 543 | yield* Console.log( |
| 411 | − `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, | |
| 544 | + flags.publishOnly | |
| 545 | + ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).` | |
| 546 | + : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`, | |
| 412 | 547 | ); |
| 413 | 548 | } |
| 414 | − | |
| 415 | − const tokens = yield* CliTokenManager.CloudCliTokenManager; | |
| 416 | − yield* tokens.get; | |
| 417 | − yield* CliState.setCliDesiredCloudLink( | |
| 418 | − true, | |
| 419 | − flags.publishOnly ? "publish_only" : "managed", | |
| 420 | − ); | |
| 421 | − if (flags.publishOnly) { | |
| 422 | − // A publish-only link exists solely to publish; without the publish | |
| 423 | − // flag the link would be inert and the success message a lie. | |
| 424 | − const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 425 | − yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); | |
| 426 | − } | |
| 427 | − yield* Console.log( | |
| 428 | − flags.publishOnly | |
| 429 | − ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)." | |
| 430 | − : "This T3 environment will be available through T3 Connect the next time T3 starts.", | |
| 431 | − ); | |
| 432 | 549 | }), |
| 433 | 550 | ), |
| 434 | 551 | ), |
| @@ -566,8 +683,80 @@ const connectLogoutCommand = Command.make("logout", { · 131 unmodified lines | ||
| 566 | 683 | ), |
| 567 | 684 | ); |
| 568 | 685 | |
| 569 | −export const connectCommand = Command.make("connect").pipe( | |
| 570 | − Command.withDescription("Manage headless T3 Connect access."), | |
| 686 | +const offerBootService = Effect.gen(function* () { | |
| 687 | + const bootService = yield* BootService.BootService; | |
| 688 | + const { supported, installed, current } = yield* bootService.status; | |
| 689 | + if (!supported) { | |
| 690 | + // Don't prompt for something that can only fail; background setup is | |
| 691 | + // Linux/systemd-only for now. | |
| 692 | + return false; | |
| 693 | + } | |
| 694 | + if (installed && current) { | |
| 695 | + yield* Console.log("T3 Code is already set up to run in the background on this machine."); | |
| 696 | + return true; | |
| 697 | + } | |
| 698 | + const wanted = yield* Prompt.run( | |
| 699 | + Prompt.confirm({ | |
| 700 | + message: installed | |
| 701 | + ? "The installed T3 Code background service is from an older setup. Update it now?" | |
| 702 | + : "Run T3 Code in the background whenever this machine boots? " + | |
| 703 | + "It stays reachable through T3 Connect even after you log out.", | |
| 704 | + initial: true, | |
| 705 | + }), | |
| 706 | + ); | |
| 707 | + if (!wanted) { | |
| 708 | + return false; | |
| 709 | + } | |
| 710 | + const plan = yield* bootService.install; | |
| 711 | + yield* Console.log(`Background service installed. Logs: ${plan.logPath}`); | |
| 712 | + return true; | |
| 713 | +}); | |
| 714 | + | |
| 715 | +export const recoverBootServiceOffer = <R>( | |
| 716 | + offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>, | |
| 717 | +) => | |
| 718 | + offer.pipe( | |
| 719 | + Effect.catchTags({ | |
| 720 | + QuitError: () => Effect.succeed(false), | |
| 721 | + BootServiceUnsupportedError: (error) => | |
| 722 | + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), | |
| 723 | + BootServiceCommandError: (error) => | |
| 724 | + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | |
| 725 | + BootServiceInstallError: (error) => | |
| 726 | + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | |
| 727 | + }), | |
| 728 | + ); | |
| 729 | + | |
| 730 | +export const connectCommand = Command.make("connect", { | |
| 731 | + ...projectLocationFlags, | |
| 732 | + headless: headlessFlag, | |
| 733 | +}).pipe( | |
| 734 | + Command.withDescription("Set up T3 Connect for this machine."), | |
| 735 | + Command.withHandler((flags) => | |
| 736 | + runCloudCommand( | |
| 737 | + flags, | |
| 738 | + Effect.gen(function* () { | |
| 739 | + yield* Console.log("T3 Connect\n"); | |
| 740 | + const linked = yield* linkEnvironmentForConnect(flags); | |
| 741 | + if (!linked) { | |
| 742 | + return; | |
| 743 | + } | |
| 744 | + // Show which account was linked so an unexpected identity (an | |
| 745 | + // authorization code for a different account) is visible before the | |
| 746 | + // machine is brought online. | |
| 747 | + yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`); | |
| 748 | + | |
| 749 | + // Connect itself already succeeded; a boot-service failure must not | |
| 750 | + // fail the command, just tell the user what happened and move on. | |
| 751 | + const background = yield* recoverBootServiceOffer(offerBootService); | |
| 752 | + yield* Console.log( | |
| 753 | + background | |
| 754 | + ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." | |
| 755 | + : "\nNext\n Start the server with `t3 serve` to make this machine reachable.", | |
| 756 | + ); | |
| 757 | + }), | |
| 758 | + ), | |
| 759 | + ), | |
| 571 | 760 | Command.withSubcommands([ |
| 572 | 761 | connectLoginCommand, |
| 573 | 762 | connectLinkCommand, |