refactor: normalize project paths across database and service modules

This commit is contained in:
Haileyesus
2026-04-29 18:02:33 +03:00
parent 0f93ef2781
commit f175d20c4e
13 changed files with 113 additions and 60 deletions

View File

@@ -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<ProjectRepositoryRow, 'custom_project_name'> | undefined;
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | 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 {

View File

@@ -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 {

View File

@@ -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<CreateProjectServiceResult> {
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);

View File

@@ -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') {

View File

@@ -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<WorkspacePathValidationResult> {
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<Work
}
for (const forbiddenPath of FORBIDDEN_WORKSPACE_PATHS) {
if (normalizedPath === forbiddenPath || normalizedPath.startsWith(`${forbiddenPath}${path.sep}`)) {
const normalizedForbiddenPath = normalizeProjectPath(forbiddenPath);
if (
normalizedPath === normalizedForbiddenPath
|| normalizedPath.startsWith(`${normalizedForbiddenPath}${path.sep}`)
) {
// Allow specific user-writable folders under /var.
if (
forbiddenPath === '/var'
normalizedForbiddenPath === '/var'
&& (normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))
) {
continue;
@@ -174,10 +244,10 @@ export async function validateWorkspacePath(requestedPath: string): Promise<Work
}
}
let resolvedPath = absolutePath;
let resolvedPath = normalizeProjectPath(absolutePath);
try {
await access(absolutePath);
resolvedPath = await realpath(absolutePath);
resolvedPath = normalizeProjectPath(await realpath(absolutePath));
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== 'ENOENT') {
@@ -187,7 +257,7 @@ export async function validateWorkspacePath(requestedPath: string): Promise<Work
const parentPath = path.dirname(absolutePath);
try {
const parentRealPath = await realpath(parentPath);
resolvedPath = path.join(parentRealPath, path.basename(absolutePath));
resolvedPath = normalizeProjectPath(path.join(parentRealPath, path.basename(absolutePath)));
} catch (parentError) {
const parentFileError = parentError as NodeJS.ErrnoException;
if (parentFileError.code !== 'ENOENT') {
@@ -196,7 +266,7 @@ export async function validateWorkspacePath(requestedPath: string): Promise<Work
}
}
const resolvedWorkspaceRoot = await realpath(WORKSPACES_ROOT);
const resolvedWorkspaceRoot = normalizeProjectPath(await realpath(WORKSPACES_ROOT));
if (
!resolvedPath.startsWith(`${resolvedWorkspaceRoot}${path.sep}`)
&& resolvedPath !== resolvedWorkspaceRoot

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "Konfiguration überprüfen",
"workspaceType": "Arbeitsbereichstyp:",
"existingWorkspace": "Vorhandener Arbeitsbereich",
"newWorkspace": "Neuer Arbeitsbereich",
"path": "Pfad:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "Review Your Configuration",
"workspaceType": "Workspace Type:",
"existingWorkspace": "Existing Workspace",
"newWorkspace": "New Workspace",
"path": "Path:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "Rivedi la tua configurazione",
"workspaceType": "Tipo area di lavoro:",
"existingWorkspace": "Area di lavoro esistente",
"newWorkspace": "Nuova area di lavoro",
"path": "Percorso:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "設定の確認",
"workspaceType": "ワークスペースの種類:",
"existingWorkspace": "既存のワークスペース",
"newWorkspace": "新しいワークスペース",
"path": "パス:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "설정 검토",
"workspaceType": "워크스페이스 유형:",
"existingWorkspace": "기존 워크스페이스",
"newWorkspace": "새 워크스페이스",
"path": "경로:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "Проверьте вашу конфигурацию",
"workspaceType": "Тип рабочего пространства:",
"existingWorkspace": "Существующее рабочее пространство",
"newWorkspace": "Новое рабочее пространство",
"path": "Путь:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "Yapılandırmanı Gözden Geçir",
"workspaceType": "Çalışma Alanı Türü:",
"existingWorkspace": "Mevcut Çalışma Alanı",
"newWorkspace": "Yeni Çalışma Alanı",
"path": "Yol:",

View File

@@ -176,7 +176,6 @@
},
"step3": {
"reviewConfig": "查看您的配置",
"workspaceType": "工作区类型:",
"existingWorkspace": "现有工作区",
"newWorkspace": "新建工作区",
"path": "路径:",