diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index 765336b..bad1aa8 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,13 +1,14 @@ // Onboarding scan — real filesystem scan, Git analysis, and profile compilation. // Runs synchronously on the host machine (no sandbox needed). import { spawn } from "node:child_process"; -import { access, readdir, realpath, stat } from "node:fs/promises"; +import { access, readdir, stat } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import type { Actor } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { createSpaceForMember, InvalidSpaceNameError, + resolveProjectWorkspaceDirectory, SpaceLimitError, wireProjectWorkspace, } from "@rakazo/db"; @@ -656,7 +657,8 @@ async function createProjects( for (const repo of repos) { // Compare and link the real folder, so the next scan recognizes the projects of this one. - const folder = await realpath(repo.path).catch(() => null); + // Checking it before the project exists means a folder that cannot be linked leaves none. + const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null); if (!folder || linkedFolders.has(folder)) continue; let space: { id: string; name: string }; try { diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 78bf6e1..cbe9ba6 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -138,6 +138,7 @@ import { releaseSpaceDeletionClaim, renewSpaceDeletionClaim, requireMembership, + resolveProjectWorkspaceDirectory, SPACE_DELETION_CLAIM_TIMEOUT_MS, SpaceDeletionInProgressError, SpaceLimitError, @@ -584,6 +585,18 @@ export function createRouter(deps: RouterDeps) { if (input.workspacePath && !context.actor.isDeploymentOwner) { throw new ORPCError("FORBIDDEN"); } + // Check the folder before the space exists, so a bad path leaves no space behind. + let workspacePath: string | null = null; + if (input.workspacePath) { + try { + workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath); + } catch (error) { + if (error instanceof InvalidProjectWorkspacePathError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } + } let space: { id: string; name: string }; try { space = await createSpaceForMember(deps.prisma, { @@ -597,20 +610,9 @@ 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; - } - } + const workspace = workspacePath + ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath }) + : null; return { id: space.id, name: space.name, diff --git a/packages/db/src/project-workspace.test.ts b/packages/db/src/project-workspace.test.ts index 75f0693..1a20e8c 100644 --- a/packages/db/src/project-workspace.test.ts +++ b/packages/db/src/project-workspace.test.ts @@ -72,7 +72,7 @@ describe("project workspace wiring", () => { const wired = await wireProjectWorkspace(prisma as unknown as Pick, { spaceId: "space-1", - workspacePath: root, + workspacePath: await resolveProjectWorkspaceDirectory(root), }); expect(wired.path).toBe(realpathSync(root)); expect(wired.git).toEqual({ diff --git a/packages/db/src/project-workspace.ts b/packages/db/src/project-workspace.ts index 50b3c5f..fd0c04a 100644 --- a/packages/db/src/project-workspace.ts +++ b/packages/db/src/project-workspace.ts @@ -50,18 +50,17 @@ export interface WiredProjectWorkspace { } | null; } -/** Persist a host folder and optional git metadata on a newly created space. */ +/** Persist a folder from `resolveProjectWorkspaceDirectory`, and its git metadata, on a new space. */ export async function wireProjectWorkspace( prisma: Pick, input: { spaceId: string; workspacePath: string }, ): Promise { - const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath); - const git = await readGitCheckout(resolved); + const git = await readGitCheckout(input.workspacePath); await prisma.space.update({ where: { id: input.spaceId }, data: { - workspacePath: resolved, + 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, @@ -69,7 +68,7 @@ export async function wireProjectWorkspace( }); return { - path: resolved, + path: input.workspacePath, git: git.present && git.remoteUrl && git.defaultBranch ? { diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ff9020c..ddd0059 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -2239,15 +2239,25 @@ describeWithDatabase("API authorization and resource isolation", () => { const legacyNavigation = await rpc(app, member, "spaces/list"); expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull(); + // A bad folder is rejected before the project exists, so fixing it and retrying leaves one. + for (const badPath of [path.join(hostFolder, "missing"), path.join(hostFolder, ".env")]) { + const rejected = await raw(app, owner, "spaces/create", { + name: "Owner folder", + workspacePath: badPath, + }); + expect(rejected.status, badPath).toBe(400); + } const linked = await rpc(app, owner, "spaces/create", { name: "Owner folder", workspacePath: hostFolder, }); expect(linked.workspace?.path).toBe(hostFolder); const ownerNavigation = await rpc(app, owner, "spaces/list"); - expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe( - hostFolder, - ); + expect( + ownerNavigation.spaces + .filter((space) => space.name === "Owner folder") + .map((space) => space.workspace?.path), + ).toEqual([hostFolder]); const ownerBot = await rpc( app, owner,