mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -07:00
389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
/**
|
|
* @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<void> | 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 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 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');
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
|
|
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 {
|
|
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<T>(filePath: string): Promise<T | null> {
|
|
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');
|
|
}
|
|
}
|