feat: rich project creation with folder and git wiring (#73)

Extend the existing NewSpaceDialog project flow so an optional host
folder becomes the space workspace root. When .git metadata is present,
remote URL, default branch, and head commit are stored on the space.

Team Computer file APIs expose the linked folder under project/ without
a second VCS stack. Desktop adds a native folder picker; web falls back
to an absolute path field.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-09-22 12:29:25 +02:00 committed by GitHub
parent 83302961ba
commit cf6ac7270e
22 changed files with 656 additions and 7 deletions

View File

@ -52,9 +52,11 @@ import {
hasActiveComputerControl,
isAutoReviewCheckerConfigured,
isComputerScreenUnavailable,
isProjectWorkspacePath,
isSandboxGoneError,
isScratchpadStatus,
listPiCatalog,
listProjectWorkspaceFiles,
listScratchpadItems,
McpOAuthBroker,
mapScratchpadItem,
@ -66,6 +68,7 @@ import {
provisionComputer,
queueComputerUpdate,
releaseComputerExecutionLease,
readProjectWorkspaceFile,
replaceComputer,
resolveAutoReviewChecker,
resolveBotWorkspacePath,
@ -116,8 +119,11 @@ import {
findDefaultVoiceCredential,
findModelCredential,
formatMessagingLinkCode,
InvalidProjectWorkspacePathError,
InvalidSpaceNameError,
IsolationError,
projectWorkspaceDto,
wireProjectWorkspace,
isProjectGoalStatus,
issueMessagingLinkCode,
listSpaceUserBotIds,
@ -549,12 +555,27 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
let workspace = null;
if (input.workspacePath) {
try {
workspace = await wireProjectWorkspace(deps.prisma, {
spaceId: space.id,
workspacePath: input.workspacePath,
});
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
return {
id: space.id,
name: space.name,
isDefault: false,
hasContent: false,
canDelete: true,
workspace,
bots: [],
groups: [],
externalConversations: [],
@ -2090,6 +2111,14 @@ export function createRouter(deps: RouterDeps) {
throw error;
}
}
if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId);
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
return entries.map((entry) => ({
...entry,
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
}));
}
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
if (computer.state === "running" && computer.providerRef) {
await deps.prisma.computer.updateMany({
@ -2130,6 +2159,16 @@ export function createRouter(deps: RouterDeps) {
throw error;
}
}
if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId);
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
});
return {
path: input.path,
content: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
};
}
let content: string;
if (bot.computer.state === "running" && bot.computer.providerRef) {
await deps.prisma.computer.updateMany({
@ -4709,6 +4748,20 @@ function mapUpdaterError(error: unknown): never {
});
}
async function requireProjectWorkspaceRoot(
prisma: Pick<PrismaClient, "space">,
spaceId: string,
): Promise<string> {
const space = await prisma.space.findUnique({
where: { id: spaceId },
select: { workspacePath: true },
});
if (!space?.workspacePath) {
throw new ORPCError("NOT_FOUND", { message: "This project has no linked folder." });
}
return space.workspacePath;
}
async function spaceNavigationDto(
deps: RouterDeps,
actor: Actor,
@ -4725,7 +4778,17 @@ async function spaceNavigationDto(
select: {
spaceId: true,
role: true,
space: { select: { name: true, isDefault: true, deletingAt: true } },
space: {
select: {
name: true,
isDefault: true,
deletingAt: true,
workspacePath: true,
gitRemoteUrl: true,
gitDefaultBranch: true,
gitHeadCommit: true,
},
},
},
orderBy: { createdAt: "asc" },
});
@ -4800,6 +4863,7 @@ async function spaceNavigationDto(
memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
workspace: projectWorkspaceDto(membership.space),
bots: spaceBots.map((bot) => ({
id: bot.id,
spaceId: bot.spaceId,

View File

@ -1130,6 +1130,14 @@ app.whenReady().then(async () => {
},
);
ipcMain.handle("desktop.platform", () => process.platform);
ipcMain.handle("desktop.files.pickDirectory", async (event) => {
if (!fromMainWindow(event)) return null;
const result = await dialog.showOpenDialog(windowFrom(event)!, {
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0] ?? null;
});
ipcMain.handle("desktop.window.close", (event) => {
windowFrom(event)?.close();
});

View File

@ -2,6 +2,9 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("rakazoDesktop", {
platform: process.platform,
files: {
pickDirectory: () => ipcRenderer.invoke("desktop.files.pickDirectory"),
},
localSettings: {
request: (pathname, body) =>
ipcRenderer.invoke("desktop.localSettings.request", pathname, body),

View File

@ -489,7 +489,11 @@
"Create a project": "Projekt erstellen",
"Create project": "Projekt erstellen",
"Could not create project": "Projekt konnte nicht erstellt werden",
"Choose an existing folder on this computer. Git remotes are linked automatically when a repository is present.": "Wähle einen vorhandenen Ordner auf diesem Computer. Git-Remotes werden automatisch verknüpft, wenn ein Repository vorhanden ist.",
"Choose folder": "Ordner wählen",
"Enter an absolute folder path on the server. Git remotes are linked automatically when a repository is present.": "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden automatisch verknüpft, wenn ein Repository vorhanden ist.",
"New project": "Neues Projekt",
"Project folder": "Projektordner",
"No projects yet.": "Noch keine Projekte.",
"Threads by topic · folders": "Threads nach Thema · Ordner",
"Settings": "Einstellungen",

View File

@ -4878,8 +4878,11 @@ export function ShellPage() {
<NewSpaceDialog
variant={newSpaceDialogVariant}
onCancel={() => setNewSpaceOpen(false)}
onConfirm={async (name) => {
const space = await rpc.spaces.create({ name });
onConfirm={async ({ name, workspacePath }) => {
const space = await rpc.spaces.create({
name,
...(workspacePath ? { workspacePath } : {}),
});
setNewSpaceOpen(false);
if (!selectSpace(space.id)) {
await refreshBots();

View File

@ -18,8 +18,9 @@ import {
DialogTitle,
Input,
} from "@rakazo/ui-web";
import { Lock, Users } from "lucide-react";
import { FolderOpen, Lock, Users } from "lucide-react";
import { useId, useState } from "react";
import { desktopBridge } from "../../lib/desktop";
/** Each dialog is mounted only while open, so `open` is always true and the
* parent unmounts it from `onCancel`. Escape and backdrop presses are ignored
@ -30,6 +31,11 @@ function closeUnlessBusy(busy: boolean, onCancel: () => void) {
};
}
export type NewSpaceConfirmInput = {
name: string;
workspacePath?: string;
};
export function NewSpaceDialog({
variant = "space",
onCancel,
@ -37,21 +43,28 @@ export function NewSpaceDialog({
}: {
variant?: "space" | "project";
onCancel: () => void;
onConfirm: (name: string) => Promise<void>;
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
}) {
const { t } = useLingui();
const nameId = useId();
const folderId = useId();
const [name, setName] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isProject = variant === "project";
const desktop = desktopBridge();
const trimmedWorkspacePath = workspacePath.trim();
const create = () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
setError(null);
void onConfirm(trimmed).catch((reason: unknown) => {
void onConfirm({
name: trimmed,
...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
}).catch((reason: unknown) => {
setError(
reason instanceof Error
? reason.message
@ -91,6 +104,56 @@ export function NewSpaceDialog({
className="mt-2"
/>
</label>
{isProject ? (
<div className="space-y-2">
<label htmlFor={folderId} className="block text-ui-body text-foreground/75">
<Trans>Project folder</Trans>
<span className="ml-1 text-ui-body-sm text-muted-foreground">
<Trans>(optional)</Trans>
</span>
<div className="mt-2 flex gap-2">
<Input
id={folderId}
value={workspacePath}
onChange={(event) => setWorkspacePath(event.target.value)}
placeholder={t`/Users/you/projects/acme`}
spellCheck={false}
/>
{desktop?.files?.pickDirectory ? (
<Button
type="button"
variant="outline"
disabled={saving}
aria-label={t`Choose folder`}
onClick={() => {
void desktop.files
?.pickDirectory()
.then((picked) => {
if (picked) setWorkspacePath(picked);
})
.catch(() => undefined);
}}
>
<FolderOpen size={16} strokeWidth={1.8} aria-hidden="true" />
</Button>
) : null}
</div>
</label>
<p className="text-ui-body-sm text-muted-foreground">
{desktop?.files?.pickDirectory ? (
<Trans>
Choose an existing folder on this computer. Git remotes are linked automatically
when a repository is present.
</Trans>
) : (
<Trans>
Enter an absolute folder path on the server. Git remotes are linked automatically
when a repository is present.
</Trans>
)}
</p>
</div>
) : null}
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" disabled={saving} onClick={onCancel}>

View File

@ -170,6 +170,8 @@ function isTeamRootPath(value: string): boolean {
return (
value === "shared" ||
value.startsWith("shared/") ||
value === "project" ||
value.startsWith("project/") ||
value === "bots" ||
value.startsWith("bots/")
);

View File

@ -90,6 +90,8 @@ export * from "./pi-models.js";
export * from "./pi-oauth.js";
export * from "./pi-openai-compatible-provider.js";
export * from "./pi-runtime.js";
export * from "./project-workspace-files.js";
export * from "./project-workspace-path.js";
export * from "./pi-session.js";
export * from "./pipedream-connector.js";
export * from "./realtime.js";

View File

@ -0,0 +1,52 @@
import { readdir, readFile, stat } from "node:fs/promises";
import type { ComputerFileEntry } from "@rakazo/adapter-kit";
import { normalizeWorkspacePath } from "./computer-support.js";
import {
isProjectWorkspacePath,
projectWorkspaceRelativePath,
resolveProjectHostPath,
} from "./project-workspace-path.js";
export async function listProjectWorkspaceFiles(
workspaceRoot: string,
storedPath: string,
): Promise<ComputerFileEntry[]> {
if (!isProjectWorkspacePath(storedPath)) {
throw new Error("Path is not under the project workspace");
}
const hostDirectory = resolveProjectHostPath(workspaceRoot, storedPath);
const relativeDirectory = projectWorkspaceRelativePath(storedPath);
const entries = await readdir(hostDirectory, { withFileTypes: true });
const listed = await Promise.all(
entries.map(async (entry) => {
const childHost = resolveProjectHostPath(
workspaceRoot,
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
);
const info = await stat(childHost);
const childStored = normalizeWorkspacePath(
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
);
return {
path: childStored,
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
size: info.size,
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
};
}),
);
return listed;
}
export async function readProjectWorkspaceFile(
workspaceRoot: string,
storedPath: string,
options?: { maxBytes?: number },
): Promise<Uint8Array> {
const hostPath = resolveProjectHostPath(workspaceRoot, storedPath);
const info = await stat(hostPath);
if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
}
return new Uint8Array(await readFile(hostPath));
}

View File

@ -0,0 +1,40 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
isProjectWorkspacePath,
projectWorkspaceRelativePath,
resolveProjectHostPath,
} from "./project-workspace-path.js";
describe("project workspace paths", () => {
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("detects project-root paths", () => {
expect(isProjectWorkspacePath("project")).toBe(true);
expect(isProjectWorkspacePath("project/src/main.ts")).toBe(true);
expect(isProjectWorkspacePath("shared/notes.txt")).toBe(false);
});
it("resolves host paths under the linked project folder", () => {
const root = mkdtempSync(path.join(tmpdir(), "project-workspace-path-"));
roots.push(root);
expect(resolveProjectHostPath(root, "project")).toBe(path.resolve(root));
expect(resolveProjectHostPath(root, "project/src/app.ts")).toBe(
path.resolve(root, "src/app.ts"),
);
expect(() => resolveProjectHostPath(root, "project/../outside")).toThrow(/escapes/i);
});
it("extracts relative segments from stored paths", () => {
expect(projectWorkspaceRelativePath("project")).toBe("");
expect(projectWorkspaceRelativePath("project/docs/readme.md")).toBe("docs/readme.md");
});
});

View File

@ -0,0 +1,32 @@
import path from "node:path";
import { normalizeWorkspacePath } from "./computer-support.js";
export const PROJECT_WORKSPACE_PREFIX = "project";
export function isProjectWorkspacePath(storedPath: string): boolean {
const normalized = normalizeWorkspacePath(storedPath);
return (
normalized === PROJECT_WORKSPACE_PREFIX ||
normalized.startsWith(`${PROJECT_WORKSPACE_PREFIX}/`)
);
}
export function projectWorkspaceRelativePath(storedPath: string): string {
const normalized = normalizeWorkspacePath(storedPath);
if (normalized === PROJECT_WORKSPACE_PREFIX) return "";
if (!normalized.startsWith(`${PROJECT_WORKSPACE_PREFIX}/`)) {
throw new Error("Path is not under the project workspace");
}
return normalized.slice(`${PROJECT_WORKSPACE_PREFIX}/`.length);
}
export function resolveProjectHostPath(workspaceRoot: string, storedPath: string): string {
const relative = projectWorkspaceRelativePath(storedPath);
const resolvedRoot = path.resolve(workspaceRoot);
const candidate = relative ? path.resolve(resolvedRoot, relative) : resolvedRoot;
const relativeToRoot = path.relative(resolvedRoot, candidate);
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
throw new Error("Path escapes the project workspace");
}
return candidate;
}

View File

@ -41,6 +41,10 @@ export interface RakazoDesktop {
localSettings?: {
request: (pathname: string, body: string) => Promise<{ status: number; body: string }>;
};
files?: {
/** Opens a native folder picker and returns an absolute host path, or null when cancelled. */
pickDirectory: () => Promise<string | null>;
};
platform: string;
window: {
close: () => Promise<void>;

View File

@ -246,6 +246,21 @@ export const ExternalConversationSchema = z.object({
});
export type ExternalConversation = z.infer<typeof ExternalConversationSchema>;
export const SpaceGitLinkSchema = z.object({
remoteUrl: z.string(),
defaultBranch: z.string(),
headCommit: z.string().nullable(),
dirty: z.boolean(),
dirtyPaths: z.array(z.string()),
});
export type SpaceGitLink = z.infer<typeof SpaceGitLinkSchema>;
export const SpaceWorkspaceSchema = z.object({
path: z.string(),
git: SpaceGitLinkSchema.nullable(),
});
export type SpaceWorkspace = z.infer<typeof SpaceWorkspaceSchema>;
export const SpaceSchema = z.object({
id: Id,
name: z.string(),
@ -254,6 +269,7 @@ export const SpaceSchema = z.object({
hasContent: z.boolean(),
/** True only when the current member may delete this non-default space. */
canDelete: z.boolean().optional(),
workspace: SpaceWorkspaceSchema.nullable().optional(),
bots: z.array(SpaceBotSchema),
groups: z.array(SpaceGroupSchema),
externalConversations: z.array(ExternalConversationSchema),

View File

@ -170,7 +170,14 @@ export const appContract = {
},
spaces: {
list: oc.output(SpaceNavigationSchema),
create: oc.input(z.object({ name: z.string().trim().min(1).max(60) })).output(SpaceSchema),
create: oc
.input(
z.object({
name: z.string().trim().min(1).max(60),
workspacePath: z.string().trim().min(1).max(500).optional(),
}),
)
.output(SpaceSchema),
remove: oc
.input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: Id })),

View File

@ -0,0 +1,67 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type GitRunner, readGitCheckout } from "./git-checkout.js";
describe("readGitCheckout", () => {
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function tempDir(prefix: string) {
const root = mkdtempSync(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
it("returns absent status when .git is missing", async () => {
const root = tempDir("git-checkout-absent-");
await expect(readGitCheckout(root)).resolves.toEqual({
present: false,
remoteUrl: null,
defaultBranch: null,
headCommit: null,
dirty: false,
dirtyPaths: [],
});
});
it("reads remote, branch, commit, and dirty paths from git metadata", async () => {
const root = tempDir("git-checkout-present-");
mkdirSync(path.join(root, ".git"));
writeFileSync(path.join(root, "README.md"), "hello");
const runner: GitRunner = async (args) => {
if (args.join(" ") === "rev-parse HEAD") {
return { ok: true, output: "abc123def456\n" };
}
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") {
return { ok: true, output: "main\n" };
}
if (args.join(" ") === "remote get-url origin") {
return { ok: true, output: "https://github.com/example/demo.git\n" };
}
if (args.includes("status")) {
return { ok: true, output: " M README.md\n" };
}
if (args.includes("ls-files")) {
return { ok: true, output: "notes.txt\n" };
}
return { ok: false, output: "" };
};
await expect(readGitCheckout(root, runner)).resolves.toEqual({
present: true,
remoteUrl: "https://github.com/example/demo.git",
defaultBranch: "main",
headCommit: "abc123def456",
dirty: true,
dirtyPaths: ["README.md", "notes.txt"],
});
});
});

View File

@ -0,0 +1,85 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
export interface GitCheckoutStatus {
present: boolean;
remoteUrl: string | null;
defaultBranch: string | null;
headCommit: string | null;
dirty: boolean;
dirtyPaths: string[];
}
export interface GitRunnerResult {
ok: boolean;
output: string;
}
export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult>;
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, env: process.env });
let output = "";
child.stdout.on("data", (chunk: Buffer | string) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer | string) => {
output += chunk.toString();
});
child.on("error", () => resolve({ ok: false, output }));
child.on("close", (code) => resolve({ ok: code === 0, output }));
});
}
async function hasGitMetadata(directory: string): Promise<boolean> {
try {
await access(path.join(directory, ".git"));
return true;
} catch {
return false;
}
}
/** Reads local git metadata from an existing checkout without fetching remotes. */
export async function readGitCheckout(
directory: string,
runGit: GitRunner = defaultGitRunner,
): Promise<GitCheckoutStatus> {
const root = path.resolve(directory);
if (!(await hasGitMetadata(root))) {
return {
present: false,
remoteUrl: null,
defaultBranch: null,
headCommit: null,
dirty: false,
dirtyPaths: [],
};
}
const [head, branch, remote, status, untracked] = await Promise.all([
runGit(["rev-parse", "HEAD"], root),
runGit(["rev-parse", "--abbrev-ref", "HEAD"], root),
runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root),
runGit(["--no-optional-locks", ...gitStatusArgv()], root),
runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root),
]);
const porcelain = parseGitStatusPorcelain(status.ok ? status.output : "");
const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : "");
const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])];
const stateReadable = status.ok && untracked.ok;
return {
present: true,
remoteUrl: remote.ok ? remote.output.trim() || null : null,
defaultBranch: branch.ok ? branch.output.trim() || null : null,
headCommit: head.ok ? head.output.trim() || null : null,
dirty: stateReadable ? dirtyPaths.length > 0 : true,
dirtyPaths: stateReadable ? dirtyPaths : [],
};
}

View File

@ -18,6 +18,7 @@ export * from "./computer-updates.js";
export * from "./cron.js";
export * from "./events.js";
export * from "./featured-connectors.js";
export * from "./git-checkout.js";
export * from "./group-mentions.js";
export * from "./http-response.js";
export * from "./mcp.js";

View File

@ -0,0 +1,5 @@
ALTER TABLE "spaces"
ADD COLUMN "workspacePath" TEXT,
ADD COLUMN "gitRemoteUrl" TEXT,
ADD COLUMN "gitDefaultBranch" TEXT,
ADD COLUMN "gitHeadCommit" TEXT;

View File

@ -166,6 +166,10 @@ model Space {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
workspacePath String?
gitRemoteUrl String?
gitDefaultBranch String?
gitHeadCommit String?
isDefault Boolean @default(false)
createdByUserId String?
createdByUser User? @relation("SpaceCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)

View File

@ -7,6 +7,7 @@ export * from "./events.js";
export * from "./external-conversations.js";
export * from "./groups.js";
export * from "./project-goals.js";
export * from "./project-workspace.js";
export * from "./memory-config.js";
export * from "./messages.js";
export * from "./messaging.js";

View File

@ -0,0 +1,82 @@
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "./client.js";
import {
assertProjectWorkspaceDirectory,
InvalidProjectWorkspacePathError,
normalizeProjectWorkspacePath,
wireProjectWorkspace,
} from "./project-workspace.js";
vi.mock("@rakazo/core", () => ({
readGitCheckout: vi.fn(async () => ({
present: true,
remoteUrl: "https://github.com/example/demo.git",
defaultBranch: "main",
headCommit: "abc123",
dirty: false,
dirtyPaths: [],
})),
}));
describe("project workspace wiring", () => {
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function tempDir(prefix: string) {
const root = mkdtempSync(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
it("normalizes absolute workspace paths", () => {
const root = tempDir("project-workspace-normalize-");
expect(normalizeProjectWorkspacePath(root)).toBe(path.resolve(root));
});
it("rejects missing folders", async () => {
const missing = path.join(tmpdir(), `missing-${Date.now()}`);
await expect(assertProjectWorkspaceDirectory(missing)).rejects.toBeInstanceOf(
InvalidProjectWorkspacePathError,
);
});
it("persists workspace and git metadata on the space", async () => {
const root = tempDir("project-workspace-wire-");
mkdirSync(path.join(root, ".git"));
const updates: Array<Record<string, unknown>> = [];
const prisma = {
space: {
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
updates.push(data);
}),
},
};
const wired = await wireProjectWorkspace(prisma as unknown as Pick<PrismaClient, "space">, {
spaceId: "space-1",
workspacePath: root,
});
expect(wired.path).toBe(path.resolve(root));
expect(wired.git).toEqual({
remoteUrl: "https://github.com/example/demo.git",
defaultBranch: "main",
headCommit: "abc123",
dirty: false,
dirtyPaths: [],
});
expect(updates[0]).toMatchObject({
workspacePath: path.resolve(root),
gitRemoteUrl: "https://github.com/example/demo.git",
gitDefaultBranch: "main",
gitHeadCommit: "abc123",
});
});
});

View File

@ -0,0 +1,104 @@
import { access, stat } from "node:fs/promises";
import path from "node:path";
import { readGitCheckout } from "@rakazo/core";
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);
}
export async function assertProjectWorkspaceDirectory(workspacePath: string): Promise<void> {
const resolved = normalizeProjectWorkspacePath(workspacePath);
let info;
try {
await access(resolved);
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.");
}
}
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 = normalizeProjectWorkspacePath(input.workspacePath);
await assertProjectWorkspaceDirectory(resolved);
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,
};
}