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,6 +1,17 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import {
access,
lstat,
mkdir,
readFile,
readdir,
readlink,
realpath,
stat,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
@@ -11,6 +22,7 @@ import type {
ApiSuccessShape,
AppErrorOptions,
NormalizedMessage,
WorkspacePathValidationResult,
} from '@/shared/types.js';
//----------------- NORMALIZED MESSAGE HELPER INPUT TYPES ------------
@@ -83,6 +95,154 @@ export class AppError extends Error {
}
}
// ---------------------------
//----------------- WORKSPACE PATH VALIDATION UTILITIES ------------
/**
* Root directory that all workspace/project paths must stay under.
*
* This is resolved from `WORKSPACES_ROOT` when configured; otherwise it falls
* back to the current user's home directory.
*/
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
/**
* System-critical paths that must never be used as workspace roots.
*
* The validation helper blocks these values directly and also blocks paths
* nested under them (with explicit allow-list exceptions where necessary).
*/
export const FORBIDDEN_WORKSPACE_PATHS = [
// Unix
'/',
'/etc',
'/bin',
'/sbin',
'/usr',
'/dev',
'/proc',
'/sys',
'/var',
'/boot',
'/root',
'/lib',
'/lib64',
'/opt',
'/tmp',
'/run',
// Windows
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'C:\\System Volume Information',
'C:\\$Recycle.Bin',
];
/**
* Validates that a user-supplied workspace path is safe to use.
*
* Call this before any filesystem mutation that creates or registers projects.
* The function resolves symlinks, enforces `WORKSPACES_ROOT` containment, and
* blocks known system directories.
*/
export async function validateWorkspacePath(requestedPath: string): Promise<WorkspacePathValidationResult> {
try {
const absolutePath = path.resolve(requestedPath);
const normalizedPath = path.normalize(absolutePath);
if (FORBIDDEN_WORKSPACE_PATHS.includes(normalizedPath) || normalizedPath === '/') {
return {
valid: false,
error: 'Cannot use system-critical directories as workspace locations',
};
}
for (const forbiddenPath of FORBIDDEN_WORKSPACE_PATHS) {
if (normalizedPath === forbiddenPath || normalizedPath.startsWith(`${forbiddenPath}${path.sep}`)) {
// Allow specific user-writable folders under /var.
if (
forbiddenPath === '/var'
&& (normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))
) {
continue;
}
return {
valid: false,
error: `Cannot create workspace in system directory: ${forbiddenPath}`,
};
}
}
let resolvedPath = absolutePath;
try {
await access(absolutePath);
resolvedPath = await realpath(absolutePath);
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== 'ENOENT') {
throw fileError;
}
const parentPath = path.dirname(absolutePath);
try {
const parentRealPath = await realpath(parentPath);
resolvedPath = path.join(parentRealPath, path.basename(absolutePath));
} catch (parentError) {
const parentFileError = parentError as NodeJS.ErrnoException;
if (parentFileError.code !== 'ENOENT') {
throw parentFileError;
}
}
}
const resolvedWorkspaceRoot = await realpath(WORKSPACES_ROOT);
if (
!resolvedPath.startsWith(`${resolvedWorkspaceRoot}${path.sep}`)
&& resolvedPath !== resolvedWorkspaceRoot
) {
return {
valid: false,
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`,
};
}
try {
await access(absolutePath);
const pathStats = await lstat(absolutePath);
if (pathStats.isSymbolicLink()) {
const symlinkTarget = await readlink(absolutePath);
const resolvedSymlinkPath = path.resolve(path.dirname(absolutePath), symlinkTarget);
const realSymlinkPath = await realpath(resolvedSymlinkPath);
if (
!realSymlinkPath.startsWith(`${resolvedWorkspaceRoot}${path.sep}`)
&& realSymlinkPath !== resolvedWorkspaceRoot
) {
return {
valid: false,
error: 'Symlink target is outside the allowed workspace root',
};
}
}
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== 'ENOENT') {
throw fileError;
}
}
return {
valid: true,
resolvedPath,
};
} catch (error) {
return {
valid: false,
error: `Path validation failed: ${(error as Error).message}`,
};
}
}
// ---------------------------
//----------------- NORMALIZED PROVIDER MESSAGE UTILITIES ------------
/**