Download packages/core/src/config/projectRegistry.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/config/projectRegistry.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/config/projectRegistry.ts
-
curl -L -o projectRegistry.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/config/projectRegistry.ts
13.9 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { randomUUID } from 'node:crypto'; | |
| import * as fs from 'node:fs'; | |
| import * as path from 'node:path'; | |
| import * as os from 'node:os'; | |
| import { lock } from 'proper-lockfile'; | |
| import { z } from 'zod'; | |
| import { debugLogger } from '../utils/debugLogger.js'; | |
| import { isNodeError } from '../utils/errors.js'; | |
| export interface RegistryData { | |
| projects: Record<string, string>; | |
| } | |
| const registryDataSchema = z.object({ | |
| projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)), | |
| }); | |
| const PROJECT_ROOT_FILE = '.project_root'; | |
| const LOCK_TIMEOUT_MS = 10000; | |
| const LOCK_RETRY_DELAY_MS = 100; | |
| /** | |
| * Manages a mapping between absolute project paths and short, human-readable identifiers. | |
| * This helps reduce context bloat and makes temporary directories easier to work with. | |
| */ | |
| export class ProjectRegistry { | |
| private readonly registryPath: string; | |
| private readonly baseDirs: string[]; | |
| private data: RegistryData | undefined; | |
| private initPromise: Promise<void> | undefined; | |
| constructor(registryPath: string, baseDirs: string[] = []) { | |
| this.registryPath = registryPath; | |
| this.baseDirs = baseDirs; | |
| } | |
| /** | |
| * Initializes the registry by loading data from disk. | |
| */ | |
| async initialize(): Promise<void> { | |
| if (this.initPromise) { | |
| return this.initPromise; | |
| } | |
| this.initPromise = (async () => { | |
| if (this.data) { | |
| return; | |
| } | |
| this.data = await this.loadData(); | |
| })(); | |
| return this.initPromise; | |
| } | |
| private async loadData(): Promise<RegistryData> { | |
| try { | |
| const content = await fs.promises.readFile(this.registryPath, 'utf8'); | |
| const parsed: unknown = JSON.parse(content); | |
| if (this.isValidRegistryData(parsed)) { | |
| return parsed; | |
| } | |
| debugLogger.warn( | |
| `Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`, | |
| ); | |
| return { projects: {} }; | |
| } catch (error: unknown) { | |
| if (isNodeError(error) && error.code === 'ENOENT') { | |
| return { projects: {} }; // Normal first run | |
| } | |
| if (error instanceof SyntaxError || error instanceof z.ZodError) { | |
| debugLogger.warn( | |
| 'Failed to load registry (JSON corrupted), resetting to empty: ', | |
| error, | |
| ); | |
| // Ownership markers on disk will allow self-healing when short IDs are requested. | |
| return { projects: {} }; | |
| } | |
| // If it's a real filesystem error (e.g. EACCES permission denied), DO NOT swallow it. | |
| // Swallowing read errors and overwriting the file would permanently destroy user data. | |
| debugLogger.error('Critical failure reading project registry:', error); | |
| throw error; | |
| } | |
| } | |
| private normalizePath(projectPath: string): string { | |
| let resolved = path.resolve(projectPath); | |
| if (os.platform() === 'win32') { | |
| resolved = resolved.toLowerCase(); | |
| } | |
| return resolved; | |
| } | |
| private async save(data: RegistryData): Promise<void> { | |
| const dir = path.dirname(this.registryPath); | |
| if (!fs.existsSync(dir)) { | |
| await fs.promises.mkdir(dir, { recursive: true }); | |
| } | |
| // Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently | |
| const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp'; | |
| let savedSuccessfully = false; | |
| try { | |
| // Unconditionally ensure the directory exists; recursive ignores EEXIST. | |
| await fs.promises.mkdir(dir, { recursive: true }); | |
| const content = JSON.stringify(data, null, 2); | |
| await fs.promises.writeFile(tmpPath, content, 'utf8'); | |
| // Exponential backoff for OS-level file locks (EBUSY/EPERM) during rename | |
| const maxRetries = 5; | |
| for (let attempt = 0; attempt < maxRetries; attempt++) { | |
| try { | |
| await fs.promises.rename(tmpPath, this.registryPath); | |
| savedSuccessfully = true; | |
| break; // Success, exit the retry loop | |
| } catch (error: unknown) { | |
| const code = isNodeError(error) ? error.code : ''; | |
| const isRetryable = code === 'EBUSY' || code === 'EPERM'; | |
| if (!isRetryable || attempt === maxRetries - 1) { | |
| throw error; // Throw immediately on fatal error or final attempt | |
| } | |
| const delayMs = Math.pow(2, attempt) * 50; | |
| debugLogger.debug( | |
| `Rename failed with ${code}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`, | |
| ); | |
| await new Promise((resolve) => setTimeout(resolve, delayMs)); | |
| } | |
| } | |
| } catch (error) { | |
| debugLogger.error( | |
| `Failed to save project registry to ${this.registryPath}:`, | |
| error, | |
| ); | |
| throw error; | |
| } finally { | |
| // Clean up the temporary file if it was left behind (e.g. if writeFile or rename failed) | |
| if (!savedSuccessfully) { | |
| try { | |
| await fs.promises.unlink(tmpPath); | |
| } catch { | |
| // Ignore errors during cleanup | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Returns a short identifier for the given project path. | |
| * If the project is not already in the registry, a new identifier is generated and saved. | |
| */ | |
| async getShortId(projectPath: string): Promise<string> { | |
| if (!this.data) { | |
| throw new Error('ProjectRegistry must be initialized before use'); | |
| } | |
| const normalizedPath = this.normalizePath(projectPath); | |
| // Ensure directory exists so we can create a lock file | |
| const dir = path.dirname(this.registryPath); | |
| if (!fs.existsSync(dir)) { | |
| await fs.promises.mkdir(dir, { recursive: true }); | |
| } | |
| // Ensure the registry file exists so proper-lockfile can lock it. | |
| // If it doesn't exist, we try to create it. If someone else creates it | |
| // between our check and our write, we just continue. | |
| if (!fs.existsSync(this.registryPath)) { | |
| try { | |
| await this.save({ projects: {} }); | |
| } catch (e: unknown) { | |
| if (!fs.existsSync(this.registryPath)) { | |
| throw e; // File still doesn't exist and save failed, this is a real error. | |
| } | |
| // Someone else created it while we were trying to save. Continue to locking. | |
| } | |
| } | |
| // Use proper-lockfile to prevent racy updates | |
| const release = await lock(this.registryPath, { | |
| retries: { | |
| retries: Math.floor(LOCK_TIMEOUT_MS / LOCK_RETRY_DELAY_MS), | |
| minTimeout: LOCK_RETRY_DELAY_MS, | |
| }, | |
| }); | |
| try { | |
| // Re-load data under lock to get the latest state | |
| const currentData = await this.loadData(); | |
| this.data = currentData; | |
| let shortId: string | undefined = currentData.projects[normalizedPath]; | |
| // If we have a mapping, verify it against the folders on disk | |
| if (shortId) { | |
| if (await this.verifySlugOwnership(shortId, normalizedPath)) { | |
| // HEAL: If it passed verification but markers are missing (e.g. new base dir or deleted marker), recreate them. | |
| await this.ensureOwnershipMarkers(shortId, normalizedPath); | |
| return shortId; | |
| } | |
| // If verification fails, it means the registry is out of sync or someone else took it. | |
| // We'll remove the mapping and find/generate a new one. | |
| delete currentData.projects[normalizedPath]; | |
| } | |
| // Try to find if this project already has folders assigned that we didn't know about | |
| shortId = await this.findExistingSlugForPath(normalizedPath); | |
| if (!shortId) { | |
| // Generate a new one | |
| shortId = await this.claimNewSlug(normalizedPath, currentData.projects); | |
| } | |
| currentData.projects[normalizedPath] = shortId; | |
| await this.save(currentData); | |
| return shortId; | |
| } finally { | |
| try { | |
| await release(); | |
| } catch (e) { | |
| // Prevent proper-lockfile errors (e.g. if the lock dir was externally deleted) | |
| // from masking the original error thrown inside the try block. | |
| debugLogger.error('Failed to release project registry lock:', e); | |
| } | |
| } | |
| } | |
| private async verifySlugOwnership( | |
| slug: string, | |
| projectPath: string, | |
| ): Promise<boolean> { | |
| if (this.baseDirs.length === 0) { | |
| return true; // Nothing to verify against | |
| } | |
| for (const baseDir of this.baseDirs) { | |
| const markerPath = path.join(baseDir, slug, PROJECT_ROOT_FILE); | |
| try { | |
| const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim(); | |
| if (this.normalizePath(owner) !== this.normalizePath(projectPath)) { | |
| return false; | |
| } | |
| } catch (e: unknown) { | |
| if (isNodeError(e) && e.code === 'ENOENT') { | |
| // Marker doesn't exist, this is fine, we just won't fail verification | |
| continue; | |
| } | |
| debugLogger.debug(`Failed to read ownership marker ${markerPath}:`, e); | |
| // If we can't read it for other reasons (perms, corrupted), assume not ours. | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private async findExistingSlugForPath( | |
| projectPath: string, | |
| ): Promise<string | undefined> { | |
| if (this.baseDirs.length === 0) { | |
| return undefined; | |
| } | |
| const normalizedTarget = this.normalizePath(projectPath); | |
| // Scan all base dirs to see if any slug already belongs to this project | |
| for (const baseDir of this.baseDirs) { | |
| if (!fs.existsSync(baseDir)) { | |
| continue; | |
| } | |
| try { | |
| const candidates = await fs.promises.readdir(baseDir); | |
| for (const candidate of candidates) { | |
| const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE); | |
| if (fs.existsSync(markerPath)) { | |
| const owner = ( | |
| await fs.promises.readFile(markerPath, 'utf8') | |
| ).trim(); | |
| if (this.normalizePath(owner) === normalizedTarget) { | |
| // Found it! Ensure all base dirs have the marker | |
| await this.ensureOwnershipMarkers(candidate, normalizedTarget); | |
| return candidate; | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e); | |
| } | |
| } | |
| return undefined; | |
| } | |
| private async claimNewSlug( | |
| projectPath: string, | |
| existingMappings: Record<string, string>, | |
| ): Promise<string> { | |
| const baseName = path.basename(projectPath) || 'project'; | |
| const slug = this.slugify(baseName); | |
| let counter = 0; | |
| const existingIds = new Set(Object.values(existingMappings)); | |
| while (true) { | |
| const candidate = counter === 0 ? slug : `${slug}-${counter}`; | |
| counter++; | |
| // Check if taken in registry | |
| if (existingIds.has(candidate)) { | |
| continue; | |
| } | |
| // Check if taken on disk | |
| let diskCollision = false; | |
| for (const baseDir of this.baseDirs) { | |
| const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE); | |
| if (fs.existsSync(markerPath)) { | |
| try { | |
| const owner = ( | |
| await fs.promises.readFile(markerPath, 'utf8') | |
| ).trim(); | |
| if (this.normalizePath(owner) !== this.normalizePath(projectPath)) { | |
| diskCollision = true; | |
| break; | |
| } | |
| } catch { | |
| // If we can't read it, assume it's someone else's to be safe | |
| diskCollision = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (diskCollision) { | |
| continue; | |
| } | |
| // Try to claim it | |
| try { | |
| await this.ensureOwnershipMarkers(candidate, projectPath); | |
| return candidate; | |
| } catch (error: unknown) { | |
| // Only retry if it was a collision (someone else took the slug) | |
| // or a race condition during marker creation. | |
| const code = isNodeError(error) ? error.code : ''; | |
| const isCollision = | |
| code === 'EEXIST' || | |
| (error instanceof Error && | |
| error.message.includes('already owned by')); | |
| if (isCollision) { | |
| debugLogger.debug(`Slug collision for ${candidate}, trying next...`); | |
| continue; | |
| } | |
| // Fatal error (Permission denied, Disk full, etc.) | |
| throw error; | |
| } | |
| } | |
| } | |
| private async ensureOwnershipMarkers( | |
| slug: string, | |
| projectPath: string, | |
| ): Promise<void> { | |
| const normalizedProject = this.normalizePath(projectPath); | |
| for (const baseDir of this.baseDirs) { | |
| const slugDir = path.join(baseDir, slug); | |
| if (!fs.existsSync(slugDir)) { | |
| await fs.promises.mkdir(slugDir, { recursive: true }); | |
| } | |
| const markerPath = path.join(slugDir, PROJECT_ROOT_FILE); | |
| if (fs.existsSync(markerPath)) { | |
| const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim(); | |
| if (this.normalizePath(owner) === normalizedProject) { | |
| continue; | |
| } | |
| // Collision! | |
| const error = Object.assign( | |
| new Error(`Slug ${slug} is already owned by ${owner}`), | |
| { code: 'EEXIST' }, | |
| ); | |
| throw error; | |
| } | |
| // Use flag: 'wx' to ensure atomic creation | |
| try { | |
| await fs.promises.writeFile(markerPath, normalizedProject, { | |
| encoding: 'utf8', | |
| flag: 'wx', | |
| }); | |
| } catch (e: unknown) { | |
| if (isNodeError(e) && e.code === 'EEXIST') { | |
| // Re-verify ownership in case we just lost a race | |
| const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim(); | |
| if (this.normalizePath(owner) === normalizedProject) { | |
| continue; | |
| } | |
| } | |
| throw e; | |
| } | |
| } | |
| } | |
| private slugify(text: string): string { | |
| return ( | |
| text | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]/g, '-') | |
| .replace(/-+/g, '-') | |
| .replace(/^-|-$/g, '') || 'project' | |
| ); | |
| } | |
| private isValidRegistryData(data: unknown): data is RegistryData { | |
| return registryDataSchema.safeParse(data).success; | |
| } | |
| } | |