1import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
2import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
3// @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths.
4import * as NodeFS from "node:fs";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { assert, it } from "@effect/vitest";
10import {
11 CommandId,
12 EnvironmentInternalError,
13 EventId,
14 ProviderInstanceId,
15 ThreadId,
16 type OrchestrationV2AppThread,
17 type ProjectId,
18} from "@t3tools/contracts";
19import * as NetService from "@t3tools/shared/Net";
20import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";
24import * as References from "effect/References";
25import * as Stream from "effect/Stream";
26import { Command } from "effect/cli";
27
28import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
29import { cli } from "../binCli.ts";
30import * as ServerConfig from "../config.ts";
31import * as EventSink from "../orchestration-v2/EventSink.ts";
32import * as EventStore from "../orchestration-v2/EventStore.ts";
33import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
34import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
35import * as SqlitePersistence from "../persistence/Sqlite.ts";
36import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
37import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
38import * as ProjectService from "../project/ProjectService.ts";
39import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
40import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
41import * as ServerSettings from "../serverSettings.ts";
42import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
43import {
44 ProjectLiveServerDeclaredResponseError,
45 ProjectLiveServerRequestError,
46 projectCommandErrorFromLiveServerRequest,
47} from "./project.ts";
48
49const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
50const runCli = (args: ReadonlyArray<string>) =>
51 Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(layerCliRuntime));
52
53const makeConfig = (baseDir: string) =>
54 Effect.gen(function* () {
55 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
56 return {
57 logLevel: "Info",
58 traceMinLevel: "Info",
59 traceTimingEnabled: true,
60 traceBatchWindowMs: 200,
61 traceMaxBytes: 10 * 1024 * 1024,
62 traceMaxFiles: 10,
63 otelEnvironment: OtelEnvironment.none,
64 otlpTracesUrl: undefined,
65 otlpMetricsUrl: undefined,
66 otlpLogsUrl: undefined,
67 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
68 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
69 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
70 mode: "web",
71 port: 0,
72 host: "127.0.0.1",
73 cwd: process.cwd(),
74 baseDir,
75 ...derivedPaths,
76 staticDir: undefined,
77 devUrl: undefined,
78 devAllowedOrigins: [],
79 noBrowser: true,
80 startupPresentation: "browser",
81 desktopBootstrapToken: undefined,
82 autoBootstrapProjectFromCwd: false,
83 logWebSocketEvents: false,
84 tailscaleServeEnabled: false,
85 tailscaleServePort: 443,
86 } satisfies ServerConfig.ServerConfig["Service"];
87 });
88
89const readProjects = (baseDir: string) =>
90 Effect.gen(function* () {
91 const config = yield* makeConfig(baseDir);
92 const layer = RuntimeLayer.layerProjectService.pipe(
93 Layer.provideMerge(ProjectEnrichmentService.layer),
94 Layer.provideMerge(RepositoryIdentityResolver.layer),
95 Layer.provideMerge(ProjectFaviconResolver.layer),
96 Layer.provideMerge(T3ProjectFileLoader.layer),
97 Layer.provideMerge(WorkspacePaths.layer),
98 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
99 Layer.provideMerge(SqlitePersistence.layerConfig),
100 Layer.provideMerge(NodeServices.layer),
101 Layer.provide(ServerConfig.layer(config)),
102 Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)),
103 );
104 return yield* ProjectService.ProjectService.pipe(
105 Effect.flatMap((projects) => projects.snapshot),
106 Effect.provide(layer),
107 );
108 });
109
110it("maps declared server failures into structural project command errors", () => {
111 const cause = new EnvironmentInternalError({
112 code: "internal_error",
113 reason: "access_token_issuance_failed",
114 traceId: "trace-123",
115 });
116
117 const error = projectCommandErrorFromLiveServerRequest(cause);
118
119 assert.instanceOf(error, ProjectLiveServerDeclaredResponseError);
120 assert.strictEqual(error.operation, "callLiveServer");
121 assert.strictEqual(error.code, "internal_error");
122 assert.strictEqual(error.traceId, "trace-123");
123 assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123).");
124 assert.strictEqual(error.cause, cause);
125});
126
127it("preserves unexpected server failures without deriving the message from them", () => {
128 const cause = new Error("credential abc123 was rejected");
129
130 const error = projectCommandErrorFromLiveServerRequest(cause);
131
132 assert.instanceOf(error, ProjectLiveServerRequestError);
133 assert.strictEqual(error.operation, "callLiveServer");
134 assert.strictEqual(error.message, "Failed to call the running server.");
135 assert.strictEqual(error.cause, cause);
136});
137
138it.effect("adds, renames, and removes projects through the V2 project CLI domain", () =>
139 Effect.gen(function* () {
140 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-project-cli-"));
141 const workspaceRoot = NodeFS.mkdtempSync(
142 NodePath.join(NodeOS.tmpdir(), "t3-v2-project-workspace-"),
143 );
144
145 yield* runCli(["project", "add", workspaceRoot, "--title", "Alpha", "--base-dir", baseDir]);
146 const added = (yield* readProjects(baseDir)).projects[0];
147 assert.equal(added?.title, "Alpha");
148 assert.equal(added?.workspaceRoot, workspaceRoot);
149
150 yield* runCli(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
151 assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Beta");
152
153 yield* runCli(["project", "remove", added?.id ?? "", "--base-dir", baseDir]);
154 assert.deepEqual((yield* readProjects(baseDir)).projects, []);
155 }).pipe(Effect.provide(NodeServices.layer)),
156);
157
158const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () {
159 const fs = yield* FileSystem.FileSystem;
160 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" });
161 const baseDir = NodePath.join(root, "state");
162 const workspaceRoot = NodePath.join(root, "workspace");
163 yield* fs.makeDirectory(workspaceRoot);
164 yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]);
165 const project = (yield* readProjects(baseDir)).projects[0];
166 assert.isDefined(project);
167 return { baseDir, workspaceRoot, project: project! };
168});
169
170const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersistenceLayer")(
171 function* (baseDir: string) {
172 const config = yield* makeConfig(baseDir);
173 return Layer.mergeAll(
174 RuntimeLayer.layerEventSink,
175 ProjectionStore.layer,
176 EventStore.layer,
177 ).pipe(
178 Layer.provideMerge(SqlitePersistence.layerConfig),
179 Layer.provideMerge(NodeServices.layer),
180 Layer.provide(ServerConfig.layer(config)),
181 Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)),
182 );
183 },
184);
185
186const seedNativeThreads = Effect.fn("ProjectCliTest.seedNativeThreads")(function* (
187 baseDir: string,
188 threads: ReadonlyArray<{
189 readonly id: ThreadId;
190 readonly projectId: ProjectId;
191 readonly archived: boolean;
192 }>,
193) {
194 const layer = yield* makeThreadPersistenceLayer(baseDir);
195 const createdAt = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z");
196 const providerInstanceId = ProviderInstanceId.make("codex");
197 yield* Effect.gen(function* () {
198 const eventSink = yield* EventSink.EventSinkV2;
199 yield* eventSink.write({
200 commandId: CommandId.make("project-cli-seed-threads"),
201 events: threads.map(({ id, projectId, archived }) => {
202 const payload: OrchestrationV2AppThread = {
203 createdBy: "user",
204 creationSource: "web",
205 id,
206 projectId,
207 title: id,
208 providerInstanceId,
209 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
210 runtimeMode: "full-access",
211 interactionMode: "default",
212 branch: null,
213 worktreePath: null,
214 activeProviderThreadId: null,
215 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
216 forkedFrom: null,
217 createdAt,
218 updatedAt: createdAt,
219 archivedAt: archived ? createdAt : null,
220 settledOverride: null,
221 settledAt: null,
222 lastVisitedAt: null,
223 deletedAt: null,
224 };
225 return {
226 id: EventId.make(`project-cli-create-${id}`),
227 type: "thread.created" as const,
228 threadId: id,
229 providerInstanceId,
230 occurredAt: createdAt,
231 payload,
232 };
233 }),
234 });
235 }).pipe(Effect.provide(layer));
236});
237
238const readNativeThreadState = Effect.fn("ProjectCliTest.readNativeThreadState")(function* (
239 baseDir: string,
240 threadId: ThreadId,
241) {
242 const layer = yield* makeThreadPersistenceLayer(baseDir);
243 return yield* Effect.gen(function* () {
244 const projections = yield* ProjectionStore.ProjectionStoreV2;
245 const events = yield* EventStore.EventStoreV2;
246 return {
247 thread: yield* projections.getThread(threadId),
248 events: yield* events.read({ threadId }).pipe(Stream.runCollect),
249 };
250 }).pipe(Effect.provide(layer));
251});
252
253it.layer(NodeServices.layer)("project deletion with native V2 threads", (it) => {
254 it.effect.each([
255 { label: "an active thread", archived: false, missing: false },
256 { label: "an archived thread", archived: true, missing: false },
257 {
258 label: "an active thread after its workspace disappears",
259 archived: false,
260 missing: true,
261 },
262 {
263 label: "an archived thread after its workspace disappears",
264 archived: true,
265 missing: true,
266 },
267 ])("rejects unforced removal of a project with $label", ({ archived, missing }) =>
268 Effect.gen(function* () {
269 const fs = yield* FileSystem.FileSystem;
270 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
271 const threadId = ThreadId.make("project-cli-preserved-thread");
272 yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived }]);
273 const before = yield* readNativeThreadState(baseDir, threadId);
274 if (missing) yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
275
276 const error = yield* runCli([
277 "project",
278 "remove",
279 missing ? workspaceRoot : project.id,
280 "--base-dir",
281 baseDir,
282 ]).pipe(
283 Effect.match({
284 onFailure: (error) => error,
285 onSuccess: () => assert.fail("Removing a nonempty project must require --force."),
286 }),
287 );
288
289 assert.include(error.message, "not empty");
290 assert.deepEqual(
291 (yield* readProjects(baseDir)).projects.map((entry) => entry.id),
292 [project.id],
293 );
294 assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before);
295 assert.equal(yield* fs.exists(workspaceRoot), !missing);
296 }),
297 );
298
299 it.effect.each(["present", "missing"] as const)(
300 "force-removes active and archived V2 threads with the workspace %s, preserving unrelated projects",
301 (workspace) =>
302 Effect.gen(function* () {
303 const fs = yield* FileSystem.FileSystem;
304 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
305 const otherWorkspace = `${workspaceRoot}-other`;
306 yield* fs.makeDirectory(otherWorkspace);
307 yield* runCli(["project", "add", otherWorkspace, "--base-dir", baseDir]);
308 const otherProject = (yield* readProjects(baseDir)).projects.find(
309 (entry) => entry.workspaceRoot === otherWorkspace,
310 );
311 assert.isDefined(otherProject);
312 const activeId = ThreadId.make("project-cli-deleted-active");
313 const archivedId = ThreadId.make("project-cli-deleted-archived");
314 const unrelatedId = ThreadId.make("project-cli-unrelated-thread");
315 yield* seedNativeThreads(baseDir, [
316 { id: activeId, projectId: project.id, archived: false },
317 { id: archivedId, projectId: project.id, archived: true },
318 { id: unrelatedId, projectId: otherProject!.id, archived: false },
319 ]);
320 const unrelatedBefore = yield* readNativeThreadState(baseDir, unrelatedId);
321 if (workspace === "missing") {
322 yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
323 }
324
325 yield* runCli([
326 "project",
327 "remove",
328 workspace === "missing" ? workspaceRoot : project.id,
329 "--force",
330 "--base-dir",
331 baseDir,
332 ]);
333
334 assert.deepEqual(
335 (yield* readProjects(baseDir)).projects.map((entry) => entry.id),
336 [otherProject!.id],
337 );
338 for (const threadId of [activeId, archivedId]) {
339 const state = yield* readNativeThreadState(baseDir, threadId);
340 assert.isNotNull(state.thread.deletedAt);
341 assert.lengthOf(
342 state.events.filter((record) => record.event.type === "thread.deleted"),
343 1,
344 );
345 }
346 assert.deepEqual(yield* readNativeThreadState(baseDir, unrelatedId), unrelatedBefore);
347 assert.equal(yield* fs.exists(workspaceRoot), workspace === "present");
348 assert.isTrue(yield* fs.exists(otherWorkspace));
349 }),
350 );
351});
352
353it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => {
354 it.effect.each(["id", "stored path"] as const)(
355 "removes an empty project by %s after its directory is gone",
356 (identifier) =>
357 Effect.gen(function* () {
358 const fs = yield* FileSystem.FileSystem;
359 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
360 yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
361 yield* runCli([
362 "project",
363 "remove",
364 identifier === "id" ? project.id : workspaceRoot,
365 "--base-dir",
366 baseDir,
367 ]);
368 assert.deepEqual((yield* readProjects(baseDir)).projects, []);
369 assert.isFalse(yield* fs.exists(workspaceRoot));
370 }),
371 );
372
373 it.effect("renames by ID and stored path after the directory is gone", () =>
374 Effect.gen(function* () {
375 const fs = yield* FileSystem.FileSystem;
376 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
377 yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
378 for (const [identifier, title] of [
379 [project.id, "Renamed by ID"],
380 [workspaceRoot, "Renamed by stored path"],
381 ] as const) {
382 yield* runCli(["project", "rename", identifier, title, "--base-dir", baseDir]);
383 assert.equal((yield* readProjects(baseDir)).projects[0]?.title, title);
384 }
385 assert.isFalse(yield* fs.exists(workspaceRoot));
386 }),
387 );
388
389 it.effect("does not resolve another environment's project ID in an empty database", () =>
390 Effect.gen(function* () {
391 const fs = yield* FileSystem.FileSystem;
392 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
393 yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
394 const replacementDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-empty-" });
395 const error = yield* runCli([
396 "project",
397 "remove",
398 project.id,
399 "--force",
400 "--base-dir",
401 replacementDir,
402 ]).pipe(Effect.flip);
403 assert.include(error.message, "No active project found");
404 assert.deepEqual(
405 (yield* readProjects(baseDir)).projects.map((entry) => entry.id),
406 [project.id],
407 );
408 assert.deepEqual((yield* readProjects(replacementDir)).projects, []);
409 }),
410 );
411
412 it.effect("normalizes existing paths without conflating separately registered symlinks", () =>
413 Effect.gen(function* () {
414 const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
415 yield* runCli([
416 "project",
417 "rename",
418 `${workspaceRoot}${NodePath.sep}.`,
419 "Normalized",
420 "--base-dir",
421 baseDir,
422 ]);
423 assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Normalized");
424 const aliasPath = `${workspaceRoot}-alias`;
425 NodeFS.symlinkSync(workspaceRoot, aliasPath, "junction");
426 const unknownAlias = yield* runCli([
427 "project",
428 "remove",
429 aliasPath,
430 "--base-dir",
431 baseDir,
432 ]).pipe(Effect.flip);
433 assert.include(unknownAlias.message, "No active project found");
434 yield* runCli(["project", "add", aliasPath, "--base-dir", baseDir]);
435 const added = (yield* readProjects(baseDir)).projects;
436 assert.equal(added.length, 2);
437 const aliasProject = added.find((entry) => entry.workspaceRoot === aliasPath);
438 assert.isDefined(aliasProject);
439 assert.notEqual(aliasProject?.id, project.id);
440 yield* runCli(["project", "remove", `${aliasPath}${NodePath.sep}.`, "--base-dir", baseDir]);
441 assert.deepEqual(
442 (yield* readProjects(baseDir)).projects.map((entry) => entry.id),
443 [project.id],
444 );
445 assert.isTrue(NodeFS.existsSync(workspaceRoot));
446 }),
447 );
448});