1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as TestClock from "effect/testing/TestClock";
11import { ChildProcessSpawner } from "effect/process";
12
13import {
14 baseSshArgs,
15 getLastNonEmptyOutputLine,
16 parseSshResolveOutput,
17 remoteStateKey,
18 runSshCommand,
19} from "./command.ts";
20import { SshCommandError } from "./errors.ts";
21
22const encoder = new TextEncoder();
23
24const makeFailedProcess = (input: { readonly stdout: string; readonly stderr?: string }) => {
25 const stdoutStream = Stream.make(encoder.encode(input.stdout));
26 const stderrStream = input.stderr ? Stream.make(encoder.encode(input.stderr)) : Stream.empty;
27 return ChildProcessSpawner.makeHandle({
28 pid: ChildProcessSpawner.ProcessId(123),
29 stdout: stdoutStream,
30 stderr: stderrStream,
31 all: Stream.empty,
32 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
33 isRunning: Effect.succeed(false),
34 kill: () => Effect.void,
35 stdin: Sink.drain,
36 getInputFd: () => Sink.drain,
37 getOutputFd: () => Stream.empty,
38 unref: Effect.succeed(Effect.void),
39 });
40};
41
42const makeNeverFinishingProcess = () => {
43 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
44 return ChildProcessSpawner.makeHandle({
45 pid: ChildProcessSpawner.ProcessId(123),
46 stdout: Stream.empty,
47 stderr: Stream.empty,
48 all: Stream.empty,
49 exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => {
50 finish = (exitCode) => resume(Effect.succeed(exitCode));
51 return Effect.sync(() => {
52 finish = null;
53 });
54 }),
55 isRunning: Effect.succeed(true),
56 kill: () =>
57 Effect.sync(() => {
58 finish?.(ChildProcessSpawner.ExitCode(143));
59 }),
60 stdin: Sink.drain,
61 getInputFd: () => Sink.drain,
62 getOutputFd: () => Stream.empty,
63 unref: Effect.succeed(Effect.void),
64 });
65};
66
67describe("ssh command", () => {
68 it.effect("parses resolved ssh config output into a target", () =>
69 Effect.sync(() => {
70 assert.deepEqual(
71 parseSshResolveOutput(
72 "devbox",
73 ["hostname devbox.example.com", "user julius", "port 2222", ""].join("\n"),
74 ),
75 {
76 alias: "devbox",
77 hostname: "devbox.example.com",
78 username: "julius",
79 port: 2222,
80 },
81 );
82 }),
83 );
84
85 it.effect("builds interactive ssh args without forcing batch mode", () =>
86 Effect.sync(() => {
87 assert.deepEqual(
88 baseSshArgs(
89 {
90 alias: "devbox",
91 hostname: "devbox.example.com",
92 username: "julius",
93 port: 2222,
94 },
95 { batchMode: "no" },
96 ),
97 ["-o", "BatchMode=no", "-o", "ConnectTimeout=10", "-p", "2222"],
98 );
99 }),
100 );
101
102 // Remote servers store state under this key, so it must not change across releases.
103 it.effect("derives a stable remote state key", () =>
104 Effect.gen(function* () {
105 assert.equal(
106 yield* remoteStateKey({
107 alias: "devbox",
108 hostname: "devbox.example.com",
109 username: "julius",
110 port: 2222,
111 }),
112 "711bc738002d72fd",
113 );
114 assert.equal(
115 yield* remoteStateKey({
116 alias: "fixture",
117 hostname: "fixture",
118 username: null,
119 port: null,
120 }),
121 "326264c4f08c8a0c",
122 );
123 }).pipe(Effect.provide(NodeServices.layer)),
124 );
125
126 it.effect("reads the last non-empty ssh output line", () =>
127 Effect.sync(() => {
128 assert.equal(
129 getLastNonEmptyOutputLine(
130 ["Welcome to the host", "", '{"credential":"pairing-token"}', ""].join("\n"),
131 ),
132 '{"credential":"pairing-token"}',
133 );
134 }),
135 );
136
137 it.effect("includes stdout in non-zero command failures when stderr is empty", () => {
138 const spawner = ChildProcessSpawner.make(() =>
139 Effect.succeed(makeFailedProcess({ stdout: "Pairing token creation failed\n" })),
140 );
141 const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
142 const layerProcess = Layer.mergeAll(NodeServices.layer, layerSpawner);
143
144 return Effect.gen(function* () {
145 const result = yield* Effect.result(
146 runSshCommand(
147 {
148 alias: "devbox",
149 hostname: "devbox.example.com",
150 username: "julius",
151 port: 2222,
152 },
153 { remoteCommandArgs: ["sh", "-s"] },
154 ),
155 );
156
157 assert.isTrue(Result.isFailure(result));
158 if (Result.isFailure(result)) {
159 assert.instanceOf(result.failure, SshCommandError);
160 assert.equal(result.failure.message, "Pairing token creation failed");
161 assert.equal(result.failure.stdout, "Pairing token creation failed\n");
162 assert.equal(result.failure.stderr, "");
163 }
164 }).pipe(Effect.provide(layerProcess));
165 });
166
167 it.effect("redacts credentials from stdout in non-zero command failures", () => {
168 const spawner = ChildProcessSpawner.make(() =>
169 Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })),
170 );
171 const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
172 const layerProcess = Layer.mergeAll(NodeServices.layer, layerSpawner);
173
174 return Effect.gen(function* () {
175 const result = yield* Effect.result(
176 runSshCommand(
177 {
178 alias: "devbox",
179 hostname: "devbox.example.com",
180 username: "julius",
181 port: 2222,
182 },
183 { remoteCommandArgs: ["sh", "-s"] },
184 ),
185 );
186
187 assert.isTrue(Result.isFailure(result));
188 if (Result.isFailure(result)) {
189 assert.instanceOf(result.failure, SshCommandError);
190 assert.equal(result.failure.message, '{"credential":"[redacted]"}');
191 assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n');
192 }
193 }).pipe(Effect.provide(layerProcess));
194 });
195
196 it.effect("fails commands that never finish", () => {
197 const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess()));
198 const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
199 const layerProcess = Layer.mergeAll(NodeServices.layer, layerSpawner, TestClock.layer());
200
201 return Effect.gen(function* () {
202 const fiber = yield* Effect.forkChild(
203 Effect.result(
204 runSshCommand(
205 {
206 alias: "devbox",
207 hostname: "devbox.example.com",
208 username: "julius",
209 port: 2222,
210 },
211 { timeoutMs: 1 },
212 ),
213 ),
214 );
215 yield* Effect.yieldNow;
216 yield* TestClock.adjust(Duration.millis(1));
217
218 const result = yield* Fiber.join(fiber);
219
220 assert.isTrue(Result.isFailure(result));
221 if (Result.isFailure(result)) {
222 assert.include(result.failure.message, "SSH command timed out after 1ms.");
223 }
224 }).pipe(Effect.provide(layerProcess));
225 });
226});