mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +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.
175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
|
|
import { getConnection } from '@/modules/database/connection.js';
|
|
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
|
|
|
|
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
|
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
|
if (trimmedCustomName.length > 0) {
|
|
return trimmedCustomName;
|
|
}
|
|
|
|
const directoryName = path.basename(projectPath);
|
|
return directoryName || projectPath;
|
|
}
|
|
|
|
export const projectsDb = {
|
|
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
|
|
const db = getConnection();
|
|
const normalizedProjectName = normalizeProjectDisplayName(projectPath, customProjectName);
|
|
const row = db.prepare(`
|
|
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
|
|
VALUES (?, ?, ?, 0)
|
|
ON CONFLICT(project_path) DO UPDATE SET
|
|
isArchived = 0
|
|
WHERE projects.isArchived = 1
|
|
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
|
|
`).get(randomUUID(), projectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
|
|
|
|
if (row) {
|
|
return {
|
|
outcome: row.isArchived === 1 ? 'reactivated_archived' : 'created',
|
|
project: row,
|
|
};
|
|
}
|
|
|
|
const existingProject = projectsDb.getProjectPath(projectPath);
|
|
return {
|
|
outcome: 'active_conflict',
|
|
project: existingProject,
|
|
};
|
|
},
|
|
|
|
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(projectPath) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
getProjectById(projectId: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
/**
|
|
* Resolve the absolute project directory from a database project_id.
|
|
*
|
|
* This is the canonical lookup used after the projectName → projectId migration:
|
|
* API routes receive the DB-assigned `projectId` and must resolve the real folder
|
|
* path through this helper before touching the filesystem. Returns `null` when the
|
|
* project row does not exist so callers can respond with a 404.
|
|
*/
|
|
getProjectPathById(projectId: string): string | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_path
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
|
|
|
|
return row?.project_path ?? null;
|
|
},
|
|
|
|
getProjectPaths(): ProjectRepositoryRow[] {
|
|
const db = getConnection();
|
|
return db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE isArchived = 0
|
|
`).all() as ProjectRepositoryRow[];
|
|
},
|
|
|
|
getCustomProjectName(projectPath: string): string | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT custom_project_name
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(projectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
|
|
|
|
return row?.custom_project_name ?? null;
|
|
},
|
|
|
|
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
INSERT INTO projects (project_id, project_path, custom_project_name)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
|
|
`).run(randomUUID(), projectPath, customProjectName);
|
|
},
|
|
|
|
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET custom_project_name = ?
|
|
WHERE project_id = ?
|
|
`).run(customProjectName, projectId);
|
|
},
|
|
|
|
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_path = ?
|
|
`).run(isStarred ? 1 : 0, projectPath);
|
|
},
|
|
|
|
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_id = ?
|
|
`).run(isStarred ? 1 : 0, projectId);
|
|
},
|
|
|
|
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_path = ?
|
|
`).run(isArchived ? 1 : 0, projectPath);
|
|
},
|
|
|
|
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_id = ?
|
|
`).run(isArchived ? 1 : 0, projectId);
|
|
},
|
|
|
|
deleteProjectPath(projectPath: string): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_path = ?
|
|
`).run(projectPath);
|
|
},
|
|
|
|
deleteProjectById(projectId: string): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_id = ?
|
|
`).run(projectId);
|
|
},
|
|
};
|