1// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
2import * as NodeHttp from "node:http";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { AuthStandardClientScopes } from "@t3tools/contracts";
9import * as NetService from "@t3tools/shared/Net";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import { assert, describe, expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as TestConsole from "effect/testing/TestConsole";
15import { Command, CliError } from "effect/cli";
16
17import { cli } from "../binCli.ts";
18import {
19 SERVICE_LAUNCHER_CONTEXT_ENV,
20 SERVICE_LAUNCHER_PROTOCOL,
21} from "../cloud/serviceProtocol.ts";
22import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
23import {
24 makePersistedServerRuntimeState,
25 persistServerRuntimeState,
26 type PersistedServerRuntimeState,
27} from "../serverRuntimeState.ts";
28import {
29 DevServerNotProxiableError,
30 resolveDirectPairingBaseUrl,
31 resolveTailscaleLocalTarget,
32} from "./pair.ts";
33
34import packageJson from "../../package.json" with { type: "json" };
35
36const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
37
38const baseState = {
39 version: 1,
40 pid: 123,
41 port: 3_773,
42 origin: "http://127.0.0.1:3773",
43 startedAt: "2026-06-20T00:00:00.000Z",
44} as const satisfies PersistedServerRuntimeState;
45
46describe("pair base URL selection", () => {
47 it("pairs through the dev web origin when the server fronts a dev server", () => {
48 expect(resolveDirectPairingBaseUrl({ ...baseState, devUrl: "http://localhost:5733/" })).toBe(
49 "http://localhost:5733/",
50 );
51 });
52
53 it("pairs through the bound host when there is no dev server", () => {
54 expect(resolveDirectPairingBaseUrl({ ...baseState, host: "100.64.0.7" })).toBe(
55 "http://100.64.0.7:3773",
56 );
57 expect(resolveDirectPairingBaseUrl(baseState)).toBe("http://localhost:3773");
58 });
59});
60
61describe("pair tailscale local target", () => {
62 it("proxies the dev web port for dev servers", () => {
63 expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual(
64 { localPort: 5_733 },
65 );
66 // A dev server on a non-loopback interface must be proxied at that
67 // interface; tailscale serve defaults to 127.0.0.1 otherwise.
68 expect(
69 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://192.168.1.10:5733/" }),
70 ).toEqual({ localPort: 5_733, localHost: "192.168.1.10" });
71 // URL.hostname keeps IPv6 brackets, so the serve target stays valid.
72 expect(
73 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://[fd7a:115c::1]:5733/" }),
74 ).toEqual({ localPort: 5_733, localHost: "[fd7a:115c::1]" });
75 });
76
77 it("rejects HTTPS dev URLs, which tailscale serve cannot proxy", () => {
78 expect(
79 resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }),
80 ).toBeInstanceOf(DevServerNotProxiableError);
81 });
82
83 it("proxies the backend port directly otherwise", () => {
84 expect(resolveTailscaleLocalTarget(baseState)).toEqual({ localPort: 3_773 });
85 expect(resolveTailscaleLocalTarget({ ...baseState, host: "0.0.0.0" })).toEqual({
86 localPort: 3_773,
87 });
88 expect(resolveTailscaleLocalTarget({ ...baseState, host: "192.168.1.42" })).toEqual({
89 localPort: 3_773,
90 localHost: "192.168.1.42",
91 });
92 });
93});
94
95const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
96
97const provideCliTestLayers = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
98 Effect.provide(effect, Layer.mergeAll(layerCliRuntime, TestConsole.layer));
99
100// Console output accumulates across CLI runs within a test, and each
101// Console.log call is one entry — so the latest command's output is the last
102// entry, even when it spans many lines.
103const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
104 provideCliTestLayers(
105 Effect.gen(function* () {
106 yield* effect;
107 return (
108 (yield* TestConsole.logLines).findLast(
109 (line): line is string => typeof line === "string",
110 ) ?? ""
111 );
112 }),
113 );
114
115const testDescriptor = {
116 environmentId: "pair-test-environment",
117 label: "pair-test",
118 platform: { os: "linux", arch: "x64" },
119 serverVersion: "0.0.1",
120 capabilities: { repositoryIdentity: true },
121};
122
123const withDescriptorServer = <A, E, R>(run: (origin: string) => Effect.Effect<A, E, R>) =>
124 Effect.acquireUseRelease(
125 Effect.callback<NodeHttp.Server>((resume) => {
126 const server = NodeHttp.createServer((request, response) => {
127 if (request.url === "/.well-known/t3/environment") {
128 response.writeHead(200, { "content-type": "application/json" });
129 response.end(JSON.stringify(testDescriptor));
130 return;
131 }
132 response.writeHead(404);
133 response.end();
134 });
135 server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
136 }),
137 (server) => {
138 const address = server.address();
139 if (address === null || typeof address === "string") {
140 return Effect.die(new Error("Expected a TCP address"));
141 }
142 return run(`http://127.0.0.1:${String(address.port)}`);
143 },
144 (server) => Effect.sync(() => server.close()),
145 );
146
147describe("t3 pair", () => {
148 it.effect("mints a token and prints a QR pairing URL for a live server", () =>
149 withDescriptorServer((origin) =>
150 Effect.gen(function* () {
151 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-test-"));
152 const port = Number(new URL(origin).port);
153 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
154 yield* persistServerRuntimeState({
155 path: statePath,
156 state: yield* makePersistedServerRuntimeState({
157 config: { host: "127.0.0.1", devUrl: undefined },
158 port,
159 }),
160 });
161
162 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
163
164 assert.include(output, `Pairing with pair-test (${origin})`);
165 assert.include(output, `Pairing URL: ${origin}/pair#token=`);
166 assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄"));
167 // Loopback origins are not reachable from a phone; the output must say so.
168 assert.include(output, "only reachable from this machine");
169
170 const token = /#token=([A-Z2-9]+)/.exec(output)?.[1];
171 assert.isString(token);
172
173 // The token must be in the same store the running server reads.
174 const listed = yield* captureStdout(
175 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
176 );
177 const credentials = JSON.parse(listed) as ReadonlyArray<{
178 readonly label?: string;
179 readonly scopes: ReadonlyArray<string>;
180 }>;
181 assert.equal(credentials.length, 1);
182 assert.equal(credentials[0]?.label, "t3 pair");
183 assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes);
184 }),
185 ).pipe(
186 Effect.provide(NodeServices.layer),
187 Effect.provideService(HostProcess.Environment, {
188 ...process.env,
189 [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
190 protocol: SERVICE_LAUNCHER_PROTOCOL,
191 childVersion: packageJson.version,
192 }),
193 }),
194 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, {
195 connected: false,
196 send: () => false,
197 on: () => undefined,
198 off: () => undefined,
199 }),
200 ),
201 );
202
203 it.effect("mints a pairing grant with only the selected scopes", () =>
204 withDescriptorServer((origin) =>
205 Effect.gen(function* () {
206 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-scopes-test-"));
207 yield* persistServerRuntimeState({
208 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
209 state: yield* makePersistedServerRuntimeState({
210 config: { host: "127.0.0.1", devUrl: undefined },
211 port: Number(new URL(origin).port),
212 }),
213 });
214
215 yield* captureStdout(
216 runCli([
217 "pair",
218 "--base-dir",
219 baseDir,
220 "--scope",
221 "orchestration:read",
222 "--scope",
223 "relay:read",
224 "--scope",
225 "orchestration:read",
226 ]),
227 );
228 const listed = yield* captureStdout(
229 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
230 );
231 const credentials = JSON.parse(listed) as ReadonlyArray<{
232 readonly scopes: ReadonlyArray<string>;
233 }>;
234 assert.lengthOf(credentials, 1);
235 assert.deepEqual(credentials[0]?.scopes, ["orchestration:read", "relay:read"]);
236 }),
237 ).pipe(Effect.provide(NodeServices.layer)),
238 );
239
240 it.effect("pairs through the recorded dev web URL for dev servers", () =>
241 withDescriptorServer((origin) =>
242 Effect.gen(function* () {
243 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-dev-test-"));
244 const port = Number(new URL(origin).port);
245 const statePath = NodePath.join(baseDir, "dev", "server-runtime.json");
246 yield* persistServerRuntimeState({
247 path: statePath,
248 state: yield* makePersistedServerRuntimeState({
249 config: { host: undefined, devUrl: new URL("http://localhost:5733") },
250 port,
251 }),
252 });
253
254 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
255
256 assert.include(output, "Pairing URL: http://localhost:5733/pair#token=");
257 }),
258 ).pipe(Effect.provide(NodeServices.layer)),
259 );
260
261 it.effect("directs to t3 serve or t3 connect when no server is running", () =>
262 Effect.gen(function* () {
263 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-none-test-"));
264
265 const error = yield* provideCliTestLayers(
266 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
267 );
268
269 const rendered = String(
270 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
271 );
272 assert.include(rendered, "No running T3 Code server found.");
273 assert.include(rendered, "npx t3 serve");
274 assert.include(rendered, "npx t3 connect");
275 }).pipe(Effect.provide(NodeServices.layer)),
276 );
277
278 it.effect("ignores runtime state whose recorded pid is no longer alive", () =>
279 withDescriptorServer((origin) =>
280 Effect.gen(function* () {
281 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-pid-test-"));
282 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
283 // The origin answers (another server reused the port), but the pid
284 // that wrote this state file is dead — pairing must not mint a token
285 // into the dead server's database.
286 const state = yield* makePersistedServerRuntimeState({
287 config: { host: "127.0.0.1", devUrl: undefined },
288 port: Number(new URL(origin).port),
289 });
290 yield* persistServerRuntimeState({
291 path: statePath,
292 // pid 2**22 + 1 exceeds any default Linux/macOS pid range.
293 state: { ...state, pid: 4_194_305 },
294 });
295
296 const error = yield* provideCliTestLayers(
297 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
298 );
299
300 const rendered = String(
301 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
302 );
303 assert.include(rendered, "No running T3 Code server found.");
304 }),
305 ).pipe(Effect.provide(NodeServices.layer)),
306 );
307
308 it.effect("ignores stale runtime state pointing at a dead server", () =>
309 Effect.gen(function* () {
310 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-stale-test-"));
311 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
312 // A port from the dynamic range with nothing listening: the probe fails
313 // fast with ECONNREFUSED and discovery moves on.
314 yield* persistServerRuntimeState({
315 path: statePath,
316 state: yield* makePersistedServerRuntimeState({
317 config: { host: "127.0.0.1", devUrl: undefined },
318 port: 1,
319 }),
320 });
321
322 const error = yield* provideCliTestLayers(
323 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
324 );
325
326 const rendered = String(
327 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
328 );
329 assert.include(rendered, "No running T3 Code server found.");
330 }).pipe(Effect.provide(NodeServices.layer)),
331 );
332});
333
334describe("auth scope options", () => {
335 it.effect.each([
336 { group: "pairing", action: "create" },
337 { group: "session", action: "issue" },
338 ] as const)(
339 "issues and persists only the selected scopes for auth $group $action",
340 ({ group, action }) =>
341 Effect.gen(function* () {
342 const baseDir = NodeFS.mkdtempSync(
343 NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-scopes-test-"),
344 );
345 const output = yield* captureStdout(
346 runCli([
347 "auth",
348 group,
349 action,
350 "--base-dir",
351 baseDir,
352 "--json",
353 "--scope",
354 "orchestration:read",
355 "--scope",
356 "access:read",
357 "--scope",
358 "orchestration:read",
359 ]),
360 );
361 const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray<string> };
362 const listOutput = yield* captureStdout(
363 runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]),
364 );
365 const listed = JSON.parse(listOutput) as ReadonlyArray<{
366 readonly scopes: ReadonlyArray<string>;
367 }>;
368
369 assert.deepEqual(issued.scopes, ["orchestration:read", "access:read"]);
370 assert.lengthOf(listed, 1);
371 assert.deepEqual(listed[0]?.scopes, issued.scopes);
372 }),
373 );
374
375 it.effect.each(
376 [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]].map((command) => ({
377 command,
378 label: command.join(" "),
379 })),
380 )("rejects invalid scopes before running $label", ({ command }) =>
381 Effect.gen(function* () {
382 const error = yield* runCli([
383 ...command,
384 "--scope",
385 "orchestration:read",
386 "--scope",
387 "admin",
388 ]).pipe(Effect.provide(layerCliRuntime), Effect.flip);
389
390 if (!CliError.isCliError(error) || error._tag !== "ShowHelp") {
391 assert.fail(`Expected ShowHelp, got ${String(error)}`);
392 }
393 assert.deepEqual(error.commandPath, ["t3", ...command]);
394 const scopeError = error.errors[0];
395 if (scopeError?._tag !== "InvalidValue") {
396 assert.fail(`Expected InvalidValue, got ${String(scopeError?._tag)}`);
397 }
398 assert.equal(scopeError.option, "scope");
399 assert.equal(scopeError.value, "admin");
400 }),
401 );
402});