/** * Project Router — register, list, get, delete, initiative associations */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { join } from 'node:path'; import { rm, access } from 'node:fs/promises'; import type { ProcedureBuilder } from '../trpc.js'; import { requireProjectRepository, requireProjectSyncManager } from './_helpers.js'; import { cloneProject } from '../../git/clone.js'; import { getProjectCloneDir } from '../../git/project-clones.js'; export function projectProcedures(publicProcedure: ProcedureBuilder) { return { registerProject: publicProcedure .input(z.object({ name: z.string().min(1), url: z.string().min(1), defaultBranch: z.string().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); let project; try { project = await repo.create({ name: input.name, url: input.url, ...(input.defaultBranch && { defaultBranch: input.defaultBranch }), }); } catch (error) { const msg = (error as Error).message ?? ''; if (msg.includes('UNIQUE') || msg.includes('unique')) { if (msg.includes('projects.name') || (msg.includes('name') && !msg.includes('url'))) { throw new TRPCError({ code: 'CONFLICT', message: 'A project with this name already exists', }); } if (msg.includes('projects.url') || msg.includes('url')) { throw new TRPCError({ code: 'CONFLICT', message: 'A project with this URL already exists', }); } // fallback: neither column identifiable throw new TRPCError({ code: 'CONFLICT', message: 'A project with this name or URL already exists', }); } throw error; } if (ctx.workspaceRoot) { const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id)); try { await cloneProject(input.url, clonePath); } catch (cloneError) { await repo.delete(project.id); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to clone repository: ${(cloneError as Error).message}`, }); } // Validate that the specified default branch exists in the cloned repo const branchToValidate = input.defaultBranch ?? 'main'; if (ctx.branchManager) { const exists = await ctx.branchManager.remoteBranchExists(clonePath, branchToValidate); if (!exists) { // Clean up: remove project and clone await rm(clonePath, { recursive: true, force: true }).catch(() => {}); await repo.delete(project.id); throw new TRPCError({ code: 'BAD_REQUEST', message: `Branch '${branchToValidate}' does not exist in the repository`, }); } } } // Check for preview config let hasPreviewConfig = false; if (ctx.workspaceRoot) { const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id)); try { await access(join(clonePath, '.cw-preview.yml')); hasPreviewConfig = true; } catch { /* no config */ } } return { ...project, hasPreviewConfig }; }), listProjects: publicProcedure .query(async ({ ctx }) => { const repo = requireProjectRepository(ctx); return repo.findAll(); }), getProject: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); const project = await repo.findById(input.id); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project '${input.id}' not found`, }); } return project; }), deleteProject: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); const project = await repo.findById(input.id); if (project && ctx.workspaceRoot) { const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id)); await rm(clonePath, { recursive: true, force: true }).catch(() => {}); } await repo.delete(input.id); return { success: true }; }), updateProject: publicProcedure .input(z.object({ id: z.string().min(1), defaultBranch: z.string().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); const { id, ...data } = input; const existing = await repo.findById(id); if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project '${id}' not found`, }); } // Validate that the new default branch exists in the repo if (data.defaultBranch && ctx.workspaceRoot && ctx.branchManager) { const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(existing.name, existing.id)); const exists = await ctx.branchManager.remoteBranchExists(clonePath, data.defaultBranch); if (!exists) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Branch '${data.defaultBranch}' does not exist in the repository`, }); } } return repo.update(id, data); }), getInitiativeProjects: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); return repo.findProjectsByInitiativeId(input.initiativeId); }), updateInitiativeProjects: publicProcedure .input(z.object({ initiativeId: z.string().min(1), projectIds: z.array(z.string().min(1)).min(1), })) .mutation(async ({ ctx, input }) => { const repo = requireProjectRepository(ctx); await repo.setInitiativeProjects(input.initiativeId, input.projectIds); return { success: true }; }), syncProject: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const syncManager = requireProjectSyncManager(ctx); return syncManager.syncProject(input.id); }), syncAllProjects: publicProcedure .mutation(async ({ ctx }) => { const syncManager = requireProjectSyncManager(ctx); return syncManager.syncAllProjects(); }), getProjectSyncStatus: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const syncManager = requireProjectSyncManager(ctx); return syncManager.getSyncStatus(input.id); }), }; }