mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 10:18:37 +00:00
Restructure project creation, listing, GitHub clone progress, and TaskMaster details behind a dedicated TypeScript module under server/modules/projects/, and align the client wizard with a single path-based flow. Server / routing - Remove server/routes/projects.js and mount server/modules/projects/ projects.routes.ts at /api/projects (still behind authenticateToken). - Drop duplicate handlers from server/index.js for GET /api/projects and GET /api/projects/:projectId/taskmaster; those live on the new router. - Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in index.js instead of the deleted projects route module. Projects router (projects.routes.ts) - GET /: list projects with sessions (existing snapshot behavior). - POST /create-project: validate body, reject legacy workspaceType and mixed clone fields, delegate to createProject service, return distinct success copy when an archived path is reactivated. - GET /clone-progress: Server-Sent Events for clone progress/complete/error; requires authenticated user id for token resolution; wires startCloneProject. - GET /:projectId/taskmaster: delegates to getProjectTaskMaster. Services (new) - project-management.service.ts: path validation, workspace directory creation, persistence via projectsDb.createProjectPath, mapping to API project shape; surfaces AppError for validation, conflict, and not-found cases; optional dependency injection for tests. - project-clone.service.ts: validates workspace, resolves GitHub auth (stored token or inline token), runs git clone with progress callbacks, registers project via createProject on success; sanitizes errors and supports cancellation; injectable dependencies for tests. - projects-has-taskmaster.service.ts: moves TaskMaster detection and normalization out of server/projects.js; resolve-by-id and public getProjectTaskMaster with structured AppError responses. Persistence and shared types - projectsDb.createProjectPath now returns CreateProjectPathResult (created | reactivated_archived | active_conflict) using INSERT … ON CONFLICT with selective update when the row is archived; normalizes display name from path or custom name; repository row typing moves to shared ProjectRepositoryRow. - getProjectPaths() returns only non-archived rows (isArchived = 0). - shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome, WorkspacePathValidationResult. - shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath, asyncHandler for Express async routes. Legacy cleanup - server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo, and getProjectTaskMasterById (logic lives in the new service). - server/routes/agent.js: register external API project paths with projectsDb.createProjectPath instead of addProjectManually try/catch; treat active_conflict as an existing registration and continue. Tests - Add Node test suites for project-management, project-clone, and projects-has-taskmaster services; update projects.service test import for renamed projects-with-sessions-fetch.service.ts. Rename - projects.service.ts → projects-with-sessions-fetch.service.ts; re-export from modules/projects/index.ts. Client (project creation wizard) - Remove StepTypeSelection and workspaceType from form state and types; wizard is two steps (configure path/GitHub auth, then review). - createWorkspaceRequest → createProjectRequest; clone vs create-only inferred from githubUrl (pathUtils / isCloneWorkflow). - Adjust step indices, WizardProgress, StepConfiguration/Review, WorkspacePathField, and src/utils/api.js as needed for the new API. Docs - Minor websocket README touch-up. Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is centralized in typed services with explicit HTTP errors and test seams.
170 lines
4.9 KiB
TypeScript
170 lines
4.9 KiB
TypeScript
import express from 'express';
|
|
|
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
|
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
|
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
|
import { AppError, asyncHandler } from '@/shared/utils.js';
|
|
import { getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
|
|
|
const router = express.Router();
|
|
|
|
type AuthenticatedUser = {
|
|
id?: number | string;
|
|
};
|
|
|
|
function readQueryStringValue(value: unknown): string {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
return value[0];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function readOptionalNumericQueryValue(value: unknown): number | null {
|
|
const rawValue = readQueryStringValue(value).trim();
|
|
if (!rawValue) {
|
|
return null;
|
|
}
|
|
|
|
const parsedValue = Number.parseInt(rawValue, 10);
|
|
return Number.isNaN(parsedValue) ? null : parsedValue;
|
|
}
|
|
|
|
function resolveRouteErrorMessage(error: unknown): string {
|
|
if (error instanceof AppError) {
|
|
return error.message;
|
|
}
|
|
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return 'Failed to clone repository';
|
|
}
|
|
|
|
router.get(
|
|
'/',
|
|
asyncHandler(async (_req, res) => {
|
|
const projects = await getProjectsWithSessions();
|
|
res.json(projects);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/create-project',
|
|
asyncHandler(async (req, res) => {
|
|
const requestBody = req.body as Record<string, unknown>;
|
|
const projectPath = typeof requestBody.path === 'string' ? requestBody.path : '';
|
|
const customName = typeof requestBody.customName === 'string' ? requestBody.customName : null;
|
|
|
|
if (requestBody.workspaceType !== undefined) {
|
|
throw new AppError('workspaceType is no longer supported. Use the single create-project flow.', {
|
|
code: 'LEGACY_WORKSPACE_TYPE_UNSUPPORTED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (requestBody.githubUrl || requestBody.githubTokenId || requestBody.newGithubToken) {
|
|
throw new AppError('Repository cloning is not supported on create-project', {
|
|
code: 'CLONE_NOT_SUPPORTED_ON_CREATE_PROJECT',
|
|
statusCode: 400,
|
|
details: 'Use /api/projects/clone-progress for cloning workflows',
|
|
});
|
|
}
|
|
|
|
const projectCreationResult = await createProject({
|
|
projectPath,
|
|
customName,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
project: projectCreationResult.project,
|
|
message:
|
|
projectCreationResult.outcome === 'reactivated_archived'
|
|
? 'Archived project path reused successfully'
|
|
: 'Project created successfully',
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get('/clone-progress', async (req, res) => {
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.flushHeaders();
|
|
|
|
const sendEvent = (type: string, data: Record<string, unknown>) => {
|
|
if (res.writableEnded) {
|
|
return;
|
|
}
|
|
|
|
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
};
|
|
|
|
let cloneOperation: Awaited<ReturnType<typeof startCloneProject>> | null = null;
|
|
const closeListener = () => {
|
|
cloneOperation?.cancel();
|
|
};
|
|
req.on('close', closeListener);
|
|
|
|
try {
|
|
const queryParams = req.query as Record<string, unknown>;
|
|
const workspacePath = readQueryStringValue(queryParams.path);
|
|
const githubUrl = readQueryStringValue(queryParams.githubUrl);
|
|
const githubTokenId = readOptionalNumericQueryValue(queryParams.githubTokenId);
|
|
const newGithubToken = readQueryStringValue(queryParams.newGithubToken) || null;
|
|
|
|
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
|
|
const userId = authenticatedUser?.id;
|
|
if (userId === undefined || userId === null) {
|
|
throw new AppError('Authenticated user is required', {
|
|
code: 'AUTHENTICATION_REQUIRED',
|
|
statusCode: 401,
|
|
});
|
|
}
|
|
|
|
cloneOperation = await startCloneProject(
|
|
{
|
|
workspacePath,
|
|
githubUrl,
|
|
githubTokenId,
|
|
newGithubToken,
|
|
userId,
|
|
},
|
|
{
|
|
onProgress: (message) => {
|
|
sendEvent('progress', { message });
|
|
},
|
|
onComplete: ({ project, message }) => {
|
|
sendEvent('complete', { project, message });
|
|
},
|
|
},
|
|
);
|
|
|
|
await cloneOperation.waitForCompletion;
|
|
} catch (error) {
|
|
sendEvent('error', { message: resolveRouteErrorMessage(error) });
|
|
} finally {
|
|
req.off('close', closeListener);
|
|
if (!res.writableEnded) {
|
|
res.end();
|
|
}
|
|
}
|
|
});
|
|
|
|
router.get(
|
|
'/:projectId/taskmaster',
|
|
asyncHandler(async (req, res) => {
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
|
const taskMasterDetails = await getProjectTaskMaster(projectId);
|
|
res.json(taskMasterDetails);
|
|
}),
|
|
);
|
|
|
|
export default router;
|