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.
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { projectsDb } from '@/modules/database/index.js';
|
|
import type {
|
|
CreateProjectPathResult,
|
|
ProjectRepositoryRow,
|
|
WorkspacePathValidationResult,
|
|
} from '@/shared/types.js';
|
|
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
|
|
|
type CreateProjectInput = {
|
|
projectPath: string;
|
|
customName?: string | null;
|
|
};
|
|
|
|
type CreateProjectDependencies = {
|
|
validatePath: (projectPath: string) => Promise<WorkspacePathValidationResult>;
|
|
ensureWorkspaceDirectory: (projectPath: string) => Promise<void>;
|
|
persistProjectPath: (projectPath: string, customName: string | null) => CreateProjectPathResult;
|
|
getProjectByPath: (projectPath: string) => ProjectRepositoryRow | null;
|
|
};
|
|
|
|
type ProjectApiView = {
|
|
projectId: string;
|
|
path: string;
|
|
fullPath: string;
|
|
displayName: string;
|
|
customName: string | null;
|
|
isArchived: boolean;
|
|
isStarred: boolean;
|
|
sessions: [];
|
|
cursorSessions: [];
|
|
codexSessions: [];
|
|
geminiSessions: [];
|
|
sessionMeta: {
|
|
hasMore: false;
|
|
total: 0;
|
|
};
|
|
};
|
|
|
|
type CreateProjectServiceResult = {
|
|
outcome: 'created' | 'reactivated_archived';
|
|
project: ProjectApiView;
|
|
};
|
|
|
|
const defaultDependencies: CreateProjectDependencies = {
|
|
validatePath: validateWorkspacePath,
|
|
ensureWorkspaceDirectory: async (projectPath: string): Promise<void> => {
|
|
await fs.mkdir(projectPath, { recursive: true });
|
|
const directoryStats = await fs.stat(projectPath);
|
|
if (!directoryStats.isDirectory()) {
|
|
throw new AppError('Path exists but is not a directory', {
|
|
code: 'PROJECT_PATH_NOT_DIRECTORY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
},
|
|
persistProjectPath: (projectPath: string, customName: string | null): CreateProjectPathResult =>
|
|
projectsDb.createProjectPath(projectPath, customName),
|
|
getProjectByPath: (projectPath: string): ProjectRepositoryRow | null =>
|
|
projectsDb.getProjectPath(projectPath),
|
|
};
|
|
|
|
function resolveDisplayName(customName: string | null | undefined, projectPath: string): string {
|
|
const trimmedCustomName = typeof customName === 'string' ? customName.trim() : '';
|
|
if (trimmedCustomName.length > 0) {
|
|
return trimmedCustomName;
|
|
}
|
|
|
|
return path.basename(projectPath) || projectPath;
|
|
}
|
|
|
|
function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiView {
|
|
return {
|
|
projectId: projectRow.project_id,
|
|
path: projectRow.project_path,
|
|
fullPath: projectRow.project_path,
|
|
displayName: resolveDisplayName(projectRow.custom_project_name, projectRow.project_path),
|
|
customName: projectRow.custom_project_name,
|
|
isArchived: Boolean(projectRow.isArchived),
|
|
isStarred: Boolean(projectRow.isStarred),
|
|
sessions: [],
|
|
cursorSessions: [],
|
|
codexSessions: [],
|
|
geminiSessions: [],
|
|
sessionMeta: {
|
|
hasMore: false,
|
|
total: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function createProject(
|
|
input: CreateProjectInput,
|
|
dependencies: CreateProjectDependencies = defaultDependencies,
|
|
): Promise<CreateProjectServiceResult> {
|
|
const normalizedPath = (input.projectPath || '').trim();
|
|
if (!normalizedPath) {
|
|
throw new AppError('path is required', {
|
|
code: 'PROJECT_PATH_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const pathValidation = await dependencies.validatePath(normalizedPath);
|
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
throw new AppError('Invalid project path', {
|
|
code: 'INVALID_PROJECT_PATH',
|
|
statusCode: 400,
|
|
details: pathValidation.error ?? 'Path validation failed',
|
|
});
|
|
}
|
|
|
|
const resolvedProjectPath = pathValidation.resolvedPath;
|
|
await dependencies.ensureWorkspaceDirectory(resolvedProjectPath);
|
|
|
|
const normalizedCustomName = resolveDisplayName(input.customName ?? null, resolvedProjectPath);
|
|
const persistedProject = dependencies.persistProjectPath(resolvedProjectPath, normalizedCustomName);
|
|
|
|
if (persistedProject.outcome === 'active_conflict') {
|
|
throw new AppError('Project path already exists and is active', {
|
|
code: 'PROJECT_ALREADY_EXISTS',
|
|
statusCode: 409,
|
|
details: `Project path already exists: ${resolvedProjectPath}`,
|
|
});
|
|
}
|
|
|
|
const projectRow = persistedProject.project ?? dependencies.getProjectByPath(resolvedProjectPath);
|
|
if (!projectRow) {
|
|
throw new AppError('Failed to resolve project after creation', {
|
|
code: 'PROJECT_CREATE_FAILED',
|
|
statusCode: 500,
|
|
});
|
|
}
|
|
|
|
// Archived rows intentionally remain archived when reused, as requested.
|
|
return {
|
|
outcome: persistedProject.outcome,
|
|
project: mapProjectRowToApiView(projectRow),
|
|
};
|
|
}
|