Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 226 additions & 1 deletion apps/server/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -354,4 +354,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"));
}),
);
});
111 changes: 110 additions & 1 deletion apps/server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,9 +1138,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<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
}) {
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}`));
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

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");
Comment thread
cursor[bot] marked this conversation as resolved.
}

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 = (
Expand Down
Loading