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:
Haileyesus
2026-04-25 19:36:47 +03:00
parent 7023a8cf7b
commit bb86236520
29 changed files with 1639 additions and 857 deletions

View File

@@ -1,48 +1,64 @@
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';
type ProjectRow = {
project_id: string;
project_path: string;
custom_project_name: string | null;
isStarred: number;
isArchived: number;
};
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): void {
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
const db = getConnection();
db.prepare(`
INSERT INTO projects (project_id, project_path, custom_project_name)
VALUES (?, ?, ?)
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
custom_project_name = CASE
WHEN projects.custom_project_name IS NULL OR projects.custom_project_name = ''
THEN excluded.custom_project_name
ELSE projects.custom_project_name
END
`).run(randomUUID(), projectPath, customProjectName);
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): ProjectRow | null {
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 ProjectRow | undefined;
`).get(projectPath) as ProjectRepositoryRow | undefined;
return row ?? null;
},
getProjectById(projectId: string): ProjectRow | 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 ProjectRow | undefined;
`).get(projectId) as ProjectRepositoryRow | undefined;
return row ?? null;
},
@@ -61,17 +77,18 @@ export const projectsDb = {
SELECT project_path
FROM projects
WHERE project_id = ?
`).get(projectId) as Pick<ProjectRow, 'project_path'> | undefined;
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
return row?.project_path ?? null;
},
getProjectPaths(): ProjectRow[] {
getProjectPaths(): ProjectRepositoryRow[] {
const db = getConnection();
return db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
`).all() as ProjectRow[];
WHERE isArchived = 0
`).all() as ProjectRepositoryRow[];
},
getCustomProjectName(projectPath: string): string | null {
@@ -80,7 +97,7 @@ export const projectsDb = {
SELECT custom_project_name
FROM projects
WHERE project_path = ?
`).get(projectPath) as Pick<ProjectRow, 'custom_project_name'> | undefined;
`).get(projectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
return row?.custom_project_name ?? null;
},