Files
Codewalkers/apps/server/trpc/routers/project.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

201 lines
7.1 KiB
TypeScript

/**
* 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);
}),
};
}