mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-17 17:35:01 +00:00
refactor: modularize project services, and wizard create/clone flow
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.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb } from '@/modules/database/index.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type TaskMasterTask = {
|
||||
status?: string;
|
||||
subtasks?: Array<{
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type TaskMasterMetadata =
|
||||
| {
|
||||
taskCount: number;
|
||||
subtaskCount: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
review: number;
|
||||
completionPercentage: number;
|
||||
lastModified: string;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
type TaskMasterDetectionResult = {
|
||||
hasTaskmaster: boolean;
|
||||
hasEssentialFiles?: boolean;
|
||||
files?: Record<string, boolean>;
|
||||
metadata?: TaskMasterMetadata;
|
||||
path?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type NormalizedTaskMasterInfo = {
|
||||
hasTaskmaster: boolean;
|
||||
hasEssentialFiles: boolean;
|
||||
metadata: TaskMasterMetadata;
|
||||
status: 'configured' | 'not-configured';
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterByIdResult = {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
taskmaster: NormalizedTaskMasterInfo;
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterDependencies = {
|
||||
resolveProjectPathById: (projectId: string) => string | null;
|
||||
detectTaskMasterFolder: (projectPath: string) => Promise<TaskMasterDetectionResult>;
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterResolver = (projectId: string) => Promise<GetProjectTaskMasterByIdResult | null>;
|
||||
|
||||
function extractTasksFromJson(tasksData: unknown): TaskMasterTask[] {
|
||||
if (!tasksData || typeof tasksData !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const legacyTasks = (tasksData as { tasks?: unknown }).tasks;
|
||||
if (Array.isArray(legacyTasks)) {
|
||||
return legacyTasks as TaskMasterTask[];
|
||||
}
|
||||
|
||||
const taggedTaskCollections: TaskMasterTask[] = [];
|
||||
for (const tagValue of Object.values(tasksData)) {
|
||||
if (!tagValue || typeof tagValue !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagTasks = (tagValue as { tasks?: unknown }).tasks;
|
||||
if (Array.isArray(tagTasks)) {
|
||||
taggedTaskCollections.push(...(tagTasks as TaskMasterTask[]));
|
||||
}
|
||||
}
|
||||
|
||||
return taggedTaskCollections;
|
||||
}
|
||||
|
||||
async function detectTaskMasterFolder(projectPath: string): Promise<TaskMasterDetectionResult> {
|
||||
try {
|
||||
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||
|
||||
try {
|
||||
const taskMasterStats = await stat(taskMasterPath);
|
||||
if (!taskMasterStats.isDirectory()) {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster exists but is not a directory',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const fileError = error as NodeJS.ErrnoException;
|
||||
if (fileError.code === 'ENOENT') {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster directory not found',
|
||||
};
|
||||
}
|
||||
|
||||
throw fileError;
|
||||
}
|
||||
|
||||
const keyFiles = ['tasks/tasks.json', 'config.json'];
|
||||
const fileStatus: Record<string, boolean> = {};
|
||||
let hasEssentialFiles = true;
|
||||
|
||||
for (const fileName of keyFiles) {
|
||||
const absoluteFilePath = path.join(taskMasterPath, fileName);
|
||||
try {
|
||||
await access(absoluteFilePath);
|
||||
fileStatus[fileName] = true;
|
||||
} catch {
|
||||
fileStatus[fileName] = false;
|
||||
if (fileName === 'tasks/tasks.json') {
|
||||
hasEssentialFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let taskMetadata: TaskMasterMetadata = null;
|
||||
if (fileStatus['tasks/tasks.json']) {
|
||||
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
||||
try {
|
||||
const tasksContent = await readFile(tasksPath, 'utf8');
|
||||
const parsedTasksJson = JSON.parse(tasksContent) as unknown;
|
||||
const tasks = extractTasksFromJson(parsedTasksJson);
|
||||
|
||||
const stats = tasks.reduce(
|
||||
(accumulator, currentTask) => {
|
||||
accumulator.total += 1;
|
||||
const normalizedTaskStatus = currentTask.status || 'pending';
|
||||
accumulator.byStatus[normalizedTaskStatus] = (accumulator.byStatus[normalizedTaskStatus] || 0) + 1;
|
||||
|
||||
if (Array.isArray(currentTask.subtasks)) {
|
||||
for (const subtask of currentTask.subtasks) {
|
||||
accumulator.subtotalTasks += 1;
|
||||
const normalizedSubtaskStatus = subtask.status || 'pending';
|
||||
accumulator.subtaskByStatus[normalizedSubtaskStatus] =
|
||||
(accumulator.subtaskByStatus[normalizedSubtaskStatus] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
subtotalTasks: 0,
|
||||
byStatus: {} as Record<string, number>,
|
||||
subtaskByStatus: {} as Record<string, number>,
|
||||
},
|
||||
);
|
||||
|
||||
const tasksStat = await stat(tasksPath);
|
||||
taskMetadata = {
|
||||
taskCount: stats.total,
|
||||
subtaskCount: stats.subtotalTasks,
|
||||
completed: stats.byStatus.done || 0,
|
||||
pending: stats.byStatus.pending || 0,
|
||||
inProgress: stats.byStatus['in-progress'] || 0,
|
||||
review: stats.byStatus.review || 0,
|
||||
completionPercentage: stats.total > 0 ? Math.round(((stats.byStatus.done || 0) / stats.total) * 100) : 0,
|
||||
lastModified: tasksStat.mtime.toISOString(),
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse tasks.json:', (parseError as Error).message);
|
||||
taskMetadata = {
|
||||
error: 'Failed to parse tasks.json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles,
|
||||
files: fileStatus,
|
||||
metadata: taskMetadata,
|
||||
path: taskMasterPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error detecting TaskMaster folder:', error);
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: `Error checking directory: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaskMasterInfo(taskMasterResult: TaskMasterDetectionResult | null = null): NormalizedTaskMasterInfo {
|
||||
const hasTaskmaster = Boolean(taskMasterResult?.hasTaskmaster);
|
||||
const hasEssentialFiles = Boolean(taskMasterResult?.hasEssentialFiles);
|
||||
|
||||
return {
|
||||
hasTaskmaster,
|
||||
hasEssentialFiles,
|
||||
metadata: taskMasterResult?.metadata ?? null,
|
||||
status: hasTaskmaster && hasEssentialFiles ? 'configured' : 'not-configured',
|
||||
};
|
||||
}
|
||||
|
||||
const defaultDependencies: GetProjectTaskMasterDependencies = {
|
||||
resolveProjectPathById: (projectId: string): string | null => projectsDb.getProjectPathById(projectId),
|
||||
detectTaskMasterFolder,
|
||||
};
|
||||
|
||||
export async function getProjectTaskMasterById(
|
||||
projectId: string,
|
||||
dependencies: GetProjectTaskMasterDependencies = defaultDependencies,
|
||||
): Promise<GetProjectTaskMasterByIdResult | null> {
|
||||
const projectPath = dependencies.resolveProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskMasterResult = await dependencies.detectTaskMasterFolder(projectPath);
|
||||
return {
|
||||
projectId,
|
||||
projectPath,
|
||||
taskmaster: normalizeTaskMasterInfo(taskMasterResult),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProjectTaskMaster(
|
||||
projectId: string,
|
||||
resolveById: GetProjectTaskMasterResolver = getProjectTaskMasterById,
|
||||
): Promise<GetProjectTaskMasterByIdResult> {
|
||||
const normalizedProjectId = projectId.trim();
|
||||
if (!normalizedProjectId) {
|
||||
throw new AppError('projectId is required', {
|
||||
code: 'PROJECT_ID_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const taskMasterDetails = await resolveById(normalizedProjectId);
|
||||
if (!taskMasterDetails) {
|
||||
throw new AppError('Project not found', {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return taskMasterDetails;
|
||||
}
|
||||
Reference in New Issue
Block a user