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.
315 lines
9.2 KiB
TypeScript
315 lines
9.2 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { access, mkdir, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { githubTokensDb } from '@/modules/database/index.js';
|
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
|
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
|
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
|
|
|
type CloneProjectInput = {
|
|
workspacePath: string;
|
|
githubUrl: string;
|
|
githubTokenId?: number | null;
|
|
newGithubToken?: string | null;
|
|
userId: number | string;
|
|
};
|
|
|
|
type CloneCompletePayload = {
|
|
project: Record<string, unknown>;
|
|
message: string;
|
|
};
|
|
|
|
type CloneProjectEventHandlers = {
|
|
onProgress: (message: string) => void;
|
|
onComplete: (payload: CloneCompletePayload) => void;
|
|
};
|
|
|
|
type GitCloneProcess = {
|
|
stdout: NodeJS.ReadableStream | null;
|
|
stderr: NodeJS.ReadableStream | null;
|
|
on(event: 'close', listener: (code: number | null) => void): void;
|
|
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): void;
|
|
kill(): void;
|
|
};
|
|
|
|
type CloneProjectDependencies = {
|
|
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
|
|
ensureDirectory: (directoryPath: string) => Promise<void>;
|
|
pathExists: (targetPath: string) => Promise<boolean>;
|
|
removePath: (targetPath: string) => Promise<void>;
|
|
getGithubTokenById: (
|
|
tokenId: number,
|
|
userId: number,
|
|
) => Promise<{ github_token: string } | null>;
|
|
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
|
|
registerProject: (projectPath: string, customName: string) => Promise<{ project: Record<string, unknown> }>;
|
|
logError: (message: string, error: unknown) => void;
|
|
};
|
|
|
|
export type CloneProjectOperation = {
|
|
waitForCompletion: Promise<void>;
|
|
cancel: () => void;
|
|
};
|
|
|
|
async function defaultPathExists(targetPath: string): Promise<boolean> {
|
|
try {
|
|
await access(targetPath);
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function sanitizeGitError(message: string, token: string | null): string {
|
|
if (!message || !token) {
|
|
return message;
|
|
}
|
|
|
|
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return message.replace(new RegExp(escapedToken, 'g'), '***');
|
|
}
|
|
|
|
function resolveCloneFailureMessage(lastError: string, sanitizedError: string): string {
|
|
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
|
return 'Authentication failed. Please check your credentials.';
|
|
}
|
|
|
|
if (lastError.includes('Repository not found')) {
|
|
return 'Repository not found. Please check the URL and ensure you have access.';
|
|
}
|
|
|
|
if (lastError.includes('already exists')) {
|
|
return 'Directory already exists';
|
|
}
|
|
|
|
if (sanitizedError) {
|
|
return sanitizedError;
|
|
}
|
|
|
|
return 'Git clone failed';
|
|
}
|
|
|
|
function resolveErrorMessage(error: unknown): string {
|
|
if (error instanceof AppError) {
|
|
return error.message;
|
|
}
|
|
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return 'Unexpected error';
|
|
}
|
|
|
|
const defaultDependencies: CloneProjectDependencies = {
|
|
validatePath: validateWorkspacePath,
|
|
ensureDirectory: async (directoryPath: string): Promise<void> => {
|
|
await mkdir(directoryPath, { recursive: true });
|
|
},
|
|
pathExists: defaultPathExists,
|
|
removePath: async (targetPath: string): Promise<void> => {
|
|
await rm(targetPath, { recursive: true, force: true });
|
|
},
|
|
getGithubTokenById: async (
|
|
tokenId: number,
|
|
userId: number,
|
|
): Promise<{ github_token: string } | null> => {
|
|
const tokenRow = githubTokensDb.getGithubTokenById(userId, tokenId) as
|
|
| { github_token: string }
|
|
| null;
|
|
return tokenRow;
|
|
},
|
|
spawnGitClone: (cloneUrl: string, clonePath: string): GitCloneProcess =>
|
|
spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
},
|
|
}) as unknown as GitCloneProcess,
|
|
registerProject: async (
|
|
projectPath: string,
|
|
customName: string,
|
|
): Promise<{ project: Record<string, unknown> }> =>
|
|
createProject({
|
|
projectPath,
|
|
customName,
|
|
}) as Promise<{ project: Record<string, unknown> }>,
|
|
logError: (message: string, error: unknown): void => {
|
|
console.error(message, error);
|
|
},
|
|
};
|
|
|
|
export async function startCloneProject(
|
|
input: CloneProjectInput,
|
|
handlers: CloneProjectEventHandlers,
|
|
dependencies: CloneProjectDependencies = defaultDependencies,
|
|
): Promise<CloneProjectOperation> {
|
|
const normalizedWorkspacePath = input.workspacePath.trim();
|
|
const normalizedGithubUrl = input.githubUrl.trim();
|
|
|
|
if (!normalizedWorkspacePath) {
|
|
throw new AppError('workspacePath and githubUrl are required', {
|
|
code: 'WORKSPACE_PATH_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (!normalizedGithubUrl) {
|
|
throw new AppError('workspacePath and githubUrl are required', {
|
|
code: 'GITHUB_URL_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
|
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
throw new AppError(pathValidation.error || 'Invalid workspace path', {
|
|
code: 'INVALID_PROJECT_PATH',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const absolutePath = pathValidation.resolvedPath;
|
|
await dependencies.ensureDirectory(absolutePath);
|
|
|
|
let githubToken: string | null = null;
|
|
if (typeof input.githubTokenId === 'number') {
|
|
const numericUserId =
|
|
typeof input.userId === 'number' ? input.userId : Number.parseInt(String(input.userId), 10);
|
|
if (Number.isNaN(numericUserId)) {
|
|
throw new AppError('Authenticated user is required', {
|
|
code: 'AUTHENTICATION_REQUIRED',
|
|
statusCode: 401,
|
|
});
|
|
}
|
|
|
|
const token = await dependencies.getGithubTokenById(input.githubTokenId, numericUserId);
|
|
if (!token) {
|
|
throw new AppError('GitHub token not found', {
|
|
code: 'GITHUB_TOKEN_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
githubToken = token.github_token;
|
|
} else if (input.newGithubToken && input.newGithubToken.trim().length > 0) {
|
|
githubToken = input.newGithubToken.trim();
|
|
}
|
|
|
|
const sanitizedGithubUrl = normalizedGithubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
const repoName = sanitizedGithubUrl.split('/').pop() || 'repository';
|
|
const clonePath = path.join(absolutePath, repoName);
|
|
|
|
if (await dependencies.pathExists(clonePath)) {
|
|
throw new AppError(
|
|
`Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.`,
|
|
{
|
|
code: 'CLONE_TARGET_ALREADY_EXISTS',
|
|
statusCode: 409,
|
|
},
|
|
);
|
|
}
|
|
|
|
let cloneUrl = normalizedGithubUrl;
|
|
if (githubToken) {
|
|
try {
|
|
const url = new URL(normalizedGithubUrl);
|
|
url.username = githubToken;
|
|
url.password = '';
|
|
cloneUrl = url.toString();
|
|
} catch {
|
|
// SSH URLs cannot be represented by URL constructor and are used as-is.
|
|
}
|
|
}
|
|
|
|
handlers.onProgress(`Cloning into '${repoName}'...`);
|
|
const gitProcess = dependencies.spawnGitClone(cloneUrl, clonePath);
|
|
let lastError = '';
|
|
|
|
gitProcess.stdout?.on('data', (data: Buffer | string) => {
|
|
const message = data.toString().trim();
|
|
if (message) {
|
|
handlers.onProgress(message);
|
|
}
|
|
});
|
|
|
|
gitProcess.stderr?.on('data', (data: Buffer | string) => {
|
|
const message = data.toString().trim();
|
|
lastError = message;
|
|
if (message) {
|
|
handlers.onProgress(message);
|
|
}
|
|
});
|
|
|
|
const waitForCompletion = new Promise<void>((resolve, reject) => {
|
|
gitProcess.on('close', async (code) => {
|
|
if (code === 0) {
|
|
try {
|
|
const createdProject = await dependencies.registerProject(clonePath, repoName);
|
|
handlers.onComplete({
|
|
project: createdProject.project,
|
|
message: 'Repository cloned successfully',
|
|
});
|
|
resolve();
|
|
} catch (error) {
|
|
reject(
|
|
new AppError(`Clone succeeded but failed to add project: ${resolveErrorMessage(error)}`, {
|
|
code: 'CLONE_PROJECT_REGISTRATION_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
|
const errorMessage = resolveCloneFailureMessage(lastError, sanitizedError);
|
|
|
|
try {
|
|
await dependencies.removePath(clonePath);
|
|
} catch (cleanupError) {
|
|
dependencies.logError('Failed to clean up after clone failure:', cleanupError);
|
|
}
|
|
|
|
reject(
|
|
new AppError(errorMessage, {
|
|
code: 'GIT_CLONE_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
});
|
|
|
|
gitProcess.on('error', (error) => {
|
|
if (error.code === 'ENOENT') {
|
|
reject(
|
|
new AppError('Git is not installed or not in PATH', {
|
|
code: 'GIT_NOT_FOUND',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
reject(
|
|
new AppError(error.message, {
|
|
code: 'GIT_EXECUTION_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
return {
|
|
waitForCompletion,
|
|
cancel: () => {
|
|
gitProcess.kill();
|
|
},
|
|
};
|
|
}
|