authorgravatar for bortstheboat@gmail.comAndrew Barnes <bortstheboat@gmail.com> 2026-07-20 04:53:02-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:53:02+00:00
log5fcfe242c1643eb5cee3699b160f59ad5f969fca
treeb464d18e0836994567e7e12e9124b36bb2ce4a8b
parentdf65a6c3ad605feb2f326c0ad78d056c10476770
signature Signed by PGP key B5690EEEBB952194

fix(cli): support force removing projects (#3922)


2 files changed, 70 insertions(+), 1 deletions(-)

apps/server/src/bin.test.ts+65-1
...@@ -6,10 +6,16 @@ import * as NodePath from "node:path";...@@ -6,10 +6,16 @@ import * as NodePath from "node:path";
66
7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";7import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
8import * as NodeServices from "@effect/platform-node/NodeServices";8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts";9import {
10 CommandId,
11 EnvironmentOrchestrationHttpApi,
12 ProviderInstanceId,
13 ThreadId,
14} from "@t3tools/contracts";
10import * as NetService from "@t3tools/shared/Net";15import * as NetService from "@t3tools/shared/Net";
11import { assert, it } from "@effect/vitest";16import { assert, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
18import * as DateTime from "effect/DateTime";
13import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
14import * as HttpRouter from "effect/unstable/http/HttpRouter";20import * as HttpRouter from "effect/unstable/http/HttpRouter";
15import * as HttpServer from "effect/unstable/http/HttpServer";21import * as HttpServer from "effect/unstable/http/HttpServer";
...@@ -22,6 +28,7 @@ import { Command } from "effect/unstable/cli";...@@ -22,6 +28,7 @@ import { Command } from "effect/unstable/cli";
22import { cli, makeCli } from "./bin.ts";28import { cli, makeCli } from "./bin.ts";
23import * as ServerConfig from "./config.ts";29import * as ServerConfig from "./config.ts";
24import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";30import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
31import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
25import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";32import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
26import { orchestrationHttpApiLayer } from "./orchestration/http.ts";33import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";34import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
...@@ -460,6 +467,63 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -460,6 +467,63 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
460 }),467 }),
461 );468 );
462469
470 it.effect("force removes projects that still contain threads", () =>
471 Effect.gen(function* () {
472 const baseDir = NodeFS.mkdtempSync(
473 NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-test-"),
474 );
475 const workspaceRoot = NodeFS.mkdtempSync(
476 NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-workspace-"),
477 );
478
479 yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]);
480 const afterAdd = yield* readPersistedSnapshot(baseDir);
481 const project = afterAdd.projects.find(
482 (candidate) => candidate.workspaceRoot === workspaceRoot && candidate.deletedAt === null,
483 );
484 assert.isTrue(project !== undefined);
485
486 const config = yield* makeCliTestServerConfig(baseDir);
487 yield* Effect.gen(function* () {
488 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
489 yield* engine.dispatch({
490 type: "thread.create",
491 commandId: CommandId.make("cmd-cli-force-remove-thread"),
492 threadId: ThreadId.make("thread-cli-force-remove"),
493 projectId: project!.id,
494 title: "Thread",
495 modelSelection: {
496 instanceId: ProviderInstanceId.make("codex"),
497 model: "gpt-5-codex",
498 },
499 interactionMode: "default",
500 runtimeMode: "approval-required",
501 branch: null,
502 worktreePath: null,
503 createdAt: DateTime.formatIso(yield* DateTime.now),
504 });
505 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
506
507 yield* runCliWithRuntime([
508 "project",
509 "remove",
510 project!.id,
511 "--force",
512 "--base-dir",
513 baseDir,
514 ]);
515 const afterRemove = yield* readPersistedSnapshot(baseDir);
516 assert.isTrue(
517 (afterRemove.projects.find((candidate) => candidate.id === project!.id)?.deletedAt ??
518 null) !== null,
519 );
520 assert.isTrue(
521 (afterRemove.threads.find((thread) => thread.id === "thread-cli-force-remove")?.deletedAt ??
522 null) !== null,
523 );
524 }),
525 );
526
463 it.effect("routes project commands through a running server when runtime state is present", () =>527 it.effect("routes project commands through a running server when runtime state is present", () =>
464 Effect.gen(function* () {528 Effect.gen(function* () {
465 const baseDir = NodeFS.mkdtempSync(529 const baseDir = NodeFS.mkdtempSync(
apps/server/src/cli/project.ts+5
...@@ -493,6 +493,10 @@ const projectRemoveCommand = Command.make("remove", {...@@ -493,6 +493,10 @@ const projectRemoveCommand = Command.make("remove", {
493 project: Argument.string("project").pipe(493 project: Argument.string("project").pipe(
494 Argument.withDescription("Project id or workspace root to remove."),494 Argument.withDescription("Project id or workspace root to remove."),
495 ),495 ),
496 force: Flag.boolean("force").pipe(
497 Flag.withDescription("Delete the project and all of its threads."),
498 Flag.withDefault(false),
499 ),
496}).pipe(500}).pipe(
497 Command.withDescription("Remove a project."),501 Command.withDescription("Remove a project."),
498 Command.withHandler((flags) =>502 Command.withHandler((flags) =>
...@@ -515,6 +519,7 @@ const projectRemoveCommand = Command.make("remove", {...@@ -515,6 +519,7 @@ const projectRemoveCommand = Command.make("remove", {
515 type: "project.delete",519 type: "project.delete",
516 commandId: CommandId.make(yield* projectCommandUuid),520 commandId: CommandId.make(yield* projectCommandUuid),
517 projectId: project.id,521 projectId: project.id,
522 force: flags.force,
518 });523 });
519 return `Removed project ${project.id} (${project.title}).`;524 return `Removed project ${project.id} (${project.title}).`;
520 }),525 }),