spaces.create linked any workspacePath to the new project, and computer.files and computer.readFile then listed and read files under that folder for anyone in the space. On a deployment with signups, any member could link / or the API's own checkout and read whatever the API process can read, such as its .env file. Linking also ran git in the chosen folder and stored its remote URL. A linked folder lives on the host, and the host is the deployment owner's machine, the rule the onboarding scan already follows: - spaces.create refuses a folder from anyone but the deployment owner, before the space is created or the folder is touched. - Project files are listed and read only for the deployment owner, so a space a member linked before this change stays closed too. Space navigation no longer shows a linked folder's path and git remote to other members. - A linked folder is stored as its real path, and listing and reading follow symlinks only while they stay inside it. Symlinks that leave the folder or lead nowhere are left out of a listing instead of failing it. - The web New project dialog offers the folder field only to the deployment owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
107 lines
3.3 KiB
TypeScript
107 lines
3.3 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 host folder and optional git metadata on a newly created space. */
|
|
export async function wireProjectWorkspace(
|
|
prisma: Pick<PrismaClient, "space">,
|
|
input: { spaceId: string; workspacePath: string },
|
|
): Promise<WiredProjectWorkspace> {
|
|
const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath);
|
|
const git = await readGitCheckout(resolved);
|
|
|
|
await prisma.space.update({
|
|
where: { id: input.spaceId },
|
|
data: {
|
|
workspacePath: resolved,
|
|
gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null,
|
|
gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null,
|
|
gitHeadCommit: git.present ? git.headCommit : null,
|
|
},
|
|
});
|
|
|
|
return {
|
|
path: resolved,
|
|
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,
|
|
};
|
|
}
|