mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-03 19:28:38 +00:00
Why
- Legacy installs can have a sessions table shape that predates provider/custom_name columns. Running migrateLegacySessionNames first caused its INSERT OR REPLACE INTO sessions (...) to target columns that may not exist and fail during startup migration.
- Some upgraded databases had projects.project_id as plain TEXT instead of a real PRIMARY KEY. That breaks assumptions used by id-based lookups and can allow invalid/duplicate identity semantics over time.
- projectsDb.createProjectPath inferred outcomes from
ow.isArchived, but the upsert path always returns the post-update row with isArchived=0, so archived-reactivation and fresh-create could be misclassified.
- git clone accepted user-controlled URLs directly in argv position, so inputs beginning with - could be interpreted as options instead of a repository argument.
What
- Added
ebuildProjectsTableWithPrimaryKeySchema in migrations: detect table shape via getTableInfo('projects'), verify project_id has pk=1, and rebuild when missing.
- Rebuild flow now creates a canonical projects__new table (project_id TEXT PRIMARY KEY), copies rows with transformation, backfills empty ids via SQLITE_UUID_SQL, deduplicates conflicting ids/paths, then swaps tables inside a transaction.
- Replaced the prior ddColumnToTableIfNotExists(...) + UPDATE project_id sequence with PK-aware detection/rebuild logic so legacy DBs converge to the required schema.
- Reordered migration sequence to run
ebuildSessionsTableWithProjectSchema before migrateLegacySessionNames, ensuring sessions is normalized before legacy session_names merge writes execute.
- Updated projectsDb.createProjectPath to generate an ttemptedId before insert, pass it into the prepared statement, and classify outcomes by comparing returned
ow.project_id to ttemptedId (created vs
eactivated_archived), with no-row remaining ctive_conflict.
- Hardened clone execution by inserting -- before clone URL in git argv and rejecting normalized GitHub URLs that start with - in startCloneProject.
Tests
- Added integration coverage for projectsDb.createProjectPath branches: fresh insert, archived reactivation, and active conflict.
- Added clone service test for option-prefixed githubUrl rejection (INVALID_GITHUB_URL).
176 lines
6.2 KiB
TypeScript
176 lines
6.2 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
|
|
import { getConnection } from '@/modules/database/connection.js';
|
|
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
|
|
|
|
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
|
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
|
if (trimmedCustomName.length > 0) {
|
|
return trimmedCustomName;
|
|
}
|
|
|
|
const directoryName = path.basename(projectPath);
|
|
return directoryName || projectPath;
|
|
}
|
|
|
|
export const projectsDb = {
|
|
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
|
|
const db = getConnection();
|
|
const normalizedProjectName = normalizeProjectDisplayName(projectPath, customProjectName);
|
|
const attemptedId = randomUUID();
|
|
const row = db.prepare(`
|
|
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
|
|
VALUES (?, ?, ?, 0)
|
|
ON CONFLICT(project_path) DO UPDATE SET
|
|
isArchived = 0
|
|
WHERE projects.isArchived = 1
|
|
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
|
|
`).get(attemptedId, projectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
|
|
|
|
if (row) {
|
|
return {
|
|
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
|
|
project: row,
|
|
};
|
|
}
|
|
|
|
const existingProject = projectsDb.getProjectPath(projectPath);
|
|
return {
|
|
outcome: 'active_conflict',
|
|
project: existingProject,
|
|
};
|
|
},
|
|
|
|
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(projectPath) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
getProjectById(projectId: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
/**
|
|
* Resolve the absolute project directory from a database project_id.
|
|
*
|
|
* This is the canonical lookup used after the projectName → projectId migration:
|
|
* API routes receive the DB-assigned `projectId` and must resolve the real folder
|
|
* path through this helper before touching the filesystem. Returns `null` when the
|
|
* project row does not exist so callers can respond with a 404.
|
|
*/
|
|
getProjectPathById(projectId: string): string | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_path
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
|
|
|
|
return row?.project_path ?? null;
|
|
},
|
|
|
|
getProjectPaths(): ProjectRepositoryRow[] {
|
|
const db = getConnection();
|
|
return db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE isArchived = 0
|
|
`).all() as ProjectRepositoryRow[];
|
|
},
|
|
|
|
getCustomProjectName(projectPath: string): string | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT custom_project_name
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(projectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
|
|
|
|
return row?.custom_project_name ?? null;
|
|
},
|
|
|
|
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
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);
|
|
},
|
|
|
|
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET custom_project_name = ?
|
|
WHERE project_id = ?
|
|
`).run(customProjectName, projectId);
|
|
},
|
|
|
|
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_path = ?
|
|
`).run(isStarred ? 1 : 0, projectPath);
|
|
},
|
|
|
|
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_id = ?
|
|
`).run(isStarred ? 1 : 0, projectId);
|
|
},
|
|
|
|
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_path = ?
|
|
`).run(isArchived ? 1 : 0, projectPath);
|
|
},
|
|
|
|
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_id = ?
|
|
`).run(isArchived ? 1 : 0, projectId);
|
|
},
|
|
|
|
deleteProjectPath(projectPath: string): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_path = ?
|
|
`).run(projectPath);
|
|
},
|
|
|
|
deleteProjectById(projectId: string): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_id = ?
|
|
`).run(projectId);
|
|
},
|
|
};
|