mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 14:08:19 +00:00
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:
160
server/modules/projects/tests/project-clone.service.test.ts
Normal file
160
server/modules/projects/tests/project-clone.service.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type TestDependencies = Parameters<typeof startCloneProject>[2];
|
||||
|
||||
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
|
||||
return {
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
|
||||
ensureDirectory: async () => undefined,
|
||||
pathExists: async () => false,
|
||||
removePath: async () => undefined,
|
||||
getGithubTokenById: async () => ({ github_token: 'token-value' }),
|
||||
spawnGitClone: () => {
|
||||
throw new Error('spawnGitClone should be overridden in this test');
|
||||
},
|
||||
registerProject: async () => ({ project: { projectId: 'project-1' } }),
|
||||
logError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockGitProcess() {
|
||||
const emitter = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
emitter.stdout = new PassThrough();
|
||||
emitter.stderr = new PassThrough();
|
||||
emitter.kill = () => {
|
||||
emitter.emit('close', null);
|
||||
};
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
test('startCloneProject rejects when workspace path is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '',
|
||||
githubUrl: 'https://github.com/example/repo',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies(),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject rejects when github URL is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: '',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies(),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject rejects when selected github token does not exist', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: 'https://github.com/example/repo',
|
||||
githubTokenId: 12,
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies({
|
||||
getGithubTokenById: async () => null,
|
||||
}),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
|
||||
const gitProcess = createMockGitProcess();
|
||||
const progressMessages: string[] = [];
|
||||
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
|
||||
let capturedProjectPath = '';
|
||||
let capturedCustomName = '';
|
||||
|
||||
const operation = await startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: 'https://github.com/example/repo.git',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: (message) => {
|
||||
progressMessages.push(message);
|
||||
},
|
||||
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
|
||||
completePayload = payload;
|
||||
},
|
||||
},
|
||||
buildDependencies({
|
||||
spawnGitClone: () => gitProcess as any,
|
||||
registerProject: async (projectPath, customName) => {
|
||||
capturedProjectPath = projectPath;
|
||||
capturedCustomName = customName;
|
||||
return { project: { projectId: 'project-1', path: projectPath } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
gitProcess.emit('close', 0);
|
||||
await operation.waitForCompletion;
|
||||
|
||||
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
|
||||
assert.equal(capturedCustomName, 'repo');
|
||||
assert.equal(path.basename(capturedProjectPath), 'repo');
|
||||
assert.notEqual(completePayload, null);
|
||||
const resolvedCompletePayload = completePayload as unknown as {
|
||||
project: Record<string, unknown>;
|
||||
message: string;
|
||||
};
|
||||
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
|
||||
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
|
||||
});
|
||||
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const projectRow = {
|
||||
project_id: 'project-1',
|
||||
project_path: '/workspace/my-project',
|
||||
custom_project_name: 'my-project',
|
||||
isStarred: 0,
|
||||
isArchived: 0,
|
||||
};
|
||||
|
||||
test('createProject throws when project path is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () => createProject({ projectPath: '' }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
|
||||
assert.equal(error.statusCode, 400);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject throws when path validation fails', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
createProject(
|
||||
{ projectPath: '/invalid/path' },
|
||||
{
|
||||
validatePath: async () => ({ valid: false, error: 'blocked path' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'INVALID_PROJECT_PATH');
|
||||
assert.equal(error.statusCode, 400);
|
||||
assert.equal(error.details, 'blocked path');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject throws conflict when active project path already exists', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
createProject(
|
||||
{ projectPath: '/workspace/my-project' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({ outcome: 'active_conflict', project: projectRow }),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_ALREADY_EXISTS');
|
||||
assert.equal(error.statusCode, 409);
|
||||
assert.equal(error.details, 'Project path already exists: /workspace/my-project');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject falls back to directory name when custom name is not provided', async () => {
|
||||
let capturedCustomName: string | null = null;
|
||||
|
||||
const result = await createProject(
|
||||
{ projectPath: '/workspace/my-project', customName: '' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: (_projectPath, customName) => {
|
||||
capturedCustomName = customName;
|
||||
return {
|
||||
outcome: 'created',
|
||||
project: {
|
||||
...projectRow,
|
||||
custom_project_name: customName,
|
||||
},
|
||||
};
|
||||
},
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(capturedCustomName, 'my-project');
|
||||
assert.equal(result.outcome, 'created');
|
||||
assert.equal(result.project.displayName, 'my-project');
|
||||
});
|
||||
|
||||
test('createProject returns archived reuse outcome when archived row is reused', async () => {
|
||||
const result = await createProject(
|
||||
{ projectPath: '/workspace/my-project' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({
|
||||
outcome: 'reactivated_archived',
|
||||
project: {
|
||||
...projectRow,
|
||||
isArchived: 1,
|
||||
},
|
||||
}),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.outcome, 'reactivated_archived');
|
||||
assert.equal(result.project.isArchived, true);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getProjectTaskMaster,
|
||||
getProjectTaskMasterById,
|
||||
} from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
test('getProjectTaskMasterById returns null when project path is missing', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => null,
|
||||
detectTaskMasterFolder: async () => {
|
||||
throw new Error('detectTaskMasterFolder should not be called when path is missing');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('getProjectTaskMasterById returns configured status when taskmaster exists with essential files', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => '/workspace/project-1',
|
||||
detectTaskMasterFolder: async () => ({
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles: true,
|
||||
metadata: {
|
||||
taskCount: 3,
|
||||
subtaskCount: 0,
|
||||
completed: 1,
|
||||
pending: 2,
|
||||
inProgress: 0,
|
||||
review: 0,
|
||||
completionPercentage: 33,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.projectId, 'project-1');
|
||||
assert.equal(result.projectPath, '/workspace/project-1');
|
||||
assert.equal(result.taskmaster.hasTaskmaster, true);
|
||||
assert.equal(result.taskmaster.hasEssentialFiles, true);
|
||||
assert.equal(result.taskmaster.status, 'configured');
|
||||
assert.deepEqual(result.taskmaster.metadata, {
|
||||
taskCount: 3,
|
||||
subtaskCount: 0,
|
||||
completed: 1,
|
||||
pending: 2,
|
||||
inProgress: 0,
|
||||
review: 0,
|
||||
completionPercentage: 33,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('getProjectTaskMasterById returns not-configured status when taskmaster is missing', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => '/workspace/project-1',
|
||||
detectTaskMasterFolder: async () => ({
|
||||
hasTaskmaster: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.taskmaster.hasTaskmaster, false);
|
||||
assert.equal(result.taskmaster.hasEssentialFiles, false);
|
||||
assert.equal(result.taskmaster.status, 'not-configured');
|
||||
assert.equal(result.taskmaster.metadata, null);
|
||||
});
|
||||
|
||||
test('getProjectTaskMaster throws when project id is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
getProjectTaskMaster('', async () => ({
|
||||
projectId: 'project-1',
|
||||
projectPath: '/workspace/project-1',
|
||||
taskmaster: {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles: true,
|
||||
metadata: null,
|
||||
status: 'configured',
|
||||
},
|
||||
})),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_ID_REQUIRED');
|
||||
assert.equal(error.statusCode, 400);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('getProjectTaskMaster throws when project does not exist', async () => {
|
||||
await assert.rejects(
|
||||
async () => getProjectTaskMaster('project-that-does-not-exist', async () => null),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_NOT_FOUND');
|
||||
assert.equal(error.statusCode, 404);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createProjectsSnapshot,
|
||||
} from '@/modules/projects/index.js';
|
||||
import { ProjectListItem, ProjectsSnapshot } from '@/modules/projects/services/projects.service.js';
|
||||
import { ProjectListItem, ProjectsSnapshot } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
||||
|
||||
test('createProjectsSnapshot returns an object matching the predefined snapshot type', () => {
|
||||
const projects: ProjectListItem[] = [
|
||||
|
||||
Reference in New Issue
Block a user