Files
claudecodeui/src/components/project-creation-wizard/data/workspaceApi.ts
Haileyesus bb86236520 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.
2026-04-25 19:36:47 +03:00

187 lines
5.2 KiB
TypeScript

import { api } from '../../../utils/api';
import type {
BrowseFilesystemResponse,
CloneProgressEvent,
CreateFolderResponse,
CreateProjectPayload,
CreateProjectResponse,
CredentialsResponse,
FolderSuggestion,
TokenMode,
} from '../types';
type CloneWorkspaceParams = {
workspacePath: string;
githubUrl: string;
tokenMode: TokenMode;
selectedGithubToken: string;
newGithubToken: string;
};
type CloneProgressHandlers = {
onProgress: (message: string) => void;
};
const parseJson = async <T>(response: Response): Promise<T> => {
const data = (await response.json()) as T;
return data;
};
const resolveCreateProjectErrorMessage = (responseData: CreateProjectResponse): string | null => {
if (typeof responseData.details === 'string' && responseData.details.trim().length > 0) {
return responseData.details;
}
if (typeof responseData.error === 'string' && responseData.error.trim().length > 0) {
return responseData.error;
}
if (responseData.error && typeof responseData.error === 'object') {
const errorObject = responseData.error as { message?: unknown; details?: unknown };
if (typeof errorObject.details === 'string' && errorObject.details.trim().length > 0) {
return errorObject.details;
}
if (typeof errorObject.message === 'string' && errorObject.message.trim().length > 0) {
return errorObject.message;
}
if (
errorObject.details
&& typeof errorObject.details === 'object'
&& typeof (errorObject.details as { projectPath?: unknown }).projectPath === 'string'
) {
return `Project path already exists: ${(errorObject.details as { projectPath: string }).projectPath}`;
}
}
if (typeof responseData.message === 'string' && responseData.message.trim().length > 0) {
return responseData.message;
}
return null;
};
export const fetchGithubTokenCredentials = async () => {
const response = await api.get('/settings/credentials?type=github_token');
const data = await parseJson<CredentialsResponse>(response);
if (!response.ok) {
throw new Error(data.error || 'Failed to load GitHub tokens');
}
return (data.credentials || []).filter((credential) => credential.is_active);
};
export const browseFilesystemFolders = async (pathToBrowse: string) => {
const endpoint = `/browse-filesystem?path=${encodeURIComponent(pathToBrowse)}`;
const response = await api.get(endpoint);
const data = await parseJson<BrowseFilesystemResponse>(response);
if (!response.ok) {
throw new Error(data.error || 'Failed to browse filesystem');
}
return {
path: data.path || pathToBrowse,
suggestions: (data.suggestions || []) as FolderSuggestion[],
};
};
export const createFolderInFilesystem = async (folderPath: string) => {
const response = await api.createFolder(folderPath);
const data = await parseJson<CreateFolderResponse>(response);
if (!response.ok) {
throw new Error(data.error || 'Failed to create folder');
}
return data.path || folderPath;
};
export const createProjectRequest = async (payload: CreateProjectPayload) => {
const response = await api.createProject(payload);
const data = await parseJson<CreateProjectResponse>(response);
if (!response.ok) {
throw new Error(resolveCreateProjectErrorMessage(data) || 'Failed to create project');
}
return data.project;
};
const buildCloneProgressQuery = ({
workspacePath,
githubUrl,
tokenMode,
selectedGithubToken,
newGithubToken,
}: CloneWorkspaceParams) => {
const query = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
query.set('githubTokenId', selectedGithubToken);
}
if (tokenMode === 'new' && newGithubToken.trim()) {
query.set('newGithubToken', newGithubToken.trim());
}
// EventSource cannot send custom headers, so the auth token is passed as query.
const authToken = localStorage.getItem('auth-token');
if (authToken) {
query.set('token', authToken);
}
return query.toString();
};
export const cloneWorkspaceWithProgress = (
params: CloneWorkspaceParams,
handlers: CloneProgressHandlers,
) =>
new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
const query = buildCloneProgressQuery(params);
const eventSource = new EventSource(`/api/projects/clone-progress?${query}`);
let settled = false;
const settle = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
eventSource.close();
callback();
};
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as CloneProgressEvent;
if (payload.type === 'progress' && payload.message) {
handlers.onProgress(payload.message);
return;
}
if (payload.type === 'complete') {
settle(() => resolve(payload.project));
return;
}
if (payload.type === 'error') {
settle(() => reject(new Error(payload.message || 'Failed to clone repository')));
}
} catch (error) {
console.error('Error parsing clone progress event:', error);
}
};
eventSource.onerror = () => {
settle(() => reject(new Error('Connection lost during clone')));
};
});