mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 09:21:33 +00:00
refactor: normalize project paths across database and service modules
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "Konfiguration überprüfen",
|
||||
"workspaceType": "Arbeitsbereichstyp:",
|
||||
"existingWorkspace": "Vorhandener Arbeitsbereich",
|
||||
"newWorkspace": "Neuer Arbeitsbereich",
|
||||
"path": "Pfad:",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "Review Your Configuration",
|
||||
"workspaceType": "Workspace Type:",
|
||||
"existingWorkspace": "Existing Workspace",
|
||||
"newWorkspace": "New Workspace",
|
||||
"path": "Path:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "設定の確認",
|
||||
"workspaceType": "ワークスペースの種類:",
|
||||
"existingWorkspace": "既存のワークスペース",
|
||||
"newWorkspace": "新しいワークスペース",
|
||||
"path": "パス:",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "설정 검토",
|
||||
"workspaceType": "워크스페이스 유형:",
|
||||
"existingWorkspace": "기존 워크스페이스",
|
||||
"newWorkspace": "새 워크스페이스",
|
||||
"path": "경로:",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "Проверьте вашу конфигурацию",
|
||||
"workspaceType": "Тип рабочего пространства:",
|
||||
"existingWorkspace": "Существующее рабочее пространство",
|
||||
"newWorkspace": "Новое рабочее пространство",
|
||||
"path": "Путь:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -176,7 +176,6 @@
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "查看您的配置",
|
||||
"workspaceType": "工作区类型:",
|
||||
"existingWorkspace": "现有工作区",
|
||||
"newWorkspace": "新建工作区",
|
||||
"path": "路径:",
|
||||
|
||||
Reference in New Issue
Block a user