From f175d20c4ea6a985c89bc949947d160332868b5a Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:02:33 +0300 Subject: [PATCH] refactor: normalize project paths across database and service modules --- .../database/repositories/projects.db.ts | 26 ++++-- .../database/repositories/sessions.db.ts | 42 +++------ .../services/project-management.service.ts | 6 +- server/routes/agent.js | 5 +- server/shared/utils.ts | 86 +++++++++++++++++-- src/i18n/locales/de/common.json | 1 - src/i18n/locales/en/common.json | 1 - src/i18n/locales/it/common.json | 1 - src/i18n/locales/ja/common.json | 1 - src/i18n/locales/ko/common.json | 1 - src/i18n/locales/ru/common.json | 1 - src/i18n/locales/tr/common.json | 1 - src/i18n/locales/zh-CN/common.json | 1 - 13 files changed, 113 insertions(+), 60 deletions(-) diff --git a/server/modules/database/repositories/projects.db.ts b/server/modules/database/repositories/projects.db.ts index 17eff396..c99b8a54 100644 --- a/server/modules/database/repositories/projects.db.ts +++ b/server/modules/database/repositories/projects.db.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { getConnection } from '@/modules/database/connection.js'; import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js'; +import { normalizeProjectPath } from '@/shared/utils.js'; function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string { const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : ''; @@ -17,7 +18,8 @@ function normalizeProjectDisplayName(projectPath: string, customProjectName: str export const projectsDb = { createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult { const db = getConnection(); - const normalizedProjectName = normalizeProjectDisplayName(projectPath, customProjectName); + const normalizedProjectPath = normalizeProjectPath(projectPath); + const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName); const attemptedId = randomUUID(); const row = db.prepare(` INSERT INTO projects (project_id, project_path, custom_project_name, isArchived) @@ -26,7 +28,7 @@ export const projectsDb = { isArchived = 0 WHERE projects.isArchived = 1 RETURNING project_id, project_path, custom_project_name, isStarred, isArchived - `).get(attemptedId, projectPath, normalizedProjectName) as ProjectRepositoryRow | undefined; + `).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined; if (row) { return { @@ -35,7 +37,7 @@ export const projectsDb = { }; } - const existingProject = projectsDb.getProjectPath(projectPath); + const existingProject = projectsDb.getProjectPath(normalizedProjectPath); return { outcome: 'active_conflict', project: existingProject, @@ -44,11 +46,12 @@ export const projectsDb = { getProjectPath(projectPath: string): ProjectRepositoryRow | null { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); const row = db.prepare(` SELECT project_id, project_path, custom_project_name, isStarred, isArchived FROM projects WHERE project_path = ? - `).get(projectPath) as ProjectRepositoryRow | undefined; + `).get(normalizedProjectPath) as ProjectRepositoryRow | undefined; return row ?? null; }, @@ -94,22 +97,24 @@ export const projectsDb = { getCustomProjectName(projectPath: string): string | null { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); const row = db.prepare(` SELECT custom_project_name FROM projects WHERE project_path = ? - `).get(projectPath) as Pick | undefined; + `).get(normalizedProjectPath) as Pick | undefined; return row?.custom_project_name ?? null; }, updateCustomProjectName(projectPath: string, customProjectName: string | null): void { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); db.prepare(` INSERT INTO projects (project_id, project_path, custom_project_name) VALUES (?, ?, ?) ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name - `).run(randomUUID(), projectPath, customProjectName); + `).run(randomUUID(), normalizedProjectPath, customProjectName); }, updateCustomProjectNameById(projectId: string, customProjectName: string | null): void { @@ -123,11 +128,12 @@ export const projectsDb = { updateProjectIsStarred(projectPath: string, isStarred: boolean): void { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); db.prepare(` UPDATE projects SET isStarred = ? WHERE project_path = ? - `).run(isStarred ? 1 : 0, projectPath); + `).run(isStarred ? 1 : 0, normalizedProjectPath); }, updateProjectIsStarredById(projectId: string, isStarred: boolean): void { @@ -141,11 +147,12 @@ export const projectsDb = { updateProjectIsArchived(projectPath: string, isArchived: boolean): void { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); db.prepare(` UPDATE projects SET isArchived = ? WHERE project_path = ? - `).run(isArchived ? 1 : 0, projectPath); + `).run(isArchived ? 1 : 0, normalizedProjectPath); }, updateProjectIsArchivedById(projectId: string, isArchived: boolean): void { @@ -159,10 +166,11 @@ export const projectsDb = { deleteProjectPath(projectPath: string): void { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); db.prepare(` DELETE FROM projects WHERE project_path = ? - `).run(projectPath); + `).run(normalizedProjectPath); }, deleteProjectById(projectId: string): void { diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 0ef6fd05..967d2d59 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -1,7 +1,6 @@ -import path from 'node:path'; - import { getConnection } from '@/modules/database/connection.js'; import { projectsDb } from '@/modules/database/repositories/projects.db.js'; +import { normalizeProjectPath } from '@/shared/utils.js'; type SessionRow = { session_id: string; @@ -29,32 +28,9 @@ function normalizeTimestamp(value?: string): string | null { return parsed.toISOString(); } -function normalizeCodexProjectPath(projectPath: string): string { - const trimmedPath = projectPath.trim(); - if (!trimmedPath) { - return projectPath; - } - - if (process.platform !== 'win32') { - return path.normalize(trimmedPath); - } - - let strippedPath = trimmedPath; - if (strippedPath.startsWith('\\\\?\\UNC\\')) { - strippedPath = `\\\\${strippedPath.slice('\\\\?\\UNC\\'.length)}`; - } else if (strippedPath.startsWith('\\\\?\\')) { - strippedPath = strippedPath.slice('\\\\?\\'.length); - } - - return path.win32.normalize(strippedPath); -} - function normalizeProjectPathForProvider(provider: string, projectPath: string): string { - if (provider !== 'codex') { - return projectPath; - } - - return normalizeCodexProjectPath(projectPath); + void provider; + return normalizeProjectPath(projectPath); } export const sessionsDb = { @@ -142,17 +118,19 @@ export const sessionsDb = { getSessionsByProjectPath(projectPath: string): SessionRow[] { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at FROM sessions WHERE project_path = ?` ) - .all(projectPath) as SessionRow[]; + .all(normalizedProjectPath) as SessionRow[]; }, getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at @@ -161,25 +139,27 @@ export const sessionsDb = { ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC LIMIT ? OFFSET ?` ) - .all(projectPath, limit, offset) as SessionRow[]; + .all(normalizedProjectPath, limit, offset) as SessionRow[]; }, countSessionsByProjectPath(projectPath: string): number { const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); const row = db .prepare( `SELECT COUNT(*) AS count FROM sessions WHERE project_path = ?` ) - .get(projectPath) as { count: number } | undefined; + .get(normalizedProjectPath) as { count: number } | undefined; return Number(row?.count ?? 0); }, deleteSessionsByProjectPath(projectPath: string): void { const db = getConnection(); - db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(projectPath); + const normalizedProjectPath = normalizeProjectPath(projectPath); + db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath); }, getSessionName(sessionId: string, provider: string): string | null { diff --git a/server/modules/projects/services/project-management.service.ts b/server/modules/projects/services/project-management.service.ts index 4362c487..9dbe857e 100644 --- a/server/modules/projects/services/project-management.service.ts +++ b/server/modules/projects/services/project-management.service.ts @@ -7,7 +7,7 @@ import type { ProjectRepositoryRow, WorkspacePathValidationResult, } from '@/shared/types.js'; -import { AppError, validateWorkspacePath } from '@/shared/utils.js'; +import { AppError, normalizeProjectPath, validateWorkspacePath } from '@/shared/utils.js'; type CreateProjectInput = { projectPath: string; @@ -95,7 +95,7 @@ export async function createProject( input: CreateProjectInput, dependencies: CreateProjectDependencies = defaultDependencies, ): Promise { - const normalizedPath = (input.projectPath || '').trim(); + const normalizedPath = normalizeProjectPath(input.projectPath || ''); if (!normalizedPath) { throw new AppError('path is required', { code: 'PROJECT_PATH_REQUIRED', @@ -112,7 +112,7 @@ export async function createProject( }); } - const resolvedProjectPath = pathValidation.resolvedPath; + const resolvedProjectPath = normalizeProjectPath(pathValidation.resolvedPath); await dependencies.ensureWorkspaceDirectory(resolvedProjectPath); const normalizedCustomName = resolveDisplayName(input.customName ?? null, resolvedProjectPath); diff --git a/server/routes/agent.js b/server/routes/agent.js index 33fb951f..37a9ed26 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -12,6 +12,7 @@ import { spawnGemini } from '../gemini-cli.js'; import { Octokit } from '@octokit/rest'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { IS_PLATFORM } from '../constants/config.js'; +import { normalizeProjectPath } from '../shared/utils.js'; const router = express.Router(); @@ -889,7 +890,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath); } else { // Use existing project path - finalProjectPath = path.resolve(projectPath); + finalProjectPath = normalizeProjectPath(path.resolve(projectPath)); // Verify the path exists try { @@ -899,6 +900,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { } } + finalProjectPath = normalizeProjectPath(finalProjectPath); + // Register project path in DB (or reuse existing active registration) const registrationResult = projectsDb.createProjectPath(finalProjectPath, null); if (registrationResult.outcome === 'active_conflict') { diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 6af7ca40..84a382c3 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -138,6 +138,64 @@ export const FORBIDDEN_WORKSPACE_PATHS = [ 'C:\\$Recycle.Bin', ]; +function stripWindowsLongPathPrefix(inputPath: string): string { + if (inputPath.startsWith('\\\\?\\UNC\\')) { + return `\\\\${inputPath.slice('\\\\?\\UNC\\'.length)}`; + } + + if (inputPath.startsWith('\\\\?\\')) { + return inputPath.slice('\\\\?\\'.length); + } + + return inputPath; +} + +function shouldUseWindowsPathNormalization(inputPath: string): boolean { + if (process.platform === 'win32') { + return true; + } + + return inputPath.startsWith('\\\\') || /^[a-zA-Z]:([\\/]|$)/.test(inputPath); +} + +/** + * Canonicalizes project/workspace paths for stable DB keys and comparisons. + * + * Normalization rules: + * - trim whitespace + * - strip Windows long-path prefixes (`\\?\` and `\\?\UNC\`) + * - normalize path separators and dot segments + * - trim trailing separators except for filesystem roots + */ +export function normalizeProjectPath(inputPath: string): string { + if (typeof inputPath !== 'string') { + return ''; + } + + const trimmed = inputPath.trim(); + if (!trimmed) { + return ''; + } + + const withoutLongPrefix = stripWindowsLongPathPrefix(trimmed); + const useWindowsPathRules = shouldUseWindowsPathNormalization(withoutLongPrefix); + const normalized = useWindowsPathRules + ? path.win32.normalize(withoutLongPrefix) + : path.posix.normalize(withoutLongPrefix); + + if (!normalized) { + return ''; + } + + const parser = useWindowsPathRules ? path.win32 : path.posix; + const root = parser.parse(normalized).root; + if (normalized === root) { + return normalized; + } + + return normalized.replace(/[\\/]+$/, ''); +} + /** * Validates that a user-supplied workspace path is safe to use. * @@ -147,8 +205,16 @@ export const FORBIDDEN_WORKSPACE_PATHS = [ */ export async function validateWorkspacePath(requestedPath: string): Promise { try { - const absolutePath = path.resolve(requestedPath); - const normalizedPath = path.normalize(absolutePath); + const normalizedRequestedPath = normalizeProjectPath(requestedPath); + if (!normalizedRequestedPath) { + return { + valid: false, + error: 'Workspace path is required', + }; + } + + const absolutePath = path.resolve(normalizedRequestedPath); + const normalizedPath = normalizeProjectPath(absolutePath); if (FORBIDDEN_WORKSPACE_PATHS.includes(normalizedPath) || normalizedPath === '/') { return { @@ -158,10 +224,14 @@ export async function validateWorkspacePath(requestedPath: string): Promise