1// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeNet from "node:net";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8
9import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { assert, it } from "@effect/vitest";
12import type { DesktopAppActivationRequest } from "@t3tools/contracts";
13import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import * as NetService from "@t3tools/shared/Net";
16import * as ConfigProvider from "effect/ConfigProvider";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Schema from "effect/Schema";
20import * as TestConsole from "effect/testing/TestConsole";
21import { Command } from "effect/cli";
22import { describe, expect } from "vite-plus/test";
23
24import { makeCli } from "../binCli.ts";
25import { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
26
27const liveProcess = Effect.acquireRelease(
28 Effect.promise(async () => {
29 const child = NodeChildProcess.spawn(process.execPath, ["-e", "process.stdin.resume()"], {
30 stdio: ["pipe", "ignore", "ignore"],
31 });
32 await NodeEvents.EventEmitter.once(child, "spawn");
33 return child;
34 }),
35 (child) =>
36 Effect.promise(async () => {
37 const exited = NodeEvents.EventEmitter.once(child, "exit");
38 child.kill();
39 await exited;
40 }),
41);
42
43const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState));
44
45// Node reads this variable itself, so the test must set the real process environment.
46const setProcessEnv = (name: string, value: string) => {
47 const previous = process.env[name];
48 process.env[name] = value;
49 return () => {
50 if (previous === undefined) delete process.env[name];
51 else process.env[name] = previous;
52 };
53};
54
55const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
56 Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe(
57 Effect.provide(
58 Layer.mergeAll(
59 NodeServices.layer,
60 NodeCrypto.layer,
61 NetService.layer,
62 ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
63 ),
64 ),
65 );
66
67const pathExists = (path: string) =>
68 Effect.promise(() =>
69 NodeFSP.stat(path).then(
70 () => true,
71 () => false,
72 ),
73 );
74
75async function startFakeDesktop(input: {
76 readonly baseDir: string;
77 readonly stateSubdirectory?: "userdata" | "dev";
78 readonly platform: NodeJS.Platform;
79 readonly userId: number | undefined;
80 readonly reply?: (request: DesktopAppActivationRequest) => unknown;
81}) {
82 const target = resolveDesktopAppControlAddress({
83 stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"),
84 platform: input.platform,
85 tempDir: NodeOS.tmpdir(),
86 userId: input.userId,
87 joinPath: NodePath.join,
88 });
89 if (target.directory !== null) {
90 await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 });
91 await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => {
92 if (error.code !== "ENOENT") throw error;
93 });
94 }
95
96 const received: DesktopAppActivationRequest[] = [];
97 const server = NodeNet.createServer((socket) => {
98 socket.setEncoding("utf8");
99 let buffer = "";
100 socket.on("data", (chunk) => {
101 buffer += chunk;
102 const newline = buffer.indexOf("\n");
103 if (newline === -1) return;
104 const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest;
105 received.push(request);
106 const response = input.reply
107 ? input.reply(request)
108 : {
109 version: 1,
110 requestId: request.requestId,
111 ok: true,
112 projectId: "project-1",
113 threadId: `thread-${received.length}`,
114 };
115 socket.end(`${JSON.stringify(response)}\n`);
116 });
117 });
118 await new Promise<void>((resolve, reject) => {
119 server.once("error", reject);
120 server.listen(target.address, resolve);
121 });
122
123 return {
124 directory: target.directory,
125 received,
126 close: async () => {
127 await new Promise<void>((resolve) => server.close(() => resolve()));
128 if (target.directory !== null) {
129 await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => {
130 if (error.code !== "ENOENT") throw error;
131 });
132 }
133 },
134 };
135}
136
137const fakeDesktop = Effect.fn(function* (
138 input: Omit<Parameters<typeof startFakeDesktop>[0], "platform" | "userId">,
139) {
140 const platform = yield* HostProcess.Platform;
141 const userId = yield* HostProcess.UserId;
142 return yield* Effect.acquireRelease(
143 Effect.promise(() => startFakeDesktop({ ...input, platform, userId })),
144 (server) => Effect.promise(() => server.close()),
145 );
146});
147
148const withTempDirectory = <A, E, R>(
149 prefix: string,
150 use: (root: string) => Effect.Effect<A, E, R>,
151 tempDir = NodeOS.tmpdir(),
152) =>
153 Effect.acquireUseRelease(
154 Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(tempDir, prefix))),
155 use,
156 (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })),
157 );
158
159describe("t3 server command safety", () => {
160 it.effect("rejects unknown command words without creating a home or project", () =>
161 withTempDirectory("t3-cli-unknown-", (root) =>
162 Effect.gen(function* () {
163 const baseDir = NodePath.join(root, "home");
164 for (const word of [
165 "account",
166 "login",
167 "clients",
168 "conenct",
169 "package.json",
170 "C:new-project",
171 ]) {
172 const error = yield* runCli([word, "--base-dir", baseDir]).pipe(
173 Effect.provideService(HostProcess.Platform, "linux"),
174 Effect.flip,
175 );
176 expect(String(error)).toContain(`Unknown command "${word}"`);
177 expect(yield* pathExists(word)).toBe(word === "package.json");
178 expect(yield* pathExists(baseDir)).toBe(false);
179 }
180 }),
181 ),
182 );
183
184 it.effect("does not offer auto-bootstrap in serve help, since serve never bootstraps", () =>
185 Effect.gen(function* () {
186 yield* runCli(["serve", "--help"]);
187 const help = (yield* TestConsole.logLines).join("\n");
188 expect(help).toContain("--no-browser");
189 expect(help).not.toContain("--auto-bootstrap-project-from-cwd");
190 }).pipe(Effect.provide(Layer.fresh(TestConsole.layer))),
191 );
192
193 it.effect("refuses manual startup over a live server before creating directories", () =>
194 withTempDirectory("t3-cli-running-", (root) =>
195 Effect.gen(function* () {
196 const baseDir = NodePath.join(root, "home");
197 const stateDir = NodePath.join(baseDir, "userdata");
198 const statePath = NodePath.join(stateDir, "server-runtime.json");
199 const child = yield* liveProcess;
200 assert(child.pid !== undefined);
201 const record = yield* encodeRuntimeState({
202 version: 1,
203 pid: child.pid,
204 port: 3773,
205 origin: "http://127.0.0.1:3773",
206 startedAt: "2026-10-01T00:00:00.000Z",
207 serviceManaged: true,
208 });
209 yield* Effect.promise(() => NodeFSP.mkdir(stateDir, { recursive: true }));
210 yield* Effect.promise(() => NodeFSP.writeFile(statePath, record));
211 const newDirectory = NodePath.join(root, "new-project");
212 const platform = yield* HostProcess.Platform;
213 for (const args of [
214 [],
215 ["start"],
216 ["."],
217 ["node_modules"],
218 ["C:new-project"],
219 [newDirectory],
220 ["start", newDirectory],
221 ]) {
222 const error = yield* runCli(args, { T3CODE_HOME: baseDir }).pipe(
223 Effect.provideService(
224 HostProcess.Platform,
225 args[0] === "C:new-project" ? "win32" : platform,
226 ),
227 Effect.flip,
228 );
229 expect(String(error)).toContain("A T3 Code server is already running");
230 expect(yield* Effect.promise(() => NodeFSP.readFile(statePath, "utf8"))).toBe(record);
231 expect(yield* pathExists(newDirectory)).toBe(false);
232 expect(yield* Effect.promise(() => NodeFSP.readdir(stateDir))).toEqual([
233 "server-runtime.json",
234 ]);
235 }
236 }),
237 ),
238 );
239});
240
241describe("t3 app", () => {
242 it.live.each(["permissions", "owner", "symlink", "missing owner"] as const)(
243 "rejects unsafe %s on the socket directory before sending",
244 (unsafe) =>
245 Effect.gen(function* () {
246 const platform = yield* HostProcess.Platform;
247 if (platform === "win32") return;
248 yield* withTempDirectory(
249 "t3-app-unsafe-",
250 (root) =>
251 Effect.gen(function* () {
252 yield* Effect.acquireRelease(
253 Effect.sync(() => setProcessEnv("TMPDIR", root)),
254 (restore) => Effect.sync(restore),
255 );
256 const baseDir = NodePath.join(root, ".t3");
257 const userId = yield* HostProcess.UserId;
258 assert(userId !== undefined);
259 const expectedUserId =
260 unsafe === "missing owner" ? undefined : unsafe === "owner" ? userId + 1 : userId;
261 const desktop = yield* fakeDesktop({ baseDir }).pipe(
262 Effect.provideService(HostProcess.UserId, expectedUserId),
263 );
264 const directory = desktop.directory;
265 assert(directory !== null);
266 if (unsafe === "permissions") {
267 yield* Effect.promise(() => NodeFSP.chmod(directory, 0o755));
268 } else if (unsafe === "symlink") {
269 const moved = NodePath.join(root, "moved");
270 yield* Effect.promise(() => NodeFSP.rename(directory, moved));
271 yield* Effect.promise(() => NodeFSP.symlink(moved, directory));
272 }
273 const result = yield* runCli(["app"]).pipe(
274 Effect.provideService(HostProcess.HomeDirectory, root),
275 Effect.provideService(HostProcess.UserId, expectedUserId),
276 Effect.result,
277 );
278 expect(desktop.received).toEqual([]);
279 expect(result).toMatchObject({
280 _tag: "Failure",
281 failure: { _tag: "DesktopAppUnreachableError" },
282 });
283 }).pipe(Effect.scoped),
284 "/tmp",
285 );
286 }),
287 );
288
289 it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () =>
290 withTempDirectory("t3-app-command-test-", (root) =>
291 Effect.gen(function* () {
292 const baseDir = NodePath.join(root, "t3-home");
293 const explicitPath = NodePath.join(root, "project");
294 const platform = yield* HostProcess.Platform;
295 const workingDirectory = yield* HostProcess.WorkingDirectory;
296 const desktop = yield* fakeDesktop({ baseDir });
297
298 yield* runCli(["app"], { T3CODE_HOME: baseDir });
299 yield* runCli(["app", explicitPath, "--base-dir", baseDir]);
300
301 expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([
302 workingDirectory,
303 explicitPath,
304 ]);
305 expect(desktop.received.every((request) => request.platform === platform)).toBe(true);
306 }).pipe(Effect.scoped),
307 ),
308 );
309
310 it.effect("prefers the installed desktop app when a dev desktop is also running", () =>
311 withTempDirectory("t3-app-preferred-test-", (root) =>
312 Effect.gen(function* () {
313 const baseDir = NodePath.join(root, ".t3");
314 const desktop = yield* fakeDesktop({ baseDir });
315 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
316
317 yield* runCli(["app"]);
318
319 expect(desktop.received).toHaveLength(1);
320 expect(development.received).toHaveLength(0);
321 }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped),
322 ),
323 );
324
325 it.effect("finds the dev desktop when the default desktop socket is absent", () =>
326 withTempDirectory("t3-app-dev-test-", (root) =>
327 Effect.gen(function* () {
328 const baseDir = NodePath.join(root, ".t3");
329 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
330
331 yield* runCli(["app"]);
332 yield* runCli(["app"], { T3CODE_HOME: " " });
333
334 expect(development.received).toHaveLength(2);
335 expect(yield* pathExists(baseDir)).toBe(false);
336 }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped),
337 ),
338 );
339
340 it.effect("never searches a dev state directory for an explicit T3 home", () =>
341 withTempDirectory("t3-app-explicit-test-", (root) =>
342 Effect.gen(function* () {
343 const baseDir = NodePath.join(root, ".t3");
344 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
345
346 const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip);
347 const envError = yield* runCli(["app"], { T3CODE_HOME: baseDir }).pipe(Effect.flip);
348
349 expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" });
350 expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" });
351 expect(development.received).toHaveLength(0);
352 }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped),
353 ),
354 );
355
356 it.effect.each(["failure", "invalid"] as const)(
357 "never falls back after the default desktop sends a %s response",
358 (responseKind) =>
359 withTempDirectory("t3-app-response-test-", (root) =>
360 Effect.gen(function* () {
361 const baseDir = NodePath.join(root, ".t3");
362 const desktop = yield* fakeDesktop({
363 baseDir,
364 reply: (request) =>
365 responseKind === "failure"
366 ? {
367 version: 1,
368 requestId: request.requestId,
369 ok: false,
370 code: "project-create-failed",
371 message: "The project path is not available.",
372 }
373 : { invalid: true },
374 });
375 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
376
377 const error = yield* runCli(["app"]).pipe(Effect.flip);
378
379 expect(desktop.received).toHaveLength(1);
380 expect(development.received).toHaveLength(0);
381 if (responseKind === "failure") {
382 expect(error).toMatchObject({
383 _tag: "DesktopAppRequestFailedError",
384 code: "project-create-failed",
385 requestId: desktop.received[0]?.requestId,
386 workspaceRoot: yield* HostProcess.WorkingDirectory,
387 message: expect.stringContaining("project-create-failed"),
388 cause: {
389 ok: false,
390 code: "project-create-failed",
391 message: "The project path is not available.",
392 },
393 });
394 } else {
395 expect(error).toMatchObject({
396 _tag: "DesktopAppUnreachableError",
397 cause: { message: "The desktop app response is invalid." },
398 });
399 }
400 }).pipe(Effect.provideService(HostProcess.HomeDirectory, root), Effect.scoped),
401 ),
402 );
403});