evOo-local/packages/db/src/project-workspace.ts
cynarAI a15da068d7
fix(api): check a project folder before creating the space (#114)
spaces.create created the space first and only then checked the folder,
so a folder that was missing or not a folder returned 400 but left the
new space behind. The New project dialog shows that error, so fixing the
path and submitting again created a second project with the same name.

The folder is now resolved and checked right after the deployment-owner
check, before the space exists, and the space stores that resolved path.
wireProjectWorkspace takes the resolved folder instead of resolving it
again after the space was created. The onboarding scan, its other
caller, resolves and checks each repository folder the same way before
creating the project, and skips a folder that cannot be linked.

The authorization test has the deployment owner submit a missing folder
and a file (both 400) and then the real folder under the same name, and
checks that exactly one project with that name exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 20:35:29 +02:00

106 lines
3.2 KiB
TypeScript

import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { readGitCheckout } from "@rakazo/core/git-checkout";
import type { PrismaClient } from "./client.js";
export class InvalidProjectWorkspacePathError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidProjectWorkspacePathError";
}
}
export function normalizeProjectWorkspacePath(input: string): string {
const trimmed = input.trim();
if (!trimmed) throw new InvalidProjectWorkspacePathError("A folder path is required.");
if (trimmed.length > 500) {
throw new InvalidProjectWorkspacePathError("That folder path is too long.");
}
if (trimmed.includes("\0")) {
throw new InvalidProjectWorkspacePathError("That folder path is not valid.");
}
return path.resolve(trimmed);
}
/** The real folder behind a path, so a symlink cannot later point the link somewhere else. */
export async function resolveProjectWorkspaceDirectory(workspacePath: string): Promise<string> {
const normalized = normalizeProjectWorkspacePath(workspacePath);
let resolved: string;
let info: Awaited<ReturnType<typeof stat>>;
try {
resolved = await realpath(normalized);
info = await stat(resolved);
} catch {
throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable.");
}
if (!info.isDirectory()) {
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
}
return resolved;
}
export interface WiredProjectWorkspace {
path: string;
git: {
remoteUrl: string;
defaultBranch: string;
headCommit: string | null;
dirty: boolean;
dirtyPaths: string[];
} | null;
}
/** Persist a folder from `resolveProjectWorkspaceDirectory`, and its git metadata, on a new space. */
export async function wireProjectWorkspace(
prisma: Pick<PrismaClient, "space">,
input: { spaceId: string; workspacePath: string },
): Promise<WiredProjectWorkspace> {
const git = await readGitCheckout(input.workspacePath);
await prisma.space.update({
where: { id: input.spaceId },
data: {
workspacePath: input.workspacePath,
gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null,
gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null,
gitHeadCommit: git.present ? git.headCommit : null,
},
});
return {
path: input.workspacePath,
git:
git.present && git.remoteUrl && git.defaultBranch
? {
remoteUrl: git.remoteUrl,
defaultBranch: git.defaultBranch,
headCommit: git.headCommit,
dirty: git.dirty,
dirtyPaths: git.dirtyPaths,
}
: null,
};
}
export function projectWorkspaceDto(space: {
workspacePath: string | null;
gitRemoteUrl: string | null;
gitDefaultBranch: string | null;
gitHeadCommit: string | null;
}): WiredProjectWorkspace | null {
if (!space.workspacePath) return null;
return {
path: space.workspacePath,
git:
space.gitRemoteUrl && space.gitDefaultBranch
? {
remoteUrl: space.gitRemoteUrl,
defaultBranch: space.gitDefaultBranch,
headCommit: space.gitHeadCommit,
dirty: false,
dirtyPaths: [],
}
: null,
};
}