1// @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths.
2import * as NodePath from "node:path";
3
4import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { assert, it } from "@effect/vitest";
7import * as NetService from "@t3tools/shared/Net";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import { Command } from "effect/cli";
14import { FetchHttpClient } from "effect/http";
15import { vi } from "vite-plus/test";
16
17import { cli } from "../binCli.ts";
18import {
19 makePersistedServerRuntimeState,
20 persistServerRuntimeState,
21} from "../serverRuntimeState.ts";
22
23const hooks = vi.hoisted(() => ({
24 importsAttempted: [] as string[],
25 historyImportStarted: Promise.withResolvers<void>(),
26 releaseHistoryImport: Promise.withResolvers<void>(),
27 runClaudeHistoryWorker: vi.fn(async () => {}),
28}));
29
30vi.mock("../server.ts", () => {
31 hooks.importsAttempted.push("server");
32 throw new Error("server import failed");
33});
34vi.mock("../orchestration-v2/runtimeLayer.ts", () => {
35 hooks.importsAttempted.push("runtimeLayer");
36 throw new Error("runtime import failed");
37});
38vi.mock("../claudeHistoryWorker.ts", async () => {
39 hooks.importsAttempted.push("claudeHistoryWorker");
40 hooks.historyImportStarted.resolve();
41 await hooks.releaseHistoryImport.promise;
42 return { runClaudeHistoryWorker: hooks.runClaudeHistoryWorker };
43});
44
45const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
46const runCli = (args: ReadonlyArray<string>) =>
47 Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(layerCliRuntime));
48
49it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CLI deferred imports", (it) => {
50 it.effect("runs commands that do not need them without loading them", () =>
51 Effect.gen(function* () {
52 hooks.importsAttempted.length = 0;
53 for (const args of [["--version"], ["--help"], ["project", "--help"], ["thread", "--help"]]) {
54 yield* runCli(args);
55 }
56 assert.deepEqual(hooks.importsAttempted, []);
57 }),
58 );
59
60 it.effect("fails a server start on a broken import before resolving config", () =>
61 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-server-" });
64 const baseDir = NodePath.join(root, "state");
65
66 for (const command of ["serve", "start"]) {
67 const exit = yield* runCli([command, "--base-dir", baseDir]).pipe(Effect.exit);
68 assert.isTrue(Exit.hasDies(exit));
69 }
70
71 assert.isFalse(yield* fs.exists(baseDir));
72 }),
73 );
74
75 it.effect("keeps server state when the offline project runtime fails to import", () =>
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-project-" });
79 const baseDir = NodePath.join(root, "state");
80 const runtimeStatePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
81 yield* persistServerRuntimeState({
82 path: runtimeStatePath,
83 state: yield* makePersistedServerRuntimeState({
84 config: { host: "127.0.0.1", devUrl: undefined },
85 port: 3_773,
86 }),
87 });
88 const unreachable: typeof globalThis.fetch = () =>
89 Promise.reject(new Error("connection refused"));
90
91 const exit = yield* runCli(["project", "add", root, "--base-dir", baseDir]).pipe(
92 Effect.provideService(FetchHttpClient.Fetch, unreachable),
93 Effect.exit,
94 );
95
96 assert.isTrue(Exit.hasDies(exit));
97 assert.isTrue(yield* fs.exists(runtimeStatePath));
98 }),
99 );
100
101 it.effect("forwards live project mutations without importing the offline runtime", () =>
102 Effect.gen(function* () {
103 const fs = yield* FileSystem.FileSystem;
104 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-live-project-" });
105 const baseDir = NodePath.join(root, "state");
106 const workspaceRoot = NodePath.join(root, "workspace");
107 yield* fs.makeDirectory(workspaceRoot);
108 yield* persistServerRuntimeState({
109 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
110 state: yield* makePersistedServerRuntimeState({
111 config: { host: "127.0.0.1", devUrl: undefined },
112 port: 3_773,
113 }),
114 });
115
116 hooks.importsAttempted.length = 0;
117 let mutations = 0;
118 const fetch: typeof globalThis.fetch = async (_input, init) => {
119 const isMutation = init?.method === "POST";
120 if (isMutation) mutations++;
121 return Response.json(
122 isMutation
123 ? {
124 id: "live-project",
125 title: NodePath.basename(workspaceRoot),
126 workspaceRoot,
127 defaultModelSelection: null,
128 scripts: [],
129 createdAt: "2026-01-01T00:00:00.000Z",
130 updatedAt: "2026-01-01T00:00:00.000Z",
131 deletedAt: null,
132 }
133 : { projects: [], updatedAt: "2026-01-01T00:00:00.000Z" },
134 );
135 };
136
137 const exit = yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]).pipe(
138 Effect.provideService(FetchHttpClient.Fetch, fetch),
139 Effect.exit,
140 );
141
142 assert.isTrue(Exit.isSuccess(exit));
143 assert.equal(mutations, 1);
144 assert.deepEqual(hooks.importsAttempted, []);
145 }),
146 );
147
148 it.effect("never starts the history worker after an interrupted import", () =>
149 Effect.gen(function* () {
150 const history = yield* runCli(["__claude-history", "getSessionMessages", "session"]).pipe(
151 Effect.forkChild,
152 );
153 yield* Effect.promise(() => hooks.historyImportStarted.promise);
154 yield* Fiber.interrupt(history);
155 hooks.releaseHistoryImport.resolve();
156 yield* Effect.promise(() => import("../claudeHistoryWorker.ts"));
157 assert.equal(hooks.runClaudeHistoryWorker.mock.calls.length, 0);
158
159 yield* runCli(["__claude-history", "getSessionMessages", "session"]);
160 yield* runCli(["__claude-history", "forkSession", "session", '{"dir":"/repo"}']);
161 assert.deepEqual(hooks.runClaudeHistoryWorker.mock.calls, [
162 ["getSessionMessages", "session", undefined],
163 ["forkSession", "session", '{"dir":"/repo"}'],
164 ]);
165 }),
166 );
167});