mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 10:18:37 +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).
322 lines
9.4 KiB
TypeScript
322 lines
9.4 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { access, mkdir, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { githubTokensDb } from '@/modules/database/index.js';
|
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
|
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
|
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
|
|
|
type CloneProjectInput = {
|
|
workspacePath: string;
|
|
githubUrl: string;
|
|
githubTokenId?: number | null;
|
|
newGithubToken?: string | null;
|
|
userId: number | string;
|
|
};
|
|
|
|
type CloneCompletePayload = {
|
|
project: Record<string, unknown>;
|
|
message: string;
|
|
};
|
|
|
|
type CloneProjectEventHandlers = {
|
|
onProgress: (message: string) => void;
|
|
onComplete: (payload: CloneCompletePayload) => void;
|
|
};
|
|
|
|
type GitCloneProcess = {
|
|
stdout: NodeJS.ReadableStream | null;
|
|
stderr: NodeJS.ReadableStream | null;
|
|
on(event: 'close', listener: (code: number | null) => void): void;
|
|
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): void;
|
|
kill(): void;
|
|
};
|
|
|
|
type CloneProjectDependencies = {
|
|
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
|
|
ensureDirectory: (directoryPath: string) => Promise<void>;
|
|
pathExists: (targetPath: string) => Promise<boolean>;
|
|
removePath: (targetPath: string) => Promise<void>;
|
|
getGithubTokenById: (
|
|
tokenId: number,
|
|
userId: number,
|
|
) => Promise<{ github_token: string } | null>;
|
|
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
|
|
registerProject: (projectPath: string, customName: string) => Promise<{ project: Record<string, unknown> }>;
|
|
logError: (message: string, error: unknown) => void;
|
|
};
|
|
|
|
export type CloneProjectOperation = {
|
|
waitForCompletion: Promise<void>;
|
|
cancel: () => void;
|
|
};
|
|
|
|
async function defaultPathExists(targetPath: string): Promise<boolean> {
|
|
try {
|
|
await access(targetPath);
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function sanitizeGitError(message: string, token: string | null): string {
|
|
if (!message || !token) {
|
|
return message;
|
|
}
|
|
|
|
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return message.replace(new RegExp(escapedToken, 'g'), '***');
|
|
}
|
|
|
|
function resolveCloneFailureMessage(lastError: string, sanitizedError: string): string {
|
|
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
|
return 'Authentication failed. Please check your credentials.';
|
|
}
|
|
|
|
if (lastError.includes('Repository not found')) {
|
|
return 'Repository not found. Please check the URL and ensure you have access.';
|
|
}
|
|
|
|
if (lastError.includes('already exists')) {
|
|
return 'Directory already exists';
|
|
}
|
|
|
|
if (sanitizedError) {
|
|
return sanitizedError;
|
|
}
|
|
|
|
return 'Git clone failed';
|
|
}
|
|
|
|
function resolveErrorMessage(error: unknown): string {
|
|
if (error instanceof AppError) {
|
|
return error.message;
|
|
}
|
|
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return 'Unexpected error';
|
|
}
|
|
|
|
const defaultDependencies: CloneProjectDependencies = {
|
|
validatePath: validateWorkspacePath,
|
|
ensureDirectory: async (directoryPath: string): Promise<void> => {
|
|
await mkdir(directoryPath, { recursive: true });
|
|
},
|
|
pathExists: defaultPathExists,
|
|
removePath: async (targetPath: string): Promise<void> => {
|
|
await rm(targetPath, { recursive: true, force: true });
|
|
},
|
|
getGithubTokenById: async (
|
|
tokenId: number,
|
|
userId: number,
|
|
): Promise<{ github_token: string } | null> => {
|
|
const tokenRow = githubTokensDb.getGithubTokenById(userId, tokenId) as
|
|
| { github_token: string }
|
|
| null;
|
|
return tokenRow;
|
|
},
|
|
spawnGitClone: (cloneUrl: string, clonePath: string): GitCloneProcess =>
|
|
spawn('git', ['clone', '--progress', '--', cloneUrl, clonePath], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
},
|
|
}) as unknown as GitCloneProcess,
|
|
registerProject: async (
|
|
projectPath: string,
|
|
customName: string,
|
|
): Promise<{ project: Record<string, unknown> }> =>
|
|
createProject({
|
|
projectPath,
|
|
customName,
|
|
}) as Promise<{ project: Record<string, unknown> }>,
|
|
logError: (message: string, error: unknown): void => {
|
|
console.error(message, error);
|
|
},
|
|
};
|
|
|
|
export async function startCloneProject(
|
|
input: CloneProjectInput,
|
|
handlers: CloneProjectEventHandlers,
|
|
dependencies: CloneProjectDependencies = defaultDependencies,
|
|
): Promise<CloneProjectOperation> {
|
|
const normalizedWorkspacePath = input.workspacePath.trim();
|
|
const normalizedGithubUrl = input.githubUrl.trim();
|
|
|
|
if (!normalizedWorkspacePath) {
|
|
throw new AppError('workspacePath and githubUrl are required', {
|
|
code: 'WORKSPACE_PATH_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (!normalizedGithubUrl) {
|
|
throw new AppError('workspacePath and githubUrl are required', {
|
|
code: 'GITHUB_URL_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (normalizedGithubUrl.startsWith('-')) {
|
|
throw new AppError('Invalid githubUrl', {
|
|
code: 'INVALID_GITHUB_URL',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
|
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
throw new AppError(pathValidation.error || 'Invalid workspace path', {
|
|
code: 'INVALID_PROJECT_PATH',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const absolutePath = pathValidation.resolvedPath;
|
|
await dependencies.ensureDirectory(absolutePath);
|
|
|
|
let githubToken: string | null = null;
|
|
if (typeof input.githubTokenId === 'number') {
|
|
const numericUserId =
|
|
typeof input.userId === 'number' ? input.userId : Number.parseInt(String(input.userId), 10);
|
|
if (Number.isNaN(numericUserId)) {
|
|
throw new AppError('Authenticated user is required', {
|
|
code: 'AUTHENTICATION_REQUIRED',
|
|
statusCode: 401,
|
|
});
|
|
}
|
|
|
|
const token = await dependencies.getGithubTokenById(input.githubTokenId, numericUserId);
|
|
if (!token) {
|
|
throw new AppError('GitHub token not found', {
|
|
code: 'GITHUB_TOKEN_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
githubToken = token.github_token;
|
|
} else if (input.newGithubToken && input.newGithubToken.trim().length > 0) {
|
|
githubToken = input.newGithubToken.trim();
|
|
}
|
|
|
|
const sanitizedGithubUrl = normalizedGithubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
const repoName = sanitizedGithubUrl.split('/').pop() || 'repository';
|
|
const clonePath = path.join(absolutePath, repoName);
|
|
|
|
if (await dependencies.pathExists(clonePath)) {
|
|
throw new AppError(
|
|
`Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.`,
|
|
{
|
|
code: 'CLONE_TARGET_ALREADY_EXISTS',
|
|
statusCode: 409,
|
|
},
|
|
);
|
|
}
|
|
|
|
let cloneUrl = normalizedGithubUrl;
|
|
if (githubToken) {
|
|
try {
|
|
const url = new URL(normalizedGithubUrl);
|
|
url.username = githubToken;
|
|
url.password = '';
|
|
cloneUrl = url.toString();
|
|
} catch {
|
|
// SSH URLs cannot be represented by URL constructor and are used as-is.
|
|
}
|
|
}
|
|
|
|
handlers.onProgress(`Cloning into '${repoName}'...`);
|
|
const gitProcess = dependencies.spawnGitClone(cloneUrl, clonePath);
|
|
let lastError = '';
|
|
|
|
gitProcess.stdout?.on('data', (data: Buffer | string) => {
|
|
const message = data.toString().trim();
|
|
if (message) {
|
|
handlers.onProgress(message);
|
|
}
|
|
});
|
|
|
|
gitProcess.stderr?.on('data', (data: Buffer | string) => {
|
|
const message = data.toString().trim();
|
|
lastError = message;
|
|
if (message) {
|
|
handlers.onProgress(message);
|
|
}
|
|
});
|
|
|
|
const waitForCompletion = new Promise<void>((resolve, reject) => {
|
|
gitProcess.on('close', async (code) => {
|
|
if (code === 0) {
|
|
try {
|
|
const createdProject = await dependencies.registerProject(clonePath, repoName);
|
|
handlers.onComplete({
|
|
project: createdProject.project,
|
|
message: 'Repository cloned successfully',
|
|
});
|
|
resolve();
|
|
} catch (error) {
|
|
reject(
|
|
new AppError(`Clone succeeded but failed to add project: ${resolveErrorMessage(error)}`, {
|
|
code: 'CLONE_PROJECT_REGISTRATION_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
|
const errorMessage = resolveCloneFailureMessage(lastError, sanitizedError);
|
|
|
|
try {
|
|
await dependencies.removePath(clonePath);
|
|
} catch (cleanupError) {
|
|
dependencies.logError('Failed to clean up after clone failure:', cleanupError);
|
|
}
|
|
|
|
reject(
|
|
new AppError(errorMessage, {
|
|
code: 'GIT_CLONE_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
});
|
|
|
|
gitProcess.on('error', (error) => {
|
|
if (error.code === 'ENOENT') {
|
|
reject(
|
|
new AppError('Git is not installed or not in PATH', {
|
|
code: 'GIT_NOT_FOUND',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
reject(
|
|
new AppError(error.message, {
|
|
code: 'GIT_EXECUTION_FAILED',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
return {
|
|
waitForCompletion,
|
|
cancel: () => {
|
|
gitProcess.kill();
|
|
},
|
|
};
|
|
}
|