| ... | @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; | ... | @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; |
| 3 | import * as NodeFS from "node:fs"; | 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeOS from "node:os"; | 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; | 5 | import * as NodePath from "node:path"; |
| | 6 | import * as NodeChildProcess from "node:child_process"; |
| 6 | | 7 | |
| 7 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 8 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | @@ -137,6 +138,225 @@ const readPersistedSnapshot = (baseDir: string) => | ... | @@ -137,6 +138,225 @@ const readPersistedSnapshot = (baseDir: string) => |
| 137 | }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); | 138 | }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); |
| 138 | }); | 139 | }); |
| 139 | | 140 | |
| | 141 | const makeProjectLookupFixture = Effect.fn("makeProjectLookupFixture")(function* ( |
| | 142 | withThread: boolean, |
| | 143 | removeWorkspace: boolean, |
| | 144 | ) { |
| | 145 | const baseDir = NodeFS.mkdtempSync( |
| | 146 | NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-state-"), |
| | 147 | ); |
| | 148 | const workspaceRoot = NodeFS.mkdtempSync( |
| | 149 | NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-git-"), |
| | 150 | ); |
| | 151 | NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main", workspaceRoot], { |
| | 152 | stdio: "ignore", |
| | 153 | }); |
| | 154 | yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); |
| | 155 | const snapshot = yield* readPersistedSnapshot(baseDir); |
| | 156 | const project = snapshot.projects.find((candidate) => candidate.workspaceRoot === workspaceRoot)!; |
| | 157 | assert.isDefined(project); |
| | 158 | if (withThread) { |
| | 159 | const config = yield* makeCliTestServerConfig(baseDir); |
| | 160 | yield* Effect.gen(function* () { |
| | 161 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; |
| | 162 | yield* engine.dispatch({ |
| | 163 | type: "thread.create", |
| | 164 | commandId: CommandId.make("cmd-project-lookup-thread"), |
| | 165 | threadId: ThreadId.make("thread-project-lookup"), |
| | 166 | projectId: project.id, |
| | 167 | title: "Project lookup test", |
| | 168 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
| | 169 | interactionMode: "default", |
| | 170 | runtimeMode: "approval-required", |
| | 171 | branch: null, |
| | 172 | worktreePath: null, |
| | 173 | createdAt: DateTime.formatIso(yield* DateTime.now), |
| | 174 | }); |
| | 175 | }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); |
| | 176 | } |
| | 177 | if (removeWorkspace) { |
| | 178 | NodeFS.renameSync(workspaceRoot, `${workspaceRoot}-removed`); |
| | 179 | assert.isFalse(NodeFS.existsSync(workspaceRoot)); |
| | 180 | } |
| | 181 | return { baseDir, workspaceRoot, project }; |
| | 182 | }); |
| | 183 | |
| | 184 | it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => { |
| | 185 | it.effect("removes an empty project by ID without force after its directory is gone", () => |
| | 186 | Effect.gen(function* () { |
| | 187 | const { baseDir, project } = yield* makeProjectLookupFixture(false, true); |
| | 188 | yield* runCliWithRuntime(["project", "remove", project.id, "--base-dir", baseDir]); |
| | 189 | const after = yield* readPersistedSnapshot(baseDir); |
| | 190 | assert.isNotNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt); |
| | 191 | }), |
| | 192 | ); |
| | 193 | |
| | 194 | it.effect.each([true, false])( |
| | 195 | "requires force for child threads, then removes by ID; missing=%s", |
| | 196 | (removeWorkspace) => |
| | 197 | Effect.gen(function* () { |
| | 198 | const { baseDir, project } = yield* makeProjectLookupFixture(true, removeWorkspace); |
| | 199 | const error = yield* runCliWithRuntime([ |
| | 200 | "project", |
| | 201 | "remove", |
| | 202 | project.id, |
| | 203 | "--base-dir", |
| | 204 | baseDir, |
| | 205 | ]).pipe(Effect.flip); |
| | 206 | assert.include(error.message, "cannot be deleted without force=true"); |
| | 207 | const retained = yield* readPersistedSnapshot(baseDir); |
| | 208 | assert.isNull( |
| | 209 | retained.projects.find((candidate) => candidate.id === project.id)!.deletedAt, |
| | 210 | ); |
| | 211 | assert.isNull( |
| | 212 | retained.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt, |
| | 213 | ); |
| | 214 | yield* runCliWithRuntime([ |
| | 215 | "project", |
| | 216 | "remove", |
| | 217 | project.id, |
| | 218 | "--force", |
| | 219 | "--base-dir", |
| | 220 | baseDir, |
| | 221 | ]); |
| | 222 | const after = yield* readPersistedSnapshot(baseDir); |
| | 223 | assert.isNotNull( |
| | 224 | after.projects.find((candidate) => candidate.id === project.id)!.deletedAt, |
| | 225 | ); |
| | 226 | assert.isNotNull( |
| | 227 | after.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt, |
| | 228 | ); |
| | 229 | }), |
| | 230 | ); |
| | 231 | |
| | 232 | it.effect("cannot remove the old environment's ID from a replacement empty database", () => |
| | 233 | Effect.gen(function* () { |
| | 234 | const { baseDir, project } = yield* makeProjectLookupFixture(true, true); |
| | 235 | const replacementDir = NodeFS.mkdtempSync( |
| | 236 | NodePath.join(NodeOS.tmpdir(), "t3-cli-project-lookup-new-state-"), |
| | 237 | ); |
| | 238 | const error = yield* runCliWithRuntime([ |
| | 239 | "project", |
| | 240 | "remove", |
| | 241 | project.id, |
| | 242 | "--force", |
| | 243 | "--base-dir", |
| | 244 | replacementDir, |
| | 245 | ]).pipe(Effect.flip); |
| | 246 | assert.include(error.message, "No active project found"); |
| | 247 | assert.include(String(error.cause), "Workspace root does not exist"); |
| | 248 | const original = yield* readPersistedSnapshot(baseDir); |
| | 249 | assert.isNull(original.projects.find((candidate) => candidate.id === project.id)!.deletedAt); |
| | 250 | const replacement = yield* readPersistedSnapshot(replacementDir); |
| | 251 | assert.equal(replacement.projects.length, 0); |
| | 252 | }), |
| | 253 | ); |
| | 254 | |
| | 255 | it.effect("renames by ID and stored path, then force removes after the directory is gone", () => |
| | 256 | Effect.gen(function* () { |
| | 257 | const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(true, true); |
| | 258 | yield* runCliWithRuntime([ |
| | 259 | "project", |
| | 260 | "rename", |
| | 261 | project.id, |
| | 262 | "Renamed by ID", |
| | 263 | "--base-dir", |
| | 264 | baseDir, |
| | 265 | ]); |
| | 266 | const afterIdRename = yield* readPersistedSnapshot(baseDir); |
| | 267 | assert.equal( |
| | 268 | afterIdRename.projects.find((candidate) => candidate.id === project.id)!.title, |
| | 269 | "Renamed by ID", |
| | 270 | ); |
| | 271 | yield* runCliWithRuntime([ |
| | 272 | "project", |
| | 273 | "rename", |
| | 274 | workspaceRoot, |
| | 275 | "Renamed by stored path", |
| | 276 | "--base-dir", |
| | 277 | baseDir, |
| | 278 | ]); |
| | 279 | const afterPathRename = yield* readPersistedSnapshot(baseDir); |
| | 280 | assert.equal( |
| | 281 | afterPathRename.projects.find((candidate) => candidate.id === project.id)!.title, |
| | 282 | "Renamed by stored path", |
| | 283 | ); |
| | 284 | const error = yield* runCliWithRuntime([ |
| | 285 | "project", |
| | 286 | "remove", |
| | 287 | workspaceRoot, |
| | 288 | "--base-dir", |
| | 289 | baseDir, |
| | 290 | ]).pipe(Effect.flip); |
| | 291 | assert.include(error.message, "cannot be deleted without force=true"); |
| | 292 | yield* runCliWithRuntime([ |
| | 293 | "project", |
| | 294 | "remove", |
| | 295 | workspaceRoot, |
| | 296 | "--force", |
| | 297 | "--base-dir", |
| | 298 | baseDir, |
| | 299 | ]); |
| | 300 | const after = yield* readPersistedSnapshot(baseDir); |
| | 301 | assert.isNotNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt); |
| | 302 | assert.isNotNull( |
| | 303 | after.threads.find((thread) => thread.id === "thread-project-lookup")!.deletedAt, |
| | 304 | ); |
| | 305 | assert.isFalse(NodeFS.existsSync(workspaceRoot)); |
| | 306 | }), |
| | 307 | ); |
| | 308 | |
| | 309 | it.effect("preserves normalized paths and distinct symlink project entries", () => |
| | 310 | Effect.gen(function* () { |
| | 311 | const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(false, false); |
| | 312 | const normalizedInput = `${workspaceRoot}${NodePath.sep}.`; |
| | 313 | yield* runCliWithRuntime([ |
| | 314 | "project", |
| | 315 | "rename", |
| | 316 | normalizedInput, |
| | 317 | "Normalized", |
| | 318 | "--base-dir", |
| | 319 | baseDir, |
| | 320 | ]); |
| | 321 | const renamed = yield* readPersistedSnapshot(baseDir); |
| | 322 | assert.equal( |
| | 323 | renamed.projects.find((candidate) => candidate.id === project.id)!.title, |
| | 324 | "Normalized", |
| | 325 | ); |
| | 326 | const aliasPath = `${workspaceRoot}-alias`; |
| | 327 | NodeFS.symlinkSync(workspaceRoot, aliasPath, "junction"); |
| | 328 | const error = yield* runCliWithRuntime([ |
| | 329 | "project", |
| | 330 | "remove", |
| | 331 | aliasPath, |
| | 332 | "--force", |
| | 333 | "--base-dir", |
| | 334 | baseDir, |
| | 335 | ]).pipe(Effect.flip); |
| | 336 | assert.include(error.message, "No active project found"); |
| | 337 | yield* runCliWithRuntime(["project", "add", aliasPath, "--base-dir", baseDir]); |
| | 338 | const added = yield* readPersistedSnapshot(baseDir); |
| | 339 | const aliasProject = added.projects.find( |
| | 340 | (candidate) => candidate.workspaceRoot === aliasPath, |
| | 341 | )!; |
| | 342 | assert.notEqual(aliasProject.id, project.id); |
| | 343 | yield* runCliWithRuntime([ |
| | 344 | "project", |
| | 345 | "remove", |
| | 346 | `${aliasPath}${NodePath.sep}.`, |
| | 347 | "--base-dir", |
| | 348 | baseDir, |
| | 349 | ]); |
| | 350 | const after = yield* readPersistedSnapshot(baseDir); |
| | 351 | assert.isNotNull( |
| | 352 | after.projects.find((candidate) => candidate.id === aliasProject.id)!.deletedAt, |
| | 353 | ); |
| | 354 | assert.isNull(after.projects.find((candidate) => candidate.id === project.id)!.deletedAt); |
| | 355 | assert.isTrue(NodeFS.existsSync(workspaceRoot)); |
| | 356 | }), |
| | 357 | ); |
| | 358 | }); |
| | 359 | |
| 140 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => | 360 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => |
| 141 | Effect.gen(function* () { | 361 | Effect.gen(function* () { |
| 142 | const config = yield* makeCliTestServerConfig(baseDir); | 362 | const config = yield* makeCliTestServerConfig(baseDir); |