| 1 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; |
| 2 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 3 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths. |
| 4 | import * as NodeFS from "node:fs"; |
| 5 | import * as NodeOS from "node:os"; |
| 6 | import * as NodePath from "node:path"; |
| 7 | |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { assert, it } from "@effect/vitest"; |
| 10 | import { |
| 11 | CommandId, |
| 12 | EnvironmentInternalError, |
| 13 | EventId, |
| 14 | ProviderInstanceId, |
| 15 | ThreadId, |
| 16 | type OrchestrationV2AppThread, |
| 17 | type ProjectId, |
| 18 | } from "@t3tools/contracts"; |
| 19 | import * as NetService from "@t3tools/shared/Net"; |
| 20 | import * as DateTime from "effect/DateTime"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Layer from "effect/Layer"; |
| 24 | import * as References from "effect/References"; |
| 25 | import * as Stream from "effect/Stream"; |
| 26 | import { Command } from "effect/cli"; |
| 27 | |
| 28 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 29 | import { cli } from "../binCli.ts"; |
| 30 | import * as ServerConfig from "../config.ts"; |
| 31 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 32 | import * as EventStore from "../orchestration-v2/EventStore.ts"; |
| 33 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 34 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 35 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 36 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 37 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 38 | import * as ProjectService from "../project/ProjectService.ts"; |
| 39 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 40 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 41 | import * as ServerSettings from "../serverSettings.ts"; |
| 42 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 43 | import { |
| 44 | ProjectLiveServerDeclaredResponseError, |
| 45 | ProjectLiveServerRequestError, |
| 46 | projectCommandErrorFromLiveServerRequest, |
| 47 | } from "./project.ts"; |
| 48 | |
| 49 | const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 50 | const runCli = (args: ReadonlyArray<string>) => |
| 51 | Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(layerCliRuntime)); |
| 52 | |
| 53 | const 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 | |
| 89 | const 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 | |
| 110 | it("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 | |
| 127 | it("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 | |
| 138 | it.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 | |
| 158 | const 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 | |
| 170 | const 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 | |
| 186 | const 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 | |
| 238 | const 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 | |
| 253 | it.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 | |
| 353 | it.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 | }); |