1import type {
2 DesktopDiscoveredSshHost,
3 DesktopSshEnvironmentBootstrap,
4 DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";
6import * as NetService from "@t3tools/shared/Net";
7import * as SshAuth from "@t3tools/ssh/auth";
8import { resolveSshTarget } from "@t3tools/ssh/command";
9import { discoverSshHosts } from "@t3tools/ssh/config";
10import {
11 SshCommandError,
12 SshHostDiscoveryError,
13 SshInvalidTargetError,
14 SshLaunchError,
15 SshPairingError,
16 SshPasswordPromptError,
17 SshReadinessError,
18} from "@t3tools/ssh/errors";
19import * as SshTunnel from "@t3tools/ssh/tunnel";
20import * as Context from "effect/Context";
21import type * as Crypto from "effect/Crypto";
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as Layer from "effect/Layer";
25import * as Path from "effect/Path";
26import * as HttpClient from "effect/http/HttpClient";
27import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
28
29import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
30
31export type DesktopSshEnvironmentRuntimeServices =
32 | ChildProcessSpawner.ChildProcessSpawner
33 | Crypto.Crypto
34 | FileSystem.FileSystem
35 | Path.Path
36 | HttpClient.HttpClient
37 | NetService.NetService;
38
39export type DesktopSshEnvironmentOperationError =
40 | SshCommandError
41 | SshInvalidTargetError
42 | SshLaunchError
43 | SshPairingError
44 | SshReadinessError
45 | SshPasswordPromptError
46 | NetService.NetError;
47
48export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
49
50export 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
69export interface DesktopSshEnvironmentLayerOptions {
70 readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;
71}
72
73function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
74 return discoverSshHosts(input ?? {});
75}
76
77export function isDesktopSshPasswordPromptCancellation(
78 error: unknown,
79): error is SshPasswordPromptError {
80 return (
81 error instanceof SshPasswordPromptError &&
82 DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause)
83 );
84}
85
86function unexpectedPasswordPromptError(error: never): never {
87 throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`);
88}
89
90export 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
120const 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. */
129export 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
165export 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 );