From 21c6480b65528a98ac0e1e3855f3c78c1f9b7cbe Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Wed, 20 Aug 2025 10:55:47 +0900 Subject: Refac: Centralize storage file management (#4078) Co-authored-by: Taylor Mullen --- packages/core/src/utils/editCorrector.test.ts | 1 + .../core/src/utils/installationManager.test.ts | 102 +++++++ packages/core/src/utils/installationManager.ts | 58 ++++ packages/core/src/utils/paths.ts | 29 -- packages/core/src/utils/userAccountManager.test.ts | 339 +++++++++++++++++++++ packages/core/src/utils/userAccountManager.ts | 140 +++++++++ packages/core/src/utils/user_account.test.ts | 339 --------------------- packages/core/src/utils/user_account.ts | 131 -------- packages/core/src/utils/user_id.test.ts | 24 -- packages/core/src/utils/user_id.ts | 58 ---- 10 files changed, 640 insertions(+), 581 deletions(-) create mode 100644 packages/core/src/utils/installationManager.test.ts create mode 100644 packages/core/src/utils/installationManager.ts create mode 100644 packages/core/src/utils/userAccountManager.test.ts create mode 100644 packages/core/src/utils/userAccountManager.ts delete mode 100644 packages/core/src/utils/user_account.test.ts delete mode 100644 packages/core/src/utils/user_account.ts delete mode 100644 packages/core/src/utils/user_id.test.ts delete mode 100644 packages/core/src/utils/user_id.ts (limited to 'packages/core/src/utils') diff --git a/packages/core/src/utils/editCorrector.test.ts b/packages/core/src/utils/editCorrector.test.ts index cd588312..a7ef9522 100644 --- a/packages/core/src/utils/editCorrector.test.ts +++ b/packages/core/src/utils/editCorrector.test.ts @@ -27,6 +27,7 @@ let mockSendMessageStream: any; vi.mock('fs', () => ({ statSync: vi.fn(), + mkdirSync: vi.fn(), })); vi.mock('../core/client.js', () => ({ diff --git a/packages/core/src/utils/installationManager.test.ts b/packages/core/src/utils/installationManager.test.ts new file mode 100644 index 00000000..d6a35f68 --- /dev/null +++ b/packages/core/src/utils/installationManager.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; +import { InstallationManager } from './installationManager.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'crypto'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn(actual.readFileSync), + existsSync: vi.fn(actual.existsSync), + } as typeof actual; +}); + +vi.mock('os', async (importOriginal) => { + const os = await importOriginal(); + return { + ...os, + homedir: vi.fn(), + }; +}); + +vi.mock('crypto', async (importOriginal) => { + const crypto = await importOriginal(); + return { + ...crypto, + randomUUID: vi.fn(), + }; +}); + +describe('InstallationManager', () => { + let tempHomeDir: string; + let installationManager: InstallationManager; + const installationIdFile = () => + path.join(tempHomeDir, '.gemini', 'installation_id'); + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + (os.homedir as Mock).mockReturnValue(tempHomeDir); + installationManager = new InstallationManager(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + describe('getInstallationId', () => { + it('should create and write a new installation ID if one does not exist', () => { + const newId = 'new-uuid-123'; + (randomUUID as Mock).mockReturnValue(newId); + + const installationId = installationManager.getInstallationId(); + + expect(installationId).toBe(newId); + expect(fs.existsSync(installationIdFile())).toBe(true); + expect(fs.readFileSync(installationIdFile(), 'utf-8')).toBe(newId); + }); + + it('should read an existing installation ID from a file', () => { + const existingId = 'existing-uuid-123'; + fs.mkdirSync(path.dirname(installationIdFile()), { recursive: true }); + fs.writeFileSync(installationIdFile(), existingId); + + const installationId = installationManager.getInstallationId(); + + expect(installationId).toBe(existingId); + }); + + it('should return the same ID on subsequent calls', () => { + const firstId = installationManager.getInstallationId(); + const secondId = installationManager.getInstallationId(); + expect(secondId).toBe(firstId); + }); + + it('should handle read errors and return a fallback ID', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(true); + const readSpy = vi.mocked(fs.readFileSync); + readSpy.mockImplementationOnce(() => { + throw new Error('Read error'); + }); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const id = installationManager.getInstallationId(); + + expect(id).toBe('123456789'); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/utils/installationManager.ts b/packages/core/src/utils/installationManager.ts new file mode 100644 index 00000000..9146ddd0 --- /dev/null +++ b/packages/core/src/utils/installationManager.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import { randomUUID } from 'crypto'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; + +export class InstallationManager { + private getInstallationIdPath(): string { + return Storage.getInstallationIdPath(); + } + + private readInstallationIdFromFile(): string | null { + const installationIdFile = this.getInstallationIdPath(); + if (fs.existsSync(installationIdFile)) { + const installationid = fs + .readFileSync(installationIdFile, 'utf-8') + .trim(); + return installationid || null; + } + return null; + } + + private writeInstallationIdToFile(installationId: string) { + const installationIdFile = this.getInstallationIdPath(); + const dir = path.dirname(installationIdFile); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(installationIdFile, installationId, 'utf-8'); + } + + /** + * Retrieves the installation ID from a file, creating it if it doesn't exist. + * This ID is used for unique user installation tracking. + * @returns A UUID string for the user. + */ + getInstallationId(): string { + try { + let installationId = this.readInstallationIdFromFile(); + + if (!installationId) { + installationId = randomUUID(); + this.writeInstallationIdToFile(installationId); + } + + return installationId; + } catch (error) { + console.error( + 'Error accessing installation ID file, generating ephemeral ID:', + error, + ); + return '123456789'; + } + } +} diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index e7cf54cc..fe690b39 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -10,8 +10,6 @@ import * as crypto from 'crypto'; export const GEMINI_DIR = '.gemini'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; -const TMP_DIR_NAME = 'tmp'; -const COMMANDS_DIR_NAME = 'commands'; /** * Special characters that need to be escaped in file paths for shell compatibility. @@ -174,33 +172,6 @@ export function getProjectHash(projectRoot: string): string { return crypto.createHash('sha256').update(projectRoot).digest('hex'); } -/** - * Generates a unique temporary directory path for a project. - * @param projectRoot The absolute path to the project's root directory. - * @returns The path to the project's temporary directory. - */ -export function getProjectTempDir(projectRoot: string): string { - const hash = getProjectHash(projectRoot); - return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash); -} - -/** - * Returns the absolute path to the user-level commands directory. - * @returns The path to the user's commands directory. - */ -export function getUserCommandsDir(): string { - return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME); -} - -/** - * Returns the absolute path to the project-level commands directory. - * @param projectRoot The absolute path to the project's root directory. - * @returns The path to the project's commands directory. - */ -export function getProjectCommandsDir(projectRoot: string): string { - return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME); -} - /** * Checks if a path is a subpath of another path. * @param parentPath The parent path. diff --git a/packages/core/src/utils/userAccountManager.test.ts b/packages/core/src/utils/userAccountManager.test.ts new file mode 100644 index 00000000..1e7e05aa --- /dev/null +++ b/packages/core/src/utils/userAccountManager.test.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; +import { UserAccountManager } from './userAccountManager.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import path from 'node:path'; + +vi.mock('os', async (importOriginal) => { + const os = await importOriginal(); + return { + ...os, + homedir: vi.fn(), + }; +}); + +describe('UserAccountManager', () => { + let tempHomeDir: string; + let userAccountManager: UserAccountManager; + let accountsFile: () => string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + (os.homedir as Mock).mockReturnValue(tempHomeDir); + accountsFile = () => + path.join(tempHomeDir, '.gemini', 'google_accounts.json'); + userAccountManager = new UserAccountManager(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + describe('cacheGoogleAccount', () => { + it('should create directory and write initial account file', async () => { + await userAccountManager.cacheGoogleAccount('test1@google.com'); + + // Verify Google Account ID was cached + expect(fs.existsSync(accountsFile())).toBe(true); + expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( + JSON.stringify({ active: 'test1@google.com', old: [] }, null, 2), + ); + }); + + it('should update active account and move previous to old', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify( + { active: 'test2@google.com', old: ['test1@google.com'] }, + null, + 2, + ), + ); + + await userAccountManager.cacheGoogleAccount('test3@google.com'); + + expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( + JSON.stringify( + { + active: 'test3@google.com', + old: ['test1@google.com', 'test2@google.com'], + }, + null, + 2, + ), + ); + }); + + it('should not add a duplicate to the old list', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify( + { active: 'test1@google.com', old: ['test2@google.com'] }, + null, + 2, + ), + ); + await userAccountManager.cacheGoogleAccount('test2@google.com'); + await userAccountManager.cacheGoogleAccount('test1@google.com'); + + expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( + JSON.stringify( + { active: 'test1@google.com', old: ['test2@google.com'] }, + null, + 2, + ), + ); + }); + + it('should handle corrupted JSON by starting fresh', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), 'not valid json'); + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + await userAccountManager.cacheGoogleAccount('test1@google.com'); + + expect(consoleLogSpy).toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ + active: 'test1@google.com', + old: [], + }); + }); + + it('should handle valid JSON with incorrect schema by starting fresh', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ active: 'test1@google.com', old: 'not-an-array' }), + ); + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + await userAccountManager.cacheGoogleAccount('test2@google.com'); + + expect(consoleLogSpy).toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ + active: 'test2@google.com', + old: [], + }); + }); + }); + + describe('getCachedGoogleAccount', () => { + it('should return the active account if file exists and is valid', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ active: 'active@google.com', old: [] }, null, 2), + ); + const account = userAccountManager.getCachedGoogleAccount(); + expect(account).toBe('active@google.com'); + }); + + it('should return null if file does not exist', () => { + const account = userAccountManager.getCachedGoogleAccount(); + expect(account).toBeNull(); + }); + + it('should return null if file is empty', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), ''); + const account = userAccountManager.getCachedGoogleAccount(); + expect(account).toBeNull(); + }); + + it('should return null and log if file is corrupted', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), '{ "active": "test@google.com"'); // Invalid JSON + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + const account = userAccountManager.getCachedGoogleAccount(); + + expect(account).toBeNull(); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('should return null if active key is missing', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), JSON.stringify({ old: [] })); + const account = userAccountManager.getCachedGoogleAccount(); + expect(account).toBeNull(); + }); + }); + + describe('clearCachedGoogleAccount', () => { + it('should set active to null and move it to old', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify( + { active: 'active@google.com', old: ['old1@google.com'] }, + null, + 2, + ), + ); + + await userAccountManager.clearCachedGoogleAccount(); + + const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); + expect(stored.active).toBeNull(); + expect(stored.old).toEqual(['old1@google.com', 'active@google.com']); + }); + + it('should handle empty file gracefully', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), ''); + await userAccountManager.clearCachedGoogleAccount(); + const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); + expect(stored.active).toBeNull(); + expect(stored.old).toEqual([]); + }); + + it('should handle corrupted JSON by creating a fresh file', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), 'not valid json'); + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + await userAccountManager.clearCachedGoogleAccount(); + + expect(consoleLogSpy).toHaveBeenCalled(); + const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); + expect(stored.active).toBeNull(); + expect(stored.old).toEqual([]); + }); + + it('should be idempotent if active account is already null', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ active: null, old: ['old1@google.com'] }, null, 2), + ); + + await userAccountManager.clearCachedGoogleAccount(); + + const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); + expect(stored.active).toBeNull(); + expect(stored.old).toEqual(['old1@google.com']); + }); + + it('should not add a duplicate to the old list', async () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify( + { + active: 'active@google.com', + old: ['active@google.com'], + }, + null, + 2, + ), + ); + + await userAccountManager.clearCachedGoogleAccount(); + + const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); + expect(stored.active).toBeNull(); + expect(stored.old).toEqual(['active@google.com']); + }); + }); + + describe('getLifetimeGoogleAccounts', () => { + it('should return 0 if the file does not exist', () => { + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); + }); + + it('should return 0 if the file is empty', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), ''); + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); + }); + + it('should return 0 if the file is corrupted', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync(accountsFile(), 'invalid json'); + const consoleDebugSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); + expect(consoleDebugSpy).toHaveBeenCalled(); + }); + + it('should return 1 if there is only an active account', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ active: 'test1@google.com', old: [] }), + ); + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(1); + }); + + it('should correctly count old accounts when active is null', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ + active: null, + old: ['test1@google.com', 'test2@google.com'], + }), + ); + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(2); + }); + + it('should correctly count both active and old accounts', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ + active: 'test3@google.com', + old: ['test1@google.com', 'test2@google.com'], + }), + ); + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(3); + }); + + it('should handle valid JSON with incorrect schema by returning 0', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ active: null, old: 1 }), + ); + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('should not double count if active account is also in old list', () => { + fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); + fs.writeFileSync( + accountsFile(), + JSON.stringify({ + active: 'test1@google.com', + old: ['test1@google.com', 'test2@google.com'], + }), + ); + expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(2); + }); + }); +}); diff --git a/packages/core/src/utils/userAccountManager.ts b/packages/core/src/utils/userAccountManager.ts new file mode 100644 index 00000000..28d3cef9 --- /dev/null +++ b/packages/core/src/utils/userAccountManager.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { promises as fsp, readFileSync } from 'node:fs'; +import { Storage } from '../config/storage.js'; + +interface UserAccounts { + active: string | null; + old: string[]; +} + +export class UserAccountManager { + private getGoogleAccountsCachePath(): string { + return Storage.getGoogleAccountsPath(); + } + + /** + * Parses and validates the string content of an accounts file. + * @param content The raw string content from the file. + * @returns A valid UserAccounts object. + */ + private parseAndValidateAccounts(content: string): UserAccounts { + const defaultState = { active: null, old: [] }; + if (!content.trim()) { + return defaultState; + } + + const parsed = JSON.parse(content); + + // Inlined validation logic + if (typeof parsed !== 'object' || parsed === null) { + console.log('Invalid accounts file schema, starting fresh.'); + return defaultState; + } + const { active, old } = parsed as Partial; + const isValid = + (active === undefined || active === null || typeof active === 'string') && + (old === undefined || + (Array.isArray(old) && old.every((i) => typeof i === 'string'))); + + if (!isValid) { + console.log('Invalid accounts file schema, starting fresh.'); + return defaultState; + } + + return { + active: parsed.active ?? null, + old: parsed.old ?? [], + }; + } + + private readAccountsSync(filePath: string): UserAccounts { + const defaultState = { active: null, old: [] }; + try { + const content = readFileSync(filePath, 'utf-8'); + return this.parseAndValidateAccounts(content); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return defaultState; + } + console.log('Error during sync read of accounts, starting fresh.', error); + return defaultState; + } + } + + private async readAccounts(filePath: string): Promise { + const defaultState = { active: null, old: [] }; + try { + const content = await fsp.readFile(filePath, 'utf-8'); + return this.parseAndValidateAccounts(content); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return defaultState; + } + console.log('Could not parse accounts file, starting fresh.', error); + return defaultState; + } + } + + async cacheGoogleAccount(email: string): Promise { + const filePath = this.getGoogleAccountsCachePath(); + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + + const accounts = await this.readAccounts(filePath); + + if (accounts.active && accounts.active !== email) { + if (!accounts.old.includes(accounts.active)) { + accounts.old.push(accounts.active); + } + } + + // If the new email was in the old list, remove it + accounts.old = accounts.old.filter((oldEmail) => oldEmail !== email); + + accounts.active = email; + await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); + } + + getCachedGoogleAccount(): string | null { + const filePath = this.getGoogleAccountsCachePath(); + const accounts = this.readAccountsSync(filePath); + return accounts.active; + } + + getLifetimeGoogleAccounts(): number { + const filePath = this.getGoogleAccountsCachePath(); + const accounts = this.readAccountsSync(filePath); + const allAccounts = new Set(accounts.old); + if (accounts.active) { + allAccounts.add(accounts.active); + } + return allAccounts.size; + } + + async clearCachedGoogleAccount(): Promise { + const filePath = this.getGoogleAccountsCachePath(); + const accounts = await this.readAccounts(filePath); + + if (accounts.active) { + if (!accounts.old.includes(accounts.active)) { + accounts.old.push(accounts.active); + } + accounts.active = null; + } + + await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); + } +} diff --git a/packages/core/src/utils/user_account.test.ts b/packages/core/src/utils/user_account.test.ts deleted file mode 100644 index 35231ca3..00000000 --- a/packages/core/src/utils/user_account.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; -import { - cacheGoogleAccount, - getCachedGoogleAccount, - clearCachedGoogleAccount, - getLifetimeGoogleAccounts, -} from './user_account.js'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import path from 'node:path'; - -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); - return { - ...os, - homedir: vi.fn(), - }; -}); - -describe('user_account', () => { - let tempHomeDir: string; - const accountsFile = () => - path.join(tempHomeDir, '.gemini', 'google_accounts.json'); - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); - }); - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.clearAllMocks(); - }); - - describe('cacheGoogleAccount', () => { - it('should create directory and write initial account file', async () => { - await cacheGoogleAccount('test1@google.com'); - - // Verify Google Account ID was cached - expect(fs.existsSync(accountsFile())).toBe(true); - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify({ active: 'test1@google.com', old: [] }, null, 2), - ); - }); - - it('should update active account and move previous to old', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'test2@google.com', old: ['test1@google.com'] }, - null, - 2, - ), - ); - - await cacheGoogleAccount('test3@google.com'); - - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify( - { - active: 'test3@google.com', - old: ['test1@google.com', 'test2@google.com'], - }, - null, - 2, - ), - ); - }); - - it('should not add a duplicate to the old list', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'test1@google.com', old: ['test2@google.com'] }, - null, - 2, - ), - ); - await cacheGoogleAccount('test2@google.com'); - await cacheGoogleAccount('test1@google.com'); - - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify( - { active: 'test1@google.com', old: ['test2@google.com'] }, - null, - 2, - ), - ); - }); - - it('should handle corrupted JSON by starting fresh', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'not valid json'); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await cacheGoogleAccount('test1@google.com'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ - active: 'test1@google.com', - old: [], - }); - }); - - it('should handle valid JSON with incorrect schema by starting fresh', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'test1@google.com', old: 'not-an-array' }), - ); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await cacheGoogleAccount('test2@google.com'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ - active: 'test2@google.com', - old: [], - }); - }); - }); - - describe('getCachedGoogleAccount', () => { - it('should return the active account if file exists and is valid', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'active@google.com', old: [] }, null, 2), - ); - const account = getCachedGoogleAccount(); - expect(account).toBe('active@google.com'); - }); - - it('should return null if file does not exist', () => { - const account = getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - - it('should return null if file is empty', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - const account = getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - - it('should return null and log if file is corrupted', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), '{ "active": "test@google.com"'); // Invalid JSON - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - const account = getCachedGoogleAccount(); - - expect(account).toBeNull(); - expect(consoleLogSpy).toHaveBeenCalled(); - }); - - it('should return null if active key is missing', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), JSON.stringify({ old: [] })); - const account = getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - }); - - describe('clearCachedGoogleAccount', () => { - it('should set active to null and move it to old', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'active@google.com', old: ['old1@google.com'] }, - null, - 2, - ), - ); - - await clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['old1@google.com', 'active@google.com']); - }); - - it('should handle empty file gracefully', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - await clearCachedGoogleAccount(); - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual([]); - }); - - it('should handle corrupted JSON by creating a fresh file', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'not valid json'); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await clearCachedGoogleAccount(); - - expect(consoleLogSpy).toHaveBeenCalled(); - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual([]); - }); - - it('should be idempotent if active account is already null', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: null, old: ['old1@google.com'] }, null, 2), - ); - - await clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['old1@google.com']); - }); - - it('should not add a duplicate to the old list', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { - active: 'active@google.com', - old: ['active@google.com'], - }, - null, - 2, - ), - ); - - await clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['active@google.com']); - }); - }); - - describe('getLifetimeGoogleAccounts', () => { - it('should return 0 if the file does not exist', () => { - expect(getLifetimeGoogleAccounts()).toBe(0); - }); - - it('should return 0 if the file is empty', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - expect(getLifetimeGoogleAccounts()).toBe(0); - }); - - it('should return 0 if the file is corrupted', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'invalid json'); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - expect(getLifetimeGoogleAccounts()).toBe(0); - expect(consoleLogSpy).toHaveBeenCalled(); - }); - - it('should return 1 if there is only an active account', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'test1@google.com', old: [] }), - ); - expect(getLifetimeGoogleAccounts()).toBe(1); - }); - - it('should correctly count old accounts when active is null', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: null, - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(getLifetimeGoogleAccounts()).toBe(2); - }); - - it('should correctly count both active and old accounts', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: 'test3@google.com', - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(getLifetimeGoogleAccounts()).toBe(3); - }); - - it('should handle valid JSON with incorrect schema by returning 0', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: null, old: 1 }), - ); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - expect(getLifetimeGoogleAccounts()).toBe(0); - expect(consoleLogSpy).toHaveBeenCalled(); - }); - - it('should not double count if active account is also in old list', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: 'test1@google.com', - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(getLifetimeGoogleAccounts()).toBe(2); - }); - }); -}); diff --git a/packages/core/src/utils/user_account.ts b/packages/core/src/utils/user_account.ts deleted file mode 100644 index 18b7dcf4..00000000 --- a/packages/core/src/utils/user_account.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import path from 'node:path'; -import { promises as fsp, readFileSync } from 'node:fs'; -import * as os from 'os'; -import { GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js'; - -interface UserAccounts { - active: string | null; - old: string[]; -} - -function getGoogleAccountsCachePath(): string { - return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME); -} - -/** - * Parses and validates the string content of an accounts file. - * @param content The raw string content from the file. - * @returns A valid UserAccounts object. - */ -function parseAndValidateAccounts(content: string): UserAccounts { - const defaultState = { active: null, old: [] }; - if (!content.trim()) { - return defaultState; - } - - const parsed = JSON.parse(content); - - // Inlined validation logic - if (typeof parsed !== 'object' || parsed === null) { - console.log('Invalid accounts file schema, starting fresh.'); - return defaultState; - } - const { active, old } = parsed as Partial; - const isValid = - (active === undefined || active === null || typeof active === 'string') && - (old === undefined || - (Array.isArray(old) && old.every((i) => typeof i === 'string'))); - - if (!isValid) { - console.log('Invalid accounts file schema, starting fresh.'); - return defaultState; - } - - return { - active: parsed.active ?? null, - old: parsed.old ?? [], - }; -} - -function readAccountsSync(filePath: string): UserAccounts { - const defaultState = { active: null, old: [] }; - try { - const content = readFileSync(filePath, 'utf-8'); - return parseAndValidateAccounts(content); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return defaultState; - } - console.log('Error during sync read of accounts, starting fresh.', error); - return defaultState; - } -} - -async function readAccounts(filePath: string): Promise { - const defaultState = { active: null, old: [] }; - try { - const content = await fsp.readFile(filePath, 'utf-8'); - return parseAndValidateAccounts(content); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return defaultState; - } - console.log('Could not parse accounts file, starting fresh.', error); - return defaultState; - } -} - -export async function cacheGoogleAccount(email: string): Promise { - const filePath = getGoogleAccountsCachePath(); - await fsp.mkdir(path.dirname(filePath), { recursive: true }); - - const accounts = await readAccounts(filePath); - - if (accounts.active && accounts.active !== email) { - if (!accounts.old.includes(accounts.active)) { - accounts.old.push(accounts.active); - } - } - - // If the new email was in the old list, remove it - accounts.old = accounts.old.filter((oldEmail) => oldEmail !== email); - - accounts.active = email; - await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); -} - -export function getCachedGoogleAccount(): string | null { - const filePath = getGoogleAccountsCachePath(); - const accounts = readAccountsSync(filePath); - return accounts.active; -} - -export function getLifetimeGoogleAccounts(): number { - const filePath = getGoogleAccountsCachePath(); - const accounts = readAccountsSync(filePath); - const allAccounts = new Set(accounts.old); - if (accounts.active) { - allAccounts.add(accounts.active); - } - return allAccounts.size; -} - -export async function clearCachedGoogleAccount(): Promise { - const filePath = getGoogleAccountsCachePath(); - const accounts = await readAccounts(filePath); - - if (accounts.active) { - if (!accounts.old.includes(accounts.active)) { - accounts.old.push(accounts.active); - } - accounts.active = null; - } - - await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); -} diff --git a/packages/core/src/utils/user_id.test.ts b/packages/core/src/utils/user_id.test.ts deleted file mode 100644 index 5c11d773..00000000 --- a/packages/core/src/utils/user_id.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { getInstallationId } from './user_id.js'; - -describe('user_id', () => { - describe('getInstallationId', () => { - it('should return a valid UUID format string', () => { - const installationId = getInstallationId(); - - expect(installationId).toBeDefined(); - expect(typeof installationId).toBe('string'); - expect(installationId.length).toBeGreaterThan(0); - - // Should return the same ID on subsequent calls (consistent) - const secondCall = getInstallationId(); - expect(secondCall).toBe(installationId); - }); - }); -}); diff --git a/packages/core/src/utils/user_id.ts b/packages/core/src/utils/user_id.ts deleted file mode 100644 index 6f16806f..00000000 --- a/packages/core/src/utils/user_id.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as os from 'os'; -import * as fs from 'fs'; -import * as path from 'path'; -import { randomUUID } from 'crypto'; -import { GEMINI_DIR } from './paths.js'; - -const homeDir = os.homedir() ?? ''; -const geminiDir = path.join(homeDir, GEMINI_DIR); -const installationIdFile = path.join(geminiDir, 'installation_id'); - -function ensureGeminiDirExists() { - if (!fs.existsSync(geminiDir)) { - fs.mkdirSync(geminiDir, { recursive: true }); - } -} - -function readInstallationIdFromFile(): string | null { - if (fs.existsSync(installationIdFile)) { - const installationid = fs.readFileSync(installationIdFile, 'utf-8').trim(); - return installationid || null; - } - return null; -} - -function writeInstallationIdToFile(installationId: string) { - fs.writeFileSync(installationIdFile, installationId, 'utf-8'); -} - -/** - * Retrieves the installation ID from a file, creating it if it doesn't exist. - * This ID is used for unique user installation tracking. - * @returns A UUID string for the user. - */ -export function getInstallationId(): string { - try { - ensureGeminiDirExists(); - let installationId = readInstallationIdFromFile(); - - if (!installationId) { - installationId = randomUUID(); - writeInstallationIdToFile(installationId); - } - - return installationId; - } catch (error) { - console.error( - 'Error accessing installation ID file, generating ephemeral ID:', - error, - ); - return '123456789'; - } -} -- cgit v1.2.3