Showing the changes to apps/server/src (14 of 35 files). Show all
Unified · Split
| @@ -236,7 +236,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 235 unmodified lines | ||
| 236 | 236 | }), |
| 237 | 237 | ); |
| 238 | 238 | |
| 239 | − it.effect("logs in to headless connect without enabling access", () => | |
| 239 | + it.effect("accepts the --headless login override without enabling access", () => | |
| 240 | 240 | Effect.gen(function* () { |
| 241 | 241 | const baseDir = NodeFS.mkdtempSync( |
| 242 | 242 | NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"), |
| @@ -254,7 +254,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 11 unmodified lines | ||
| 254 | 254 | ); |
| 255 | 255 | |
| 256 | 256 | const login = yield* captureStdout( |
| 257 | − runConnectCli(["connect", "login", "--base-dir", baseDir]), | |
| 257 | + runConnectCli(["connect", "login", "--base-dir", baseDir, "--headless"]), | |
| 258 | 258 | ); |
| 259 | 259 | const status = yield* captureStdout( |
| 260 | 260 | runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]), |
| @@ -265,7 +265,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 4 unmodified lines | ||
| 265 | 265 | readonly authenticated: boolean; |
| 266 | 266 | }; |
| 267 | 267 | |
| 268 | − assert.equal(login.output, "Signed in to T3 Connect."); | |
| 268 | + assert.equal(login.output, "✓ Signed in"); | |
| 269 | 269 | assert.isFalse(decoded.desired); |
| 270 | 270 | assert.isTrue(decoded.authenticated); |
| 271 | 271 | }), |
| @@ -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, |
| @@ -0,0 +1,264 @@ | ||
| 1 | +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { assert, it } from "@effect/vitest"; | |
| 4 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 5 | +import * as Crypto from "effect/Crypto"; | |
| 6 | +import * as Deferred from "effect/Deferred"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Encoding from "effect/Encoding"; | |
| 9 | +import * as Layer from "effect/Layer"; | |
| 10 | +import * as Option from "effect/Option"; | |
| 11 | +import * as Queue from "effect/Queue"; | |
| 12 | +import * as Schema from "effect/Schema"; | |
| 13 | +import * as Terminal from "effect/Terminal"; | |
| 14 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 15 | +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | |
| 16 | + | |
| 17 | +import * as CliTokenManager from "./CliTokenManager.ts"; | |
| 18 | +import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts"; | |
| 19 | + | |
| 20 | +// pk_test_<base64 of "clerk.example.test$"> | |
| 21 | +const TEST_ENV = { | |
| 22 | + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", | |
| 23 | + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test", | |
| 24 | + T3CODE_HOSTED_APP_URL: "https://hosted.example.test", | |
| 25 | +}; | |
| 26 | + | |
| 27 | +interface RecordedTokenRequest { | |
| 28 | + readonly url: string; | |
| 29 | + readonly params: URLSearchParams; | |
| 30 | +} | |
| 31 | + | |
| 32 | +// A JWT whose payload claims { email: "theo@example.test" } (signature is not | |
| 33 | +// verified — the CLI only reads the claim to display the connected account). | |
| 34 | +const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") })); | |
| 35 | +const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String })); | |
| 36 | +const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson); | |
| 37 | +const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson); | |
| 38 | +const idTokenWithEmail = (() => { | |
| 39 | + const header = Encoding.encodeBase64Url(encodeTestIdTokenHeader({ alg: "none" })); | |
| 40 | + const payload = Encoding.encodeBase64Url( | |
| 41 | + encodeTestIdTokenPayload({ email: "theo@example.test" }), | |
| 42 | + ); | |
| 43 | + return `${header}.${payload}.`; | |
| 44 | +})(); | |
| 45 | + | |
| 46 | +const TestTokenResponseJson = Schema.fromJsonString( | |
| 47 | + Schema.Struct({ | |
| 48 | + access_token: Schema.String, | |
| 49 | + refresh_token: Schema.String, | |
| 50 | + id_token: Schema.String, | |
| 51 | + expires_in: Schema.Number, | |
| 52 | + token_type: Schema.String, | |
| 53 | + }), | |
| 54 | +); | |
| 55 | +const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson); | |
| 56 | + | |
| 57 | +const makeTokenEndpointLayer = ( | |
| 58 | + requests: Array<RecordedTokenRequest>, | |
| 59 | + options?: { readonly idToken?: string }, | |
| 60 | +) => | |
| 61 | + Layer.succeed( | |
| 62 | + HttpClient.HttpClient, | |
| 63 | + HttpClient.make((request) => | |
| 64 | + Effect.sync(() => { | |
| 65 | + const body = | |
| 66 | + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; | |
| 67 | + requests.push({ url: request.url, params: new URLSearchParams(body) }); | |
| 68 | + return HttpClientResponse.fromWeb( | |
| 69 | + request, | |
| 70 | + new Response( | |
| 71 | + encodeTestTokenResponse({ | |
| 72 | + access_token: "access-token-1", | |
| 73 | + refresh_token: "refresh-token-1", | |
| 74 | + id_token: options?.idToken ?? idTokenWithEmail, | |
| 75 | + expires_in: 3600, | |
| 76 | + token_type: "bearer", | |
| 77 | + }), | |
| 78 | + { status: 200, headers: { "content-type": "application/json" } }, | |
| 79 | + ), | |
| 80 | + ); | |
| 81 | + }), | |
| 82 | + ), | |
| 83 | + ); | |
| 84 | + | |
| 85 | +const provideTestEnv = Effect.provide( | |
| 86 | + ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), | |
| 87 | +); | |
| 88 | + | |
| 89 | +const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); | |
| 90 | + | |
| 91 | +class PromptRejectedError extends Schema.TaggedErrorClass<PromptRejectedError>()( | |
| 92 | + "PromptRejectedError", | |
| 93 | + { message: Schema.String }, | |
| 94 | +) {} | |
| 95 | + | |
| 96 | +it("formats loopback authorization with a headless-host fallback", () => { | |
| 97 | + assert.equal( | |
| 98 | + CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), | |
| 99 | + [ | |
| 100 | + "Open this URL to authorize T3 Connect:", | |
| 101 | + " https://clerk.example.test/authorize", | |
| 102 | + "", | |
| 103 | + "Press \u001b[1mEnter\u001b[22m to open it in your browser.", | |
| 104 | + "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", | |
| 105 | + ].join("\n"), | |
| 106 | + ); | |
| 107 | +}); | |
| 108 | + | |
| 109 | +const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) => | |
| 110 | + Terminal.make({ | |
| 111 | + columns: Effect.succeed(80), | |
| 112 | + rows: Effect.succeed(24), | |
| 113 | + readInput: Effect.succeed(Queue.asDequeue(queue)), | |
| 114 | + readLine: Effect.never, | |
| 115 | + display: () => Effect.void, | |
| 116 | + }); | |
| 117 | + | |
| 118 | +const userInput = (name: string): Terminal.UserInput => ({ | |
| 119 | + input: Option.some(name), | |
| 120 | + key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() }, | |
| 121 | +}); | |
| 122 | + | |
| 123 | +it.effect("opens the browser on Enter and switches the active flow on H", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const queue = yield* Queue.make<Terminal.UserInput>(); | |
| 126 | + yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]); | |
| 127 | + const opened: Array<string> = []; | |
| 128 | + | |
| 129 | + const result = yield* CliTokenManager.waitForLoopbackAuthorization({ | |
| 130 | + authorizationUrl: "https://clerk.example.test/authorize", | |
| 131 | + callback: Effect.never, | |
| 132 | + terminal: makeTestTerminal(queue), | |
| 133 | + launchBrowser: (url) => | |
| 134 | + Effect.sync(() => { | |
| 135 | + opened.push(url); | |
| 136 | + }), | |
| 137 | + }); | |
| 138 | + | |
| 139 | + assert.deepEqual(opened, ["https://clerk.example.test/authorize"]); | |
| 140 | + assert.deepEqual(result, { _tag: "HeadlessRequested" }); | |
| 141 | + }), | |
| 142 | +); | |
| 143 | + | |
| 144 | +it.effect("finishes normally when the browser callback wins", () => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + const queue = yield* Queue.make<Terminal.UserInput>(); | |
| 147 | + const callback = yield* Deferred.make<string>(); | |
| 148 | + yield* Deferred.succeed(callback, "clerk-code-123"); | |
| 149 | + | |
| 150 | + const result = yield* CliTokenManager.waitForLoopbackAuthorization({ | |
| 151 | + authorizationUrl: "https://clerk.example.test/authorize", | |
| 152 | + callback: Deferred.await(callback), | |
| 153 | + terminal: makeTestTerminal(queue), | |
| 154 | + launchBrowser: () => Effect.die("browser launch should not run"), | |
| 155 | + }); | |
| 156 | + | |
| 157 | + assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" }); | |
| 158 | + }), | |
| 159 | +); | |
| 160 | + | |
| 161 | +it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => { | |
| 162 | + it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () => | |
| 163 | + Effect.gen(function* () { | |
| 164 | + const requests: Array<RecordedTokenRequest> = []; | |
| 165 | + let seenAuthorizeUrl = ""; | |
| 166 | + | |
| 167 | + const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( | |
| 168 | + ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) => | |
| 169 | + Effect.gen(function* () { | |
| 170 | + seenAuthorizeUrl = authorizeUrl; | |
| 171 | + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); | |
| 172 | + assert.isNotNull(request); | |
| 173 | + return yield* validate(`clerk-code-123.${request!.state}`).pipe( | |
| 174 | + Effect.mapError((message) => new PromptRejectedError({ message })), | |
| 175 | + ); | |
| 176 | + }), | |
| 177 | + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv); | |
| 178 | + | |
| 179 | + const authorizeUrl = new URL(seenAuthorizeUrl); | |
| 180 | + assert.equal(authorizeUrl.origin, "https://hosted.example.test"); | |
| 181 | + assert.equal(authorizeUrl.pathname, "/connect"); | |
| 182 | + const request = readConnectAuthorizeRequest(authorizeUrl); | |
| 183 | + assert.isNotNull(request); | |
| 184 | + assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/); | |
| 185 | + | |
| 186 | + assert.equal(token.accessToken, "access-token-1"); | |
| 187 | + assert.equal(token.refreshToken, "refresh-token-1"); | |
| 188 | + assert.equal(token.identity, "theo@example.test"); | |
| 189 | + // The id_token's email claim is surfaced so connect can show the account. | |
| 190 | + assert.equal(identity, "theo@example.test"); | |
| 191 | + | |
| 192 | + assert.lengthOf(requests, 1); | |
| 193 | + const exchange = requests[0]!; | |
| 194 | + assert.equal(exchange.url, "https://clerk.example.test/oauth/token"); | |
| 195 | + assert.equal(exchange.params.get("grant_type"), "authorization_code"); | |
| 196 | + assert.equal(exchange.params.get("code"), "clerk-code-123"); | |
| 197 | + assert.equal( | |
| 198 | + exchange.params.get("redirect_uri"), | |
| 199 | + "https://hosted.example.test/connect/callback", | |
| 200 | + ); | |
| 201 | + assert.equal(exchange.params.get("client_id"), "oauth_client_test"); | |
| 202 | + // The verifier must hash to the challenge advertised in the authorize URL. | |
| 203 | + const verifier = exchange.params.get("code_verifier"); | |
| 204 | + assert.isNotNull(verifier); | |
| 205 | + const crypto = yield* Crypto.Crypto; | |
| 206 | + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!)); | |
| 207 | + assert.equal(Encoding.encodeBase64Url(digest), request!.challenge); | |
| 208 | + }), | |
| 209 | + ); | |
| 210 | + | |
| 211 | + it.effect("rejects out-of-band codes whose state does not match the request", () => | |
| 212 | + Effect.gen(function* () { | |
| 213 | + const requests: Array<RecordedTokenRequest> = []; | |
| 214 | + | |
| 215 | + const validationErrors: Array<string> = []; | |
| 216 | + const result = yield* CliTokenManager.outOfBandOAuthLogin( | |
| 217 | + ({ validate }: OutOfBandOAuthPromptInput) => | |
| 218 | + validate("clerk-code-123.wrong-state").pipe( | |
| 219 | + Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))), | |
| 220 | + Effect.mapError((message) => new PromptRejectedError({ message })), | |
| 221 | + ), | |
| 222 | + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); | |
| 223 | + | |
| 224 | + assert.lengthOf(requests, 0); | |
| 225 | + assert.lengthOf(validationErrors, 1); | |
| 226 | + assert.include(validationErrors[0], "different connect request"); | |
| 227 | + assert.instanceOf(result, PromptRejectedError); | |
| 228 | + }), | |
| 229 | + ); | |
| 230 | + | |
| 231 | + it.effect("ignores an id_token whose claims are not valid JSON", () => | |
| 232 | + Effect.gen(function* () { | |
| 233 | + const requests: Array<RecordedTokenRequest> = []; | |
| 234 | + const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`; | |
| 235 | + | |
| 236 | + const { identity } = yield* CliTokenManager.outOfBandOAuthLogin( | |
| 237 | + ({ authorizeUrl }: OutOfBandOAuthPromptInput) => { | |
| 238 | + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); | |
| 239 | + assert.isNotNull(request); | |
| 240 | + return Effect.succeed(`clerk-code-123.${request!.state}`); | |
| 241 | + }, | |
| 242 | + ).pipe( | |
| 243 | + Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })), | |
| 244 | + provideTestEnv, | |
| 245 | + ); | |
| 246 | + | |
| 247 | + assert.isNull(identity); | |
| 248 | + assert.lengthOf(requests, 1); | |
| 249 | + }), | |
| 250 | + ); | |
| 251 | + | |
| 252 | + it.effect("fails without touching the token endpoint when the prompt returns garbage", () => | |
| 253 | + Effect.gen(function* () { | |
| 254 | + const requests: Array<RecordedTokenRequest> = []; | |
| 255 | + | |
| 256 | + const result = yield* CliTokenManager.outOfBandOAuthLogin(() => | |
| 257 | + Effect.succeed("not-a-connect-code"), | |
| 258 | + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); | |
| 259 | + | |
| 260 | + assert.lengthOf(requests, 0); | |
| 261 | + assert.isTrue(isAuthorizationError(result)); | |
| 262 | + }), | |
| 263 | + ); | |
| 264 | +}); | |
| @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 5 | 5 | import * as Clock from "effect/Clock"; |
| 6 | +import * as Cause from "effect/Cause"; | |
| 6 | 7 | import * as Console from "effect/Console"; |
| 7 | 8 | import * as Context from "effect/Context"; |
| 8 | 9 | import * as Crypto from "effect/Crypto"; |
| @@ -12,8 +13,10 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 12 | 13 | import * as Encoding from "effect/Encoding"; |
| 13 | 14 | import * as Layer from "effect/Layer"; |
| 14 | 15 | import * as Option from "effect/Option"; |
| 16 | +import * as Queue from "effect/Queue"; | |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | +import * as Terminal from "effect/Terminal"; | |
| 17 | 20 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 18 | 21 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; |
| 19 | 22 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| @@ -21,19 +24,105 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; · 1 unmodified line | ||
| 21 | 24 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 22 | 25 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 23 | 26 | |
| 27 | +import { | |
| 28 | + buildConnectAuthorizeRequestUrl, | |
| 29 | + buildConnectClerkAuthorizeUrl, | |
| 30 | + checkConnectAuthCode, | |
| 31 | + connectCallbackUrl, | |
| 32 | +} from "@t3tools/shared/connectAuth"; | |
| 33 | + | |
| 24 | 34 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 25 | −import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; | |
| 35 | +import * as ExternalLauncher from "../process/externalLauncher.ts"; | |
| 36 | +import { | |
| 37 | + cloudCliOAuthConfig, | |
| 38 | + hostedAppUrlConfig, | |
| 39 | + type CloudCliOAuthConfig, | |
| 40 | +} from "./publicConfig.ts"; | |
| 41 | +import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; | |
| 26 | 42 | |
| 27 | 43 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; |
| 28 | 44 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); |
| 29 | 45 | const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); |
| 46 | +const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; | |
| 47 | + | |
| 48 | +export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { | |
| 49 | + return [ | |
| 50 | + "Open this URL to authorize T3 Connect:", | |
| 51 | + ` ${authorizationUrl}`, | |
| 52 | + "", | |
| 53 | + `Press ${boldTerminalText("Enter")} to open it in your browser.`, | |
| 54 | + `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`, | |
| 55 | + ].join("\n"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export type LoopbackAuthorizationResult = | |
| 59 | + | { readonly _tag: "AuthorizationCode"; readonly code: string } | |
| 60 | + | { readonly _tag: "HeadlessRequested" }; | |
| 61 | + | |
| 62 | +const readLoopbackAuthorizationAction = Effect.fn( | |
| 63 | + "cloud.cli_token.read_loopback_authorization_action", | |
| 64 | +)(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) { | |
| 65 | + while (true) { | |
| 66 | + const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({}))); | |
| 67 | + const keyName = event.key.name.toLowerCase(); | |
| 68 | + if (!event.key.ctrl && !event.key.meta && keyName === "h") { | |
| 69 | + return "headless" as const; | |
| 70 | + } | |
| 71 | + if (keyName === "enter" || keyName === "return") { | |
| 72 | + return "open-browser" as const; | |
| 73 | + } | |
| 74 | + } | |
| 75 | +}); | |
| 76 | + | |
| 77 | +export const waitForLoopbackAuthorization = Effect.fn( | |
| 78 | + "cloud.cli_token.wait_for_loopback_authorization", | |
| 79 | +)(function* <E, R>(input: { | |
| 80 | + readonly authorizationUrl: string; | |
| 81 | + readonly callback: Effect.Effect<string, E, R>; | |
| 82 | + readonly terminal: Terminal.Terminal; | |
| 83 | + readonly launchBrowser: ( | |
| 84 | + url: string, | |
| 85 | + ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>; | |
| 86 | +}) { | |
| 87 | + return yield* Effect.scoped( | |
| 88 | + Effect.gen(function* () { | |
| 89 | + const terminalInput = yield* input.terminal.readInput; | |
| 90 | + while (true) { | |
| 91 | + const result = yield* Effect.raceFirst( | |
| 92 | + input.callback.pipe( | |
| 93 | + Effect.map( | |
| 94 | + (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }), | |
| 95 | + ), | |
| 96 | + ), | |
| 97 | + readLoopbackAuthorizationAction(terminalInput), | |
| 98 | + ); | |
| 99 | + if (typeof result !== "string") { | |
| 100 | + return result; | |
| 101 | + } | |
| 102 | + if (result === "headless") { | |
| 103 | + return { _tag: "HeadlessRequested" } as const; | |
| 104 | + } | |
| 105 | + yield* input | |
| 106 | + .launchBrowser(input.authorizationUrl) | |
| 107 | + .pipe( | |
| 108 | + Effect.catch(() => | |
| 109 | + Console.warn( | |
| 110 | + `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`, | |
| 111 | + ), | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | + } | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | +}); | |
| 30 | 118 | |
| 31 | 119 | const PersistedToken = Schema.Struct({ |
| 32 | 120 | accessToken: Schema.String, |
| 33 | 121 | refreshToken: Schema.String, |
| 34 | 122 | expiresAtEpochMs: Schema.Number, |
| 123 | + identity: Schema.optional(Schema.String), | |
| 35 | 124 | }); |
| 36 | −type PersistedToken = typeof PersistedToken.Type; | |
| 125 | +export type PersistedToken = typeof PersistedToken.Type; | |
| 37 | 126 | |
| 38 | 127 | const PersistedTokenJson = Schema.fromJsonString(PersistedToken); |
| 39 | 128 | const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); |
| @@ -42,10 +131,39 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); · 2 unmodified lines | ||
| 42 | 131 | const OAuthTokenResponse = Schema.Struct({ |
| 43 | 132 | access_token: Schema.String, |
| 44 | 133 | refresh_token: Schema.optional(Schema.String), |
| 134 | + id_token: Schema.optional(Schema.String), | |
| 45 | 135 | expires_in: Schema.Number, |
| 46 | 136 | token_type: Schema.String, |
| 47 | 137 | }); |
| 48 | 138 | |
| 139 | +const OidcIdentityClaimsJson = Schema.fromJsonString( | |
| 140 | + Schema.Struct({ | |
| 141 | + email: Schema.optional(Schema.String), | |
| 142 | + preferred_username: Schema.optional(Schema.String), | |
| 143 | + sub: Schema.optional(Schema.String), | |
| 144 | + }), | |
| 145 | +); | |
| 146 | +const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson); | |
| 147 | + | |
| 148 | +/** | |
| 149 | + * Best-effort read of the `email` (or fallback) claim from an OIDC id_token. | |
| 150 | + * Only used to show the operator which account they linked, so a malformed | |
| 151 | + * token degrades to "no identity" rather than an error. | |
| 152 | + */ | |
| 153 | +function idTokenIdentity(idToken: string | undefined): string | null { | |
| 154 | + if (!idToken) return null; | |
| 155 | + const payload = idToken.split(".")[1]; | |
| 156 | + if (!payload) return null; | |
| 157 | + const decoded = Encoding.decodeBase64UrlString(payload); | |
| 158 | + if (decoded._tag !== "Success") return null; | |
| 159 | + const claims = decodeOidcIdentityClaimsJson(decoded.success); | |
| 160 | + if (Option.isNone(claims)) return null; | |
| 161 | + for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) { | |
| 162 | + if (typeof value === "string" && value.length > 0) return value; | |
| 163 | + } | |
| 164 | + return null; | |
| 165 | +} | |
| 166 | + | |
| 49 | 167 | export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()( |
| 50 | 168 | "CloudCliCredentialRemovalError", |
| 51 | 169 | { cause: Schema.Defect() }, |
| @@ -103,18 +221,18 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; · 51 unmodified lines | ||
| 103 | 221 | export class CloudCliTokenManager extends Context.Service< |
| 104 | 222 | CloudCliTokenManager, |
| 105 | 223 | { |
| 106 | − readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>; | |
| 224 | + readonly get: Effect.Effect< | |
| 225 | + | { readonly _tag: "Authorized"; readonly token: PersistedToken } | |
| 226 | + | { readonly _tag: "HeadlessRequested" }, | |
| 227 | + CloudCliTokenManagerError | Terminal.QuitError | |
| 228 | + >; | |
| 107 | 229 | readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; |
| 108 | 230 | readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; |
| 231 | + readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>; | |
| 109 | 232 | readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; |
| 110 | 233 | } |
| 111 | 234 | >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} |
| 112 | 235 | |
| 113 | −const wrapError = | |
| 114 | − <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) => | |
| 115 | − <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> => | |
| 116 | − effect.pipe(Effect.mapError(makeError)); | |
| 117 | − | |
| 118 | 236 | function stringToBytes(value: string): Uint8Array { |
| 119 | 237 | return new TextEncoder().encode(value); |
| 120 | 238 | } |
| @@ -123,10 +241,101 @@ function bytesToString(value: Uint8Array): string { · 2 unmodified lines | ||
| 123 | 241 | return new TextDecoder().decode(value); |
| 124 | 242 | } |
| 125 | 243 | |
| 244 | +const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( | |
| 245 | + metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">, | |
| 246 | + params: Record<string, string>, | |
| 247 | +) { | |
| 248 | + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); | |
| 249 | + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( | |
| 250 | + HttpClientRequest.bodyUrlParams(params), | |
| 251 | + httpClient.execute, | |
| 252 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), | |
| 253 | + ); | |
| 254 | + const now = yield* Clock.currentTimeMillis; | |
| 255 | + const identity = idTokenIdentity(response.id_token); | |
| 256 | + return { | |
| 257 | + token: { | |
| 258 | + accessToken: response.access_token, | |
| 259 | + refreshToken: response.refresh_token ?? params.refresh_token ?? "", | |
| 260 | + expiresAtEpochMs: now + response.expires_in * 1_000, | |
| 261 | + ...(identity === null ? {} : { identity }), | |
| 262 | + } satisfies PersistedToken, | |
| 263 | + identity, | |
| 264 | + }; | |
| 265 | +}); | |
| 266 | + | |
| 267 | +const makePkceRequest = Effect.gen(function* () { | |
| 268 | + const crypto = yield* Crypto.Crypto; | |
| 269 | + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); | |
| 270 | + const challenge = Encoding.encodeBase64Url( | |
| 271 | + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), | |
| 272 | + ); | |
| 273 | + const state = Encoding.encodeBase64Url(yield* crypto.randomBytes(16)); | |
| 274 | + return { verifier, challenge, state }; | |
| 275 | +}); | |
| 276 | + | |
| 277 | +export interface OutOfBandOAuthPromptInput { | |
| 278 | + readonly authorizeUrl: string; | |
| 279 | + readonly validate: (value: string) => Effect.Effect<string, string>; | |
| 280 | +} | |
| 281 | + | |
| 282 | +/** | |
| 283 | + * Out-of-band OAuth for machines without a local browser (SSH). The user | |
| 284 | + * opens the hosted /connect URL elsewhere, signs in, and enters the displayed | |
| 285 | + * code in this terminal. The PKCE verifier never leaves this process, so the | |
| 286 | + * authorization code is useless to an observer, and the state bundled into | |
| 287 | + * the blob preserves the loopback flow's CSRF check. | |
| 288 | + */ | |
| 289 | +export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* < | |
| 290 | + E, | |
| 291 | + R, | |
| 292 | +>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect<string, E, R>) { | |
| 293 | + const metadata = yield* cloudCliOAuthConfig; | |
| 294 | + const hostedAppUrl = yield* hostedAppUrlConfig; | |
| 295 | + const { verifier, challenge, state } = yield* makePkceRequest; | |
| 296 | + | |
| 297 | + const authorizationCode = yield* promptForCode({ | |
| 298 | + authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }), | |
| 299 | + validate: (value) => { | |
| 300 | + const checked = checkConnectAuthCode(value, state); | |
| 301 | + return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value); | |
| 302 | + }, | |
| 303 | + }).pipe( | |
| 304 | + // Clerk authorization codes expire on this horizon anyway; matching the | |
| 305 | + // loopback flow's timeout turns an abandoned prompt into a clear error. | |
| 306 | + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), | |
| 307 | + Effect.catchTag("TimeoutError", (cause) => | |
| 308 | + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), | |
| 309 | + ), | |
| 310 | + ); | |
| 311 | + // promptForCode is caller-supplied, so re-check the returned value rather | |
| 312 | + // than trusting that the prompt ran validate. | |
| 313 | + const authCode = checkConnectAuthCode(authorizationCode, state); | |
| 314 | + if (typeof authCode === "string") { | |
| 315 | + return yield* new CloudCliAuthorizationError({ cause: authCode }); | |
| 316 | + } | |
| 317 | + | |
| 318 | + return yield* exchangeToken(metadata, { | |
| 319 | + grant_type: "authorization_code", | |
| 320 | + code: authCode.code, | |
| 321 | + redirect_uri: connectCallbackUrl(hostedAppUrl), | |
| 322 | + client_id: metadata.clientId, | |
| 323 | + code_verifier: verifier, | |
| 324 | + }); | |
| 325 | +}); | |
| 326 | + | |
| 126 | 327 | export const make = Effect.gen(function* () { |
| 328 | + // Capture exactly the services the login/refresh flows need at build time | |
| 329 | + // (matching the behavior before the out-of-band flow captured the instances), not | |
| 330 | + // the whole ambient context. | |
| 127 | 331 | const crypto = yield* Crypto.Crypto; |
| 128 | − const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); | |
| 332 | + const httpClient = yield* HttpClient.HttpClient; | |
| 333 | + const services = Context.make(Crypto.Crypto, crypto).pipe( | |
| 334 | + Context.add(HttpClient.HttpClient, httpClient), | |
| 335 | + ); | |
| 129 | 336 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 337 | + const terminal = yield* Terminal.Terminal; | |
| 338 | + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | |
| 130 | 339 | const semaphore = yield* Semaphore.make(1); |
| 131 | 340 | const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { |
| 132 | 341 | const encoded = yield* encodePersistedToken(token); |
| @@ -136,7 +345,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 136 | 345 | |
| 137 | 346 | const clear = secrets |
| 138 | 347 | .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) |
| 139 | − .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); | |
| 348 | + .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause }))); | |
| 140 | 349 | |
| 141 | 350 | const read = Effect.fn("cloud.cli_token.read")(function* () { |
| 142 | 351 | const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); |
| @@ -144,39 +353,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line | ||
| 144 | 353 | return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); |
| 145 | 354 | }); |
| 146 | 355 | |
| 147 | − const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( | |
| 148 | − metadata: CloudCliOAuthConfig, | |
| 149 | − params: Record<string, string>, | |
| 150 | − ) { | |
| 151 | − const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( | |
| 152 | − HttpClientRequest.bodyUrlParams(params), | |
| 153 | − httpClient.execute, | |
| 154 | − Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), | |
| 155 | − ); | |
| 156 | − const now = yield* Clock.currentTimeMillis; | |
| 157 | − return { | |
| 158 | − accessToken: response.access_token, | |
| 159 | − refreshToken: response.refresh_token ?? params.refresh_token ?? "", | |
| 160 | − expiresAtEpochMs: now + response.expires_in * 1_000, | |
| 161 | − } satisfies PersistedToken; | |
| 162 | − }); | |
| 163 | − | |
| 164 | 356 | const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { |
| 165 | 357 | const metadata = yield* cloudCliOAuthConfig; |
| 166 | − return yield* exchangeToken(metadata, { | |
| 358 | + const { token: refreshed } = yield* exchangeToken(metadata, { | |
| 167 | 359 | grant_type: "refresh_token", |
| 168 | 360 | refresh_token: token.refreshToken, |
| 169 | 361 | client_id: metadata.clientId, |
| 170 | 362 | }); |
| 363 | + return refreshed.identity === undefined && token.identity !== undefined | |
| 364 | + ? { ...refreshed, identity: token.identity } | |
| 365 | + : refreshed; | |
| 171 | 366 | }); |
| 172 | 367 | |
| 173 | 368 | const login = Effect.fn("cloud.cli_token.login")(function* () { |
| 174 | 369 | const metadata = yield* cloudCliOAuthConfig; |
| 175 | − const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); | |
| 176 | − const challenge = Encoding.encodeBase64Url( | |
| 177 | − yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), | |
| 178 | − ); | |
| 179 | − const state = yield* crypto.randomUUIDv4; | |
| 370 | + const { verifier, challenge, state } = yield* makePkceRequest; | |
| 180 | 371 | const callback = yield* Deferred.make<string>(); |
| 181 | 372 | const callbackRoute = HttpRouter.add( |
| 182 | 373 | "GET", |
| @@ -191,14 +382,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines | ||
| 191 | 382 | }); |
| 192 | 383 | } |
| 193 | 384 | yield* Deferred.succeed(callback, code); |
| 194 | − return yield* HttpServerResponse.html` | |
| 195 | −<html> | |
| 196 | − <body style="font-family: sans-serif; text-align: center; margin-top: 50px;"> | |
| 197 | − <h1>T3 Connect authorization complete</h1> | |
| 198 | − <p>You can close this window and return to your terminal.</p> | |
| 199 | − </body> | |
| 200 | −</html> | |
| 201 | −`; | |
| 385 | + return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml()); | |
| 202 | 386 | }), |
| 203 | 387 | ); |
| 204 | 388 | yield* HttpRouter.serve(callbackRoute, { |
| @@ -214,32 +398,37 @@ export const make = Effect.gen(function* () { · 9 unmodified lines | ||
| 214 | 398 | ), |
| 215 | 399 | Layer.build, |
| 216 | 400 | ); |
| 217 | − const authorizationUrl = new URL(metadata.authorizationEndpoint); | |
| 218 | − authorizationUrl.searchParams.set("client_id", metadata.clientId); | |
| 219 | − authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); | |
| 220 | − authorizationUrl.searchParams.set("response_type", "code"); | |
| 221 | − authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); | |
| 222 | − authorizationUrl.searchParams.set("state", state); | |
| 223 | − authorizationUrl.searchParams.set("code_challenge", challenge); | |
| 224 | − authorizationUrl.searchParams.set("code_challenge_method", "S256"); | |
| 225 | − yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); | |
| 226 | − const code = yield* Deferred.await(callback).pipe( | |
| 227 | − Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), | |
| 228 | − Effect.catchTag("TimeoutError", (cause) => | |
| 229 | − Effect.fail( | |
| 230 | − new CloudCliAuthorizationTimeoutError({ | |
| 231 | − cause, | |
| 232 | − }), | |
| 401 | + const authorizationUrl = buildConnectClerkAuthorizeUrl({ | |
| 402 | + authorizationEndpoint: metadata.authorizationEndpoint, | |
| 403 | + clientId: metadata.clientId, | |
| 404 | + redirectUri: metadata.redirectUri, | |
| 405 | + scopes: metadata.scopes, | |
| 406 | + state, | |
| 407 | + challenge, | |
| 408 | + }); | |
| 409 | + yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); | |
| 410 | + const authorization = yield* waitForLoopbackAuthorization({ | |
| 411 | + authorizationUrl, | |
| 412 | + callback: Deferred.await(callback).pipe( | |
| 413 | + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), | |
| 414 | + Effect.catchTag("TimeoutError", (cause) => | |
| 415 | + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), | |
| 233 | 416 | ), |
| 234 | 417 | ), |
| 235 | − ); | |
| 236 | − return yield* exchangeToken(metadata, { | |
| 418 | + terminal, | |
| 419 | + launchBrowser: externalLauncher.launchBrowser, | |
| 420 | + }); | |
| 421 | + if (authorization._tag === "HeadlessRequested") { | |
| 422 | + return authorization; | |
| 423 | + } | |
| 424 | + const { token } = yield* exchangeToken(metadata, { | |
| 237 | 425 | grant_type: "authorization_code", |
| 238 | − code, | |
| 426 | + code: authorization.code, | |
| 239 | 427 | redirect_uri: metadata.redirectUri, |
| 240 | 428 | client_id: metadata.clientId, |
| 241 | 429 | code_verifier: verifier, |
| 242 | 430 | }); |
| 431 | + return { _tag: "Authorized", token } as const; | |
| 243 | 432 | }); |
| 244 | 433 | |
| 245 | 434 | const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { |
| @@ -253,24 +442,50 @@ export const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 253 | 442 | }); |
| 254 | 443 | |
| 255 | 444 | const getExisting = semaphore.withPermits(1)( |
| 256 | − getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), | |
| 445 | + getExistingNoLock().pipe( | |
| 446 | + Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })), | |
| 447 | + Effect.provide(services), | |
| 448 | + ), | |
| 257 | 449 | ); |
| 258 | 450 | const hasCredential = semaphore.withPermits(1)( |
| 259 | 451 | read().pipe( |
| 260 | 452 | Effect.map(Option.isSome), |
| 261 | − wrapError((cause) => new CloudCliCredentialReadError({ cause })), | |
| 453 | + Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })), | |
| 262 | 454 | ), |
| 263 | 455 | ); |
| 264 | 456 | const get = semaphore.withPermits(1)( |
| 265 | 457 | Effect.gen(function* () { |
| 266 | − const token = yield* getExistingNoLock(); | |
| 267 | − return Option.isSome(token) | |
| 268 | − ? token.value | |
| 269 | − : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); | |
| 270 | − }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), | |
| 458 | + // A stored credential that can't be read or refreshed (corrupt, revoked, | |
| 459 | + // expired grant) must fall through to a fresh login rather than dead-end | |
| 460 | + // the command — authorizeCli applies the same fallback to out-of-band | |
| 461 | + // authorization. | |
| 462 | + const token = yield* getExistingNoLock().pipe( | |
| 463 | + Effect.orElseSucceed(() => Option.none<PersistedToken>()), | |
| 464 | + ); | |
| 465 | + if (Option.isSome(token)) { | |
| 466 | + return { _tag: "Authorized", token: token.value } as const; | |
| 467 | + } | |
| 468 | + const authorization = yield* Effect.scoped(login()); | |
| 469 | + return authorization._tag === "Authorized" | |
| 470 | + ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const) | |
| 471 | + : authorization; | |
| 472 | + }).pipe( | |
| 473 | + Effect.mapError((cause) => | |
| 474 | + Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }), | |
| 475 | + ), | |
| 476 | + Effect.provide(services), | |
| 477 | + ), | |
| 271 | 478 | ); |
| 479 | + const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) { | |
| 480 | + yield* semaphore.withPermits(1)( | |
| 481 | + persist(token).pipe( | |
| 482 | + Effect.asVoid, | |
| 483 | + Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })), | |
| 484 | + ), | |
| 485 | + ); | |
| 486 | + }); | |
| 272 | 487 | |
| 273 | − return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); | |
| 488 | + return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); | |
| 274 | 489 | }); |
| 275 | 490 | |
| 276 | 491 | export const layer = Layer.effect(CloudCliTokenManager, make); |
| @@ -0,0 +1,529 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Path from "effect/Path"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 10 | + | |
| 11 | +import { | |
| 12 | + HostProcessArguments, | |
| 13 | + HostProcessExecutablePath, | |
| 14 | + HostProcessPlatform, | |
| 15 | +} from "@t3tools/shared/hostProcess"; | |
| 16 | + | |
| 17 | +import * as ProcessRunner from "../processRunner.ts"; | |
| 18 | +import * as BootService from "./bootService.ts"; | |
| 19 | + | |
| 20 | +const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError); | |
| 21 | +const isCommandError = Schema.is(BootService.BootServiceCommandError); | |
| 22 | + | |
| 23 | +interface RecordedCommand { | |
| 24 | + readonly command: string; | |
| 25 | + readonly args: ReadonlyArray<string>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const makeRecordingRunnerLayer = ( | |
| 29 | + commands: Array<RecordedCommand>, | |
| 30 | + options?: { | |
| 31 | + readonly failCommand?: string; | |
| 32 | + readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean; | |
| 33 | + }, | |
| 34 | +) => | |
| 35 | + Layer.succeed( | |
| 36 | + ProcessRunner.ProcessRunner, | |
| 37 | + ProcessRunner.ProcessRunner.of({ | |
| 38 | + run: (input) => | |
| 39 | + Effect.sync(() => { | |
| 40 | + assert.isUndefined(input.env); | |
| 41 | + commands.push({ command: input.command, args: input.args }); | |
| 42 | + const failed = | |
| 43 | + input.command === options?.failCommand || | |
| 44 | + options?.failWhen?.(input.command, input.args) === true; | |
| 45 | + return { | |
| 46 | + stdout: "", | |
| 47 | + stderr: failed ? `${input.command} exploded` : "", | |
| 48 | + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), | |
| 49 | + timedOut: false, | |
| 50 | + stdoutTruncated: false, | |
| 51 | + stderrTruncated: false, | |
| 52 | + }; | |
| 53 | + }), | |
| 54 | + }), | |
| 55 | + ); | |
| 56 | + | |
| 57 | +const makeHost = (entry: string): BootService.BootServiceHost => ({ | |
| 58 | + execPath: "/usr/local/bin/node", | |
| 59 | + cliEntryPath: entry, | |
| 60 | +}); | |
| 61 | + | |
| 62 | +const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => | |
| 63 | + Effect.provide( | |
| 64 | + Layer.mergeAll( | |
| 65 | + Layer.succeed(HostProcessPlatform, platform), | |
| 66 | + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | +const makeTestContext = Effect.fn("test.makeTestContext")(function* () { | |
| 71 | + const fs = yield* FileSystem.FileSystem; | |
| 72 | + const path = yield* Path.Path; | |
| 73 | + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); | |
| 74 | + // A real file for the stable-entry cases so status can confirm the entry | |
| 75 | + // point exists. | |
| 76 | + const stableEntry = path.join(root, "bin.mjs"); | |
| 77 | + yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n"); | |
| 78 | + return { | |
| 79 | + fs, | |
| 80 | + path, | |
| 81 | + dirs: { | |
| 82 | + home: root, | |
| 83 | + baseDir: path.join(root, ".t3"), | |
| 84 | + logsDir: path.join(root, ".t3", "userdata", "logs"), | |
| 85 | + stableEntry, | |
| 86 | + }, | |
| 87 | + }; | |
| 88 | +}); | |
| 89 | + | |
| 90 | +it("renders a systemd unit with absolute paths and append-mode logging", () => { | |
| 91 | + const unit = BootService.renderBootServiceUnit({ | |
| 92 | + nodePath: "/usr/local/bin/node", | |
| 93 | + t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", | |
| 94 | + baseDir: "/home/theo/.t3", | |
| 95 | + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | |
| 96 | + unitPath: "/home/theo/.config/systemd/user/t3code.service", | |
| 97 | + }); | |
| 98 | + | |
| 99 | + assert.equal( | |
| 100 | + unit, | |
| 101 | + [ | |
| 102 | + "[Unit]", | |
| 103 | + "Description=T3 Code server (T3 Connect)", | |
| 104 | + "StartLimitIntervalSec=300", | |
| 105 | + "StartLimitBurst=5", | |
| 106 | + "", | |
| 107 | + "[Service]", | |
| 108 | + "Type=simple", | |
| 109 | + "WorkingDirectory=%h", | |
| 110 | + "Environment=T3CODE_HOME=/home/theo/.t3", | |
| 111 | + "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", | |
| 112 | + "Restart=always", | |
| 113 | + "RestartSec=5", | |
| 114 | + "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log", | |
| 115 | + "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log", | |
| 116 | + "", | |
| 117 | + "[Install]", | |
| 118 | + "WantedBy=default.target", | |
| 119 | + "", | |
| 120 | + ].join("\n"), | |
| 121 | + ); | |
| 122 | +}); | |
| 123 | + | |
| 124 | +it("quotes systemd values containing spaces and escapes percent specifiers", () => { | |
| 125 | + assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path"); | |
| 126 | + assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"'); | |
| 127 | + assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu"); | |
| 128 | + | |
| 129 | + const unit = BootService.renderBootServiceUnit({ | |
| 130 | + nodePath: "/home/me/my tools/node", | |
| 131 | + t3EntryPath: "/home/me/T3 Data/bin.mjs", | |
| 132 | + baseDir: "/home/me/T3 Data", | |
| 133 | + logPath: "/home/me/100%logs/boot.log", | |
| 134 | + unitPath: "/home/me/.config/systemd/user/t3code.service", | |
| 135 | + }); | |
| 136 | + assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); | |
| 137 | + assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); | |
| 138 | + // append: paths take the rest of the line literally (spaces are fine, | |
| 139 | + // quoting is not), but % still goes through specifier expansion. | |
| 140 | + assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log"); | |
| 141 | + assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); | |
| 142 | +}); | |
| 143 | + | |
| 144 | +it("flags package-manager cache entry points as ephemeral", () => { | |
| 145 | + assert.isTrue( | |
| 146 | + BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), | |
| 147 | + ); | |
| 148 | + assert.isTrue( | |
| 149 | + BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), | |
| 150 | + ); | |
| 151 | + assert.isTrue( | |
| 152 | + BootService.isEphemeralCacheEntry( | |
| 153 | + "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", | |
| 154 | + ), | |
| 155 | + ); | |
| 156 | + assert.isTrue( | |
| 157 | + BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"), | |
| 158 | + ); | |
| 159 | + assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); | |
| 160 | + assert.isFalse( | |
| 161 | + BootService.isEphemeralCacheEntry( | |
| 162 | + "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs", | |
| 163 | + ), | |
| 164 | + ); | |
| 165 | + assert.isFalse( | |
| 166 | + BootService.isEphemeralCacheEntry( | |
| 167 | + "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", | |
| 168 | + ), | |
| 169 | + ); | |
| 170 | +}); | |
| 171 | + | |
| 172 | +it.layer(NodeServices.layer)("BootService", (it) => { | |
| 173 | + it.effect("installs the unit, enables the service, and enables linger", () => | |
| 174 | + Effect.gen(function* () { | |
| 175 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 176 | + const commands: Array<RecordedCommand> = []; | |
| 177 | + const service = yield* BootService.make({ | |
| 178 | + baseDir: dirs.baseDir, | |
| 179 | + logsDir: dirs.logsDir, | |
| 180 | + cliVersion: "0.0.27", | |
| 181 | + host: makeHost(dirs.stableEntry), | |
| 182 | + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); | |
| 183 | + | |
| 184 | + const plan = yield* service.install; | |
| 185 | + | |
| 186 | + // A stable entry point is reused directly — no npm install. | |
| 187 | + assert.equal(plan.t3EntryPath, dirs.stableEntry); | |
| 188 | + assert.deepEqual( | |
| 189 | + commands.map((entry) => [entry.command, ...entry.args].join(" ")), | |
| 190 | + [ | |
| 191 | + "systemctl --user daemon-reload", | |
| 192 | + "systemctl --user enable t3code.service", | |
| 193 | + // restart (not enable --now) so repairing a stale unit replaces a | |
| 194 | + // running process instead of leaving the old one until reboot. | |
| 195 | + "systemctl --user restart t3code.service", | |
| 196 | + "loginctl enable-linger", | |
| 197 | + ], | |
| 198 | + ); | |
| 199 | + | |
| 200 | + const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); | |
| 201 | + const unit = yield* fs.readFileString(unitPath); | |
| 202 | + assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`); | |
| 203 | + assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); | |
| 204 | + | |
| 205 | + const status = yield* service.status; | |
| 206 | + assert.isTrue(status.supported); | |
| 207 | + assert.isTrue(status.installed); | |
| 208 | + assert.isTrue(status.current); | |
| 209 | + | |
| 210 | + const removed = yield* service.uninstall; | |
| 211 | + assert.isTrue(removed); | |
| 212 | + assert.isFalse(yield* fs.exists(unitPath)); | |
| 213 | + const statusAfter = yield* service.status; | |
| 214 | + assert.isFalse(statusAfter.installed); | |
| 215 | + const removedAgain = yield* service.uninstall; | |
| 216 | + assert.isFalse(removedAgain); | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + it.effect("pins a runtime via npm install when running from the npx cache", () => | |
| 221 | + Effect.gen(function* () { | |
| 222 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 223 | + const commands: Array<RecordedCommand> = []; | |
| 224 | + const service = yield* BootService.make({ | |
| 225 | + baseDir: dirs.baseDir, | |
| 226 | + logsDir: dirs.logsDir, | |
| 227 | + cliVersion: "0.0.27", | |
| 228 | + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), | |
| 229 | + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); | |
| 230 | + | |
| 231 | + const plan = yield* service.install; | |
| 232 | + | |
| 233 | + const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); | |
| 234 | + assert.equal( | |
| 235 | + plan.t3EntryPath, | |
| 236 | + path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"), | |
| 237 | + ); | |
| 238 | + assert.deepEqual(commands[0], { | |
| 239 | + command: "npm", | |
| 240 | + args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], | |
| 241 | + }); | |
| 242 | + // Success is recorded via a sentinel so interrupted installs re-run. | |
| 243 | + assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); | |
| 244 | + }), | |
| 245 | + ); | |
| 246 | + | |
| 247 | + it.effect("reinstalls a pinned runtime when its entry point is missing", () => | |
| 248 | + Effect.gen(function* () { | |
| 249 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 250 | + const commands: Array<RecordedCommand> = []; | |
| 251 | + const service = yield* BootService.make({ | |
| 252 | + baseDir: dirs.baseDir, | |
| 253 | + logsDir: dirs.logsDir, | |
| 254 | + cliVersion: "0.0.27", | |
| 255 | + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), | |
| 256 | + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); | |
| 257 | + | |
| 258 | + const plan = yield* service.install; | |
| 259 | + yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true }); | |
| 260 | + yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n"); | |
| 261 | + yield* fs.remove(plan.t3EntryPath); | |
| 262 | + commands.length = 0; | |
| 263 | + | |
| 264 | + yield* service.install; | |
| 265 | + | |
| 266 | + assert.isTrue(commands.some(({ command }) => command === "npm")); | |
| 267 | + }), | |
| 268 | + ); | |
| 269 | + | |
| 270 | + it.effect("reads executable metadata from host process references", () => | |
| 271 | + Effect.gen(function* () { | |
| 272 | + const { dirs } = yield* makeTestContext(); | |
| 273 | + const commands: Array<RecordedCommand> = []; | |
| 274 | + const service = yield* BootService.make({ | |
| 275 | + baseDir: dirs.baseDir, | |
| 276 | + logsDir: dirs.logsDir, | |
| 277 | + cliVersion: "0.0.27", | |
| 278 | + }).pipe( | |
| 279 | + Effect.provide(makeRecordingRunnerLayer(commands)), | |
| 280 | + provideHostRefs(dirs.home), | |
| 281 | + Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"), | |
| 282 | + Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]), | |
| 283 | + ); | |
| 284 | + | |
| 285 | + const plan = yield* service.install; | |
| 286 | + assert.equal(plan.nodePath, "/opt/node/bin/node"); | |
| 287 | + assert.equal(plan.t3EntryPath, dirs.stableEntry); | |
| 288 | + }), | |
| 289 | + ); | |
| 290 | + | |
| 291 | + it.effect("cleans up and fails when the pinned runtime install fails", () => | |
| 292 | + Effect.gen(function* () { | |
| 293 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 294 | + const commands: Array<RecordedCommand> = []; | |
| 295 | + const service = yield* BootService.make({ | |
| 296 | + baseDir: dirs.baseDir, | |
| 297 | + logsDir: dirs.logsDir, | |
| 298 | + cliVersion: "0.0.27", | |
| 299 | + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), | |
| 300 | + }).pipe( | |
| 301 | + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })), | |
| 302 | + provideHostRefs(dirs.home), | |
| 303 | + ); | |
| 304 | + | |
| 305 | + const error = yield* service.install.pipe(Effect.flip); | |
| 306 | + assert.isTrue(isCommandError(error)); | |
| 307 | + const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); | |
| 308 | + // The half-installed tree must not be reused by the next attempt. | |
| 309 | + assert.isFalse(yield* fs.exists(runtimeDir)); | |
| 310 | + assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); | |
| 311 | + }), | |
| 312 | + ); | |
| 313 | + | |
| 314 | + it.effect("reports an installed-but-stale unit so connect can offer a repair", () => | |
| 315 | + Effect.gen(function* () { | |
| 316 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 317 | + const commands: Array<RecordedCommand> = []; | |
| 318 | + const service = yield* BootService.make({ | |
| 319 | + baseDir: dirs.baseDir, | |
| 320 | + logsDir: dirs.logsDir, | |
| 321 | + cliVersion: "0.0.27", | |
| 322 | + host: makeHost(dirs.stableEntry), | |
| 323 | + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); | |
| 324 | + | |
| 325 | + const unitDir = path.join(dirs.home, ".config", "systemd", "user"); | |
| 326 | + yield* fs.makeDirectory(unitDir, { recursive: true }); | |
| 327 | + yield* fs.writeFileString( | |
| 328 | + path.join(unitDir, "t3code.service"), | |
| 329 | + "[Service]\nExecStart=/old/node /old/t3 serve\n", | |
| 330 | + ); | |
| 331 | + | |
| 332 | + const status = yield* service.status; | |
| 333 | + assert.isTrue(status.supported); | |
| 334 | + assert.isTrue(status.installed); | |
| 335 | + assert.isFalse(status.current); | |
| 336 | + }), | |
| 337 | + ); | |
| 338 | + | |
| 339 | + it.effect("reports a current unit as stale when its entry point is gone", () => | |
| 340 | + Effect.gen(function* () { | |
| 341 | + const { dirs, fs } = yield* makeTestContext(); | |
| 342 | + const commands: Array<RecordedCommand> = []; | |
| 343 | + const service = yield* BootService.make({ | |
| 344 | + baseDir: dirs.baseDir, | |
| 345 | + logsDir: dirs.logsDir, | |
| 346 | + cliVersion: "0.0.27", | |
| 347 | + host: makeHost(dirs.stableEntry), | |
| 348 | + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); | |
| 349 | + | |
| 350 | + yield* service.install; | |
| 351 | + assert.isTrue((yield* service.status).current); | |
| 352 | + | |
| 353 | + // The pinned runtime (or global bin) was deleted to reclaim space; the | |
| 354 | + // unit still matches byte-for-byte but would crashloop at boot. | |
| 355 | + yield* fs.remove(dirs.stableEntry); | |
| 356 | + const status = yield* service.status; | |
| 357 | + assert.isTrue(status.installed); | |
| 358 | + assert.isFalse(status.current); | |
| 359 | + }), | |
| 360 | + ); | |
| 361 | + | |
| 362 | + it.effect("fails on non-Linux platforms without touching the filesystem", () => | |
| 363 | + Effect.gen(function* () { | |
| 364 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 365 | + const commands: Array<RecordedCommand> = []; | |
| 366 | + const service = yield* BootService.make({ | |
| 367 | + baseDir: dirs.baseDir, | |
| 368 | + logsDir: dirs.logsDir, | |
| 369 | + cliVersion: "0.0.27", | |
| 370 | + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), | |
| 371 | + }).pipe( | |
| 372 | + Effect.provide(makeRecordingRunnerLayer(commands)), | |
| 373 | + provideHostRefs(dirs.home, "darwin"), | |
| 374 | + ); | |
| 375 | + | |
| 376 | + const error = yield* service.install.pipe(Effect.flip); | |
| 377 | + assert.isTrue(isUnsupportedError(error)); | |
| 378 | + assert.lengthOf(commands, 0); | |
| 379 | + assert.isFalse( | |
| 380 | + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), | |
| 381 | + ); | |
| 382 | + | |
| 383 | + const status = yield* service.status; | |
| 384 | + assert.isFalse(status.supported); | |
| 385 | + assert.isFalse(status.installed); | |
| 386 | + }), | |
| 387 | + ); | |
| 388 | + | |
| 389 | + it.effect("removes the unit file when an activation step fails", () => | |
| 390 | + Effect.gen(function* () { | |
| 391 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 392 | + const commands: Array<RecordedCommand> = []; | |
| 393 | + const service = yield* BootService.make({ | |
| 394 | + baseDir: dirs.baseDir, | |
| 395 | + logsDir: dirs.logsDir, | |
| 396 | + cliVersion: "0.0.27", | |
| 397 | + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), | |
| 398 | + }).pipe( | |
| 399 | + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })), | |
| 400 | + provideHostRefs(dirs.home), | |
| 401 | + ); | |
| 402 | + | |
| 403 | + const error = yield* service.install.pipe(Effect.flip); | |
| 404 | + assert.isTrue(isCommandError(error)); | |
| 405 | + // A leftover unit would make the next connect report "already set up" | |
| 406 | + // even though linger never happened. | |
| 407 | + assert.isFalse( | |
| 408 | + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), | |
| 409 | + ); | |
| 410 | + const status = yield* service.status; | |
| 411 | + assert.isFalse(status.installed); | |
| 412 | + assert.isTrue( | |
| 413 | + commands.some( | |
| 414 | + ({ command, args }) => | |
| 415 | + command === "systemctl" && args.join(" ") === "--user disable --now t3code.service", | |
| 416 | + ), | |
| 417 | + ); | |
| 418 | + }), | |
| 419 | + ); | |
| 420 | + | |
| 421 | + it.effect("restores the previous unit when a repair cannot activate", () => | |
| 422 | + Effect.gen(function* () { | |
| 423 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 424 | + const initialCommands: Array<RecordedCommand> = []; | |
| 425 | + const initialService = yield* BootService.make({ | |
| 426 | + baseDir: dirs.baseDir, | |
| 427 | + logsDir: dirs.logsDir, | |
| 428 | + cliVersion: "0.0.27", | |
| 429 | + host: makeHost(dirs.stableEntry), | |
| 430 | + }).pipe( | |
| 431 | + Effect.provide(makeRecordingRunnerLayer(initialCommands)), | |
| 432 | + provideHostRefs(dirs.home), | |
| 433 | + ); | |
| 434 | + yield* initialService.install; | |
| 435 | + | |
| 436 | + const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); | |
| 437 | + const previousUnit = yield* fs.readFileString(unitPath); | |
| 438 | + const replacementEntry = path.join(dirs.home, "replacement-bin.mjs"); | |
| 439 | + yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n"); | |
| 440 | + const repairCommands: Array<RecordedCommand> = []; | |
| 441 | + const repairService = yield* BootService.make({ | |
| 442 | + baseDir: dirs.baseDir, | |
| 443 | + logsDir: dirs.logsDir, | |
| 444 | + cliVersion: "0.0.28", | |
| 445 | + host: makeHost(replacementEntry), | |
| 446 | + }).pipe( | |
| 447 | + Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })), | |
| 448 | + provideHostRefs(dirs.home), | |
| 449 | + ); | |
| 450 | + | |
| 451 | + const error = yield* repairService.install.pipe(Effect.flip); | |
| 452 | + | |
| 453 | + assert.isTrue(isCommandError(error)); | |
| 454 | + assert.equal(yield* fs.readFileString(unitPath), previousUnit); | |
| 455 | + assert.isTrue( | |
| 456 | + repairCommands.some( | |
| 457 | + ({ command, args }) => | |
| 458 | + command === "systemctl" && args.join(" ") === "--user restart t3code.service", | |
| 459 | + ), | |
| 460 | + ); | |
| 461 | + }), | |
| 462 | + ); | |
| 463 | + | |
| 464 | + it.effect("keeps the unit when stopping it during uninstall fails", () => | |
| 465 | + Effect.gen(function* () { | |
| 466 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 467 | + const installCommands: Array<RecordedCommand> = []; | |
| 468 | + const installedService = yield* BootService.make({ | |
| 469 | + baseDir: dirs.baseDir, | |
| 470 | + logsDir: dirs.logsDir, | |
| 471 | + cliVersion: "0.0.27", | |
| 472 | + host: makeHost(dirs.stableEntry), | |
| 473 | + }).pipe( | |
| 474 | + Effect.provide(makeRecordingRunnerLayer(installCommands)), | |
| 475 | + provideHostRefs(dirs.home), | |
| 476 | + ); | |
| 477 | + yield* installedService.install; | |
| 478 | + | |
| 479 | + const uninstallCommands: Array<RecordedCommand> = []; | |
| 480 | + const failingService = yield* BootService.make({ | |
| 481 | + baseDir: dirs.baseDir, | |
| 482 | + logsDir: dirs.logsDir, | |
| 483 | + cliVersion: "0.0.27", | |
| 484 | + host: makeHost(dirs.stableEntry), | |
| 485 | + }).pipe( | |
| 486 | + Effect.provide( | |
| 487 | + makeRecordingRunnerLayer(uninstallCommands, { | |
| 488 | + failWhen: (command, args) => | |
| 489 | + command === "systemctl" && args.includes("disable") && args.includes("--now"), | |
| 490 | + }), | |
| 491 | + ), | |
| 492 | + provideHostRefs(dirs.home), | |
| 493 | + ); | |
| 494 | + | |
| 495 | + const error = yield* failingService.uninstall.pipe(Effect.flip); | |
| 496 | + | |
| 497 | + assert.isTrue(isCommandError(error)); | |
| 498 | + assert.isTrue( | |
| 499 | + yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), | |
| 500 | + ); | |
| 501 | + }), | |
| 502 | + ); | |
| 503 | + | |
| 504 | + it.effect("appends failed steps to the boot-service log", () => | |
| 505 | + Effect.gen(function* () { | |
| 506 | + const { dirs, fs, path } = yield* makeTestContext(); | |
| 507 | + const commands: Array<RecordedCommand> = []; | |
| 508 | + const service = yield* BootService.make({ | |
| 509 | + baseDir: dirs.baseDir, | |
| 510 | + logsDir: dirs.logsDir, | |
| 511 | + cliVersion: "0.0.27", | |
| 512 | + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), | |
| 513 | + }).pipe( | |
| 514 | + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })), | |
| 515 | + provideHostRefs(dirs.home), | |
| 516 | + ); | |
| 517 | + | |
| 518 | + const error = yield* service.install.pipe(Effect.flip); | |
| 519 | + assert.isTrue(isCommandError(error)); | |
| 520 | + if (!isCommandError(error)) return; | |
| 521 | + assert.equal(error.exitCode, 1); | |
| 522 | + assert.equal(error.stderrLength, "systemctl exploded".length); | |
| 523 | + | |
| 524 | + const logPath = path.join(dirs.logsDir, "boot-service.log"); | |
| 525 | + assert.isTrue(yield* fs.exists(logPath)); | |
| 526 | + assert.include(yield* fs.readFileString(logPath), "exit code 1"); | |
| 527 | + }), | |
| 528 | + ); | |
| 529 | +}); | |
| @@ -0,0 +1,452 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Config from "effect/Config"; | |
| 3 | +import * as DateTime from "effect/DateTime"; | |
| 4 | +import * as Duration from "effect/Duration"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as Option from "effect/Option"; | |
| 9 | +import * as Path from "effect/Path"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +import { | |
| 13 | + HostProcessArguments, | |
| 14 | + HostProcessExecutablePath, | |
| 15 | + HostProcessPlatform, | |
| 16 | +} from "@t3tools/shared/hostProcess"; | |
| 17 | + | |
| 18 | +import * as ProcessRunner from "../processRunner.ts"; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Installs T3 Code as a per-user boot service so a connected machine stays | |
| 22 | + * reachable through T3 Connect after the SSH session ends. Linux-only for | |
| 23 | + * now: systemd user unit + loginctl enable-linger. The service runs a pinned | |
| 24 | + * runtime installed under <baseDir>/runtime — never `npx t3`, whose cache is | |
| 25 | + * ephemeral and whose registry fetch at boot would make startup depend on | |
| 26 | + * the network. | |
| 27 | + */ | |
| 28 | + | |
| 29 | +const BOOT_SERVICE_NAME = "t3code"; | |
| 30 | +const BOOT_RUNTIME_DIR = "runtime"; | |
| 31 | + | |
| 32 | +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; | |
| 33 | +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); | |
| 34 | + | |
| 35 | +const EPHEMERAL_CACHE_SEGMENTS = [ | |
| 36 | + "/_npx/", // npx | |
| 37 | + "\\_npx\\", | |
| 38 | + "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx) | |
| 39 | + "/.pnpm/dlx/", | |
| 40 | + "/.bun/install/cache/", // bunx | |
| 41 | +]; | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager | |
| 45 | + * caches that can be evicted at any time — a boot service must never point | |
| 46 | + * there. Global installs, repo checkouts, and the pinned runtime below are | |
| 47 | + * all stable. | |
| 48 | + */ | |
| 49 | +export function isEphemeralCacheEntry(entryPath: string): boolean { | |
| 50 | + return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * systemd expands `%` specifiers in most directive values, including the | |
| 55 | + * `append:` file paths, which take the rest of the line literally and must | |
| 56 | + * NOT be quoted. | |
| 57 | + */ | |
| 58 | +export function escapeSystemdSpecifiers(value: string): string { | |
| 59 | + return value.replaceAll("%", "%%"); | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** | |
| 63 | + * systemd word-splits ExecStart and Environment values and expands `%` | |
| 64 | + * specifiers, so paths with spaces or percents must be quoted and escaped. | |
| 65 | + */ | |
| 66 | +export function quoteSystemdValue(value: string): string { | |
| 67 | + const escaped = escapeSystemdSpecifiers(value); | |
| 68 | + return /[\s"'\\]/.test(escaped) | |
| 69 | + ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` | |
| 70 | + : escaped; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export interface BootServicePlan { | |
| 74 | + /** Absolute path of the node binary running this CLI. */ | |
| 75 | + readonly nodePath: string; | |
| 76 | + /** Absolute path of the pinned t3 entry point the unit will run. */ | |
| 77 | + readonly t3EntryPath: string; | |
| 78 | + readonly baseDir: string; | |
| 79 | + readonly logPath: string; | |
| 80 | + readonly unitPath: string; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * Pure so it is testable byte-for-byte. systemd user units run with a | |
| 85 | + * minimal environment: every path must be absolute, and the service must | |
| 86 | + * not rely on PATH, nvm shims, or shell profiles. Failures land in | |
| 87 | + * `logPath` because `systemctl --user` failures are otherwise invisible. | |
| 88 | + */ | |
| 89 | +export function renderBootServiceUnit(plan: BootServicePlan): string { | |
| 90 | + // No After=network-online.target: it does not exist in the systemd *user* | |
| 91 | + // manager, so ordering on it is silently ignored. The server retries its | |
| 92 | + // relay connection, and Restart=always covers early-boot failures. | |
| 93 | + return [ | |
| 94 | + "[Unit]", | |
| 95 | + "Description=T3 Code server (T3 Connect)", | |
| 96 | + // Give up after 5 crashes in 5 minutes so a persistently broken install | |
| 97 | + // (deleted runtime, broken workspace) stops instead of restarting every | |
| 98 | + // 5s forever and growing the unrotated append log without bound. | |
| 99 | + "StartLimitIntervalSec=300", | |
| 100 | + "StartLimitBurst=5", | |
| 101 | + "", | |
| 102 | + "[Service]", | |
| 103 | + "Type=simple", | |
| 104 | + "WorkingDirectory=%h", | |
| 105 | + `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, | |
| 106 | + `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, | |
| 107 | + "Restart=always", | |
| 108 | + "RestartSec=5", | |
| 109 | + `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, | |
| 110 | + `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, | |
| 111 | + "", | |
| 112 | + "[Install]", | |
| 113 | + "WantedBy=default.target", | |
| 114 | + "", | |
| 115 | + ].join("\n"); | |
| 116 | +} | |
| 117 | + | |
| 118 | +export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( | |
| 119 | + "BootServiceUnsupportedError", | |
| 120 | + { platform: Schema.String }, | |
| 121 | +) { | |
| 122 | + override get message(): string { | |
| 123 | + return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`; | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +export class BootServiceCommandError extends Schema.TaggedErrorClass<BootServiceCommandError>()( | |
| 128 | + "BootServiceCommandError", | |
| 129 | + { | |
| 130 | + step: Schema.String, | |
| 131 | + exitCode: Schema.optional(Schema.Number), | |
| 132 | + stdoutLength: Schema.optional(Schema.Number), | |
| 133 | + stderrLength: Schema.optional(Schema.Number), | |
| 134 | + cause: Schema.optional(Schema.Defect()), | |
| 135 | + }, | |
| 136 | +) { | |
| 137 | + override get message(): string { | |
| 138 | + return this.exitCode === undefined | |
| 139 | + ? `Background setup failed while ${this.step}.` | |
| 140 | + : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`; | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +export class BootServiceInstallError extends Schema.TaggedErrorClass<BootServiceInstallError>()( | |
| 145 | + "BootServiceInstallError", | |
| 146 | + { cause: Schema.Defect() }, | |
| 147 | +) { | |
| 148 | + override get message(): string { | |
| 149 | + return "Could not set up the T3 Code background service."; | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +export type BootServiceError = | |
| 154 | + | BootServiceUnsupportedError | |
| 155 | + | BootServiceCommandError | |
| 156 | + | BootServiceInstallError; | |
| 157 | + | |
| 158 | +export interface BootServiceStatus { | |
| 159 | + readonly supported: boolean; | |
| 160 | + readonly installed: boolean; | |
| 161 | + /** False when the installed unit no longer matches what install would write. */ | |
| 162 | + readonly current: boolean; | |
| 163 | + readonly unitPath: string; | |
| 164 | + readonly logPath: string; | |
| 165 | +} | |
| 166 | + | |
| 167 | +export class BootService extends Context.Service< | |
| 168 | + BootService, | |
| 169 | + { | |
| 170 | + /** Installs the pinned runtime + unit, enables linger, starts the service. */ | |
| 171 | + readonly install: Effect.Effect<BootServicePlan, BootServiceError>; | |
| 172 | + /** | |
| 173 | + * Stops and removes the unit; leaves the pinned runtime for reuse. | |
| 174 | + * Returns whether a unit was actually removed. | |
| 175 | + */ | |
| 176 | + readonly uninstall: Effect.Effect<boolean, BootServiceError>; | |
| 177 | + readonly status: Effect.Effect<BootServiceStatus, BootServiceError>; | |
| 178 | + } | |
| 179 | +>()("t3/cloud/bootService") {} | |
| 180 | + | |
| 181 | +export interface BootServiceHost { | |
| 182 | + readonly execPath: string; | |
| 183 | + readonly cliEntryPath: string; | |
| 184 | +} | |
| 185 | + | |
| 186 | +export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | |
| 187 | + readonly baseDir: string; | |
| 188 | + readonly logsDir: string; | |
| 189 | + readonly cliVersion: string; | |
| 190 | + readonly host?: BootServiceHost; | |
| 191 | +}) { | |
| 192 | + const hostExecPath = yield* HostProcessExecutablePath; | |
| 193 | + const hostArguments = yield* HostProcessArguments; | |
| 194 | + const host = input.host ?? { | |
| 195 | + execPath: hostExecPath, | |
| 196 | + // When running the packed CLI this is dist/bin.mjs; when stable (global | |
| 197 | + // install, repo checkout) the boot service runs this same artifact. | |
| 198 | + cliEntryPath: hostArguments[1] ?? "", | |
| 199 | + }; | |
| 200 | + const platform = yield* HostProcessPlatform; | |
| 201 | + const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); | |
| 202 | + const fs = yield* FileSystem.FileSystem; | |
| 203 | + const path = yield* Path.Path; | |
| 204 | + const runner = yield* ProcessRunner.ProcessRunner; | |
| 205 | + | |
| 206 | + const unitDir = path.join(homeDir, ".config", "systemd", "user"); | |
| 207 | + const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); | |
| 208 | + const logPath = path.join(input.logsDir, "boot-service.log"); | |
| 209 | + const runtimeVersionDir = path.join( | |
| 210 | + input.baseDir, | |
| 211 | + BOOT_RUNTIME_DIR, | |
| 212 | + "versions", | |
| 213 | + input.cliVersion, | |
| 214 | + ); | |
| 215 | + const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); | |
| 216 | + const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); | |
| 217 | + | |
| 218 | + const requireSystemdLinux = Effect.gen(function* () { | |
| 219 | + if (platform !== "linux" || homeDir === "") { | |
| 220 | + return yield* new BootServiceUnsupportedError({ platform }); | |
| 221 | + } | |
| 222 | + }); | |
| 223 | + | |
| 224 | + const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( | |
| 225 | + step: string, | |
| 226 | + command: string, | |
| 227 | + args: ReadonlyArray<string>, | |
| 228 | + options?: { readonly timeout?: Duration.Input }, | |
| 229 | + ) { | |
| 230 | + return yield* runner.run({ command, args, timeout: options?.timeout }).pipe( | |
| 231 | + Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), | |
| 232 | + Effect.filterOrFail( | |
| 233 | + (result) => result.code === 0, | |
| 234 | + (result) => | |
| 235 | + new BootServiceCommandError({ | |
| 236 | + step, | |
| 237 | + exitCode: Number(result.code), | |
| 238 | + stdoutLength: result.stdout.length, | |
| 239 | + stderrLength: result.stderr.length, | |
| 240 | + }), | |
| 241 | + ), | |
| 242 | + Effect.tapError((error) => | |
| 243 | + DateTime.now.pipe( | |
| 244 | + Effect.flatMap((now) => | |
| 245 | + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { | |
| 246 | + flag: "a", | |
| 247 | + }), | |
| 248 | + ), | |
| 249 | + Effect.ignore, | |
| 250 | + ), | |
| 251 | + ), | |
| 252 | + ); | |
| 253 | + }); | |
| 254 | + | |
| 255 | + /** | |
| 256 | + * Ensures plannedEntryPath exists before the unit points at it. A stable | |
| 257 | + * install (global bin, repo checkout) is used as-is; an ephemeral cache | |
| 258 | + * entry is replaced by `npm install --prefix`-ing the exact running | |
| 259 | + * version into <baseDir>/runtime/versions/<v>. A real install (not a copy | |
| 260 | + * of bin.mjs) because t3 ships native deps like node-pty. | |
| 261 | + */ | |
| 262 | + const ensurePinnedRuntime = Effect.gen(function* () { | |
| 263 | + if (!isEphemeralCacheEntry(host.cliEntryPath)) { | |
| 264 | + return; | |
| 265 | + } | |
| 266 | + // The sentinel is written only after npm exits 0. Checking the entry | |
| 267 | + // file alone is not enough: npm extracts files before running native | |
| 268 | + // builds (node-pty), so a killed install leaves a plausible-looking but | |
| 269 | + // broken tree behind. | |
| 270 | + const alreadyPinned = yield* Effect.all([ | |
| 271 | + fs.exists(runtimeSentinelPath), | |
| 272 | + fs.exists(runtimeEntryPath), | |
| 273 | + ]).pipe( | |
| 274 | + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), | |
| 275 | + Effect.mapError((cause) => new BootServiceInstallError({ cause })), | |
| 276 | + ); | |
| 277 | + if (alreadyPinned) { | |
| 278 | + return; | |
| 279 | + } | |
| 280 | + yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( | |
| 281 | + Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), | |
| 282 | + Effect.mapError((cause) => new BootServiceInstallError({ cause })), | |
| 283 | + ); | |
| 284 | + yield* runStep( | |
| 285 | + "installing the pinned t3 runtime (this can take a few minutes)", | |
| 286 | + "npm", | |
| 287 | + [ | |
| 288 | + "install", | |
| 289 | + "--prefix", | |
| 290 | + runtimeVersionDir, | |
| 291 | + "--no-fund", | |
| 292 | + "--no-audit", | |
| 293 | + `t3@${input.cliVersion}`, | |
| 294 | + ], | |
| 295 | + // Native deps (node-pty) can compile from source on slow boxes; the | |
| 296 | + // ProcessRunner default of 60s would kill a healthy install. | |
| 297 | + { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, | |
| 298 | + ).pipe( | |
| 299 | + Effect.tapError(() => | |
| 300 | + fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), | |
| 301 | + ), | |
| 302 | + ); | |
| 303 | + yield* fs | |
| 304 | + .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) | |
| 305 | + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | |
| 306 | + }); | |
| 307 | + | |
| 308 | + // Where the unit will point: derivable without touching the network, so | |
| 309 | + // status can compare units purely; install materializes it first. | |
| 310 | + const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) | |
| 311 | + ? runtimeEntryPath | |
| 312 | + : host.cliEntryPath; | |
| 313 | + const plan: BootServicePlan = { | |
| 314 | + nodePath: host.execPath, | |
| 315 | + t3EntryPath: plannedEntryPath, | |
| 316 | + baseDir: input.baseDir, | |
| 317 | + logPath, | |
| 318 | + unitPath, | |
| 319 | + }; | |
| 320 | + | |
| 321 | + const install: BootService["Service"]["install"] = Effect.gen(function* () { | |
| 322 | + yield* requireSystemdLinux; | |
| 323 | + yield* fs | |
| 324 | + .makeDirectory(input.logsDir, { recursive: true }) | |
| 325 | + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | |
| 326 | + | |
| 327 | + yield* ensurePinnedRuntime; | |
| 328 | + | |
| 329 | + const previousUnit = yield* fs.exists(unitPath).pipe( | |
| 330 | + Effect.flatMap((exists) => | |
| 331 | + exists | |
| 332 | + ? fs.readFileString(unitPath).pipe(Effect.map(Option.some)) | |
| 333 | + : Effect.succeed(Option.none<string>()), | |
| 334 | + ), | |
| 335 | + Effect.mapError((cause) => new BootServiceInstallError({ cause })), | |
| 336 | + ); | |
| 337 | + | |
| 338 | + yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( | |
| 339 | + Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), | |
| 340 | + Effect.mapError((cause) => new BootServiceInstallError({ cause })), | |
| 341 | + ); | |
| 342 | + | |
| 343 | + // If any activation step fails, remove the unit again: a leftover file | |
| 344 | + // would make the next `t3 connect` report the service as already set up | |
| 345 | + // even though it was never enabled or lingered. | |
| 346 | + yield* Effect.gen(function* () { | |
| 347 | + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); | |
| 348 | + yield* runStep("enabling the service", "systemctl", [ | |
| 349 | + "--user", | |
| 350 | + "enable", | |
| 351 | + BOOT_SERVICE_UNIT_FILE, | |
| 352 | + ]); | |
| 353 | + // restart rather than enable --now: --now does not replace an already | |
| 354 | + // running process, so repairing a stale unit would leave the old | |
| 355 | + // server running until reboot. restart also starts a stopped service. | |
| 356 | + yield* runStep("starting the service", "systemctl", [ | |
| 357 | + "--user", | |
| 358 | + "restart", | |
| 359 | + BOOT_SERVICE_UNIT_FILE, | |
| 360 | + ]); | |
| 361 | + // Linger keeps the user manager (and this service) running without an | |
| 362 | + // open session — the whole point on a box reached over SSH. No | |
| 363 | + // username argument: loginctl defaults to the calling user, which is | |
| 364 | + // always right, while $USER can be stale (su without -l) or unset. | |
| 365 | + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); | |
| 366 | + }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit))); | |
| 367 | + | |
| 368 | + return plan; | |
| 369 | + }).pipe(Effect.withSpan("cloud.boot_service.install")); | |
| 370 | + | |
| 371 | + // If activation fails partway (e.g. enable succeeds but restart/linger | |
| 372 | + // fails), leave nothing behind: disable removes the enable symlink, remove | |
| 373 | + // deletes the file, daemon-reload clears the stale definition — otherwise a | |
| 374 | + // dangling wants/ symlink logs "Failed to load unit" at every boot and the | |
| 375 | + // next connect misreports the state. | |
| 376 | + const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( | |
| 377 | + previousUnit: Option.Option<string>, | |
| 378 | + ) { | |
| 379 | + if (Option.isSome(previousUnit)) { | |
| 380 | + yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore); | |
| 381 | + } else { | |
| 382 | + yield* runStep("cleaning up the service", "systemctl", [ | |
| 383 | + "--user", | |
| 384 | + "disable", | |
| 385 | + "--now", | |
| 386 | + BOOT_SERVICE_UNIT_FILE, | |
| 387 | + ]).pipe(Effect.ignore); | |
| 388 | + yield* fs.remove(unitPath).pipe(Effect.ignore); | |
| 389 | + } | |
| 390 | + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe( | |
| 391 | + Effect.ignore, | |
| 392 | + ); | |
| 393 | + if (Option.isSome(previousUnit)) { | |
| 394 | + yield* runStep("restoring the previous service", "systemctl", [ | |
| 395 | + "--user", | |
| 396 | + "restart", | |
| 397 | + BOOT_SERVICE_UNIT_FILE, | |
| 398 | + ]).pipe(Effect.ignore); | |
| 399 | + } | |
| 400 | + }); | |
| 401 | + | |
| 402 | + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { | |
| 403 | + yield* requireSystemdLinux; | |
| 404 | + const exists = yield* fs | |
| 405 | + .exists(unitPath) | |
| 406 | + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | |
| 407 | + if (!exists) { | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + yield* runStep("stopping the service", "systemctl", [ | |
| 411 | + "--user", | |
| 412 | + "disable", | |
| 413 | + "--now", | |
| 414 | + BOOT_SERVICE_UNIT_FILE, | |
| 415 | + ]); | |
| 416 | + yield* fs | |
| 417 | + .remove(unitPath) | |
| 418 | + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | |
| 419 | + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); | |
| 420 | + return true; | |
| 421 | + }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); | |
| 422 | + | |
| 423 | + const status: BootService["Service"]["status"] = Effect.gen(function* () { | |
| 424 | + if (platform !== "linux" || homeDir === "") { | |
| 425 | + return { supported: false, installed: false, current: false, unitPath, logPath }; | |
| 426 | + } | |
| 427 | + const unitExists = yield* fs.exists(unitPath); | |
| 428 | + if (!unitExists) { | |
| 429 | + return { supported: true, installed: false, current: false, unitPath, logPath }; | |
| 430 | + } | |
| 431 | + const unit = yield* fs.readFileString(unitPath); | |
| 432 | + // A unit is current only if it matches what install would write now (an | |
| 433 | + // older CLI wrote a different runtime/node path) AND the entry point it | |
| 434 | + // references still exists (a pinned runtime under ~/.t3 can be deleted to | |
| 435 | + // reclaim space). Either mismatch makes connect offer a repair. | |
| 436 | + const entryExists = yield* fs.exists(plannedEntryPath); | |
| 437 | + const current = unit === renderBootServiceUnit(plan) && entryExists; | |
| 438 | + return { supported: true, installed: true, current, unitPath, logPath }; | |
| 439 | + }).pipe( | |
| 440 | + Effect.mapError((cause) => new BootServiceInstallError({ cause })), | |
| 441 | + Effect.withSpan("cloud.boot_service.status"), | |
| 442 | + ); | |
| 443 | + | |
| 444 | + return BootService.of({ install, uninstall, status }); | |
| 445 | +}); | |
| 446 | + | |
| 447 | +export const layer = (input: { | |
| 448 | + readonly baseDir: string; | |
| 449 | + readonly logsDir: string; | |
| 450 | + readonly cliVersion: string; | |
| 451 | + readonly host?: BootServiceHost; | |
| 452 | +}) => Layer.effect(BootService, make(input)); | |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; | |
| 4 | + | |
| 5 | +it("renders the branded loopback authorization completion page", () => { | |
| 6 | + const html = renderLoopbackAuthorizationCompleteHtml(); | |
| 7 | + | |
| 8 | + expect(html).toContain("T3 Code"); | |
| 9 | + expect(html).not.toContain("Secure terminal handoff"); | |
| 10 | + expect(html).toContain("You're connected"); | |
| 11 | + expect(html).toContain("return to the terminal"); | |
| 12 | + expect(html).toContain('name="viewport"'); | |
| 13 | +}); | |
| @@ -0,0 +1,140 @@ | ||
| 1 | +export function renderLoopbackAuthorizationCompleteHtml(): string { | |
| 2 | + return `<!doctype html> | |
| 3 | +<html lang="en"> | |
| 4 | + <head> | |
| 5 | + <meta charset="utf-8" /> | |
| 6 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 7 | + <meta name="color-scheme" content="light dark" /> | |
| 8 | + <title>T3 Connect authorization complete</title> | |
| 9 | + <style> | |
| 10 | + :root { | |
| 11 | + color-scheme: light dark; | |
| 12 | + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 13 | + background: #f6f7f9; | |
| 14 | + color: #17191f; | |
| 15 | + } | |
| 16 | + * { box-sizing: border-box; } | |
| 17 | + body { | |
| 18 | + min-height: 100vh; | |
| 19 | + margin: 0; | |
| 20 | + display: grid; | |
| 21 | + place-items: center; | |
| 22 | + padding: 32px 16px; | |
| 23 | + background: | |
| 24 | + radial-gradient(48rem 22rem at 50% -8rem, rgba(47, 119, 235, 0.15), transparent), | |
| 25 | + #f6f7f9; | |
| 26 | + } | |
| 27 | + main { | |
| 28 | + width: min(100%, 576px); | |
| 29 | + overflow: hidden; | |
| 30 | + border: 1px solid rgba(23, 25, 31, 0.1); | |
| 31 | + border-radius: 20px; | |
| 32 | + background: rgba(255, 255, 255, 0.94); | |
| 33 | + box-shadow: 0 24px 64px rgba(16, 24, 40, 0.16); | |
| 34 | + } | |
| 35 | + .stage { | |
| 36 | + position: relative; | |
| 37 | + height: 96px; | |
| 38 | + overflow: hidden; | |
| 39 | + padding: 22px 24px; | |
| 40 | + color: white; | |
| 41 | + background: linear-gradient(145deg, #5ab8fa 0%, #347ff8 46%, #1939bd 100%); | |
| 42 | + } | |
| 43 | + .stage::before { | |
| 44 | + content: ""; | |
| 45 | + position: absolute; | |
| 46 | + inset: 0; | |
| 47 | + opacity: 0.42; | |
| 48 | + background-image: | |
| 49 | + linear-gradient(rgba(234, 246, 255, 0.25) 1px, transparent 1px), | |
| 50 | + linear-gradient(90deg, rgba(234, 246, 255, 0.25) 1px, transparent 1px), | |
| 51 | + linear-gradient(rgba(234, 246, 255, 0.12) 1px, transparent 1px), | |
| 52 | + linear-gradient(90deg, rgba(234, 246, 255, 0.12) 1px, transparent 1px); | |
| 53 | + background-size: 32px 32px, 32px 32px, 8px 8px, 8px 8px; | |
| 54 | + } | |
| 55 | + .stage::after { | |
| 56 | + content: ""; | |
| 57 | + position: absolute; | |
| 58 | + inset: 0; | |
| 59 | + background: | |
| 60 | + radial-gradient(circle at 78% 20%, rgba(210, 255, 255, 0.36), transparent 34%), | |
| 61 | + linear-gradient(to bottom, transparent 24%, rgba(8, 28, 89, 0.38)); | |
| 62 | + } | |
| 63 | + .stage-content { | |
| 64 | + position: relative; | |
| 65 | + z-index: 1; | |
| 66 | + } | |
| 67 | + .brand { | |
| 68 | + margin: 0; | |
| 69 | + font-size: 10px; | |
| 70 | + font-weight: 700; | |
| 71 | + letter-spacing: 0.2em; | |
| 72 | + text-transform: uppercase; | |
| 73 | + } | |
| 74 | + .brand { color: rgba(255, 255, 255, 0.82); } | |
| 75 | + .content { padding: 30px 32px 34px; } | |
| 76 | + .status { | |
| 77 | + width: 42px; | |
| 78 | + height: 42px; | |
| 79 | + display: grid; | |
| 80 | + place-items: center; | |
| 81 | + margin-top: -52px; | |
| 82 | + margin-bottom: 22px; | |
| 83 | + border: 4px solid white; | |
| 84 | + border-radius: 50%; | |
| 85 | + background: #2065df; | |
| 86 | + color: white; | |
| 87 | + font-size: 22px; | |
| 88 | + font-weight: 700; | |
| 89 | + box-shadow: 0 8px 22px rgba(25, 72, 177, 0.3); | |
| 90 | + } | |
| 91 | + .eyebrow { | |
| 92 | + margin: 0 0 8px; | |
| 93 | + color: #2866cc; | |
| 94 | + font-size: 10px; | |
| 95 | + font-weight: 700; | |
| 96 | + letter-spacing: 0.18em; | |
| 97 | + text-transform: uppercase; | |
| 98 | + } | |
| 99 | + h1 { margin: 0; font-size: clamp(26px, 5vw, 34px); line-height: 1.12; letter-spacing: -0.035em; } | |
| 100 | + .description { margin: 12px 0 0; color: #646975; font-size: 15px; line-height: 1.6; } | |
| 101 | + .next { | |
| 102 | + margin-top: 24px; | |
| 103 | + padding: 14px 16px; | |
| 104 | + border: 1px solid rgba(23, 25, 31, 0.1); | |
| 105 | + border-radius: 12px; | |
| 106 | + background: #f7f8fa; | |
| 107 | + color: #454a55; | |
| 108 | + font-size: 13px; | |
| 109 | + } | |
| 110 | + .next strong { color: #17191f; } | |
| 111 | + @media (prefers-color-scheme: dark) { | |
| 112 | + :root { background: #101115; color: #f1f3f7; } | |
| 113 | + body { background: radial-gradient(48rem 22rem at 50% -8rem, rgba(55, 102, 210, 0.2), transparent), #101115; } | |
| 114 | + main { border-color: rgba(255, 255, 255, 0.1); background: rgba(25, 27, 33, 0.96); } | |
| 115 | + .status { border-color: #191b21; } | |
| 116 | + .eyebrow { color: #77a8ff; } | |
| 117 | + .description { color: #a8adb8; } | |
| 118 | + .next { border-color: rgba(255, 255, 255, 0.1); background: #20232a; color: #b9bec8; } | |
| 119 | + .next strong { color: #f1f3f7; } | |
| 120 | + } | |
| 121 | + </style> | |
| 122 | + </head> | |
| 123 | + <body> | |
| 124 | + <main> | |
| 125 | + <header class="stage"> | |
| 126 | + <div class="stage-content"> | |
| 127 | + <p class="brand">T3 Code</p> | |
| 128 | + </div> | |
| 129 | + </header> | |
| 130 | + <section class="content"> | |
| 131 | + <div class="status" aria-hidden="true">✓</div> | |
| 132 | + <p class="eyebrow">Browser authorization complete</p> | |
| 133 | + <h1>You're connected</h1> | |
| 134 | + <p class="description">The authorization code was delivered securely to your waiting terminal.</p> | |
| 135 | + <div class="next"><strong>Next:</strong> return to the terminal to finish T3 Connect setup. You can close this window.</div> | |
| 136 | + </section> | |
| 137 | + </main> | |
| 138 | + </body> | |
| 139 | +</html>`; | |
| 140 | +} | |
| @@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => { · 199 unmodified lines | ||
| 200 | 200 | get: unusedSecretStoreOperation(), |
| 201 | 201 | getExisting: Effect.succeed(Option.none()), |
| 202 | 202 | hasCredential: unusedSecretStoreOperation(), |
| 203 | + store: () => unusedSecretStoreOperation(), | |
| 203 | 204 | clear: unusedSecretStoreOperation(), |
| 204 | 205 | }), |
| 205 | 206 | ), |
| @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | + hostedAppUrlConfig, | |
| 7 | 8 | makeCloudCliOAuthConfig, |
| 8 | 9 | makeRelayUrlConfig, |
| 9 | 10 | resolveRelayClientTracingConfig, |
| @@ -47,6 +48,40 @@ it.effect("rejects an injected relay URL with a non-origin path", () => · 37 unmodified lines | ||
| 47 | 48 | makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip), |
| 48 | 49 | ); |
| 49 | 50 | |
| 51 | +it.effect("normalizes the hosted app URL to an absolute origin", () => | |
| 52 | + Effect.gen(function* () { | |
| 53 | + assert.equal( | |
| 54 | + yield* hostedAppUrlConfig.pipe( | |
| 55 | + provideEnv({ T3CODE_HOSTED_APP_URL: "https://nightly.app.t3.codes" }), | |
| 56 | + ), | |
| 57 | + "https://nightly.app.t3.codes", | |
| 58 | + ); | |
| 59 | + assert.equal( | |
| 60 | + yield* hostedAppUrlConfig.pipe( | |
| 61 | + provideEnv({ T3CODE_HOSTED_APP_URL: "http://localhost:5733" }), | |
| 62 | + ), | |
| 63 | + "http://localhost:5733", | |
| 64 | + ); | |
| 65 | + }), | |
| 66 | +); | |
| 67 | + | |
| 68 | +it.effect("rejects malformed or insecure hosted app URLs", () => | |
| 69 | + Effect.gen(function* () { | |
| 70 | + for (const value of [ | |
| 71 | + "app.t3.codes", | |
| 72 | + "http://app.t3.codes", | |
| 73 | + "https://app.t3.codes/nested", | |
| 74 | + "https://app.t3.codes?alias=true", | |
| 75 | + ]) { | |
| 76 | + const result = yield* hostedAppUrlConfig.pipe( | |
| 77 | + provideEnv({ T3CODE_HOSTED_APP_URL: value }), | |
| 78 | + Effect.result, | |
| 79 | + ); | |
| 80 | + assert.isTrue(Result.isFailure(result), value); | |
| 81 | + } | |
| 82 | + }), | |
| 83 | +); | |
| 84 | + | |
| 50 | 85 | it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () => |
| 51 | 86 | Effect.gen(function* () { |
| 52 | 87 | const config = yield* makeCloudCliOAuthConfig({ |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; | |
| 1 | 2 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 2 | 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 3 | 4 | import * as Config from "effect/Config"; |
| @@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi · 11 unmodified lines | ||
| 15 | 16 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; |
| 16 | 17 | |
| 17 | 18 | const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; |
| 18 | −const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; | |
| 19 | +const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; | |
| 19 | 20 | |
| 20 | 21 | function validateRelayUrl(value: string) { |
| 21 | 22 | const relayUrl = normalizeSecureRelayUrl(value); |
| @@ -100,6 +101,44 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { · 78 unmodified lines | ||
| 100 | 101 | |
| 101 | 102 | export const relayUrlConfig = makeRelayUrlConfig(); |
| 102 | 103 | |
| 104 | +/** | |
| 105 | + * Hosted app origin used for out-of-band OAuth on headless | |
| 106 | + * machines. Overridable so staging/nightly builds can point their CLIs at a | |
| 107 | + * matching hosted deployment. | |
| 108 | + */ | |
| 109 | +export const hostedAppUrlConfig = makePublicValueConfig( | |
| 110 | + "T3CODE_HOSTED_APP_URL", | |
| 111 | + DEFAULT_HOSTED_APP_URL, | |
| 112 | +).pipe(Config.mapOrFail(validateHostedAppUrl)); | |
| 113 | + | |
| 114 | +function validateHostedAppUrl(value: string) { | |
| 115 | + try { | |
| 116 | + const url = new URL(value); | |
| 117 | + const isLoopbackHttp = | |
| 118 | + url.protocol === "http:" && | |
| 119 | + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); | |
| 120 | + if ( | |
| 121 | + (url.protocol !== "https:" && !isLoopbackHttp) || | |
| 122 | + url.pathname !== "/" || | |
| 123 | + url.search !== "" || | |
| 124 | + url.hash !== "" | |
| 125 | + ) { | |
| 126 | + throw new Error("invalid hosted app origin"); | |
| 127 | + } | |
| 128 | + return Effect.succeed(url.origin); | |
| 129 | + } catch { | |
| 130 | + return Effect.fail( | |
| 131 | + new Config.ConfigError( | |
| 132 | + new Schema.SchemaError( | |
| 133 | + new SchemaIssue.InvalidValue(Option.some(value), { | |
| 134 | + message: "Hosted app URL must be an absolute HTTPS origin (or HTTP loopback origin).", | |
| 135 | + }), | |
| 136 | + ), | |
| 137 | + ), | |
| 138 | + ); | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 103 | 142 | function makePublicValueConfig(name: string, fallback: string) { |
| 104 | 143 | const runtimeConfig = Config.nonEmptyString(name); |
| 105 | 144 | return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( |
| @@ -123,15 +123,11 @@ export interface RunMigrationsOptions { · 122 unmodified lines | ||
| 123 | 123 | export const runMigrations = Effect.fn("runMigrations")(function* ({ |
| 124 | 124 | toMigrationInclusive, |
| 125 | 125 | }: RunMigrationsOptions = {}) { |
| 126 | − yield* Effect.log( | |
| 127 | − toMigrationInclusive === undefined | |
| 128 | − ? "Running all migrations..." | |
| 129 | − : `Running migrations 1 through ${toMigrationInclusive}...`, | |
| 130 | − ); | |
| 131 | 126 | const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); |
| 132 | − yield* Effect.log("Migrations ran successfully").pipe( | |
| 133 | − Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), | |
| 134 | − ); | |
| 127 | + const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); | |
| 128 | + yield* migrations.length === 0 | |
| 129 | + ? Effect.logDebug("Database schema is current") | |
| 130 | + : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); | |
| 135 | 131 | return executedMigrations; |
| 136 | 132 | }); |
| 137 | 133 | |
| @@ -322,7 +322,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 321 unmodified lines | ||
| 322 | 322 | Layer.provideMerge(ServerSecretStore.layer), |
| 323 | 323 | Layer.provideMerge( |
| 324 | 324 | Layer.mergeAll( |
| 325 | − CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 325 | + CloudCliTokenManager.layer.pipe( | |
| 326 | + Layer.provide(ServerSecretStore.layer), | |
| 327 | + Layer.provide(ExternalLauncher.layer), | |
| 328 | + ), | |
| 326 | 329 | CloudManagedEndpointRuntimeLive, |
| 327 | 330 | ), |
| 328 | 331 | ), |