| 1 | import type { |
| 2 | DesktopDiscoveredSshHost, |
| 3 | DesktopSshEnvironmentBootstrap, |
| 4 | DesktopSshEnvironmentTarget, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import * as NetService from "@t3tools/shared/Net"; |
| 7 | import * as SshAuth from "@t3tools/ssh/auth"; |
| 8 | import { resolveSshTarget } from "@t3tools/ssh/command"; |
| 9 | import { discoverSshHosts } from "@t3tools/ssh/config"; |
| 10 | import { |
| 11 | SshCommandError, |
| 12 | SshHostDiscoveryError, |
| 13 | SshInvalidTargetError, |
| 14 | SshLaunchError, |
| 15 | SshPairingError, |
| 16 | SshPasswordPromptError, |
| 17 | SshReadinessError, |
| 18 | } from "@t3tools/ssh/errors"; |
| 19 | import * as SshTunnel from "@t3tools/ssh/tunnel"; |
| 20 | import * as Context from "effect/Context"; |
| 21 | import type * as Crypto from "effect/Crypto"; |
| 22 | import * as Effect from "effect/Effect"; |
| 23 | import * as FileSystem from "effect/FileSystem"; |
| 24 | import * as Layer from "effect/Layer"; |
| 25 | import * as Path from "effect/Path"; |
| 26 | import * as HttpClient from "effect/http/HttpClient"; |
| 27 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 28 | |
| 29 | import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; |
| 30 | |
| 31 | export type DesktopSshEnvironmentRuntimeServices = |
| 32 | | ChildProcessSpawner.ChildProcessSpawner |
| 33 | | Crypto.Crypto |
| 34 | | FileSystem.FileSystem |
| 35 | | Path.Path |
| 36 | | HttpClient.HttpClient |
| 37 | | NetService.NetService; |
| 38 | |
| 39 | export type DesktopSshEnvironmentOperationError = |
| 40 | | SshCommandError |
| 41 | | SshInvalidTargetError |
| 42 | | SshLaunchError |
| 43 | | SshPairingError |
| 44 | | SshReadinessError |
| 45 | | SshPasswordPromptError |
| 46 | | NetService.NetError; |
| 47 | |
| 48 | export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; |
| 49 | |
| 50 | export class DesktopSshEnvironment extends Context.Service< |
| 51 | DesktopSshEnvironment, |
| 52 | { |
| 53 | readonly discoverHosts: (input?: { |
| 54 | readonly homeDir?: string; |
| 55 | }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>; |
| 56 | readonly resolveHost: ( |
| 57 | alias: string, |
| 58 | ) => Effect.Effect<DesktopSshEnvironmentTarget, SshCommandError | SshInvalidTargetError>; |
| 59 | readonly ensureEnvironment: ( |
| 60 | target: DesktopSshEnvironmentTarget, |
| 61 | options?: { readonly issuePairingToken?: boolean }, |
| 62 | ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>; |
| 63 | readonly disconnectEnvironment: ( |
| 64 | target: DesktopSshEnvironmentTarget, |
| 65 | ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>; |
| 66 | } |
| 67 | >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} |
| 68 | |
| 69 | export interface DesktopSshEnvironmentLayerOptions { |
| 70 | readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>; |
| 71 | } |
| 72 | |
| 73 | function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { |
| 74 | return discoverSshHosts(input ?? {}); |
| 75 | } |
| 76 | |
| 77 | export function isDesktopSshPasswordPromptCancellation( |
| 78 | error: unknown, |
| 79 | ): error is SshPasswordPromptError { |
| 80 | return ( |
| 81 | error instanceof SshPasswordPromptError && |
| 82 | DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) |
| 83 | ); |
| 84 | } |
| 85 | |
| 86 | function unexpectedPasswordPromptError(error: never): never { |
| 87 | throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); |
| 88 | } |
| 89 | |
| 90 | export function toSshPasswordPromptError( |
| 91 | cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, |
| 92 | ): SshPasswordPromptError { |
| 93 | let message: string; |
| 94 | switch (cause._tag) { |
| 95 | case "DesktopSshPromptRequestIdGenerationError": |
| 96 | message = "Secure randomness is unavailable."; |
| 97 | break; |
| 98 | case "DesktopSshPromptWindowUnavailableError": |
| 99 | case "DesktopSshPromptPresentationError": |
| 100 | message = "The Knife window is not available for SSH authentication."; |
| 101 | break; |
| 102 | case "DesktopSshPromptTimedOutError": |
| 103 | message = `SSH authentication timed out for ${cause.destination}.`; |
| 104 | break; |
| 105 | case "DesktopSshPromptCancelledError": |
| 106 | message = `SSH authentication cancelled for ${cause.destination}.`; |
| 107 | break; |
| 108 | case "DesktopSshPromptWindowClosedError": |
| 109 | message = "SSH authentication was cancelled because the app window closed."; |
| 110 | break; |
| 111 | case "DesktopSshPromptServiceStoppedError": |
| 112 | message = "SSH password prompt service stopped."; |
| 113 | break; |
| 114 | default: |
| 115 | return unexpectedPasswordPromptError(cause); |
| 116 | } |
| 117 | return new SshPasswordPromptError({ message, cause }); |
| 118 | } |
| 119 | |
| 120 | const makePasswordPrompt = ( |
| 121 | prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], |
| 122 | ): SshAuth.SshPasswordPrompt["Service"] => ({ |
| 123 | isAvailable: true, |
| 124 | request: (request: SshAuth.SshPasswordRequest) => |
| 125 | prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), |
| 126 | }); |
| 127 | |
| 128 | /** @public Service construction is part of the canonical Effect module API. */ |
| 129 | export const make = Effect.gen(function* () { |
| 130 | const manager = yield* SshTunnel.SshEnvironmentManager; |
| 131 | const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; |
| 132 | const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>(); |
| 133 | const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); |
| 134 | |
| 135 | return DesktopSshEnvironment.of({ |
| 136 | discoverHosts: (input) => |
| 137 | discoverDesktopSshHostsEffect(input).pipe( |
| 138 | Effect.provide(runtimeContext), |
| 139 | Effect.withSpan("desktop.ssh.discoverHosts"), |
| 140 | ), |
| 141 | resolveHost: (alias) => |
| 142 | resolveSshTarget(alias.trim()).pipe( |
| 143 | Effect.provide(runtimeContext), |
| 144 | Effect.withSpan("desktop.ssh.resolveHost"), |
| 145 | ), |
| 146 | ensureEnvironment: (target, ensureOptions) => |
| 147 | manager |
| 148 | .ensureEnvironment(target, ensureOptions) |
| 149 | .pipe( |
| 150 | Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), |
| 151 | Effect.provide(runtimeContext), |
| 152 | Effect.withSpan("desktop.ssh.ensureEnvironment"), |
| 153 | ), |
| 154 | disconnectEnvironment: (target) => |
| 155 | manager |
| 156 | .disconnectEnvironment(target) |
| 157 | .pipe( |
| 158 | Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), |
| 159 | Effect.provide(runtimeContext), |
| 160 | Effect.withSpan("desktop.ssh.disconnectEnvironment"), |
| 161 | ), |
| 162 | }); |
| 163 | }); |
| 164 | |
| 165 | export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => |
| 166 | Layer.effect(DesktopSshEnvironment, make).pipe( |
| 167 | Layer.provide( |
| 168 | SshTunnel.SshEnvironmentManager.layer( |
| 169 | options.resolveCliRunner === undefined |
| 170 | ? {} |
| 171 | : { resolveCliRunner: options.resolveCliRunner }, |
| 172 | ), |
| 173 | ), |
| 174 | ); |