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.
249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
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;
|
|
}
|