| ... | ... | @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; |
| 3 | 3 | import * as NodeFS from "node:fs"; |
| 4 | 4 | import * as NodeOS from "node:os"; |
| 5 | 5 | import * as NodePath from "node:path"; |
| 6 | import * as NodeChildProcess from "node:child_process"; |
| 6 | 7 | |
| 7 | 8 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 8 | 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -137,6 +138,225 @@ const readPersistedSnapshot = (baseDir: string) => |
| 137 | 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 | 360 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => |
| 141 | 361 | Effect.gen(function* () { |
| 142 | 362 | const config = yield* makeCliTestServerConfig(baseDir); |