Showing the changes to apps/server/src/cli (2 of 35 files). Show all
Unified · Split
| @@ -1,19 +1,45 @@ | |||
| 1 | import * as RelayClient from "@t3tools/shared/relayClient"; | 1 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 4 | import * as Console from "effect/Console"; | 5 | import * as Console from "effect/Console"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; | 7 | import * as Exit from "effect/Exit"; |
| 7 | import * as Logger from "effect/Logger"; | 8 | import * as Logger from "effect/Logger"; |
| 8 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 9 | import * as References from "effect/References"; | 10 | import * as References from "effect/References"; |
| 11 | import * as Terminal from "effect/Terminal"; | ||
| 10 | 12 | ||
| 11 | import { | 13 | import { |
| 12 | acquireRelayClientForLink, | 14 | acquireRelayClientForLink, |
| 15 | formatHeadlessAuthorizationPrompt, | ||
| 16 | formatRelayClientReady, | ||
| 17 | headlessSessionConfig, | ||
| 13 | isPublishAgentActivityEnabledValue, | 18 | isPublishAgentActivityEnabledValue, |
| 19 | recoverBootServiceOffer, | ||
| 14 | reportCloudDisconnectResults, | 20 | reportCloudDisconnectResults, |
| 15 | } from "./connect.ts"; | 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 | const managedExecutable = { | 43 | const managedExecutable = { |
| 18 | status: "available", | 44 | status: "available", |
| 19 | executablePath: "/tmp/cloudflared", | 45 | executablePath: "/tmp/cloudflared", |
| @@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line | |||
| 21 | version: RelayClient.CLOUDFLARED_VERSION, | 47 | version: RelayClient.CLOUDFLARED_VERSION, |
| 22 | } as const; | 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 | it.effect("does not install the relay client when the user declines the managed download", () => | 66 | it.effect("does not install the relay client when the user declines the managed download", () => |
| 25 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 26 | let installCalls = 0; | 68 | let installCalls = 0; |
| @@ -6,9 +6,12 @@ import { · 5 unmodified lines | |||
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | import * as RelayClient from "@t3tools/shared/relayClient"; | 8 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 9 | import * as Terminal from "effect/Terminal"; | ||
| 9 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| 12 | import * as Config from "effect/Config"; | ||
| 11 | import * as Console from "effect/Console"; | 13 | import * as Console from "effect/Console"; |
| 14 | import * as Crypto from "effect/Crypto"; | ||
| 12 | import * as Duration from "effect/Duration"; | 15 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 14 | import * as Exit from "effect/Exit"; | 17 | import * as Exit from "effect/Exit"; |
| @@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line | |||
| 16 | import * as Layer from "effect/Layer"; | 19 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | 20 | import * as Option from "effect/Option"; |
| 18 | import * as References from "effect/References"; | 21 | import * as References from "effect/References"; |
| 22 | import * as Schema from "effect/Schema"; | ||
| 19 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; | 23 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 20 | import { | 24 | import { |
| 21 | FetchHttpClient, | 25 | FetchHttpClient, |
| @@ -25,8 +29,10 @@ import { · 3 unmodified lines | |||
| 25 | } from "effect/unstable/http"; | 29 | } from "effect/unstable/http"; |
| 26 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | 30 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 27 | 31 | ||
| 32 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 28 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 33 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 29 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 34 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 35 | import * as BootService from "../cloud/bootService.ts"; | ||
| 30 | import * as CliState from "../cloud/CliState.ts"; | 36 | import * as CliState from "../cloud/CliState.ts"; |
| 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | 37 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 32 | import { | 38 | import { |
| @@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines | |||
| 38 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; | 44 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; |
| 39 | import * as ServerConfig from "../config.ts"; | 45 | import * as ServerConfig from "../config.ts"; |
| 40 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 46 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 47 | import * as ExternalLauncher from "../process/externalLauncher.ts"; | ||
| 48 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 41 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | 49 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 42 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | 50 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 43 | 51 | ||
| @@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines | |||
| 46 | Flag.withDefault(false), | 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 | function bytesToString(value: Uint8Array): string { | 132 | function bytesToString(value: Uint8Array): string { |
| 50 | return new TextDecoder().decode(value); | 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 | if (options.clearAuthorization) { | 396 | if (options.clearAuthorization) { |
| 314 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | 397 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 315 | yield* tokens.clear; | 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 | yield* reportCloudDisconnectResults({ | 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 | flags: { readonly baseDir: Option.Option<string> }, | 428 | flags: { readonly baseDir: Option.Option<string> }, |
| 331 | run: Effect.Effect< | 429 | run: Effect.Effect< |
| 332 | A, | 430 | A, |
| @@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines | |||
| 335 | | CliTokenManager.CloudCliTokenManager | 433 | | CliTokenManager.CloudCliTokenManager |
| 336 | | RelayClient.RelayClient | 434 | | RelayClient.RelayClient |
| 337 | | EnvironmentAuth.EnvironmentAuth | 435 | | EnvironmentAuth.EnvironmentAuth |
| 436 | | BootService.BootService | ||
| 437 | | Crypto.Crypto | ||
| 338 | | FileSystem.FileSystem | 438 | | FileSystem.FileSystem |
| 339 | | HttpClient.HttpClient | 439 | | HttpClient.HttpClient |
| 340 | | Prompt.Environment | 440 | | Prompt.Environment |
| @@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines | |||
| 344 | options?: { | 444 | options?: { |
| 345 | readonly quietLogs?: boolean; | 445 | readonly quietLogs?: boolean; |
| 346 | }, | 446 | }, |
| 347 | ) => | 447 | ) { |
| 348 | Effect.gen(function* () { | 448 | const logLevel = yield* GlobalFlag.LogLevel; |
| 349 | const logLevel = yield* GlobalFlag.LogLevel; | 449 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 350 | const config = yield* resolveCliAuthConfig(flags, logLevel); | 450 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; |
| 351 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | 451 | const runtimeLayer = Layer.mergeAll( |
| 352 | const runtimeLayer = Layer.mergeAll( | 452 | ServerSecretStore.layer, |
| 353 | ServerSecretStore.layer, | 453 | CliTokenManager.layer.pipe( |
| 354 | CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | 454 | Layer.provide(ServerSecretStore.layer), |
| 355 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), | 455 | Layer.provide(ExternalLauncher.layer), |
| 356 | EnvironmentAuth.runtimeLayer, | 456 | ), |
| 357 | ServerEnvironment.layer, | 457 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 358 | headlessRelayClientTracingLayer, | 458 | EnvironmentAuth.runtimeLayer, |
| 359 | ).pipe( | 459 | ServerEnvironment.layer, |
| 360 | Layer.provideMerge(FetchHttpClient.layer), | 460 | BootService.layer({ |
| 361 | Layer.provideMerge(ServerConfig.layer(config)), | 461 | baseDir: config.baseDir, |
| 362 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | 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)); | 492 | if (Option.isNone(installed)) { |
| 365 | }); | 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 | const connectLoginCommand = Command.make("login", { | 508 | const connectLoginCommand = Command.make("login", { |
| 368 | ...projectLocationFlags, | 509 | ...projectLocationFlags, |
| 510 | headless: headlessFlag, | ||
| 369 | }).pipe( | 511 | }).pipe( |
| 370 | Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), | 512 | Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), |
| 371 | Command.withHandler((flags) => | 513 | Command.withHandler((flags) => |
| 372 | runCloudCommand( | 514 | runCloudCommand( |
| 373 | flags, | 515 | flags, |
| 374 | Effect.gen(function* () { | 516 | Effect.gen(function* () { |
| 375 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | 517 | yield* Console.log("T3 Connect\n"); |
| 376 | yield* tokens.get; | 518 | const identity = yield* authorizeCli(flags); |
| 377 | yield* Console.log("Signed in to T3 Connect."); | 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 | const connectLinkCommand = Command.make("link", { | 525 | const connectLinkCommand = Command.make("link", { |
| 384 | ...projectLocationFlags, | 526 | ...projectLocationFlags, |
| 527 | headless: headlessFlag, | ||
| 385 | publishOnly: Flag.boolean("publish-only").pipe( | 528 | publishOnly: Flag.boolean("publish-only").pipe( |
| 386 | Flag.withDescription( | 529 | Flag.withDescription( |
| 387 | "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).", | 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 | runCloudCommand( | 537 | runCloudCommand( |
| 395 | flags, | 538 | flags, |
| 396 | Effect.gen(function* () { | 539 | Effect.gen(function* () { |
| 397 | // A publish-only link needs no Cloudflare tunnel, so skip installing the | 540 | yield* Console.log("T3 Connect\n"); |
| 398 | // relay client entirely. | 541 | const linked = yield* linkEnvironmentForConnect(flags); |
| 399 | if (!flags.publishOnly) { | 542 | if (linked) { |
| 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 | } | ||
| 410 | yield* Console.log( | 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( | 686 | const offerBootService = Effect.gen(function* () { |
| 570 | Command.withDescription("Manage headless T3 Connect access."), | 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 | Command.withSubcommands([ | 760 | Command.withSubcommands([ |
| 572 | connectLoginCommand, | 761 | connectLoginCommand, |
| 573 | connectLinkCommand, | 762 | connectLinkCommand, |