From 381a39e24b6e72127896d0ffe0d7579fc1da7210 Mon Sep 17 00:00:00 2001 From: Shoaib Ansari Date: Sat, 2 May 2026 18:30:30 +0530 Subject: [PATCH] feat: add t3 project move command with --dry-run flag - Adds t3 project move to physically move workspace dirs - Adds --dry-run flag that previews without making changes - Validates source exists, target parent exists+writable, target not taken - Reuses existing project.meta.update orchestration command for metadata - Adds 4 tests: success, dry-run no-op, missing source, existing target Closes #2358 --- apps/server/src/cli.test.ts | 227 +++++++++++++++++++++++++++++++++++- apps/server/src/cli.ts | 111 +++++++++++++++++- 2 files changed, 336 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index 7ebde01067..7c5c9bf69f 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -1,5 +1,5 @@ import * as NodeHttp from "node:http"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -353,4 +353,229 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { assert.equal(optionError.option, "--dev-url"); }), ); + + it.effect("moves a project workspace directory and updates metadata", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-move-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-move-workspace-")); + const newWorkspaceRoot = join(tmpdir(), `t3-cli-move-target-${Date.now()}`); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "MoveTest", + "--base-dir", + baseDir, + ]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const addedProject = afterAdd.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + + yield* runCliWithRuntime([ + "project", + "move", + workspaceRoot, + newWorkspaceRoot, + "--base-dir", + baseDir, + ]); + const afterMove = yield* readPersistedSnapshot(baseDir); + const movedProject = afterMove.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.equal(movedProject?.workspaceRoot, newWorkspaceRoot); + assert.equal(movedProject?.title, "MoveTest"); + assert.equal(movedProject?.deletedAt, null); + }), + ); + + it.effect("dry-run move previews without making changes", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-dry-run-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-dry-run-workspace-")); + const newWorkspaceRoot = join(tmpdir(), `t3-cli-dry-run-target-${Date.now()}`); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "DryRunTest", + "--base-dir", + baseDir, + ]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const addedProject = afterAdd.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + + const { output } = yield* captureStdout( + runCli([ + "project", + "move", + workspaceRoot, + newWorkspaceRoot, + "--dry-run", + "--base-dir", + baseDir, + ]).pipe(Effect.provide(CliRuntimeLayer)), + ); + + assert.isTrue(output.includes("[dry-run]")); + assert.isTrue(output.includes(workspaceRoot)); + assert.isTrue(output.includes(newWorkspaceRoot)); + + const afterDryRun = yield* readPersistedSnapshot(baseDir); + const unchangedProject = afterDryRun.projects.find( + (project) => project.id === addedProject?.id, + ); + assert.equal(unchangedProject?.workspaceRoot, workspaceRoot); + }), + ); + + it.effect("rejects move when source directory does not exist", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-move-no-source-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-move-no-source-workspace-")); + const newWorkspaceRoot = join(tmpdir(), `t3-cli-move-target-${Date.now()}`); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "MissingSource", + "--base-dir", + baseDir, + ]); + + rmSync(workspaceRoot, { recursive: true, force: true }); + + const error = yield* runCliWithRuntime([ + "project", + "move", + workspaceRoot, + newWorkspaceRoot, + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + + assert.isTrue(error instanceof Error); + }), + ); + + it.effect("rejects move when target already exists", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-move-exists-test-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-move-exists-workspace-")); + const existingTarget = mkdtempSync(join(tmpdir(), "t3-cli-move-exists-target-")); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "ExistsTest", + "--base-dir", + baseDir, + ]); + + const error = yield* runCliWithRuntime([ + "project", + "move", + workspaceRoot, + existingTarget, + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + + assert.isTrue(error instanceof Error); + assert.isTrue((error as Error).message.includes("Target path already exists")); + }), + ); + + it.effect("rejects move when another project already uses the target workspace root", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-move-conflict-test-")); + const workspaceRootA = mkdtempSync(join(tmpdir(), "t3-cli-move-conflict-a-")); + const workspaceRootB = mkdtempSync(join(tmpdir(), "t3-cli-move-conflict-b-")); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRootA, + "--title", + "ProjectA", + "--base-dir", + baseDir, + ]); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRootB, + "--title", + "ProjectB", + "--base-dir", + baseDir, + ]); + + rmSync(workspaceRootA, { recursive: true, force: true }); + + const error = yield* runCliWithRuntime([ + "project", + "move", + workspaceRootB, + workspaceRootA, + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + + assert.isTrue(error instanceof Error); + assert.isTrue((error as Error).message.includes("Another active project already exists at")); + }), + ); + + it.effect("dry-run rejects move when source directory does not exist using project id", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-dry-run-missing-source-")); + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-dry-run-missing-source-ws-")); + const newWorkspaceRoot = join(tmpdir(), `t3-cli-dry-run-missing-target-${Date.now()}`); + + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "DryRunMissing", + "--base-dir", + baseDir, + ]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const addedProject = afterAdd.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); + assert.isTrue(addedProject !== undefined); + + rmSync(workspaceRoot, { recursive: true, force: true }); + + const error = yield* runCliWithRuntime([ + "project", + "move", + addedProject!.id, + newWorkspaceRoot, + "--dry-run", + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + + assert.isTrue(error instanceof Error); + assert.isTrue((error as Error).message.includes("Source directory does not exist")); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 4fc23a1ded..705f71aaec 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1091,9 +1091,118 @@ const projectRenameCommand = Command.make("rename", { ), ); +const projectMoveCommand = Command.make("move", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to move."), + ), + path: Argument.string("path").pipe( + Argument.withDescription("New workspace root path for the project."), + ), + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Preview the move without making changes."), + Flag.optional, + ), +}).pipe( + Command.withDescription("Move a project workspace directory."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectMoveMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const expandedPath = yield* expandHomePath(flags.path.trim()); + const newPath = path.resolve(expandedPath); + if (newPath === project.workspaceRoot) { + return `Project ${project.id} is already at ${newPath}.`; + } + + const sourceExists = yield* fs.exists(project.workspaceRoot); + if (!sourceExists) { + return yield* Effect.fail( + new Error(`Source directory does not exist: ${project.workspaceRoot}`), + ); + } + + const targetParent = path.dirname(newPath); + const parentExists = yield* fs.exists(targetParent); + if (!parentExists) { + return yield* Effect.fail( + new Error(`Target parent directory does not exist: ${targetParent}`), + ); + } + + const parentWritable = yield* fs + .access(targetParent, { writable: true }) + .pipe(Effect.match({ onFailure: () => false, onSuccess: () => true })); + if (!parentWritable) { + return yield* Effect.fail( + new Error(`Target parent directory is not writable: ${targetParent}`), + ); + } + + const targetExists = yield* fs.exists(newPath); + if (targetExists) { + return yield* Effect.fail(new Error(`Target path already exists: ${newPath}`)); + } + + const conflictProject = snapshot.projects.find( + (p) => p.deletedAt === null && p.workspaceRoot === newPath, + ); + if (conflictProject) { + return yield* Effect.fail( + new Error(`Another active project already exists at ${newPath}: ${conflictProject.id}`), + ); + } + + const isDryRun = Option.isSome(flags.dryRun) && flags.dryRun.value; + + if (isDryRun) { + return [ + `[dry-run] Would move project ${project.id} (${project.title}):`, + ` ${project.workspaceRoot}`, + ` -> ${newPath}`, + ].join("\n"); + } + + const rollbackRename = () => + fs.rename(newPath, project.workspaceRoot).pipe(Effect.ignore({ log: true })); + + yield* fs.rename(project.workspaceRoot, newPath); + + yield* dispatch({ + type: "project.meta.update", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + workspaceRoot: newPath, + }).pipe(Effect.tapError(() => rollbackRename())); + return `Moved project ${project.id} (${project.title}) to ${newPath}.`; + }), + ), + ), +); + const projectCommand = Command.make("project").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), + Command.withSubcommands([ + projectAddCommand, + projectRemoveCommand, + projectRenameCommand, + projectMoveCommand, + ]), ); const runServerCommand = (