/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import * as os from 'node:os'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import { GEMINI_DIR, homedir, GOOGLE_ACCOUNTS_FILENAME, isSubpath, resolveToRealPath, normalizePath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const AGENTS_DIR_NAME = '.agents'; export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml'; export class Storage { private readonly targetDir: string; private readonly sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; private customPlansDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; this.sessionId = sessionId; } setCustomPlansDir(dir: string | undefined): void { this.customPlansDir = dir; } static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { return path.join(os.tmpdir(), GEMINI_DIR); } return path.join(homeDir, GEMINI_DIR); } static getGlobalAgentsDir(): string { const homeDir = homedir(); if (!homeDir) { return ''; } return path.join(homeDir, AGENTS_DIR_NAME); } static getMcpOAuthTokensPath(): string { return path.join(Storage.getGlobalGeminiDir(), 'mcp-oauth-tokens.json'); } static getA2AOAuthTokensPath(): string { return path.join(Storage.getGlobalGeminiDir(), 'a2a-oauth-tokens.json'); } static getGlobalSettingsPath(): string { return path.join(Storage.getGlobalGeminiDir(), 'settings.json'); } static getInstallationIdPath(): string { return path.join(Storage.getGlobalGeminiDir(), 'installation_id'); } static getGoogleAccountsPath(): string { return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME); } static getUserCommandsDir(): string { return path.join(Storage.getGlobalGeminiDir(), 'commands'); } static getUserSkillsDir(): string { return path.join(Storage.getGlobalGeminiDir(), 'skills'); } static getUserAgentSkillsDir(): string { return path.join(Storage.getGlobalAgentsDir(), 'skills'); } static getGlobalMemoryFilePath(): string { return path.join(Storage.getGlobalGeminiDir(), 'memory.md'); } static getUserPoliciesDir(): string { return path.join(Storage.getGlobalGeminiDir(), 'policies'); } static getUserKeybindingsPath(): string { return path.join(Storage.getGlobalGeminiDir(), 'keybindings.json'); } static getUserAgentsDir(): string { return path.join(Storage.getGlobalGeminiDir(), 'agents'); } static getAcknowledgedAgentsPath(): string { return path.join( Storage.getGlobalGeminiDir(), 'acknowledgments', 'agents.json', ); } static getPolicyIntegrityStoragePath(): string { return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json'); } private static getSystemConfigDir(): string { if (os.platform() === 'darwin') { return '/Library/Application Support/GeminiCli'; } else if (os.platform() === 'win32') { return 'C:\\ProgramData\\gemini-cli'; } else { return '/etc/gemini-cli'; } } static getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; } return path.join(Storage.getSystemConfigDir(), 'settings.json'); } static getSystemPoliciesDir(): string { return path.join(Storage.getSystemConfigDir(), 'policies'); } static getGlobalTempDir(): string { return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME); } static getGlobalBinDir(): string { return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME); } getGeminiDir(): string { return path.join(this.targetDir, GEMINI_DIR); } /** * Checks if the current workspace storage location is the same as the global/user storage location. * This handles symlinks and platform-specific path normalization. */ isWorkspaceHomeDir(): boolean { return ( normalizePath(resolveToRealPath(this.targetDir)) === normalizePath(resolveToRealPath(homedir())) ); } getAgentsDir(): string { return path.join(this.targetDir, AGENTS_DIR_NAME); } getProjectTempDir(): string { const identifier = this.getProjectIdentifier(); const tempDir = Storage.getGlobalTempDir(); return path.join(tempDir, identifier); } getWorkspacePoliciesDir(): string { return path.join(this.getGeminiDir(), 'policies'); } getWorkspaceAutoSavedPolicyPath(): string { return path.join( this.getWorkspacePoliciesDir(), AUTO_SAVED_POLICY_FILENAME, ); } getAutoSavedPolicyPath(): string { return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME); } ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } static getOAuthCredsPath(): string { return path.join(Storage.getGlobalGeminiDir(), OAUTH_FILE); } getProjectRoot(): string { return this.targetDir; } private getFilePathHash(filePath: string): string { return crypto.createHash('sha256').update(filePath).digest('hex'); } private getProjectIdentifier(): string { if (!this.projectIdentifier) { throw new Error('Storage must be initialized before use'); } return this.projectIdentifier; } /** * Initializes storage by setting up the project registry and performing migrations. */ async initialize(): Promise { if (this.initPromise) { return this.initPromise; } this.initPromise = (async () => { if (this.projectIdentifier) { return; } const registryPath = path.join( Storage.getGlobalGeminiDir(), 'projects.json', ); const registry = new ProjectRegistry(registryPath, [ Storage.getGlobalTempDir(), path.join(Storage.getGlobalGeminiDir(), 'history'), ]); await registry.initialize(); this.projectIdentifier = await registry.getShortId(this.getProjectRoot()); await this.performMigration(); })(); return this.initPromise; } /** * Performs migration of legacy hash-based directories to the new slug-based format. * This is called internally by initialize(). */ private async performMigration(): Promise { const shortId = this.getProjectIdentifier(); const oldHash = this.getFilePathHash(this.getProjectRoot()); // Migrate Temp Dir const newTempDir = path.join(Storage.getGlobalTempDir(), shortId); const oldTempDir = path.join(Storage.getGlobalTempDir(), oldHash); await StorageMigration.migrateDirectory(oldTempDir, newTempDir); // Migrate History Dir const historyDir = path.join(Storage.getGlobalGeminiDir(), 'history'); const newHistoryDir = path.join(historyDir, shortId); const oldHistoryDir = path.join(historyDir, oldHash); await StorageMigration.migrateDirectory(oldHistoryDir, newHistoryDir); } getHistoryDir(): string { const identifier = this.getProjectIdentifier(); const historyDir = path.join(Storage.getGlobalGeminiDir(), 'history'); return path.join(historyDir, identifier); } getProjectMemoryDir(): string { const identifier = this.getProjectIdentifier(); return path.join(Storage.getGlobalGeminiDir(), 'memory', identifier); } getProjectMemoryTempDir(): string { return path.join(this.getProjectTempDir(), 'memory'); } getProjectSkillsMemoryDir(): string { return path.join(this.getProjectMemoryTempDir(), 'skills'); } getWorkspaceSettingsPath(): string { return path.join(this.getGeminiDir(), 'settings.json'); } getProjectCommandsDir(): string { return path.join(this.getGeminiDir(), 'commands'); } getProjectSkillsDir(): string { return path.join(this.getGeminiDir(), 'skills'); } getProjectAgentSkillsDir(): string { return path.join(this.getAgentsDir(), 'skills'); } getProjectAgentsDir(): string { return path.join(this.getGeminiDir(), 'agents'); } getProjectTempCheckpointsDir(): string { return path.join(this.getProjectTempDir(), 'checkpoints'); } getProjectTempLogsDir(): string { return path.join(this.getProjectTempDir(), 'logs'); } getProjectTempPlansDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'plans'); } return path.join(this.getProjectTempDir(), 'plans'); } getProjectTempTrackerDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tracker'); } return path.join(this.getProjectTempDir(), 'tracker'); } getPlansDir(): string { if (this.customPlansDir) { const resolvedPath = path.resolve( this.getProjectRoot(), this.customPlansDir, ); const realProjectRoot = resolveToRealPath(this.getProjectRoot()); const realResolvedPath = resolveToRealPath(resolvedPath); if (!isSubpath(realProjectRoot, realResolvedPath)) { throw new Error( `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, ); } return resolvedPath; } return this.getProjectTempPlansDir(); } getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); } return path.join(this.getProjectTempDir(), 'tasks'); } async listProjectChatFiles(): Promise< Array<{ filePath: string; lastUpdated: string }> > { const chatsDir = path.join(this.getProjectTempDir(), 'chats'); try { const files = await fs.promises.readdir(chatsDir); const jsonFiles = files.filter((f) => f.endsWith('.json')); const sessions = await Promise.all( jsonFiles.map(async (file) => { const absolutePath = path.join(chatsDir, file); const stats = await fs.promises.stat(absolutePath); return { filePath: path.join('chats', file), lastUpdated: stats.mtime.toISOString(), mtimeMs: stats.mtimeMs, }; }), ); return sessions .sort((a, b) => b.mtimeMs - a.mtimeMs) .map(({ filePath, lastUpdated }) => ({ filePath, lastUpdated })); } catch (e) { // If directory doesn't exist, return empty if ( e instanceof Error && 'code' in e && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (e as NodeJS.ErrnoException).code === 'ENOENT' ) { return []; } throw e; } } async loadProjectTempFile(filePath: string): Promise { const absolutePath = path.join(this.getProjectTempDir(), filePath); try { const content = await fs.promises.readFile(absolutePath, 'utf8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as T; } catch (e) { // If file doesn't exist, return null if ( e instanceof Error && 'code' in e && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (e as NodeJS.ErrnoException).code === 'ENOENT' ) { return null; } throw e; } } getExtensionsDir(): string { return path.join(this.getGeminiDir(), 'extensions'); } getExtensionsConfigPath(): string { return path.join(this.getExtensionsDir(), 'gemini-extension.json'); } getHistoryFilePath(): string { return path.join(this.getProjectTempDir(), 'shell_history'); } }