Files
Codewalkers/apps/server/trpc/routers/project.ts
Lukas May ebe186bd5e feat: Add agent preview integration with auto-teardown and simplified commands
- Add agentId label to preview containers (cw.agent-id) for tracking
- Add startForAgent/stopByAgentId methods to PreviewManager
- Auto-teardown: previews torn down on agent:stopped event
- Conditional preview prompt injection for execute/refine/discuss agents
- Agent-simplified CLI: cw preview start/stop --agent <id>
- cw preview setup command with --auto mode for guided config generation
- hasPreviewConfig hint on cw project register output
- New tRPC procedures: startPreviewForAgent, stopPreviewByAgent
2026-03-05 15:39:15 +01:00

188 lines
6.5 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')) {
throw new TRPCError({
code: 'CONFLICT',
message: `A project with that 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);
}),
};
}